let、const 在声明前访问为什么抛 ReferenceError?时态死区 TDZ 如何理解
【免费下载链接】33-js-concepts📜 33 JavaScript concepts every developer should know.项目地址: https://gitcode.com/GitHub_Trending/33/33-js-concepts
写 JavaScript 时,下面这段代码会直接抛错:
console.log(name) // ReferenceError: Cannot access 'name' before initialization let name = "Alice"而把let换成var就只打印undefined,不报错。这个差异来自时态死区(Temporal Dead Zone,TDZ)。本文基于 33-js-concepts 仓库的 TDZ 文档 和配套的 vitest 测试文件 temporal-dead-zone.test.js,说明 TDZ 的边界、它在哪些语法里会出现,以及如何在这个仓库里实际跑起来验证每一种行为。文档声明了前置要求:需要先理解作用域与闭包(全局、函数、块级作用域的区别),可先复习 scope-and-closures。
TDZ 是"进入作用域"到"执行声明"之间的区间
按 TDZ 文档 的定义:TDZ 是从进入一个作用域开始、到let/const/class声明那一行被初始化为止的区间。在这个区间内,变量已经存在(引擎知道它),但任何访问都会抛ReferenceError。文档引用 ECMAScript 规范的说法是:let和const的绑定在其所在环境记录实例化时就被创建,但在声明被求值之前保持未初始化状态。
TDZ 的起止位置在一个块里可以这样标注:
{ // TDZ for 'x' starts here (beginning of block) console.log(x) // ReferenceError: Cannot access 'x' before initialization let x = 10 // TDZ for 'x' ends here console.log(x) // 10 (works fine) }会产生 TDZ 的声明包括:
let声明const声明class声明- 函数默认参数(特定情况下)
- 类的静态字段
为什么 var 不抛错而是打印 undefined
var和let/const一样会被提升(hoist),区别在初始化这一步:
| 方面 | var | let/const |
|---|---|---|
| 会被提升吗? | 是 | 是 |
| 提升时初始化吗? | 是,初始化为undefined | 否(保持未初始化) |
| 声明前访问的结果 | 返回undefined | 抛ReferenceError |
| 有 TDZ 吗? | 没有 | 有 |
文档给出的对照代码:
function varExample() { console.log(x) // undefined (not an error!) var x = 10 console.log(x) // 10 } function letExample() { console.log(y) // ReferenceError: Cannot access 'y' before initialization let y = 10 console.log(y) // never reaches here }Hoisting 文档 特别指出一个常见误解:很多教程说let/const"没有被提升",这是不正确的——它们确实被提升,只是在 TDZ 中保持未初始化,直到声明执行。
"Temporal" 指的是执行时间,不是代码位置
TDZ 之所以叫"时态"死区,是因为它取决于代码何时执行,而不是变量在源码中的位置。文档的例子:
{ // TDZ for 'x' starts here const getX = () => x // 定义函数,此时并未访问 x let x = 42 // TDZ 结束 console.log(getX()) // 42 - works! }getX在 TDZ 期间被定义没有问题,它在x初始化之后才被调用,所以能正常返回 42。反过来,如果在 TDZ 期间就调用它:
{ const getX = () => x // OK: just defining, not accessing getX() // ReferenceError! Calling during TDZ let x = 42 getX() // 42 - now it works }也就是说:TDZ 只在真正访问变量时才起作用,定义一个稍后会访问它的函数是完全安全的。
在仓库里运行 TDZ 测试文件来验证
上面的示例如果直接交给node执行,进程会因ReferenceError中断,所以仓库用 vitest 把它们包进断言里验证。temporal-dead-zone.test.js 覆盖了基本行为、TDZ 边界、typeof、默认参数、解构、循环、class、静态字段、遮蔽等场景,断言方式形如:
expect(() => { eval(` const value = x let x = 10 `) }).toThrow(ReferenceError)验证步骤:
- 在仓库根目录执行
npm install,安装 package.json 声明的 devDependencies(包括vitest ^4.0.16和jsdom),依赖会写入根目录的node_modules。 - 只运行 TDZ 这一个测试文件(测试匹配规则见 vitest.config.js,为
tests/**/*.test.js):
npx vitest run tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js- 成功条件是全部用例通过,其中应包含:
let/const声明前访问抛ReferenceError、声明后访问得到初始值、var声明前访问返回undefined、typeof在 TDZ 中抛错、循环中let每次迭代产生新绑定等。
注意 package.json 里的npm test(即vitest run)会运行tests/下全部概念测试,不只是 TDZ;只想核对 TDZ 行为时使用上面的单文件命令即可。
typeof 在 TDZ 中不是安全网
typeof对未声明的变量是安全的,返回"undefined":
console.log(typeof undeclaredVar) // "undefined" (no error)但对处于 TDZ的变量会抛ReferenceError,因为引擎知道该变量存在(已被提升),所以强制执行 TDZ 限制:
{ console.log(typeof x) // ReferenceError: Cannot access 'x' before initialization let x = 10 }这经常让依赖typeof做"安全检测"的代码意外崩溃。文档给出的规则:不要单独依赖typeof判断变量是否存在,而是把变量声明放在需要检测之前。
默认参数与解构:从左到右的 TDZ 规则
函数默认参数和对象解构都按从左到右求值,后面的可以引用前面的,反过来就是 TDZ 错误:
// Works: b can reference a function example(a = 1, b = a + 1) { return a + b // 1 + 2 = 3 } // Fails: a cannot reference b (TDZ!) function broken(a = b, b = 2) { return a + b // ReferenceError }解构遵循同样规则,自引用也是 TDZ 错误:
// Works: b can use a's default let { a = 1, b = a + 1 } = {} console.log(a, b) // 1, 2 // Fails: a cannot use b (TDZ!) let { a = b, b = 1 } = {} // ReferenceError // 自引用 let { x = x } = {} // ReferenceError: Cannot access 'x' before initialization自引用报错的原因:=右边的x指向正在声明的那个x,求值时它还在 TDZ 中。
循环:头部自引用与每次迭代的新绑定
for...of/for...in的循环变量在头部求值时处于 TDZ,所以拿它引用自身会抛错(测试文件里两种写法都有覆盖):
// This throws because 'n' is used in its own declaration for (let n of n.values) { // ReferenceError console.log(n) }let在循环中还有一个与 TDZ 相关的关键行为:每次迭代获得一个全新的绑定:
const funcs = [] for (let i = 0; i < 3; i++) { funcs.push(() => i) } console.log(funcs[0]()) // 0 console.log(funcs[1]()) // 1 console.log(funcs[2]()) // 2而var的闭包共享同一个变量,三个调用都返回3。这正是let在循环中能避开经典闭包陷阱的原因。
最容易踩的 TDZ 陷阱:遮蔽外层变量
文档把变量遮蔽(shadowing)列为最常见的 TDZ 陷阱:
// 错误示例 const x = 10 function example() { console.log(x) // ReferenceError! Inner x is in TDZ let x = 20 // 遮蔽了外层的 x return x } example() // ReferenceError!内部的let x遮蔽了外部的const x。在内部声明之前读x时,JavaScript 看到的是内部那个x(此时在 TDZ 中),而不是外层那个,所以抛错。修复方式按文档给出的三条:
- 内外值都要用时改用不同的变量名;
- 在声明内部变量之前先把外层值捕获下来;
- 让声明排在使用之前。
// 正确示例 const x = 10 function fixed() { const outerX = x // Capture outer x first let y = 20 // Use different name, no shadowing return outerX + y // Use both: 10 + 20 = 30 }ES 模块循环引用:导入的值还在 TDZ 中
模块 A 和 B 互相导入时,一方执行到访问对方导出的值时,对方可能还没执行到那行声明,于是触发 TDZ 的ReferenceError。文档的例子:
// -- a.js (entry point) -- import { b } from "./b.js" console.log("a.js: b =", b) // 1 export const a = 2// -- b.js -- import { a } from "./a.js" console.log("b.js: a =", a) // ReferenceError! export const b = 1执行流程是:a.js启动 → 遇到import { b }暂停自己去加载b.js→b.js里的import { a }创建了绑定,但a.js还没执行到export const a→b.js访问a时它仍在 TDZ → 抛错。
文档给出三种解法:
方案 1:延迟访问—— 不在顶层立即使用导入值,放进稍后运行的函数里:
// -- b.js (fixed) -- import { a } from "./a.js" export const b = 1 // Access 'a' later, when it's definitely initialized export function getA() { return a }方案 2:重组模块—— 把共享代码抽到一个双方都依赖的模块,打破循环:
// -- shared.js -- export const a = 2 export const b = 1 // -- a.js / b.js 都改为从 ./shared.js 导入方案 3:动态导入—— 用import()推迟加载:
// -- b.js -- export const b = 1 export async function getA() { const { a } = await import("./a.js") return a }文档还给了一个排查提示:如果对某个你确定存在的导入值看到ReferenceError,先检查是否存在循环导入;报错信息Cannot access 'X' before initialization是模块中 TDZ 的典型信号。
判断与规避要点
把上述行为收拢成可核对的判断:
- TDZ 是"进入作用域"到"声明被初始化"之间的区间,期间访问
let/const/class抛ReferenceError;var没有 TDZ,声明前访问得到undefined。 - "temporal" 指执行时间:TDZ 期间定义引用该变量的函数没问题,调用它才有问题。
typeof在 TDZ 中同样抛错,不能当作安全检测手段。- 默认参数与解构按从左到右求值,右侧引用前面的可以、引用后面的抛错。
- 静态字段引用尚未定义的后续字段返回的是
undefined(属性访问),不抛ReferenceError;但在类声明之前访问类名本身仍会抛错(const x = MyClass.value在class MyClass之前会抛ReferenceError)。 - 文档给出的最简单规避方式是:声明排在使用之前;对遮蔽场景,先捕获外层值或改用不同变量名。
核对完理解后,可以随时用npx vitest run tests/beyond/language-mechanics/temporal-dead-zone/temporal-dead-zone.test.js重跑测试文件;如果之后在项目中遇到Cannot access 'X' before initialization且确定该值存在,优先按上一节的循环导入排查路径检查模块间的相互导入。
【免费下载链接】33-js-concepts📜 33 JavaScript concepts every developer should know.项目地址: https://gitcode.com/GitHub_Trending/33/33-js-concepts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考