JavaScript 闭包

一个函数和对其周围状态(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执行体中。
//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)的独立函数。我们可以使用它,并且不必每次都提供一个参数,因为参数是被绑定了的。
另一方面,当我们有一个非常通用的函数,并希望有一个通用型更低的该函数的变体时,偏函数会非常有用。
//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是不存在的,因为没有使用。
image
测试环境:Edge 95,JavaScript: V8 9.5.91
但这个和引擎的实现有关,实际使用时应当慎重。

总结

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

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