# 作用域与闭包

章节目录:

# 什么是作用域?

所有语言的最基础的模型之一就是在变量中存储值,并在稍后取出或修改这些值的能力。事实上,在变量中存储值和取出值的能力,给程序赋予了状态

但程序中纳入变量,引出了几个问题:变量存储在哪里?程序需要变量的时候,如何找到它们?

回答这些问题需要一组明确定义的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些变量。这组规则称为:作用域

# 编译器理论

传统编译型语言处理中,在被执行之前通常会经历三个步骤,大致被称为编译

  1. 分词/词法分析

    将一串字符打断成有意义的片段,称为token(记号)。

注意

分词与词法分析的区别是比较微妙的,其核心在于这些token是以无状态有状态的方式被识别。

简而言之,如果分词器去调用有状态的的解析规则来弄清a是否应当被考虑为一个不同的token,还是只是其他token的一部分,那么这就是词法解析

  1. 解析

    将一个token流(数组)转换为一个嵌套元素的树,它综合地表示了程序的语法结构。这棵树称为抽象语法树(AST——Abstract Syntax Tree)

  2. 代码生成

    将抽象语法树转换为可执行的代码。这一部分根据语言,目标平台等因素有较大不同。可以简单理解为,将抽象语法树转换为机器指令。

和大多数其他语言的编译器一样,JavaScript引擎要比这区区三步复杂太多了。例如,在解析和代码生成的处理中,一定会存在优化执行效率的步骤,包括压缩冗余元素等。

JavaScript引擎没有(像其他语言的编译器那样)大把的时间去优化,因为JavaScript的编译和其他语言不同,不是提前发生在一个构建的步骤中。

在许多情况下,JavaScript的编译发生在代码被执行前的仅仅几微秒之内(或更少),为了确保最快的性能,JS引擎将使用所有的招数(比如JIT,它可以懒编译甚至是热编译等等)。

为了简单起见,我们可以说,任何JavaScript代码段在它执行之前(通常是刚好在它执行之前)都必须被编译。

# 理解作用域

  • 引擎:负责从始至终的编译和执行JS程序
  • 编译器:引擎的辅助,处理代码解析和生成代码的重活
  • 作用域:引擎的辅助,收集并维护一张所有被声明的标识符列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。

# 编译器术语

  • LHS:Left-hand Side

    LHS寻找变量容器本身,以便它可以赋值。简单理解为:取得它的源

  • RHS:Right-hand Side

    RHS寻找值。简单理解为:去取...的值

# 嵌套作用域

作用域是通过标识符名称查询变量的一组规则。但是,通常作用域不仅仅是单一的。

代码块或函数可以被嵌套在另一个代码块或函数中,作用域也会被嵌套在其他作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎就会咨询下一个外层作用域,直到全局作用域为止。

遍历嵌套作用域的简单规则:引擎从当前执行的作用域开始,在那里查找变量,如果没有找到,就向上走一级继续查找,如此类推。如果到了最外层的全局作用域,无论是否找到了变量,都会停止继续查找。

# 错误

为什么区别LHS和RHS那么重要?因为两种类型的查询行为不同。

  • RHS查询,如果在作用域中找不到,引擎会抛出ReferenceError错误。

  • LHS查询,如果在作用域中找不到,非Strict模式,会在全局作用域中自动创建这个值;Strict模式下会抛出ReferenceError错误。

现在,如果一个RHS查询的变量被找到了,你尝试做一些这个值不能做到的事情,比如将非函数值作为函数运行或者引用nullundefined值的属性,那么引擎就会抛出TypeError错误。

总结下来就是:ReferenceError是关于作用域解析失败的,而TypeError暗示作用域解析成功了,但是试图对结果进行非法操作。

# 词法作用域

作用域的工作方式有两种占统治地位的模型。

  • 词法作用域
  • 动态作用域

JavaScript采用的作用域模型为词法作用域

# 词法分析时

标准语言编译器的第一个传统步骤为词法分析(也就是分词),是检查一串源代码字符并给token赋予语法含义作为某种有状态解析的输出。

词法作用域是在词法分析时被定义的作用域。换句话说,词法作用域是基于开发者在写程序时,定义变量和作用域的块儿时所决定的。

注意

虽然JS可以通过某些方法骗过词法作用域,从而在词法分析器处理过后改变它,但这些方法都是不优雅的。

公认的最佳实践是,词法作用域只依靠词法,编写时就应该可以确定它。

# 查询

当查询一个标识符时,一旦找到第一个匹配,作用域查询就停止了。因此内层作用域的标识符会遮蔽(shadowing)外层作用域的标识符。

词法作用域查询只会处理头等标识符,例如foo.bar.baz,词法作用域只会查询foo标识符,一旦定位这个变量,对象属性访问规则将会分别接管barbaz属性的解析。

# 欺骗词法作用域

词法作用域是编写时决定的,这也是最佳的实践。那么有没有可能在运行时『修改』(欺骗)词法作用域呢?

JS有两种这样的机制,目前公认其存在效率低下,实现不优雅,阅读难度提升等问题。当然最主要的问题就是:欺骗词法作用域会导致低下的性能

  • eval
  • with

为什么欺骗词法作用域会导致性能下降呢?因为JS引擎在编译阶段期间进行了许多性能优化工作,其中的一些优化原理都归结为实质上在进行词法解析时可以静态地分析代码并提前决定所有的变量和函数声明在什么位置,这样在执行期间就可以少花力气来解析标识符。

但如果引擎在代码中发现了evelwith,它就得假定已知的所有标识符位置可能是无效的,因为在词法分析时它不可能知道你将会传递怎么样的代码来修改词法作用域。

因此只要evelwith出现,JS引擎做的所有优化都将没有意义,所以性能会下降

# 函数与块儿作用域

作用域由一系列『气泡』组成,每一个『气泡』可以类比为一个篮子或容器,标识符(变量、函数)就在它里面被声明。这些气泡整齐地互相嵌套在一起,而这种嵌套是在编写时定义的。

# 函数中的作用域

JavaScript拥有基于函数的作用域,声明的每一个函数都创建了一个气泡。

函数作用域的设计思路为:所有变量都属于函数,在函数内部任意地方都可以使用和重用(甚至可以在嵌套的作用域中访问)。这种设计方式可以完全利用JavaScript的『动态』性质——变量可以根据需要接受不同类型的值。

# 隐藏于普通作用域

通常使用函数的方式是,先声明一个函数,然后在其内部添加代码。但是相反的操作也同样强大和有效,即在书写的代码外面包裹一个函数。这实际上是『隐藏』了这段代码。

换句话说,你可以将变量和函数围在一个函数的作用域中来『隐藏它们』。

为什么『隐藏』变量和函数是一种有用的技术?

有多种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为『最低权限原则』的软件设计原则引起的,也称为『最低授权』和『最少曝光』。这个原则规定,在软件设计中,比如一个模块/对象API,你应当暴露所需要的最低限度的东西,而『隐藏』其它的一切。

# 避免冲突

将变量和函数『隐藏』在一个作用域内部的另一个好处是:避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。

比如全局『名称空间』,在全局作用域中经常会出现变量冲突的情况。当加载多个库时,如果它们没有隐藏内部的私有变量和函数,那么就非常容易产生冲突。

这样的库通常会在全局作用域中使用一个足够独特的名称来创建一个单独的变量声明,它通常是一个对象。然后这个对象被用作这个库的一个『名称空间』,所有需要暴露出现的功能都被当作属性挂在这个对象上,而不是将它们自身作为顶层词法作用域的标识符。例如jQuery库就是这样操作。

# 模块管理

另一种回避冲突的选择是通过依赖管理器,使用更加现代的『模块』方式。使用这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。

可以看到,以上『隐藏』代码的几种方式,并没有豁免词法作用域规则的『魔法』功能。它们只是巧妙的利用作用域规则,来控制标识符不会被注入共享的作用域,从而保持其私有性。

# 函数作为作用域

我们已经知道,通过给代码包裹一个函数,就能对外部作用域『隐藏』这段代码。

例如:

var a = 2;

function foo() { // 插入
   var a = 3;
   console.log(a); // 3
} // 插入

foo(); // 插入

console.log(a); // 2

虽然这种技术『可以工作』,但是不太理想。首先是,我们必须得声明一个foo函数,这意味着这个foo标识符已经污染了外层作用域。其次是,还得必须明确的调用foo()函数来使包装的函数真正的运行。

因此,如果包裹函数不需要名称,而且函数能够自动执行就更理想了。

幸运的是,JavaScript提供了一个解决方法。

var a = 2;

(function foo() { // 插入
  var a = 3;
  console.log(a); // 3
})(); // 插入

console.log(a); // 2

function...相对,这个包装函数以(function...开头,虽然看起来是一个微小的细节,但实际上这是一个重大改变。与将这个函数视为一个标准的声明不同的是,这个函数被视为一个函数表达式。

注意

区分声明与表达式最简单的方法是,这个语句中的开头。

如果是以关键字var或function开头,那么就是声明,否则就是表达式。

# 匿名与命名

常见的函数表达式作为回调参数,例如:

setTimeout(function () {
  console.log("I waited 1 second!");
}, 1000);

这就是一个"匿名函数表达式",因为function()...上没有名称标识符。

匿名函数表达式使用非常方便,而且许多库和工具往往鼓励这种代码风格,然而它们有几个缺点需要考虑:

  • 在栈轨迹上匿名函数没有名称可以表示,这可能使得调试比较困难。
  • 没有名称的情况下,如果这个函数需要为了递归等目的引用它自己,或者一个事件处理器函数在被触发后想要把自己解除绑定时,就比较困难。
  • 匿名函数省略的名称经常对提供更易读/易懂的代码很有帮助。一个描述性的名称可以帮助代码自解释。

# 立即调用函数表达式

IIFE,表示"立即被调用的函数表达式"。IIFE不一定需要一个名称,匿名或命名都可以。

var a = 2;

(function IIFE(){

	var a = 3;
	console.log( a ); // 3

})(); // 这个()执行放在内部和外部没有任何区别,纯粹偏好风格

console.log( a ); // 2

IIFE的另一种十分常见的变种是,利用调用函数的机制,传入参数值。


var a = 2;

(function IIFE( global ){

	var a = 3;
	console.log( a ); // 3
	console.log( global.a ); // 2

})( window );

console.log( a ); // 2

这样就可以将全局与非全局引用有了一个划分。也可以避免其它小问题,比如undefined埋雷。

undefined = true; // 给其他的代码埋地雷!别这么干!

(function IIFE( undefined ){

	var a;
	if (a === undefined) {
		console.log( "Undefined is safe here!" );
	}

})();

IIFE还有一种变种,它将事情的顺序颠倒了过来,要被执行的函数在调用和传递给它的参数之后给出。这种模式被用于UMD(Universal Module Definition —— 统一模块定义)项目。

var a = 2;

(function IIFE( def ){
	def( window );
})(function def( global ){

	var a = 3;
	console.log( a ); // 3
	console.log( global.a ); // 2

});

def函数表达式在这个代码段的后半部分被定义,然后作为一个参数(也叫def)被传递给代码段前半部分定义的IIFE函数。最后,参数def被调用,并将window作为global参数传入。

# 块儿作为作用域

ES3开始try/catch中的catch子句有了块儿作用域。

ES6中,引入了let,它会劫持当层的作用域,将自己附着在上面。

关于let的例子:

for (let i = 0; i < 10; i++) {
	console.log( i );
}

// 上面函数等同于
let j;
for (j = 0; j < 10; j++) {
   let i = j;
   cnosole.log(i);
}

在for循环头部的let不仅将i绑定在for循环体中,实际上,每一次循环都会重新绑定i,确保它被赋予上一次循环迭代末尾的值。

# 提升

现在,你应该对作用域比较熟悉:变量如何根据它们被声明的方式和位置的附着在不同的作用域层级。

函数作用域和块儿作用域的行为都是依赖于这个相同的规则:在一个作用域中声明的任何变量都附着在这个作用域上。

但是关于出现在一个作用域内各种位置的声明如何附着在作用域上,有一个微妙的细节,这就是我们需要讨论的。

# 先有鸡还是先有蛋?

你可能以为程序的执行过程是从上到下一行一行地被解释执行,虽然这大致上是对的,但是下面这种情况,会让你思考事实真的如此吗?

a = 2;

var a;

console.log(a); // 2

你认为这里是undefined,因为var a出现在a = 2之后,应该是被重定义了,所以值应该是undefined,但事实上这里的输出是2

再来一个例子:

console.log(a);

var a = 2;

因为上面的例子,你可能会被误导:因为上一个代码段看起来不是从上到下,你认为这个代码段也会打印2。还有可能,你认为变量a在它被声明之前就使用了,所以这里应该抛出ReferenceError错误。

但事实是这里输出了undefined

那么,这里发生了什么?看起来,我们遇见了一个先有鸡还是先有蛋的问题,声明->蛋,赋值->鸡。

# 编译器再次来袭

要回答这个问题,还得看第一章对于编译器的讨论。引擎实际上会在解释执行JavaScript代码之前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上。

因此,我们的代码任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。

当你看到var a = 2;时,你可能认为这是一个语句。但是JavaScript却认为这是两个语句:var a;a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在原处

于是我们的第一个代码段应当被认为是这样被处理的:

var a;
a = 2;

console.log(a);

第一部分是编译,第二部分是执行。正因为这种处理方式,变量和函数声明的位置相当于『移动』到了代码的顶端,于是产生了『提升』。

所以前一小节的问题就可以回答了,先有蛋(声明),后有鸡(赋值)

注意

只有声明本身被提升了,而任何赋值或者其它的执行逻辑仍然留在原处。如果提升会改变代码的执行逻辑,那将是一场灾难。

foo();

function foo() {
	console.log(a); // undefined

	var a = 2;
}

因为函数foo的声明被提升了,所以第一行的调用是可以执行的。

注意

提升是以作用域为单位的。换句话说,变量只会提升到当前作用域的顶端,而不是程序的顶端。

函数声明会被提升,但是函数表达式不会:

foo(); // 不是ReferenceError,而是TypeError!

var foo = function bar() {
  // ...
};

变量标识符foo被提升,所以foo()执行不会出现ReferenceError引用错误,因为foo标识符的确存在。但foo还没有值,因为表达式还未执行,所以foo()就是试图把undefined值作为函数来调用,因此抛出TypeError非法操作错误。

# 函数优先

函数声明和变量声明都会被提升。但有一个微妙的细节(可以存在多个重复的声明)是:函数会首先被提升,然后才是变量。

例如:

foo(); // 1

var foo;

function foo() {
	console.log(1);
}

foo = function() {
	console.log(2);
}

1被打印了,而不是2!这个代码段被引擎解释执行为:

function foo() {
	console.log(1);
}

foo();

foo = function() {
	console.log(2);
}

注意那个var foo是一个重复(因此被无视)的声明,即便它出现在funtion foo() ...声明之前也会被无视,因为函数声明是在普通变量之前被提升的。

虽然多个/重复的var声明实质上是被忽略的,但是后续的函数声明确实会覆盖前一个。

foo(); // 3

function foo() {
	console.log(1);
}

var foo = function() {
	console.log(2);
}

function foo() {
	console.log(3);
}

虽然这一切听起来不过是一些有趣的学院派细节,但它强调了一个事实:在同一个作用域内的重复定义是非常差劲的主意,而且经常会导致令人困惑的结果。

# 作用域闭包

闭包是JS非常非常重要的一个概念。

# 事实真相

理解和识别闭包,有一个简单粗暴的定义:

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。

function foo() {
	var a = 2;

	function bar() {
		console.log(a);
	}

	return bar;
}

var baz = foo();

baz(); // 2 -- 看见闭包了

内层bar()函数通过词法作用域访问外层foo()是符合规则的。执行foo()函数将bar()返给外部的baz变量,baz执行也是没问题的,因为baz就是bar()只是换了一个名字。

但是现在出现一个有意思的现象,通过baz来执行bar()时,此时它在其声明的词法作用域外部执行的。

照理来说,foo()函数执行完毕后,其整个内部作用域都将消失(被引擎启用垃圾回收器回收掉了)。应该无法访问foo()中的a变量才符合预期,但是却仍然能够访问a,就是因为闭包的原因,使其内部的作用域依然存在。

bar()拥有对那个作用域的引用,而这个引用称为闭包。

# 现在我能看到了

前面的代码过于学术化,现在让我们来看看实际场景经常碰见的闭包代码。

function wait(message) {
	
	setTimeout(function timer() {
		console.log(message);
	}, 1000);

}

wait("Hello, closure!");

上面代码中,有一个内部函数timer将它传递给setTimeouttimer引用着wait的词法作用域。

当我们执行wait一千毫秒后,因为引用(闭包)存在,因此可以获取message变量,否则wait的词法作用域早就消失了。

事实上,无论何时何地只要你将函数作为头等值并将他们传来传去,你就可能看见这些函数在使用闭包。例如:计时器、事件处理器、Ajax请求、跨窗口消息、Web Worker或者任何其它同步、异步任务,当你传入一个回调函数,你就已经使用了闭包。

# IIFE

前面几章,我们了解了IIFE,人们常说IIFE是标准闭包的例子,但是根据上面的定义,它其实并不完全符合闭包标准。

例子如下:

var a = 2;

(function IIFE() {
	console.log(a);
}))();

这段代码『好用』,但严格来说它不是在观察闭包。为什么?因为这个函数没有在它的词法作用域之外执行

它仍然在它被声明的相同的作用域中被调用。a是通过普通的词法作用域查询到的,而不是通过真正的闭包。

虽然IIFE本身不是一个闭包的例子,但是它的确创建了作用域,而且它是我们用来创建可以被闭包的作用域的最常见的工具之一,所以IIFE确实与闭包有强烈的关联,即便它们本身不行使闭包。

# 循环 + 闭包

最常见最权威的例子是老实巴交的for循环。

for (var i = 1; i <= 5; i++) {
	setTimeout(function timer() {
		console.log(i);
	}, i * 1000);
}

上面代码段的执行结果为:一秒一次,打印五次数字6。

实际用户可能想得到的结果是:一秒一次,打印1、2、3、4、5。

其原因就是『闭包』,我们试图在迭代期间,每次迭代都『捕捉』一份对i的拷贝。虽然这5个函数在每次循环迭代中分离地定义,但是由于作用域的工作方式,它们都闭包在同一个共享的全局作用域上,而它事实上只有一个i

因此,五个函数指向了同一个i,所以返回了相同的值。循环五次和直接声明五个超时回调没有任何区别。

现在问题已经明确了,我们需要为5个函数各自创建一个闭包作用域,而不是共享一个全局作用域。

通过前面学到的知识,我们知道可以使用IIFE通过声明来立即执行一个函数来创建作用域。

for (var i = 1; i <= 5; i++) {
	(function() {
		setTimeout(function timer() {
			console.log(i);
		}, 1000);
	})();
}

这好用吗?『不好用』。现在每个函数都已经拥有独立词法作用域,为什么还是不好用?

因为只拥有被闭包的空的作用域是不够的。仔细观察,空作用域没有属于自己的变量,仍然是通过词法作用域获取的外部的i

因此,需要定义自己词法作用域中的变量:

function (var i = 1; i <= 5; i++) {
	(function() {
		var j = i; // 定义一个只属于此词法作用域的变量
		setTimeout(function timer() {
			console.log(j);
		}, j * 1000);
	})();
}

j存在每个IIFE函数的词法作用域,现在问题解决了。

它还有一种稍稍变形的形式,意思是一样的:

for (var i = 1; i <= 5; i++) {
	(function(j) {
		setTimeout(function timer() {
			console.log(j);
		}, j * 1000);
	})(i);
}

每次循环时,通过IIFE创建新作用域,每个作用域都存储着『正确』的值。

# 重温块儿作用域

分析上面的解决方案,我们通过IIFE在每一次迭代中创建新的作用域。换句话说,每次递归都需要一个块儿作用域

这实质上将块儿变成了一个我们可以闭包的作用域,因此我们可以有更简单的解决方案:

for (var i = 1; i <= 5; i++) {
	let j = i; // 仅存在于块儿作用域中
	setTimeout(function timer() {
		console.log(j);
	}, j * 1000);
}

还可以更简洁一点,for循环头部为let声明时会有一种特殊行为。每次迭代都会声明一次,并且后续迭代中的初始值为上一次迭代的值。

for (let i = 1; i <= 5; i++) {
	setTimeout(function timer() {
		console.log(i);
	}, i * 1000);
}

块儿作用域和闭包携手工作,威力无穷啊。

# 模块

还有其它的代码模式利用了闭包的力量,例如,模块。

function foo() {
	var something = "cool";
	var another = [1, 2, 3];

	function doSomething() {
		console.log(something);
	}

	function doAnother() {
		console.log(another.join(" ! "));
	}
}

上面这段代码,没有发生明显的闭包。只有两个私有变量somethinganother,以及两个内部函数doSomething()doAnother(),它们都拥有覆盖在foo()内部作用域上的词法作用域(因此是闭包!)。

现在考虑这段代码:

function CoolModule() {
	var something = "cool";
	var another = [1, 2, 3];

	function doSomething() {
		console.log(something);
	}

	function doAnother() {
		console.log(another.join(" ! "));
	}

	return {
		doSomething: doSomething,
		doAnother: doAnother
	};
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

在JavaScript我们称这种模式为『模块』。实现模块模式最常见的方法是『揭示模块』,它是我们上面代码的变种。

让我们分析一下这段代码到底做了什么事情。

首先,CoolModule()只是一个函数,但它必须被调用才能成为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包将不会发生。

第二,CoolModule()函数返回一个对象,通过对象字面量{key: value, ...}标记。此对象拥有指向内部函数的引用,但是没有指向内部数据变量的引用。因此可以保证数据的隐藏性和私有性。所以返回对象实质上是模块的共有API

这个返回值对象最终赋值给外部变量foo,因此我们可以在这个API上访问那些属性,比如foo.doSomething()

注意

模块也可以不用返回对象,仅仅直接返回一个内部函数也是可以的。

比如jQuery就是一个例子,jQuery$标识符是jQuery模块的共有API,但是它们本身只是一个函数(这个函数本身可以有属性,因为所有的函数都是对象)。

doSomething()doAnother()函数拥有模块『实例』内部作用域的闭包(通过实际调用CoolModule()得到的)。当我们通过返回值对象的属性引用,将这些函数传送到词法作用域外部时,就建立好了可以观察和行使的闭包条件。

简单来说,行使模块模式有两个『必要条件』:

  1. 必须有一个外部的外围函数,而且它必须至少被调用一次(每次创建一个新的模块实例)。
  2. 外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问和修改私有状态。

仅一个带有函数属性的对象本身不是真正的模块。从可观察的角度来说,一个从函数调用中返回的对象,仅带有数据属性而没有闭包的函数,也不是真正的模块。

模块的变种,单例模式代码如下:

var foo = (function CoolModule() {
	var something = "cool";
	var another = [1, 2, 3];

	function doSomething() {
		console.log(something);
	}

	function doAnother() {
		console.log(another.join(" ! "));
	}

	return {
		doSomething: doSomething,
		doAnother: doAnother
	};
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

通过IIFE实现单例模式。

模块还可以传参,代码如下:

var foo = (function CoolModule(id) {
	function change() {
		// 修改公有 API
		publicAPI.identify = identify2;
	}

	function identify1() {
		console.log(id);
	}

	function identify2() {
		console.log(id.toUpperCase());
	}

	var publicAPI = {
		change: change,
		identify: identify1,
	};

	return publicAPI;
})("foo module");

foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

在模块实例内部持有一个指向公有API对象的内部引用,你可以从内部修改这个模块,包括添加和删除方法、属性和改变它们的值。

# 现代的模块

各种模块依赖加载器/消息机制实质上都是将这种模块定义包装进一个友好的API。下面通过代码来解释:

var MyModules = (function Manager() {
  var modules = {};

	function define(name, deps, impl) {
		for (var i = 0; i < deps.length; i++) {
			deps[i] = modules[deps[i]];
		}
		modules[name] = impl.apply(impl, deps);
	}

	function get(name) {
		return modules[name];
	}

	return {
		define: define,
		get: get,
	};
})();

这段代码的关键部分是modules[name] = impl.apply(impl, deps)。这为一个模块调用了它定义的包装函数(传入所有依赖),并将返回值也就是模块API,存储到一个用名称追踪的内部模块列表中。

下面展示如何使用它来定义一个模块:

MyModules.define("bar", [], function() {
	function hello(who) {
		return "Let me introduce: " + who;
	}

	return {
		hello: hello
	};
});

MyModules.define("foo", ["bar"], function(bar) {
	var hungry = "hippo";

	function awesome() {
		console.log(bar.hello(hungry).toUpperCase());
	}

	return {
		awesome: awesome
	};
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo

foo.awesome(); // LET ME INTRODUCE: HIPPO

模块『foo』和『bar』都使用一个返回公有API的函数来定义,『foo』甚至接收一个『bar』的实例作为依赖参数。

花点时间分析这段代码,来理解闭包带来的好处。关键在于,对于模块管理器来说并没有什么特殊的『魔法』。它们只是满足了上面列出的模块模式的两个性质:调用一个函数定义包装器,并将它的返回值作为模块的API保存下来。

换句话说,模块就是模块,即便你在它们上面放了一个友好的包装工具。

# 未来的模块

ES6为模块的概念增加了头等的语法支持。当通过模块系统加载时,ES6将一个文件视为一个独立的模块。每个模块可以导入其它的模块或特定的API成员,也可以导出它们自己的公有API成员。

注意

基于函数的模块不是静态识别的模式(编译器不知道),所以它们的API语义直到运行时才会被考虑。也就是,你实际上可以在运行时期间修改模块的API。

相比之下,ES6模块API是静态的(API不会在运行时改变)。因为编译器知道它,它可以(确实也在这么做)在文件加载和编译期间检查一个指向被导入模块的成员的引用是否实际存在。如果API引用不存在,编译器就会在编译时抛出一个『早期』错误,而不是等待传统的动态运行时解决方案和错误。

ES6模块没有『内联』格式,它们必须被定义在一个分离的文件中(每个模块一个)。浏览器/引擎拥有一个默认的『模块加载器』(它可以被覆盖),它在模块被导入时同步地加载模块文件。

思考下面的代码:

bar.js

function hello(who) {
	return "Let me introduce: " + who;
}

export hello;

foo.js

// 仅导入"bar"模块中的`hello()`
import hello from "bar";

var hungry = "hippo";

function awesome() {
	console.log(hello(hungry).toUpperCase());
}

export awesome;
// 导入`foo`和`bar`整个模块
module foo from "foo";
module bar from "bar";

console.log(bar.hello("rhino")); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

模块文件内部的内容被视为包裹在一个作用域闭包中,就像前面函数闭包模块那样。

# 附录

其它知识点梳理。

# 动态作用域

第二章提到过,JS与大多数语言作用域的工作方式模型都是『词法作用域』,这里我们来比较『动态作用域』和『词法作用域』的差别是啥?

对于JavaScript中的另一种机制(this)来说,动态作用域实际上是它的近亲表兄。

通过学习第二章,我们知道词法作用域是一组引擎如何查询变量和它在何处能够找到变量的规则。其关键性质是,它在代码编写时被定义的(如果你不是用evel()with作弊的话)。

而动态作用域,其作用域是在运行时被确定的,而不是编写时静态地确定,通过代码来说明这种情况:

function foo() {
	console.log(a); // 2
}

function bar() {
	var a = 3;
	foo();
}

var a = 2;

bar();

foo()的词法作用域中指向a的RHS引用将被解析为全局变量a,它将导致输出的结果值为2

相比之下,动态作用域本身不关心函数和作用域在哪里和如何被声明的,而是关心它们是从何处被调用的。换句话说,它的作用域链条是基于调用栈的,而不是代码中作用域的嵌套

所有,如果JavaScript拥有动态作用域,当foo()执行时,理论上结果应该返回3

因为foo()找不到a引用时,不是沿着嵌套作用域链向上走一层,而是沿着调用栈向上走一层。因此在bar()中找到了a的引用,所以返回3

JavaScript实际上没有动态作用域,它只有词法作用域,但是this的机制有些像动态作用域。

关键的差异:词法作用域是编写时的,而动态作用域(和this)是运行时的。词法作用域关心函数在何处被声明,但是动态作用域关心的是函数从何处被调用

最后:this关心的是函数从何处被调用,因此也揭示了this机制与动态作用域有多么紧密的关联。

# 填补块儿作用域

在第三章中,我们学习了块儿作用域,知道最早在ES3中引入的withcatch子句都是存在于JavaScript中的块儿作用域的小例子。

但是ES6引入的let才使我们的代码有了完整的,不受约束的块作用域能力。

但是我们想在ES6之前的环境中使用块儿作用域呢?

思考如下代码:

{
	let a = 2;
	console.log(a); // 2
}

console.log(a); // ReferenceError

它在ES6环境下工作的非常好,但是我们能在ES6之前这么做吗?catch就是答案。

try {
	throw 2;
} catch (a) {
	console.log(a); // 2
}

console.log(a); // ReferenceError

通过try/catch强制抛出一个错误,错误值为2,然后声明在catch子句中。实现方式非常丑陋。

因为catch子句拥有块儿作用域,因此它可以被用于ES6之前的环境来填补块儿作用域。

# Traceur

Google维护了一个称为『Traceur』的项目,它的任务就是将ES6特性转译为ES6之前的代码。

Traceur将会如何转译上面的代码段呢?

{
	try {
		throw undefined;
	} catch (a) {
		a = 2;
		console.log(a);
	}
}

console.log(a);

没错,就是通过catch实现。

# 性能

为啥不使用IIFE来创建作用域呢?

  1. try/catch性能做了专门的优化,因此无须担心性能问题。
  2. IIFE包裹代码,会改变代码的含义以及它的thisreturncontinue等,因此不是很好的选择,只有在特定情况下手动使用。

# 词法this

ES6为函数声明增加了一种特殊的语法形式,称为『箭头函数』。它看起来像这样:

var foo = a => {
	console.log(a);
};

foo(2); // 2

这个『箭头』通常被称为function关键字的缩写。

但是箭头函数可不仅是缩写,思考如下代码,这段代码有一个问题:

var obj = {
	id: "awesome",
	cool: function coolFn() {
		console.log(this.id);
	}
};

var id = "not awesome";

obj.cool(); // awesome

setTimeout(obj.cool, 100); // not awesome

问题就是cool()函数上丢失了this绑定。有挺多的办法可以解决这个问题,最常用的解决方案就是var self = this;,例如下面的代码:

var obj = {
	count: 0,
	cool: function coolFn() {
		var self = this; // timer的this固定指向外层coolFn

		if (self.count < 1) {
			setTimeout(function timer() {
				self.count++;
				console.log("awesome?");
			}, 100);
		}
	}
};

obj.cool(); // awesome?

简单来说,var self = this的『解决方案』规避了理解和正确使用this绑定的问题,让问题回退到我们的舒适区『词法作用域』。self变成了一个可以通过词法作用域和闭包解析的标识符,而不关心timer中的this到底发送了什么。

人们不喜欢写繁冗的东西,特别是需要一次又一次的重复它的时候,因此,ES6的一个动机就是帮助缓和这些场景,将常见的惯用法问题固定下来。

ES6的解决方案,箭头函数,引入了一种称为『词法this』的行为。

var obj = {
	count: 0,
	cool: function coolFn() {
		if (this.count < 1) {
			setTimeout(() => { // 使用箭头函数
				this.count++;
				console.log("awesome?");
			}, 100);
		}
	}
};

obj.cool(); // awesome?

简单来说,当箭头函数遇到它们的this绑定时,它们的行为与一般的函数有所不同。它们摒弃了this绑定的所有一般规则,而是采用它们的直接外围词法作用域的this值,无论它是什么。

所以,这个代码段中,箭头函数不会以不可预知的方式丢掉this绑定,它只是『继承』cool()函数的this绑定。

虽然代码变得简洁了,但是箭头函数只不过是将一个开发者们常犯的错误固化成了语言的语法,但本质其实是开发者混淆了this绑定规则与词法作用域规则。

换一种说法:this的编码形式并不麻烦,只是开发者混淆概念,将词法作用域与this绑定都书写了,导致代码复杂混乱,因此只要剔除混淆的部分,只使用一种规则就很简洁了。

注意

箭头函数的另一个非议是,它们是匿名的,不是命名的。

因此,解决这个问题的最佳方式是,正确地使用并接受this机制,而不是规避它。

var obj = {
	count: 0,
	cool: function coolFn() {
		if (this.count < 1) {
			setTimeout(function timer() {
				this.count++; // 因为有bind所有this是明确的
				console.log("more awesome?");
			}.bind(this), 100); // 这里使用bind
		}
	}
};

obj.cool(); // awesome?

无论你喜欢箭头函数,还是bind(),最重要的是要明白,箭头函数不仅仅只是缩写。

当我们完全理解了词法作用域和闭包,那么理解词法this就是小菜一碟了。

最近更新时间: 2023/3/21 19:40:56