本篇文章, 我们来说说老 (bei) 生(xie)常 (lan) 谈(le)的闭包, 很多文章包括一些权威书籍中对于闭包的解释不尽相同, 每个人的理解也都不一样. 并且在其他语言中, 也有对闭包的不同实现, 让我们来看看 JavaScript 中是如何实现闭包的以及有哪些特性.
直接进入主题, 上一段简短的代码:
- function outer(count) {
- var temp = new Array(count)
- function log() {
- console.log(temp)
- }
- log()
- function inner() {
- console.log('done')
- }
- return inner
- }
- var o = {}
- for(var i = 0; i < 1000000; i++) {
- o["f"+i] = outer(i)
- }
如果你不知道这段代码可能带来的问题, 那么这篇文章就值得你读一读.
执行上下文 & 作用域链
我们先把上面的问题放一放, 先让我们来看一看下面这段简单的代码:
- function outer() {
- var b = 2
- function inner() {
- console.log(a, b)
- }
- return inner
- }
- var a = 1
- var inner = outer()
- inner()
在 JS 引擎 中, 是通过执行上下文栈来管理和执行代码的. 上述代码的伪执行过程如下(本节内容主要参考冴羽大大的系列文章 https://github.com/mqyqingfeng/Blog ):
0, 程序开始
ECStack = []
1, 创建全局上下文 globalContext, 并将其入栈
- ECStack = [
- globalContext
- ]
2, 在执行之前初始化这个全局上下文
- globalContext = {
- VO: {
- a: undefined,
- inner: undefined,
- outer: function outer() {...}
- },
- Scope: [globalContext]
- }
初始化作用域链属性 Scope 为 [globalContext], 此时代码还没有执行, 由于变量提升的缘故, inner 和 a 变量为 undefined, 需要注意的是, 这个时候, outer 函数的作用域 [[scope]] 内部属性已确定(静态作用域):
- outer.[[scope]] = [
- globalContext.VO
- ]
3, 执行 globalContext 全局上下文
在执行过程中, 不断改变 VO, 执行到 a = 1 语句, 将 VO 中的 a 置为 1, 执行到 inner = outer() 语句, 执行 outer 函数, 进入 outer 函数的执行上下文.
4, 创建 outerContext 执行上下文, 将其入栈
- ECStack = [
- outerContext,
- globalContext
- ]
5, 初始化 outerContext 执行上下文
- outerContext = {
- VO: {
- b: undefined,
- inner: function inner() {...}
- },
- Scope: [VO, globalContext.VO]
- }
初始化作用域链属性 Scope 为 [VO].concat(outer.[[scope]]) 即 [VO, globalContext.VO]. 并在此时, 确定 inner 函数的 [[scope]] 属性:
- inner.[[scope]] = [
- outerContext.VO,
- globalContext.VO
- ]
6, 执行 outerContext 上下文
执行语句 b = 2, 将 VO 中的 b 置为 2, 最后返回 inner.
7,outerContext 执行完毕, 出栈, 继续回到 globalContext 执行余下的代码
- ECStack = [
- globalContext
- ]
继续执行 inner = outer() 语句的赋值操作, 将 outer 函数的返回结果赋给 inner 变量.
执行 inner() 语句, 进入 inner 函数的执行上下文.
8, 创建 innerContext 执行上下文, 将其入栈
- ECStack = [
- innerContext,
- globalContext
- ]
9, 初始化 innerContext 执行上下文
- innerContext = {
- VO: {},
- Scope: [VO, outerContext.VO, globalContext.VO]
- }
初始化作用域链属性 Scope 为 [VO].concat(inner.[[scope]]) 即 [VO, outerContext.VO, globalContext.VO].
10, 执行 innerContext 上下文
执行语句 console.log(a, b),VO 中没有变量 a, 往上查找到 outerContext.VO, 找到变量 a,VO 中没有变量 b, 依次往上查找到 globalContext.VO, 找到变量 b. 执行 console.log 函数, 这里同样涉及到 变量 console 的作用域链查找, console.log 函数的执行上下文切换, 不再赘述.
11,globalContext 执行完毕, 出栈, 程序结束
ECStack = []
在第 7 步中, outerContext 执行完毕后, 虽然其已出栈并在随后被垃圾回收机制回收, 但是可以看到 innerContext.Scope 仍有对 outerContext.VO 的引用. 当 outerContext 被回收后, outerContext.VO 并不会被回收, 如下图:
这就使得我们在执行 inner 函数时仍可以通过其作用域链访问到已执行完毕的 outer 函数中的变量, 这就是闭包.
通过执行上下文和作用域链相关知识, 我们引出了闭包的概念, 让我们继续.
在第 5 步中, 我们说到, 初始化 outerContext 的过程中, 同时确定了 inner 函数的作用域属性 [[scope]] 为 [outerContext.VO, globalContext.VO], 这其实是不准确的.
我们稍微改动下加上两句代码:
- function outer() {
- var b = 2
- var c = new Array(100000).join('*')
- var d = 3
- function inner() {
- console.log(a, b)
- }
- return inner
- }
- var a = 1
- var inner = outer()
- inner()
聪明的你会发现, 变量 c 和 d 在 inner 中并不会用到, 如果按照如上所述, 将 inner 函数的 [[scope]] 属性置为 [outerContext.VO, globalContext.VO], 那么变量 c (准确的说应该是变量 c 指向的那块内存, 下同)只能一直等到 inner 函数执行完毕后才会被销毁, 如果 inner 函数一直不执行的话, new Array(100000).join('*') 所占用的内存一直无法被释放.
那么, 你可能会想, 我们在确定 inner 函数 [[scope]] 属性的时候, 只引用 inner 函数体内用到的变量不就好了吗? 实际上, JS 引擎 和你一样聪明, 就是这么干的, 在 Chrome 调试工具下:
可以看到, 并没有对变量 c 的引用, 我们可以认为 inner 函数 [[scope]] 属性为:
- inner.[[scope]] = [
- Closure(outerContext.VO),
- globalContext.VO
- ]
这里, 我们用 Closure 这样一个函数来表示得到内部函数体中 (包括内部函数中的内部函数, 一直下去...) 引用外部函数变量的集合, 即闭包.
共享闭包
让我们继续前进的脚步, 把上面的代码再稍微改动下:
- function outer() {
- var b = 2
- var c = new Array(100000).join('*')
- var d = 3
- function log() {
- console.log(c)
- }
- function inner() {
- console.log(a, b)
- }
- log()
- return inner
- }
- var a = 1
- var inner = outer()
- inner()
这里, 我们只是加了一个 log 函数, 并将变量 c 打印出来. 对于 inner 函数来说, 并没有什么改变, 果真如此吗? 我们看下 Chrome 调试工具下作用域和闭包相关信息.
outer 函数执行之前:
outer 函数执行完成:
咦, 我们可以看到 inner 函数中的闭包中竟然包含了变量 c! 但是 inner 函数中并没有用到 c 啊, 你可能隐隐发现了什么, 是的, 我们在 log 函数中引用了变量 c, 这竟然会影响到 inner 函数的闭包.
在前文中, 我们说到确定 inner 函数 [[scope]] 属性时, 会通过 Closure 函数得到 inner 函数体内引用到的所有闭包变量集合, 那有多个内部函数呢?
其实, JS 引擎 会通过 Clousre 函数得到 outer 函数下所有内部函数体中用到的闭包变量集合 Closure(outerContext.VO) , 并且所有的内部函数的 [[scope]] 属性都引用这个共同的闭包, 所以:
- inner.[[scope]] = [
- Closure(outerContext.VO),
- globalContext.VO
- ]
- log.[[scope]] = [
- Closure(outerContext.VO),
- globalContext.VO
- ]
- Closure(outerContext.VO) = { b, c }
让我们来看看 log 函数的闭包信息, 同样也有变量 b:
这里, 你可能会有疑问, 变量 a 哪里去了, 其实变量 a 在 globalContext 下.
读到这里, 细心的你会发现, 这和文章开头给出的代码几乎一毛一样啊, 那究竟会带来什么问题呢, 我想你应该知道了: 内存泄露!
让我们回到文章开头的那段代码, 返回的 inner 函数中, 一直引用着 temp 变量, 在 inner 函数不执行的情况下, temp 变量一直无法被垃圾回收.
我们再稍微改下代码:
- function outer(count) {
- var temp = new Array(count)
- function log() {
- console.log(temp)
- }
- log()
- function inner() {
- var message = 'done'
- return function innermost() {
- console.log(message)
- }
- }
- return inner()
- }
- var o = {}
- for(var i = 0; i < 1000000; i++) {
- o["f"+i] = outer(i)
- }
这里, 我们在 inner 函数里面又包了一层, 那最终返回的 innermost 还有对 temp 变量的引用吗?
按照前面关于执行上下文相关内容的逻辑分析下去, 其实是有的. innermost 的 [[scope]] 属性如下:
- innermost.[[scope]] = [
- Closure(innerContext.VO): { message },
- Closure(outerContext.VO): { temp },
- globalContext
- ]
当然, 你可能会说, 只要 inner 函数执行完成后, 这些内存就会被回收掉. OK, 那我们再来看一个更经典的例子:
- var theThing = null;
- var replaceThing = function () {
- var originalThing = theThing;
- var unused = function () {
- if (originalThing)
- console.log("hi");
- };
- theThing = {
- longStr: new Array(1000000).join('*'),
- someMethod: function () {
- console.log(someMessage);
- }
- };
- };
- setInterval(replaceThing, 1000);
unused 函数引用了 originalThing , 由于共享闭包的特性, theThing.someMethod 函数的闭包中也包含了对 originalThing 的引用, 而 originalThing 是上一个 theThing, 也就是说下一个 theThing 引用者上一个 theThing, 形成了一个链. 并随着 setInterval 的执行, 这个链越来越长, 最终导致内存泄露, 如下:
如果把间隔时间改小点, 分分钟 out of memory.
这个例子来源于这里, 建议大家都点进去读一读(我记得之前有小伙伴翻译了这篇文章的, 一时找不到了, 有知道中文翻译链接的小伙伴在评论里贴一下哈).
- Real Local Variable
- vs Context Variable
Real Local Variable, 直译过来就是真正的局部变量, 在这里变量 d 就是 Real Local Variable, 在 C++ 层面, 它可以直接分配在栈上, 随着 inner 函数执行完毕的出栈操作而被立即回收掉, 不需要后面垃圾回收机制的干预.
Context Variable, 上下文环境变量或者称之为闭包变量, 在这里变量 b 就是 Context Variable, 在 C++ 层面, 它一定分配在堆上, 尽管这里它是一个基本类型.
那变量 c 呢, 你可以认为它是一个 Real Local Variable, 只是在栈上存的是指向这个 new Array() 的内存地址, 而 new Array() 的实际内容是存在堆上的.
内存分布如下:
通过上面的分析, 我们在很多文章中经常看到的 基本类型分布在栈上, 引用类型分布在堆上 这句话明显是错误的, 对于被闭包引用的变量, 不管其是什么类型, 肯定是分配在堆上的.
eval 与闭包
前文中已经提到, JS 引擎 会分析所有内部函数体中引用了哪些外部函数的变量, 但是对于 eval 的直接调用是无法分析的. 因为无法预料到 eval 中可能会访问那些变量, 所以会把外部函数中的所有变量都囊括进来.
- function outer() {
- var b = 2
- var c = new Array(100000).join('*')
- var d = 3
- function inner() {
- eval("console.log(1)")
- }
- return inner
- }
- var a = 1
- var inner = outer()
- inner()
JS 引擎 内心 OS 是这样的: eval 这家伙什么事情都干的出来, 你们 (局部变量) 统统不准走!
如果, 你在层层嵌套的函数下面来一个 eval, 那么 eval 所在函数的所有父级函数中的变量都无法被释放掉, 想想就可怕...
那对于 eval 的间接调用呢?
- function outer() {
- var b = 2
- var c = new Array(100000).join('*')
- var d = 3
- function inner() {
- (0, eval)("console.log(a)") // 输出 1
- }
- return inner
- }
- var a = 1
- var inner = outer()
- inner()
这时 JS 引擎 内心 OS 又是这样的: eval 是谁, 不认识, 你们 (局部变量) 都回家收衣服吧...
其实, 对于 eval 和 function 的组合还有各种姿势, 比如:
- function outer() {
- var b = 2
- var c = new Array(100000).join('*')
- var d = 3
- return eval("(function() { console.log(a) })")
- // return (0,eval)("(function() { console.log(a) })")
- // return (function(){ return eval("(function(){ console.log(a) })") })()
- // ...
- // 更多姿势留待各位自己去发掘和尝试, 逃...
- }
- var a = 1
- var inner = outer()
- inner()
到这里就写完了, 希望各位对闭包有一个新的认识和见解.
最后欢迎各路大佬们啪啪打脸...
来源: https://juejin.im/post/5c723d90f265da2dc0065bdb