基本编译流程

词法分析(Lexical Analysis)

将代码解析为词法单元 token 。 主要分为以下几种:

  • 关键字:例如 var、let、const等
  • 标识符:没有被引号括起来的连续字符,可能是一个变量,也可能是 if、else 这些关键字,又或者是 true、false 这些内置常量
  • 运算符: +、-、 *、/ 等
  • 数字:像十六进制,十进制,八进制以及科学表达式等语法
  • 字符串:因为对计算机而言,字符串的内容会参与计算或显示
  • 空格:连续的空格,换行,缩进等
  • 注释:行注释或块注释都是一个不可拆分的最小语法单元
  • 其他:大括号、小括号、分号、冒号等
  1. // source code
  2. var name = 'donggua';
  1. // Tokens
  2. [
  3. {
  4. "type": "Keyword",
  5. "value": "var"
  6. },
  7. {
  8. "type": "Identifier",
  9. "value": "name"
  10. },
  11. {
  12. "type": "Punctuator",
  13. "value": "="
  14. },
  15. {
  16. "type": "String",
  17. "value": "'donggua'"
  18. },
  19. {
  20. "type": "Punctuator",
  21. "value": ";"
  22. }
  23. ]

语法分析(Synatax Analysis)

将词法分析获得的token,结合语句表达式,组合建立抽象语法树(Abstract Synatax Tree, AST

  1. // AST
  2. {
  3. "type": "Program",
  4. "body": [
  5. {
  6. "type": "VariableDeclaration",
  7. "declarations": [
  8. {
  9. "type": "VariableDeclarator",
  10. "id": {
  11. "type": "Identifier",
  12. "name": "name"
  13. },
  14. "init": {
  15. "type": "Literal",
  16. "value": "donggua",
  17. "raw": "'donggua'"
  18. }
  19. }
  20. ],
  21. "kind": "var"
  22. }
  23. ],
  24. "sourceType": "script"
  25. }

AST中代码结构清晰,比如例子中的变量声明语句VariableDeclaration,变量声明器VariableDeclarator 使用的是var,并在初始化init 时提供字面量Literal值为donggua

抽象语法树AST

通过遍历AST,可以实现许多源码之外的优化与操作:

  • ESLint 等代码风格与语法检查
  • babel 等工具进行代码转换
  • IDE 的代码格式化、高亮与自动补全等
  • UgligyJS 代码压缩混淆等

代码生成

获取AST并将其转化成平台机器可执行的低级代码


执行上下文与堆栈(Execution Contect & Stack)

除此之外,在JavaScript解析执行过程中,JS引擎并不是真正的逐行解析,而是根据代码划分成对应的执行环境并依此执行

执行上下文

JS代码的执行环境称为 执行上下文(Execution Contect),一般分为以下几种:

  • 全局代码
  • 函数代码
  • Eval 代码

    eval通过调用 JS 解释器执行代码,拥有调用者权限,容易导致网站遭受恶意攻击,应避免使用 详见MDN-eval()

  1. // ctx.js
  2. // Global context
  3. function fn() {
  4. // local context of funtion fn
  5. function foo() {
  6. // local context of funtion foo
  7. }
  8. function bar() {
  9. // local context of funtion bar
  10. }
  11. foo();
  12. }
  13. fn();

执行上下文堆栈

在实际开发中,函数的数量是任意多的,而JavaScript解释器是单线程同步进行的,即每次仅能执行处理一个上下文。
因此JS中通过 堆栈(stack)抽象 执行上下文堆栈 处理代码块执行

通过堆栈对上述例子进行抽象如下:

  1. // abstract stack for ctx.js
  2. // 程序执行时,初始化将推入全局执行上下文
  3. const ECStack = [GlobalExecutionContext];
  4. // fn() 函数fn执行,推入栈中
  5. ECStack.push(fnExecutionContext);
  6. // foo() 函数foo执行,推入栈中
  7. ECStack.push(fooExecutionContext);
  8. // 函数bar并没有执行,不会推入栈中
  9. // foo() 执行完毕后自动推出堆栈
  10. ECStack.pop();
  11. // fn() 执行完毕后自动推出堆栈
  12. ECStack.pop();
  13. // ECStack的GLobalExecutionContext将保留直到程序结束运行,比如浏览器关闭/当前标签页关闭

可以借助浏览器调试工具进行直观的debugger:chrome ==> Source ===> Call Stack
execution_context_example.gif

执行上下文细节

当进入执行上下文时,都会经历两个阶段:

创建阶段

1. 初始化作用域链(Scope Chain)

在JavaScript中,决定变量、函数和对象等属性的可访问性的区域称为 作用域 (Scope),作用域起到了数据隔离的作用,非嵌套的作用域是独立不冲突的。

  • Javascript使用词法作用域(lexical scoping),又称静态作用域(static scoping) 在函数定义时即决定了作用域

与之相对的是动态作用域(dynamic scoping), 在函数运行时才决定作用域 简单来说,单从代码层面上我们就能确定JavaScript中函数的作用域

与执行上下文的区别:

  • 作用域在函数定义时就已经确定,而函数执行时才会创建执行上下文
  • 作用域可能包含多个执行上下文,且作用域可以嵌套存在。而执行上下文是独立的

作用域链 决定了各级上下文中的代码在访问变量和函数时的顺序与权限

  • 作用域链从全局上下文的变量对象开始,并延伸至函数上下文,正在执行的上下文的变量对象始终位于作用域链的最前端。
  • 内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西
  • 查找变量时,先从当前上下文中查找。若找不到,则向上查找父级上下文,直到最顶层的全局上下文。若最终找不到,则抛出错误 Reference Error
    1. // Global Scope
    2. var val = 1;
    3. function foo() {
    4. // Local Scope #foo
    5. console.log(val);
    6. function bar() {
    7. // Local Scope #bar
    8. val = 2;
    9. console.log(val);
    10. }
    11. bar();
    12. }
    13. foo();
    编译执行流程 - 图2

2. 创建变量对象(Variable Object,AO)

变量对象是与执行上下文相关的数据对象,其保存了上下文中所定义的变量和函数,只有解析器在处理数据时才会使用它。

在web浏览器的全局上下文中,window 对象被认为是全局对象(Global Object,GO),因此所有全局变量和函数都是作为 window 对象的属性与方法。即全局上下文中,全局对象预被定义为活动对象,且通过 window 属性指向其自身

  1. this === window; // true
  2. window.window === window; // true

变量对象是 ECMAScript 规范术语。只有进入执行上下文时,变量对象才被激活,并且其各种属性才能被访问。
因此在函数中,变量对象也被称为 活动对象(Activation Object,AO)
当进入函数执行上下文时,活动对象将被创建,其初始化时只包括 arguments 对象。

arguments:类数组对象,包含了函数所有的形参与长度

随后扫描分析代码:

  1. 先处理函数声明,使用函数名在活动对象中创建属性,并引用指向该函数。若存在相同的函数名,完全替换之。
  2. 再处理变量声明,使用变量名在活动对象中创建属性,并初始化值为undefined。若存在相同的变量名,则跳过。 ```javascript function foo(a) { var b = 1; function fn() {}; var anonymous = function() {}; // 声明变量且指向匿名函数 b = 2; }

context(1);

  1. ```javascript
  2. fooExecutionContext = {
  3. ScopeChain: { ... },
  4. ActivationObject: {
  5. arguments: {
  6. 1: 3,
  7. length: 1
  8. },
  9. a: 1,
  10. b: undefined,
  11. fn: reference to function fn(),
  12. anonymous: undefined
  13. },
  14. this: { ... }
  15. }

3. 确定 this 的值

  • 在全局上下文中,this指向全局对象,具体值由其宿主环境决定

在web浏览器中,默认为非严格模式,其指向window
在ECMAScript规范中,默认开启严格模式,this 的值为 undefined

  1. this; // window
  2. function fn() {
  3. 'use strict';
  4. console.log(this);
  5. }
  6. fn(); // undefined
  • 在函数中,this 始终指向调用函数的对象(运行时绑定) ```javascript function fn() { console.log(this); } const obj = { fn };

fn(); // window - 相当于window.fn(); obj.fn(); // obj fn.call(obj); // obj

  1. - ES6 箭头函数中,没有自身的`this`绑定,箭头函数的 `this` 将继承其所在上下文的`this`
  2. ```javascript
  3. function fn() {
  4. var a = 2;
  5. setTimeout(() => {
  6. console.log(this.a);
  7. }, 0)
  8. }
  9. var a = 1;
  10. fn(); // 1

激活/执行阶段

  • 在上下文中逐行执行代码为变量/函数并赋值
    1. fooExecutionContext = {
    2. ScopeChain: { ... },
    3. ActivationObject: {
    4. arguments: {
    5. 1: 3,
    6. length: 1
    7. },
    8. a: 1,
    9. b: 2,
    10. fn: reference to function fn(), // 这里是对函数fn的引用
    11. anonymous: reference to FunctionExpression // 这里是对函数表达式的引用
    12. },
    13. this: { ... }
    14. }

拓展

变量提升

了解了执行上下文的完整流程,也可以理解varfunction变量提升:

  1. console.log(typeof foo); // function
  2. function foo() {};
  3. var foo = 1;
  4. console.log(typeof foo); // number

编译流程如下:

  1. 进入全局上下文
  2. 初始化变量对象 ```javascript /*
    • 创建阶段 */ // 当执行到 line 3 声明变量 foo 指向函数 function foo() {}; VO = { foo: reference to function foo() }

// 当执行到 line 4 var foo = 1; // 变量声明,变量名已存在,跳过处理 VO = { foo: reference to function foo() }

/*

  • 执行阶段,边赋值边执行 */ // line 1 console.log(typeof foo); // 此时 foo 是函数指针 log: function

// line 4 var foo = 1; // 变量赋值,将 foo 赋值为 1 VO = { foo: 1 }

// line 6 log: number

  1. **注:变量提升只有声明提升,且仅提升到当前执行上下文的最顶部**
  2. <a name="n0GES"></a>
  3. ## let / const
  4. ES6之前,JS中声明变量只有 `var` `function` 两种形式,并且相对其他语言没有块级作用域,只有全局/函数作用域。这导致了一些不合理的现象:
  5. - 在变量声明前使用变量不会报错
  6. ```javascript
  7. console.log(a); // undefined
  8. var a = 1;
  • 作用域内外变量数据冲突

    1. var a = 1;
    2. function fn() {
    3. if(false) {
    4. var a = 2;
    5. }
    6. console.log(a);
    7. }
    8. fn(); // undefined

    由于变量提升,在函数fn创建阶段将变量a覆盖了全局下 a 的值为 undefinedif 语句的条件为 false
    所以并没有执行 a = 2,此时打印 a 得到的结果就是 undefined 而不是 1

  • 应属于块级作用域的变量泄漏为全局变量 ```javascript for(var i = 0; i < 3; i++) { console.log(‘i in for’, i); } console.log(i); // 3

// 没有块级作用域,相当于 var i; for(i = 0; i < 3; i++) { console.log(‘i in for’, i); } console.log(i); // 3

  1. ES6中新增了 `let` `const` 用于声明变量,并有以下[标准](https://262.ecma-international.org/6.0/#sec-let-and-const-declarations):
  2. > The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variables _LexicalBinding_ is evaluated
  3. 大意即:在实例化变量时,包含变量的词法作用域将创建变量,但在具体声明前不允许访问使用<br />该标准指明两点内容:
  4. - `let``const` 声明仍存在变量提升
  5. - `let``const` 声明的变量,必须先声明再使用,否则将抛出错误 `ReferenceError`
  6. 其中,在 `let``const`所在作用域直到其声明语句前的区域,在语法上称为**暂时性死区(Temporal Dead Zone, 简称TDZ)**<br />结合例子理解下上述内容:
  7. ```javascript
  8. console.log(a);
  9. let a;
  10. // Uncaught ReferenceError: a is not defined
  1. 在声明前使用变量,报错Uncaught ReferenceError: a is not defined ```javascript let a = 1; function fn() { console.log(a); let a = 2; } fn();

// ReferenceError: Cannot access ‘a’ before initialization

  1. 2. 如果不存在变量提升,那么执行函数 `fn` 应该时打印全局作用域中 `a=1` 而不是报错
  2. ```javascript
  3. for(let i = 0; i < 3; i++) {
  4. console.log('i in for', i);
  5. }
  6. console.log(i); // Uncaught ReferenceError: i is not defined
  1. let 声明的变量仅所在块级作用域有效,不会提升到全局上

注:const 声明的变量并非值不可改变,而是变量指向的那个内存地址所保存的数据不得改动

  1. const a = 1;
  2. a = 2; // Uncaught TypeError: Assignment to constant variable
  3. const obj = { a: 1 };
  4. obj.a = 2; // { a: 2 }

闭包

了解了 作用域作用域链,我们知道变量存在于上下文的作用域中,当函数执行完毕离开执行上下文时,该作用域将不再存在,理论上也不能再访问其中的变量。如:

  1. function fn() {
  2. var a = 1;
  3. console.log(a); // 1
  4. }
  5. fn();
  6. console.log(a); // VM38:5 Uncaught ReferenceError: a is not defined

而实际上,我们会发现有些时候并不一样:

  1. function fn() {
  2. var a = 1;
  3. function log() {
  4. console.log(a);
  5. }
  6. return log;
  7. }
  8. var demo = fn();
  9. demo(); // 1

在这个例子中,我们就成功打印出了函数 fn 中变量a的值,即我们通过在函数 fn 内部嵌套使用 log 函数,保留了对其父级作用域的引用,这也就是闭包。

闭包的应用

  1. for(var i = 1; i <= 5; i++){
  2. setTimeout(function(){
  3. console.log(i)
  4. }, 0)
  5. }
  6. // (5)6

浏览器EventLoops机制 中, setTimeout(fn, 0) 意为,当主线程执行栈内为空时,执行回调函数fn
在这个例子中,第一次主线程执行完毕后,全局上下文下 i = 6,此时再依次执行 setTimeout() 的回调函数,所以打印结果都是 6

  1. // 借用闭包解决
  2. for(var i = 1; i <= 5; i++){
  3. (function(j){
  4. setTimeout(function(){
  5. console.log(j)
  6. },0)
  7. })(i)
  8. }
  9. // 1 2 3 4 5

通过立即执行函数([**IIFE**](https://developer.mozilla.org/zh-CN/docs/Glossary/IIFE))实际上也是创建了一个闭包函数,将每次循环时i的值保留到定时器的回调函数中。

箭头函数

ES6对于函数扩展新增了箭头函数 () =>,简化了函数表达式和回调函数的书写。
但需要留意以下几点:

  • 没有自身的this绑定,箭头函数的 this 将继承其所在上下文的this
  • 没有自身的 this绑定, 因此不能作为构造函数
  • 不可使用argument 对象,需要用 rest 参数代替
  • 不可使用 yield 指令,因此不能作为 Generator 函数
  • 返回对象时,必须用扣号包裹,否则将报错
  • 箭头函数不存在原型,arrowFn.prototype === undefined

参考文献