「JS」作用域和闭包

190 阅读13分钟

image.png

1 作用域是什么

作用域是根据名称查找变量的一套规则。

1.1 编译原理

JavaScript 不是提前编译的,编译结果不能在分布式系统中进行移植。

传统的编译过程分为三步,统称为编译:

  1. 词法分析,这一步将字符串分解成词法单元。
  2. 语法分析,这一步将词法单元流(数组)转化为抽象语法树(AST)。
  3. 代码生成,这一步将 AST 转化为可执行代码,与语言、目标平台等息息相关。

比起传统的编译器,JavaScript 引擎要更为复杂。JavaScript 代码片段就在执行前进行编译。

1.2 理解作用域

1.2.1 参加程序处理的角色

参加程序处理的角色如下:

  1. 引擎,从头到尾负责 JavaScript 程序的编译执行
  2. 编译器,负责词法分析、语法分析和代码生成等工作。
  3. 作用域,负责搜集和维护变量的查询,通过规则确定代码对变量的访问权限。

1.2.2 处理过程

对于 var a = 2; 这个语句,编译器首先会进行词法分析和语法分析。代码生成阶段,编译器会进行如下处理:

image.png

1.2.3 LHS 和 RHS

编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。

查找的方法分为 LHS 和 RHS:

  1. LHS,查找变量的容器本身,从而对它赋值。如 a = 2; 中,要对变量 a 赋值首先要找到 a 这个变量的容器本身,对 a 的引用称为 LHS 引用。
  2. RHS,查找变量的值,如 console.log(a); 中,要输出 a 的值首先要找到 a 这个变量的值,对 a 的引用称为 RHS 引用。

对于下面这段程序,有多处使用到了 LHS 和 RHS:

function foo(a) {
 console.log( a ); // 2
}
foo( 2 );
  1. 第 4 行对 foo(...) 的调用需要找到 foo 函数,进行 foo 的 RHS 引用
  2. 第 1 行 foo(...) 函数的参数传递过程需要找到 a 容器,相当于 a = 2,进行了 a 的 LHS 引用
  3. 第 2 行 console.log(a) 对 a 值的引用需要找到 a 的值,进行了 RHS 引用
  4. 第 2 行的 console.log(a) 还进行了一次引用,那就是找到 console 对象的 log 方法,也就是找到 console 的值,这里进行了 RHS 引用
  5. 之后将 a 的值 2 传递给 log(...) 实现中它可以接收参数,假设为 arg1,这里要找到 arg1 容器,相当于 arg1 = 2,进行了 arg1 的 LHS 引用

1.3 作用域嵌套

一开始我们说过,作用域是根据名称查找变量的一套规则。

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

1.4 异常

LHS 和 RHS 的查询行为是不一样的,报出的异常也不同。

image.png

2 词法作用域

2.1 词法阶段

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

image.png

  1. 全局作用域中包含的变量有 foo
  2. foo 所创建的作用域包含的变量有 a,b,bar
  3. bar 所创建的作用域包含的变量有 c

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。

作用域查找会在找到第一个匹配的标识符时停止,这就是遮蔽效应

2.2 欺骗词法

词法作用域由写代码期间函数所声明的位置来定义,在运行时,我们也可以修改词法作用域。

2.2.1 eval

JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。eval 函数会带来一些问题,不要使用 eval

function foo(str, a) {
 eval( str ); // 欺骗!
 console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

2.2.2 with

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

function foo(obj) {
 with (obj) {
 a = 2;
 }
}
var o1 = {
 a: 3
};
var o2 = {
 b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域

使用 with 会带来一些混淆错误和兼容性问题,应该避免使用 with

2.2.3 性能

eval(..) 和 with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

JavaScript 引擎在编译阶段进行的性能优化是基于代码的词法进行静态分析上的,能够预先确定变量和函数的定义位置,在执行时快速找到标识符。如果使用了 eval(...) 或 with,这些优化将是无意义的,因此会完全不做任何优化,代码会运行得更慢。

3 函数作用域和块作用域

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

3.1 函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。

3.2 隐藏内部实现

从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。

隐藏内部实现是遵循了软件设计中的最小暴露原则。例如:

function doSomething(a) {
 function doSomethingElse(a) {
 return a - 1;
 }
 var b;
 b = a + doSomethingElse( a * 2 );
 console.log( b * 3 );
}
doSomething( 2 ); // 15

在这个例子中,全局作用域无法访问到 doSomethingElse(...) 和 b 这些函数和变量,也就是将具体的内容私有化了。

3.3 函数作用域

3.3.1 匿名和具名

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

这叫作匿名函数表达式,因为 function().. 没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名。

如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。始终给函数表达式命名是一个最佳实践:

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
 console.log( "I waited 1 second!" );
}, 1000 );

3.3.2 立即执行函数表达式

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

(function foo(){ .. })()。第一个 ( ) 将函数变成表达式,第二个 ( ) 执行了这个函数。还可以写成 (function foo(){}()),二者功能一致。

3.4 块作用域

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

这里的变量 i 会污染整个函数作用域。

3.4.1 with

用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

3.4.2 try/catch

try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
 undefined(); // 执行一个非法操作来强制制造一个异常
} 
catch (err) {
 console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found

3.4.3 let

ES6 引入了 let 关键字,它可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。

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

相当于:

{
 let j;
 for (j=0; j<10; j++) {
   let i = j; // 每个迭代重新绑定!
   console.log( i );
 }
}

3.4.4 const

ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

4 提升

4.1 先有声明后有赋值

可以看到这样的现象:

a = 2;
var a;
console.log( a ); // 2
console.log( a ); // ReferenceError
var a = 2;

4.2 所有声明在执行前处理

第 1 章说到,引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。第 2 章中展示了这个机制,也正是词法作用域的核心内容。

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

当你看到 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;
}

// 等同于
function foo() {
 var a;
 console.log( a ); // undefined
 a = 2; 
}
foo();
foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
 // ...
};

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

另外,具名函数表达式在赋值前后也无法在作用域中使用。

var foo = function bar (){
    console.log(123)
}
bar(); // ReferenceError

4.3 函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量

foo(); // 1
var foo; // 变量 foo 与函数 foo() 重复声明,由于函数优先,这一句被忽略
function foo() {
 console.log( 1 );
}
foo = function() {
 console.log( 2 );
};

// 等同于
function foo() {
 console.log( 1 );
}
foo(); // 1
foo = function() {
 console.log( 2 );
};

另外,出现在后面的函数声明还是可以覆盖前面的。

由于上述函数提升的特性,我们需要尽量避免在块内部声明函数。

5 作用域闭包

5.1 什么是闭包

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

function foo() {
 var a = 2;
 function bar() { 
 console.log( a );
 }
 return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

这里对 baz() 的引用实际上是对 foo() 内部函数 bar() 的引用。

通常,foo() 执行后内部作用域会被 JS 引擎的垃圾回收器销毁。闭包产生时,foo() 中的作用域依然存在(bar() 在使用),因此不会被回收。

bar() 持有对 foo() 作用域的引用,这个引用叫做闭包

无论通过何种手段(作为当前函数的返回值、作为另一个函数的实参、赋值给作用域外的变量等)将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

5.2 使用闭包

function wait(message) {
 setTimeout( function timer() {
   console.log( message );
 }, 1000 );
}
wait( "Hello, closure!" );

wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..) 作用域的闭包。因为函数很快就会执行结束,所以 setTimeout 函数设置了 1000ms 之后执行,也就是 wait 函数结束后,timer 函数依然能执行,这就是闭包。

在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

5.3 循环和闭包

一个常见的例子:

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

这个例子中,for 循环结束后,setTimeout 中的回调函数才开始执行。也就是说 timer 执行的时刻 i = 6,所以最终会输出五个 6。

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

我们可以使用以下两种方法解决这个缺陷:

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

每个立即执行函数都会生成一个新的作用域,使得延迟函数的回调可以将新的 作用域封闭在每个迭代内部。

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

之前说过,使用 let 声明 for 的头部会在每次迭代中声明一个变量保存当前迭代的值。

5.4 模块

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() 来创建一个模块实例,它是一个对象,这个对象不包含变量的引用,而是函数的引用,本质上是模块的公共 API。通过它来访问 API 中的属性方法,如 foo.doSomething()

模块模式需要两个必要条件:必须有外部封闭的函数;封闭函数返回至少一个内部函数。

也就是说,一个函数调用所返回的具有函数属性的对象是真正的模块

A 动态作用域

作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。

词法作用域最重要的特征是它的定义过程发生在代码的书写阶段。而动态作用域的作用域链是基于调用栈的,而不是代码中的作用域嵌套。JavaScript 并不具有动态作用域。

同样一段代码,基于词法作用域和动态作用域具有不同的输出结果:

function foo() {
 console.log( a ); // 2
}
function bar() {
 var a = 3;
 foo();
}
var a = 2;
bar();
function foo() {
 console.log( a ); // 3(不是 2 !)
}
function bar() {
 var a = 3;
 foo();
}
var a = 2;
bar();

image-20220215184513149.png