- 教程
- 文档
【免费下载链接】you-dont-know-js-ru
📚 Russian translation of "You Don't Know JS" book series
本文围绕 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 个 6
for (var i=1; i<=5; 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 i=1; i<=5; i++) { (function(){ setTimeout( function timer(){ console.log( i ); }, i*1000 ); })(); }不行!为什么?现在每个 timer 回调确实闭包了每次迭代由 IIFE 显式创建的作用域,但问题是:只有隔离的作用域还不够,如果这个作用域是空的。仔细看——我们的 IIFE 只是一个空转的、什么都不做的作用域。它需要自己的变量,在每次迭代中保存i的一个副本:
for (var i=1; i<=5; i++) { (function(){ var j = i; setTimeout( function timer(){ console.log( j ); }, j*1000 ); })(); }**找到了!这样就能正常工作了!**每次迭代的 IIFE 创建了一个新作用域,里面用var j = i把当前的i值固化下来,timer 回调闭包这个新作用域,拿到正确的迭代值。
5.4 解法二:IIFE 传参
换个口味,一些人更喜欢用参数传递的变体:
for (var i=1; i<=5; 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 i=1; i<=5; i++) { let j = i; // 让块级作用域为闭包服务! setTimeout( function timer(){ console.log( j ); }, j*1000 ); }5.6 let 在 for 头部的特殊重绑定行为
但还有更妙的!let在 for 循环头部有特殊行为:它不是在整轮循环中只声明一次变量,而是为每一次迭代都声明一个,并且会在每次后续迭代开始时,用上一次迭代结束时的值重新初始化它。
for (let i=1; i<=5; 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, ... }写成的对象。这个对象包含对内部函数的引用,但不包含对内部变量的引用——变量被保持为隐藏、私有的。应当把这个返回对象理解为模块的公共 API(public API);- 返回的对象被赋给外部变量
foo,随后通过foo.doSomething()访问 API 方法。
doSomething()和doAnother()对模块"实例"(通过调用CoolModule()获得)的内部作用域拥有闭包。当我们通过返回对象的属性引用把它们传递到词法作用域之外时,就真正具备了闭包产生并兑现的条件。
由此,模块模式必须满足两个必要条件:
- 必须有一个外层包裹函数,且至少被调用一次(每次调用创建一个新的模块实例);
- 外层函数必须至少返回一个内部函数,使该内部函数对私有作用域形成闭包,从而可以访问和/或修改内部状态。
要注意:一个只有"函数属性"的对象本身并不是模块;由函数调用返回、只有数据属性而没有闭包函数的对象,从常识角度讲也算不上模块。
补充:第 3 章在讨论"隐藏实现、防止标识符冲突"时也预告了这一结论——现代"依赖注入管理器"并没有凌驾于词法作用域规则之上的魔法,它们只是利用作用域规则把标识符保存在私有的、无冲突的作用域里。模块模式的细节正是第 5 章的主题。
6.3 单例变体
如果只想要一个模块实例(类似"单例"),可以把模块函数改造成 IIFE:
var 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 2"6.5 可动态修改的公共 API
模块模式的另一个完整变体:给返回的公共 API 对象起名,并在模块内部持有它的引用,从而从内部修改模块的公共 API:
var 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 i=0; i<deps.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.js
function 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 的一个或多个成员导入当前作用域,各成一个变量(本例的hello);module:把整个模块 API 导入指定变量(本例的foo、bar);export:把标识符(变量、函数)导出到当前模块的公共 API。
这些语句在模块定义中可按需多次使用。模块文件内部的内容被当作包在闭包作用域中处理——和前面讨论的基于函数闭包的模块如出一辙。
更完整的 ES6 语法与新特性体系,可继续阅读本仓库的另一分册 《ES6 与更远的未来》。
7. 总结:识别闭包的自检清单
第 5 章在结尾重申:关于闭包的知识充满了偏见与迷信,仿佛它是 JavaScript 中只有最勇敢的灵魂才能抵达的神秘世界。但实际上,闭包只是"在词法作用域环境下、函数作为值自由传递"时的一个标准且近乎显然的事实。
闭包 = 函数能够记住并访问其词法作用域,即使当它在该词法作用域之外被调用。
对照这份自检清单,你就能在自己的代码中主动识别闭包:
- 是否有一个函数,声明在了另一个函数内部?——是,则它对包裹它的作用域拥有闭包关系;
- 这个函数是否被当作值传递出去了?(return、参数、赋值、回调)——是,则闭包在外部调用时"生效";
- 循环中回调是否依赖循环变量?——是,请确认每迭代都有独立作用域(
let或 IIFE 快照),否则会踩"共享i"的坑; - 是否需要私有状态 + 公开方法?——是,这就是模块模式,请确认满足两条:外层函数被调用过 + 至少返回一个内部函数。
闭包可能像循环示例那样让我们困惑,但同时也是通往模块等强大模式的钥匙。现在,你可以在自己的既有代码里看到无处不在的闭包,并主动利用它们的全部优势了。本分册的完整阅读路径见 《作用域与闭包》README 与 目录,全书开篇背景见 前言。
- 教程
- 文档
【免费下载链接】you-dont-know-js-ru
📚 Russian translation of "You Don't Know JS" book series
相关推荐
浏览器内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
教程文档
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考