[核心概念] 一文说透 JS 中的执行上下文

1,562 阅读12分钟

执行上下文

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

面试题

  • 什么是执行上下文?
  • 当 JS 引擎处理一段脚本内容的时候,它是以怎样的顺序解析和执行的?
  • 与之相关的词法环境,this 指向,全局执行上下文,变量对象等都是些啥?

这是干什么的?

当引擎解析到可执行代码时,会创建对应的 "执行上下文(execution context 简称 EC)" 或者也可以叫做执行环境。它是代码执行环境的抽象概念。

和作用域的区别

作用域【关联概念(强)】听上去有点像,但不要搞混,是不同概念。

最大区别简单来说,之前提到的词法作用域规则是在代码定义时就确定了,无可改变。

而执行上下文是在代码执行时可以改变的环境。

比如你定义了一个函数,它的作用域函数定义时就已经确定了,但是执行上下文函数调用之前创建的。

执行上下文栈

我们每调用一个函数,就会创建一个新的执行上下文,那么如何管理如此复杂的调用创建的执行上下文呢?

这就有了执行上下文栈,或者叫调用栈执行栈,execution context stack,ECS都行。这个概念听着耳熟,聊 this 时是不是经常听到,分析 this 指向就分析调用栈,后面细说。

栈【数据结构】, 具有 LIFO(后进先出)结构。我们用它来存储在代码执行期间创建的所有执行上下文。

全局执行上下文

首先我们介绍下一个特殊的执行上下文,全局执行上下文,一个程序中只会存在一个唯一的全局上下文。

当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以首先就会向执行上下文栈压入一个全局执行上下文,它在整个程序生命周期内都会存在于执行栈的最底部,除非整个应用程序结束,栈才会被清空。

函数执行上下文

我们每调用一个函数,就会创建一个新的执行上下文。一般我们所讨论的都是函数执行上下文。

模拟过程

我们简易来模拟下,定义一个数组ExecutionContextStack = []代表执行上下文栈。

首先是全局执行上下文入栈。

// 全局执行上下文 globalContext 表示
ExecutionContextStack = [
    globalContext
];

然后执行到这段代码

function inner() {
    console.log('k')
}

function outer() {
    inner();
}

outer();

那么大概模拟过程的伪代码应该是

// 函数执行上下文 functionContext 表示
// 调用outer();
ExecutionContextStack.push(<outer> functionContext);

// outer中调用了inner,还要创建inner函数的执行上下文
ExecutionContextStack.push(<inner> functionContext);

// inner执行完毕
ExecutionContextStack.pop();

// outer执行完毕
ExecutionContextStack.pop();

// javascript接着执行下面的代码,为新的函数创建新的执行上下文

大概流程图示

全局执行上下文入栈    outer函数上下文入栈          inner函数上下文入栈 
|             |  |                      |  |inner functionContext |
|             |->|outer functionContext |->|outer functionContext |->
|globalContext|  |     globalContext    |  |    globalContext     |

   inner执行完出栈            outer执行完毕出栈 
|                       |  |               |   继续执行下面的代码,但是栈底永
| outer functionContext |->|               |-> 远有个globalContext直到整
|    globalContext      |  | globalContext |     个应用程序结束执行,栈置空

执行上下文的创建过程

为什么要了解创建过程,实际上就是在分解执行上下文的结构

大概分为这几部分

  • 确定 this 如何绑定 (This Binding)
  • 词法环境 (Lexical Environment)
  • 变量环境 (Variable Environment) 看上去长这样
// 执行上下文概念伪代码
ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

This Binding

简单来说,在全局执行上下文中,this 的值指向全局对象【关联概念(强)】

在函数执行上下文中,this 的值取决于函数的调用方式,在此不细说。请看 this【关联概念(强)】概念专题。

词法环境 Lexical Environment

在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment)的内部(隐藏)的关联对象。

这是一个规范对象(specification object):它仅仅是存在于 编程语言规范中的“理论上”存在的,用于描述事物如何运作的对象。我们无法在代码中获取该对象并直接对其进行操作

词法环境对象由两部分组成:

  • 环境记录(Environment Record)—— 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  • 对外部词法环境的引用,与外部代码相关联(可以访问外部的词法环境)。

所以 一个“变量”只是环境记录这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。

举例

下面的例子来源于现代 JavaScript 教程 文末有链接,非常清晰。

这段没有函数的简单的代码中只有一个词法环境:

这就是所谓的与整个脚本相关联的 全局词法环境

在上面的图片中,矩形表示环境记录(变量存储),箭头表示外部引用

全局词法环境没有外部引用,所以箭头指向了 null

随着代码开始并继续运行,词法环境发生了变化。这是更长的代码:

右侧的矩形演示了执行过程中全局词法环境的变化:

  • 当脚本开始运行,词法环境预先填充所有声明的变量
    • 最初,它们处于“未初始化(Uninitialized)”状态。这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。几乎就像变量不存在一样。
  • 然后 let phrase 定义出现了。它尚未被赋值,因此它的值为 undefined。从这一刻起,我们就可以使用变量了。
  • phrase 被赋予了一个值。
  • phrase 的值被修改。

然后我们再来看看函数声明

一个函数其实也是一个值,就像变量一样。

不同之处在于函数声明的初始化会被立即完成。 有没有印象,这就是变量提升 -> 函数优先的原理【关联子概念】

当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。

这就是为什么我们可以在(函数声明)的定义之前调用函数声明。

但这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name) {...}

我们再来看看内部词法环境和外部词法环境的关联

首先有内部,说明有函数执行,在全局或函数内部的词法环境中又创建了内部的函数环境,说作用域会不会更清楚,其实这边要讲的就是作用域链的原理

在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。

例如,对于 say("John"),它看起来像这样(当前执行位置在箭头标记的那一行上):

在这个函数调用期间,我们有两个词法环境:内部一个(用于函数调用)和外部一个(全局):

  • 内部词法环境与 say 的当前执行相对应。它具有一个单独的属性:name,函数的参数。我们调用的是 say("John"),所以 name 的值为 "John"
  • 外部词法环境是全局词法环境。它具有 phrase 变量和函数本身。

内部词法环境引用了 outer。

当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

在这个示例中,搜索过程如下:

  • 对于 name 变量,当 say 中的 alert 试图访问 name 时,会立即在内部词法环境中找到它。
  • 当它试图访问 phrase 时,然而内部没有 phrase,所以它顺着对外部词法环境的引用找到了它。

是不是此时你清楚的明白了作用域链【关联概念(强)】到底是怎么形成的,为什么内部作用域可访问外部变量之类的问题都迎刃而解了。

看这个例子, 当函数返回值是函数时:

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。 因此,我们有两层嵌套的词法环境,就像上面的示例一样:(注意现在只运行到红三角那)

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。我们尚未运行它,仅创建了它。

所有的函数在“诞生”时都会“记住”创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]]隐藏属性,该属性保存了对创建该函数的词法环境的引用

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]] 引用在函数创建时被设置并永久保存

稍后,当调用 counter() 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]

现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。

在变量所在的词法环境中更新变量。 所以直接在 makeCounter 的词法环境中改变 count变量的值。

这是执行后的状态:

如果我们调用 counter() 多次,count 变量将在同一位置增加到 2,3 等。因为我们查到的都是makeCounter() 的词法环境这个引用被永久设置了,所以都是同一份。这就是闭包数据持久化的原理。

JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于 [[Environment]] 属性和词法环境原理的技术细节。

关于闭包【关联概念】,点进去。

看完上面的讲述,希望你会对作用域链,声明提升,闭包等概念有了更深层的认识!

变量环境 Variable Environment

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,LexicalEnvironment 组件和 VariableEnvironment 组件的区别在于前者用于存储函数声明和变量( letconst )绑定,而后者仅用于存储变量( var )绑定。

就不提 var 太多了,希望用好能生成块级作用域的 let / const

总结

执行上下文创建过程是 JavaScript代码被解析和执行时准备运行环境的过程,执行上下文是代码执行环境的抽象概念。

其他

解释型 OR 编译型

我们通常把 Javascript 归类位"动态","解释执行" 语言,事实上他是一门混合型语言(和传统编译有区别,不是提前编译,而且编译结果也不能在分布式系统中移植),传统编译流程中,程序在执行一段源码前会经历3个步骤统称为 '编译'

  • 分词/词法分析
    • 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元。
  • 解析/语法分析
    • 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)【关联概念】。
  • 优化/代码生成
    • 将 AST 转换为可执行代码的过程称被称为代码生成

变量对象 VO、活动对象 AO

这些是ES3中的历史说法 有兴趣可以寻找相关材料

参考