JS的作用域链

JS的作用域链

知乎上的问题

最近在知乎上看到个有关作用域的问题,一时找不到,类似如下:

1
2
3
4
5
6
7
8
var x = 1;
function foo(x, y = function() { x = 2; }) {
var x = 3;
y();
console.log(x);
}
foo(); // 3
x // 1

其实这个是阮老师的《ES6 入门》里的一个例子。知乎提问者对于这段代码的疑问点是「执行 y() 之后,为什么还是输出 3 ?」,其实阮老师已经解释得比较清楚了(这是传送门),但是这里也牵扯到了作用域的问题,加上最近自己有看相关的资料,就再写一下下,加深理解。

作用域和作用域链

和 C,Java 等许多语言不同的是,ES5 只有两种作用域 —- 全局作用域和函数作用域。每个执行上下文都有一个表示变量的对象 —- 变量对象。简单来说,一个作用域对应于一个变量对象,作用域就是变量的集合。所以,作用域链就是变量对象的有序集合,用于变量的查询。下面讲讲这一切的由来。

全局上下文的变量对象始终存在,而像函数这样的局部上下文的变量对象,则只在函数执行的过程中存在。

创建函数时,函数内部有个[[scope]]属性会指向由所有父变量对象所组成的层级链。注意,这个属性是在函数创建时就已经被存储在函数内部,并且永不会改变,直至函数被销毁。理解这个就可以知道 JavaScript 是词法作用域的,而不是动态作用域。

函数被调用时,会创建当前函数的执行上下文,然后会创建基于当前上下文的变量对象,另外,thisarguments等也是在此时才确定下来的。然后此变量对象跟上面所说的函数内部属性[[scope]]链接起来而形成了作用域,而这个当前上下文的变量对象是作用域链的前端。这就是作用域链的本质 —- 当前变量对象和所有父变量对象的有序集合,值得注意的是作用域链只引用变量对象,并不直接保存变量对象。

this的指向

上一节讲到了this是在函数被调用时才确定下来的,那关于this指向的理解,我是这么认为的:所谓函数,天生就是让别人调用的,这可以说是很本质的了。很直观嘛,谁调用的就指向谁,JS 就是那么爽快。但是事情总是有另一面,在 Java 的世界,方法好像总是定义在类里面的?所以this总是指向所在类的实例,也很直观。问题来了,JS 天生灵活,其函数是可以被提取出来的,也可以通过call()apply()等方法改变函数执行时的this,但万变不离其宗,JS 的this始终还是指向最终的调用者

闭包

闭包的出现源于 JavaScript 的作用域链。是怎么个闭法,又是什么包呢?

1
2
3
4
5
6
7
var arr = [];
for (var i = 0; i < 5; i++) {
arr.push(function() {
console.log(i);
});
}
arr[0](); // -> 5

这道题出现过 N 次了,都知道结果是5,至于怎么解决,大家也很熟悉了,要不把var改为let,要不就加一层闭包,咦,那为什么这样可以解决呢?

在这里,每次循环都新建一个函数,按前文所说的,在新建函数的时候就会有个[[scope]]属性指向由所有父变量对象所组成的层级链,而这题只有一个父变量对象即全局变量对象,里面有arri等属性。注意,新建的函数并不是直接存着父变量对象里面的各个变量,而仅仅是存着变量对象的引用

在循环结束的时候,外层的i已经变成了5,而里层的函数还是对外面的世界一脸懵比、一无所知,它还天真地以为只要有一条能到达外面世界的桥,就可以知道外面发生的一切。然而事实是,当函数被调用的时候,得到的却是已经改变了的i —- 5

那怎么办呢?既然存的是引用,那在函数外面再加一个中间变量对象,用来直接存变量就行了嘛。于是改成这样:

1
2
3
4
5
6
7
8
9
var arr = [];
for (var i = 0; i < 5; i++) {
(function(x) {
arr.push(function() {
console.log(x);
});
})(i);
}
arr[0](); // -> 0

这里具体做了两件事:一,在原本的函数外面在加一层中间函数;二,将i作为参数传递进去,并立即执行这个中间函数。为什么要立即执行呢?前面说了在函数被调用的时候,会创建这个函数的执行上下文并且创建变量对象。所以如果不立即执行,哪里来的中间变量对象,没有中间变量对象,接下来的里层函数怎么引用…一般来说,在函数调用结束之后,基于这个函数的执行上下文就会被销毁,这个执行上下文的变量对象也就会随着销毁,但是如果在函数调用结束后还有指针引用着变量对象,这变量对象就不会被销毁了。看回题目,即使中间函数立即执行完毕了,但由于其变量对象还被里层的函数的[[scope]]属性所引用着,所以其不会被销毁,而这里的变量对象就是闭包。

现在应该可以回答什么是闭包这个问题了。包着各种变量(其实就是变量对象),相对于外界是封闭的,却可以被里层的函数所感知。

关于 ES6 的参数作用域

前段时间有同学在阮老师的评论里跟阮老师争论关于 ES6 的参数作用域的问题,最来阮老师修改了文章中有关作用域的内容。按那位同学后来写的博客里面的说法:

如果参数存在默认值,则有三个环境 environment( environment in ES6 = scope in ES5). Outer environment / parameters environment / function body environment.
parameters environment 可以访问自己和外层,不能访问函数体内的变量。
函数体内可以修改 parameters env 里定义的 formal parameters 的值,不能重新定义(除非用var……)。

但是这里我认为这里说得有一点小问题。看下规格原文:

9.2.12 FunctionDeclarationInstantiation
If the function’s formal parameters do not include any default value initializers then the body declarations are instantiated in the same Environment Record as the parameters. If default value parameter initializers exist, a second Environment Record is created for the body declarations. Formal parameters and functions are initialized as part of FunctionDeclarationInstantiation. All other bindings are initialized during evaluation of the function body.

注意了,原文是default value initializers,而不是default value,所以我认为那位同学的说法在文字上有点问题。看下这个例子:

1
2
3
4
5
6
7
8
var x = 1;
function foo(x, y = function() { x = 2; }) {
var x = 3;
y();
console.log(x);
}
foo(undefined, () => { x = 4; }); // 3
x // 4

显然,这里 y 里面的 x 指向全局 x,而不是参数 x,所以这里的参数作用域没有起作用,或者说这时候压根就不存在参数作用域。这个例子跟一开始的例子的区别只是,在调用 foo 时就已经给 y 传递了一个函数。而参数默认值的赋值行为只发生在没有传参或传递undefined的时候,也就是说 y 的默认初始化行为并没有发生。举这个无厘头的例子只是想说明,只有发生参数默认值的初始化时,参数作用域才会存在,而不是存在参数默认值就会有参数作用域。

这个例子有点扯淡,本来很直观的事情被这么一搞就懵了,其实我只是举个栗子说明下那位同学的说法的一点问题而已。