toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

JavaScript 闭包详解 #267

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中神奇的“闭包”(Closure)。

闭包是基于词法作用域书写代码时所产生的自然结果。

JavaScript 语言是采用了词法作用域。一般情况下,函数、变量的作用域在编写的时候已经确定且不可改变的。除了 evalwith 之外,它们会在运行的时候“修改”词法作用域,但实际项目中,几乎很少用到它们,欺骗词法作用域会有性能问题,我们可以忽略。

还有,千万别把 this 跟作用域混淆在一起,this 与函数调用有关,可以说是“动态”的。而作用域是静态的,跟函数怎样调用没关系。词法作用域也被叫做“静态作用域”。

若对词法作用域、执行上下文、变量对象、作用域链等内容不熟悉的话,建议先学习相关知识。到时回来再看闭包的时候,就非常容易理解了。

概念

无论网上文章,还是各类书籍,对闭包的定义都不尽相同。列举几个:

讲实话,我也不知道以上哪个说法更贴切、更符合。当了解作用域链之后,就很容易理解闭包了。

上面提到了自由变量一词,

自由变量(Free Variable):是指在函数中使用的,但既不是函数参数,也不是函数的局部变量的变量。

var a = 1
function foo() {
  var b = 2 // b 不是自由变量
  console.log(a) //  a 是自由变量
}
foo()

在 ECMAScript 中,闭包指的是:

就我个人认为,闭包不是一个函数,它是一种机制,用于访问自由变量。闭包不是 JavaScript 中专有术语,在上世纪很早就被提出来了,在其他语言(如 Ruby 语言)中,闭包可以是一个过程对象,一个 Lambda 表达式或者是代码块。

Chrome 眼中的闭包

其实上面概念可能很多人都不理解,但问题不大,我们先看看 Chrome 眼中的闭包是长怎么样的。

举个例子:

function foo() {
  var a = 1
  function bar() {
    console.log(a)
  }
  return bar
}

var f = foo()
f() // 1

相信很多人都知道,函数 foo 就是一个闭包,通过 Chrome 断点调试可以从视角感知。

但是我们稍微修改一下,

var a = 1
function foo() {
  function bar() {
    console.log(a)
  }
  return bar
}

var f = foo()
f()

此时 a 是全局上下文的变量,尽管对于函数 bar 来说 a 属于自由变量,但它不是 foo 函数上下文内声明的变量,因此 foo 就不是闭包。

函数内访问全局变量算是闭包吗?

总结:在函数 A(如 foo)中存在某个函数 B(如 bar,且必须是在 A 中定义的),且 B 内至少引用了 A 中的一个“变量”,那么函数 A 就是一个闭包。

请注意,与函数 B 的调用方式没关系。无论 B 是在 foo 内部被调用,还是作为返回值返回,然后在别处调用。

再看一个例子:

function foo() {
  var b = () => {
    // 由于 b 是箭头函数,内部没有 arguments 对象,
    // 所以这个 arguments 对象是 foo 中变量对象的一员,
    // 因此 foo 也是一个闭包。
    console.log(arguments)
  }
  return b
}

var f = foo('foo')
f() // { 0: 'foo', length: 1 }

上述这个示例,是为了提醒 BA 中的某个“变量”(指变量、函数、arguments、形参等)的引用,不仅仅是通过 varfunctionletconstclass 等关键字显式声明的,还可以是 arguments 对象、形参。换句话说,就是 AO 中的所有变量。

再看,下面示例中 foo 是闭包吗?

function foo(fn) {
  var a = 'local'
  fn()
}

function bar() {
  console.log(a)
}

var a = 'global'

foo(bar) // "global"

答案是 NO。前面总结过一个函数要成为闭包,该函数(foo)内部必须存在另外一个函数(fn),且 fn 内需要 foo 中的某个变量。那不正好引用了 foo 中的变量 a 吗?显然,这理解是错误的。

根据词法作用域可知,函数 bar 的作用域链 [[scope]] 在声明时就已确定且不可变,只含 GlobalContext.VO,因此当查找自由变量 a 时,当 bar 的 AO 内查不到,下一步是前往全局对象下查找,于是就找到了 a 其值为 "global"。所以 fn 内部对 foo 构成不了引用,因此 foo 就不是闭包。

若到这里,对闭包还是懵懵懂懂的,这块引用的内容,请跳过。

突然间,我好像明白了为什么函数内部缺省声明关键字的变量(如 a = 1),在执行时才将其视为全局变量。

假设将其作为函数上下文的变量,要怎么做:

  • 假设将其视为当前函数执行上下文的一个变量,那么 JS 引擎在进入执行上下文时,初始化工作量实在太多了,要通篇扫描当前上下文的声明语句和赋值语句,还要判别赋值语句是单纯地给已有变量赋值,还是上面提到的缺省声明情况。显然很影响效率和性能。
  • 如果不通篇扫描,在执行代码的时候再更新到 AO 上,那么又会破坏 JavaScript 的词法作用域。似乎就变成了“动态作用域”。

但如果将其视为全局上下文的一个变量,上面的额外的工作都省了。但注意,它与全局声明的变量有些区别,前者可以被删除,而后者无法删除(原因可看这里)。在严格模式下对这种“隐式”声明全局变量的行为作为禁止,并抛出 SyntaxError。不确定是不是因为这个原因而被禁的。

这个是突然灵光一闪的,所以也 Mark 下来了。

综上所述,Chrome 浏览器眼中的闭包应该是这样的:

在某个函数 A 中存在另一个函数 B(函数 B 必须是在函数 A 中定义的),而且 B 内至少引用了 A 中的一个变量,那么当 B 在任意地方被调用时,函数 A 就是一个闭包。

其实,我认为概念不是很重要的...

更多示例

前面的示例,都相对比较简单和清晰的。再看多几个吧。

关于 Chrome 浏览器调试,在 Source 选项卡进行断点调试时,可以看到作用域、闭包的变化。

CallStack: 调用栈
Scope: 当前执行上下文的作用域链
  Local   // 当前 AO/VO 对象,但不完全是,我们也可以看到 this 指向
  Block   // 包含块级作用域 let、const、class 的变量
  Closure // 闭包
  modules // ESM 模块
  Script  // <script> 内所有 let、const、class 声明的变量
  Global  // 即 window,通过 var function 声明的全局变量,会放在这里

示例一

请问以下示例会不会产生闭包?(这道题不是考你 this 指向哈,别搞错了)

var name = 'Frankie'
var obj = {
  name: 'Mandy',
  sayHi: function () {
    return function () {
      console.log(this.name)
    }
  }
}

obj.sayHi()()

答案是 NO。我们可以在控制台看到。

然后再修改下,当 obj.sayHi() 返回的匿名函数被调用时,存在对 obj.sayHi 方法的引用。因此 obj.sayHi 就是一个闭包。

var name = 'Frankie'
var obj = {
  name: 'Mandy',
  sayHi: function () {
    var _this = this
    return function () {
      console.log(_this.name)
    }
  }
}

obj.sayHi()()

我们知道箭头函数内部不存在 this,因此无论 obj.sayHi() 返回的匿名箭头函数怎样调用,最终 this 都指向 obj 对象。但我的疑问在于,以下示例会不会产生闭包?

var name = 'Frankie'
var obj = {
  name: 'Mandy',
  sayHi: function () {
    return () => {
      console.log(this.name)
      // console.log(arguments) // arguments 会产生闭包,而 this 是不会的
    }
  }
}

obj.sayHi()()

答案还是 NO。其实我这里是有个疑问的,按道理箭头函数不存在 argumentsthis 对象,若在监听函数内访问这两个对象,都应该产生闭包。但事实是,this 引用不会使得 sayHi 称为闭包。但是若箭头函数内引用了 arguments 对象,则会产生闭包。这一点要注意下!

示例二

经典面试题,哈哈!

for (var i = 1; i <= 3; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

上述示例打印结果是:3、3、3(时间间隔一秒)。如果要每间隔一秒分别输出:1、2、3,怎么处理?解决方案很简单。

解决方法一:

setTimeout 披一个函数,即多一层作用域。

for (var i = 1; i <= 3; i++) {
  (function fn(i) {
    setTimeout(function timer() {
      console.log(i)
    }, i * 1000)
  })(i)
}

我就不打断点了,直接从执行过程分析:

1. 全局代码开始执行
  ECStack = [ GlobalContext.VO ]

2. 开始执行 for 循环,fn 的函数上下文初始化如下,

  FunctionalContext<fn> = {
    AO: {
      arguments: {
        0: 1,
        length: 1
      }
      i: 1,
      timer: ƒ timer()
    },
    Scope: [ GlobalContext.VO, AO ],
    this: undefined
  }

  当 timer 声明的时候,它的 [[scope]] 就确定了,即 FunctionalContext<fn>.Scope
  由于 fn 内部存在一个函数 timer,且 timer 中的 i 引用了 fn 中的 AO 变量,
  因此 fn 形成闭包。

3. 后面两次循环同理...

4. 一秒后,会调用 timer 函数,然后进入 timer 函数执行上下文,并初始化:

  FunctionalContext<timer> = {
    AO: {
      arguments: {
        length: 0
      }
    },
    Scope: [ GlobalContext.VO, FunctionalContext<fn>.AO, AO ],
    this: undefined
  }

  执行 timer 内部代码时,要查找 a 变量,首先当前 AO 没有,
  接着往 FunctionalContext<fn>.AO 上面找,于是就找到了 a 为 1。
  然后 timer 执行完毕。

5. 又过了一秒,又会触发 timer 函数,过程同上。

6. 但注意每次循环执行的 fn 函数都不是同一个函数哦,它们原先执行上下文的 AO 对象
   被保存至 timer 函数 [[scope]] 里面了。
   因此,每次执行 timer 函数的时候,i 都是不一样的。

7. 所以按照这样去改造的话,就能每间隔一秒分别输出:1、2、3

解决方法二:使用 let 来声明变量 i

首先请注意 for 语句两种方式的区别,如下:

/* 全局作用域 */
for (let i = 1; ;/* 块级作用域 1 */) {
  /* 块级作用域 2 */
}

/* 全局作用域 */
for (var j = 1; ;/* 全局作用域 */) {
  /* 全局作用域 */
}
for (let i = 1; i <= 3; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

三个作用域的体现:

我认为的闭包

在我的理解里, 每个 JavaScript 函数都是闭包。或者说某个函数引用了其作用域外的变量,那么这个函数可以被认为是闭包。

尽管 Chrome 浏览器不认同我的说法,也不会影响我理解和使用闭包,因为我已经知道了作用域链与闭包直接相关。

插一段废话,不想看的话直接跳到下一节。

我在学习闭包的之前,先整体了 JS 整个加载、编译和执行过程。其实学习还是其他,都应该从宏观和微观的角度分析。它们的过程是循序渐进的。

我猜,可能还有挺多有一定经验的 JSer 不知道 JS 脚本是按块加载的。按块加载什么意思?比如我们的网页有两个 JS 脚本(即两对 <script> 标签)。JS 引擎会先对其中一块进行编译与执行的过程,完成之后,才开始对下一个脚本进行编译与执行。假设你不了解,可能误以为 JS 引擎会通篇扫描所有脚本的语法,然后再按顺序(或不按顺序)执行。这是不对的。

因此学习闭包也是一样的道理,请先了解 JavaScript 代码从编译到执行的过程。

编译阶段:
  词法分析
  语法分析
  代码生成

执行阶段:
  执行上下文栈
    出栈/入栈
  创建执行上下文
  初始化执行上下文:
    变量对象/活动对象(VO/AO):
      创建时就确定函数的 [[scope]](要学好闭包,这玩意要弄明白)
    作用域链(Scope Chain)
    This
  代码执行:
    闭包

词法作用域:
  什么是词法作用域?
  什么是动态作用域?

等这些内容都属性之后,再结合本文或其他大佬的文章,闭包就自然而然就懂了。如果跳过以上内容,直接看闭包,我认为是很难理解的,即使好像当时看懂了,但很快就会忘了。

The end.