执行上下文
系列开篇
为进入前端的你建立清晰、准确、必要的概念和这些概念的之间清晰、准确、必要的关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。
面试题
- 什么是执行上下文?
- 当 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 组件的区别在于前者用于存储函数声明和变量( let
和 const
)绑定,而后者仅用于存储变量( var
)绑定。
就不提 var
太多了,希望用好能生成块级作用域的 let
/ const
总结
执行上下文创建过程是 JavaScript代码被解析和执行时准备运行环境的过程,执行上下文是代码执行环境的抽象概念。
其他
解释型 OR 编译型
我们通常把 Javascript 归类位"动态","解释执行" 语言,事实上他是一门混合型语言(和传统编译有区别,不是提前编译,而且编译结果也不能在分布式系统中移植),传统编译流程中,程序在执行一段源码前会经历3个步骤统称为 '编译'
- 分词/词法分析
- 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元。
- 解析/语法分析
- 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)【关联概念】。
- 优化/代码生成
- 将 AST 转换为可执行代码的过程称被称为代码生成
变量对象 VO、活动对象 AO
这些是ES3中的历史说法 有兴趣可以寻找相关材料