这个系列写到第四篇了。前几篇我分别聊了执行上下文的基本模型、调用栈怎么管理函数调用以及作用域链和闭包到底是怎么串起来的。这三块内容放在一起其实已经能把“JavaScript 代码运行时的空间模型”讲出一个比较完整的框架。但很多同学继续往下读的时候会卡在一个地方执行上下文里一直有个叫 this 的“乘客”明明和上下文强相关却总是不按常理出牌再往后let/const 带来的临时性死区、异步任务在微任务队列里排队的行为又和“上下文”概念连在一起让整套模型变得更复杂。所以这一篇把剩下的核心拼图补上先看执行上下文的创建阶段里词法环境和变量环境的区别再看 this 绑定、块级作用域与 TDZ、异步调度与执行栈的联动。这篇文章对正在准备面试、或者写业务代码遇到 this/异步时序问题的同学会特别有用。我会尽量像前面几篇一样把每一段关键流程都配上示意图用“图”把抽象过程变成看得见的结构。1. 执行上下文创建阶段词法环境与变量环境到底差在哪1.1 先给前三篇画个句号执行上下文可以理解成“函数或模块/全局代码在运行时生成的一份完整档案”。这份档案里至少有三样东西当前代码能够访问的变量与函数的登记表环境记录向外层上下文查找的引用outerthis 的指向。调用栈负责维护这些档案的进出顺序当前正在执行的函数永远在栈顶栈顶上下文销毁后控制权交还给下面的上下文。作用域链是一个向上的查找链条当前环境记录里找不到某个变量时就沿着 outer 找一层层往外翻。闭包则是“档案销毁后由于外部还有引用内部变量依然存活”的现象。这三件事本质都是在讲述同一件事的不同侧面JavaScript 是一门在运行时动态决定“变量是谁、函数来自哪里、执行完回到哪”的语言。系列四要补的是这三件事在创建阶段如何落地以及当异步出现时这些档案怎么排队。先放一张创建阶段的整体图后面讲 this 和 TDZ 都会用这张图执行上下文创建阶段 ├── 词法环境 Lexical Environment │ ├── 环境记录登记 let/const/函数声明等 │ ├── this 绑定 │ └── outer向外层环境的引用 └── 变量环境 Variable Environment └── 环境记录登记 var 声明等图里有两个容易被忽略的细节一是环境记录分成了“词法环境”和“变量环境”两部分二是 this 绑定被放在了词法环境这一侧。为什么会有这样一个分裂的设计这里其实藏着 ES6 引入块级作用域的历史原因。1.2 var 与 let/const 在创建阶段的不同待遇ES3 时代执行上下文里只有一个变量对象var 声明和函数声明都往里面塞变量会被初始化为 undefined函数声明则直接初始化为函数对象。ES6 之后规范引入了 let/const它们并不像 var 那样在上下文创建时就被统一初始化而是以“已声明、未初始化”的状态存在于词法环境的环境记录里。打个比方var 的变量就像入住酒店时前台已经帮你把房间钥匙和一张房卡都准备好了初始化为 undefined哪怕你还没走进房间也能先在大堂使用这张卡let/const 的变量则更像“房间已经订好但房卡要等你到房间办完手续才激活”在你执行到声明语句之前这张卡是刷不开门的——这就是临时性死区TDZ的底层原因。变量环境的问题则简单一些它专门给 var 声明留了一块地方函数声明也会出现在这里。这样设计的好处是JavaScript 引擎在创建上下文时可以同时维护两套登记机制一套处理“会立即初始化成 undefined 的旧式变量”一套处理“需要等到执行到声明语句时才初始化的新式变量”。两者的查找逻辑最终都会顺着 outer 向上走但初始化时机完全不同。我在这里补一个常用图解图2左侧变量环境右侧词法环境。var b 在左侧一进上下文就变成 undefinedlet a 在右侧只有“未初始化”标记直到执行到赋值语句a 才真正拥有初始值。创建阶段 变量环境: 词法环境: var b - undefined let a - uninitialized const c - uninitialized function f - function f() {}function 声明在词法环境里会被立即初始化这也是“函数声明提升比变量提升更彻底”的原因之一。1.3 环境记录、outer 与作用域链如何共同成网有一点要注意outer 不是指向“上一个调用它的函数”而是指向“创建当前上下文时所处的词法外层”。这个概念很多人会混。看代码function outer() { const a 10; function inner() { console.log(a); } return inner; } const fn outer(); fn(); // 10inner 的 outer 指向创建它时的 outer 函数词法环境而不是调用它时候的“某句话”。这个引用关系在函数定义时就被决定与函数被谁调用无关所以即使 outer 返回后inner 依旧能向上查到 a。上下文销毁并不等于环境记录被 delete只要闭包还持有引用环境记录就会一直活在堆里。这其实也是我看系列二、系列三时劝大家反复琢磨的一点执行上下文负责“运行时调度”环境记录负责“变量存放”。上下文从调用栈弹出并不代表环境记录一定被回收闭包就是它不回收的证据。理解到这一层再去看 TDZ 和 this会顺很多。2. this 绑定执行上下文里最容易被误解的乘客2.1 this 的四条绑定规则与优先级this 是执行上下文创建阶段就被确定下来的“当前函数调用方式的快照”。它不像普通变量可以沿作用域链查找它只认调用方式。常见的绑定规则有四种默认绑定独立调用时非严格模式指向全局对象严格模式指向 undefined。隐式绑定作为对象方法调用时指向该对象。显式绑定通过 call/apply/bind 指定。new 绑定作为构造函数调用时this 指向新创建的对象。优先级从低到高是默认 隐式 显式 new。也就是说如果一个函数可以被多种规则同时触发最后 this 以更高优先级为准。我画一张非常简单的图图3四条规则同一根轴上从左到右优先级递增右侧标注常见反例。优先级 → 默认 隐式 显式 new 场景示例 foo() obj.foo() foo.call(o) new foo() this 指向 全局/undefined obj o 新对象2.2 隐式丢失与回调丢 this 的三个常见现场实际工作中最常见的 this 问题不是“不知道规则”而是“规则在调用一瞬间根本没生效”。三个典型现场现场 1把方法单独取出来调用。const obj { name: obj, say() { console.log(this.name); } }; const say obj.say; say(); // undefined/TypeErrorobj.say 取出来赋值给 say再独立调用时它已经和 obj 没有关系了this 回到默认绑定。这里发生的现象叫隐式丢失。现场 2作为回调传进一个需要独立调用的函数。setTimeout(obj.say, 100); // this 不是 objsetTimeout 内部会以独立调用的方式执行回调所以 this 丢失。想要不丢就得先换一种函数形式比如箭头函数或者显式绑定 obj.say.bind(obj)。现场 3嵌套函数里的普通函数。const obj { name: obj, run() { [1, 2].forEach(function () { console.log(this.name); // this 也不指向 obj }); } }; obj.run();这是一个非常经典的困惑点。run 方法里的 this 明明指向 obj但 forEach 回调里的 this 却丢失了原因是那个回调函数是独立调用的。修复方法有很多外层保存 that、箭头函数、bind都是让回调捕获到 run 执行上下文中的 this。2.3 箭头函数没有自己的执行上下文是怎么一回事箭头函数是 ES6 里处理 this 的最直观工具但也最容易让人产生误解。它并不是“把 this 绑定到外层”而是“根本不创建自己的 this 绑定”。执行上下文创建阶段箭头函数的词法环境里不会登记 thisBinding当箭头函数内部遇到 this 时引擎会直接沿着词法作用域往上找找到最近一层拥有 this 绑定的普通函数用它作为当前 this。所以const obj { name: obj, say() { const arrow () { console.log(this.name); }; arrow(); } }; obj.say(); // objarrow 被定义在 say 方法内部say 执行时的 this 指向 obj于是 arrow 里的 this 也等于 obj。反过来如果把箭头函数定义在全局环境那它里面的 this 就是全局或者说非严格模式下的全局对象严格模式也不受影响因为它是“借用外层 this”而不是自己有 this。这一类问题在面试里经常会被翻新花样。回答时不要背“箭头函数 this 指向定义它的作用域”而是说“箭头函数没有自己的 this它在创建执行上下文时不会生成 this 绑定查找 this 时走词法环境链”。这个说法更准确也更能打动面试官。2.4 实战建议遇到 this 先问“它以什么身份被调用”我在实际项目中排 this 问题的顺序是固定的先看目标函数这次调用是“谁触发的”——是独立函数对象方法call/apply 传入new 出来的如果是箭头函数再往上找它最近的非箭头父函数/全局环境。只要把这个顺序理一遍绝大多数 this 问题都能现场分析出来而不是靠猜。遇到 setTimeout、事件回调、数组的 forEach/map 回调提前想清楚“回调最终会被引擎以什么方式调用”再决定要不要 bind、箭头函数还是外层保存变量。这个意识比记住规则更有用。3. 块级作用域、TDZ 与执行上下文的“冻结空间”3.1 临时性死区是怎么产生的前面说到let/const 在创建阶段只是“已声明、未初始化”。这个状态一直保持到执行到对应声明语句而这段“已声明但还没初始化”的时间窗口就是临时性死区。准确地说TDZ 不是一个真正的时间段而是一种状态描述只要某个变量处于可被识别但初始值尚未赋予的状态任何读取它的行为都会抛出 ReferenceError。看这段代码console.log(a); // ReferenceError: Cannot access a before initialization let a 1;如果把 a 换成 varconsole.log(b); // undefined var b 1;var b 在创建阶段就已经是 undefined所以不会报错。这个差异正是许多老项目里 var 被诟病“看起来提升变量却容易掩盖未初始化 bug”的原因。还有两个细节值得注意typeof 一个 TDZ 变量同样会抛错因为这个变量不是“未定义”而是“已登记但没初始化”。typeof 一个完全不存在的变量反而不会抛错。同一个作用域里重复 let/const 声明同一个变量会在语法解析阶段直接报错。因为词法环境里已经登记了这个绑定。我画一张冻结状态图图4帮助记忆时间轴 var b ----------- 创建阶段b undefined 执行到 var b 1b 1 let a ----------- 创建阶段a uninitialized TDZ 区间 执行到 let a 1a 1 此后可访问3.2 for 循环里的 let上下文创建了一次又一次for 循环里的经典面试题放在执行上下文视角下会变得非常直观。先看 var 版本for (var i 0; i 3; i) { setTimeout(() console.log(i)); } // 输出3 3 3var i 隶属于函数作用域自始至终只有一个 i循环结束 i 已经变成 3。setTimeout 回调回头执行时读到的都是同一个变量。再看 let 版本for (let i 0; i 3; i) { setTimeout(() console.log(i)); } // 输出0 1 2let i 是块级作用域每次迭代都会创建一个新的块级词法环境新的 i 被初始化成当前迭代值。setTimeout 回调捕获的是对应迭代的那一个 i所以得到的是 0、1、2。更准确地说let 在 for 循环里的行为比较特殊规范要求每一轮迭代都会生成一个新的词法环境并把上一轮迭代的变量的值拷贝/绑定到新的环境里。回调闭包跟着每次迭代的环境走自然各不相同。这个机制在执行上下文层面理解就是“同一段 for 代码被反复创建/回收了多个块级上下文”。3.3 块级上下文与函数声明的碰撞块级作用域还有一个容易踩的细节在 if/for 块内声明 function行为在不同规范与严格模式下存在差异。现代浏览器里块内函数声明会被提升到块级顶部并且块外不一定能访问。更安全的姿势是不要在块内使用函数声明改用 const fn () {} 或 let。这个建议背后的原因还是 let/const 与 var 在词法环境、变量环境中的不同登记逻辑。块内函数如果被“提升到块级环境”那么块外没有可见性而 var 声明的变量却能穿透块级作用域。理解到“块级作用域 一个嵌套的词法环境记录”许多类似问题都能自行推导。4. 异步与执行上下文的深层联动4.1 执行栈、宏任务、微任务三者的调度顺序本系列的第三篇讲完作用域链后很多同学留言问setTimeout 里的回调也有执行上下文那它和正常的函数调用有什么区别这里的关键是回调确实会创建执行上下文但不是立刻创建而是等事件循环轮询到它的时候再把回调推入调用栈创建上下文并执行。JavaScript 引擎同一时间只能执行一段同步代码事件循环负责安排“接下来执行哪段”。浏览器环境里的基本调度规则可以总结成一句话一个宏任务执行期间执行栈内可以穿插微任务但宏任务与宏任务之间必须先把当前宏任务期间产生的微任务队列清空。图5 我画成三列宏任务1当前脚本/事件回调执行中 ├── 遇到 Promise.then → 将回调放入微任务队列 ├── 遇到 setTimeout → 将回调放入宏任务队列 └── 执行栈清空后 ↓ 检查微任务队列按序执行所有微任务期间产生的新微任务继续追加 ↓ 取出下一个宏任务 → 重新开始注意“检查微任务队列”时微任务队列可能会在执行过程中不断追加新微任务引擎会一直清直到队列为空才会进入下一个宏任务。这也是“微任务可以无限追加导致页面卡死”的原因之一。4.2 await 的本质把一个后续代码块变成微任务从执行上下文视角看 async/await 最通透。async 函数正常执行时它就像普通函数一样拥有一个执行上下文在调用栈上执行。可一旦遇到 await函数并不会再继续往下走而是先求值 await 后面的表达式把 async 函数期望后续执行的代码包装成一个续体把续体放入微任务队列当前 async 函数的执行上下文从调用栈弹出让出控制权。等 Promise 状态确定后事件循环执行微任务队列续体被重新推入调用栈引擎会带着之前保存的状态恢复 async 函数的执行继续执行后面的代码。所以 await 在“时间轴”上把一段同步代码拆成了两段await 之前同步跑完await 之后变成微任务。这也是为什么很多人初学 async/await 时会对“await 不阻塞外部代码”感到意外。它不是同步等待而是让出执行栈让外面的同步代码继续跑。遇到多个 await 的时候每个 await 都相当于一次“切分点”。4.3 图解一道经典输出顺序题我们拿一段没有争议的经典例子看执行上下文的变化console.log(script start); setTimeout(() console.log(timeout)); Promise.resolve().then(() console.log(promise1)); console.log(script end);输出结果script start → script end → promise1 → timeout。按事件循环和图5 的框架拆解当前的宏任务是主脚本执行栈从无到有逐步执行。第一行打印 script start。setTimeout 把回调注册进宏任务队列登记的是“稍后执行”。Promise.resolve().then 把 then 回调压入微任务队列。打印 script end。主脚本作为宏任务结束执行栈清空。事件循环检查微任务队列取出 promise1 回调创建执行上下文打印 promise1。微任务队列清空后取出下一个宏任务即 setTimeout 回调打印 timeout。把图5 的队列状态按步骤画出来图6中间每一步对应哪个队列在增加/减少一目了然。这类题目只要掌握“同步代码永远是当前宏任务先执行完再清微任务最后才轮到下一个宏任务”这个顺序就基本不会错。补充一句await 的推出顺序在不同版本引擎里存在过差异新版改成了更少的一次 Promise 包装所以如果面试里碰上包含多个 await 的复杂时序题最好现场画微任务队列宁可慢一点也不要凭旧答案背。5. 面试与实战执行上下文的三个高频考点破题5.1 手写 new 与 call/apply/bind 时的上下文思维面试经常要求手写 new 或 bind。这两个实现本质上都是“调整 this 绑定 构造上下文状态”。new 的核心步骤创建一个新对象继承构造函数的 prototype 属性以该对象为 this 调用构造函数如果构造函数返回了一个对象则返回那个对象否则返回新对象。实现范例如下function myNew(Ctor, ...args) { const obj Object.create(Ctor.prototype); const result Ctor.apply(obj, args); return result ! null typeof result object ? result : obj; }重点是Ctor.apply(obj, args) 就是在创建函数调用上下文时把 this 绑定到 obj。这行代码同时利用了“隐式对象”和“显式绑定”的机制。bind 的简化实现Function.prototype.myBind function (thisArg, ...bindArgs) { const fn this; return function (...callArgs) { return fn.apply(thisArg, [...bindArgs, ...callArgs]); }; };这里需要注意的是完整 bind 还有一个细节如果 bind 返回的函数被当作构造函数 new那么 this 应该是新创建的对象而不是 bind 传入的 thisArg。规范里使用了“是否被 new 调用”的判断来区分。面试如果被追问可以把这一点主动讲出来会显得对 this 优先级理解得很完整。5.2 一道高频综合题的拆解很多公司喜欢出下面这类综合题同时考察 var、let、闭包和异步顺序for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 0); } for (let j 0; j 3; j) { setTimeout(function () { console.log(j); }, 0); }拆解思路分四步第一个 for 里 var i 存在于函数作用域同一轮循环结束后 i 3三个 setTimeout 回调都捕获同一个 i全部输出 3第二个 for 每次迭代都会创建新的块级词法环境let j 被绑定到各自迭代环境三个回调输出 0、1、2。如果再掺和 this就继续问调用的身份。综合题本质上就是“执行上下文创建阶段做了什么 异步队列如何安排”。把这两个维度分开列出来题目可以解得很干净。5.3 调试心得断点看调用栈、作用域与 this我个人的排错习惯强烈推荐不要只靠 console.log 打点要善用开发者工具的断点。在可疑代码行打断点右侧面板能看到当前作用域链、闭包里的变量、this 的当前值。调用栈面板显示当前上下文嵌套关系能清楚看到当前执行栈顶部函数是谁底层是谁以及异步回调是如何被“推到”栈顶的。遇到 “Cannot access before initialization” 时基本就可以定位为 TDZ 问题回到代码里看变量是否在声明语句之前被读取。有一个小技巧遇到 this 疑似丢失先在当前函数第一行断点看 this 面板里到底是什么再往上追调用栈里上一帧的 this。大多数情况下你能在十秒内找到是哪一次调用方式改变导致的。我自己处理过很多次这类问题基本都是从“调用方式”入手极少需要真正去改业务逻辑。6. 我建议你这样消化这一系列文章这个系列写到这里该把学习方法也交代一下。我见过不少同学收藏了很多文章但始终觉得 JavaScript 运行机制还是很抽象。我自己的经验是把代码执行过程“翻译”成执行栈上的画面是消化这一类知识最快的方式。具体做法很简单。看到任何一段 JavaScript 代码别急着背输出结果先在脑子里过一遍现在栈顶是哪个函数执行上下文它对应的词法环境里有哪些变量各自的初始化状态是什么outer 指向哪一层当前 this 是谁如果遇到 Promise/setTimeout哪个回调将来会进哪个队列刚开始会慢但坚持用这种方式推演一两周你会发现面试题里的输出题基本都能推出来而不是靠记忆。我还建议把这一系列文章的图自己用笔重新画一遍。画图的过程其实就是把抽象概念在脑子里落地的过程。画到“执行上下文创建了两个环境”“this 绑定来自调用方式”“微任务在回调真正执行前排队”这三幅图都能默画出来时这一整套关于执行上下文的知识才算真正成了你自己的工具箱。最后分享一个我工作中最常受益的小技巧遇到一段被闭包、this、异步搞得特别绕的代码先小步重构把它拆成若干小函数保证每层上下文边界清晰。很多时候问题不在“看不懂”而在“一个函数里堆了太多职责”。让每个函数只负责一个执行上下文该做的事this 丢失和变量污染的问题会自然减少一大半。这也是我读过这个系列之后对自己代码可读性要求提升的一个直接改变。