深入理解 JavaScript 闭包
1. 前言
面试的时候常常会有面试官会问:谈谈你对闭包的理解?
我觉得回答问题要有清晰的思路和逻辑顺序,如:
- 描述实际问题是什么,以及问题产生的背景。
- 问题是怎么解决。
- 结果怎么样。
我觉得按这样的思路回答问题,比按MDN 上的解释说一遍回答“闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合”要好。
接下来我将尝试按照这个思路回答【谈谈你对闭包的理解?】
- 闭包产生的背景。(背景,及问题是什么)
- 闭包的实现机制。(怎么解决的)
- 闭包的应用场景。(带来了什么结果)
2. 闭包产生的背景
2.1 函数执行环境的问题
在传统的面向堆栈的编程语言中,函数的局部变量都是保存在栈上的,每当函数激活的时候,这些变量和函数参数都会压入到该堆栈上。 当函数返回的时候,这些参数又会从栈中移除。这种模型对将函数作为函数式值使用的时候有很大的限制,特别是在以下场景:
- 函数作为返回值
- 函数作为参数传递
- 函数中包含自由变量
- 在ECMAScript中,函数是可以封装在父函数中的,并可以使用父函数上下文的变量。
- 自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量
2.2 Funarg 问题详解
Funarg(函数参数)问题是闭包概念产生的直接导火索,主要分为两类:
2.2.1 向上引用的 Funarg 问题(Upward Funarg)
function foo() {
const x = 10;
return function bar() {
console.log(x); // 如何访问已经执行完毕的foo函数中的x?
}
}
const fn = foo();
fn(); // 10
function foo() {
const x = 10;
return function bar() {
console.log(x); // 如何访问已经执行完毕的foo函数中的x?
}
}
const fn = foo();
fn(); // 10
2.2.2 向下引用的 Funarg 问题(Downward Funarg)
const x = 10;
function foo() {
console.log(x);
}
(function (funArg) {
const x = 20;
funArg(); // 如何找到x变量?应该输出10还是20?
})(foo);
const x = 10;
function foo() {
console.log(x);
}
(function (funArg) {
const x = 20;
funArg(); // 如何找到x变量?应该输出10还是20?
})(foo);
为了解决上述问题,JavaScript 就引入了闭包的机制。
3. 闭包的实现机制
3.1 词法作用域(Lexical Scope)
JavaScript 采用静态(词法)作用域,这意味着函数的作用域在函数定义时就已确定,而非函数调用时。
再次强调下:ECMAScript 只使用静态(词法)作用域 。 ( 而诸如Perl这样的语言,既可以使用静态作用域也可以使用动态作用域进行变量声明)。
3.2 作用域链与闭包
function foo(){}
// foo函数执行上下文
fooContext = {
VO:{...}, // 变量对象
this:thisValue, // this值是执行上下文一属性
Scope, // 函数作用域链
}
function foo(){}
// foo函数执行上下文
fooContext = {
VO:{...}, // 变量对象
this:thisValue, // this值是执行上下文一属性
Scope, // 函数作用域链
}
foo函数创建时,会形成以下重要的内部属性:
- [[Scopes]] - 保存函数创建时的词法环境
- 作用域链(Scope Chain)- 函数执行上下文作用域链 fooContext.Scope = fooContext.AO + foo.[[Scopes]]
技术上说,创建foo函数的父级上下文的数据是保存在该函数的内部属性 [[Scopes]]中的。
但是 JS 引擎怎么知道它要用到哪些外部引用呢,需要做 AST 扫描,很多 JS 引擎会做 Lazy Parsing,这时候去 parse 函数,正好也能知道它用到了哪些外部引用,然后把这些外部用打包成 Closure 闭包,加到 [[scopes]] 中。
如下图:firstClosure函数在创建完成之后,函数内部引用的自由变量就已经打包成Closure闭包,挂到函数的[[Scopes]]上了。
调用 firstClosure 函数的时候,JS 引擎 会取出 [[Scopes]] 中的打包的 Closure + Global 链,设置成新的作用域链, 这就是函数用到的所有外部环境了,有了外部环境,自然就可以运行了。
4. 闭包的应用场景
- 避免命名空间污染:模块要用多个变量,我们希望变量不影响全局,全局也不影响我们的变量。
- 模拟私有属性
- 有状态的函数
4.1 模块化模式
const module = (function () {
let privateData = [];
function privateMethod() {
// ...
}
return {
publicMethod() {
privateMethod();
}
}
})();
const module = (function () {
let privateData = [];
function privateMethod() {
// ...
}
return {
publicMethod() {
privateMethod();
}
}
})();
4.2 数据私有化
function createPerson(name) {
let _name = name;
return {
getName() {
return _name;
},
setName(newName) {
_name = newName;
}
}
}
function createPerson(name) {
let _name = name;
return {
getName() {
return _name;
},
setName(newName) {
_name = newName;
}
}
}
4.3 函数工厂
function multiply(x) {
return function (y) {
return x * y;
}
}
const multiplyByTwo = multiply(2);
function multiply(x) {
return function (y) {
return x * y;
}
}
const multiplyByTwo = multiply(2);
5. 闭包的性能考量
5.1 内存管理
- 如果一个变量被闭包对象 Closure 引用,无法被释放回收。
- 如果一个很大的对象被闭包对象 Closure 引用,本来函数调用结束就能销毁,但是现在引用却被通过闭包保存到了堆里,而且还一直用不到,那这块堆内存就一直没法使用,严重到一定程度就算是内存泄漏了。
- 闭包函数又在多个地方被引用,导致数据引用复杂,容易发生内存泄漏问题。
5.2 优化建议
- 避免创建不必要的闭包(使用面向对象编程)
- 及时清理不再使用的闭包
- 在循环中创建闭包时要特别注意
至此通过清晰的回答,面试官会对你肯定刮目相看。