1.2 变量提升与暂时性死区


1.2 变量提升与暂时性死区:登记表决定一切

本节摘要:变量提升与暂时性死区不是两条独立规则,而是同一个动作的两个结果——引擎在执行代码前先创建执行上下文、登记所有声明。var 与函数声明被登记成「可直接使用」,let、const 被登记成「已占位但未初始化」。本节用登记表这一张图解释全部现象,覆盖 var、let、const、函数声明、函数表达式的差异,以及参数默认值作用域的进阶案例。

从一个报错说起

先看三段只差一个关键字的可复现实验:

// 实验一:var console.log(a); // undefined var a = 1;
// 实验二:let console.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 1;
// 实验三:根本没声明 console.log(c); // ReferenceError: c is not defined

三个报错各不相同:实验一不报错,打印 undefined;实验二报「初始化之前无法访问」;实验三报「未定义」。注意实验二和实验三的报错文案不一样,这不是引擎故弄玄虚——实验二里 b 确实被登记过(引擎知道它存在,只是还没初始化),实验三里 c 在登记表上查无此人。报错文案本身就是引擎内部状态的外泄:能不能找到登记项、登记项有没有值,是两件事。

一、登记表:创建阶段的现场

引擎在执行任何代码之前,先做一件安静的事:通读当前作用域的全部声明,把它们登记到「词法环境」这张表上。这个动作分两个阶段:

创建阶段:登记所有 var 变量(初始值 undefined)、所有函数声明(完整的函数对象);let 和 const 也在此时占位,但被标记为「未初始化」,不挂在可访问名下。

执行阶段:逐行执行。执行到 let b = 1 这行时,引擎把 b 从「未初始化」翻转为「已绑定」,从此可访问。

从「块开始」到「let 声明被执行」之间的这段区域,就是教材所说的暂时性死区(TDZ)。所谓死区,就是「登记表上有名字、值还没就位」的那段执行区间。

图 1.2-1 同一段代码在两个阶段的登记表对照

图 1.2-1 同一段代码在两个阶段的登记表对照

这张表能直接推导出一些容易被忽略的行为。expr 登记的是 var 名字(undefined),赋值要等执行到那一行,所以把它当函数提前调用会得到另一类报错:

expr(); // TypeError: expr is not a function var expr = function () { };

不是 ReferenceError(名字在表上),而是 TypeError(值还是 undefined,却被当函数调用)。函数声明与函数表达式的全部差异,用登记表看一目了然:前者整体上表,后者只上名字。

二、死区边界与常见误伤

暂时性死区经常在三处咬人。

第一处:typeof 不再安全。 老经验说「用 typeof 判断变量是否存在不会抛错」,这对未声明的变量成立,对死区变量失效:

console.log(typeof x); // "undefined"(x 未声明,安全) console.log(typeof y); // ReferenceError(y 在死区) let y = 2;

第二处:同一作用域内同名遮蔽。

let value = 'outer'; function read() { console.log(value); // ReferenceError let value = 'inner'; // 这个声明让函数内的 value 全程处于死区 } read();

引擎在函数的登记表里发现了 value,就不会再去外层找——内层登记遮蔽了外层同名,而内层值未就位,直接进死区报错。把内层声明删掉,则正常打印 outer。很多「我明明外面定义了」的困惑源于此。

第三处:参数默认值形成独立作用域。

function bad(a = b, b = 2) { return a + b; } bad(); // ReferenceError: Cannot access 'b' before initialization

参数默认值表达式运行在一个专门的作用域里,排在参数表自身的登记之后。a = b 求值时 b 尚未初始化,触发死区。把参数顺序对调(b = 2 在前)就能跑通。这是面试和代码评审都容易漏掉的暗礁。

⚠️ 常见坑:循环里的 var ilet i 行为差异同样是登记位置差异——var 把 i 登记到函数作用域,循环结束后仍可访问且值为跳出值;let 每轮迭代登记一个新的 i。回调里打印循环变量的经典问题(三个都打印 3,还是打印 0、1、2)的答案,取决于用哪个关键字声明。

三、用登记表演练三道综合题

题一:混合声明的一次性输出。

var v = 1; function f() { console.log(v); // undefined(不是 1!) var v = 2; } f();

函数创建自己的登记表时发现了局部 var v,遮蔽外层同名。执行第一行打印时局部 v 尚为 undefined。若删掉函数内 var v = 2,则打印 1。

题二:函数声明的覆盖顺序。

fn(); // "second" function fn() { console.log('first'); } function fn() { console.log('second'); }

创建阶段逐条登记函数声明,后登记的覆盖先登记的,所以真正挂到表上的是第二个。调用输出 second。若第二个改成函数表达式(var fn = ...),则输出 first——表达式不上函数表,只登记 var 名。

题三:块级作用域的条件分支。

console.log(flag); // undefined(var,登记到函数/全局表) if (true) { console.log(inner); // 死区?不——此时尚未进入块 let inner = 1; } var flag = true;

块级登记发生在「执行进入该块」的瞬间,而不是整个脚本创建时。所以 if 体内第一行访问 inner 才处于死区;块外根本看不见 inner。这解释了为什么「块级死区」只影响块内代码。

💡 关键直觉:把「提升」这个词从脑子里删掉,换成「先登记、后执行」。任何看似诡异的声明行为,都可以用「创建阶段登记了什么、执行阶段值何时到岗」两问推导出来,不需要背任何特例。

本节要点回顾

  • 登记先行:执行前,引擎通读作用域并登记全部声明;var 与函数声明直接可用,let 与 const 占位待初始化;
  • 报错可反推状态:not defined 说明查无此人,before initialization 说明在死区,not a function 说明值未就位却被调用;
  • 函数声明整体上表,函数表达式只上名字,这是两者行为差异的全部来源;
  • typeof 在死区会抛错,参数默认值有独立作用域且按参数顺序求值;
  • 块级登记发生在进入块时,同名内层登记会遮蔽外层并制造死区。

综合演练:预读一段真实代码

拿一段接近真实项目的代码,练习「只看登记表,不运行就推演输出」:

var status = '全局状态'; function render() { console.log(status, typeof refresh); var status = '局部状态'; function refresh() { return '刷新'; } if (true) { console.log(inner); let inner = '块内值'; } console.log(part); var part = () => '部分'; } render();

逐行推演。render 被调用,进入创建阶段:登记 var status(undefined)、函数声明 refresh(完整对象)、var part(undefined)。执行第一行打印:status 是局部登记的 undefined(不是「全局状态」——内层登记遮蔽外层),typeof refresh 是 function(函数声明已整体上表)。进入 if 块,块级登记 let inner,立刻访问——ReferenceError,报错文案是「before initialization」,证明 inner 在表上但未就位。假设把这行注释掉继续:块结束时 inner 随块的环境一同消亡;后面打印 part 得 undefined(var 只登记名字)。实测对照:

undefined function Uncaught ReferenceError: Cannot access 'inner' before initialization

这个演练的可复制价值在于方法本身:任何声明相关的怪现象,先默画登记表(谁登记了、登记成什么、值何时到岗),再读执行顺序。把这段代码改成自己项目里的形状再推一次,方法就长在手上了。

常见问答

var 是不是就该从代码里禁绝?
新代码默认 let 与 const 没有争议(const 优先,需要重赋值再降级 let)。但存量代码里的 var 不必为改而改——行为差异点(循环捕获、重复声明、函数作用域)都有测试覆盖的话,改动收益低于回归风险。真正要禁的是「var 与 let 混用于同一作用域」造成的阅读混乱。

为什么函数声明能整体提升,函数表达式不行?
设计动机是让「先调用、后定义」的组织方式可行——脚本时代的代码习惯把入口写在顶部。引擎实现上,函数声明在语法分析阶段就被完整登记(连带函数体),而表达式只是个赋值语句,赋值必须等执行。想保留「先调用后定义」的布局又不吃提升的亏,现代写法是把函数声明集中放作用域顶部,或全部改用表达式加箭头函数,顺序交给模块结构表达。

let 的暂时性死区和 import 的静态检查是一回事吗?
机制同源、时机不同。模块顶层的 import 绑定在模块实例化阶段就建立,访问未完成求值的导出同样触发 TDZ 报错(第 7 章循环依赖的现场)。可以把 import 理解成「跨模块的 let」——占位先行、赋值在求值阶段到位,这个统一视角能把两处「看似无关」的报错串成一条线。

登记表演练题两道

题一:混合声明 + 条件分支。 不运行,推演输出:

var tag = 'A'; function pick() { console.log(tag); if (false) { var tag = 'B'; } console.log(tag); } pick();

推演:pick 的登记表里有 var tag(undefined)——if 里的 var 也登记(块对 var 不设防,var 只认函数作用域)。两行打印都是 undefined:第一行是「登记了但没赋值」,第二行同样。删除 if 内那行则两行都打印 A。这题的考点是「var 的登记无视块级结构」,也是为什么建议新代码杜绝 var。

题二:函数声明与表达式的组合覆盖。

run(); // 第一次调用 var run = function () { console.log('表达式版'); }; function run() { console.log('声明版'); } run(); // 第二次调用

推演:创建阶段先登记 var run(undefined),再登记函数声明 run(声明版整体上表,覆盖 undefined);执行到第一行,调用的是声明版;随后执行赋值语句,run 被替换成表达式版;第二次调用输出表达式版。实测输出「声明版、表达式版」。这题说明「同名时函数声明在登记阶段赢,赋值语句在执行阶段赢」——两条时间线的先后决胜。

两道题练完,建议再自造一题丢给同事:混合 let、const、函数声明、参数默认值的六行代码,登记表推演对了,这个专题就可以翻篇了。

变量声明的团队风格规约

把登记表知识落成可执行的团队规约,四条即可覆盖绝大多数争议:

第一条:const 为默认,let 为例外,var 禁用。 const 语义最诚实(「这个绑定不变」),需要重赋值时才降级 let。var 的两个特性(函数作用域、提升)在团队协作里只制造意外,没有换取任何必要能力。旧文件里的 var 不主动改,新文件里的 var 在评审打回。

第二条:声明写到使用处附近。 登记表机制决定了「声明位置不影响可见性起点」,但人类读代码靠位置。把声明放在首次使用的上一行,既符合直觉,也让暂时性死区几乎不可能被踩到——因为声明在使用之前就执行了。

第三条:函数声明放作用域顶部,函数表达式随用随定义。 函数声明的「可提前调用」是它的能力,把它放顶部让读者第一眼看到全部能力清单;箭头函数与表达式跟着逻辑走,顺序即依赖。两种风格并存时,团队要约定「顶层函数声明、内联用箭头」的分工,避免同名混用的覆盖陷阱(本节登记表演练题二)。

第四条:循环变量一律 let。 这一条单独列出,因为它是 var 时代遗留事故的最高发点(第 2.3 节的循环捕获专题会展开完整机制)。存量代码里发现 for 加 var 配回调的,直接标记为「待修的隐患」而不是「可接受的老写法」。

四条规约的共同底层逻辑:让代码的视觉顺序与引擎的执行顺序尽量重合。登记表机制允许两者分离(这正是许多「怪癖」的来源),而好的风格就是主动放弃这种自由——语法上能写,不代表应该写。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U