JavaScript:从编译原理开始,搞明白到底啥是作用域和闭包?

83 阅读11分钟

作用域

词法作用域

程序的执行过程可以分为解释和编译两种类型,解释型是把源代码一行一行地转换成引擎执行指令,转换一行运行一行;那编译型呢?编译型是把所有的源代码进行预处理然后全部转换成机器可理解的指令然后再逐条执行。那么javascript的语言是属于解释型语言还是编译型呢?其实是属于编译型的,在经典编译理论中,编译器编译一个程序大概分为以下三个步骤:

  1. 符号化 Tokenizing/Lexing:将一串字符分成有意义的最小单位——token。
  2. 解析 Parsing:将一连串的tokens变成由嵌套元素组成的树,这个树代表了程序的语法结构,叫做抽象语义树Abstract Syntax Tree (AST)。
  3. 代码生成 Code Generation:把AST转换成可执行的代码。

那么我们就可以知道js的词法作用域Lexical Scope就是在编译的第一个步骤,语义符号化生成的。如果在一个函数中声明了一个变量,那么编译器会在解析函数时处理这个变量,然后这个变量就会跟这个函数的作用域相关联,所以词法作用域就是注册了所有在解析当前函数时声明的变量的词法环境。如果声明的变量没有在当前作用域被找到,那么就会继续查询上一级的作用域,直到全局作用域为止。也就是说当前函数可以访问上级outer环境的变量,但是不能访问下级inner函数的变量。这也是理解闭包的一个关键点。

作用域链

因为当前作用域可以访问上一级的作用域,所以作用域和嵌套作用域之间的连接就被称为作用域链。作用域链决定了变量可以被访问的路径,并且作用域链是单向的,这意味着查找过程只能是从内向外的。

在查找过程中,从内至外匹配到第一个声明变量时,就返回这个变量的值,所以当一个作用域链中声明变量的标识符重复时,会优先匹配最里层的,而忽略当前作用域层向外的所有变量,这个现象在词法作用域中就称为变量覆盖Shadowing

变量的查询过程其实只是一个概念性的理解,实际上并不是在运行时进行的。因为在程序语义化的时候,词法作用域和作用域链就已经生成了,而且是不变的,他们被存储在AST的每一个变量条目中,所以并不需要去海量的词法作用域中查找变量来自于哪个作用域,因为这些信息是已知的,每个变量最多只需要查重一次。避免在运行时重复查找是词法作用域的一个很关键的优化优势。

变量提升

由上可知,标识符会在所属作用域的一开始就被创建,即使变量的声明语句出现在作用域靠后的部分,这种变量就叫做变量提升hoisting

虽然hoisting翻译为变量提升,但其实他的本意指包括提升,其中也包括函数提升Function hoisting,指针对于用function关键字开头的正式声明,而非函数表达式的赋值。用关键字function声明的函数标识符将会被提升在作用域的最上面,初始化为一个引用;用关键字var声明的变量标识符将会被提升到作用域的最上面,初始化为undefined以为后续的赋值或者索引。

function tobeHoisted(){
}

var notTobeHoisted = function notTobeHoisted(){
}

那么变量可以被重复声明吗?因为变量提升的缘故,所以所有的变量声明语句都会被提升到作用域的最上面,当声明已存在的变量,编译器会忽略后面是声明,但是如果不光是声明了并且赋值了的话,当运行到重复的赋值时,会覆盖之前的值。但是以上操作只针对于使用var声明的变量,因为使用let或是const关键字的声明是不会提升变量的,所以会抛出syntax error。

暂时性死区 Temporal Dead Zone

上文提到使用let或是const关键字的声明的变量是不会提升的,其实这个表述并不准确。事实上,用let或是const关键字的声明的变量也会在作用域的最顶端注册,只不过在注册时并没有被初始化直到执行到声明语句时才会被初始化,而当变量没有被初始化的时候是不能被使用或者直接赋值的,所以我们通常说let``const是不会变量提升的。

从进入作用域,到变量初始化的这段时间窗口称之为暂时性死区。其实变量是存在的,只不过没有被初始化,不能访问,一旦运行到原始声明的语句,那么暂时性死区结束,变量可以在剩下的作用域自由地被使用。其实使用var声明的变量也有暂时性死区,只不过长度为零,所以很难察觉。

最小曝光原则 POLE

那么你有没有想过我们为什么要把变量放在不同的作用域里呢?软件工程创建了一个基本原则叫作最小权限原则The Principle of Least Privilege (POLP) 也叫做最少曝光Least Exposure (POLE),以应用于软件安全。POLP表达了一种防御姿态:系统的组成部分应当设计成具有最少的特权、最少的权限、最少的曝光。如果每一个部件都以最低限度的必要连接,那么从安全角度来说,整体系统会变得更加强大,因为局部的失败对于整体的影响也就越小。

那么对于js语言来说,运用POLE的方式就是在各个作用域内注册所需变量,那么如果没有使用这种方式的话,都会有哪些危害呢?

  1. 命名污染 Naming Collisions:如果在两个程序中使用一个相同的变量、函数名称,并且变量的标识符在同一个作用域内(比如全局作用域),那么就会发生命名冲突,产生一些不必要的bug。
  2. 难以预期的行为 Unexpected Behavior:如果一个变量只是针对于某个特定的程序,但是它暴露了使用,使其他程序也可以调用其变量,那么就会产生无法预料的行为或是bug。
  3. 意外地依赖 Unintended Dependency:如果当前程序的变量被别的程序使用,就有可能使其他开发者依赖了原本私有的属性,那么虽然不会破坏目前的程序,但是会对将来的代码重构造成负面的影响。

POLE应用于作用域中,其实就是说要以最低的限度暴露变量以保持其私有性,在尽可能小并且深度的嵌套作用域中声明变量,而不是把所有变量都声明在全局作用域中。

立即执行函数 IIFE

现在我们已经知道了隐藏变量的重要性,但都是对于可以提升的varfunction声明如何隐藏呢?我们可以在声明函数周围包裹住整个函数,那么立即执行函数Immediately Invoked Function Expression (IIFE)就应运而生了。在函数表达式的结尾加上()以表示在声明此函数时立即调用。所以当我们想创建一个作用域隐藏变量或者函数,IIFE就很有用,因为它本身是一个表达式,所以在任何可以用表达式的地方都可以使用,而且它还可以是匿名的,示例如下:

// outer scope

(function(){
    // inner hidden scope
})();

// more outer scope

块级作用域

通常情况下,任何花括号对{}(curly-brace pair)都可以表明语句可以作为一个块(block)而不一定是作用域。只有一种情况下块(block)可以当作作用域就是花括号内包含块级作用域的声明,例如let const。其实这也不难理解,因为我们上文提到了变量提升的原理,所以如果只是使用花括号的话,var function是可以穿透的,包括if for的花括号也可以,因为变量会在当前的函数作用域/词法作用域的最上面初始化。

但是并不是所有花括号都可以创建一个块(block):

  • Object字面量的括号,是用来限制键值对列表的,所以不是作用域。
  • class使用花括号来限定定义主体,所以既不是块,也不是作用域。
  • function使用花括号定义函数主体,这是一个作用域。
  • switch语句使用花括号囊括所有的子集case,它不是块或作用域。

一般情况下,提到作用域,其实就是词法作用域,是以函数的主体来界定的,所以也称为函数作用域;块级作用域的使用场景,主要是针对let声明的。

闭包 Closure

在理解了作用域的一系列操作以后,那么我们终于可以来理解闭包是什么了。首先我们来看下官方的定义,闭包是一个函数以及其捆绑的词法作用域。闭包建立的方法是,对于我们需要多次使用的变量,与其把它放在更外面(暴露更多的)作用域,不如把它封装起来并且保留内部函数的访问。函数通过闭包来记住其引用的作用域。所以闭包其实是与一个函数实例相关联,而并不是一个单一的词法定义。

那么我们现在可以从观察的角度给闭包下一个定义:一个函数可以使用外部作用域的变量,即使这些变量没有在可被访问的作用域中运行。听着好像有点绕口,但是我们可以拆分下这个定义的三个主要组成部分:

  1. 必须有函数参与
  2. 必须引用至少一个变量是来自外部作用域
  3. 不是在被引用变量的作用域链中被调用的

或者我们还可以从执行层面再来理解一次:闭包是一个函数实例,它的作用域被原地保留以至于任何被引用的变量都可以被传入,即使该函数是从别的作用域中被调用。而这个闭包定义的基础在于,函数是一等公民(first class values)。意思就是说函数可以作为函数的参数、返回的值,赋值给变量或者存储在数据结构中,就像其他任何变量一样。所以说闭包就是通过创建一个函数实例来封装在运行时所需要的变量以避免需要时再次提供,这种设计模式不光使代码更简洁,而且也符合上文所提到的最小暴露原则POLE。

尽管闭包有很多好处,但是关于垃圾回收的问题也值得注意。如果一个变量在十个闭包中被引用,然后九个函数被废弃,剩下的一个函数引用也会保留此变量。然后最后一个函数引用被废弃后,闭包中的变量引用也会被垃圾回收。这很大程度上影响了运行的效率和性能,闭包会无意地阻止一个变量被回收,久而久之,会对内存造成很大的压力。这就是为什么当闭包不再使用变量引用时,及时释放内存是很重要的事情。

现在的js引擎有一种闭包垃圾回收优化的机制,就是当闭包中的变量没有被明确引用的时候,会将其回收,但是事实上这种优化并没有被很多引擎使用,所以闭包中的变量其实会比预想的要存在的更久。所以说与其期待引擎优化垃圾回收,不如在不再使用持有大数值(比如数列、对象)的变量时,手动回收垃圾释放内存。

所以现在你可以理解闭包了吗?

Reference: github.com/getify/You-… github.com/getify/You-… github.com/getify/You-… github.com/getify/You-… github.com/getify/You-… github.com/getify/You-…