深入剖析 JavaScript 执行上下文:代码运行的幕后机制

在 JavaScript 编程中,执行上下文是一个至关重要却又常被忽视的概念。它如同幕后的导演,掌控着代码的执行流程和变量的管理。理解执行上下文,对于深入掌握 JavaScript 的运行机制、排查代码错误以及优化代码性能都有着不可估量的作用。

一、什么是执行上下文

执行上下文是 JavaScript 引擎在执行代码时创建的一种抽象概念,它定义了变量、函数声明和其他标识符的可访问范围。简单来说,执行上下文就像是一个容器,包含了代码执行时所需的各种信息,如变量环境、词法环境、作用域链、this值等。

每当 JavaScript 代码开始执行时,就会创建执行上下文,并且在代码执行过程中,根据需要可能会创建多个执行上下文,这些执行上下文共同构成了执行上下文栈,以确保代码按照正确的顺序和规则执行。

二、执行上下文的类型

JavaScript 中有三种主要的执行上下文类型:

(一)全局执行上下文

  • 全局执行上下文是最外层的执行上下文,在浏览器环境中,它与window对象紧密相关(在 Node.js 环境中是global对象)。当 JavaScript 代码加载并开始执行时,首先会创建全局执行上下文。
  • 全局执行上下文在整个页面或程序的生命周期内始终存在,直到页面卸载或程序结束。
  • 在全局执行上下文中,定义的全局变量和函数会成为window对象(或global对象)的属性和方法。例如:
javascript 复制代码
var globalVar = '全局变量';
function globalFunction() {
    console.log('这是一个全局函数');
}
console.log(window.globalVar); // 输出: 全局变量
window.globalFunction(); // 输出: 这是一个全局函数

(二)函数执行上下文

  • 每当一个函数被调用时,就会创建一个新的函数执行上下文。函数执行上下文包含了函数执行时的局部变量、参数以及函数内部定义的其他函数。
  • 不同的函数调用会创建不同的函数执行上下文,它们相互独立,互不干扰。
  • 函数执行上下文的生命周期从函数被调用开始,到函数执行完毕返回结果或者抛出异常结束。例如:
javascript 复制代码
function outerFunction() {
    var outerVar = '外部函数变量';
    function innerFunction() {
        var innerVar = '内部函数变量';
        console.log(outerVar); // 可以访问外部函数的变量
        console.log(innerVar);
    }
    innerFunction();
}
outerFunction();

在上述代码中,outerFunction被调用时创建了一个函数执行上下文,innerFunction被调用时又创建了另一个函数执行上下文。innerFunction的执行上下文可以访问outerFunction执行上下文中的变量,这是通过作用域链实现的,后面会详细介绍。

(三)Eval 执行上下文

eval函数是 JavaScript 中的一个特殊函数,它可以将字符串作为 JavaScript 代码进行解析和执行。当eval函数被调用时,会创建一个eval执行上下文。不过,由于eval函数存在安全风险和性能问题,在现代 JavaScript 开发中,一般不推荐使用。例如:

javascript 复制代码
var code = "var localVar = '通过eval定义的变量'; console.log(localVar);";
eval(code); // 输出: 通过eval定义的变量

在非严格模式下,直接调用 eval 时,它创建的执行上下文与调用它的上下文共享作用域,所以可以访问和修改调用上下文里的变量,这就容易引发意外行为和安全漏洞。

三、执行上下文的创建过程

执行上下文的创建过程可以分为两个阶段:词法环境和变量环境的创建阶段以及执行阶段。在 ES6 引入词法环境和变量环境的概念后,对执行上下文的创建过程有了更清晰、更符合规范的描述。具体如下:

(一)词法环境与变量环境的创建

词法环境

词法环境是一种规范类型,它定义了标识符与变量的关联关系。每个执行上下文都有一个与之关联的词法环境。词法环境由两部分组成:环境记录和外部词法环境引用。

环境记录

环境记录是用来存放变量和函数声明的实际数据结构。在不同的执行上下文中(函数执行上下文或全局执行上下文),存放的东西有所不同。例如:

  • 函数执行上下文(以foo函数为例)
javascript 复制代码
function foo(a, b) {
    var localVar = '局部变量';
    function inner() {
        // 此处的词法环境的环境记录包含了inner函数的参数(如果有)、局部变量(无)以及函数声明(无)
        // 外部词法环境引用指向foo函数的词法环境
    }
    // foo函数词法环境的环境记录包含了参数a、b,局部变量localVar以及函数声明inner
    // 外部词法环境引用指向全局词法环境
}
    1. 对于foo函数来说,当它的执行上下文创建时,它的环境记录里就会放入一些东西:
    • 参数 :函数foo接收了两个参数ab,它们会被存放在环境记录中。
    • 局部变量var localVar = '局部变量';这行代码声明了一个局部变量localVar,这个变量也会被放入foo函数的环境记录中。局部变量就像是foo函数自己 "私有的物品",只能在foo函数内部使用。
    • 函数声明function inner() {... }foo函数内部声明了一个inner函数,这个函数声明同样会被存放在foo函数的环境记录中。
  • 同时,foo函数的词法环境有一个 "外部词法环境引用",它指向全局词法环境。这就好比foo函数所在的 "房间"(词法环境)有一扇门,这扇门通向了全局的 "大房间"(全局词法环境)。

    1. 对于内部函数inner,它也有自己的词法环境和环境记录。在它的环境记录中:
    • 由于 inner 函数既未定义参数,也未声明局部变量和其他函数,所以环境记录里没有这些内容。
    • inner 函数词法环境的 "外部词法环境引用" 指向 foo 函数的词法环境,这意味着 inner 函数所在的 "房间" 有扇 "门" 通向 foo 函数的 "房间"。当 inner 函数查找变量或函数时,若在自身环境记录中未找到,便可通过这扇 "门" 去 foo 函数的环境记录中查找。
  • 全局执行上下文

    • 全局词法环境的环境记录里,存放着全局变量(比如在最外层声明的变量)和全局函数声明(在全局作用域下声明的函数)。
    • 由于全局词法环境已经是最外层的了,没有 "更外面" 的词法环境可以连接,所以它的 "外部词法环境引用" 为null,就好比全局这个 "大房间" 没有向外的门了。
外部词法环境引用
  • 在 JavaScript 中,每个词法环境都具备一个指向外部词法环境的引用,这个引用如同纽带,将当前词法环境与包含它的外层词法环境相连,进而形成了词法环境链。

  • 在函数嵌套时,词法环境链的结构一目了然。内部函数词法环境的外部引用,直接指向它的外层函数词法环境;外层函数词法环境的外部引用,又指向更外层函数的词法环境。如此层层嵌套,所有词法环境最终都会指向全局词法环境。

  • 作用域链在执行变量查找时,会沿着词法环境链,依次在各个词法环境的环境记录中查找变量。当在当前词法环境的环境记录中未找到目标变量时,就会依据当前词法环境的外部词法环境引用,去查找外层词法环境的环境记录,如此这般,作用域链顺着词法环境链逐层深入,直至找到变量或抵达全局词法环境。若到达全局词法环境仍未找到,则会抛出引用错误。

变量环境

变量环境也是一种词法环境,在 ES6 规范中,它专门用于存储使用 var 声明的变量。变量环境的结构和词法环境类似,也包含环境记录和外部词法环境引用。

执行上下文创建时,变量环境的环境记录会将 var 声明的变量进行初始化,把它们的值设为 undefined,这就是所谓的 "变量提升" 现象。这意味着无论 var 声明的变量在代码中的实际位置如何,在执行上下文创建阶段就已经完成了初始化。例如:

javascript 复制代码
console.log(x); 
var x = 10;

在上述代码中,console.log(x) 语句在 var x = 10; 之前执行,但并不会报错,而是输出 undefined。这是因为在执行上下文创建时,变量环境的环境记录已经对 x 进行了初始化,将其值设为 undefined。实际代码的执行顺序就如同下面这样:

javascript 复制代码
var x; // 变量提升,初始值为 undefined
console.log(x); 
x = 10;

除了 var 声明的变量,函数声明也会受到变量环境的影响。在执行上下文创建时,函数声明会被提升到变量环境的环境记录中,这被称为 "函数声明提升"。这使得在函数声明之前就可以调用该函数。例如:

javascript 复制代码
foo(); 
function foo() {
    console.log('Hello, World!');
}

在执行上下文创建阶段,JavaScript 引擎扫描到函数声明 function foo() {... },然后将整个函数定义存储到变量环境的环境记录中。此时,虽然代码还未正式执行,但 foo 已经在当前作用域中存在了,并且指向了函数定义。

执行上下文创建完成后,就进入执行阶段,代码会按照书写顺序逐行执行。当执行到 foo(); 时,JavaScript 引擎会在当前作用域的变量环境中查找 foo

由于在创建阶段已经将 foo 函数声明存储到了变量环境中,所以引擎能够找到 foo 对应的函数定义,然后调用该函数,进而执行函数内部的代码 console.log('Hello, World!');,最终输出 Hello, World!

(二)执行阶段

在词法环境和变量环境创建完成后,进入执行阶段。在这个阶段,JavaScript 引擎会按照代码的顺序逐行执行,为变量赋值,调用函数等。在执行过程中,如果遇到新的函数调用,会再次创建新的函数执行上下文,并将其压入执行上下文栈中。

四、执行上下文栈

执行上下文栈(Execution Context Stack,简称 ECS),也被称为调用栈(Call Stack),是一个存储执行上下文的栈结构。它负责管理和维护 JavaScript 代码的执行顺序。

(一)执行上下文栈的工作原理

当 JavaScript 代码开始执行时,首先会创建全局执行上下文,并将其压入执行上下文栈的底部。随着代码的执行,每当遇到函数调用,就会创建相应的函数执行上下文,并将其压入栈顶。函数执行完毕后,其对应的执行上下文会从栈顶弹出,控制权交还给调用该函数的执行上下文,继续执行剩余的代码。例如:

javascript 复制代码
function openHomePage() {
    console.log('打开主页');
    openArticlePage();
    console.log('返回主页');
}

function openArticlePage() {
    console.log('打开文章页面');
    openCommentPage();
    console.log('返回文章页面');
}

function openCommentPage() {
    console.log('打开评论页面');
    console.log('关闭评论页面');
}

openHomePage();

当上述代码开始执行时,会先创建全局执行上下文并将其压入执行上下文栈的底部。在全局执行上下文中,发现了 openHomePage() 函数调用语句。

调用 openHomePage 函数

  • openHomePage 函数的执行上下文被压入栈顶。
  • 执行 console.log('打开主页');,输出 打开主页
  • 接着调用 openArticlePage() 函数。

调用 openArticlePage 函数

  • openArticlePage 函数的执行上下文被压入栈顶。
  • 执行 console.log('打开文章页面');,输出 打开文章页面
  • 接着调用 openCommentPage() 函数。

调用 openCommentPage 函数

  • openCommentPage 函数的执行上下文被压入栈顶。
  • 执行 console.log('打开评论页面');,输出 打开评论页面
  • 执行 console.log('关闭评论页面');,输出 关闭评论页面
  • openCommentPage 函数执行完毕,其执行上下文从栈顶弹出。

继续执行 openArticlePage 函数

  • 此时栈顶是 openArticlePage 函数的执行上下文。
  • 执行 console.log('返回文章页面');,输出 返回文章页面
  • openArticlePage 函数执行完毕,其执行上下文从栈顶弹出。

继续执行 openHomePage 函数

  • 此时栈顶是 openHomePage 函数的执行上下文。
  • 执行 console.log('返回主页');,输出 返回主页
  • openHomePage 函数执行完毕,其执行上下文从栈顶弹出。

最后回到全局执行上下文,全局执行上下文中没有其他代码需要执行,程序结束。

(二)执行上下文栈与递归函数

递归函数是指在函数内部调用自身的函数。执行上下文栈在递归函数的执行过程中起着关键作用。每一次递归调用都会创建一个新的函数执行上下文并压入栈中。如果递归没有正确的终止条件,执行上下文栈会不断增长,最终导致栈溢出错误(Stack Overflow Error)。例如:

javascript 复制代码
function recursiveFunction() {
    recursiveFunction();
}
recursiveFunction(); // 报错: Uncaught RangeError: Maximum call stack size exceeded

在这个例子中,recursiveFunction没有终止条件,会一直递归调用自身,导致执行上下文栈不断被压入新的执行上下文,最终超出栈的最大容量,抛出栈溢出错误。

五、执行上下文中的作用域链

作用域链是执行上下文的一个重要组成部分,它决定了变量和函数的查找规则。在执行上下文创建时,会根据函数定义的位置构建作用域链。而词法环境在作用域链的构建中扮演着核心角色。

(一)作用域链的构建

作用域链本质上是由当前执行上下文的词法环境以及其所有父级执行上下文的词法环境组成的链表结构。在函数执行上下文中,首先会将自身的词法环境添加到作用域链的前端,然后依次添加父级执行上下文的词法环境。例如:

javascript 复制代码
var globalVar = '全局变量';
function outerFunction() {
    var outerVar = '外部函数变量';
    function innerFunction() {
        var innerVar = '内部函数变量';
        console.log(globalVar); // 从当前作用域链开始查找,找到全局词法环境中的globalVar,输出: 全局变量
        console.log(outerVar); // 在外部函数词法环境中找到outerVar,输出: 外部函数变量
        console.log(innerVar); // 在当前函数词法环境中找到innerVar,输出: 内部函数变量
    }
    innerFunction();
}
outerFunction();

innerFunction的执行上下文中,其作用域链包含了innerFunction自身的词法环境(其环境记录包含innerVar)、outerFunction的词法环境(其环境记录包含outerVar)以及全局执行上下文的词法环境(其环境记录包含globalVar)。

innerFunction查找变量时,会从作用域链的前端开始,依次查找每个词法环境的环境记录中的属性,直到找到匹配的变量或者到达作用域链的末尾。

(二)作用域链与闭包

闭包是指一个函数能够访问并记住其外部函数作用域中的变量,即便外部函数已经执行完毕。从实现机制来讲,闭包的形成既依赖于作用域链,更与词法环境紧密相关,它们共同构建了闭包能够维持对外部变量引用的基础 。例如 :

js 复制代码
function createPerson(name) {
    let privateName = name;

    return {
        getName: function () {
            return privateName;
        },
        setName: function (newName) {
            privateName = newName;
        }
    };
}

const person = createPerson('Alice');
console.log(person.getName()); // 输出: Alice
person.setName('Bob');
console.log(person.getName()); // 输出: Bob
  • 当调用 createPerson('Alice') 时,会创建 createPerson 函数的执行上下文,该执行上下文包含一个词法环境。这个词法环境的环境记录中会存储参数 name 和变量 privateName,并且将 name 的值 'Alice' 赋给 privateName

  • 每个函数都有自己的作用域链,对于 createPerson 函数,其作用域链包含自身的词法环境和全局执行上下文的词法环境。在其内部定义了 getNamesetName 两个方法。这两个方法在定义时,它们的作用域链就已经包含了 createPerson 函数的词法环境。

  • createPerson 函数执行完毕后,按照常规情况,其执行上下文会从执行栈中弹出,对应的词法环境似乎应该被销毁。但是,由于 getNamesetName 方法对 privateName 变量存在引用,所以 createPerson 函数的词法环境不会被销毁,而是被保留在内存中。这就形成了闭包。

  • getName 方法 :当调用 person.getName() 时,JavaScript 引擎会沿着 getName 方法的作用域链查找变量。首先在 getName 方法自身的词法环境中查找 privateName,由于 getName 方法没有自己定义 privateName 变量,所以继续沿着作用域链到 createPerson 函数的词法环境中查找,最终找到了 privateName 变量,并返回其值 'Alice'

  • setName 方法 :当调用 person.setName('Bob') 时,同样沿着 setName 方法的作用域链,在 createPerson 函数的词法环境中找到 privateName 变量,并将其值修改为 'Bob'

  • 由于外部代码无法直接访问 createPerson 函数的词法环境,所以 privateName 变量对于外部来说是私有的。外部代码只能通过 person 对象的 getNamesetName 方法来间接访问和修改 privateName 变量,这就实现了数据的封装和保护。

相关推荐
上单带刀不带妹1 分钟前
手写 Vue 中虚拟 DOM 到真实 DOM 的完整过程
开发语言·前端·javascript·vue.js·前端框架
前端风云志3 分钟前
typescript结构化类型应用两例
javascript
杨进军22 分钟前
React 创建根节点 createRoot
前端·react.js·前端框架
ModyQyW37 分钟前
用 AI 驱动 wot-design-uni 开发小程序
前端·uni-app
说码解字43 分钟前
Kotlin lazy 委托的底层实现原理
前端
gnip1 小时前
总结一期正则表达式
javascript·正则表达式
爱分享的程序员1 小时前
前端面试专栏-算法篇:18. 查找算法(二分查找、哈希查找)
前端·javascript·node.js
翻滚吧键盘1 小时前
vue 条件渲染(v-if v-else-if v-else v-show)
前端·javascript·vue.js
vim怎么退出1 小时前
万字长文带你了解微前端架构
前端·微服务·前端框架
你这个年龄怎么睡得着的1 小时前
为什么 JavaScript 中 'str' 不是对象,却能调用方法?
前端·javascript·面试