细说JavaScript作用域和闭包


细说JavaScript作用域和闭包

作者:叶伟

一、JavaScript有哪些作用域类型

对于一门编程语言来说,作用域主要有两种模型,即词法作用域(Lexical Scope)和动态作用域(Dynamic Scope)。而词法作用域,即静态作用域(Static Scope),被目前JavaScript在内的大部分编程语言所采用,而动态作用域只有Bash脚本等少数编程语言在使用。

词法作用域就是定义在词法阶段的作用域,当词法分析器处理代码的时候会保持作用域不变。在表现上,词法作用域的函数中遇到既不是形式参数也不是函数内部定义的局部变量的变量时,会自动去函数定义时的环境中查询(即沿作用域链)。

还是举两个例子吧:

 
1
 
1
 
1
 
1

二、作用域与标识符查询

作用域和表示符查询紧密相关,表示符正是依靠这样一套严密的作用域规则而得以有条理地查询。而对于作用域,需要理解几个词:执行环境,变量对象,活动对象,作用域链。

执行环境与作用域链

首先解释执行环境吧,执行环境定义了在这个执行环境中变量或者函数有权访问的数据,决定了它们各自的行为。在js中执行环境的结构由全局执行环境和一级级内部嵌套的子执行环境组成。其中全局执行环境由ECMAScript实现所在的宿主环境决定,在浏览器中为window对象,在Node中是global对象。每个函数都包含着自己的执行环境,一个个执行环境组成了一个执行环境栈,当执行流进入一个函数时,该函数的执行环境会被推入环境栈中,函数执行之后该环境又会被从环境栈中弹出,这便是ECMAScript执行流的机制。

每个执行环境都包含一个变量对象,它保存着环境中定义的所有变量和函数。当一个执行环境的代码执行的时候会为它的变量对象创建一个由当前执行环境沿着环境栈到全局执行环境的作用域链,作用域链保证了对当前执行环境有权访问的所有变量和函数的 有序 访问。如果当前执行环境是一个函数,那么该函数的活动对象就会成为这个执行环境的变量对象。

而对除了全局执行环境外的执行对象,活动对象最前端是arguments对象,而作用域链沿着一层层的被包含环境的变量对象沿伸到全局执行环境的变量对象。

标识符查询

了解了执行环境和作用域链,标识符的查询就很好理解了,js引擎在遇到一个标识符的时候根据作用域链沿着变量对象从前端到后端进行查询(其实就是从子作用域逐层向父作用域查询),一旦遇到匹配的标识符便会立即停止。如:

 
1
 
1

该例中在console.log对a进行RHS查询时,在bar函数的作用域内便查询到了标识符a,因此便立即停止标识符查询,所以访问不到foo函数的标识符a。这种现象是标识符的遮蔽效应,在多层的嵌套作用域内可以定义同名的标识符,遮蔽效应使得内部的标识符可以遮蔽外层表示符。

三、可以形成独立作用域的结构

在JavaScript中,可以形成独立作用域的结构有两类,函数作用域和块作用域。先说说函数作用域吧。

函数作用域

函数作用域是js中最常见的作用域,每一个函数都拥有一个作用域,而属于这个函数的变量都能在整个函数的范围内访问(当然也能访问),但是在函数外则无法访问函数内的任何变量——除非你在函数执行时把一个闭包返回到函数体外。这种函数内部变量对外的隐藏作用使得同级作用域同名标识符之间的冲突得到避免,这样,也促成了模块机制的良好运行。

利用函数内部对外部的隐藏

如果想创建一个封闭的作用域,让这个作用域内的变量不被外部访问,利用立即执行函数表达式(IIFE)便可实现。如:

 
1
 
1

这个函数表达式在声明后立即执行,这样函数体内的语句都得到了执行且对外部的变量没有影响。其实,JS中的模块也利用了这点。JS模块还是放到最后说吧。

匿名函数

JavaScript中的函数表达式存在具名函数表达式和匿名函数表达式两种,而函数声明则必须具名。匿名函数有什么用呢?让我们不用去冥思苦想标识符怎么取。如上面的例子中的IIFE函数,该函数即使去掉函数名,程序也可以正常运行,因为它的函数名在这种情况起到作用不大。

虽然匿名函数写起来十分便捷,但是基于以下原因,始终给每个函数命名是值得推荐的。

  1. 在没有函数名的情况下,函数的递归调用等需要引用自身的时候,将会不得不用arguments.callee进行引用,而这在ES5之后便不被推荐使用了——甚至在严格模式下会抛出TypeError错误。
  2. 函数名的省略使得代码可读性下降

说到了匿名函数,不得不提闭包,由于闭包内容较多,将在后面专门说明。

块作用域

除了最常见的函数作用域,JavaScript中的块作用域也可以创建出一个独立的作用域。可是,在 Nicholas C.Zakas 著的《Professional JavaScript for Web Developers(3rd Edition)》中说道:

No Block-Level Scopes

JavaScript’s lack of block-level scopes is a common source of confusion. In other C-like languages, code blocks enclosed by brackets have their own scope (more accurately described as their own execution context in ECMAScript), allowing conditional definition of variables.

Nicholas 之所以说JavaScript没有块级作用域是因为他没把withtry/catch作为块级作用域看待,他在书中把这两个情况作为延长作用域链的手段。实际上,通过withtry/catch创建的独立作用域也算是块级作用域的形式,除了这两种外还可以利用ES6中的letconst也可以形成块级作用域。

with

通过with可以创建出的作用域仅在with声明中使用。如:

 
1
 
1

try/catch

try/catch的catch分句会创建一个块级作用域,其中声明的变量只能在内部使用。如:

 
1
 
1

let/const

ES6中引入的let和const关键字可以将变量绑定到任意由{}包含的代码块中。

以下例子可以看出用let声明变量和用var声明变量的区别:

 
1
 
1

当然,也可以直接绑定在块中,如:

 
1
 
1

const关键字定义的常量和let一样,能将其定义的常量绑定到{}包含的块级作用域中,就再举例子了。

四、提升

提升的概念比较简单,但是如果对js语言只是浅尝辄止的话,可能会理解不清。这里通过提升的表现,优先级和原因来简单说明js中的提升。

提升的表现

提升是变量或函数在同个作用域内表现出的可以先使用后定义的现象。先来看看变量提升:

 
1
 
1

再看看函数提升:

 
1
 
1

提升的优先级

提升具有优先级,当一个作用域里对同个标识符既使用了变量声明,也使用了函数声明,那么函数声明会优先被提升。如下面的例子。

 
1
 
1

提升的深层原因

提升之所以存在,是因为JavaScript引擎在对代码解释前会进行预编译。在编译阶段,有一部分的工作就是找到所有的函数声明,并用合适的作用域把它们关联起来,这也是词法作用域的核心部分。有了预编译,在解释执行时,不再检测变量的声明,引擎在对变量进行LHS或RHS查询时会直接向编译阶段生成的作用域取得数据。

也就是说对于var a = 1’;这个语句来说,js引擎会识别为两个声明,即var a 和a = 1,他们分别在编译阶段和执行阶段处理。

五、跨越词法作用域的两种机制

虽然JavaScript采用的是词法作用域,但是如果真的想要在代码执行的时候修改作用域的话也是有办法的,因为js中存在两个”bug”来做到这点。这两个”bug”是传说中的eval还有之前提到过的with。由于这两个”bug”,js的作用域应该算是不完全的词法作用域。

eval

eval可能是js中最强大的函数了,它接受一个参数即一个字符串,在执行时会把这个字符串作为实际的ECMA语句插入到原位置进行解析执行,正如下面例子所示。

 
1
 
1

因为在js编译器预编译的时候,eval()中的语句并不会被执行,所以,eval()中的变量或者函数不会被提升。

 
1
 
1

当然,如果变量/函数的定义和使用都在eval中,那么里面的变量对于里面的调用来说是有提升的,比如:

 
1
 
1

JavaScript中还有一些类似eval()处理方式的函数,比如new Function(..)setInterval(..)setTimeout(..)等等。

with

with语句同样可以在执行阶段修改作用域。

 
1
 
1

在with语句的代码块里面,a,b,c来自obj的三个属性,这个在js预编译的时候也是不能判断的,因此with语句中的变量也不能在词法阶段确定。

六、闭包与模块机制

闭包

说起闭包,在我刚接触JavaScript的时候听到这个词的时候感觉它特别神秘——从这个奇怪的名字就感觉到了神秘感。直到深入了解后才发现,闭包,原来是这样。

什么是闭包

当一个函数能够保存自己所在的词法作用域的时,便产生了闭包——无论这个是在当前词法作用域中还是当前词法作用域外。

 
1
 
1

在上面的例子中,在调用foo函数时bar函数保存着foo函数的局部变量a,在bar函数被返回到foo函数外面的时候,便产生了闭包,闭包里保存着bar所在的词法作用域(包含bar函数和foo函数的所有变量以及全局对象的所有属性),故调用baz函数的时候能够正常返回a中的值2。

闭包相关的问题

问题

首先看下面的例子:

 
1
 
1

这个例子中可以看出这段代码预期是按顺序分别输出1~5的数字。而实际上,每次都输出6。

现仔细造成这个”出乎意料“的现象的原因:setTimeout函数中传进来的timer函数由于作用域闭包的原因,保存着对同一个变量 i 的引用,而在循环结束后i的值为6,又由于js的异步性,在过1000ms之后,循环已经处理结束,因此,结果会输出5个6。

解决办法

只需对上面代码进行一些改进即可解决,代码如下:

 
1
 
1

上面代码通过创建一个自执行的函数表达式来得到一个独立的作用域,再把外部的i作为参数传进函数体,因为函数的参数传递会创建一个副本,所以每个timer中保存不同的i的副本,问题就得到解决了。

模块机制

JavaScript中的模块模式正是充分利用了作用域闭包的能力而实现的。下面由简入深地描述js中的模块机制。

简单的模块

有了闭包的知识的话,下面的模块代码相信能很快看懂。

 
1
 
1

单例模式

将上面代码改变一下,可以实现单例模式:

 
1
 
1

现代模块机制

现在实现的模块通常需要一个模块管理器,其一般实现如下:

 
1
 
1

这个模块管理器的实现中,deps[i] = modules[deps[i]];语句根据目标定义模块所需要的依赖从modules中查询,而modules[name] = impl.apply(impl, deps);语句则将目标依赖注入到定义模块中。

通过上面的模块管理器,可以轻松创建模块,管理模块之间的依赖。

 
1
 
1