本文是我学习闭包以来的总结和体会, 错误或者不当之处还请读者指出, 以免误导后学。
如果转载请在正文开头注明文章来源, 以便读者可以看到及时的更新。
学习东西首先要抓住重点, 用最少的精力获取最大成果, 其余的就是搂草打兔子, 顺便学了。
JavaScript 的重点和难点之一就是闭包, 学好闭包机制 Javascript level up 50%;
学习闭包机制时, 个人建议参照 ECMAScript 规范 和 V8 引擎 学习。理论实践相结合。
下面是一些关于闭包的描述, 摘录权威性的文献或资料:
<
从技术角度来讲, 所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。
<
函数变量可以被隐藏于作用域链中, 因此看起来是函数将变量“包裹”了起来.
<
闭包是指有权访问另一个函数作用域中的变量的函数。
You Don't Know JS: Scope & Closures
Closure is when a function is able to remember and access its lexical scope
even when that function is executing outside its lexical scope.
MDN
A closure is the combination of a function and the lexical environment within which that function was declared.
某百科
In programming languages, a closure (also lexical closure or function closure) is a technique
for implementing lexically scoped name binding in a language with first-class functions.
Operationally, a closure is a record storing a function together with an environment.
The environment is a mapping associating each free variable of the function
(variables that are used locally, but defined in an enclosing scope) with the value or reference
to which the name was bound when the closure was created.
A closure—unlike a plain function—allows the function to access those captured variables
through the closure's copies of their values or references, even when the function is invoked outside their scope.
初学者看到这么多定义, 很容易迷糊, 如盲人摸象, 管中窥豹。
到底哪种定义更加全面, 更加接近闭包的本质?
要明白某个名词, 寻纠它的起源是搞明白其含义的妙招。
闭包到底是怎么来的, 这种概念是怎么出现的, 闭包到底解决了什么问题?
首先明确一点, 闭包并不是 JavaScript 所独有的概念, 其他语言也有其实现
在 JavaScript 中, 函数是一等公民: 函数可以作为参数传递, 可以从函数返回, 可以修改, 可以赋值给变量。
在支持 函数是一等公民的编程语言中, 要面临一个问题—— funarg problem
, 如何处理自由变量 (变量既不是函数的参数, 也不是局部变量)
funarg problem 分 upwards funarg problem 和 downwards funarg problem 两种。
upwards funarg problem 发生在函数将其嵌套函数作为返回值返回时(嵌套函数使用了自由变量)。
downwards funarg problem 发生在将函数作为参数传入函数时。
举例来说, 下面是 downwards funarg problem
let x = 10;
function foo() {
console.log(x);
}
function bar(funArg) {
let x = 20;
funArg(); // 10, 不是 20
}
// 将 `foo` 作为参数传给 `bar`.
bar(foo);
对于函数 foo
来说, x
就是其自由变量。 函数 foo
内的 变量 x
应该解析到全局环境中值为 10
的 x
(即采用静态作用域)还是 bar
函数中值为 20
的 x
(即采用动态作用域)?
JavaScript 解决这个问题, 通过采用 静态作用域(或者说词法作用域 —— 函数作用域是在定义函数时就确定的, 而不是运行时。)。
还有个 upwards funarg problem:
function foo() {
var a = { x: 1, y: 2 }; // 对象
var b = 10; // 基本数据类型
function bar(param) {
return param+ b;
}
return bar;
}
var b = 20;
var func = foo();
console.log(func(1));
这个例子是我们在 JS 中经常见到的例子,虽然看起来很简单, 其中却大有学问。
我们知道, 通常来说, 在基于栈的函数内存分配范式中, 调用函数时, 会将其参数和局部变量保存在栈的栈帧(或者活动记录)上, 函数调用结束后, 保存其参数和局部变量的栈帧就会从调用栈中弹出。
上述例子中, bar
函数执行时, foo
函数的栈帧已经从调用栈中弹出,如果没有某种机制, 其局部变量 a
和 b
就都不存在了, bar
根本不可能获取到变量 b
的值。
怎么办?
一个办法就是, 有外部引用引用 变量 b
时, 禁止函数 foo
的栈帧从栈中弹出,但是这打破了函数基于栈的内存分配范式(函数调用完毕后, 应该将其栈帧从栈中弹出)
怎么能够既可以使函数遵循基于栈的内存分配, 还可以使 bar
在 foo
返回后仍然可以获取到 b
的值呢?
方案一:
在堆而不是栈上分配所有栈帧,当栈帧不再需要时, 依赖某种形式的 垃圾回收 或者 引用计数 来释放栈帧。由于在堆上管理栈帧远远没在栈上管理来的高效, 这种策略可能严重降低性能。而且, 因为在通常的程序中大多数函数并不会创建 upwards funargs(当函数作为参数时, 该函数就叫 funarg), 很多这种损耗是不必要的。
方案二:
一些考虑性能的编译器会采用混合方式: 如果编译器通过 静态程序分析 推断出函数没有创建 upwards funargs, 那么 函数的栈帧就会在栈上分配, 否则的话, 在堆上分配栈帧。
方案三:
利用闭包。 闭包创建时, 将变量的值拷贝进闭包。在可变变量(mutable variables) 的情况下,这将导致不同的行为,因为状态不能够在闭包之间共享。
在实际的引擎实现中,出于性能的考虑,可能会对闭包存储的变量进行优化, 比如只保存自由变量(v8 即是采用了这种优化方式)。
JavaScript 正是采用了第三种方案, 利用闭包机制。
那么闭包到底是怎么实现的呢?
某百科有段描述, 摘录翻译如下:
闭包通常是由一种特殊的数据结构实现的, 该数据结构包括指向函数代码的指针, 以及闭包创建时函数词法环境(换句话说, 可获取到的变量集)的表示。 引用的环境在闭包创建时将非局部名称(也就是自由变量)绑定到对应的变量, 此外, 将它们的生存期扩展到至少和闭包的生存期一样长。 稍后进入闭包时可能是在不同的词法环境中, 当函数执行时使用的非本局部变量将会引用闭包捕获的变量,而不是当前环境的变量。
在 ES3 规范下, 闭包为函数代码 + 该函数的所有父作用域(也就是函数内部属性[[Scopes]]), 可以用伪码表示:
Closure = {
functionCode: ,
[[Scopes]]: []
}
JavaScript 中 所有的函数(函数声明、函数表达式、命名函数、匿名函数)均有内部属性 [[Scopes]]
。从图中(NodeJS 8.11.3
vscode
调试结果)看的话是这样子的:
从上图可以看出, 所有的函数(函数声明、函数表达式、命名函数、匿名函数)均有 [[FunctionLocation]]
和 [[Scopes]]
。
所以从技术角度来看, 所有的函数都是闭包。
闭包中保存了 所有捕捉到的自由变量, 眼见为实
这是 谷歌浏览器中调试到的结果, 可以看出:
(一) bar
执行时, Closure(foo)
中只包含了 bar
使用的自由变量 b
, 没有包含 foo
的局部变量 a
。(明显这是经过了优化, 只会保留自由变量。如果未优化的话, Closure(foo)
中会有变量 a
、b
、bar
、arguments
)
(二) 调用栈中, 只有 (anonymous)
和 bar
, 没有 foo
, bar
执行时 foo
函数已经从调用栈中弹出(网上仍然有很多博客错误的认为闭包函数执行时, 定义该函数的上下文并没有出栈)
call stack 这个概念比较通用, 在 ECMAScript中, execution context stack 就是 call stack。 类似于钱这个概念, 各国都有钱, 在我们中国, 钱就是人民币, 在美国就是美元。
验证: 只有自由变量存在的情况下,才会有真正意义上的闭包。
函数 bar
处于 foo
中 且 foo
已把 bar
返回, 但是 bar
执行的时候并没有产生闭包。 由此可以看来, 没有自由变量存在的话, 不会有闭包, 而这同样是 v8 进行优化过后的结果。( 如果涉及到 v8 的话, <
这里 虽然 foo
没有把 bar
返回在词法作用域之外执行, V8 依旧认为生成了闭包。(这个例子, 在<
还有个问题就是: 闭包存放在哪里?
可以看到当 foo(2)
执行到图示位置, bar
函数的内部属性 [[Scopes]]
已经保存了对 foo
的闭包, 此闭包中保存了自由变量 a
和其值 2
。然后, foo(2)
将返回值(也就是函数对象 bar
)赋值给了变量 baz
。
此时, baz
持有的就是这个 bar
函数对象:
foo(2)
返回后其内部的所有变量销毁。
当执行 baz()
时, bar
函数内部的 a
的值就从 [[Scopes]][0]
中获取到了, 如下图:
这些东西都搞明白后,再看文章开头的几个定义是不是清晰很多了。
总结:
由于作用域链机制的存在, 从技术上来讲,JavaScript 中 所有的函数(函数声明、函数表达式、命名函数、匿名函数)都是闭包。
闭包是保存函数代码和定义函数的环境的记录, 此环境存储着闭包创建时函数的每个自由变量和其值或引用的映射。
在实际的闭包应用(比如内存性能优化分析)中, 我们真正要关注的闭包应当是:
- 函数作为参数传递 或者 从另一个函数中返回
- 函数内部使用了自由变量
小测验
对于方案三中提到的
状态不能够在闭包之间共享
如何理解呢?
拿《JavaScript 权威指南》(第六版) 中的一个例子来说(p185):function counter() { var n = 0; return { count: function() { return n++; }, reset: function() { n = 0;} }; } var c = counter(), d = counter(); // 创建两个计数器 c.count(); // => 0 d.count(); // => 0 c.reset(); c.count(); // => 0; d.count(); // => 1
counter() 调用了两次,得到了两个计数器对象, 调用其中一个计数器对象的 count() 或者 reset() 不会影响到另一个对象。counter() 中的状态
n
在两个计数器对象之间不是共享的。
思考一下, 这是为什么?
参考资料:
- Closure 自行在某百科搜索关键词
- Funarg_problem 自行在某百科搜索关键词
- lexical-environments-ecmascript-implementation