JavaScript 闭包

  • A+
所属分类:Web前端
摘要

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。–MDN


一个函数和对其周围状态(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 

张三被调用时,创建了一个词法环境,其中包含了foobarshow等变量,他们仅在该词法环境和其内层词法环境中可见,而外层词法环境不可见。

张三被执行完毕,这个词法环境里的foobar随即销毁,这个词法环境也不复存在。

而闭包就使得这个词法环境得以保持,并且外部/其他词法环境可以访问其中的数据。
例如如下代码 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将张三的词法环境“打开”,使得拥有这个返回的函数的词法环境能够访问张三的词法环境。
无论这个函数被传递到哪里,它都会持有对原始定义作用域的引用,在访问foobar时总会先搜索张三的词法环境,如果没有则搜索其外部词法环境直至全局词法环境,如果在任何地方都找不到这个变量,那么就会报错。
张三被执行完毕,这个词法环境也不会立即销毁(张三自己会被销毁),因为还有"李四和全局要访问它",即李四和全局持有对张三的词法环境的引用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 

enlarge1enlarge2同时指向了同一个词法环境,因此两次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抛出了两个方法growsay,使其他作用域可以访问其内部的数据、方法。

性能

如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
-- 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是不存在的,因为没有使用。
JavaScript 闭包
测试环境:Edge 95,JavaScript: V8 9.5.91
但这个和引擎的实现有关,实际使用时应当慎重。

总结

事实上,JavaScript中到处都有闭包存在,每当函数被传递到当前词法作用域之外执行,就产生了闭包。
更确切地说,所有的函数都是闭包的,当函数被传递到当前词法作用域之外执行,闭包的效果得以表现。
学会并能够识别闭包可以减少出错,写出更健壮的代码。

在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于词法环境原理的技术细节。
-- https://zh.javascript.info/closure