深入了解JavaScript执行过程(JS系列之一)

3,021 阅读12分钟

前言

JavaScript  执行过程分为两个阶段,编译阶段和执行阶段。在编译阶段 JS 引擎主要做了三件事:词法分析、语法分析和代码生成;编译完成后 JS 引擎开始创建执行上下文(JavaScript 代码运行的环境),并执行 JS 代码。

编译阶段

对于常见编译型语言(例如:Java )来说,编译步骤分为:词法分析 -> 语法分析 -> 语义检查 -> 代码优化和字节码生成

对于解释型语言(例如:JavaScript )来说,编译阶通过词法分析 -> 语法分析 -> 代码生成,就可以解释并执行代码了。

词法分析

JS 引擎会将我们写的代码当成字符串分解成词法单元(token)。例如,var a = 2 ,这段程序会被分解成:“var、a、=、2、;” 五个 token 。每个词法单元token不可再分割。可以试试这个网站地址查看 tokenesprima.org/demo/parse.…

1词法分析1.png
1词法分析2.png

语法分析

语法分析阶段会将词法单元流(数组),也就是上面所说的token, 转换成树状结构的 “抽象语法树(AST)”

2语法分析.png

代码生成

AST转换为可执行代码的过程称为代码生成,因为计算机只能识别机器指令,需要通过某种方法将 var a = 2; 的 AST 转化为一组机器指令,用来创建 a 的变量(包括分配内存),并将值存储在 a 中。

执行阶段

执行程序需要有执行环境, Java 需要 Java 虚拟机,同样解析 JavaScript 也需要执行环境,我们称它为“执行上下文”。

什么是执行上下文

简而言之,执行上下文是对 JavaScript 代码执行环境的一种抽象,每当 JavaScript 运行时,它都是在执行上下文中运行。

执行上下文类型

JavaScript 执行上下文有三种:

  • 全局执行上下文 —— 当 JS 引擎执行全局代码的时候,会编译全局代码并创建执行上下文,它会做两件事:1、创建一个全局的 window 对象(浏览器环境下),2、将 this 的值设置为该全局对象;全局上下文在整个页面生命周期有效,并且只有一份。

  • 函数执行上下文 —— 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。

  • eval 执行上下文 —— 调用 eval 函数也会创建自己的执行上下文(eval函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因此不推荐使用)

执行栈

执行栈这个概念是比较贴近我们程序员的,学习它能让我们理解 JS 引擎背后工作的原理,开发中帮助我们调试代码,同时也能应对面试中有关执行栈的面试题。

执行栈,在其它编程语言中被叫做“调用栈”,是一种 LIFO(后进先出)栈的数据结构,被用来存储代码运行时创建的所有执行上下文。

JS 引擎开始执行第一行 JavaScript 代码时,它会创建一个全局执行上下文然后将它压到执行栈中,每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。

引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

结合下面代码来理解:

let a = 'Hello World!';
function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}
function second() {
  console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');

stack.png

当上述代码在浏览器加载时,JS 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() JS 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。

当从 first() 函数内部调用 second() JS 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。

first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。

如何创建执行上下文

现在我们已经了解了 JS 引擎是如何去管理执行上下文的,那么,执行上下文是如何创建的呢?

执行上下文的创建分为两个阶段:

  • 创建阶段;
  • 执行阶段;

创建阶段

执行上下文创建阶段会做三件事:

  • 绑定 this
  • 创建词法环境
  • 创建变量环境

所以执行上下文在概念上表示如下:

ExecutionContext = { // 执行上下文
  Binding This, // this值绑定
  LexicalEnvironment = { ... }, // 词法环境
  VariableEnvironment = { ... }, // 变量环境
}
绑定 this

在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this 引用 Window 对象)。

在函数执行上下文中,this 的值取决于该函数是如何被调用的

  • 通过对象方法调用函数,this 指向调用的对象
  • 声明函数后使用函数名称普通调用,this 指向全局对象,严格模式下 this 值是 undefined
  • 使用 new 方式调用函数,this 指向新创建的对象
  • 使用 callapplybind 方式调用函数,会改变 this 的值,指向传入的第一个参数,例如

function fn () {
  console.log(this)
}

function fn1 () {
  'use strict'
  console.log(this)
}

fn() // 普通函数调用,this 指向window对象
fn() // 严格模式下,this 值为 undefined

let foo = {
  baz: function() {
  console.log(this);
  }
}

foo.baz();   // 'this' 指向 'foo'

let bar = foo.baz;

bar();       // 'this' 指向全局 window 对象,因为没有指定引用对象

let obj {
  name: 'hello'
}

foo.baz.call(obj) // call 改变this值,指向obj对象
词法环境

每一个词法环境由下面两部分组成:

  • 环境记录:变量对象 =》存储声明的变量和函数( let, const, function,函数参数)
  • 外部环境引用:作用域链

ES6的官方文档 把词法环境定义为:

词法环境(Lexical Environments)是一种规范类型,用于根据ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联。词法环境由一个环境记录(Environment Record)和一个可能为空的外部词法环境(outer Lexical Environment)引用组成。

简单来说,词法环境就是一种标识符—变量映射的结构(这里的标识符指的是变量/函数的名字,变量是对实际对象[包含函数和数组类型的对象]或基础数据类型的引用)。

举个例子,看看下面的代码:

var a = 20;
var b = 40;
function foo() {
  console.log('bar');
}

上面代码的词法环境类似这样:

lexicalEnvironment = {
  a: 20,
  b: 40,
  foo: <ref. to foo function>
}

环境记录

所谓的环境记录就是词法环境中记录变量和函数声明的地方

环境记录也有两种类型:

声明类环境记录。顾名思义,它存储的是变量和函数声明,函数的词法环境内部就包含着一个声明类环境记录。

对象环境记录。全局环境中的词法环境中就包含的就是一个对象环境记录。除了变量和函数声明外,对象环境记录还包括全局对象(浏览器的window对象)。因此,对于对象的每一个新增属性(对浏览器来说,它包含浏览器提供给window对象的所有属性和方法),都会在该记录中创建一个新条目。

注意:对函数而言,环境记录还包含一个arguments对象,该对象是个类数组对象,包含参数索引和参数的映射以及一个传入函数的参数的长度属性。举个例子,一个arguments对象像下面这样:

function foo(a, b) {
  var c = a + b;
}
foo(2, 3);
// argument 对象类似下面这样
Arguments: { 0: 2, 1: 3, length: 2 }

环境记录对象在创建阶段也被称为变量对象(VO),在执行阶段被称为活动对象(AO)。之所以被称为变量对象是因为此时该对象只是存储执行上下文中变量和函数声明,之后代码开始执行,变量会逐渐被初始化或是修改,然后这个对象就被称为活动对象

外部环境引用

对于外部环境的引用意味着在当前执行上下文中可以访问外部词法环境。也就是说,如果在当前的词法环境中找不到某个变量,那么Javascript引擎会试图在上层的词法环境中寻找。(Javascript引擎会根据这个属性来构成我们常说的作用域链)

词法环境抽象出来类似下面的伪代码:

GlobalExectionContext = { // 全局执行上下文
  this: <global object> // this 值绑定
  LexicalEnvironment: { // 全局执行上下文词法环境
    EnvironmentRecord: {  // 环境记录
      Type: "Object",
     	// 标识符在这里绑定
    }
    outer: <null> // 外部引用
  }
}
FunctionExectionContext = { // 函数执行上下文
  this: <depends on how function is called> // this 值绑定
  LexicalEnvironment: { // 函数执行上下文词法环境
    EnvironmentRecord: { // 环境记录
      Type: "Declarative",
      // 标识符在这里绑定
    }
    outer: <Global or outer function environment reference> // 引用全局环境
   }
}
变量环境

它同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。

如上所述,变量环境也是一个词法环境,所以它有着上面定义的词法环境的所有属性。

在 ES6 中,词法环境变量环境的一个不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储 var 变量绑定。

看点样例代码来理解上面的概念:

let a = 20;
const b = 30;
var c;

function multiply(e, f) {
 var g = 20;
 return e * f * g;
}

c = multiply(20, 30);

执行起来看起来像这样:

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在这里绑定标识符
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>
  },

  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在这里绑定标识符
      c: undefined,
    }
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 在这里绑定标识符
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>
  },

VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 在这里绑定标识符
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>
  }
}

注意 — 只有遇到调用函数 multiply 时,函数执行上下文才会被创建。

可能你已经注意到 letconst 定义的变量并没有关联任何值,但 var 定义的变量被设成了 undefined

这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为 undefinedvar 情况下),或者未初始化(letconst 情况下)。

这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 letconst 的变量会得到一个引用错误。

这就是我们说的变量声明提升。

执行阶段

经过上面的创建执行上下文,就开始执行 JavaScript 代码了。在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined 。

执行栈应用

利用浏览器查看栈的调用信息

我们知道执行栈是用来管理执行上下文调用关系的数据结构,那么我们在实际工作中如何运用它呢。

答案是我们可以借助浏览器“开发者工具” source 标签,选择 JavaScript 代码打上断点,就可以查看函数的调用关系,并且可以切换查看每个函数的变量值

调用栈.png

我们在 second 函数内部打上断点,就可以看到右边 Call Stack 调用栈显示 secondfirst(anonymous) 调用关系,second 是在栈顶(anonymous 在栈底相当于全局执行上下文),执行second函数我们可以查看该函数作用域 Scope 局部变量abnum的值,通过查看调用栈的调用关系我们可以快速定位到我们代码执行的情况。

那如果代码执行出错,也不知道在哪个地方打断点调试,那怎么查看出错地方的调用栈呢,告诉大家一个技巧,如下图

调用栈2.png

我们不用打断点,执行上面两步操作,就可以在代码执行异常的地方自动打上断点。知道这个技巧后,再也不用担心代码出错了。

除了上面通过断点来查看调用栈,还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 second 函数里面加上了 console.trace(),就可以看到控制台输出的结果,如下图:

调用栈3.png

总结

JavaScript执行分为两个阶段,编译阶段和执行阶段。编译阶段会经过词法分析、语法分析、代码生成步骤生成可执行代码; JS 引擎执行可执行性代码会创建执行上下文,包括绑定this、创建词法环境和变量环境;词法环境创建外部引用(作用域链)和 记录环境(变量对象,let, const, function, arguments), JS 引擎创建执行上下完成后开始单线程从上到下一行一行执行 JS 代码了。

最后,分享了在开发过程中一些调用栈的的应用技巧。

引用链接

JavaScript 语法解析、AST、V8、JIT

[译] 理解 JavaScript 中的执行上下文和执行栈

理解Javascript中的执行上下文和执行栈

推荐阅读