工作两三年中, 偶然听同事们在讨论作用域 balabala.. 仔细回想一下好像自己也不太能说清他俩的区别到底是啥, 于是进行了一波回炉重造

从万物之源编译 JS开始

JS 编译一共会经历四个阶段: 词法分析/语法分析/预编译/代码执行, 大致描述一下这四个阶段在做什么:

  • 词法分析: 将符号流转为记号流, 将代码分解成最小词法单元

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // JS 源代码
    var AST = 'tree';

    // 分解为词法单元,转化为这样👇🏻
    [
    {
    "type": "Keyword",
    "value": "var"
    },
    {
    "type": "Identifier",
    "value": "AST"
    },
    {
    "type": "Punctuator",
    "value": "="
    },
    {
    "type": "String",
    "value": "'tree'"
    },
    {
    "type": "Punctuator",
    "value": ";"
    }
    ]
  • 语法分析: 再将记号流生成为抽象语法树(AST)

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

    • 创建执行上下文
    • 确定变量对象、作用域链、this指向
    • 变量对象填充顺序:函数形参、函数声明、变量声明
  • 代码执行

作用域

众所周知 JS 是词法作用域, 而词法作用域则决定了代码在书写的时候就会确定如何查找变量. 那么再回到 JS 的编译过程中, 作用域是在词法分析阶段确定, 换而言之: 你如何书写代码就决定了当前代码块对变量或其他资源的访问权限, 而查找变量也是根据定义的位置向上查找.

举个🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 这里是父级 window
var a = 2

const foo = () => {
// 这里是子级 1
console.log(a)
}
const bar = () => {
// 这里是子级 2
var a = 3
foo()
}

bar() // 输出 2

从输出结果来看打印的是 2 而不是 3. 我们结合词法作用域来分析, foo 方法的父级是 window 而不是 bar. 所以当我们在 foo 方法中查找 a 变量时, 在当前作用域中没找到, 就会继续向上查找从而在父级 window 中找到了 var a = 2. 由此可以看出我们查找变量时, 与函数在何处调用的无关, 而是跟定义位置有关

同时作用域也是一个分层和独立的区域, 分层表示: 内部作用域可以访问外部作用域的变量; 独立表示: 可以保证变量不对外暴露. 同样也举个🌰来说:

1
2
3
4
5
6
7
8
9
10
11
12
const wrapper = () => {
var a = 3
const inner = () => {
// 内部作用域 inner 可以访问到父级 wrapper 的变量 a
console.log(a)
}

inner() // 输出: 3
}

// 全局作用域则访问不到内部作用域 wrapper 中的变量
console.log(a) // Error: a is not defined

作用域链

刚刚提到了当我们在查找变量时, 如果在当前作用域中没有找到, 会继续向上在父级作用域中去找. 那么当前作用域怎么知道我有哪些父级作用域呢?

这是因为函数中有一个私有属性[[scope]], 当函数在创建的时候(预解析阶段), 会保存父级的所有变量对象指针到[[scope]] 属性中注: 函数的 [[scope]] 属性并不是作用域链, be like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const wrapper = () => {
const inner = () => {
// ...
}
}

// 当函数创建时:
wrapper.[[scope]] = [
gobalContext.VO
]

inner.[[scope]] = [
wrapperContext.AO
gobalContext.VO
]

后续执行 wrapper 函数创建执行上下文时(预编译阶段), 进入函数上下文并创建 VO/AO, 就会将活动对象添加到作用域顶端

1
2
3
4
wrapperContext = {
AO: {}, // 活动对象: wrapper 函数的参数以及变量
Scope: [AO, wrapper.[[scope]]] // !!! 这才是作用域链
}

关于 [[scope]] 和执行上下文中 Scope 的理解:
当你定义(书写)一个函数时, JS 引擎可以根据你书写的位置: 函数嵌套的位置, 生成一个[[scope]] 属性, 这个属性是属于函数的(即使函数不调用), 所以说基于词法作用域(静态的)
而 Scope 属性是在函数执行时, 生成执行上下文(VO/Scope/this), 这个是时候的 Scope 和 [[scope]] 不是同一个东西. Scope 是在 [[scope]] 的基础上新增了当前的 AO 对象来构成
所以函数定义时的[[scope]]是函数的属性, 函数执行时的 Scope 是执行上下文的属性

执行上下文

前面我们提到 VO/AO 这两个东西, 之前我们一直在说作用域和作用域链是怎么来的, 但还没提到里面的变量是如何填充进去供访问时使用的. 下面我们就来聊聊变量是如何保存和填充的

在 JS 编译过程中有提到一个叫预编译的阶段, 在这个阶段中 JS 会对可执行代码进行一个 “准备工作” 也就是执行上下文, 之后运行的代码都是在执行上下文中运行. 它一共有三种类型: 全局上下文/函数上下文/eval 上下文

对于每个执行上下文, 都有三个重要的属性:

  • 变量对象(VO): 储存上下文中定义的变量和函数声明
  • 作用域链
  • this

变量对象/活动对象

  • 全局上下文中的变量对象(VO)其实就是全局对象

  • 对于函数上下文来说, 我们用活动对象(AO: activation object)来表示变量对象

小总结一下: 活动对象和变量对象其实是一个东西, 只是变量对象是在引擎上实现的不可以在 JS 环境中被访问. 只有当进入一个执行上下文时, 这个执行上下文的变量对象才会被激活, 而只有被激活的变量对象时, 也就是活动对象上的属性才能被我们访问

填充过程
执行上下文会被分为两个阶段处理:

  1. 创建执行上下文:
    此时变量对象会包括
    1. 函数形参(如果是函数上下文的话)
      • key: 形参名称
      • value: 实参的值(没有就是 undefined)
    2. 函数声明
      • key: 函数名称 (会覆盖同名的函数声明)
      • value: 函数对象
    3. 变量声明 (会覆盖同名的函数声明或参数会忽略)
      • key: 变量名称
      • value: undefined
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        function foo(a) {
        var b = 2;
        function c() {}
        var d = function() {};

        b = 3;
        }

        foo(1);

        // 此时 AO 为
        AO = {
        arguments: {
        0: 1,
        length: 1
        },
        a: 1,
        b: undefined,
        c: reference to function c(){},
        d: undefined
        }

创建完变量对象后, 就会创建作用域链把 AO 添加到作用域链前端. 同时 This 的执行也会在创建阶段确定

  1. 执行阶段: 根据代码修改变量对象中的值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 此时 AO 更新为
    AO = {
    arguments: {
    0: 1,
    length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
    }

而我们熟知的执行上下文栈就是用来管理多个执行上下文的, 当 JS 开始要执行代码时, 首先遇到的就是全局代码, 所以初始化的时候会先向执行上下文栈中压入一个全局执行上下文, 直到所有代码执行完之前栈底始终都会有一个globalContext. 我们来模拟一个执行上下文栈的行为增强理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 开始执行
ECStack = [
globalContext
]

// 遇到这段代码
function fun3() {
console.log('fun3')
}
function fun2() {
fun3();
}
function fun1() {
fun2();
}
fun1();

// 首先执行 fun1
ECStack.push(fun1) // fucntionContext
// fun1 中调用了 fun2, 创建 func2 的执行上下文
ECStack.push(fun2) // fucntionContext
// fun2 中调用了 fun3, 创建 func3 的执行上下文
ECStack.push(fun3) // fucntionContext

// fun3执行完毕
ECStack.pop()
// fun2执行完毕
ECStack.pop()
// fun3执行完毕
ECStack.pop()

// 所有代码执行完毕
ECStack.pop() // 弹出 globalContext

有点懵逼, 捋一捋(用 ES3)

以下面的例子为例, 从创建函数作用域到变量对象填充的全过程

1
2
3
4
5
6
7
var global = 'global scope'

function localfunction () {
var local = 'local scope'
return local
}
localfunction()
  1. 词法分析阶段: 创建函数作用域, 注意创建函数作用域时, 是词法作用域!!!

  2. 创建函数

    1
    2
    3
    4
    5
    // 初始化 globalContext 的时候就会创建函数
    // 保存父级变量对象的引用到, 函数的 [[scope]] 属性
    localfunction.[[scope]] = [
    globalContext.VO
    ]
  3. 预编译阶段: 创建函数执行上下文并压入执行上下文栈

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    // 创建的 lcoalContext 压入 local 执行上下文
    ECStack = [
    lcoalContext,
    globalContext
    ]

    // 复制[[scope]] 初始化作用域链
    lcoalContext = {
    Scope: localfunction.[[scope]]
    }

    // 创建活动对象, 初始化 Arguments 对象
    lcoalContext = {
    AO: {
    arguments: {
    length: 0
    },
    local: undefined,
    },
    Scope: localfunction.[[scope]]
    }

    // 将活动对象压入 lcoal 作用域链顶端
    lcoalContext = {
    AO: {
    arguments: {
    length: 0
    },
    local: undefined,
    },
    Scope: [AO, localfunction.[[scope]]]
    }
  4. 准备工作完成, 执行代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 开始执行函数, 修改 AO 属性值
    lcoalContext = {
    AO: {
    arguments: {
    length: 0
    },
    local: 'local scope',
    },
    Scope: [AO, localfunction.[[scope]]]
    }
  5. 代码执行完毕

    1
    2
    3
    4
    5
    6
    // 弹出 localContext
    ECStack.pop()

    ECStack = [
    globalContext
    ]

ES6 执行上下文(补充)

在 ES6 规范中去除了, 上面 ES3 版本中的变量对象/活动对象, 换成了词法环境组件(LexicalEnvironment component)和变量环境组件(VariableEnvironment component)替代

同样分两个阶段:

  • 创建阶段

    1. 确定 this 的值, 也就是 This Binding(全局执行上下文指向全局对象; 函数上下文取决于函数如何被调用)
    2. 创建词法环境组件, 有两种类型: 全局环境/函数环境. 词法环境内部有两个组件: 环境记录器外部环境的引用. 其中环境记录器也有两种类型: 对象环境记录器用来定义全局环境中的变量和函数; 声明式环境记录器用来储存函数环境中的变量、函数和参数

      • 全局环境: 对象环境记录器中包含所有全局对象, 外部环境的引用为 null
      • 函数环境: 声明式环境记录器储存本地变量、函数和参数, 外部环境的引用包含父级词法环境(类似 ES3 的作用域链)
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        // 全局执行上下文
        GlobalExectionContext = {
        // 词法环境
        LexicalEnvironment: {
        //环境记录器
        EnvironmentRecord: {
        Type: 'Object', // 对象环境记录器
        // 对外部环境的引用
        outer: '<null>' // 这里并不是字符串, 只是为了让代码识别😂
        }
        }
        }
        // 函数执行上下文
        FunctionExectionContext  = {
        // 词法环境
        LexicalEnvironment: {
        //环境记录器
        EnvironmentRecord: {
        Type: 'Declarative', // 声明式环境记录器
        // 对外部环境的引用
        outer: '<Global or outer function environment reference>'
        }
        }
        }
    3. 创建变量环境组件: 其实也是一种词法环境, 所以它也拥有词法环境的所有属性. 词法环境和变量环境的区别:

      • 词法环境: 储存函数声明和 let/const 关键字声明的变量, 在函数级作用域的基础上实现了块级作用域
      • 变量环境: 储存 var 关键字声明的变量, 实现了函数级作用域
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        // 通过例子来看
        let a = 1
        const b = 2
        var c

        function foo(e, f) {
        var g = 3;
        return e + f + g;
        }

        c = foo(3, 4);

        // 执行上下文的伪代码:
        GlobalExectionContext = {
        ThisBinding: '<Global Object>',
        LexicalEnvironment: { // 储存函数声明和 let/const 的变量
        EnvironmentRecord: {
        Type: 'Object', // 对象环境记录器
        a: '< uninitialized >', // 注意⚠️: 这里并没有像 ES3 一样初始化成 undefined
        b: '< uninitialized >',
        foo: '< func >'
        }
        outer: '<null>'
        },

        VariableEnvironment: { // var 的变量
        EnvironmentRecord: {
        Type: 'Object',
        c: undefined, // 初始化成了 undefined
        }
        outer: '<null>'
        }
        }

        FunctionExectionContext = {
        ThisBinding: '<Global Object>',

        LexicalEnvironment: {
        EnvironmentRecord: {
        Type: 'Declarative', // 声明式环境记录器
        Arguments: {0: 3, 1: 4, length: 2},
        },
        outer: '<GlobalLexicalEnvironment>'
        },

        VariableEnvironment: {
        EnvironmentRecord: {
        Type: 'Declarative',
        g: undefined
        },
        outer: '<GlobalLexicalEnvironment>'
        }
        }

可以看出 ES6 中, var 和 let/const 能够共存也是因为词法环境和变量环境的区分. 发生变量提升也是因为变量环境中初始化变量赋值成了 undefined, 而词法环境中的变量则保持了未初始化.

  • 执行阶段: 代码执行, let/const 声明的变量没有找到值的话才会赋值为 undefined

总结

  • 作用域: 决定了代码块中如何访问变量或其他资源, 在在词法分析时就确定了
  • 执行上下文: 用来评估代码环境, 包括三个重要属性: 变量对象/作用域链/this. 代码执行时才能确定具体值, 随时可能会改变
  • 作用域链: 决定如何查找变量或其他资源, 会先从当前作用域下执行上下文中的变量对象中查找变量, 如果没有找到会沿着词法作用域的父级向上查找父级作用域下执行上下文的变量对象中查找, 直到全局作用域. 这样由多个执行上下文的变量对象构成
  • 执行上下文栈: 用来管理多个执行上下文, 每当遇到一个函数调用都会为其创建一个新的执行上下文并压入栈中, 函数执行完毕后弹出

参考文章

JavaScript深入之作用域链