mdn 中解释的闭包

闭包让你可以在一个内层函数中访问到其外层函数的作用域。 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

在我们深刻理解闭包之前,我们来了解一些概念,从作用域,到执行上下文,再到闭包。

作用域

作用域是指程序源代码中定义变量的区域。
作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

静态作用域和动态作用域

因为 JavaScript 采用的是静态作用域,函数的作用域是在函数定义的时候就决定了。
而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
让我们认真看个例子就能明白之间的区别:

  1. var value = 1;
  2. function foo() {
  3. console.log(value);
  4. }
  5. function bar() {
  6. var value = 2;
  7. foo();
  8. }
  9. bar();
  10. // 静态作用域的结果是 1 —— 实际 JS 的结果
  11. // 动态作用域的结果是 2
  1. 假如 JavaScript 采用静态作用域,执行过程如下:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value。如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

  1. 假如 JavaScript 采用动态作用域,执行过程如下:

执行 foo 函数,依然从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

思考题

  1. // 例一
  2. var scope = "global scope";
  3. function checkscope(){
  4. var scope = "local scope";
  5. function f(){
  6. return scope;
  7. }
  8. return f();
  9. }
  10. checkscope();
  1. // 例二
  2. var scope = "global scope";
  3. function checkscope(){
  4. var scope = "local scope";
  5. function f(){
  6. return scope;
  7. }
  8. return f;
  9. }
  10. checkscope()();

两段代码执行结果都是 local scope。

JavaScript 采用的是词法作用域,函数的作用域是基于函数创建的位置。

而引用《JavaScript权威指南》的回答就是:
JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。
嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢? 小揭秘:执行上下文栈的变化不一样。详细内容继续往下看。

执行上下文

执行上下文,别名执行环境

我们来看两个例子

  1. var foo = function () {
  2. console.log('foo1');
  3. }
  4. foo(); // foo1
  5. var foo = function () {
  6. console.log('foo2');
  7. }
  8. foo(); // foo2
  1. function foo() {
  2. console.log('foo1');
  3. }
  4. foo(); // foo2
  5. function foo() {
  6. console.log('foo2');
  7. }
  8. foo(); // foo2

JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。
当执行一段代码的时候,会进行一个“准备工作”,比如第一个例子中的变量提升,第二个例子中的函数提升。

但是本文真正想让大家思考的是:这个“一段一段”中的“段”究竟是怎么划分的呢? 到底 JavaScript 引擎遇到一段怎样的代码时才会做“准备工作”呢?

这就要说到 JavaScript 的可执行代码(executable code)的类型

  • 全局代码
  • 函数代码
  • eval 代码

“一段一段”的“段”,就是根据可执行代码类型,来分段。

当执行全局代码时,会进行准备工作 当执行一个函数代码的时候,就会进行准备工作 当执行一个 eval 代码的时候,就会进行准备工作

这里的“准备工作”,让我们用个更专业一点的说法,就叫做创建“执行上下文(execution context)”

接下来问题来了,我们写的函数多了去了,如何管理创建的那么多执行上下文呢?
所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文

执行上下文栈

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

  1. ECStack = [];

试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext

  1. ECStack = [
  2. globalContext
  3. ];

示例

  1. function fun3() {
  2. console.log('fun3')
  3. }
  4. function fun2() {
  5. fun3();
  6. }
  7. function fun1() {
  8. fun2();
  9. }
  10. fun1();

遇到了如上一段代码,执行上下文栈是如何变化的?

  1. // 伪代码
  2. // fun1()
  3. ECStack.push(<fun1> functionContext);
  4. // fun1中竟然调用了fun2,还要创建fun2的执行上下文
  5. ECStack.push(<fun2> functionContext);
  6. // 擦,fun2还调用了fun3!
  7. ECStack.push(<fun3> functionContext);
  8. // fun3执行完毕
  9. ECStack.pop();
  10. // fun2执行完毕
  11. ECStack.pop();
  12. // fun1执行完毕
  13. ECStack.pop();
  14. // javascript 接着执行下面的代码,但是 ECStack 底层永远有个 globalContext

执行上下文的内容

执行上下文(ExecutionContext)包含了以下三个部分

  • this 的值(ThisBinding
  • LexicalEnvironment(词法环境) 被创建
  • VariableEnvironment(变量环境) 被创建
    1. ExecutionContext = {
    2. ThisBinding = <this value>,
    3. LexicalEnvironment = { ... },
    4. VariableEnvironment = { ... },
    5. }

    冴羽大佬把执行上下文分为三个部分

    • 变量对象——相当于下文讲的 Variable Environment
    • 作用域链——相当于下文讲的 outer 构成的链
    • this ——相当于下文讲的 ThisBinding

    虽然这种方式也方便理解,但是根据 ECMA6 的标准,分成 this、词法环境、变量环境更好,忘了这种吧。

1. ThisBending

在全局执行上下文中, this 的值指向全局对象,在浏览器中,即 window
在函数执行上下文中, this 的值取决于函数的调用方式。

如果函数被一个对象调用,则函数中的this指向该对象 否则this 的值 在非严格模式下被设置为 全局对象,在严格模式下设置为undefined

  1. let person = {
  2. name: 'peter',
  3. birthYear: 1994,
  4. calcAge: function() {
  5. console.log(2018 - this.birthYear);
  6. }
  7. }
  8. person.calcAge();
  9. // 'this' 指向 'person', 因为 'calcAge' 是被 'person' 对象引用调用的。
  10. let calculateAge = person.calcAge;
  11. calculateAge();
  12. // 'this' 指向全局 window 对象,因为没有给出任何对象引用

2. 词法环境(Lexical Environment)

官方 ES6 文档将词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。 简而言之,词法环境是一个包含标识符变量映射的结构。

  • 标识符表示变量/函数的名称
  • 变量是对实际对象【包括函数类型对象】或原始值的引用)

在词法环境中,有两个组成部分:

  1. 环境记录(EnvironmentRecord):是存储变量和函数声明的实际位置。
    1. 声明性环境记录:用于定义 ECMAScript 语言语法元素的效果
      1. 例如 函数声明、 变量声明(let、const)try-catch,这些子句直接将标识符绑定ECMAScript 语言值关联起来
    2. 对象环境记录:用于定义 ECMAScript 元素的效果
      1. 比如 with语句,它将标识符绑定与某个对象的属性关联起来。
  2. 对外部环境的引用(outer):用于访问其外部词法环境,可能为空引用(null)

词法环境有两种类型:

  • 全局环境(全局执行上下文)
    • 全局环境的外部环境引用outernull
    • 拥有一个全局对象(window 对象)及其关联的方法和属性
    • 拥有任何用户自定义的全局变量,this 的值指向这个全局对象。
  • 函数环境(函数执行上下文)
    • 用户在函数中定义的变量被存储在环境记录中。
    • 对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
      1. // 全局执行上下文的词法环境
      2. LexicalEnvironment = {
      3. EnvironmentRecord: {
      4. Type: "Object",
      5. // 标识符绑定在这里
      6. },
      7. outer: <null>
      8. }
      9. // 函数执行上下文的词法环境
      10. LexicalEnvironment = {
      11. EnvironmentRecord: {
      12. Type: "Declarative",
      13. // 标识符绑定在这里
      14. },
      15. outer: <Global or outer function environment reference>
      16. }

      3. 变量环境(Variable Environment)

      变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

      因为变量环境也是词法环境的一部分,所以下文要说的词法环境 就是包括变量环境的。 下文的伪代码可能会让你以为 outer 指向的 Lexical Environment,没有包括 Variable Environment,其实是伪代码这种写法不太好。是包含 Variable Environment 的。

ES6 中,词法环境(Lexical Environment)和变量环境(Variable Environment)区别在于:

  1. 词法环境(Lexical Environment):用于存储 函数声明变量( **let****const**绑定
  2. 变量环境(Variable Environment):用于存储 变量( **var**绑定。

结合一些代码示例来理解上述概念:

  1. let a = 20;
  2. const b = 30;
  3. var c;
  4. function multiply(e, f) {
  5. var g = 20;
  6. return e * f * g;
  7. }
  8. c = multiply(20, 30);

上述 js 代码对应的执行上下文。
执行上下文有两个阶段,创建阶段和执行阶段。

  1. 创建阶段 ```javascript // 全局执行上下文 GlobalExectionContext = { // 指向全局对象 ThisBinding: , // 词法环境(记录 let / const / 函数) LexicalEnvironment: { // 环境记录 EnvironmentRecord: { Type: ‘Object’, a: , b: , multiply: , }, // 对外部环境的引用 outer: }, // 变量环境(记录 var) VariableEnvironment: { EnvironmentRecord: { Type: ‘Object’, c: undefined, }, // 对外部环境的引用 outer: } }

// 函数执行上下文(调用 multiply 函数时才创建) FunctionExectionContext = { ThisBinding: , LexicalEnvironment: { EnvironmentRecord: { Type: ‘Declarative’, Arguments: { 0: 20, 1: 30, length: 2 } }, outer: , }, VariableEnvironment: { EnvironmentRecord: { Type: ‘Declarative’, g: undefined, }, outer: , } }

  1. 2. 执行阶段
  2. 在此阶段,完成对所有变量的分配,最后执行代码。
  3. > **注:** 在执行阶段,如果 Javascript 引擎在源代码中声明的实际位置找不到 let 变量的值,那么将为其分配 undefined 值。
  4. <a name="TsauR"></a>
  5. ## 作用域
  6. <a name="iSk2C"></a>
  7. ### 作用域链
  8. > 当查找变量的时候,会先从当前上下文的词法环境中查找。
  9. > 如果没有找到,就会从 outer 查找上一层的词法环境,一层层往上找。
  10. 这样由多个执行上下文的 Lexical Environment 构成的链表就叫做作用域链。
  11. ---
  12. 下面内容还没更新,依然是将执行上下文分为 this、变量对象、作用域链
  13. <a name="AqaVj"></a>
  14. # 回顾上文思考题
  15. 最上面提到这个思考题,说是执行上下文栈的变化不一样。现在我们来解释它的变化过程。
  16. ```javascript
  17. // 例一
  18. var scope = "global scope";
  19. function checkscope(){
  20. var scope = "local scope";
  21. function f(){
  22. return scope;
  23. }
  24. return f();
  25. }
  26. checkscope();
  1. // 例二
  2. var scope = "global scope";
  3. function checkscope(){
  4. var scope = "local scope";
  5. function f(){
  6. return scope;
  7. }
  8. return f;
  9. }
  10. checkscope()();

例一

  1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈

    1. ECStack = [
    2. globalContext
    3. ];
  2. 全局上下文初始化,同时checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]

    [global] 代表 window / global

  1. globalContext = {
  2. VO: [global],
  3. Scope: [globalContext.VO],
  4. this: globalContext.VO
  5. }
  6. checkscope.[[scope]] = [
  7. globalContext.VO
  8. ]
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

    1. ECStack = [
    2. checkscopeContext,
    3. globalContext
    4. ];
  2. checkscope 函数执行上下文初始化:

    1. 复制函数 checkscope.[[scope]] 属性创建作用域链,
    2. 用 arguments 创建活动对象,
    3. 初始化活动对象,即加入形参、函数声明、变量声明,
    4. 将活动对象压入 checkscope 作用域链顶端。

同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]

  1. checkscopeContext = {
  2. AO: {
  3. arguments: {
  4. length: 0,
  5. },
  6. scope: undefined,
  7. f: reference to function f() {...},
  8. },
  9. Scope: [AO, globalContext.VO],
  10. this: undefined
  11. }
  12. f.[[scope]] = [checkscopeContext.AO, globalContext.VO]

this 为 undefined,非严格模式下,this 的值为 undefined 的时候,其值会被隐式转换为全局对象。

  1. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈

    1. ECStack = [
    2. fContext,
    3. checkscopeContext,
    4. globalContext
    5. ];
  2. f 函数执行上下文初始化, 以下跟第 4 步相同:

    1. 复制函数 f.[[scope]] 属性创建作用域链
    2. 用 arguments 创建活动对象
    3. 初始化活动对象,即加入形参、函数声明、变量声明
    4. 将活动对象压入 f 作用域链顶端
      1. fContext = {
      2. AO: {
      3. arguments: {
      4. length: 0
      5. }
      6. },
      7. Scope: [AO, checkscopeContext.AO, globalContext.VO],
      8. this: undefined
      9. }
  3. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

  4. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

    1. ECStack = [
    2. checkscopeContext,
    3. globalContext
    4. ];
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出

    1. ECStack = [
    2. globalContext
    3. ];

    例二

  6. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈

    1. ECStack = [
    2. globalContext
    3. ];
  7. 全局上下文初始化,同时 checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]] ```javascript globalContext = { VO: [global], Scope: [globalContext.VO], this: globalContext.VO }

checkscope.[[scope]] = [globalContext.VO]

  1. 3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
  2. ```javascript
  3. ECStack = [
  4. checkscopeContext,
  5. globalContext
  6. ];
  1. checkscope 函数执行上下文初始化:
    1. 复制函数 checkscope.[[scope]] 属性创建作用域链,
    2. 用 arguments 创建活动对象,
    3. 初始化活动对象,即加入形参、函数声明、变量声明,
    4. 将活动对象压入 checkscope 作用域链顶端。

同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]

  1. checkscopeContext = {
  2. AO: {
  3. arguments: {},
  4. scope: undefined,
  5. f: reference to function f() {...},
  6. },
  7. Scope: [AO, globalContext.VO],
  8. this: undefined
  9. }
  10. f.[[scope]] = [checkscopeContext.AO, globalContext.VO]
  1. checkscope() 执行完毕,返回函数 f。checkscope 执行上下文从执行上下文栈中弹出

    1. ECStack = [
    2. globalContext
    3. ];
  2. 接着执行的是f(),创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈

    1. ECStack = [
    2. fContext,
    3. globalContext
    4. ]
  3. f 函数执行上下文初始化,以下跟第 4 步相同:

    1. 复制函数 f.[[scope]] 属性创建作用域链
    2. 用 arguments 创建活动对象
    3. 初始化活动对象,即加入形参、函数声明、变量声明
    4. 将活动对象压入 f 作用域链顶端
      1. fContext = {
      2. AO: {
      3. arguments: {
      4. length: 0
      5. }
      6. },
      7. Scope: [AO, checkscopeContext.AO, globalContext.VO],
      8. this: undefined
      9. }
  4. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

  5. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
    1. ECStack = [
    2. globalContext
    3. ]

    闭包

    理论和实践两个角度

    mdn 对闭包的定义:一个函数和对其周围状态(词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure

每个函数声明之后,都会创建一个 [[scope]],里面存放着上层执行上下文的变量对象。这就是一种词法环境的引用。所以有如下一句话:

《JavaScript权威指南》中就讲到:从技术的角度讲,所有的 JavaScript 函数都是闭包。

怪了,怎么有的面试官不喜欢这个答案,觉得闭包是一种设计方式。又有人说什么,闭包是函数返回函数。
我们用两种角度,来概括众说纷纭的闭包。

ECMAScript中,闭包指的是:

  • 从理论的角度:所有函数都是闭包
    • 它们都在创建的时候就将上层上下文的数据保存起来了。
  • 从实践的角度:以下的函数才算闭包
    • 即使创建该函数的上下文已经销毁,但该函数仍然存在(比如,内部函数从父函数中返回)
    • 在该函数中引用了上层执行上下文的变量。

闭包的应用

这种问题,我觉得很宽泛,基于以上对闭包的理解,从实践的角度出发,没有固定的分类方法。
如果说模拟块级作用域,封装私有变量,这两者之间又可以有关系。

  1. // 比如这个例子
  2. var result = []
  3. for(let i = 0;i < 5;i++) {
  4. (function(j) {
  5. result[j] = function() {
  6. console.log(j);
  7. }
  8. })(i)
  9. }

说它模拟块级作用域吧,也行,说 j 是个私有变量吧,也行。
所以我觉得都可以说说,有的面试官希望听到的回答是防抖节流,订阅者模式等等
都说说吧。

参考资料

《JavaScript深入之词法作用域和动态作用域》
《理解 Javascript 执行上下文和执行栈》