作用域和闭包

作用域

设计良好的规则来存储变量,并且之后可以方便找到这些变量,这套规则被称为作用域

传统编译语言流程

  1. 分词/词法分析

    将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)

  2. 解析/语法分析

    将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”

  3. 代码生成

    将AST转换未可执行代码的过程

三个部分

  • 引擎
    • 从头到尾负责整个JavaScript程序的编译及执行过程
  • 编译器
    • 负责语法分析及代码生成
  • 作用域
    • 负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

例子:var a = 2

  • 一个由编译器在编译时处理
    • 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
  • 另一个则由引擎在运行时处理
    • 遇到a = 2,引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量

两种查询

  • LHS查询
    • 如果查找的目的是对变量进行赋值,那么就会使用LHS查询
    • 赋值操作的目标是谁
  • RHS查询
    • 如果目的是获取变量的值,就会使用RHS查询
    • 谁是赋值操作的源头

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止

遍历嵌套作用域链规则

引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止

词法作用域

简单地说,词法作用域就是定义在词法阶段的作用域。词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段,因此当词法分析器处理代码时会保持作用域不变

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”,但欺骗词法作用域会导致性能下降。

  • 有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符
  • 编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

动态作用域

  • eval
  • with

词法作用域和动态作用域主要区别

  • 词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用

函数作用域和块作用域

作用域包含了一系列的“气泡”,每一个都可以作为容器,其中包含了标识符(变量、函数)的定义。这些气泡互相嵌套并且整齐地排列成蜂窝型,排列的结构是在写代码时定义的

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用

任何声明在某个作用域内的变量,都将附属于这个作用域

隐藏内部实现

从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际的结果就是在这个代码片段的周围创建了一个作用域气泡,这段代码中的任何声明都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。

好处

  • 最小授权或最小暴露原则
  • 避免同名标识符之间的冲突

缺点

  • 函数名污染了所在作用域
  • 不能够自动运行

改良

  • 使用立即执行函数表达式IIFE
    • 进阶用法:把它们当作函数调用并传递参数进去
    • 还有一种用途:倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去

匿名和具名

  • 函数表达式可以是匿名的, 而函数声明则不可以省略函数名
  • 匿名函数在栈追踪中不会显示出有意义的函数名
  • 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名

块作用域

  • with
  • try/catch
  • let
    • 可以将变量绑定到所在的任意作用域中

提升

包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

  • var变量声明,函数声明都会有提升
  • 重复声明时,函数声明会覆盖普通变量声明,并且重复的var声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

例子:

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

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包

只要使用回调函数,实际上就是在使用闭包

循环和闭包

经典例子:

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

输出结果是五次6

我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

解决方法:

法一

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

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

法二

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

for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量

模块

两个必要条件

  • 必须有外部的封闭函数,该函数必须至少被调用一次
  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样