你说我坑你,不是我内心复杂,只是你不够了解我 —— JS
张北草原天路 2017.6
说明
本文根据多篇中外博客文章,加上个人理解整合而成。
因为曾尝试翻译过这些文章,发现并不是很好理解,所以希望换个自己的方式去表达原文的部分观点。如果表达的不准确,还望谅解。
本文写于 15 年,那个时候对 JS 还不是很了解,有计划从编译的角度再写一次。
名词约定
- Execution Context(EC) 执行上下文
- Executable Code 可执行代码
- Execution Context Stack(ECS) 执行上下文栈
- Variable Objec(VO) 变量对象
- Activation Object(AO) 活动对象
- Scope 作用域
- Scope Chain 作用域链
- Arguments Object 参数对象
- Global Code 全局代码
- Function Code 函数代码
- Eval Code
Execution Context
每次当解释器转到不同的可执行代码的时候,就会进入一个执行上下文 EC。可以简单的理解执行上下文就是代码的执行环境或者作用域。EC 是个抽象的概念,ECMA-262 使用 EC 和Executable Code 区分。
Executable Code
可以简单的理解:可执行代码就是 JS 中合法的代码,可以被 JS 解释器执行的代码。
可执行代码的分类:
- Global code 全局代码
可以理解为是 JS 解释器为 JS 程序提供的默认全局代码,例如 function Object(), eval() 这些内建的函数代码,window 等全局对象等等。(注意:只包含函数定义代码,但是不包括函数体中的代码,请看 Function code 做的解释)。
2. Eval code
使用 eval 函数执行的代码。
可执行代码的概念与抽象的执行上下文的概念是相关的。在某些时刻,可执行代码与执行上下文是等价的。
在执行上下文提到,当程序执行转移到不同的可执行代码的时候,就会根据当前的可执行代码的类型新建一个对应的执行环境。
根据可执行代码的类型,我自己也给执行上下文分类:
- Global Execution Context 全局执行上下文 代码的默认运行环境,程序代码一旦被载入,最先进入的执行环境。只有一个全局执行上下文。
- Function Execution Context 函数执行上下文 每当调用一个函数,也就是执行函数体中的代码会新建一个函数执行上下文环境。
- Eval Execution Context Eval 执行上下文
当使用 eval 函数执行代码的时候,会新建一个 eval 执行上下文。
为了方便理解,来看一张图:
此图表示一个完整的JS程序。
一共用 4 个执行上下文。紫色的代表全局的上下文;绿色代表 person 函数内的上下文;蓝色以及橙色代表 person 函数内的另外两个函数的上下文。
只存在一个全局的上下文,该上下文能被任何其它的上下文所访问到。也就是说,我们可以在 person 的上下文中访问到全局上下 文中的 sayHello 变量,当然在函 firstName 或者 lastName 中同样可以访问到该变量。
函数上下文的个数是没有任何限制的,每到调用执行一个函数时,解释器就会自动新建出一个函数上下文,换句话说,就是新建一个局部作用域,可以在该局部作用域中声明私有变量等,在外部的上下文中是无法直接访问到该局部作用域内的元素的。
在上述例子的,内部的函数可以访问到外部上下文中的声明的变量,反之则行不通。那么,这到底是什么原因呢?解释器内部是如何处理的呢?请往下看。
Execution Context Stack
一系列的上下文组成了上下文栈。这个栈和数据结构中的栈类似——先进后出(如果学过操作系统线程和栈帧这些概念,那理解起来非常容易——推荐《深入理解操作系统》)。 JS 程序只有一个线程,这意味着 JS 程序同一时间只能做一件事情(关于异步编程,以后再细细道来),知道这个很重要。
把执行上下文栈可视化大概是下图这个样子:
栈顶是当前活动的执行上下文,也就是程序正在栈顶那个执行环境运行。栈底是全局执行上下文,因为程序一运行立即入栈的就是全局执行上下文,我们写的 JS 代码都是在全局执行上下文环境运行的。
通过出栈和入栈,切换当前程序代码的执行环境。
类似于原型链,上下文也有父执行上下文,子执行上下文。子执行上下文可以访问父执行上下文,但父执行上下文不能访问子执行上下文。上下文之间使用 scope 链接起来,我们把它称为 Scope Chain 作用域链。
举个栗子:
(function foo(i) {if (i === 3) {return;} else {foo(++i);}
}(0));
上述 foo 被声明后,通过 () 运算符强制直接运行了。函数代码就是调用了其自身 3 次,每次是局部变量 i 增加 1。每次 foo 函数被自身调用时,就会有一个新的执行上下文被创建。每当一个上下文执行完毕,该上下文就被弹出堆栈,回到上一个上下文,直到再次回到全局上下文。
对2和3做个总结:
1.单线程
2.同步执行
3.只有一个全局执行上下文
4.无限制个函数执行上下文
Execution Context深入
Execution Context以函数上下文为例
每次函数调用都会生成一个新的函数执行上下文。这个生成过程可以分解为两个步骤:
- Creation Stage 创建阶段(函数被调用,但函数执行之前)
A.创建 Scope Chain 作用域链
B.创建变量,函数和参数
C.确定 this 的值
- Activation / Code Execution Stage 代码执行阶段
A.解释器执行代码,变量赋值。
可以把函数执行上下文想象成一个拥有三个属性的对象:
executionContextObj = {scopeChain: { / variableObject + all parent execution context's variableObject / },variableObject: { / function arguments / parameters, inner variable and function declarations / },this: {}
}
函数执行上下文创建详解
(1)找到调用函数的入口
(2)在执行函数代码之前,创建一个执行上下文
(3)进入 creation stage 上下文创建阶段
1. 初始化 Scope Chain 作用域链
2. 创建 variable object 变量对象
2.1 创建 arguments object 对象,使用调用函数传入的实参赋值。2.2 从上往下扫描函数体代码中的函数声明;
A.对于每找到的一个函数声明,在 VO 中创建创建一个使用函数名为名的属性,并赋值(这个阶段,函数定义就被载入内存,所以函数声明在这个阶段可以赋值。注意区别函数声明和函数表达式的不同)
B.如果函数名已经存在了,那就会发生覆盖。也就是,如果有重名函数,后面的会覆盖前面的。
2.3 从上到下扫描函数体代码中的变量声明
A.每找到的一个变量声明,以变量名为属性名在 VO 创建一个属性,并赋值 undefined。
B.如果在 VO 中发生重名,解释器会跳过去,接着扫描。
3. 确定 this 的值。(4)Activation / Code Execution 代码执行阶段
在创建的上下文中从上到下逐行执行函数体代码,并给变量赋值
举个栗子:
function foo(i) {var a = 'hello';var b = function privateB() { };function c() { }
}foo(22);
当执行 foo(22) 的时候,creation state 阶段的执行上下文大概是这个样子:
fooExecutionContext = {scopeChain: { ... },variableObject: {arguments: { 0: 22, length: 1 },i: 22, c: pointer to function c(),a: undefined,b: undefined},this: { ... }
}
当函数执行完成,execution stage 大概是这样:
fooExecutionContext = {scopeChain: { ... },variableObject: {arguments: { 0: 22, length: 1 },i: 22,c: pointer to function c(),a: 'hello',b: pointer to function privateB()},this: { ... }
}
总结 —— 函数提升
(function() {console.log(typeof foo);// function pointerconsole.log(typeof bar);// undefinedvar foo = 'hello',bar = function() { return 'world'; };function foo() {return 'hello';}
}());
- 为什么可以在声明 foo 之前可以使用 foo?
在 execution stage 阶段之前的 creation stage 阶段,解释器已经在 VO 创建了所有的变量,所以在代码执行的时候,foo 已经有值了。
- foo 被声明了两次,为什么 foo 的值是 function,而不是 undefined 或者 string?
尽管 foo 被声明了两次,但是在 creation stage 阶段,函数声明优先于变量声明被创建。并且,变量声明不会覆盖函数声明在 VO 的属性。 需要注意的是 console.log(typeof foo) 是 undefined.但是执行 var foo = 'hello' 后,foo 的值就是 ’hello’。
- 为什么 bar 是 undefined
bar 是一个变量,它的值是一个函数表达式。在 creation stage 阶段,只是在 VO 中创建了 bar 这个属性,并没有赋值。
思考
- 什么是执行上下文,什么是执行上下文栈?
- 执行上下文的分类?
- 执行上下文被创建的过程?
参考
- What is the Execution Context & Stack in Javascript
- 深入理解Javascript之执行上下文
- ECMA-262-3 in detail. Chapter 1. Execution Contexts
- http://bclary.com/2004/11/07/#a-10
- Understanding Execution Context and Execution Stack in Javascript