news 2026/10/14 21:01:30

执行上下文进阶:this绑定、TDZ与异步调度的运行机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
执行上下文进阶:this绑定、TDZ与异步调度的运行机制

这个系列写到第四篇了。前几篇我分别聊了执行上下文的基本模型、调用栈怎么管理函数调用,以及作用域链和闭包到底是怎么串起来的。这三块内容放在一起,其实已经能把“JavaScript 代码运行时的空间模型”讲出一个比较完整的框架。但很多同学继续往下读的时候,会卡在一个地方:执行上下文里一直有个叫 this 的“乘客”,明明和上下文强相关,却总是不按常理出牌;再往后,let/const 带来的临时性死区、异步任务在微任务队列里排队的行为,又和“上下文”概念连在一起,让整套模型变得更复杂。

所以这一篇把剩下的核心拼图补上:先看执行上下文的创建阶段里词法环境和变量环境的区别,再看 this 绑定、块级作用域与 TDZ、异步调度与执行栈的联动。这篇文章对正在准备面试、或者写业务代码遇到 this/异步时序问题的同学会特别有用。我会尽量像前面几篇一样,把每一段关键流程都配上示意图,用“图”把抽象过程变成看得见的结构。

1. 执行上下文创建阶段:词法环境与变量环境到底差在哪

1.1 先给前三篇画个句号

执行上下文可以理解成“函数(或模块/全局代码)在运行时生成的一份完整档案”。这份档案里至少有三样东西:

  • 当前代码能够访问的变量与函数的登记表(环境记录);
  • 向外层上下文查找的引用(outer);
  • this 的指向。

调用栈负责维护这些档案的进出顺序:当前正在执行的函数永远在栈顶,栈顶上下文销毁后,控制权交还给下面的上下文。作用域链是一个向上的查找链条,当前环境记录里找不到某个变量时,就沿着 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 在左侧一进上下文就变成 undefined,let 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(); // 10

inner 的 outer 指向创建它时的 outer 函数词法环境,而不是调用它时候的“某句话”。这个引用关系在函数定义时就被决定,与函数被谁调用无关,所以即使 outer 返回后,inner 依旧能向上查到 a。上下文销毁,并不等于环境记录被 delete,只要闭包还持有引用,环境记录就会一直活在堆里。

这其实也是我看系列二、系列三时劝大家反复琢磨的一点:执行上下文负责“运行时调度”,环境记录负责“变量存放”。上下文从调用栈弹出并不代表环境记录一定被回收,闭包就是它不回收的证据。理解到这一层,再去看 TDZ 和 this,会顺很多。

2. this 绑定:执行上下文里最容易被误解的乘客

2.1 this 的四条绑定规则与优先级

this 是执行上下文创建阶段就被确定下来的“当前函数调用方式的快照”。它不像普通变量可以沿作用域链查找,它只认调用方式。常见的绑定规则有四种:

  1. 默认绑定:独立调用时,非严格模式指向全局对象,严格模式指向 undefined。
  2. 隐式绑定:作为对象方法调用时,指向该对象。
  3. 显式绑定:通过 call/apply/bind 指定。
  4. 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/TypeError

obj.say 取出来赋值给 say,再独立调用时,它已经和 obj 没有关系了,this 回到默认绑定。这里发生的现象叫隐式丢失。

现场 2:作为回调传进一个需要独立调用的函数。

setTimeout(obj.say, 100); // this 不是 obj

setTimeout 内部会以独立调用的方式执行回调,所以 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(); // obj

arrow 被定义在 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 换成 var:

console.log(b); // undefined var b = 1;

var b 在创建阶段就已经是 undefined,所以不会报错。这个差异,正是许多老项目里 var 被诟病“看起来提升变量,却容易掩盖未初始化 bug”的原因。

还有两个细节值得注意:

  • typeof 一个 TDZ 变量同样会抛错,因为这个变量不是“未定义”,而是“已登记但没初始化”。typeof 一个完全不存在的变量反而不会抛错。
  • 同一个作用域里重复 let/const 声明同一个变量,会在语法解析阶段直接报错。因为词法环境里已经登记了这个绑定。

我画一张冻结状态图(图4),帮助记忆:

时间轴: var b ----------- 创建阶段:b = undefined 执行到 var b = 1:b = 1 let a ----------- 创建阶段:a = <uninitialized> == TDZ 区间 == 执行到 let a = 1:a = 1 此后可访问

3.2 for 循环里的 let:上下文创建了一次又一次

for 循环里的经典面试题,放在执行上下文视角下会变得非常直观。

先看 var 版本:

for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); } // 输出:3 3 3

var i 隶属于函数作用域,自始至终只有一个 i,循环结束 i 已经变成 3。setTimeout 回调回头执行时,读到的都是同一个变量。

再看 let 版本:

for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); } // 输出:0 1 2

let 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,函数并不会再继续往下走,而是:

  1. 先求值 await 后面的表达式;
  2. 把 async 函数期望后续执行的代码包装成一个续体;
  3. 把续体放入微任务队列;
  4. 当前 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 的核心步骤:

  1. 创建一个新对象,继承构造函数的 prototype 属性;
  2. 以该对象为 this 调用构造函数;
  3. 如果构造函数返回了一个对象,则返回那个对象;否则返回新对象。

实现范例如下:

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); }

拆解思路分四步:

  1. 第一个 for 里 var i 存在于函数作用域,同一轮循环结束后 i = 3;
  2. 三个 setTimeout 回调都捕获同一个 i,全部输出 3;
  3. 第二个 for 每次迭代都会创建新的块级词法环境,let j 被绑定到各自迭代环境;
  4. 三个回调输出 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 丢失和变量污染的问题会自然减少一大半。这也是我读过这个系列之后,对自己代码可读性要求提升的一个直接改变。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 20:57:58

FreeMarker 2.3.31模板注入修复:Configuration安全初始化指南

简介&#xff1a;本资源针对老旧 JeecgBoot 系统中无法平滑升级 Freemarker 的安全治理难题&#xff0c;提供一种轻量级、侵入性极低的漏洞修复方案&#xff0c;适用于 Java 后端开发人员及企业系统维护工程师。方案核心是重写 Freemarker 2.3.31 版本的 Configuration 类&…

作者头像 李华
网站建设 2026/10/14 20:57:03

写论文如何又快又好?博导推荐这几个AI论文平台

一、中文论文全流程首选&#xff1a;千笔AI&#xff08;教授力荐&#xff09; 定位&#xff1a;中文学术写作天花板&#xff0c;全流程覆盖&#xff0c;适配国内高校 / 期刊规范。核心优势&#xff08;教授最看重&#xff09;&#xff1a; 20秒出大纲&#xff1a;输入研究方向&…

作者头像 李华
网站建设 2026/10/14 20:57:00

2026年北京售电公司实力排行榜:需求响应与交易能力解析

2026年北京电力市场化改革持续纵深推进&#xff0c;工商业用户对售电公司的筛选标准已从单一的价差套利转向负荷管理精度、跨省绿电组织能力、交易偏差控制水平以及数字化决策支撑等综合维度。北京作为华北电网的核心负荷中心&#xff0c;聚集了大量数据中心、总部楼宇、高端制…

作者头像 李华
网站建设 2026/10/14 20:56:14

Agent Harness工程实战:循环、上下文、沙箱、验证与长时运行

1. 从一次翻车说起&#xff1a;为什么 Agent 光有循环还不够去年冬天我接手了一个内部工具链的自动化改造&#xff0c;目标很朴素&#xff1a;让一个基于大模型的 Agent 自己去读代码仓库、定位一个已知的构建失败、改掉它、跑测试、提交。听起来就是标准的“感知—决策—执行”…

作者头像 李华
网站建设 2026/10/14 20:55:38

PyTorch Vision Transformer落地实战:解包即训的5大避坑指南

简介&#xff1a;本资源是面向计算机视觉初学者与PyTorch进阶学习者的Transformer实战项目&#xff0c;聚焦图像分类任务&#xff0c;解决传统CNN模型之外的新型架构迁移与实现问题&#xff0c;特别适合希望深入理解视觉Transformer原理并动手复现的算法实践者。压缩包共4个Pyt…

作者头像 李华