☰
JavaScript 深入系列:执行上下文栈(Execution Context Stack)原理与经典思考题全解析
2026/9/30 2:00:13 网站建设 项目流程
  • 技术博客
  • 文档
  • 教程

【免费下载链接】Blog

冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。

项目地址:https://gitcode.com/GitHub_Trending/blo/Blog
点击查看免费下载

本文是冴羽博客「JavaScript 深入系列」的第三篇,围绕"JavaScript 引擎如何管理执行上下文"这一核心机制展开:先破除"JavaScript 代码总是顺序执行"的直观印象,引出可执行代码与执行上下文的概念,再用数组模型模拟执行上下文栈(ECStack)的压栈、弹栈全过程,并以此解答上篇《词法作用域和动态作用域》留下的经典思考题。读完本文,你将掌握执行上下文栈的工作模型、globalContext常驻栈底的特性,以及"函数调用即压栈、函数返回即弹栈"的底层规律,并能用同一套模型解释复杂的嵌套调用与闭包场景。

顺序执行?——两个例子颠覆直觉

谈到 JavaScript 的代码执行顺序,绝大多数开发者都有"顺序执行"的直观印象。例如下面这段代码,按照书写顺序依次执行,结果符合预期:

var foo = function () { console.log('foo1'); } foo(); // foo1 var foo = function () { console.log('foo2'); } foo(); // foo2

但换一种写法,结果却完全不同:

function foo() { console.log('foo1'); } foo(); // foo2 function foo() { console.log('foo2'); } foo(); // foo2

两处foo()打印的都是foo2。刷过面试题的开发者都知道原因:JavaScript 引擎并不是一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个"准备工作"——第一个例子对应变量提升(var foo被提升但值未赋值),第二个例子对应函数提升(后声明的函数覆盖了先声明的函数)。

但本文真正想让大家思考的是:这个"一段一段"中的"段"究竟是怎么划分的?JavaScript 引擎遇到一段怎样的代码时才会做"准备工作"?

可执行代码(Executable Code)的三种类型

答案要从 JavaScript 的可执行代码(executable code)类型说起。JavaScript 中的可执行代码其实只有三种:

  1. 全局代码(global code):整个脚本文件或<script>标签内的顶层代码;
  2. 函数代码(function code):每次调用函数时,函数体内的代码;
  3. eval 代码(eval code):通过eval()动态执行的代码。

当执行到其中任何一种可执行代码时,引擎就会进行"准备工作",这个"准备工作"在专业术语中被称为创建"执行上下文"(execution context)。

说明:虽然现代 JavaScript 还存在Module代码(模块顶层代码)等更细的分类,但本文所述三种类型是理解执行上下文栈最基础的划分,也是本系列(词法作用域和动态作用域)讨论的前提。

执行上下文栈(ECS):如何管理无数个执行上下文

函数多了、调用嵌套深了,创建的执行上下文自然也多,JavaScript 引擎如何管理它们?答案是执行上下文栈(Execution context stack,简称 ECS)。

为了模拟执行上下文栈的行为,可以把执行上下文栈定义为一个数组:

ECStack = [];

当 JavaScript 开始解释执行代码时,最先遇到的就是全局代码,所以初始化时首先向执行上下文栈压入一个全局执行上下文,用globalContext表示:

ECStack = [ globalContext ];

这里有一个关键特性:只有当整个应用程序结束的时候,ECStack 才会被清空,所以 ECStack 的底部永远有一个globalContext。无论嵌套多少层函数调用,全局执行上下文始终存在。

压栈与弹栈的完整流程

当执行一个函数时,就会创建一个执行上下文并压入执行上下文栈;当函数执行完毕,其执行上下文从栈中弹出。以经典的嵌套调用为例:

function fun3() { console.log('fun3') } function fun2() { fun3(); } function fun1() { fun2(); } fun1();

整个执行过程用伪代码描述如下:

// 伪代码 // fun1() ECStack.push(<fun1> functionContext); // fun1中竟然调用了fun2,还要创建fun2的执行上下文 ECStack.push(<fun2> functionContext); // 擦,fun2还调用了fun3! ECStack.push(<fun3> functionContext); // fun3执行完毕 ECStack.pop(); // fun2执行完毕 ECStack.pop(); // fun1执行完毕 ECStack.pop(); // javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

可以直观地看到:函数调用越深,栈越高;每次pop()之后,控制权交还给栈顶之下的执行上下文,继续执行其剩余代码。这种"后进先出"(LIFO)的模型,正是理解 JavaScript 中递归、异常抛出(throw/try...catch)以及闭包行为的基础。

解答思考题:两段代码的执行上下文栈变化差异

现在,我们已经了解执行上下文栈如何处理执行上下文,可以回答上一篇《JavaScript 深入之词法作用域和动态作用域》最后提出的思考题了。题目出自《JavaScript 权威指南》:

第一段代码:

var scope = "global scope"; function checkscope(){ var scope = "local scope"; function f(){ return scope; } return f(); } checkscope();

第二段代码:

var scope = "global scope"; function checkscope(){ var scope = "local scope"; function f(){ return scope; } return f; } checkscope()();

两段代码的执行结果相同(都打印local scope,因为 JavaScript 采用词法作用域,函数f的作用域基于其定义位置,与调用方式无关),但两段代码究竟有什么不同?

答案就是:执行上下文栈的变化不一样。

模拟第一段代码(return f(),先调用后返回):

ECStack.push(<checkscope> functionContext); ECStack.push(<f> functionContext); ECStack.pop(); ECStack.pop();

模拟第二段代码(return f;后由外层立即调用f):

ECStack.push(<checkscope> functionContext); ECStack.pop(); ECStack.push(<f> functionContext); ECStack.pop();

区别一目了然:

  • 第一段:f是在checkscope的执行上下文中被压栈、弹栈的,f弹出后checkscope才弹出,两者是嵌套关系;
  • 第二段:checkscope先执行完毕并弹出,之后f才作为返回值被外部调用,重新压入栈中,两者是先后关系。

这也是为什么return f的场景下f能够访问checkscope内的scope变量——虽然checkscope的执行上下文已从栈中弹出,但f在创建时通过内部属性[[scope]]保存了父级作用域链(词法作用域在函数定义时已确定),这一机制正是闭包的基础,本系列后续文章会详细展开。

系列呼应:从仓库文档看执行上下文栈的完整图景

执行上下文栈只是 JavaScript 执行模型的一环。在本仓库「深入系列文章」中,围绕这一主题形成了一条完整的知识链,建议对照阅读:

  • 《JavaScript深入之词法作用域和动态作用域》:本文思考题的出处,讲解 JavaScript 采用词法作用域(静态作用域),函数作用域在定义时决定;文中还提到动态作用域语言 bash 的对比实验,其可运行脚本保存在 demos/scope/scope.bash;
  • 《JavaScript深入之变量对象》:讲解执行上下文中的变量对象(VO)与活动对象(AO),说明"进入执行上下文"阶段如何加入形参、函数声明、变量声明;
  • 《JavaScript深入之作用域链》:讲解函数创建时保存[[scope]]、激活时构建Scope = [AO].concat([[Scope]])的完整过程,并以checkscope为例演示 ECStack 的压栈与弹栈;
  • 《JavaScript深入之执行上下文》:把执行上下文栈、变量对象、作用域链三者结合起来,对本文的思考题第一段代码做了逐步推演(从globalContext入栈、checkscope.[[scope]]保存,到checkscopeContext、fContext依次压栈与弹栈),并留下第二段代码供读者自行模拟。

从源码结构看,每个执行上下文都包含三个重要属性——变量对象(Variable object,VO)、作用域链(Scope chain)与this,本文所讲的执行上下文栈正是这三个属性赖以存在与切换的"容器"。理解了压栈/弹栈的时机,才能真正理解"进入执行上下文"时变量对象如何初始化、作用域链如何拼接。

小结

本文的核心结论可以归纳为四点:

  1. JavaScript 引擎分段执行代码,三种可执行代码(全局、函数、eval)各自对应一个执行上下文;
  2. 执行上下文由执行上下文栈(ECStack)管理,全局执行上下文globalContext常驻栈底,应用结束前永不清空;
  3. 函数调用时其执行上下文压栈,函数执行完毕弹栈,栈顶始终是当前正在执行的上下文;
  4. 上篇思考题中两段"结果相同"的代码,区别在于执行上下文栈的变化不同:一段是f在checkscope内部嵌套压栈,一段是checkscope先弹栈、f再被压入。

当然,"执行上下文栈的变化不同"仍是相对概括的回答。要更详细地剖析两个函数执行上的区别(如变量对象如何初始化、作用域链如何拼接、this如何确定),需要进一步探究执行上下文内部包含的内容——这正是下一篇《JavaScript 深入之变量对象》的主题,见 JavaScript深入之变量对象。

如果你希望系统性补齐这些 JavaScript 底层概念,可以按本仓库 README.md 中的「深入系列」目录顺序阅读:原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call/apply/bind、new、继承等难点均有对应文章展开。

  • 技术博客
  • 文档
  • 教程

【免费下载链接】Blog

冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。

项目地址:https://gitcode.com/GitHub_Trending/blo/Blog
点击查看免费下载
上一篇:FastAPI分块上传存储:对象存储集成完整指南
下一篇:minimind-v模型转换指南:使用convert_vlm.py进行格式转换

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询