执行上下文&&作用域&&闭包

2021-11-13 JavaScript

ES6关于执行上下文出了新规范,此文仅为学习总结,不对具体规范进行阐述。

尽管通常将 JavaScript 归类于解释执行语言,但事实上它是一门编译语言,但大部分情况下编译发生在代码执行前的几微秒甚至更短的时间内。在作用域背后,JavaScript 引擎用尽了各种办法(如 JIT ,可以延迟编译甚至实施重编译)来保证性能最佳。

# 执行上下文与作用域🥝

4种情况会创建新的执行上下文:

  1. 进入全局代码
  2. 进入 function 函数体代码
  3. 进入 eval 函数参数指定的代码(不建议)
  4. 进入 module 代码

执行上下文分为创建阶段执行阶段,以函数执行上下文为例:

  1. 当调用函数时,在还没执行里面代码前,即创建阶段,执行上下文会创建作用域链变量对象和决定this值
  2. 进入执行阶段,执行函数中每一行代码,会进行变量赋值、函数引用等动作

几点说明:

  • 变量对象包括函数中的参数、变量和函数声明
  • 作用域是根据名称查找变量的一套规则。当一个块或函数嵌套在另一个块或函数中,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。这样由多个作用域的变量对象构成的链表就叫做作用域链。
  • 作用域链中包含当前变量对象和所有父级变量对象,这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
  • 全局上下文是最外层的上下文。当代码执行流进入函数时,函数的上下文就被推到一个上下文栈上,在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
  • JavaScript是单线程的,JavaScript程序的执行流就是通过这个上下文栈进行控制的
function fn(){
	var book = "书包里的书";
}
console.log(book);//ReferenceError
1
2
3
4

该程序执行过程:进入全局执行上下文,看到了函数声明但是没有执行函数,因此没有进入fn这个函数执行上下文,继续往下执行,此时要打印输出book,但全局环境并没有book变量,因为全局作用域已经是最顶层的作用域了,无法继续向上层查找,所以报ReferenceError。

# 词法作用域🍉

大部分标准语言编译器的第一个工作阶段叫作词法化,也叫单词化,根据源代码识别分离出一个个的单词序列(token),如 let a = 5; 会被识别为leta=5;这五个单词。如果是有状态的解析过程,还会赋予单词语义。词法作用域就是定义在词法阶段的作用域,即由你在写代码时将变量和块作用域写在哪来决定,词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而预测在引擎执行代码过程中如何对它们进行查找

但也存在一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域,但最重要的一点是欺骗词法作用域会导致性能下降

  1. eval()

    • 该函数可以接受一个字符串为参数,并将其中的内容视为好像在写代码的时候就存在于程序这个位置的代码

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

      str 中的代码实际上在 foo(..) 内部创建了一个变量 b,并遮蔽了外部作用域中的同名变量,对已经存在的 foo(..) 词法作用域进行了修改

    • eval() 通常被用来执行动态创建的代码。实际情况中,可能经常会根据程序逻辑动态的拼接字符从而导致传递的代码字符串不一样,但在程序中动态生产代码的使用场景比较少,因为它所带来的好处无法抵消性能上的损失

    • 在严格模式下,eval() 在运行时有自己的词法作用域,意味着其中的声明无法修改所在的作用域

  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 被泄漏到全局作用域上了
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      当传入 o1 时, a=2 赋值操作找到了 o1.a 并将2赋值给它,所以 o1.a 得到了修改;当传入 o2 时,o2 并没有 a 属性,因此不会创建这个属性,o2.a 保持 undefined,因为在当前作用域没有找到 a,所以找 foo(...) 的作用域,直到全局作用域中都没有找到 a,所以创建了一个全局变量 a,并赋值为2

    • with 声明实际上是根据传递给它的对象创建了一个全新的词法作用域,所以对象的属性也被处理为定义在 with 块中的标识符,即通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(块作用域的一种形式)

JavaScript 引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。如果代码中大量使用 eval() 或 with ,所有的优化可能都是无意义的,使用这其中任何一个机制都将导致代码运行变慢。

# 函数作用域🍇

在任意代码片段外部添加包装函数,可以将内部的变量和函数定义"隐藏"起来,外部作用域无法访问包装函数内的任何内容。但声明具名函数时会污染其所在作用域,且必须显式地调用这个函数才能运行其中的代码。如果函数不需要函数名且能自动运行,将会更理想,JavaScript 提供了能够同时解决这两个问题的方案。

var a = 2;
(function foo(){ // <-- 添加这一行
  var a = 3;
  console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2
1
2
3
4
5
6

在这个栗子中,由于函数被包含在一个( )内部,因此成为了一个表达式,函数会被当做函数表达式而不是一个标准的函数声明来处理。(function foo(){ .. })作为函数表达式意味着 foo 只能在 .. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

# 匿名和具名

如果 function()... 没有名称标识符,则为匿名函数表达式(而不是函数声明),因为函数声明不可以省略函数名。匿名函数表达式写起来简单快捷,但也有几个缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,是调试变得困难
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用
  3. 代码可读性差

始终给函数表达式命名是一个最佳实践。

# 立即执行函数表达式(IIFE)

在上面那个栗子中,通过在尾部加上一个另外的()可以立即执行这个函数,如(function foo(){ .. })()。相较于这种形式,很多人喜欢另一个改进的形式:(function(){ .. }()),这两种形式的功能是相同的。

(function IIFE( def ) {
	def( window );
})(function def( global ) {
  var a = 3;
  console.log( a ); // 3
  console.log( global.a ); // 2
});
1
2
3
4
5
6
7

IIFE 还有一种用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当做参数传递进去。

# 提升

JavaScript 并不完全是从上到下一行一行执行的,如下代码:

a = 2;
var a;
console.log(a);
1
2
3

结果输出的是2,如果是从上到下执行,或许输出的是 undefined 。

在编译阶段中,编译器会找出所有的声明,并用合适的作用域将他们关联起来。正确的思考思路是包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。当看到 var a = 2; 时,JavaScript 会将其看成两个声明:var a; 和 a = 2; ,第一个定义声明在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段。

所以上面的代码会以如下形式处理:

var a;
a = 2;
console.log(a);
1
2
3

这个过程就叫作提升。

  • 每个作用域都会进行提升操作,函数内声明的局部变量会被提升到函数最上方
  • 函数声明会被提升,但是函数表达式不会被提升
foo(); // 不是 ReferenceError, 而是 TypeError!
  var foo = function bar() {
  // ...
};
1
2
3
4

这段程序中,变量标识符 foo 被提升并分配给了所在作用域,因此 foo() 不会导致 ReferenceError 。但此时 foo 并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值),foo() 由于对 undefined 值进行函数调用而导致非法操作,抛出 TypeError 异常。即使是在具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:

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

//代码经提升后
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
  var bar = ...self...
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注:函数声明和变量声明都会被提升,但函数会首先被提升,然后才是变量

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

//经提升后
function foo() {	
  console.log( 1 );
}
foo(); // 1
foo = function() {
  console.log( 2 );
};
//foo的重复声明被忽略了,即便如此,后面的函数声明还是可以覆盖前面的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 作用域闭包🥦

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

function foo() {  
  var a = 2;  
  function bar() {  	
    console.log( a );  
  }  
  return bar;
}
var baz = foo();
baz(); // 2 —— 这就是闭包的效果
1
2
3
4
5
6
7
8
9

上述代码中,函数 bar() 的词法作用域能够访问 foo() 的内部作用域,bar 所引用的函数对象本身被当做了 foo() 的返回值。在 foo() 执行后,该返回值赋值给了 baz 并调用了 baz(),实际上只是通过不同的标识符引用调用了内部的函数 bar() 。

在 foo() 执行后,通常希望 foo() 的整个内部作用域全部被销毁,因为引擎有垃圾回收机制用来释放不再使用的内存空间,看起来 foo() 的内容不会再被使用,所以很自然地会考虑对其回收。但闭包阻止了这件事情的发生,事实上 foo() 的内部作用域依然存在,因为 bar() 还在使用。bar() 拥有涵盖 foo() 内部作用域的闭包,使该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包,简单来说就是在函数(如该栗子的 bar )当前词法作用域外调用该函数时,闭包使得函数还能访问定义时的词法作用域

再举个栗子:

function wait(message) {  
  setTimeout( function timer() {  	
    console.log( message );  
  }, 1000 );
}
wait( "Hello, closure!" );
1
2
3
4
5
6

将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..) 作用域 的闭包,因此还保有对变量 message 的引用。 wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..) 作用域的闭包。

在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包,因为回调函数离开了它所在的词法作用域被调用还能访问到它定义时的词法作用域中的变量对象。

# 循环和闭包

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

这个栗子相信很多人都见过,是一段“表里不一”的程序。它的行为同语义所暗示的并不一致,我们试图假设循环中的每个迭代在运行中都会给自己“捕获”一个 i 的副本,但是根据作用域工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i 。

所以我们在循环的过程中每个迭代都需要一个闭包作用域。IIFE 会通过声明并立即执行一个函数来创建作用域。可以来尝试一下:

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

运行后发现不起作用,虽然每个迭代都有自己的作用域,但该作用域中还是没有变量 i ,需要去上层作用域(即全局作用域)中寻找,所以还需要进行修改,存储每个迭代中 i 的值:

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

# 小结🥑

  1. JavaScript其实是一门编译语言,但大部分情况下编译发生在代码执行前很短的时间内,先编译后执行叫AOT,边编译边执行叫JIT,v8引擎属于JIT。
  2. 作用域链中变量查找规则为先查找当前作用域,如果无法找到就到外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层作用域(即全局作用域)。
  3. eval()和with会欺骗词法作用域,这些方法在词法分析器处理过后依然可以修改作用域,最重要的是会导致性能下降,使引擎的优化无意义,不建议使用。
  4. 匿名函数表达式写起来简单便捷,但没有函数名会使调试变得困难,始终给函数表达式命名是一个最佳实践
  5. 立即执行函数表达式能够自动运行,且函数名被隐藏在自身中意味着不会非必要地污染外部作用域,还可以倒置代码的运行顺序。
  6. 我们习惯将var a = 2;看作一个声明,但实际上JavaScript引擎会将它当成 var a; 和 a = 2; 两个单独的声明,第一个是编译阶段的任务,第二个是执行阶段的任务。
  7. 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不提升。
  8. 在函数当前词法作用域外调用该函数时,闭包使得函数还能访问定义时的词法作用域。

疑问🤪:词法作用域和作用域链是一样的东西吗,虽然他们创建时间不一样...

参考书籍:你不知道的JavaScript(上) (opens new window)