JavaScript 从定义到执行, JS 引擎在实现层做了很多初始化工作, 因此在学习 JS 引擎工作机制之前, 我们需要引入几个相关的概念: 执行环境栈, 全局对象, 执行环境, 变量对象, 活动对象, 作用域和作用域链等, 这些概念正是 JS 引擎工作的核心组件. 这篇文章的目的不是孤立的为你讲解每一个概念, 而是通过一个简单的 DEMO 来展开分析, 全局讲解 JS 引擎从定义到执行的每一个细节, 以及这些概念在其中所扮演的角色.
?
1 2 3 4 5 6 7 8 9 10 | var x = 1; // 定义一个全局变量 x function A(y){ var x = 2; // 定义一个局部变量 x function B(z){// 定义一个内部函数 B console.log(x+y+z); } return B;// 返回函数 B 的引用 } var C = A(1);// 执行 A, 返回 B C(1);// 执行函数 B,输出 4 |
下面我们将分全局初始化, 执行函数 A, 执行函数 B 三个阶段来分析 JS 引擎的工作机制:
1, 全局初始化
JS 引擎在进入一段可执行的代码时, 需要完成以下三个初始化工作:
首先, 创建一个全局对象 (Global Object) , 这个对象全局只存在一份, 它的属性在任何地方都可以访问, 它的存在伴随着应用程序的整个生命周期. 全局对象在创建时, 将 Math,String,Date,document 等常用的 JS 对象作为其属性. 由于这个全局对象不能通过名字直接访问, 因此还有另外一个属性 Windows, 并将 Windows 指向了自身, 这样就可以通过 Windows 访问这个全局对象了. 用伪代码模拟全局对象的大体结构如下:
?
1 2 3 4 5 6 7 8 9 | // 创建一个全局对象 var globalObject = { Math:{}, String:{}, Date:{}, document:{},//DOM 操作 ... window:this // 让 window 属性指向了自身 } |
然后, JS 引擎需要构建一个执行环境栈 ( Execution Context Stack) , 与此同时, 也要创建一个全局执行环境 (Execution Context)EC , 并将这个全局执行环境 EC 压入执行环境栈中. 执行环境栈的作用是为了保证程序能够按照正确的顺序被执行. 在 JavaScript 中, 每个函数都有自己的执行环境, 当执行一个函数时, 该函数的执行环境就会被推入执行环境栈的顶部并获取执行权. 当这个函数执行完毕, 它的执行环境又从这个栈的顶部被删除, 并把执行权并还给之前执行环境. 我们用伪代码来模拟执行环境栈和 EC 的关系:
?
1 2 3 4 5 6 7 | var ECStack = [];// 定义一个执行环境栈,类似于数组 var EC = {}; // 创建一个执行空间, //ECMA-262 规范并没有对 EC 的数据结构做明确的定义,你可以理解为在内存中分配的一块空间 ECStack.push(EC);// 进入函数,压入执行环境 ECStack.pop(EC); // 函数返回后,删除执行环境 |
最后, JS 引擎还要创建一个与 EC 关联的全局变量对象 (Varibale Object) VO, 并把 VO 指向全局对象, VO 中不仅包含了全局对象的原有属性, 还包括在全局定义的变量 x 和函数 A, 与此同时, 在定义函数 A 的时候, 还为 A 添加了一个内部属性 scope, 并将 scope 指向了 VO. 每个函数在定义的时候, 都会创建一个与之关联的 scope 属性, scope 总是指向定义函数时所在的环境. 此时的 ECStack 结构如下:
?
1 2 3 4 5 6 7 8 9 10 | ECStack = [ // 执行环境栈 EC(G) = { // 全局执行环境 VO(G):{// 定义全局变量对象 ...// 包含全局对象原有的属性 x = 1;// 定义变量 x A = function(){...};// 定义函数 A A[[scope]] =this; // 定义 A 的 scope,并赋值为 VO 本身 } } ]; |
2, 执行函数 A
当执行进入 A(1) 时, JS 引擎需要完成以下工作:
首先, JS 引擎会创建函数 A 的执行环境 EC, 然后 EC 推入执行环境栈的顶部并获取执行权. 此时执行环境栈中有两个执行环境, 分别是全局执行环境和函数 A 执行环境, A 的执行环境在栈顶, 全局执行环境在栈的底部. 然后, 创建函数 A 的作用域链 (Scope Chain) , 在 JavaScript 中, 每个执行环境都有自己的作用域链, 用于标识符解析, 当执行环境被创建时, 它的作用域链就初始化为当前运行函数的 scope 所包含的对象.
接着, JS 引擎会创建一个当前函数的活动对象 (Activation Object) AO, 这里的活动对象扮演着变量对象的角色, 只是在函数中的叫法不同而已 (你可以认为变量对象是一个总的概念, 而活动对象是它的一个分支), AO 中包含了函数的形参, arguments 对象, this 对象, 以及局部变量和内部函数的定义, 然后 AO 会被推入作用域链的顶端. 需要注意的是, 在定义函数 B 的时候, JS 引擎同样也会为 B 添加了一个 scope 属性, 并将 scope 指向了定义函数 B 时所在的环境, 定义函数 B 的环境就是 A 的活动对象 AO, 而 AO 位于链表的前端, 由于链表具有首尾相连的特点, 因此函数 B 的 scope 指向了 A 的整个作用域链. 我们再看看此时的 ECStack 结构:
?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | ECStack = [ // 执行环境栈 EC(A) = { //A 的执行环境 [scope]:VO(G),//VO 是全局变量对象 AO(A) : {// 创建函数 A 的活动对象 y:1, x:2, // 定义局部变量 x B:function(){...},// 定义函数 B B[[scope]] =this; //this 指代 AO 本身,而 AO 位于 scopeChain 的顶端,因此 B[[scope]] 指向整个作用域链 arguments:[],// 平时我们在函数中访问的 arguments 就是 AO 中的 arguments this:window // 函数中的 this 指向调用者 window 对象 }, scopeChain: // 链表初始化为 A[[scope]], 然后再把 AO 加入该作用域链的顶端, 此时 A 的作用域链:AO(A)->VO(G) }, EC(G) = { // 全局执行环境 VO(G):{// 创建全局变量对象 ...// 包含全局对象原有的属性 x = 1;// 定义变量 x A = function(){...};// 定义函数 A A[[scope]] =this;// 定义 A 的 scope,A[[scope]] == VO(G) } } ]; |
3, 执行函数 B
函数 A 被执行以后, 返回了 B 的引用, 并赋值给了变量 C, 执行 C(1) 就相当于执行 B(1),JS 引擎需要完成以下工作:
首先, 还和上面一样, 创建函数 B 的执行环境 EC, 然后 EC 推入执行环境栈的顶部并获取执行权. 此时执行环境栈中有两个执行环境, 分别是全局执行环境和函数 B 的执行环境, B 的执行环境在栈顶, 全局执行环境在栈的底部.(注意: 当函数 A 返回后, A 的执行环境就会从栈中被删除, 只留下全局执行环境) 然后, 创建函数 B 的作用域链, 并初始化为函数 B 的 scope 所包含的对象, 即包含了 A 的作用域链. 最后, 创建函数 B 的活动对象 AO, 并将 B 的形参 z, arguments 对象 和 this 对象作为 AO 的属性. 此时 ECStack 将会变成这样:
?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | ECStack = [ // 执行环境栈 EC(B) = { // 创建 B 的执行环境, 并处于作用域链的顶端 [scope]:AO(A),// 指向函数 A 的作用域链, AO(A)->VO(G) var AO(B) = {// 创建函数 B 的活动对象 z:1, arguments:[], this:window } scopeChain: // 链表初始化为 B[[scope]], 再将 AO(B) 加入链表表头,此时 B 的作用域链:AO(B)->AO(A)-VO(G) }, EC(A),//A 的执行环境已经从栈顶被删除, EC(G) = { // 全局执行环境 VO:{// 定义全局变量对象 ...// 包含全局对象原有的属性 x = 1;// 定义变量 x A = function(){...};// 定义函数 A A[[scope]] =this;// 定义 A 的 scope,A[[scope]] == VO(G) } } ]; |
当函数 B 执行 "x+y+z" 时, 需要对 x,y,z 三个标识符进行一一解析, 解析过程遵守变量查找规则: 先查找自己的活动对象中是否存在该属性, 如果存在, 则停止查找并返回; 如果不存在, 继续沿着其作用域链从顶端依次查找, 直到找到为止, 如果整个作用域链上都未找到该变量, 则返回 "undefined". 从上面的分析可以看出函数 B 的作用域链是这样的:
- ?
- AO(B)->AO(A)->VO(G)
因此, 变量 x 会在 AO(A) 中被找到, 而不会查找 VO(G) 中的 x, 变量 y 也会在 AO(A) 中被找到, 变量 z 在自身的 AO(B) 中就找到了. 所以执行结果: 2+1+1=4.
4, 总结
了解了 JS 引擎的工作机制之后, 我们不能只停留在理解概念的层面, 而要将其作为基础工具, 用以优化和改善我们在实际工作中的代码, 提高执行效率, 产生实际价值才是我们的真正目的. 就拿变量查找机制来说, 如果你的代码嵌套很深, 每引用一次全局变量, JS 引擎就要查找整个作用域链, 比如处于作用域链的最底端 Windows 和 document 对象就存在这个问题, 因此我们围绕这个问题可以做很多性能优化的工作, 当然还有其他方面的优化, 此处不再赘述, 本文仅当作抛砖引玉吧!
by @一像素 2015
来源: https://juejin.im/entry/5c6f409a6fb9a049d132bec7