浅析JavaScript - 作用域和执行上下文

一点点关于作用域、bind、call/apply、this的介绍

博客

再来一道比较有难度的JavaScript题目,可以先思考一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Foo() {
getName = function () {
console.log('1');
};
return this;
}
Foo.getName = function () {
console.log('2');
};
Foo.prototype.getName = function () {
console.log('3');
};
var getName = function () {
console.log('4');
};
function getName() {
console.log(5);
}
Foo.getName();
getName();
Foo().getName();
getName();
new Foo.getName();
new Foo().getName();
new new Foo().getName();

答案:2 4 1 1 2 3 3

作用域是什么

如果一个变量在函数体内部申明,则变量的作用域为整个函数体,在函数体外不可以引用

先针对ES5(无const / let),以var申明的变量的作用域就是当前执行上下文

函数体内作用域

1
2
3
4
5
6
function foo() {
var x = 1
x = x + 1
}
x = x + 2 // ReferenceError: x is not defined

不同函数内部的同名变量互相不冲突

函数嵌套,内部函数可访问外部函数定义变量

JS中函数可以嵌套,此时,内部函数可以访问外部函数定义的变量。

其实就是一个 作用域链 的概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var color = 'blue';
function text1(){
var anotherColor = 'red';
function text2(){
var tempCplor = anotherColor;
anotherColor = color;
//这里可以访问到color、anotherColor和tempColor
}
//这里可以访问color和anotherColor,但不能访问到tempColor
text2();
}
//这里只能访问到color

变量屏蔽

JS函数查找变量时从自身函数定义开始,如果内部函数定义了与外部函数重名的变量,则内部函数的变量将“屏蔽”外部函数的变量

变量生存周期

1.全局变量的生存周期是永久的,除非我们主动销毁。

ps:变量永久生存,且可以随时调用,但是使用的时候要适度,正是因为它的生命周期长,所以将占据更多的内存,如果声明的变量都是全局变量,当项目比较大的时候,就可能出现性能问题,养成一个好的习惯还是有必要的。

2.而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数的调用的结束而销毁。

ps:调用函数结束,局部变量确实会销毁。但并不是完全销毁,而是一直函数的内部环境中存活着,当函数再度被调用时,变量就“复活”了,所以局部变量还是非常方便的,不会影响二次使用。

变量重名

var重名

  1. 同一个作用域,两个var,只需要var一个就好

函数重名

  1. 同一个大作用域下,函数定义多个时,后面的函数覆盖前面的,是没有函数重载这个概念的。
  2. 函数是一等公民,函数声明和函数表达式是两种定义函数的方式,且函数声明是会被提升的。
1
2
3
4
5
6
7
var a = function() {
console.log('1')
}
function a() {
console.log('2')
}
a()

在浏览器解析后,由于函数声明会被提升,所以其实代码会变成这个样子:

1
2
3
4
5
6
7
8
9
function a() {
console.log('2')
}
var a = function() {
console.log('1')
}
// 因为没有函数重载,所以后面的函数定义会覆盖前面的函数声明。
a() // 输出1

函数和变量同时重名

  1. 以函数为准(实际上不允许重名)

变量提升

注意:变量只是将定义提升,不会报错,但是仍然是undefined。

1
2
3
4
5
6
7
8
function foo() {
var x = 'Hello ' + y
console.log(x)
var y = 'world'
}
foo()
// 输出 Hello undefined

对于上述代码,浏览器看到的代码其实是

1
2
3
4
5
6
function foo() {
var y
var x = 'Hello ' + y
console.log(x)
var y = 'world'
}

请遵守 “在函数内部首先申明所有变量” 这一规定

函数申明和变量申明总是会被移动到它们所在的作用域的顶部。而变量的解析顺序(优先级),与变量进入作用域的4种方式的顺序一致。

补:变量解析顺序
先执行定义式函数function f() {} 再按照代码顺序执行变量式函数(匿名函数)let f = function() {},函数的优先级高于变量。

看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function testOrder(arg) {
console.log(arg); // arg是形参,不会被重新定义
console.log(a); // 因为函数声明比变量声明优先级高,所以这里a是函数
var arg = 'hello'; // var arg;变量声明被忽略, arg = 'hello'被执行
var a = 10; // var a;被忽视; a = 10被执行,a变成number
function a() {
console.log('fun');
} // 被提升到作用域顶部
console.log(a); // 输出10
console.log(arg); // 输出hello
};
testOrder('hi');
/* 输出:
hi
function a() {
console.log('fun');
}
10
hello
*/

全局作用域

不在任何函数内定义的变量就具有全局作用域,实际上是被绑定到 window 的一个属性。

1
2
3
4
5
6
7
8
9
var a = 'ddd'
console.log(a) //ddd
console.log(window.a) //ddd
function foo() {
console.log('foo')
}
foo() // foo
window.foo() // foo

任何变量(函数也视为变量)如果没有在当前函数作用域中找到,就会继续往上查找,如果最后在全局作用域中也没有找到,就会报错。

命名冲突

全局变量会绑定到 window 上,不同的JS文件中如果使用相同的全局变量,或者定义了相同名字的顶层函数,都会造成命名冲突。 减少冲突的方法是 把自己的所有变量和函数全部绑定到一个全局变量中

1
2
3
4
5
6
7
8
9
10
// 唯一的全局变量
var MYAPP = {}
//其他变量
MYAPP.name = 'ddd'
MYAPP.version = 1.0
MYAPP.foo = function() {
console.log('foo')
}

局部作用域

JS中的变量作用域实际上是函数内部,(ES5中)在for循环中是无法定义具有局部作用域的变量的。

1
2
3
4
5
6
function foo() {
for (var i = 0; i < 10; i++) {
}
i += 1 // 11
}

块级作用域

在ES6中有了 let ,可以代替var申明一个块级作用域的变量。

1
2
3
4
5
6
function foo() {
for (let i = 0; i < 10; i++) {
}
i += 1 // ReferenceError: i is not defined
}

const也具有块级作用域,同时可以用于申明一个常量。(注:对const申明的常量重新赋值时,某些浏览器可能不报错,但是无效果)

妙用apply, bind, call

既然提到了作用域,就继续提下去,几个有关于 call,apply,bind(它们其实就是为了改变函数体内部this的指向)

关于这三者的介绍直接指路这篇博客

apply & call

这两个方法非常类似,主要区别就在于传参的形式有所区别而已。

apply()
传入两个参数:一个 作为函数上下文的对象 ,另一个作为函数参数所组成的数组

call()
传入两个参数:一个 作为函数上下文的对象 ,后面传入一个参数列表,而不是单个参数(表示改变后调用这个函数的对象)

何时使用:当参数本身就存放在数组中的时候,使用apply(),当参数比较杂乱,使用call()

请说三遍,第一个参数是指作为函数上下文的对象,第一个参数是指作为函数上下文的对象,第一个参数是指作为函数上下文的对象。

然后举几个用法:

  • 改变this指向
1
2
3
4
5
6
7
8
9
let person = {
name: 'petnakanojo'
}
function sayName() {
console.log(this.name)
}
sayName.call(person) // 第一个参数是指作为函数上下文的对象
// 那么输出就应该是 petnakanojo

所以,可以看出call和apply是为了动态改变this而出现的,当一个object没有某个方法,但是其他的有,我们可以借助call或apply用其它对象的方法来操作。

  • 借用别的对象的方法(其实算是继承)

再看一个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let personA = function() {
this.name = 'Alice'
console.log('This is a test')
}
let personB = function() {
this.callName = function() {
console.log(this.name)
}
personA.call(this) // 在personB的执行环境里执行personA的属性和方法
// 这里其实算是执行了一次继承
// 意思是把personA的方法应用到personB这个对象上。
}
let newPerson = new personB() //This is a test
// 上一行中,在创建一个新的personB的实例的时候,`personA.call(this)`这句话也立刻执行
newPerson.callName() // Alice

仍然记着:第一个参数是指 作为函数上下文的对象

那么对于personA.call(this)这句,再根据当创建一个新的personB实例的时候,浏览器console中会输出“this is a test”就比较容易理解了。

其实这句就是将personA的this指向了personB的this。

此处需要理解的是有关于JS中的函数调用的概念。
http://www.ruanyifeng.com/blog/2010/04/using_this_keyword_in_javascript.html

建议阅读👍👍👍How this Works,以下四种调用方式在这篇博客中都有提到。

this在四种调用模式下,分别会绑定不同的值。

  1. 方法调用

    1
    2
    3
    4
    5
    6
    7
    var a = {
    v : 0,
    f : function(xx) {
    this.v = xx;
    }
    }
    a.f(5);
  2. 正常函数调用

    1
    2
    3
    4
    5
    function f(xx) {
    this.x = xx
    }
    f(5)
    // 注意函数f里的this绑定的是全局对象。this.x访问的其实是window.x

对于上述方法1和方法2的设计原理,请参看阮一峰的博客 JavaScript的this原理,用图的形式讲解了存放方式。

  1. 构造器函数调用
    如果你在一个函数前面加上new关键词来调用,此时会创建一个prototype属性,是这个函数的一个新对象,在调用这个的时候,会把this绑定在这个新对象上。new关键字也会改变return语句的行为。
1
2
3
4
5
6
7
function a(xx) {
this.x = xx
}
let b = new a(4)
// b.x 输出 4
// window.x 输出 undefined
  1. apply/call调用
    1
    2
    3
    4
    5
    6
    7
    function a(xx) {
    this.b = xx;
    }
    var o = {};
    a.apply(o, [5]);
    alert(a.b); // undefined
    alert(o.b); // 5

看到了这么一段非常有趣的形容:

猫吃鱼,狗吃肉,奥特曼打小怪兽

但是当有一天 狗想吃鱼的时候: 猫.吃鱼.call(狗, 鱼),这样狗就吃到鱼了。

猫猫不仅想吃肉,还想吃猪肉牛肉鸡肉,那就可以 狗.吃肉.apply(猫, [猪肉, 牛肉, 鸡肉])

猫有一天想打小怪兽了,那就:奥特曼.打小怪兽.call(猫, 小怪兽),这样猫猫就可以打爆小怪兽了~

bind

与call和apply方法不同的是,call和apply方法在被调用时就直接执行了当前的函数,而bind直接返回了一个改变了函数上下文之后的新函数。

bind()方法创建一个新的函数,当被调用时,将其this关键词设置为提供的值,在调用新函数时,在任何提供之前提供一个给定的参数序列。

这个新函数与原函数并非是同一个函数,而且与call类似,bind方法接受列表形式的参数

以及:bind内部其实是使用apply/call进行的绑定,所以多次调用bind()是无效的。

示例:

  • 解决在另一个函数中仍保持this上下文

  • 创建绑定函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
this.x = 9
let module = {
x: 81,
getX: function () {
return this.x
}
}
let retrieveX = module.getX
retrieveX() // 返回9
let boundGetX = retrieveX.bind(module)
boundGetX() // 返回81
  • 点击处理函数

场景:记录点击的次数

1
2
3
4
5
6
7
let logger = {
x: 0
updateCount: function () {
this.x ++
console.log(this.x)
}
}

我们需要调用logger中的updateCount()方法,但是需要记录x的值。所以使用logger.updateCount.bind(logger)

  • 和setTimeout一起使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let logger = {
x: 0
updateCount: function() {
this.x ++
console.log(this.x)
}
showCount: function() {
console.log(this.x)
}
}
// 这是上面那个例子中的logger
// 但是当我们想要使用setTimeout的时候调用logger.showCount()的时候,需要额外小心。
// setTimeout(func | code, time),第一个参数的运行环境是window。其实是window.setTimeout()
// 如果想实现:再延迟1s后,显示当前的logger中x的值。
// ❌
setTimeout(logger.showCount, 1000)
// 输出 undefined
// 如果直接使用这种方式,会输出 undefined,这是因为当前运行环境在window,具体请看这篇博客
// http://www.ruanyifeng.com/blog/2018/06/javascript-this.html
// ✅ 实现方案1: 使用es6中的箭头函数
setTimeout(() => {logger.showCount()}, 1000)
// ✅ 实现方案2: 使用一个匿名函数来保证作用对象是logger
setTimeout(function() {
logger.showCount()
}, 1000)
// ✅ 实现方案3: 使用bind,将logger.showCount()绑定到正确的上下文执行环境。
setTimeout(logger.showCount.bind(logger), 1000) // 如果是在logger对象中,则bind(this)

Function.prototype.bind()内部的样子:

1
2
3
4
5
6
Function.prototype.bind = function (scope) {
let fn = this
return function() {
return fn.apply(scope)
}
}