前置知识

我会先尝试分别对这几个概念给出一个大概的描述, 所以如果你在此前并不知道 AO/VO/作用域链或只是有一个模糊的印象, 那么这一部分你可不能跳过:-)

执行上下文

我个人认为这是最重要的一个概念, 也是后续其他部分的基础, 但是要理解什么是执行上下文并不难, 我们都知道当 JS 代码运行时才产生执行上下文, 你可以认为即是当前代码的执行环境, 那么执行上下文通常包含这么三种情况:

<script>
  function a() {
    console.log("Penumbra");
  }
</script>
  • 全局上下文, 你可以理解为当执行到<script>标签时创建.
  • 函数上下文, 函数a被调用执行时产生
  • eval, 不推荐使用, 这里就掠过

JS 引擎是如何处理多个执行上下文的? 很简单, 以的形式, 可以管它叫 函数调用栈 , 在上面的例子里, 栈底永远是全局上下文, 当执行到a, a 入栈, 也就是栈顶即是当前正在执行的上下文. 实际上这是绝对的:

  • 栈顶永远是当前正在执行的上下文
  • 栈底永远是全局上下文, 它也是唯一的一个(啥时候出栈呢, 你 × 掉浏览器窗口的时候)

由于 JS 是单线程, 所以我们可以知道只有栈顶的上下文执行完毕出栈后才会执行下一个. 每有一个函数被调用, 就会创建一个新的执行上下文, 即使是递归调用自己.

可以先扯一个闭包的例子:

function a() {
  const n = 1;
  function b() {
    alert(n);
  }
  return b;
}
const result = a();
result(); // 1

这里的函数调用栈是如何变动的? 其实也一样, 记住只有在执行时才会创建执行上下文, 所以只有走到result();时, 才会将b的执行上下文压入栈顶执行. 而 a 呢? 在前一步, a 已经执行出栈了, return 等直接终止当前代码执行的操作, 会直接将当前执行上下文弹出栈(Boom).

变量对象 VO 与活动对象 AO

JS 引擎是如何查找到变量的? 在哪里查找?

要搞清这个, 还需要对执行上下文了解的更多一点, 前面我们说到了 当前执行的上下文, 可能有些同学会觉得奇怪, 这玩意咋还能执行呢? 还真能, 我们把一个执行上下文的生命周期分为两阶段:

  • 创建阶段
    • 初始化 VO
    • 建立(连接)作用域链(Scope Chain)
    • 确定this的指向
  • 执行阶段
    • 赋值变量&函数
    • 执行代码

这里不用在意其他, 我们本小节的目的只是弄清楚 VO, 等弄懂了 VO 后面再讲起来就 easy 的很

我们知道一个非空对象一定会有KeyValue, 并且用键值对来存储信息, 变量对象顾名思义就是用来存储当前执行上下文的变量信息的, 它只是执行上下文的一个属性. 它主要存储着这些信息:

  • 变量声明
  • 函数声明
  • 函数形参

而收集信息的过程又大致分为以下几个阶段:

  • 建立arguments对象, 即收集函数参数信息

  • 收集函数声明, 会使用函数名来建立一个属性, 值为函数的内存地址(的引用).

  • 收集变量声明, 建立对应变量名的属性, 此时值为undefined

  • 对于函数表达式, 类似于变量声明赋值为undefined

  • let 和 const 也会被提升, 但不会给它赋值! (暂时性死区)

现在你知道为啥会有变量提升, 同时函数声明又优先于同名变量声明了吧(这个阶段以函数声明为准, 但不会去动变量声明).

那么 AO 呢? 它又和 VO 是什么关系?

我们看个例子:

function test() {
  console.log(a);
  console.log(foo());
  var a = 1;
  function foo() {
    return 2;
  }
}
test();
  • 当我们运行到test(), test 的执行上下文入栈, 开始第一个阶段: 创建

  • 创建 VO, 如

    VO = {
        arguments: {...}
        foo: <foo reference>  // 表示foo的地址引用
        a: undefined
    }
    
  • arguments 的属性值我相信大家都用过, 主要是这么几个

    • caller, 调用当前函数的函数, 如果没有父函数(即在全局下调用)则为 null
    • callee, 拥有这个 arguments 对象的函数, 不推荐使用
    • [index], 参数值
  • 作用域链和 this, 这里先跳过

  • 执行上下文第二个阶段, VO 被激活成为 AO, 此时里面的属性才可以被访问.

  • 然后执行到了foo(), 由于函数声明被提升, 所以此时foo可以被访问, foo的执行上下文入栈.

  • 开始创建foo的执行上下文...

    你可能觉得这样 VO 和 AO 好像其中一个是多余的, 实则不然, 它们其实是同一个对象, 但是只有当前调用栈栈顶的执行上下文的 VO, 才能够被激活蜕变成为 AO. 在foo执行完后, test回到栈顶, 它的 VO 才会重新变成 AO.

  • 注意: 我们不能直接访问 VO 对象, 在我的理解它就和Object.prototype一样是内部机制的造物, 但也有个例外: 全局上下文:

    前面我们说到, 执行上下文创建时会创建 VO, 那么全局上下文呢? 实际上这里有一个特殊的地方, 它的 VO 即是 window, 就和全局上下文的 this 一样. 也就是由于这一点, 所以我们可以直接访问全局上下文的 VO. 我们不妨再给它一个严谨的定义:

    全局对象是唯一的, 在进入其他任何执行上下文前就绝对已经创建完毕, 它的属性你可以在任何地方访问(这个机制我们会在后面作用域链讲到).

  • 代码执行阶段, 会一边执行一边修改 AO 的值, 如此时a被赋值为 1

总结一下, 执行上下文的创建阶段会创建 VO, 在处于栈顶时 VO 会被激活, 而后在当前执行上下文执行阶段, AO 的值会被更新.

作用域和作用域链

我们都知道 JS 使用的是词法作用域(也叫词法环境), 也就是说函数的作用域取决于我们创建这个函数的位置. 这实际上就是一套规则, 规定了 JS 引擎如何查找需要的变量/函数. 需要区分执行上下文与词法作用域!

害对了还有个作用域链呢, 顾名思义它把作用域串起来了吗? 可不能这么简单理解, 那它是个啥? 我们可以理解就是一条链子, 它在代码执行过程中生成, 组成它的则是当前环境与上层环境的VO, 如当前栈顶正在执行的执行上下文的作用域链就是由父函数, 爷函数...的 VO 组成的. 为什么要这么设定呢, 其实还是为了保证当前的执行上下文只能访问到有访问权限的变量/函数.

看个例子:

var a = 20;
function test() {
  var b = a + 10;
  function innerTest() {
    var c = 10;
    return b + c;
  }
  return innerTest;
}
const res = test();
res();
  • 分析一下innerTest的执行上下文, 大概是这样的:

    innerTestEC = {
      VO: {...},
      scopeChain: [VO(innerTest), VO(test), VO(global)],
    }
    

    这里使用数组来表示, 是因为我觉得作用域链并不是包含关系, 说是一趟单程高铁更合适, 如果这一站里没有找到我想要的, 就会去下一站, 直到到达终点站: 全局变量对象.

  • 这个例子里有个闭包, 我们可以顺便提前分析下 闭包是如何捕获所在执行上下文的变量的:

    • 当执行res()时, innerTest的执行上下文才会被创建, 在此时创建 VO 与作用域链, 在其使用到b这个变量时, 它会发现当前作用域链的首位里没有b这个变量, 于是搭上高铁去下一站寻找. 这也就导致了一个现象: 同名变量的覆盖. 即如果在这一站找到了就不会再去下一站, 即使下一站也有这个变量.
    • 但是可能会有细心的同学有个疑问, test执行完后不是就弹出栈销毁了吗? 为什么innerTest还能够获取test内部的变量? 这个我们留到闭包那一节再讨论.

闭包

在这里我想先不接着上面的叙述来讲解闭包, 而是直接说道说道闭包是个啥.

简单来说, 闭包就是有权访问另一个函数作用域(即使函数在当前词法作用域之外执行)的函数, 由两部分组成

  • 创建该函数的上下文
  • 该函数

或者按照 MDN 的定义, 函数与其词法环境的引用共同构成了闭包, 这里的词法环境包含了闭包创建时所能访问的所有局部变量!

Tips: 并不是只有闭包内部用到的变量被保存了引用

在知乎上我还看到了一个很有意思的定义:

  • 对象是携带了方法的数据
  • 而闭包是携带了数据的方法

携带了数据意味着闭包是由记忆的, 它的记忆即是对自己的词法作用域的引用. 看一个常见的例子:

var inc = (function() {
  // 该函数体中的语句将被立即执行(IIFE)
  var count = 0; // 局部变量 count 初始化
  return function() {
    // 父函式返回一个闭包(函式引用)
    return ++count; // 当父函式 return(即上一个 return)后,这里的 count 不再是父函式的局部变量,而是返回结果闭包中的一个闭包(环境)变量。
  };
})();
inc(); // return: 1
inc(); // return: 2

这里有两个 count, 它们有什么区别?

按照前面讲的, 闭包保存了词法作用域中局部变量的引用, 也就是说闭包内部的 count 是对外部 count 的引用. 我是这么猜测的, 当 IIFE 执行完毕实际上它还是被弹出栈了, 但是 JS 引擎在它弹出前保存了一份到堆上, 并且按引用访问. 也就是这里保持的引用并不是对第一个 count 变量的.

闭包有几种常见的形式:

  • 多个独立闭包

    function makeAdder(x) {
      return function(y) {
        return x + y;
      };
    }
    var add5 = makeAdder(5);
    var add10 = makeAdder(10);
    console.log(add5(2)); // 7
    console.log(add10(2)); // 12
    

    这里的add5add10保存的是两份不同的引用, 我们可以用前面执行上下文的知识试着解析下:

    • 执行makeAdder(5), makeAdder 入栈, AO 收集到形参 x=5, 并在 return 后弹出栈. 内部的匿名函数此时已经保存了引用, 并且它内部的 x 也被初始化为 5.
    • 执行add5(2), 此时匿名函数上下文入栈, 按引用访问到了 x=5, 并且 AO 收集到形参 y=2.
    • 你可以注意到这里执行了两次makeAdder函数, 也就是说这个函数是入栈-出栈-又入栈-又出栈. 我们前面知道每次入栈时都会创建新的执行上下文, 初始化新的 VO 对象...一系列流程, 所以这里的两个闭包所"记住"的词法环境是完全不同的, 因此也不会互相干扰.
  • 一个闭包拥有多个接口

    var Counter = (function() {
      var privateCounter = 0;
      function changeBy(val) {
        privateCounter += val;
      }
      return {
        increment: function() {
          changeBy(1);
        },
        decrement: function() {
          changeBy(-1);
        },
        value: function() {
          return privateCounter;
        },
      };
    })();
    console.log(Counter.value()); /* logs 0 */
    Counter.increment();
    Counter.increment();
    console.log(Counter.value()); /* logs 2 */
    Counter.decrement();
    console.log(Counter.value()); /* logs 1 */
    

    在这里, Counter 只被推入函数调用栈一次, 也就是说这里实际上只形成了一个闭包, 我理解为increment/decrement/value这三个函数实际上只算一个闭包, 它们对堆上的privateCounter以及changeBy的引用是共享的, 所以会相互影响.

现在我们可以真正的来用作用域链以及执行上下文的知识来尝试理解闭包了:

我们再对作用域链进行一些补充, 以便完整的理解整个流程:

  • 作用域链是由 AO/VO(顶部->AO)组成的单向链表, 指针即为对上层 VO 的引用.

  • 执行上下文的内部有一个[[scope]]属性, 它指向当前作用域链的顶端

    var bb = "全局变量";
    function AA(y) {
      var bb = "局部变量";
      function s() {
        var z = 0;
        alert(bb);
      }
      s();
    }
    AA(5);
    

    我们暂且理解为 JS 引擎会在编译时创建作用域链(为了与词法作用域对应)

    • 全局执行环境, [[scope]]-->VO[AA], 此时仅有全局 VO, 于是全局执行上下文的[[scope]]直接指向 VO.
    • 函数 AA 执行环境:[[scope]]---->VO[[s,bb]VO[[AA,bb]],首先全局 VO 压入栈,然后函数 AA VO 压入栈顶,[[scope]]属性指向栈顶,变量、函数搜索就从栈顶开始。
    • 函数 s 执行环境:[[scope]]--->VO[[z]] VO[[s,bb] VO[[AA,bb]] ,首先全局 VO 压入栈,然后依次 AA,s 压入栈,s 处于栈顶,[[scope]]属性直接指向 s 的 VO。
    • 此时调用了s, 那么会先在顶端(即当前的 VO 内)查找bb变量, 没找到, 于是继续向上直到寻获.
  • 上面我说道函数在执行上下文创建时创建作用域链, 这里又说在编译时创建, 是否自相矛盾? 其实[[scope]]是所有父级变量对象的层级链, 它是在函数创建时就被存储且不可变的, 执行上下文创建时我们更像是在连接作用域链, [[scope]]是在当前上下文之上的一个属性. 即使你永远不调用这个函数, 它也会存在, 而作用域链只有执行时才会被连接.

  • 这么说可能有点绕, 那我们再回想创建 AO/VO 的过程, 激活得到 AO 时, AO 会被添加到当前作用域链顶端, 即优先在 AO 内查找变量.

在回到闭包的例子, 现在我们可以解释为什么闭包能够在父函数出栈后仍然获取引用了, 因为这个函数创建时就已经有了[[scope]]属性, 闭包也正是这个函数和其[[scope]]("对词法环境的局部变量的引用")组成的, 但是在闭包被执行时, 我们会去搜索使用到的变量..., 这时又从何而找呢? 我的理解是 JS 引擎将父函数出栈时, 发现还有函数要使用父函数的变量, 于是将父函数的 VO 保存了一份等着闭包执行时初始化闭包内部的变量, 并将内部变量保存在堆上按引用访问.

关于闭包的应用这里就不再赘述, 主要集中在柯里化/模块化开发/私有变量等方面, 有兴趣的同学可以自己查阅相关资料.

this

我在考虑是否单独写一篇好了

总结

这篇文章主要讲述了以下几个方面的知识:

  • 执行上下文的创建

    • 初始化 VO
      • 收集参数
      • 收集变量/函数声明
    • 连接作用域链
    • 确定 this 指向
  • 执行上下文正式开始执行

    • VO 被激活, 跟随代码执行重新赋值内部属性
    • AO 同时会被推入作用域链顶端, 最先在 AO 中查找变量/函数
    • 当前 AO 没有找到, 则进入下一个 VO 查找, 这里 VO 并不会被激活, 但是能够访问.
  • 闭包

    • 使用父函数的局部变量(保存的 VO)初始化自己内部的变量, 并将这个变量保存在堆上, 按引用访问.