你不知道的javascript——动态作用域与this词法

动态作用域

第 2 章中,我们对比了动态作用域和词法作用域模型,JavaScript 中的作用域就是词法作用域(事实上大部分语言都是基于词法作用域的)

我们会简要地分析一下动态作用域,重申它与词法作用域的区别。但实际上动态作用域是JavaScript 另一个重要机制 this 的表亲,本书第二部分“this 和对象原型”中会有详细介绍。

从第 2 章中可知,词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用eval() 或 with)。

动态作用域似乎暗示有很好的理由让作用域作为一个在运行时就被动态确定的形式,而不是在写代码时进行静态确定的形式,事实上也是这样的。我们通过示例代码来说明:

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

词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。

而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3:

1
2
3
4
5
6
7
8
9
function foo() {
console.log( a ); // 3(不是 2 !)
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();

为什么会这样?因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地方查找 a,而不是在嵌套的词法作用域链中向上查找。由于 foo() 是在 bar() 中调用的,引擎会检查 bar() 的作用域,并在其中找到值为 3 的变量 a。

很奇怪吧?现在你可能会这么想。

但这其实是因为你可能只写过基于词法作用域的代码(或者至少以词法作用域为基础进行了深入的思考),因此对动态作用域感到陌生。如果你只用基于动态作用域的语言写过代码,就会觉得这是很自然的,而词法作用域看上去才怪怪的。

需要明确的是,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。但是 this 机制某种程度上很像动态作用域。

主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

最后,this 关注函数如何调用,这就表明了 this 机制和动态作用域之间的关系多么紧密。

this词法

尽管这个标题没有详细说明 this 机制,但是 ES6 中有一个主题用非常重要的方式将 this
同词法作用域联系起来了,我们会简单地讨论一下。

ES6 添加了一个特殊的语法形式用于函数声明,叫作箭头函数。它看起来是下面这样的:

1
2
3
4
var foo = a => {
console.log( a );
};
foo( 2 ); // 2

这里称作“胖箭头”的写法通常被当作单调乏味且冗长(挖苦)的 function 关键字的简写。

但是箭头函数除了让你在声明函数时少敲几次键盘以外,还有更重要的作用。简单来说,下面的代码有问题:

1
2
3
4
5
6
7
8
9
var obj = {
id: "awesome",
cool: function coolFn() {
console.log( this.id );
}
};
var id = "not awesome"
obj.cool(); // 酷
setTimeout( obj.cool, 100 ); // 不酷

问题在于 cool() 函数丢失了同 this 之间的绑定。解决这个问题有好几种办法,但最长用的就是 var self = this;。

使用起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
count: 0,
cool: function coolFn() {
var self = this;
if (self.count < 1) {
setTimeout( function timer(){
self.count++;
console.log( "awesome?" );
}, 100 );
}
}
};
obj.cool(); // 酷吧?

var self = this 这种解决方案圆满解决了理解和正确使用 this 绑定的问题,并且没有把问题过于复杂化,它使用的是我们非常熟悉的工具:词法作用域。self 只是一个可以通过词法作用域和闭包进行引用的标识符,不关心 this 绑定的过程中发生了什么。

人们不喜欢写冗长的东西,尤其是一遍又一遍地写。因此 ES6 的一个初衷就是帮助人们减少重复的场景,事实上包括修复某些习惯用法的问题,this 就是其中一个。

ES6 中的箭头函数引入了一个叫作 this 词法的行为:

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( () => { // 箭头函数是什么鬼东西?
this.count++;
console.log( "awesome?" );
}, 100 );
}
}
};
obj.cool(); // 很酷吧 ?

简单来说,箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。

因此,这个代码片段中的箭头函数并非是以某种不可预测的方式同所属的 this 进行了解绑定,而只是“继承”了 cool() 函数的 this 绑定(因此调用它并不会出错)。

这样除了可以少写一些代码,我认为箭头函数将程序员们经常犯的一个错误给标准化了,也就是混淆了 this 绑定规则和词法作用域规则。

换句话说:为什么要自找麻烦使用 this 风格的代码模式呢?把它和词法作用域结合在一起非常让人头疼。在代码中使用两种风格其中的一种是非常自然的事情,但是不要将两种风格混在一起使用。

另一个导致箭头函数不够理想的原因是它们是匿名而非具名的。具名函数比
匿名函数更可取的原因参见第 3 章。

在我看来,解决这个“问题”的另一个更合适的办法是正确使用和包含 this 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( function timer(){
this.count++; // this 是安全的
// 因为 bind(..)
console.log( "more awesome" );
}.bind( this ), 100 ); // look, bind()!
}
}
};
obj.cool(); // 更酷了。

无论你是喜欢箭头函数中 this 词法的新行为模式,还是喜欢更靠得住的 bind(),都需要注意箭头函数不仅仅意味着可以少写代码。

它们之间有意为之的不同行为需要我们理解和掌握,才能正确地使用它们。



完~