zl程序教程

您现在的位置是:首页 >  前端

当前栏目

JavaScript 闭包详解

JavaScript 详解 闭包
2023-09-27 14:25:57 时间
前言

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


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

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


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


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


JavaScript 深入系列 15 篇 冴羽 JavaScript 专题 Veda


正文一、概念


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


MDN 闭包是指那些能够访问自由变量的函数。
《JavaScript 高级程序设计》 闭包指的是那些引用了另一个函数作用域中变量的函数。
《你不知道的 JavaScript》 闭包是代码块和创建该代码块的上下文中数据的结合。


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


上面提到了自由变量一词


自由变量 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 断点调试可以从视角感知。

7.webp.jpg

但是我们稍微修改一下

var a 1

function foo() {

 function bar() {

 console.log(a)

 return bar

var f foo()

f()


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

8.webp.jpg


总结 在函数 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 }


上述这个示例 是为了提醒 B 对 A 中的某个“变量” 指变量、函数、arguments、形参等 的引用 不仅仅是通过 var、function、let、const、class 等关键字显式声明的 还可以是 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。我们可以在控制台看到。


image


然后再修改下 当 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()()


0.webp.jpg


我们知道箭头函数内部不存在 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()()


00.webp.jpg


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


示例二


经典面试题 哈哈

for (var i i i ) {

 setTimeout(function timer() {

 console.log(i)

 }, i * 1000)

}


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


解决方法一


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

for (var i i 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 */) {

 /* 块级作用域 2 */

/* 全局作用域 */

for (var j ;/* 全局作用域 */) {

 /* 全局作用域 */

}


for (let i i i ) {

 setTimeout(function timer() {

 console.log(i)

 }, i * 1000)

}


000.webp.jpg

三个作用域的体现


0000.webp.jpg


四、我认为的闭包


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


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


然后插一段 fei 话 不想看的话直接跳到下一节。


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


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


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

编译阶段 

 词法分析

 语法分析

 代码生成

执行阶段 

 执行上下文栈

 出栈/入栈

 创建执行上下文

 初始化执行上下文 

 变量对象/活动对象 VO/AO 

 创建时就确定函数的 [[scope]] 要学好闭包 这玩意要弄明白 

 作用域链 Scope Chain 

 This

 代码执行 

词法作用域 

 什么是词法作用域 

 什么是动态作用域 


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


The end.



JavaScript基础系列(5):通过简单的几个小例子让你轻松的搞明白闭包 首先代码非常简单,根据截图中可以发现,执行到断点位置时,根据词法作用域的规则,内部函数innerFunction可以访问它的外部outerFunction函数中的变量aehyok,执行后打印out函数一点毛病没有。
JS闭包之灵魂7问 Q1:慧眼认“包” Q2: 参数为何凭空消失 Q3:作用域嵌套惹的祸 Q4:迟到的兑现,错误的值 Q5:变量的从一而终 Q6:公私分明 Q7:柯里化
JS闭包(Closures)了解一下 闭包对于前端开发者来说,既十分重要,又非常难理解。如果能很好的理解它,那你将能写出很多高逼格的代码,并且成为人生赢家,赢取白富美。 这篇文章,我们来一起简单的认识一下,你走向人生巅峰的敲门砖(闭包)。 Note: 闭包不是JS所特有的。它是一个计算机概念,应用的场景也很多。