☰
深入理解JavaScript执行上下文:从创建阶段到调用栈
2026/10/14 18:00:02 网站建设 项目流程

1. 先搞清楚执行上下文到底是个什么东西

1.1 一个看似简单却总让人栽跟头的概念

做前端这几年,我面试过不少人,也带过不少新人。每次聊到 JavaScript 的基础知识,执行上下文(Execution Context)这个话题几乎必出现。很多人能背出“执行上下文是代码执行时的环境”这句话,但真让他说说代码到底是怎么跑的、变量是怎么找到的、this 为什么一会儿一个值,立刻就变哑巴了。

我之前在给团队做分享的时候,喜欢用一个类比:把执行上下文理解成“剧组拍戏时的一个拍摄现场”。每一次函数被调用,就等于搭了一个新的片场,里面有几样固定配置——有哪些演员到场(变量)、导演是谁(this 指向谁)、这个片场能通往哪个其他片场(作用域链指向哪里)。代码在这个片场里一句一句地演,演完了就拆台走人。

这个类比虽然粗糙,但方向是对的。

在 JavaScript 里,程序运行的时候并不是代码走到哪儿算哪儿,而是遵循一套严格的“上下文机制”。简单来说,执行上下文就是代码在执行过程中所处的环境抽象,它决定了当前这段代码能访问哪些变量、this 到底指向谁、以及函数之间的依赖关系怎么维护。

每当你打开一个页面,浏览器最先创建一个全局执行上下文;每当你调用一个函数,哪怕是在回调函数里、在事件处理函数里、在 Promise 的 then 里面,JavaScript 引擎都会为这次调用单独创建一个新的执行上下文。

这就是整个机制的起点,也是理解后面所有内容的地基。

1.2 执行上下文的三种类型

JavaScript 中的执行上下文一共有三种,这个基础分类必须刻在脑子里。

第一种是全局执行上下文。它是最底层的那个,整个脚本运行期间只会存在一个。浏览器环境下,全局执行上下文对应的就是 window 对象;Node.js 环境下,对应的是 global 对象。这个上下文里创建的变量和函数,挂在了全局对象上,所以你在控制台直接敲一个在全局声明的变量名,能直接访问到。

第二种是函数执行上下文。每一次函数调用都会创建一个,函数执行结束之后,这个上下文就会被销毁(除非有闭包把它“留住”了,这点后面单独讲)。函数上下文的数量不固定,和调用次数直接挂钩。

第三种是 eval 执行上下文。eval 函数执行时会产生自己的上下文。这个在日常开发中基本碰不到,因为 eval 本身是危险且不推荐使用的。你可以了解它的存在,但不用花太多精力。

关键的一点是:JavaScript 是单线程的,同一时间只能执行一段代码,所以这些执行上下文不可能同时处于“运行”状态。它们会被压进一个叫“调用栈”(Call Stack)的结构里,谁在栈顶谁就正在执行,执行完弹出,下一个继续。

1.3 为什么理解这个机制如此重要

你可能觉得,不就是一个概念嘛,知道有这个东西不就行了?我在实际工作和面试中得到的结论是:理解不透执行上下文,后面几乎所有 JavaScript 进阶知识点都会学得很虚。

举个例子。你写代码时遇到一个变量,它的值为什么是 undefined?为什么有的变量在声明之前能用、有的却直接报错?为什么箭头函数里的 this 和普通函数的 this 不一样?为什么闭包能记住外层的变量?

这些问题,答案全部藏在执行上下文的创建和执行机制里。

再往大了说,你写的每一个函数、每一个模块、每一个组件,运行时都在被这套机制管理着。如果脑子里没有这张“运行地图”,一旦遇到变量遮蔽、循环引用、意外全局变量这类问题,排查起来就会像无头苍蝇一样乱撞。

这个系列前面几篇已经把 JavaScript 的基础语法、原型链、事件循环这些内容讲过了,这篇就专门把执行上下文这块硬骨头啃透。我会从一个一个的执行细节讲起,配合能直接复制运行的代码,尽量让你看完之后,再遇到和上下文相关的面试题、实际 bug,都能在脑子里立刻画出那张运行流程图。

2. 执行上下文的生命周期:创建阶段与执行阶段

2.1 一个执行上下文从生到死经历什么

我之前看很多人画执行上下文的图,画得非常复杂,各种箭头、表格铺满一屏幕。其实拆开来看,一个执行上下文的一生只有两个阶段:创建阶段和执行阶段。

创建阶段发生在函数被调用、但函数体里的代码还没开始逐行执行的那一刻。这个阶段引擎会做三件固定的事:

第一,确定 this 的指向。这件事在创建阶段就定下来了,而且是整个执行期间不可更改的——很多人以为 this 是在调用时才临时决定的,这个说法不准确,准确地说,是在进入这个上下文的时候就已经确定了。

第二,创建词法环境(Lexical Environment)。词法环境就是一张“变量登记表”,它记录了这个上下文内部有哪些变量、哪些函数声明,以及它们当前的值是什么。你可以把它理解成一个随时更新的仓库台账。

第三,创建变量环境(Variable Environment)。变量环境也是一个登记表,但只存放 var 声明的变量。为什么要把 var 单独拎出来?因为 ES6 引入了 let 和 const,它们和 var 在作用域行为上差异巨大,引擎需要在内部区分处理。

创建阶段做完,就进入执行阶段。执行阶段就是一行一行跑代码,每执行到一条赋值语句,就去更新词法环境或变量环境里对应记录的值。

这张“先登记后执行”的流程,就是变量提升(Hoisting)现象的本质来源。var 声明的变量在创建阶段被登记,初始值默认置为 undefined;函数声明则被整体登记,引用直接指向函数对象本身。所以你在代码里写在后面的 function 声明,在前面调用也不报错;而 var 声明的变量在赋值之前拿到的值是 undefined,而不是报错。

let 和 const 就不一样了,它们也在创建阶段被登记,但不会像 var 那样先塞一个 undefined 进去,而是进入一个叫“暂时性死区”(TDZ)的状态。在这个状态下访问变量,引擎会直接抛 ReferenceError。这个设计堵住了很多由于“变量提升带来的隐式 undefined”导致的隐蔽 bug。

2.2 用一段代码看懂创建阶段的全过程

光说概念不够,我拿一段实际代码来拆。

var name = '张三'; let age = 28; function sayHello() { console.log('你好,' + name); var hobby = 'coding'; } sayHello();

这段代码运行时,引擎的第一步是创建全局执行上下文。创建阶段,全局的词法环境里登记了 name(值初始化为 undefined)、age(进入 TDZ)、以及函数 sayHello(完整放入)。变量环境里登记了 name。

注意这里有个细节:var name 同时出现在变量环境和词法环境的处理逻辑里,看起来有点重复。实际上引擎的实现有多种方式,不同浏览器也存在差异,但核心行为一致:var 声明会被登记,初始值为 undefined,且挂在全局对象上;let 和 const 不会挂到全局对象上。

执行阶段开始后,第一行 name = '张三',词法环境里的 name 从 undefined 更新为 '张三'。第二行 age = 28,同样更新。第三行只是函数声明,不执行函数体。

然后代码执行到 sayHello(),引擎为这次调用创建了一个新的函数执行上下文。创建阶段里,sayHello 的上下文中登记了内部的 hobby 变量(初始 undefined,因为它是 var),同时确定了 this 的指向——在非严格模式下,this 指向全局对象;严格模式下,this 是 undefined。

执行函数体时,console.log 需要读取 name。引擎在 sayHello 自己的上下文里没找到 name 这个变量,就沿着作用域链往上找,找到了全局上下文里的 name,输出“你好,张三”。然后 hobby = 'coding' 赋值完成,函数执行完毕,sayHello 的执行上下文从调用栈中弹出,被销毁。

整个过程清晰吗?如果你能跟着这段描述在脑子里画出每个阶段的状态表,那理解就到位了。

2.3 this 绑定的四条规则,附判断顺序

this 是执行上下文创建阶段最容易被搞混的部分。别慌,其实规则不多,核心就四条,而且是有顺序的。

第一条,默认绑定。函数直接调用、没有任何修饰,非严格模式下 this 指向全局对象,严格模式下 this 是 undefined。这就是为什么 ES6 模块和严格模式下,直接调用一个函数,里面想通过 this 拿全局对象会拿不到的原因。

第二条,隐式绑定。函数作为某个对象的方法被调用,比如 obj.method(),那么 method 里的 this 指向 obj。这是最常用的场景,也是很多人理解的“this 指向调用者”的来源。但这个规则有个坑:如果把方法单独取出来再调用,比如 const fn = obj.method; fn(),这时 fn 是一个“裸调用”,走的是默认绑定,this 就丢了。

第三条,显式绑定。用 call、apply、bind 强制指定 this 指向。这三兄弟的区别:call 和 apply 都是立即执行,区别只在传参方式上,call 一个参数一个参数传,apply 传一个数组;bind 不立即执行,返回一个新函数,之后调用时 this 固定。

第四条,new 绑定。用 new 调用构造函数时,this 指向新创建的那个实例对象,并且如果构造函数没有显式返回对象,new 表达式的结果就是 this 指向的这个对象。

判断顺序也很清晰:先看是不是 new 调用,是就 new 绑定;再看有没有 call/apply/bind,有就显式绑定;再看是不是对象方法调用,是就隐式绑定;否则默认绑定,严格模式 undefined,非严格模式全局对象。

这个顺序值得你每次分析 this 时在脑子里过一遍,比死记硬背强得多。

2.4 箭头函数是规则的例外

上面四条规则对于普通函数都成立,但箭头函数是例外中的例外。箭头函数没有自己的 this,它在定义的时候就捕获了外层作用域的 this,之后任何 call、apply、bind 都无法改变它。

这个特性在实际开发中是个双刃剑。优点在于,在回调函数、定时器、事件监听器里头,你不用再费劲 var that = this 或者用箭头函数来保留外层的 this;缺点在于,如果你想让一个对象方法里的 this 动态指向调用者,箭头函数做不到,只要定义在对象字面量里,它的 this 就跟着外层走。

一个典型误区:在对象字面量中定义箭头函数方法,期望它指向这个对象,结果 this 指向了对象外层。让我给你演示一下:

const obj = { name: '测试对象', getName: () => { return this.name; } }; console.log(obj.getName()); // undefined,this 指向的是全局对象,不是 obj

这类代码在面试题里出现频率极高。你只要记住:箭头函数里没有自己的 this,它沿用的是定义位置外层的 this。就永远不会在这个问题上掉坑。

3. 调用栈:执行上下文如何排队协作

3.1 调用栈就是一张“先进后出”的拍摄现场表

单线程的 JavaScript 无法同时执行多段代码,那多个执行上下文之间如何协调?答案就是调用栈。

调用栈实际上就是一个数组结构,遵循后进先出(LIFO)的原则。引擎每遇到一个函数调用,就创建一个新的函数执行上下文,把它压入栈顶;当前函数执行完毕,就把栈顶的上下文弹出,控制权交还给下面的那个上下文。

想象一下一个俄罗斯套娃的打开过程。你打开最外层的大套娃(全局上下文),里面套着一个稍小的(第一个被调用的函数的上下文),再打开一层,里面还套着更小的(这个函数里又调用的另一个函数的上下文)。全部打开、执行完毕之后,从最小的那个开始一个一个合上,直到回到最外层。

我写过一个非常验证性的例子:

function first() { console.log('进入 first'); second(); console.log('离开 first'); } function second() { console.log('进入 second'); third(); console.log('离开 second'); } function third() { console.log('进入 third'); debugger; console.log('离开 third'); } first();

你在浏览器里打开开发者工具跑这段代码,加个 debugger 断点,然后在 Call Stack 面板里就能看到从 anonymous(全局)到 first 到 second 到 third 的完整调用链。这个面板是理解调用栈最直观的工具,比任何讲解都有效。

调用栈还有一个隐含的作用:它记录了代码执行的“回溯路径”。当函数里抛出异常时,错误堆栈信息就是从当前的调用栈中提取的。你在控制台里看到的那个长长的报错信息,就是这段调用路径的完整留痕。

3.2 栈溢出的根源与防范

调用栈的大小不是无限的。每个浏览器对栈深度的限制不同,但大概在万级到十万级的数量区间。如果你写的代码无限递归下去,最终会触发 RangeError: Maximum call stack size exceeded,这个就是大家常说的“栈溢出”。

栈溢出的本质很简单:每一层函数调用都占一份栈空间,但栈空间是有限的,嵌套无限深,栈迟早被填满。

最常见的触发场景是递归没有写终止条件,或者条件写得不对。比如:

function loop() { return loop(); } loop();

这段代码在几毫秒之内就会让页面直接卡住,然后控制台报栈溢出错误。

还有一个容易忽略的场景:在事件循环里同步递归制造无限循环。比如一个递归函数里每层都触发一个新的同步调用,也可能爆栈。不过现在大家写递归时如果担心栈深,可以用循环改写,或者自己模拟一个手动栈来承接任务,把递归改成迭代。这也算是面试官爱抛的一个优化点。

破解栈溢出问题的排查思路通常是三步:第一步,打开控制台看调用栈,找到最深的那个函数;第二步,检查递归出口条件,看看是不是某个边界情况漏了 return;第三步,考虑是否可以用循环替代,或者用大数组结合 while 循环来模拟递归。

3.3 闭包:为什么销毁了还能继续访问

不少人学到闭包的时候开始犯迷糊,因为从直觉上看,函数都执行完了,它的执行上下文不是弹出栈、销毁了吗?为什么闭包还能访问到它内部的变量?

这里要澄清一个关键点:执行上下文被弹出调用栈,不等于它内部的所有数据都被垃圾回收了。

JavaScript 引擎在做垃圾回收时,会检查这个上下文创建的词法环境是否还被外部引用。如果某个内部函数被返回出去,并且这个函数引用了外层函数的变量,那么引擎就会把外层函数上下文中的词法环境保留下来,形成一个“闭包”。

用代码来演示:

function createCounter() { let count = 0; return function() { count++; return count; }; } const counter = createCounter(); counter(); // 1 counter(); // 2 counter(); // 3

createCounter 的上下文在第一次调用时创建,运行完毕后从调用栈弹出。按理说 count 应该被回收了,但是由于返回的那个匿名函数还抓着 count 不放,count 就被保留在实际的闭包环境里。后续每次调用 counter,JavaScript 都会创建一个新的函数执行上下文,但这个新上下文的词法环境中,外层引用指向的正是那份被保留下来的闭包环境,所以 count 的状态得以延续。

一个常见的性能教训:闭包不是免费的。保留闭包意味着对应的词法环境无法被回收,如果不注意,大量使用闭包且长期持有函数引用,会导致内存占用持续上升。比如在循环里创建闭包、把闭包挂到全局变量或者事件监听器上、组件销毁时不清理监听器,这些都会埋下内存泄漏的隐患。我见过不少页面卡顿问题,追根究底,就是闭包把一大块数据锁住不放。

4. 作用域链与变量查找机制

4.1 作用域链是怎么连起来的

前面提到,每个执行上下文的创建阶段会生成词法环境和变量环境。但函数内部的代码不可能只在自己的环境里找变量,它必然会访问外部的变量。引擎是怎么实现这个能力的?靠的就是作用域链。

作用域链的构建其实非常朴素:在执行上下文创建的时候,引擎不只是创建当前这一层的环境记录,还会把“当前环境 → 外部环境 → 更外部环境 → … → 全局环境”这条链条通过引用串起来。

这里特别容易混淆的一点是:作用域链是定义时确定的,不是调用时确定的。JavaScript 采用词法作用域(Lexical Scoping),意思就是,函数能访问哪些外部变量,取决于它在代码里被写在哪里,而不是在哪儿被调用。

我遇到过一个很典型的例子,分享给读者时大家反应都很大:

const globalVar = '全局'; function outer() { const message = 'outer 的 message'; function inner() { console.log(message); console.log(globalVar); } return inner; } const myFn = outer(); myFn();

不管 myFn 在哪儿被调用,它内部访问的 message 永远是 outer 里的那个 message,因为 inner 定义在 outer 内部,词法作用域就这么锁定了。这正是闭包能可靠工作的基石。

4.2 变量查找的完整路径

现在把整条查找链路串一遍。当执行到某个表达式需要读取一个变量时,引擎会做这样一件事:先看当前执行上下文的词法环境里有没有这个变量,有就用;没有,就沿着 outer 引用,去上一级环境的词法环境里找;再没有,继续往上,直到全局环境;如果全局都没有,就报 ReferenceError(严格模式)或者挂一个全局变量并返回 undefined(非严格模式)。

这个查找过程有个特点:一旦找到了就立刻停止,不会继续往上找。这个特点带来了一个头疼的问题——变量遮蔽(Variable Shadowing)。如果在当前作用域里声明了一个和外层同名的变量,内层的这个就会遮蔽掉外层的,外层那个在这片区域里“不可见”了。

举个例子:

let name = '外层'; function show() { let name = '内层'; console.log(name); // '内层' } show();

看起来平平无奇,但很多 bug 就出在这里。你本意是想读外层的 name,却在内层不小心用 let 或 const 重新声明了一个同名的,结果引擎优先找到了内层的,逻辑就跑偏了。

还有一个更隐蔽的情况:函数参数和内部声明的变量同名。如果函数里写了 var name = 'xxx',而参数也叫 name,这不会报错,参数会被内部声明的变量覆盖。如果你在函数里给参数重新赋值,这个赋值操作生效的是参数本身,不是外部同名变量。必须分清“赋值”和“声明”两个操作对作用域的不同影响。

4.3 块级作用域对上下文机制的影响

ES6 之前,JavaScript 只有函数作用域和全局作用域,var 声明的变量甚至没有“块”的概念,for 循环外面还能用循环变量。ES6 引入了 let 和 const,补上了块级作用域这块缺口,也给执行上下文的词法环境添加了块级环境记录。

块级作用域的意思是:一对大括号 { } 内部用 let 或 const 声明的变量,只在那个块内可见。for 循环的循环变量、if 块内部声明的临时变量,都在各自的花括号范围内生效。

这个机制对执行上下文的直接影响是:在执行阶段,引擎遇到一个块级语句时,会创建一个临时的块级词法环境,并把它作为当前词法环境的一部分串联进去。执行离开这个块,块级环境被丢弃(如果有闭包引用除外)。

对于执行上下文来说,还有个常见考点:for 循环里的 var 和 let 表现完全不同。

for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 3, 3, 3 } for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j)); // 0, 1, 2 }

第一个循环里,var 声明的 i 属于函数/全局作用域,只存在一个 i。循环结束后 i = 3,三个定时器读到的都是同一个 i,输出 3 3 3。第二个循环里,let 声明的 j 在每一次迭代都会创建一个新的块级词法环境,每个定时器都捕获了自己那一轮的 j,所以输出 0 1 2。

这个例子同时考察了块级作用域、闭包和执行上下文三者,面试出现率非常高。理解到这一层,你会觉得这些机制其实是环环相扣的,不是一堆零散的概念。

5. 常见问题与调试技巧实录

5.1 五道高频面试题,先自己算再对答案

我在面试别人时,喜欢用下面这几道题来考察候选人对执行上下文的真实掌握程度。建议你先自己心算一遍,再到浏览器里验证。

第一题,变量提升的经典陷阱:

console.log(a); var a = 10;

输出 undefined。因为创建阶段 var a 被登记且初始值为 undefined,执行阶段 console.log 先执行了,赋值还没跑。

第二题,函数声明优先于变量声明:

console.log(typeof foo); function foo() {} var foo = 'bar';

输出 function。创建阶段,函数声明整体放入环境,变量声明 var foo 被标记为已存在,不再重复初始化。所以 typeof 拿到的是 function。

第三题,let 的暂时性死区:

console.log(b); let b = 5;

直接报 ReferenceError,而不是 undefined。这就是 TDZ 的作用。执行栈在创建阶段记录 b 处于“不可访问”状态,任何读取尝试都会报错。

第四题,this 绑定综合题:

const obj = { name: 'obj', getName: function() { return this.name; } }; const fn = obj.getName; console.log(fn()); // ? console.log(obj.getName()); // ?

第一个输出取决于环境:非严格模式输出 window 相关的值,大概率 undefined 或空字符串。第二个输出 'obj'。fn 是裸调用,this 走默认绑定;obj.getName() 是方法调用,this 隐式绑定到 obj。

第五题,闭包与循环变量:

const arr = []; for (var i = 0; i < 3; i++) { arr.push(function() { return i; }); } console.log(arr[1]());

输出 3。因为这三个匿名函数共享了同一个全局/函数作用域里的 i,创建上下文时的作用域链都是同一个,循环结束 i = 3,所以无论取哪个都是 3。

这五道题如果你能全部答对并把理由说清楚,执行上下文这块的知识基本就算过关了。答错了也没关系,对照着上文讲的创建阶段、作用域链、this 绑定规则,回去重新推演一遍,这个推演过程比答案本身更有价值。

5.2 用浏览器调试工具把执行上下文“看”出来

光在脑子里推演还不够,我强烈建议你在浏览器里亲自“看”一遍执行上下文的生命周期。

第一步,打开开发者工具,切到 Sources 面板,找到你的 JavaScript 文件,在你想观察的位置点一下行号,加上断点。建议加在函数内部的第一行,比如:

function test(a) { debugger; // 打断点 let b = 2; return a + b; } test(1);

第二步,刷新页面,代码会停在 debugger 那一行。此时右侧面板会展示三块关键信息:Call Stack 显示当前调用链;Scope 面板显示当前作用域的所有变量及其值,并且会分类显示 Global、Local、Closure 等不同的作用域层级;Watch 面板可以手动输入表达式动态查看值。

第三步,点击“继续执行”或者逐行执行按钮,你会看到 Scope 面板中变量的值随着语句一条条变化。比如一开始 b 还没执行赋值,Scope 面板里可能根本看不到 b 的条目,等你执行了 let b = 2 这一行之后,b 才出现,值是 2。这就是创建阶段和执行阶段的区别在实际调试里的体现。

还有一个非常实用的技巧:在 console 里直接运行一个函数调用,然后立刻展开错误堆栈。堆栈的每一行就是调用栈里那一层执行上下文的“身份信息”。看堆栈报错排查问题,比我之前瞎猜变量值快得多。

5.3 长期实践中攒下的避坑心得

最后分享几条我踩过多次坑之后总结出的实用心得,不一定在教科书里找得到,但非常管用。

第一条,尽早开启严格模式。在文件顶部加上 'use strict',非严格模式下的隐式全局变量和 this 默认绑定行为会收敛很多。很多“变量名拼错导致意外创建全局变量”的坑,严格模式直接给你报错,省去漫长的排查时间。

第二条,注意回调函数里的 this。事件监听器、setTimeout、数组方法回调,这些场景里的 this 指向各不相同。事件监听器里的 this 指向绑定事件的元素,setTimeout 回调里的 this 在严格模式下是 undefined,在非严格模式是全局对象。如果你下意识认为“回调函数里的 this 就是外面的 this”,那就错了。解决方式无非三种:箭头函数、bind 提前绑定、闭包缓存外层 this。

第三条,别把作用域链和原型链搞混。这是新手特别容易混的一对概念。作用域链解决的是“这个变量在哪个环境中定义”的问题,原型链解决的是“对象的属性如何继承放”的问题。变量查找走作用域链,属性查找走原型链,两者是平行的机制。面试时这两个概念先后出现,一定要在脑子里把它们分开。

第四条,善用命名约定区分变量层级。比如全局变量用全大写加下划线,模块内私有变量用下划线开头,函数内临时变量用短名字。虽然 JavaScript 不做强制,但好的命名约定能让你在读到一段代码时,立刻判断它的查找路径大致走多远,对排查作用域引起的问题帮助极大。

写在最后

执行上下文这东西,每个前端都会遇到,但真正把它研究透彻的人并不多。我自己也是花了不少时间,踩了不少变量提升、this 丢失的坑之后,才逐渐把这条知识链串起来。建议你一定要动手去浏览器里调试一遍,亲眼看看 Scope 面板的变化,比看十篇文章都管用。

这篇文章是这个系列的第四篇,前面的内容如果还没看,建议按顺序回头把原型链和事件循环两篇补一下,因为执行上下文和它们的交集非常紧密。下一篇计划聊一聊模块化的发展历程和 ES Module 的底层机制,到时会继续沿用这种带图、带调试、带实战的风格,把每一个概念都讲到能直接用为止。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询