ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript 闭包完全解读:以《You Don‘t Know JS: 作用域与闭包》(you-dont-know-js-ru)第 5 章为例

JavaScript 闭包完全解读:以《You Don‘t Know JS: 作用域与闭包》(you-dont-know-js-ru)第 5 章为例 教程文档【免费下载链接】you-dont-know-js-ru Russian translation of You Dont Know JS book series项目地址https://gitcode.com/gh_mirrors/yo/you-dont-know-js-ru点击查看免费下载本文围绕 you-dont-know-js-ru 仓库中《Область видимости и замыкания》作用域与闭包分册第 5 章Замыкание области видимости展开系统讲解 JavaScript 闭包的定义、触发条件、经典循环陷阱、模块模式以及 ES6 原生模块并交叉引用本分册前四章与仓库其他分册做源码级佐证。读完本文你将能够在自己的代码中主动识别闭包、修复循环里 setTimeout 全输出 6这类经典问题并亲手实现基于闭包的模块化代码。说明本文所有代码与讲解均忠实继承自 scope closures/ch5.md涉及前置概念的佐证分别引用本分册的 第 1 章、第 2 章、第 3 章 与 第 4 章完整章节列表见 目录。1. 闭包的前置知识词法作用域速览在讨论闭包之前必须先回顾一个基础JavaScript 使用的是**词法作用域Lexical Scope**模型这一点在第 2 章有详细论证。词法作用域是在词法分析lexing/tokenizing阶段定义的作用域。也就是说作用域取决于你在写代码时把变量和函数声明在了哪里并在词法分析器处理完代码之后基本永久固定下来。依据 第 2 章引擎解析一个标识符引用时遵循如下查找规则从最内层的当前作用域开始逐层向外查找找到第一个匹配即停止这也意味着内层同名标识符会遮蔽/阴影化shadowing外层标识符对a、b、c这类第一类标识符执行作用域查找而对foo.bar.baz这类引用作用域查找只负责解析foo之后的bar、baz交给对象属性访问规则函数无论从哪里被调用、如何被调用其词法作用域只由函数声明所在的位置决定——这句话正是闭包能成立的基石。同时第 1 章 定义了引用类型的查找取值用 RHS 引用如console.log(a)中的a赋值用 LHS 引用如a 2。第 5 章的第一个示例中bar()访问外层a正是通过 RHS 引用查找完成的。另一个必要前提是函数在 JavaScript 中是一等公民first-class values可以像任何值一样被返回、传参、赋值给变量——正是这种函数即值的特性让闭包得以被带出词法作用域。2. 闭包的正式定义与第一个伪闭包2.1 闭包的正式定义第 5 章给出了全书最核心的一句话定义闭包是一个函数能够记住并访问其词法作用域即使当这个函数在其词法作用域之外被执行的时候。拆解这个定义可以得到三个关键要素函数必须记住remember一个作用域该作用域是它声明时所在的词法作用域该函数在词法作用域之外执行时这份记忆依然有效。2.2 为什么简单的嵌套访问还不算闭包先看第 5 章给出的第一个例子function foo() { var a 2; function bar() { console.log( a ); // 2 } bar(); } foo();这个代码很眼熟——它只是第 2 章嵌套作用域讨论的延续bar()依据词法作用域查找规则这里是 RHS 引用查找访问外层foo()的变量a。这算闭包吗技术上可能算但按我们的定义并不算。从纯学术角度可以说bar()对foo()的作用域存在闭包关系bar()覆盖closes over了foo()的作用域——因为bar嵌套在foo内部。但这种定义既不够有辨识度也不够可验证我们清楚地看到了词法作用域闭包却像幕布后面神秘的影子无法从行为上被确认。真正让闭包现形的是把函数传递出它的词法作用域。3. 让闭包现形把函数传递出词法作用域3.1 方式一通过 return 返回function foo() { var a 2; function bar() { console.log( a ); } return bar; } var baz foo(); baz(); // 2 —— 哇闭包刚刚现形了伙计们bar()在词法上能够访问foo()的内部作用域但这次我们把bar本身作为值返回。foo()执行完毕后返回值内部函数bar被赋给baz随后baz()调用它——注意此时bar是在它声明时所在的词法作用域之外执行的。按直觉foo()执行完毕后foo()的整个内部作用域应该被销毁引擎借助垃圾回收GC扫描并释放不再使用的内存foo()的内容看起来不再被使用理应被回收。但闭包的魔法阻止了这件事这块内部作用域仍然在被使用——使用者正是函数bar()本身。由于声明位置的关系bar()对foo()的内部作用域形成了词法闭包它持有keep alive着这块作用域供日后引用。bar()仍然保留着对这块作用域的引用这个引用就叫闭包。于是当片刻之后baz被调用时内部的bar依然有访问a的能力输出2。函数在声明时的词法作用域之外被正确调用闭包给了函数延续访问其声明时词法作用域的能力。3.2 方式二通过参数传递任何把函数作为值传递的方式都是闭包发挥作用的场景function foo() { var a 2; function baz() { console.log( a ); // 2 } bar( baz ); } function bar(fn) { fn(); // 看妈妈我看到了闭包 }内部函数baz被传给bar在bar内以fn的名义被调用。调用发生时baz对foo()内部作用域的闭包依然生效因而能访问到a。3.3 方式三间接赋值传递函数的传递还可以是间接的var fn; function foo() { var a 2; function baz() { console.log( a ); } fn baz; // 把 baz 赋给全局变量 } function bar() { fn(); // 看妈妈我看到了闭包 } foo(); bar(); // 2这里foo()把内部函数baz赋给了全局变量fn之后bar()再调用fn()。调用发生的位置bar内部远离baz的声明作用域foo内部但闭包依然成立。小结无论通过 return、传参还是全局赋值只要把内部函数传递到其词法作用域之外这份作用域引用就会一直存在函数在任意时刻、任意位置被调用闭包都会忠实地兑现。4. 闭包无处不在定时器、事件与回调前面几个例子有些学术化、刻意构造。第 5 章随即展示闭包其实遍布你日常写的每一行 JavaScript。4.1 setTimeout 定时器function wait(message) { setTimeout( function timer(){ console.log( message ); }, 1000 ); } wait( 你好闭包 );内部函数timer被传入setTimeout(..)它闭包于wait(..)的作用域保存并使用对message的引用。在wait(..)执行完毕、其内部作用域本应消散的 1000 毫秒之后timer仍然持有对那块作用域的闭包。引擎内部内建的setTimeout(..)持有一份对参数的引用可命名为fn或func执行时调用我们的timer词法作用域引用依旧完好——这就是闭包。4.2 事件处理器jQuery 示例如果你是 jQuery或其他 JS 框架阵营的开发者function setupBot(name,selector) { $( selector ).click( function activator(){ console.log( 激活 name ); } ); } setupBot( Closure Bot 1, #bot_1 ); setupBot( Closure Bot 2, #bot_2 );每次点击事件触发时activator都能访问到setupBot调用时为它记住的name——两个 bot 各自持有互不干扰的闭包。4.3 哪些场景会产生闭包只要把拥有自身词法作用域访问权的函数当作一等值到处传递闭包就会随之出现。典型场景包括定时器timer、事件处理器event handler、Ajax 请求、跨窗口消息cross-window messaging、Web Worker以及任何其他异步甚至同步任务——当你传递回调函数时闭包就已经在到处散落了。4.4 辨析IIFE 本身并不是闭包第 3 章 讲解了 IIFE 模式立即调用函数表达式即用(function..){..}()把函数声明变成表达式并立即执行。很多人声称 IIFE 是显式闭包的典型例子但第 5 章基于定义明确表示不同意var a 2; (function IIFE(){ console.log( a ); })();这段代码能工作但它不是对闭包的准确演示。原因在于这个 IIFE并没有在其词法作用域之外执行——它仍然是在声明它的同一作用域持有a的外层/全局作用域中被直接调用的。a是通过普通的词法作用域查找找到的而不是通过闭包。不过尽管 IIFE 本身不产生可观察的闭包它确实创建了一个作用域——一个可以被其他函数闭包捕获的作用域。因此 IIFE 与闭包密切相关只是它不直接生产闭包。实践任务第 5 章留给读者的练习打开你最近写过的 JavaScript 代码找出所有函数作为值出现的位置看看你在哪里已经在使用闭包而此前从未察觉。5. 循环 闭包经典陷阱及其三种解法循环与闭包的组合是展示闭包威力最经典的例子也是无数开发者踩过的坑。5.1 问题现象为什么输出的是 5 个 6for (var i1; i5; i) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }直觉上我们会期望输出1, 2, 3, 4, 5每秒一个。但实际运行结果是5 个6以 1 秒间隔依次打印。6从哪来循环的终止条件是i不再满足 5——第一次出现这种情况正是i等于6的时候。所以输出反映的是循环结束后的i的最终值。这里有个容易混淆的细节所有 timer 回调都严格在循环完全结束后才被触发。事实上即使把延迟改成setTimeout(.., 0)所有回调依然会在循环结束后才执行于是每次都打印6。5.2 根源5 个函数共享同一个i更深层的问题是我们假设每次迭代捕获了属于自己的i副本。但按作用域的工作方式这 5 个函数虽然分别定义在每次迭代中它们闭包的是同一个全局共享作用域——里面只有一个变量i。这样一来所有函数共享对同一个i的引用当然全都会打印6。循环结构的写法容易让我们误以为存在什么复杂的机制其实根本没有——这与把 5 个setTimeout调用不加循环、一字排开地写下没有任何区别。我们需要的是每次迭代都有一个独立的、隔离的作用域。5.3 解法一IIFE 局部变量第 3 章讲过IIFE 通过声明并立即执行一个函数来创建作用域。先试试for (var i1; i5; i) { (function(){ setTimeout( function timer(){ console.log( i ); }, i*1000 ); })(); }不行为什么现在每个 timer 回调确实闭包了每次迭代由 IIFE 显式创建的作用域但问题是只有隔离的作用域还不够如果这个作用域是空的。仔细看——我们的 IIFE 只是一个空转的、什么都不做的作用域。它需要自己的变量在每次迭代中保存i的一个副本for (var i1; i5; i) { (function(){ var j i; setTimeout( function timer(){ console.log( j ); }, j*1000 ); })(); }**找到了这样就能正常工作了**每次迭代的 IIFE 创建了一个新作用域里面用var j i把当前的i值固化下来timer 回调闭包这个新作用域拿到正确的迭代值。5.4 解法二IIFE 传参换个口味一些人更喜欢用参数传递的变体for (var i1; i5; i) { (function(j){ setTimeout( function timer(){ console.log( j ); }, j*1000 ); })( i ); }IIFE 本质是函数调用把i作为实参传进去、形参命名为j也可以仍叫i效果完全相同。无论哪种写法IIFE 都在每次迭代创建了新的作用域使 timer 回调能够在每次迭代捕获一个装着正确迭代值的作用域。5.5 解法三let 块级作用域细想前面的分析就会发现我们真正需要的其实是每迭代一个块级作用域。而 第 3 章 的let正是把块劫持成作用域的利器——它把一个块变成可以闭包的作用域。于是下面这段漂亮的代码直接就能跑for (var i1; i5; i) { let j i; // 让块级作用域为闭包服务 setTimeout( function timer(){ console.log( j ); }, j*1000 ); }5.6 let 在 for 头部的特殊重绑定行为但还有更妙的let在 for 循环头部有特殊行为它不是在整轮循环中只声明一次变量而是为每一次迭代都声明一个并且会在每次后续迭代开始时用上一次迭代结束时的值重新初始化它。for (let i1; i5; i) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }输出依次为1, 2, 3, 4, 5。这一行为在 第 3 章 中被进一步确认let在 for 头部不仅把i绑定到循环体实际上在每次迭代中重新绑定它并从上一迭代的结束值重新赋值循环体外访问i会抛ReferenceError。块级作用域与闭包携手解决了这个困扰无数开发者的世界性难题。5.7 延伸闭包与垃圾回收闭包保持作用域存活的特性在内存管理上也值得注意。第 3 章 给出了一个佐证示例如果回调并不需要某个大型数据结构那么只要它闭包着包含该数据的作用域引擎取决于实现就可能不得不在内存中保留这份数据。解决办法是用显式块 let把大型数据限制在局部让引擎明确这块数据用完之后就可以消失function process(data) { // 做点有意思的事 } // 这个块里声明的一切在块结束后都可以被回收 { let someReallyBigData { .. }; process( someReallyBigData ); } var btn document.getElementById( my_button ); btn.addEventListener( click, function click(evt){ console.log(button clicked); }, /*capturingPhase*/false );这也是第 3 章块级作用域能辅助垃圾回收的落地场景之一。6. 模块模式闭包的最强应用还有一些编程模式高效利用了闭包的力量却在外观上不像回调。最强大的一个就是模块Module。6.1 从私有状态出发function foo() { var something cool; var another [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( ! ) ); } }这段代码里没有任何显式的闭包——只是私有变量something、another和两个内部函数doSomething()、doAnother()后两者对foo()的内部作用域拥有词法作用域因此也拥有闭包。6.2 经典模块模式CoolModule与两个必要条件注意下面的转变function CoolModule() { var something cool; var another [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( ! ) ); } return { doSomething: doSomething, doAnother: doAnother }; } var foo CoolModule(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3这就是 JavaScript 中的模块模式最常见的实现常被称为揭示模块模式Revealing Module上面呈现的正是它。分析几个关键事实CoolModule()只是一个函数但必须被调用才会产生模块实例。不调用外层函数就不会创建内部作用域也不会产生闭包CoolModule()返回一个用对象字面量{ key: value, ... }写成的对象。这个对象包含对内部函数的引用但不包含对内部变量的引用——变量被保持为隐藏、私有的。应当把这个返回对象理解为模块的公共 APIpublic API返回的对象被赋给外部变量foo随后通过foo.doSomething()访问 API 方法。doSomething()和doAnother()对模块实例通过调用CoolModule()获得的内部作用域拥有闭包。当我们通过返回对象的属性引用把它们传递到词法作用域之外时就真正具备了闭包产生并兑现的条件。由此模块模式必须满足两个必要条件必须有一个外层包裹函数且至少被调用一次每次调用创建一个新的模块实例外层函数必须至少返回一个内部函数使该内部函数对私有作用域形成闭包从而可以访问和/或修改内部状态。要注意一个只有函数属性的对象本身并不是模块由函数调用返回、只有数据属性而没有闭包函数的对象从常识角度讲也算不上模块。补充第 3 章在讨论隐藏实现、防止标识符冲突时也预告了这一结论——现代依赖注入管理器并没有凌驾于词法作用域规则之上的魔法它们只是利用作用域规则把标识符保存在私有的、无冲突的作用域里。模块模式的细节正是第 5 章的主题。6.3 单例变体如果只想要一个模块实例类似单例可以把模块函数改造成 IIFEvar foo (function CoolModule() { var something cool; var another [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( ! ) ); } return { doSomething: doSomething, doAnother: doAnother }; })(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3这里把模块函数变成了 IIFE参见第 3 章立即调用并把返回值直接赋给唯一的标识符foo。6.4 参数化模块模块本质是函数因此可以接受参数function CoolModule(id) { function identify() { console.log( id ); } return { identify: identify }; } var foo1 CoolModule( foo 1 ); var foo2 CoolModule( foo 2 ); foo1.identify(); // foo 1 foo2.identify(); // foo 26.5 可动态修改的公共 API模块模式的另一个完整变体给返回的公共 API 对象起名并在模块内部持有它的引用从而从内部修改模块的公共 APIvar foo (function CoolModule(id) { function change() { // 修改公共 API publicAPI.identify identify2; } function identify1() { console.log( id ); } function identify2() { console.log( id.toUpperCase() ); } var publicAPI { change: change, identify: identify1 }; return publicAPI; })( foo module ); foo.identify(); // foo module foo.change(); foo.identify(); // FOO MODULE在模块实例内部保存公共 API 对象的引用就可以从内部更换模块——包括增删方法、属性以及修改它们的值。6.6 现代模块管理器的工作原理MyModules各种模块加载器/依赖管理器本质上都是把上述模块模式包装成友好的 API。第 5 章给出了一个仅用于演示目的的极简实现var MyModules (function Manager() { var modules {}; function define(name, deps, impl) { for (var i0; ideps.length; i) { deps[i] modules[deps[i]]; } modules[name] impl.apply( impl, deps ); } function get(name) { return modules[name]; } return { define: define, get: get }; })();关键一行是modules[name] impl.apply( impl, deps )它调用模块的定义包裹函数把依赖传进去并把返回值模块的 API按名字存进内部模块列表。用它定义两个模块MyModules.define( bar, [], function(){ function hello(who) { return Let me introduce: who; } return { hello: hello }; } ); MyModules.define( foo, [bar], function(bar){ var hungry hippo; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; } ); var bar MyModules.get( bar ); var foo MyModules.get( foo ); console.log( bar.hello( hippo ) ); // Let me introduce: hippo foo.awesome(); // LET ME INTRODUCE: HIPPO模块foo和bar都通过返回公共 API 的函数来定义foo甚至把bar实例作为依赖参数接收并使用。要点是模块管理器没有任何魔法——它们只是满足上面列出的模块模式两个特征调用定义包裹函数并把返回值作为该模块的 API 存储。换句话说模块就是模块无论你是否借助某个熟悉的工具。6.7 ES6 原生模块ES6 为模块概念提供了第一类语法支持。通过模块系统加载时ES6 把每个文件当作一个独立模块每个模块既能导入其他模块或其 API 成员也能导出自己的公共 API 成员。值得对比的一点第 5 章明确强调基于函数的模块不是编译期可静态识别的模式其 API 语义要到运行时才确定——因此你能在运行期修改模块 API见 6.5 节的change()。而 ES6 模块的 API 是静态的运行期不变编译器在加载文件与编译阶段就能校验导入的 API 成员引用确实存在不存在则抛出编译期的提前错误而不是等运行期动态解析再报错。ES6 模块没有内联格式必须定义在独立文件中每模块一个文件。浏览器/引擎自带默认模块加载器可被覆盖在 import 时同步加载模块文件。第 5 章给出了完整的示例bar.jsfunction hello(who) { return Let me introduce: who; } export hello;foo.js// 只从 bar 模块导入 hello() import hello from bar; var hungry hippo; function awesome() { console.log( hello( hungry ).toUpperCase() ); } export awesome;主程序// 整体导入 foo 和 bar 模块 module foo from foo; module bar from bar; console.log( bar.hello( rhino ) ); // Let me introduce: rhino foo.awesome(); // LET ME INTRODUCE: HIPPO语法要点import把模块 API 的一个或多个成员导入当前作用域各成一个变量本例的hellomodule把整个模块 API 导入指定变量本例的foo、barexport把标识符变量、函数导出到当前模块的公共 API。这些语句在模块定义中可按需多次使用。模块文件内部的内容被当作包在闭包作用域中处理——和前面讨论的基于函数闭包的模块如出一辙。更完整的 ES6 语法与新特性体系可继续阅读本仓库的另一分册 《ES6 与更远的未来》。7. 总结识别闭包的自检清单第 5 章在结尾重申关于闭包的知识充满了偏见与迷信仿佛它是 JavaScript 中只有最勇敢的灵魂才能抵达的神秘世界。但实际上闭包只是在词法作用域环境下、函数作为值自由传递时的一个标准且近乎显然的事实。闭包 函数能够记住并访问其词法作用域即使当它在该词法作用域之外被调用。对照这份自检清单你就能在自己的代码中主动识别闭包是否有一个函数声明在了另一个函数内部——是则它对包裹它的作用域拥有闭包关系这个函数是否被当作值传递出去了return、参数、赋值、回调——是则闭包在外部调用时生效循环中回调是否依赖循环变量——是请确认每迭代都有独立作用域let或 IIFE 快照否则会踩共享i的坑是否需要私有状态 公开方法——是这就是模块模式请确认满足两条外层函数被调用过 至少返回一个内部函数。闭包可能像循环示例那样让我们困惑但同时也是通往模块等强大模式的钥匙。现在你可以在自己的既有代码里看到无处不在的闭包并主动利用它们的全部优势了。本分册的完整阅读路径见 《作用域与闭包》README 与 目录全书开篇背景见 前言。赞分享教程文档【免费下载链接】you-dont-know-js-ru Russian translation of You Dont Know JS book series项目地址https://gitcode.com/gh_mirrors/yo/you-dont-know-js-ru点击查看免费下载相关推荐浏览器内ADB调试Tango如何革新Android开发体验浏览器内ADB调试Tango如何革新Android开发体验 你是否曾为Android开发调试的繁琐环境配置而烦恼传统ADB客户端需要复杂的本地环境设置限制开发工具通信移动开发CLIYou-Dont-Know-JS核心概念解析作用域与闭包的终极指南You Dont Know JS核心概念解析作用域与闭包的终极指南 JavaScript开发者必须掌握的两个核心概念作用域和闭包。理解这些概念不仅能提升代码文档教程深入理解 JavaScript 作用域编译三部曲、LHS/RHS 查找与错误处理《你不知道的 JavaScript》俄译本 you-dont-know-js-ru《作用域与闭包》第 1 章精读深入理解 JavaScript 作用域编译三部曲、LHS/RHS 查找与错误处理《你不知道的 JavaScript》俄译本 you dont know js教程文档上一篇Google Cloud 4 Words 移动版离线模式无网络时的使用技巧下一篇Apache ShenYu网关参数校验终极指南自定义Validator实现详解创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表