- 技术博客
- 文档
- 教程
【免费下载链接】Blog
冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。
本文是冴羽博客「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 中的可执行代码其实只有三种:
- 全局代码(global code):整个脚本文件或
<script>标签内的顶层代码; - 函数代码(function code):每次调用函数时,函数体内的代码;
- 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,本文所讲的执行上下文栈正是这三个属性赖以存在与切换的"容器"。理解了压栈/弹栈的时机,才能真正理解"进入执行上下文"时变量对象如何初始化、作用域链如何拼接。
小结
本文的核心结论可以归纳为四点:
- JavaScript 引擎分段执行代码,三种可执行代码(全局、函数、eval)各自对应一个执行上下文;
- 执行上下文由执行上下文栈(ECStack)管理,全局执行上下文
globalContext常驻栈底,应用结束前永不清空; - 函数调用时其执行上下文压栈,函数执行完毕弹栈,栈顶始终是当前正在执行的上下文;
- 上篇思考题中两段"结果相同"的代码,区别在于执行上下文栈的变化不同:一段是
f在checkscope内部嵌套压栈,一段是checkscope先弹栈、f再被压入。
当然,"执行上下文栈的变化不同"仍是相对概括的回答。要更详细地剖析两个函数执行上的区别(如变量对象如何初始化、作用域链如何拼接、this如何确定),需要进一步探究执行上下文内部包含的内容——这正是下一篇《JavaScript 深入之变量对象》的主题,见 JavaScript深入之变量对象。
如果你希望系统性补齐这些 JavaScript 底层概念,可以按本仓库 README.md 中的「深入系列」目录顺序阅读:原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call/apply/bind、new、继承等难点均有对应文章展开。
- 技术博客
- 文档
- 教程
【免费下载链接】Blog
冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。
相关推荐
AVA 执行上下文(Execution Context)完全指南:深入理解每个测试与钩子收到的 `t` 对象
AVA 执行上下文(Execution Context)完全指南:深入理解每个测试与钩子收到的 t 对象 AVA 是面向 Node.js 的并发测试运行器,它把
测试深入解析 @truffle/require:在 Truffle 上下文中安全执行 JavaScript 与 TypeScript 模块
深入解析 @truffle/require:在 Truffle 上下文中安全执行 JavaScript 与 TypeScript 模块 导读 @truffle/
区块链开发工具Web3深入解析 Flask 的 App Context 与 Request Context:上下文机制与源码级原理
深入解析 Flask 的 App Context 与 Request Context:上下文机制与源码级原理 本篇指南以 Flask 官方的 App/Reque
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考