
1. 那晚的现场订单页白屏报错却指向一行无辜代码凌晨一点腰还酸着屏幕上的订单汇总页白得刺眼。控制台报了一行我看了三遍都没看出毛病的错最后一层层查下去才发现又是变量提升和执行上下文这对老朋友在对我搞报复。想想也好笑长期伏案让腰肌不断告警而一段看似人畜无害的 JavaScript又用最经典的方式戳中了我对执行上下文的理解盲区。先说清楚这篇文章是什么。它不是什么系统性的原理讲义而是一次真实到有点丢人的排错记录。我会把当时的现场代码、报错信息、完整排查链路以及最后让我彻底想明白的那几个关键概念全部拆开晾出来。如果你也被变量提升TDZ作用域链调用栈这几个词绕晕过这篇应该能帮上忙。1.1 还原当时的业务代码当时的需求很简单根据配置渲染页面语言。我写了这么一段代码const defaultConfig { theme: dark, language: zh }; const app { config: { theme: light, language: en }, init() { this.render(); }, render() { // 这里出事了直接访问了 config console.log(渲染语言, config.language); // 本意是想基于 defaultConfig 生成一份页面局部配置 const config { ...defaultConfig, theme: dark }; document.title config.language zh ? 订单页 : Order Page; } }; app.init();你看到问题在哪了吗当时我完全没有。app对象上明明有config属性方法里也明明有this可以拿我偏偏在图省事的时候写了个裸config。按理说这个config在全局环境里根本不存在应该报ReferenceError: config is not defined。但浏览器给我的是另一句话Uncaught ReferenceError: Cannot access config before initialization这两条报错之间的差别就是整个故事的入口。1.2 报错措辞里藏着关键信息很多人看报错只看红字从不细品措辞。这是大忌。config is not defined和Cannot access config before initialization在表面上看都是找不到变量但底层含义完全不同报错情况真实含义config is not defined引擎沿着作用域链查完所有词法环境压根没找到config这个名字Cannot access config before initialization引擎知道config存在已经把这个名字登记在案了只是还没初始化处于不可触碰状态config is undefined变量名已存在且已初始化但值是undefined常见于var提升所以当我看到Cannot access ... before initialization时第一反应应该是这一段代码里一定有一个let/const声明的config只不过它出现在访问语句的后面。没错就是render()函数里的const config { ...defaultConfig }。let/const声明的变量在被执行到之前确实处于一个登记过但未初始化的状态,这个状态就是人们常说的暂时性死区TDZ, Temporal Dead Zone。1.3 为什么代码看起来一点毛病没有这段代码迷惑性最强的点在于app.config明明存在this.config.language也完全能用。我写裸config时脑子里其实想的是反正对象里有 config但 JS 引擎根本不会因为你对象上有同名属性就帮你把裸标识符解析成属性。变量名解析走的是执行上下文里的词法环境跟对象属性是两套完全独立的机制。this.config是属性访问config是标识符解析。前者查的是对象后者查的是作用域链。更坑的是因为函数作用域内声明了const config整个函数体的作用域从第一行开始就已经把config这个名字预定了。所以在它初始化之前无论你怎么访问一律报 TDZ 错误。这个案子最有价值的点在于报错信息直接暴露了执行上下文的存在。引擎要是不在创建阶段登记名字它就只会说我不认识 config而不是我知道它但还没初始化。理解了这一点后面的所有机制就不再是黑盒了。2. 变量提升的本相引擎不是在搬家而是在提前念花名册变量提升这个翻译坑了无数人。它给人的感觉是JavaScript 引擎会把var声明和函数声明移动到代码顶部。如果你真的信了这套后面理解 TDZ 的时候就会彻底卡住——因为let和const如果也提升了为什么不报undefined而是报ReferenceError2.1 提升只是一种可观察效果不是物理动作引擎在解析源码时不会真的改写你的代码文本。所谓提升hoisting实际是执行上下文创建阶段的环境记录绑定行为。具体来说当一个作用域刚开始被初始化时引擎会扫描整个作用域里的声明把所有名字提前登记到对应的环境记录里。就好比开会之前先念一遍与会人员花名册人还没到齐但名单已经列好了。之后执行到某一行才真正给这个变量发工牌、赋予初始值。var的登记方式是名字记上同时顺手给一个初始值undefined。这就是为什么你在声明之前读它不会报错只会拿到undefined。let/const的登记方式是名字记上但不给初始值。在规范里这个状态称为未初始化。从作用域开始到声明语句执行完成之前的这段区间就是这个变量的暂时性死区。往死区里伸手必然报ReferenceError。2.2 三种声明在登记姿势上的区别我把常见声明的登记行为整理成一张表这也是我觉得最有用的速查参照声明方式创建阶段行为声明之前访问的结果典型报错var x 1登记名字初始化为undefined拿到undefined不报错无但容易产生隐性问题function foo() {}登记名字且直接绑定整个函数体可以正常调用无var foo function() {}登记名字初始化为undefined调用foo()报类型错误TypeError: foo is not a functionlet x 1/const y 2登记名字不初始化报引用错误ReferenceError: Cannot access x before initializationclass Foo {}类似let不初始化同上同上这里特别容易考的冷门点函数声明是整体提升函数表达式只提升变量名不提升函数体。所以sayHi(); // 正常输出 hello function sayHi() { console.log(hello); } sayBye(); // TypeError: sayBye is not a function var sayBye function () { console.log(bye); };第二个报错不是因为sayBye不存在而是因为创建阶段只把sayBye初始化为undefined了。undefined当然不能被调用。另外补充一个容易被忽略的细节对未初始化的let变量执行typeof也会抛ReferenceError而不是像未声明变量那样返回undefined。这也从侧面印证了名单上已经有名字了这个事实。2.3 局部遮蔽最常见的灵异事件来源变量提升最坑人的地方不是提前访问而是局部声明遮蔽外部变量。看这个经典案例var price 199; function pay() { console.log(当前价格, price); // 输出 undefined var price 99; return price; } pay();很多人的第一反应是console.log应该读到全局的price 199才对。但实际输出是undefined。原因就在创建阶段进入pay()函数时引擎扫描到函数体内有var price于是把局部price登记并初始化为undefined。这个局部的price会优先于全局的price出现在作用域链的前端。所以你在声明前访问到的是这个还拿着undefined的局部变量而不是外层的同名变量。这就是打个雷再下雨结果雨全被屋顶挡住了的意思。学习变量提升不能只记住声明会提前还要理解同名声明的遮蔽规则和作用域链的查找顺序。这两个机制配合起来才构成完整的灵异现场。3. 执行上下文的两幕剧先登记再执行才是完整生命周期变量提升只是执行上下文创建阶段的表现之一。要彻底弄明白得把执行上下文这整个概念拆开看。3.1 执行上下文到底在管理什么执行上下文Execution Context是 JavaScript 引擎在执行一段可运行代码前构建的一个内部管理单元。代码运行时需要的所有元信息都挂在这个上下文上。一个函数执行上下文里面至少包含三样东西LexicalEnvironment词法环境存放let/const声明的绑定以及对外层词法环境的引用outer。VariableEnvironment变量环境存放var声明和函数声明的绑定。ThisBinding当前this指向谁。为什么当年 ES6 要把环境拆成词法环境和变量环境两份因为var和let/const的初始化时机、是否入死区等行为完全不同共用一套环境记录会遇到很多边界问题。分成两份各管各的规范就清晰了。全局有全局执行上下文每个函数被调用时都会临时创建一个函数执行上下文。这些上下文不会凭空存在而是被放进一个叫做执行上下文栈也就是常说的调用栈的结构里后进先出。3.2 创建阶段的五件事函数被调用的瞬间、函数体第一行代码执行之前引擎会完成以下创建动作确定this绑定根据调用方式算出this是谁。创建词法环境扫描函数体内所有let/const/class声明登记名字但不初始化。创建变量环境扫描所有var声明和函数声明。初始化var绑定把所有var名字统一打上undefined初始值。初始化函数声明绑定把函数声明直接绑定为函数对象所以函数可以在声明前被调用。注意第 2 步和第 3 步的先后顺序在不同引擎实现上可能不是严格按这个顺序但语义结果一致。对于理解来说这个顺序足够正确。3.3 执行阶段逐行结算值才算落地创建阶段只是登记真正的值还在后面。执行阶段就是逐行运行代码每遇到一条赋值语句就去对应的环境记录里更新那个绑定。还是用刚才的pay()例子走一遍完整流程var price 199; function pay() { console.log(当前价格, price); // undefined var price price * 0.8; return price; } pay();第一步全局上下文创建变量环境里登记price初始值undefined然后执行var price 199全局环境里price更新为 199。第二步调用pay()创建一个新的函数执行上下文。创建阶段扫描到函数体内有var price于是在这个新上下文的变量环境里登记一个局部price初始值undefined。第三步执行console.log(当前价格, price)。此时的price在作用域链上查找先命中的是当前函数上下文的局部price值为undefined。于是输出undefined。第四步执行price price * 0.8。右边的price还是undefinedundefined * 0.8得到NaN。所以函数返回值是NaN而不是报错。这就是var最阴险的地方它不报错它静默地把你的逻辑算错。相比之下let/const直接抛异常反而更容易发现。3.4 嵌套调用时的执行上下文快照再看一个稍复杂的例子把它和调用栈对上号const taxRate 0.1; function calcTax(amount) { const tax amount * taxRate; return tax; } function checkout(total) { const final total calcTax(total); console.log(final); } checkout(100);执行到checkout(100)时调用栈从栈底到栈顶依次是全局执行上下文 -checkout函数执行上下文。执行到calcTax(total)时栈变成全局 -checkout-calcTax。calcTax执行完 return它的执行上下文出栈销毁。然后checkout继续执行把final算出来输出 110再出栈。最后栈里只剩全局执行上下文。每层函数上下文都带自己的词法环境和变量环境查找变量时一层一层顺着outer链往上找。calcTax的环境里没有taxRate就会通过outer引用找全局环境找到0.1。这也是为什么执行上下文的学习一定要配合调试器看调用栈和作用域两个面板纯用脑子推很容易推歪。4. 调用栈与作用域链执行上下文决定谁找得到谁前面讲了单个执行上下文内部的结构这一节把视角拉到多个上下文之间调用栈决定了现在谁在运行作用域链决定了当前环境能看见哪些名字。两者经常被混为一谈但它们是完全不同的两套机制。4.1 栈的推入与弹出决定谁在说话调用栈的核心逻辑引擎每进入一个函数就往栈顶推入一个新的执行上下文函数 return 或抛错结束时栈顶上下文弹出控制权交还给栈中下一个上下文。这个机制很直观但有个容易被忽略的推论调用栈是动态的作用域链是静态的。调用栈取决于运行时实际的调用顺序而作用域链取决于函数在源码中的定义位置。所以哪怕同一个函数被两个不同的地方调用它在两个时刻继承的外层作用域是完全一样的。4.2 作用域链不是调用链是出生地决定的这个区别直接解释了很多人困惑的为什么我在函数 A 里调用了函数 BB 却看不到 A 的局部变量。function foo() { console.log(bar); // ReferenceError: bar is not defined } function baz() { var bar 1; foo(); // 从调用链上看foo 是 baz 调用的能否找到 bar } baz();如果作用域链像调用链一样工作那foo内部应该能看到baz的局部bar。但实际运行必然报错。因为foo是在全局作用域中定义的它的词法环境的outer引用指向全局环境跟baz没任何关系。JavaScript 是词法作用域lexical scoping函数的作用域链在定义那一刻就被决定了调用位置不参与。这个设计的好处是一个函数无论从哪里被调用它搜到的变量集合始终稳定不会出现同样的代码换个调用者就换一套含义的混乱。4.3 闭包保留的是词法环境不是整个执行上下文闭包这个概念也跟执行上下文强相关。看这个例子function makeCounter() { let count 0; return function () { count; return count; }; } const counter makeCounter(); counter(); // 1 counter(); // 2makeCounter()执行结束后它的函数执行上下文应该弹出栈了。但返回的匿名函数依然能访问count并且每次调用还能让count继续累加。秘密在于匿名函数在被定义时内部悄悄保存了一个引用指向makeCounter执行上下文中的词法环境。这个引用在规范里叫[[Environment]]。所以即使makeCounter的执行上下文已经出栈它创建的词法环境仍然存活在内存中被匿名函数长期引用。这就回答了一个常见疑问**闭包到底持有什么是保存了整个外层执行上下文吗**不是。执行上下文里的this、调用状态、代码位置等信息都会随出栈被清掉闭包真正持有的是词法环境记录——也就是那一批变量绑定和outer引用。4.4 this 绑定执行上下文里的另一个变量this也是执行上下文的一部分但它完全不走作用域链查找。它是在创建阶段按照调用方式直接计算出来的一个值。调用方式示例this指向普通函数调用fn()非严格模式下是全局对象严格模式下是undefined方法调用obj.fn()调用该方法的对象obj显式绑定fn.call(ctx)/fn.apply(ctx)/fn.bind(ctx)传入的ctx构造函数调用new Fn()新建的实例对象箭头函数() {}定义时捕获外层执行上下文的this箭头函数很特殊它自己没有独立的this绑定创建时会从定义位置的外层词法环境中捕获。所以箭头函数里的this跟调用方式无关只看它在哪。在实际 debug 时我经常提醒自己this看的是怎么调的调用方式变量看的是在哪定义的词法作用域。这两条线一旦分清执行上下文里的大半困惑就解除了。5. 破案回放从 ReferenceError 措辞差异定位 TDZ 根因回到开头那个让我腰酸背痛又精神崩溃的 bug。现在我把当时的排查链路完整还原一遍这部分是我觉得比原理讲解更值钱的内容。5.1 第一步细品报错措辞先判断故障类型报错是Cannot access config before initialization不是config is not defined。这两个信息的差别直接决定了排查方向is not defined说明作用域链上没这个名字方向是检查拼写、检查是否真的声明过。before initialization说明名字存在但未初始化方向是找同名let/const声明并确认访问语句是否在初始化之前。所以我立刻在render()函数体里搜config字样果然在第三行看到了const config { ...defaultConfig, theme: dark }。访问语句在第 5 行声明语句在第 8 行访问在声明之前TDZ 成立。5.2 第二步看调用栈确认当前执行上下文然后我打开 DevTools 的 Sources 面板在报错行打了断点重新运行。右侧的 Call Stack 面板清楚地显示了两层栈帧render init (anonymous)render在最顶部说明报错就发生在 render 函数的执行上下文里。此时在当前上下文的词法环境中config已经被登记但状态是未初始化。这个栈信息还帮我确认了this的绑定路径init里通过this.render()调用所以render里的this指向app但这跟 TDZ 无关,我没必要在 this 上浪费时间。5.3 第三步Scope 面板的未初始化状态断点停在第 5 行时右侧 Scope 面板会有几个分区Local、Global可能还有Closure或Block。在Local区域里你会看到config这个名字但它的值不是undefined而是显示为uninitialized不同版本的 Chrome 显示措辞略有差异有的显示value unavailable有的直接显示灰色。看到这个状态基本就实锤了这个绑定是let/const创建的正处于 TDZ。如果它是var这里会直接显示undefined。所以 Scope 面板是区分提升到 undefined和登记但未初始化的最直观工具。5.4 第四步修复方案对比问题定位后修复方案其实有几种我实际评估了两种方案一把局部变量改名避免裸标识符歧义。render() { const mergedConfig { ...defaultConfig, theme: dark }; console.log(渲染语言, mergedConfig.language); document.title mergedConfig.language zh ? 订单页 : Order Page; }这是最省事也最推荐的方案。mergedConfig不再与任何外部概念撞名也不会触发 TDZ。方案二如果确实想叫config就把访问挪到声明之后。render() { const config { ...defaultConfig, theme: dark }; console.log(渲染语言, config.language); document.title config.language zh ? 订单页 : Order Page; }这个方案同样能消除 TDZ 报错因为它保证所有访问都发生在初始化之后。但要注意TS/ESLint 的no-use-before-define规则一般也会强制这种写法所以第二个方案通常也是被规范逼出来的。5.5 把排查套路推广到 var 型 bug处理完 TDZ我又想起之前踩过的另一个var坑顺手做个经验备忘。var版本的 bug 通常不会报错只会出现undefined或者NaN这类静默错误。排查套路完全不同如果你在调试时看到某个变量的值莫名变成undefined先别急着怀疑赋值逻辑去 Scope 面板看一眼有没有同名var声明。如果有基本就是局部声明遮蔽 提升初始化的组合拳打的。这种情况在旧代码里特别常见尤其是那种在一个函数里既有var config ...又到处用config做其他事情的老写法。6. 康复计划让变量提升和执行上下文从折磨变成工具折腾了一晚上腰更酸了但对这两个机制的敬畏心也彻底建立起来了。第二天理完思路我把能治这个问题的办法拆成了几个实际可落地的习惯分享出来。6.1 三条编码习惯从源头避开 90% 的坑第一条项目里全面禁用var一律let/const。不是var不能用而是它静默转undefined的特性对代码可维护性伤害太大。ESLint 里开一条no-var规则旧代码逐步迁移新代码从第一步就避开一大半坑。第二条变量声明放在作用域的顶部或者紧挨着它的使用处。不要把声明藏在函数中段。因为 TDZ 覆盖整个块let/const声明在哪一行会对声明之前的访问产生直接影响。声明放前面既清晰又安全。第三条警惕同名遮蔽。config、data、res、item这类高频词汇特别容易在函数内部被重新声明。每次在函数里看到这类名字都多问一句它会不会遮蔽外层同名变量如果会就换名或者显式用参数传入绝不依赖恰好能访问到外层同名变量这种运气。6.2 两个调试技巧把黑盒变白盒第一个技巧多用 DevTools 的 Scope 面板和 Call Stack 面板。断点一打当前执行上下文里有哪些绑定、每个绑定是undefined还是uninitialized、调用栈长什么样全都摊在眼前。这比对着代码猜效率高十倍。第二个技巧用console.dir(某个函数)查看它的[[Scopes]]属性。展开后能看到这个函数定义时捕获到的所有外层词法环境包括闭包里存的变量长什么样。排查闭包引用错乱的问题时这个技巧几乎是开挂级别的。function makeCounter() { let count 0; return function () { return count; }; } const counter makeCounter(); console.dir(counter); // 在输出中展开 [[Scopes]]能看到 Closure 区域里 count 的当前值6.3 一个心智模型把执行上下文想成开会现场最后分享一个帮我消化所有概念的心智模型把一个作用域想成一场会议。创建阶段 会议开始前布置会场、打印花名册、在场外挂好外部门牌outer引用。执行阶段 会议进行中按议程逐项走每次有人进场就把名字和座位写进白板赋值更新。var 会议一开场就被叫起来报到的人座位已经安排好了但人可能还没到花名册上先写待定undefined。let/const 花名册上有名字但工牌还没发。你去前台问这个人到了没前台只告诉你名单上有但还没到不能见。作用域链 你在这个会议室找不到某个人就顺着门牌号格子去旁边的会议室找再找不到就再去更大的一层找一直找到大厦前台全局环境。调用栈 当前在开哪个会、上一场会的记录压在哪里。闭包 会都散了但某个参会者临走前把你的会议室门牌钥匙复制了一把你之后随时可以溜进去翻白板上的记录。这套模型帮我解决的最核心问题就是提升不是代码物理移动而是登记先于初始化。一旦建立了这个认知TDZ、遮蔽、闭包、调用栈这些概念就全部连成一张网了。那天凌晨修完 bug 之后我坐在椅子上又盯了那段代码好几分钟。腰还是酸的但心里舒坦多了。回想整个过程变量提升和执行上下文对我打击报复这件事本质上不是在报复我是在提醒我我对这门语言底层的运行机制还缺一层肌肉记忆。现在的感受是吃亏越狠记忆越深。如果你也正在被类似的诡异报错折磨希望这篇记录能帮你少熬一个夜。