- A+
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。--MDN
闭包?
每当一个函数被创建时,这个函数就和创建其的词法环境绑定了。这保证了这个函数始终能访问创建其的词法环境里的数据。
例如如下代码 00:
function 张三(){ let foo = "张三的foo"; let bar = "张三的bar"; function show(){ console.log(foo,bar) } show(); } 张三(); // 张三的foo 张三的bar console.log(foo,bar); // Uncaught ReferenceError: foo is not defined
张三
被调用时,创建了一个词法环境,其中包含了foo
、bar
和show
等变量,他们仅在该词法环境和其内层词法环境中可见,而外层词法环境不可见。
当张三
被执行完毕,这个词法环境里的foo
和bar
随即销毁,这个词法环境也不复存在。
而闭包就使得这个词法环境得以保持,并且外部/其他词法环境可以访问其中的数据。
例如如下代码 01:
let foo = "全局的foo"; let bar = "全局的bar"; function 张三(){ let foo = "张三的foo"; return function show(){ // * console.log(foo,bar); } } function 李四(){ let foo = "李四的foo"; let bar = "李四的bar"; let show = 张三(); show(); // 张三的foo 全局的bar } 李四(); let show = 张三(); show(); // 张三的foo 全局的bar
┌───────────────────────┐ ┌───────────────────────┐ │[ 张三 ] │ │[ 李四 ] │ │ │ │ foo: '李四的foo' │ │ foo: '张三的foo' >>>>function show>>>> bar: '李四的bar' │ │ │ │ show: function │ │ │ │ │ └──────────↡────────────┘ └───────────────────────┘ ↡ ↳>>>>function show>>>> [ global ]
*
处的return function show
将张三的词法环境“打开”,使得拥有这个返回的函数的词法环境能够访问张三的词法环境。
无论这个函数被传递到哪里,它都会持有对原始定义作用域的引用,在访问foo
和bar
时总会先搜索张三的词法环境,如果没有则搜索其外部词法环境直至全局词法环境,如果在任何地方都找不到这个变量,那么就会报错。
当张三
被执行完毕,这个词法环境也不会立即销毁(张三自己会被销毁),因为还有"李四和全局要访问它",即李四和全局持有对张三的词法环境的引用show
。
这个在 其他作用域 中对 原始定义时的作用域 的引用就是闭包。
关于销毁
上文说道,show
能使定义它的作用域不被销毁,但不会阻止张三不被销毁
例如如下代码 02:
function 张三他爹(){ function 张三(){ let foo = "张三的foo"; return function show(){ console.log(foo); } } return 张三(); } let show = 张三他爹(); show(); // 张三的foo
张三他爹
执行完后,张三
不复存在,但show
仍能访问定义它的作用域
或者来点更直接的,杀死张三
,代码 03:
function 张三(){ let foo = "张三的foo"; return function show(){ console.log(foo); } } let show = 张三(); 张三 = null; //张三死了 show(); //张三的foo
张三死了,但他的精神还在
作用域 闭包
显然,多个作用域持有的闭包指向同一个词法环境。如果某个作用域中通过闭包修改了指向的词法环境的某个数据,其它作用域也会感知到。
例如以下代码 04:
function 张三(){ let size = 10; return [ function(n){ size += n; console.log(size); }, function(n){ size += n; console.log(size); } ] } [enlarge1,enlarge2] = 张三(); enlarge1(2); // 12 enlarge2(2); // 14
enlarge1
和enlarge2
同时指向了同一个词法环境,因此两次enlarge了同一个张三的size
。
代码 02看起来有些怪异。
请看代码 05:
function 张三(){ let size = 10; return function(n){ size += n; console.log(size); } } enlarge1 = 张三(); enlarge2 = 张三(); enlarge1(2); // 12 enlarge2(2); // 12
看起来正常多了,但是结果不是我们想要的。
?
两次调用张三
实际上创建了两个独立的词法环境 [张三1, 张三2],所以两次enlarge了两个不同的张三的size
。
循环
比较经典的是循环与闭包结合
例如以下代码 06:
for (var i=1; i<=5; i++) { setTimeout( function logi() { console.log( i ); }, i*100 ); }
输出为5个6。
异步的setTimeout
总是在当前事件(循环及其后面的同步代码)执行结束后再开始执行。每次向setTimeout
传递的function都是对循环内词法环境的闭包,因此当这些function被调用时,它们访问循环内词法环境中的i
,得到的是累加完毕的6。
let
如果使用现代的let
而非var
,结果会完全不同
例如以下代码 07:
for (let i=1; i<=5; i++) { setTimeout( function logi() { console.log( i ); }, i*100 ); }
输出为1 2 3 4 5。
这个循环实际上有两个作用域
┌─────────────────────────┐ │[ ()内 ] i │ │ ┌──────────────────┐ │ │ │[ {}内 ] i >>>>function logi>>>>[ global ] │ └──────────────────┘ │ │ │ └─────────────────────────┘
每次迭代,JavaScript就会用上次迭代结束时i
的值重新声明并初始化 {}内的 i
验证
代码 08:
for(let i=1;i<5;i++){ console.log(i); //Uncaught ReferenceError: Cannot access 'i' before initialization let i = 1; }
在ES6中,let声明会被提升到作用域开头但不会初始化,这就是暂时性死区。
上述代码抛出了ReferenceError: Cannot access 'i' before initialization
而不是SyntaxError: Identifier 'i' has already been declared
证明i
并不在{}内作用域定义,而是属于其外层作用域。
但i
确实存在在{}内作用域中
猜测:由于整个for循环的执行体中并没有使用let,但是执行中每次都产生了块级作用域,我猜想是由底层代码创建并塞给for执行体中。
-- https://www.cnblogs.com/echolun/p/10584703.html
应用
工厂函数
可以通过一个函数创建多个特定函数
例如代码 09:
function createAdder(m){ return function(n){ return m+n; } } let addTo5 = createAdder(5); addTo5(10);
调用createAdder
会产生一个偏函数,实现功能更狭窄但更精确的函数。
如addTo5
返回某数与5的和。
为什么我们通常会创建一个偏函数?
好处是我们可以创建一个具有可读性高的名字(double,triple)的独立函数。我们可以使用它,并且不必每次都提供一个参数,因为参数是被绑定了的。
另一方面,当我们有一个非常通用的函数,并希望有一个通用型更低的该函数的变体时,偏函数会非常有用。
-- https://zh.javascript.info/bind
模块/类
例如代码 0A:
let person = (function createPerson(){ let age = 1; let name = "Li Hua"; return { grow(){ age++ }, say(){ console.log(`I'm ${name}, and I'm ${age} years old`) } } })(); person.say() // I'm Li Hua, and I'm 1 years old person.grow() person.say() // I'm Li Hua, and I'm 2 years old
createPerson
抛出了两个方法grow
和say
,使其他作用域可以访问其内部的数据、方法。
性能
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
-- MDN
使用闭包来实现一些东西不是明智的,例如代码 0A中每创建一个对象都要把方法赋给这个对象。
改为使用原型,代码 0B:
let father = { age: undefined, name: undefined, init(name){ this.age = 1; this.name = name; }, grow(){ this.age++; }, say(){ console.log(`I'm ${this.name}, and I'm ${this.age} years old`) } } let p1 = Object.create(father); p1.init("Li Hua") let p2 = Object.create(father); p2.init("Li Ming") p1.grow() p1.say() // I'm Li Hua, and I'm 2 years old p2.say() // I'm Li Ming, and I'm 1 years old
或使用class
来实现。
这样,方法都存在他们的原型中,免去了每次创建的赋值。
内存消耗
从理论上说,闭包会保持住其 原始定义作用域 的所有数据,因此会导致较大的内存消耗。
现代的JavaScript引擎可能会分析出一片作用域中没有使用的量,并把他们从环境中删除。
例如 代码0C:
function 张三(){ let foo = "张三的foo"; let bar = "张三的bar"; return function show(){ console.log(foo); } } let show = 张三(); show();
当通过show()
访问张三作用域时,bar是不存在的,因为没有使用。
测试环境:Edge 95,JavaScript: V8 9.5.91
但这个和引擎的实现有关,实际使用时应当慎重。
总结
事实上,JavaScript中到处都有闭包存在,每当函数被传递到当前词法作用域之外执行,就产生了闭包。
更确切地说,所有的函数都是闭包的,当函数被传递到当前词法作用域之外执行,闭包的效果得以表现。
学会并能够识别闭包可以减少出错,写出更健壮的代码。
在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于词法环境原理的技术细节。
-- https://zh.javascript.info/closure