为何你始终理解不了JavaScript作用域链?

8,838 阅读6分钟

前言

掘金上关于作用域和作用域链的讨论非常多,但少有人来讲清楚JS中相关的机制,这里我就捡一些大佬们看剩的知识,来讲讲理解作用域之前的准备。 带着这些问题看文章:

  • JavaScript 是如何编译执行的?
  • 查找作用域时是如何一层层往上查询的?
  • JavaScript作用域链的本质是?

想直接看解析的请跳到:2. JavaScript是如何执行的?

还有速记口诀:作用域链口诀

1. 理解前的普及:编译原理

1.1 分词/词法解析

这些代码块被称为词法单元(token) ,这些词法单元组成了词法单元流数组

var sum = 30;
// 词法分析后的结果
[  "var" : "keyword",  "sum" : "identifier",  "="   : "assignment",  "30"  : "integer",  ";"   : "eos" (end of statement)]

1.2 语法分析

把词法单元流数组转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,这个树被称为“抽象语法树” (Abstract Syntax Tree, 简称AST)。

1.3 代码生成

将抽象语法树(AST)转换为一组机器指令,也就是可执行代码,简单说,就是用来创建一个变量a,并将3这个值储存在a中。

1.4 JavaScript 编译过程的不同处

  • JavaScript 大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内
  • JavaScript 引擎用尽了各种办法(比如 JIT,可以延 迟编译甚至实施重编译)来保证性能最佳

2. JavaScript是如何执行的?

  • 核心重点:变量和函数在内的所有声明都会在任何代码被执行前首先 被处理。

  • 函数运行的瞬间,创建一个AO (Active Object 活动对象)运行载体。

2.1 例子一


function a(age) {
    console.log(age);
    var age = 20
    console.log(age);
    function age() {
    }
    console.log(age);
}
a(18);

2.1.1 分析阶段

函数运行的瞬间,创建一个AO (Active Object 活动对象)

AO (Active Object 活动对象) 相当于载体

AO = {}
第一步,分析函数参数:
形式参数:AO.age = undefined
实参:AO.age = 18
第二步,分析变量声明:
// 第3行代码有var age
// 但此前第一步中已有AO.age = 18, 有同名属性,不做任何事
即AO.age = 18
第三步,分析函数声明:
// 第5行代码有函数age
// 则将function age(){}付给AO.age
AO.age = function age() {}
函数声明特点:AO上如果有与函数名同名的属性,则会被此函数覆盖。

因为函数在JS领域,也是变量的一种类型

分析阶段最终结果是:
AO.age = function age() {}

2.1.2 执行阶段

2.2 例子二

    function a(age) {
        console.log(age);
        var age = function () {
            console.log('25');
        }
    }
    a(18);

2.2.1 分析阶段

第一步,分析函数参数:
形式参数:AO.age = undefined
实参:AO.age = 18
第二步,分析变量声明:
// 第3行代码有函数表达式 var age = function () { console.log('25');}
// 但此前第一步中已有AO.age = 18, 有同名属性,不做任何事
即AO.age = 18
第三步,分析函数声明(无)
分析阶段最终结果是:
AO.age = 18

2.2.2 执行阶段

2.3 例子三

 function a(age) {
        console.log(age);
        var age = function () {
            console.log(age);
        }
        age();
    }
a(18);

2.3.1 分析阶段

第一步,分析函数参数:AO.age = 18
第二步,分析变量声明:有同名属性,不做任何事 AO.age = 18
第三步,分析函数声明(无)
分析阶段最终结果是:
AO.age = 18

2.3.2 执行阶段

到这里,很多人会犯迷糊:age();不是应该输出18 吗?

代码执行到age();时,其实又会再分析 & 执行。

2.3.3 age()的分析&执行

// 分析阶段
创建AO对象,AO = {}
第一步,分析函数参数(无)
第二步,分析变量声明(无)
第三步,分析函数声明(无)
分析阶段最终结果是:AO = {}
  • age() 自己的AO对象,即age.AO是个空对象时,它会往上调用。
  • 上一级的AO对象a,即a.AO, a.AO下有个执行完后得到的a.AO.age = function(){console.log(age);}
  • 输出 ƒ () { console.log(age); } `

2.4 执行总结:何为作用域链

JavaScript上每一个函数执行时,会先在自己创建的AO上找对应属性值。若找不到则往父函数的AO上找,再找不到则再上一层的AO,直到找到大boss:window(全局作用域)。 而这一条形成的“AO链” 就是JavaScript中的作用域链。

3.LHSRHS查询:作用域链的两大利器

LHS,RHS 这两个术语就是出现在引擎对变量进行查询的时候。在《你不知道的Javascript(上)》也有很清楚的描述。在这里,我想引用freecodecamp 上面的回答来解释:

LHS = 变量赋值或写入内存。想象为将文本文件保存到硬盘中。 RHS = 变量查找或从内存中读取。想象为从硬盘打开文本文件。 Learning Javascript, LHS RHS

3.1 两者的特性

  • 都会在所有作用域中查询
  • 严格模式下,找不到所需的变量时,引擎都会抛出ReferenceError异常。
  • 非严格模式下,LHR稍微比较特殊: 会自动创建一个全局变量
  • 查询成功时,如果对变量的值进行不合理的操作,比如:对一个非函数类型的值进行函数调用,引擎会抛出TypeError异常

3.2 拿书中的例子来讲

function foo(a) {
    var b = a;
    return a + b;
}
var c = foo( 2 );

直接看执行查找:

LHS(写入内存):

c=, a=2(隐式变量分配), b=

RHS(读取内存):

读foo(2), = a, a ,b
(return a + b 时需要查找ab)

按 写入/读取内存来理解,是不是比书中的好理解多了?

3.3 关于LHSRHS抛错

拿两个最简单的例子将:

3.3.1 不合理的操作

LHS执行查询阶段,原本查询成功,但将a作用函数调用a();,故引擎会抛出TypeError异常。

3.3.2 LHS抛错

LHS比较少见的情况是:很多时候我们都没开启严格模式,即:“use strict”。 你们可以现在打开chrome调试工具,分别试下以下代码严格/非严格模式的输出:

“use strict”
function init(a){
  b=a+3;
}
init(2);    
console.log(b);

3.3.3 RHS抛错

4. 作用域链口诀

这里我们拿《你不知道的Javascript(上)》中的一张图解释:

我也总结了一个作用域链口诀,教你快速找到输出:

  • 分析阶段创AO,参数看完找变量,变量不顶函数顶,顶完之后定乾坤。

  • 执行阶段看LR,内层不行找外层,翻遍楼层找不到,抛个异常连连看。

感悟:

这几天摸爬滚打的找了很多资料,发现很多都讲得语焉不详。要么非常复杂,讲得贼深奥。要么就是粗略概括,没有系统介绍。这也是为啥这么多将作用域与作用域链,却没一个彻底看明白的原因(大概率也是因为菜)

作者文章总集

公众号