闭包的定义
JavaScript中,闭包是函数中变量沿作用域链查找机制的产物。基于闭包的特性,开发人员能够完成特定的开发范式或者设计模式。JavaScript社区有很多有关闭包的讨论,当人们谈论闭包的概念的时候,大部分会从下面两个切入角度去给闭包下定义。两种方式都正确,它们给出的结果是相同的,只是切入点不同。
《JavaScript 权威指南》:从技术的角度讲,所有的JavaScript函数都是闭包。
词法作用域
目前的MDN文档采用了基于词法作用域概念的解释。词法作用域又称静态作用域,在JavaScript 作用域与声明提升中我们介绍了静态作用域与动态作用域的区别。MDN文档上如此定义闭包:
闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,一个闭包让一个函数能访问它的外部作用域。在 JavaScript 中,闭包会随着每次函数的创建而同时创建。
由这个定义,可以称闭包中的函数为闭包函数,函数周围状态为闭包词法环境。并且,JavaScript中全局作用域、模块作用域、函数作用域、块级作用域都属于词法作用域,在这些作用域都可以形成闭包。
JavaScript中所有函数都定义在它的作用域环境中(最外层函数也运行在全局作用域),所有函数都绑定在它的作用域环境下。所以,JavaScript程序每次调用函数其实都是在创建闭包。以下结合代码以说明:
function run() {
var one = 1;
function addOne(num) {
return one + num;
}
function addTwo(num) {
return num + 2;
}
console.log(addOne(3)); // 输出:4
console.log(addTwo(5)); // 输出:7
}
run();
上述代码中,函数aadOne()
和函数addTwo()
均定义在函数run()
的作用域中,绑定在函数run()
的词法作用域下,并且函数one()
内部访问了词法作用域中的变量one
。每次函数aadOne()
或函数addTwo()
的运行均创建了一个闭包。
注意,闭包的词法环境和执行上下文的词法环境有联系但不是一回事。这里的词法环境是JavaScript代码中函数内的变量、函数声明情况。而我们在JavaScript 执行上下文中介绍的词法环境,是JavaScript引擎解析JavaScript代码的解析结果中对执行上下文环境变量和函数的描述。它们在很多地方上相似,在细节上不同。比较如下:
闭包词法环境 | 执行上下文词法环境 | |
---|---|---|
let 、const 、function 、class 声明 |
let 、const 、function 、class 声明作为可访问环境变量 |
记录let 、const 、function 、class 声明以供访问 |
函数参数(Arguments ) |
函数参数作为可访问环境变量 | 记录函数参数以供访问 |
this 绑定 |
通常,闭包函数有自己的this 对象,不关心词法环境中的this 。箭头函数例外 |
记录运行时this 绑定以供访问 |
var 声明 |
var 声明作为可访问环境变量 |
var 声明不记录在词法环境上,而是记录在变量环境上 |
扩展:箭头函数内没有独立的this
绑定,箭头函数中的this
对象保留了闭合词法上下文的this
值。这意味着箭头函数的行为就像它们是“自动绑定”的——无论如何调用,this
都绑定到函数创建时获得的this
的值。在函数内部创建的箭头函数的this
是函数的this
,在全局环境下创建的this
值是globalThis
(浏览器中是window
)。
自由变量和约束变量
维基百科上基于自由变量和约束变量来解释闭包,这是一个计算机术语的通识解释,不针对特定编程语言。维基百科上定义:
闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。
什么是自由变量和约束变量呢?在《计算机程序构造与解释》中对它们有这样的描述:“如果在一个完整的过程定义里将某个约束变量统一换名,这一过程定义的意义将不会有任何改变。如果一个变量不是约束的,我们就称它是自由的。一个名字的定义被约束于的那一集表达式称为这个名字的作用域”。我们可以为自由变量和约束变量定义如下:
- 自由变量:自由变量是指在表达式中出现,但未在该表达式的范围内定义的变量。
- 约束变量:约束变量是指在一个特定的作用域内定义并且有明确值的变量。它们在函数或表达式的作用域内“被约束”,其生命周期和作用范围仅限于此作用域。
请看如下代码示例:
var one = 1;
function addOne(num) {
var numberType = 'number';
return typeof num === numberType ? (num + one) : NaN;
}
对于上述函数addOne
来说,变量one
在函数中使用,声明却在函数外部,就是自由变量。而参数num
和变量numberType
均声明在函数体中,一个是形参,一个是函数内变量,它们在函数内统一换名不影响函数意义,它们是约束变量。对于闭包函数来说,词法作用域中的变量都属于函数外部的自由变量。
根据这个维基百科对闭包的定义,闭包是一个存储了函数和关联环境的结构体,关联环境中存在约束变量和自由变量的绑定,自由变量可以没有。这里对函数本身没有提出什么要求。和其它将函数作为头等公民的编程语言一样,JavaScript中任一函数运行都会创建闭包。
维基百科对闭包的解释不针对特定编程语言,我们前端开发人员在开发应用中使用闭包,JavaScript引擎内部同样使用闭包去构建JavaScript的词法作用域实现。
实践中的闭包
既然所有的JavaScript函数都基于闭包结构运行,那么闭包的强大在什么地方呢?JavaScript社区通常讨论闭包的时候,通常讨论的是闭包的一种特例,这种特例有两个特征:
- 闭包函数被保持。
- 闭包函数捕获了闭包词法环境中的变量,即捕获了自由变量。
闭包函数被保持的常见方式是作为结果从外层函数中返回。正是因为闭包函数捕获了自由变量并且被保持,为了维护这份“保持”,JavaScript引擎不会销毁外层函数的执行上下文,哪怕在外层函数已经执行完毕之后也是如此。只有当被保持的函数也被销毁时,被保持的外层函数执行上下文才会一同销毁。正是外层函数执行完还能保持上下文地特性,让闭包在程序设计中强大而重要。
在实践中,JavaScript引擎对函数地闭包结构有针对性地优化策略。从引擎层面来看,函数如果没有捕捉自由变量,那么它其实可以被实现为一个函数指针,或者直接内联到调用点,如果它捕捉了自由变量那么它将保持为一个闭包。另外,V8 JavaScript引擎只会在闭包词法环境中保存那些被闭包函数捕获地变量。考虑这些优化策略,JavaScript引擎视这种特例闭包为闭包,平常类型地闭包结构会被优化为普通函数。
查看下列代码以及在Chrome浏览器中的执行信息:
function getAddOne() {
var one = 1;
var two = 2;
function addOne(num) {
debugger;
return one + num;
}
console.log(two); // 输出 2
return addOne;
}
var addOneWith = getAddOne();
console.log(addOneWith(3)); // 输出:4
在这段代码中,getAddOne()
函数中定义了one
、two
两个变量,以及addOne()
函数。addOne()
函数捕获了外层函数的变量one
。并作为getAddOne()
函数的返回值以便外部保持。外部获得getAddOne()
函数后进行了调用,我们在addOne()
函数设置断点以便查看函数的执行情况。执行情况如下图:
可见,Closure对象中保存了变量one
,但是没有保存变量two
。
闭包的用途
闭包能将数据(词法环境)与运算函数关联起来。这显然类似于面向对象编程。基于闭包特性进行函数柯里化,也加大JavaScript语言函数式编程地表达能力。闭包地用途还有很多,在这里我们稍作列举。
1. 模拟私有属性
下面的代码展示了如何使用闭包定义能访问私有函数和私有变量的公共函数。
const makeCounter = function () {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
value() {
return privateCounter;
},
};
};
const counter1 = makeCounter();
const counter2 = makeCounter();
console.log(counter1.value()); // 输出:0
counter1.increment();
counter1.increment();
console.log(counter1.value()); // 输出:2
counter1.decrement();
console.log(counter1.value()); // 输出:1
console.log(counter2.value()); // 输出:0
上述代码中,increment()
、decrement()
、value()
三个闭包方法(函数)共用一个词法环境,它们每个都能访问词法环境中的privateCounter
变量和changeBy
函数。同时,counter1
和counter2
引用的闭包互不影响,各自拥有独立的词法环境。
2. 函数柯里化(curry)
JavaScript 中实现函数的柯里化完全有赖于闭包的特性。
function curry(func) {
// 获取函数的参数个数
const funcLength = func.length;
// 内部的柯里化函数
function curried(...args) {
// 如果已经传递了足够的参数,直接调用原始函数
if (args.length >= funcLength) {
return func(...args);
} else {
// 否则返回一个新的函数,继续收集参数
return function(...nextArgs) {
return curried(...args, ...nextArgs);
};
}
}
return curried;
}
function add(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(add(1, 2, 3)); // 输出:6
console.log(curriedAdd(1)(2)(3)); // 输出:6
console.log(curriedAdd(1, 2)(3)); // 输出:6
console.log(curriedAdd(1)(2, 3)); // 输出:6
上述代码定义了能够将普通函数柯里化的高阶函数curry
,它接受一个普通函数并返回一个柯里化版本的函数,调用curry(add)
完成对普通函数add()
的柯里化,柯里化后的函数调用和原始函数调用运行结果相同。
·
3. 实现备忘录(MEMENTO)模式
以下代码展示了斐波那契数列的备忘录模式。
function memoize(func) {
const cache = {}; // 用来存储计算结果的缓存
return function(...args) {
const key = JSON.stringify(args); // 将参数序列化为唯一键值
// 如果缓存中有结果,直接返回缓存的值
if (cache[key]) {
console.log('从缓存中读取');
return cache[key];
}
// 否则计算并缓存结果
const result = func(...args);
cache[key] = result; // 存入缓存
return result;
};
}
// 斐波那契函数
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 使用备忘录优化斐波那契函数
const memoizedFibonacci = memoize(fibonacci);
console.log(memoizedFibonacci(10)); // 第一次调用,计算并缓存结果
console.log(memoizedFibonacci(10)); // 第二次调用,直接从缓存中读取
console.log(memoizedFibonacci(5)); // 第一次计算 5
console.log(memoizedFibonacci(5)); // 从缓存读取 5
memoize()
函数中有一个cache
变量声明,他会缓存每次函数调用的结果,当同样的调用再次发生的时,会直接从缓存中取出数据,而不是真的重新调用一遍原始函数。
性能问题
每一个闭包都是函数(方法)和其实例所处的词法环境的组合,闭包的定义中包含着一个性能问题。闭包中引用的函数是一个具体实例,同一个外层函数创建的闭包中不共享这些闭包函数实例的引用,它们分别维持一个实例引用。这会给程序的性能和内存开销产生负面影响。这种情况和在构造函数中定义方法一样。解决办法是尽量使用原型构造函数模式去创建对象,避免使用闭包。当然我们并不是说原型构造函数模式可以完全替代闭包,它们是两类事物。只是说在我们在构建程序中面对具体问题时,可以多思考一些解决方案。
有关性能问题还有一个误解,很多人会认为闭包会造成内存泄漏。因为外层函数在执行完毕后执行上下文被保存的特性,很容易让人感觉它一直没被回收。闭包函数实例捕获的自由变量一直没回收是真实发生的,但这种情况正是我们需要的,如果不是需要保持外层函数的变量,我们也就没必要运用闭包了。闭包可能造成的内存问题是闭包函数实例在不需要的时候一直没被释放,多放生在错误地把闭包函数实例保存在一个长期地执行上下文中,诸如全局执行上下文,所以,一定要注意不要将闭包函数实例暴漏在全局作用域中。通常情况下闭包函数实例都会在被保存地执行上下文销毁时被销毁。
闭包的另一个问题是容易形成循环引用,这会促发一些问题。 在IE8及其早期版本的浏览器中,对于BOM和DOM对象的垃圾回收机制采用了引用计数策略,在引用计数策略下,如果两个对象之间形成了循环引用就会导致它们不能被回收。当闭包中保存了一些DOM节点时,更容易引发这个问题。不过对于现代浏览器,不存在BOM和DOM对象循环引用的问题。有关JavaScript的垃圾回收机制,我们后面再单独探讨。