块级作用域中的函数声明

28 阅读4分钟

今天来讨论一道比较八股的问题,看下面这段代码:

function a() {}
{
  a = 1;
  console.log(a);
  a = 2;
  console.log(a);
  function a() {}
  a = 3;
  console.log(a);
}
console.log(a);

输出结果是:

1 2 3 2

看起来非常诡异的结果。前面三个打印结果按照常规理解没问题,分别输出 1、2、3,但是在块级作用域以外的打印不是函数也不是 3,而是 2。

如果按照 ES5 的逻辑,函数声明会被提升到作用域顶部,a 应该一直是一个函数或者被覆盖;如果按照纯粹 ES6 的块级作用域逻辑,块内的 a 应该完全隔离,外面的 a 应该还是最初的函数。

为什么外面的 a 变成了 2,而且是在块内部执行中间过程的某个值?

核心原因:ES6 的浏览器兼容性(Annex B)

这一切的“罪魁祸首”在于 ES6 规范为了兼容老旧的 Web 页面,制定了一套特殊的规则。在 ES6 之前,块级作用域中声明函数是非法的(虽然浏览器大多支持,但表现不一)。ES6 引入了块级作用域,为了既能支持新特性,又不破坏老代码,在非严格模式下,浏览器对于块级作用域中的函数声明(Function Declaration in Block)采取了非常特殊的处理行为。

我们可以将这套行为总结为三条潜规则:

  1. 块内提升:函数声明会被提升到块级作用域的顶部。这使得块内可以像访问 let 一样访问这个函数。
  2. 全局声明:函数声明也会像 var 一样,在全局作用域(或所在的函数作用域)中被声明(变量提升),初始值为 undefined
  3. 同步映射(Magic Line):这是最关键的一点。当代码执行流经过函数声明的那一行代码时,会将当前块级作用域中那个变量的值,赋值给全局作用域中同名的变量。

基于上述三条规则,我们可以将最初的代码“翻译”成 JS 引擎实际执行的逻辑(伪代码):

// 1. 全局作用域
var a = function() {}; // 最初的定义

{
  // 2. 块级作用域,函数 a 被提升到块顶部
  // 注意:这里用 let 模拟块级作用域的行为
  let local_a = function() {}; 

  // 开始执行代码
  local_a = 1;
  console.log(local_a); // 输出 1

  local_a = 2;
  console.log(local_a); // 输出 2

  // 3. 【关键时刻】遇到 function a() {} 声明行
  // 触发潜规则:将当前块内变量 local_a 的值同步给全局变量 a
  a = local_a; // 此时 local_a 是 2,所以全局的 a 变成了 2

  local_a = 3;
  console.log(local_a); // 输出 3
}

// 4. 块级作用域结束
console.log(a); // 输出全局的 a,在第3步中被同步成了 2

逐步解析

  1. 块级提升:进入 {} 块时,function a() {} 导致在块内部创建了一个局部变量 a
  2. 赋值 1 和 2a = 1a = 2 修改的都是这个块内部的局部变量 a。所以前两个 log 输出 1 和 2。
  3. 同步时刻:当代码运行到 function a() {} 这一行时,浏览器会将此时块内 a 的值(也就是 2)同步给外部作用域的 a
    • 为什么是 2? 因为在这一行之前,块内的 a 已经被赋值为 2 了。
    • 为什么不影响后面的 3? 因为同步只发生在声明的那一行,后续的 a = 3 只是修改块内的局部变量,不再影响外部。
  4. 赋值 3a = 3 修改块内局部变量,输出 3。
  5. 外部打印:离开块级作用域,访问全局 a,它的值停留在了同步那一刻的 2

严格模式下的表现

需要注意的是,上述怪异行为仅存在于非严格模式。如果你在代码顶部加上 'use strict';,情况会完全不同。

'use strict';
function a() {}
{
  a = 1; // ReferenceError: a is not defined 
         // 或者直接报错,因为严格模式下 function a() {} 在块内是块级作用域
         // 且不会提升到让上面的 a=1 访问到(类似暂时性死区或未定义行为,取决于具体实现)
  function a() {}
}

在严格模式下,块级作用域内的函数声明就纯粹是块级作用域,遵循标准的 let/const 语义,不再有修改全局变量的副作用。

总结与启示

这道题虽然看似偏门,但它揭示了 JavaScript 在演进过程中为了背负“历史包袱”所做的妥协。了解这个原理,不仅仅是为了应付面试,更是为了在遇到老旧代码库或者诡异 bug 时,能够快速定位问题所在。

最佳实践:

  1. 永远不要在块级作用域(if, for, {})中进行函数声明(Function Declaration)。
  2. 如果需要在块中使用函数,请使用函数表达式并赋值给 letconst
// 推荐写法
{
  const myFunc = function() { ... };
}