基本编译流程
词法分析(Lexical Analysis)
将代码解析为词法单元 token 。 主要分为以下几种:
- 关键字:例如 var、let、const等
- 标识符:没有被引号括起来的连续字符,可能是一个变量,也可能是 if、else 这些关键字,又或者是 true、false 这些内置常量
- 运算符: +、-、 *、/ 等
- 数字:像十六进制,十进制,八进制以及科学表达式等语法
- 字符串:因为对计算机而言,字符串的内容会参与计算或显示
- 空格:连续的空格,换行,缩进等
- 注释:行注释或块注释都是一个不可拆分的最小语法单元
- 其他:大括号、小括号、分号、冒号等
// source codevar name = 'donggua';
// Tokens[{"type": "Keyword","value": "var"},{"type": "Identifier","value": "name"},{"type": "Punctuator","value": "="},{"type": "String","value": "'donggua'"},{"type": "Punctuator","value": ";"}]
语法分析(Synatax Analysis)
将词法分析获得的token,结合语句表达式,组合建立抽象语法树(Abstract Synatax Tree, AST)
// AST{"type": "Program","body": [{"type": "VariableDeclaration","declarations": [{"type": "VariableDeclarator","id": {"type": "Identifier","name": "name"},"init": {"type": "Literal","value": "donggua","raw": "'donggua'"}}],"kind": "var"}],"sourceType": "script"}
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()
// ctx.js// Global contextfunction fn() {// local context of funtion fnfunction foo() {// local context of funtion foo}function bar() {// local context of funtion bar}foo();}fn();
执行上下文堆栈
在实际开发中,函数的数量是任意多的,而JavaScript解释器是单线程同步进行的,即每次仅能执行处理一个上下文。
因此JS中通过 堆栈(stack)抽象 执行上下文堆栈 处理代码块执行
通过堆栈对上述例子进行抽象如下:
// abstract stack for ctx.js// 程序执行时,初始化将推入全局执行上下文const ECStack = [GlobalExecutionContext];// fn() 函数fn执行,推入栈中ECStack.push(fnExecutionContext);// foo() 函数foo执行,推入栈中ECStack.push(fooExecutionContext);// 函数bar并没有执行,不会推入栈中// foo() 执行完毕后自动推出堆栈ECStack.pop();// fn() 执行完毕后自动推出堆栈ECStack.pop();// ECStack的GLobalExecutionContext将保留直到程序结束运行,比如浏览器关闭/当前标签页关闭
可以借助浏览器调试工具进行直观的debugger:chrome ==> Source ===> Call Stack
执行上下文细节
创建阶段
1. 初始化作用域链(Scope Chain)
在JavaScript中,决定变量、函数和对象等属性的可访问性的区域称为 作用域 (Scope),作用域起到了数据隔离的作用,非嵌套的作用域是独立不冲突的。
- Javascript使用词法作用域(lexical scoping),又称静态作用域(static scoping) 在函数定义时即决定了作用域
与之相对的是动态作用域(dynamic scoping), 在函数运行时才决定作用域 简单来说,单从代码层面上我们就能确定JavaScript中函数的作用域
与执行上下文的区别:
- 作用域在函数定义时就已经确定,而函数执行时才会创建执行上下文
- 作用域可能包含多个执行上下文,且作用域可以嵌套存在。而执行上下文是独立的
而 作用域链 决定了各级上下文中的代码在访问变量和函数时的顺序与权限
- 作用域链从全局上下文的变量对象开始,并延伸至函数上下文,正在执行的上下文的变量对象始终位于作用域链的最前端。
- 内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西
- 查找变量时,先从当前上下文中查找。若找不到,则向上查找父级上下文,直到最顶层的全局上下文。若最终找不到,则抛出错误
Reference Error// Global Scopevar val = 1;function foo() {// Local Scope #fooconsole.log(val);function bar() {// Local Scope #barval = 2;console.log(val);}bar();}foo();

2. 创建变量对象(Variable Object,AO)
变量对象是与执行上下文相关的数据对象,其保存了上下文中所定义的变量和函数,只有解析器在处理数据时才会使用它。
在web浏览器的全局上下文中,window 对象被认为是全局对象(Global Object,GO),因此所有全局变量和函数都是作为 window 对象的属性与方法。即全局上下文中,全局对象预被定义为活动对象,且通过 window 属性指向其自身
this === window; // truewindow.window === window; // true
变量对象是 ECMAScript 规范术语。只有进入执行上下文时,变量对象才被激活,并且其各种属性才能被访问。
因此在函数中,变量对象也被称为 活动对象(Activation Object,AO)
当进入函数执行上下文时,活动对象将被创建,其初始化时只包括 arguments 对象。
arguments:类数组对象,包含了函数所有的形参与长度
随后扫描分析代码:
- 先处理函数声明,使用函数名在活动对象中创建属性,并引用指向该函数。若存在相同的函数名,完全替换之。
- 再处理变量声明,使用变量名在活动对象中创建属性,并初始化值为
undefined。若存在相同的变量名,则跳过。 ```javascript function foo(a) { var b = 1; function fn() {}; var anonymous = function() {}; // 声明变量且指向匿名函数 b = 2; }
context(1);
```javascriptfooExecutionContext = {ScopeChain: { ... },ActivationObject: {arguments: {1: 3,length: 1},a: 1,b: undefined,fn: reference to function fn(),anonymous: undefined},this: { ... }}
3. 确定 this 的值
- 在全局上下文中,
this指向全局对象,具体值由其宿主环境决定
在web浏览器中,默认为非严格模式,其指向window
在ECMAScript规范中,默认开启严格模式,this 的值为 undefined
this; // windowfunction fn() {'use strict';console.log(this);}fn(); // undefined
- 在函数中,
this始终指向调用函数的对象(运行时绑定) ```javascript function fn() { console.log(this); } const obj = { fn };
fn(); // window - 相当于window.fn(); obj.fn(); // obj fn.call(obj); // obj
- ES6 箭头函数中,没有自身的`this`绑定,箭头函数的 `this` 将继承其所在上下文的`this` 值```javascriptfunction fn() {var a = 2;setTimeout(() => {console.log(this.a);}, 0)}var a = 1;fn(); // 1
激活/执行阶段
- 在上下文中逐行执行代码为变量/函数并赋值
fooExecutionContext = {ScopeChain: { ... },ActivationObject: {arguments: {1: 3,length: 1},a: 1,b: 2,fn: reference to function fn(), // 这里是对函数fn的引用anonymous: reference to FunctionExpression // 这里是对函数表达式的引用},this: { ... }}
拓展
变量提升
了解了执行上下文的完整流程,也可以理解var 及 function 的变量提升:
console.log(typeof foo); // functionfunction foo() {};var foo = 1;console.log(typeof foo); // number
编译流程如下:
- 进入全局上下文
- 初始化变量对象
```javascript
/*
- 创建阶段
*/
// 当执行到 line 3 声明变量 foo 指向函数
function foo() {};VO = { foo: reference to function foo() }
- 创建阶段
*/
// 当执行到 line 3 声明变量 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
**注:变量提升只有声明提升,且仅提升到当前执行上下文的最顶部**<a name="n0GES"></a>## let / const在 ES6之前,JS中声明变量只有 `var` 和 `function` 两种形式,并且相对其他语言没有块级作用域,只有全局/函数作用域。这导致了一些不合理的现象:- 在变量声明前使用变量不会报错```javascriptconsole.log(a); // undefinedvar a = 1;
作用域内外变量数据冲突
var a = 1;function fn() {if(false) {var a = 2;}console.log(a);}fn(); // undefined
由于变量提升,在函数
fn创建阶段将变量a覆盖了全局下a的值为undefined,if语句的条件为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
在ES6中新增了 `let` 与 `const` 用于声明变量,并有以下[标准](https://262.ecma-international.org/6.0/#sec-let-and-const-declarations):> The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s _LexicalBinding_ is evaluated大意即:在实例化变量时,包含变量的词法作用域将创建变量,但在具体声明前不允许访问使用<br />该标准指明两点内容:- `let`、`const` 声明仍存在变量提升- `let`、`const` 声明的变量,必须先声明再使用,否则将抛出错误 `ReferenceError`其中,在 `let`、`const`所在作用域直到其声明语句前的区域,在语法上称为**暂时性死区(Temporal Dead Zone, 简称TDZ)**<br />结合例子理解下上述内容:```javascriptconsole.log(a);let a;// Uncaught ReferenceError: a is not defined
- 在声明前使用变量,报错
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
2. 如果不存在变量提升,那么执行函数 `fn` 应该时打印全局作用域中 `a=1` 而不是报错```javascriptfor(let i = 0; i < 3; i++) {console.log('i in for', i);}console.log(i); // Uncaught ReferenceError: i is not defined
let声明的变量仅所在块级作用域有效,不会提升到全局上
注:const 声明的变量并非值不可改变,而是变量指向的那个内存地址所保存的数据不得改动
const a = 1;a = 2; // Uncaught TypeError: Assignment to constant variableconst obj = { a: 1 };obj.a = 2; // { a: 2 }
闭包
了解了 作用域 与 作用域链,我们知道变量存在于上下文的作用域中,当函数执行完毕离开执行上下文时,该作用域将不再存在,理论上也不能再访问其中的变量。如:
function fn() {var a = 1;console.log(a); // 1}fn();console.log(a); // VM38:5 Uncaught ReferenceError: a is not defined
而实际上,我们会发现有些时候并不一样:
function fn() {var a = 1;function log() {console.log(a);}return log;}var demo = fn();demo(); // 1
在这个例子中,我们就成功打印出了函数 fn 中变量a的值,即我们通过在函数 fn 内部嵌套使用 log 函数,保留了对其父级作用域的引用,这也就是闭包。
闭包的应用
for(var i = 1; i <= 5; i++){setTimeout(function(){console.log(i)}, 0)}// (5)6
在 浏览器EventLoops机制 中, setTimeout(fn, 0) 意为,当主线程执行栈内为空时,执行回调函数fn。
在这个例子中,第一次主线程执行完毕后,全局上下文下 i = 6,此时再依次执行 setTimeout() 的回调函数,所以打印结果都是 6。
// 借用闭包解决for(var i = 1; i <= 5; i++){(function(j){setTimeout(function(){console.log(j)},0)})(i)}// 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
