还是补坑,前端基础的坑,哎,然后还有一些其他的事情。6月份更新本身并不多。
好了废话不多说,今天聊闭包。
闭包是JavaScript 中最基本也是最重要的概念之一。但是闭包又绝对不是一个单一的概念:它涉及作用域,作用域链,执行上下文,内存管理等多重知识点。

作用域
作用域其实就是一套规则:这个规则用于确定在特定场景下如何查找变量。任何语言都有作用域的概念,同一种语言在演进过程中也会不断完善作用域规则。例如:在JavaScript 中,ES6出现之前只有函数作用域和全局作用域之分。
函数作用域和全局作用域
函数作用域相信大家都很熟悉
function foo(){
var a = 'bar'
console.log(a)
}
foo()
执行 foo 函数时,变量 a 在函数 foo 作用域内,函数体内可以正常访问,并输出 bar。
而当:
var b = 'bar'
function foo(){
console.log(b)
}
foo()
执行这段代码时,foo 函数在自身函数作用域内并未查到b变量,但是它会继续往外扩大查找范围,因此可以在全局作用域中找到变量b,输出bar
但是我们在改动一波呢:
function bar() {
var b = 'bar'
}
function foo() {
console.log(b)
}
foo()
执行这段代码的时候, foo 和 bar 函数分别属于俩个彼此独立的函数作用域,foo 函数无法访问 bar 函数 中定义的变量 b,且其作用域链内(上层全局作用域中)也不存在相应的变量,因此报错:Uncaught ReferenceError: b is not defined。
总结:在JavaScript 执行一段函数时,遇到变量读取其值,这时候会"就近"现在函数内部找到该变量的声明或者赋值情况。这里涉及
"变量声明方式"以及"变量提升"的知识点,我们后面后涉及到。如果在函数内无法找到该变量,就要跳出函数作用域,到更上层的作用域去寻找,这里的"更上层作用域" 可能是一个函数作用域,例如:
function bar() {
var b = 'bar'
function foo() {
console.log(b)
}
foo()
}
bar()
在foo 函数执行时,对于变量b的声明或读值情况是在其上层函数 bar 作用域中获取的。
同时“更上层作用域”也可以顺着作用域范围向外扩散,一直找到全局作用域:
var b = 'bar'
function bar() {
function foo() {
console.log(b)
}
foo()
}
bar()
我们看到,变量作用域的查找是一个扩散过程,就像各个环节想扣的链条,逐次递进,这就是函数作用域链的由来。
块级作用域和暂时性死区
作用域概念不断演进,ES6 增加了 let 和 const 声明变量的块级作用域,使得 JavaScript 中作用域范围更加丰富。块级作用域,顾名思义,作用域范围限制在代码块中,这个概念在其他语言里也普遍存在。当然这些新特性的添加,也增加了一定的复杂度,带来了新的概念,比如暂时性死区。这里有必要稍作展开:说到暂时性死区,还需要从"变量提升"说起,参看一下代码:
function foo() {
console.log(bar)
var bar = 3
}
foo()
会输出: undefined, 原因是变量 bar 在函数内进行了提升。相当于:
function foo() {
var bar
console.log(bar)
bar = 3
}
foo()
会报错:Uncaught ReferenceError:bar is not defined。
我们知道使用let 或 const 声明变量,会针对这个变量形成一个封闭的块级作用域,在这个块级作用域当中,如果在声明变量前访问该变量,就会报 referenceError 错误; 如果在声明变量后访问,则正常获取变量值:
function foo(){
let bar = 3
console.log(bar)
}
foo()
正常输出 3。 因此在相应花括号形成的作用域中,存在一个“死区”,起始于函数开头,终止于相关变量声明的一行。在这个范围内无法访问 let 或 const 声明的变量。 这个“死区”的专业的名称为:TDZ( Temporal Dead Zone ),相关语言规范的介绍读者 ECMA2015
http://www.ecma-international.org/ecma-262/6.0/#sec-let-and-const-declarations
参考下图加深理解:

除了自身作用域内的 foo3 以外,bar2 函数可以访问 foo2、 foo1; 但是 bar1 函数却无法访问 bar2 函数内定义的 foo3

在重复一遍,bar1函数 let foo3 = 'foo3' 代码执行前,为“死区”,访问变量foo3会报错;该行后即可正常访问。
注意我在上图中勾出的暂时性死区区域,这里介绍一个比较“极端”的情况:函数的参数默认值设置也会受到TDZ的影响:
function foo(arg1 = arg2, arg2){
console.log(`${arg1} ${arg2}`)
}
在上面foo函数中,如果第一个参数没有传,将会使用第二个参数作为第一个实参值。调用:
function foo(arg1 = arg2, arg2){
console.log(`${arg1} ${arg2}`)
}
foo('arg1', 'arg2') // 返回: arg1 arg2
返回内容正常,但是当第一个参数缺省时,执行 arg1 = arg2 会当作暂时性死区处理:
function foo(arg1 = arg2, arg2){
console.log(`${arg1} ${arg2}`)
}
foo(undefined, 'arg2') // Uncaught ReferenceError: arg2 is not defined
因为除了块级作用域外,函数参数默认值也受到TDZ影响
抖机灵的写法,康康下面代码会输出什么?
function foo(arg1 = arg2, arg2) {
console.log(`${arg1} ${arg2}`)
}
foo(null, 'arg2')
输出:null arg2, 这就涉及到 undefined 和 null 的区别了。在执行foo(null, 'arg2')时,不会认为"函数第一个参数缺省",而会直接接受null 作为第一个参数值。
这个知识点已经不是本课的主题了,具体 undefined 和 null 的区别我们挖坑,(这里简单的聊一句,null是一个对象,但是undefined确实是一个基本类型)
既然偏题了,那就在偏一点
function foo(arg1) {
let arg1
}
foo('arg1')
猜猜看会输出什么?
浏览器会报错:Uncaught SyntaxError: Identifier 'arg1' has already been declared。这同样和TDZ没有关系,而是因为函数参数名会出现其“执行上下文/作用域”当中。
在函数的第一行,便已经声明了arg1这个变量,函数体再用let声明,会报错(这里是let声明变量的特点,ES6基础内容,不再展开)
上文我们提到了“执行上下文”,我们再看看它究竟是什么。
执行上下文和调用栈
有多人可能无法准确的定义执行上下文和调用栈,尤其是苦逼与忙于业务,天天调用接口的前端同学们,没有贬低的意思,但是,年轻体力好能加班,老了呢。这个哲理的问题,咱们不在进一步的做探讨。网上卖惨,散布焦虑的文章一大把,也轮不到我来说。这个本身就是一个哲学问题看大家怎么看吧。在换个角度,我手里有1000W,你管我天天调不调接口,我有钱开心就完了。焦虑的本身还是源自于没钱。没钱还没有什么进步,可怕的是还觉的自己技术还不错,比较写了一大堆业务。好了扯远了,这个哲学问题咱们不多讨论了。回归主题,从我们接触JavaScript开始,这俩个概念便常伴随我们左右。我们写出的每一行代码,每一个函数都和它们息息相关,但是它们确实是隐形的,藏着代码背后,出现在JavaScript引擎里。
执行上下文就是当前代码的执行环境/作用域,和前文介绍的作用域链相辅相成,但又是完全不同的两个概念。直观上看,执行上下文包含了作用域链,同时它们又像是一条河的上下游:有了作用域链,才有了执行上下文的一部分。
代码执行的俩个阶段
理解这俩个概念,要从JavaScript 代码的执行过程说起,这在平时的开发中并不会涉及,但对于我们理解JavaScript语言和运行机制非常重要,
JavaScript 执行主要分为俩个阶段:
代码编译阶段
代码执行阶段
预编译阶段是前置阶段,这个时候由编译器将JavaScript代码编译成可执行的代码,注意,这里的编译
这里的预编译和传统的编译并不一样,传统的编译非常复杂,涉及分词、解析、代码生成等过程 。这里的预编译是 JavaScript 中独特的概念,虽然 JavaScript 是解释型语言,编译一行,执行一行。但是在代码执行前,JavaScript 引擎确实会做一些“预先准备工作”。
执行阶段主要任务是执行代码,执行上下文在这个阶段全部创建完成。
在通过语法分析,确认语法无误之后,JavaScript 代码在预编译阶段对变量的内存空间进行分配,我们熟悉的变量提升过程便是在此阶段完成的。如下代码:
经过预编译过程,我们应该注意三点:
预编译阶段进行变量声明;
预编译阶段变量声明进行提升,但是值为 undefined;
预编译阶段所有非表达式的函数声明进行提升。
那么请看如下代码:
function bar() {
console.log('bar1')
}
var bar = function () {
console.log('bar2')
}
bar()
输出:bar2,我们调换顺序:
var bar = function () {
console.log('bar2')
}
function bar() {
console.log('bar1')
}
bar()
仍然输出是:bar2,因为在预编译阶段变量bar进行声明,但是不会赋值;函数bar则进行创建并提升。在执行代码时,变量bar才进行(表达式)赋值,值内容是函数体console.log('bar2')的函数,输出结果bar2。
请思考下面这道题:
foo(10)
function foo (num) {
console.log(foo)
foo = num;
console.log(foo)
var foo
}
console.log(foo)
foo = 1
console.log(foo)
输出结果:
undefined
10
ƒ foo (num) {
console.log(foo)
foo = num
console.log(foo)
var foo
}
1
在执行foo(10)执行时,函数体内进行变量提升后,函数体内第一行输出undefined,函数体内第三行输出foo。接着运行代码,到了整体第8行,console.log(foo) 输出 foo 函数内容(因为foo函数内的 foo = num,将num赋值给的是函数作用域内的foo变量 )
结论 作用域在预编译阶段确定,但是作用域链是执行上下文的创建阶段完全生成的。因为函数在调用时,才会开始创建对应的执行上下文。执行上下文包括了:变量对象、作用域链以及 this 的指向。
如图所示:

代码执行的整个过程说起来就像一条生产流水线。第一道工序是在预编译阶段创建变量对象(Variable Object), 此时只是创建,而未赋值。到了下一道工序执行阶段,变量对象转为激活对象(Variable Object), 即完成 VO --> AO. 此时,作用域链也将被确定,它由当前执行环境的变量对象和外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,即如果当前作用域中未找到变量,则继续向上查找直到全局作用域。
这样的工序在流水线上串成一个整体,这便是JavaScript 引擎执行机制的最基本道理。
了解了上面的内容,函数调用栈便很好理解了。我们在执行一个函数的时,如果这个函数又调用了另外一个函数,而这个“另外的一个函数”也调用了“另外一个函数”,便形成了一系列的调用栈。如下代码:
function foo1() {
foo2()
}
function foo2() {
foo3()
}
function foo3() {
foo4()
}
function foo4() {
console.log('foo4')
}
foo1()
调用关系 foo1 → foo2 → foo3 → foo4
这个过程是 foo1 先入栈,紧接着foo1 调用 foo2, foo2 入栈,以此类推,foo3、foo4,直到 foo4 执行完 ---- foo4先出栈,foo3再出栈,接着是 foo2 出栈,最后是 foo1 出栈。这个过程“先进后出”(“后进先出”),因此称为调用栈。
我们故意将 foo4 中的代码写错:
function foo1() {
foo2()
}
function foo2() {
foo3()
}
function foo3() {
foo4()
}
function foo4() {
console.lg('foo4')
}
foo1()
会得到一个新的东西,得到错误提示如图:

或者在Chrome 中执行代码,打断点得到:

不管哪种方式,我们从中都可以借助 JavaScript 引擎,清晰地看到错误堆栈信息,也就是函数调用栈关系。
正常的来讲,在函数执行完毕并出栈时,函数内部变量在下一个垃圾回收节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数可以访问该变量,该变量在预编译阶段进行创建,在执行阶段进行激活,在函数执行完毕后,相关上下文被销毁。
闭包
介绍了这么多前置概念,终于到了闭包环节
闭包并不是 JavaScript 特有的概念,社区上对于闭包的定义也并不完全相同。虽然本质上表达的意思相似,但是晦涩且多样的定义仍然给初学者带来了困惑。
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。
我们看一个简单的代码示例:
function numGenerator() {
let num = 1
num++
return () => {
console.log(num)
}
}
var getNum = numGenerator()
getNum()
这个简单的闭包例子中, numGenerator 创建了一个变量num,返回打印num值的匿名函数,这个函数引用了变量num,使得外部可以通过调用 getNum 方法访问到变量 num,因此在 numGenerator 执行完毕后,即相关调用栈出栈后,变量num不会改变,仍然有机会被外界访问。

对比前述内容,我们知道正常情况下外界是无法访问函数内部变量的,函数执行完了之后,上下文即被销毁。但是在(外层)函数中,如果我们返回了另一个函数,且这个返回的函数使用了(外层)函数内的变量,外界因而便能够通过这个返回的函数获取原(外层)函数内部的变量值。这就是闭包的基本原理。
因此,直观上来看,闭包这个概念为 JavaScript 中访问函数内变量提供了途径和便利。这样做的好处很多,比如我们可以利用闭包实现“模块化”;再比如,翻看 Redux 源码的中间件实现机制,也会发现(函数式理念)大量运用了闭包。
内存管理
内存管理是计算机科学中的概念。不论是什么程序语言,内存管理都是对内存生命周期的管理,而内存的生命周期无外乎:
分配内存空间
读写内存
释放内存空间
我们来用代码举例:
var foo = 'bar' // 在堆内存中给变量分配空间
alert(foo) // 使用内存
foo = null // 释放内存空间
内存管理基本概念
我们知道内存空间可以分为堆空间 和 栈空间,其中
栈空间:由操作系统自动释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。
堆空间:一般由开发者分配释放,这部分空间就要考虑垃圾回收的问题。
在JavaScript中,数据类型包括(未包含ES Next 新数据类型):
基本类型数据,如 Undefined、Null、Number、Boolean、String 等
引用类型,如 Object、Array、 Function 等
一般情况下,基本数据类型保存在栈内存当中,引用类型保存在堆内存当中。如下代码:
var a = 11
var b = 10
var c = [1, 2, 3]
var d = { e: 20 }
对应内存分配图示:

对于分配内存和读写内存的行为所有语言都较为一致,但释放内存空间在不同语言之间有差异。
例如,JavaScript 依赖宿主浏览器的垃圾回收机制,一般情况下不用程序员操心。但这并不表示万事大吉,某些情况下依然会出现内存泄漏现象。
内存泄漏是指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。这是一个非常“玄学”的概念,因为内存空间是否还在使用,某种程度上是不可判定问题,或者判定成本很高。内存泄漏危害却非常直观:它会直接导致程序运行缓慢,甚至崩溃。
内存泄露场景举例
我们来看几个典型引起内存泄露的例子:
var element = document.getElementById("element")
element.mark = "marked"
// 移除 element 节点
function remove() {
element.parentNode.removeChild(element)
}
上面的代码,我们只是把 id 为 element 的节点移除,但是变量 element 依然存在,该节点占有的内存无法被释放。

我们需要在remove方法中添加:element = null, 这样更加稳妥。
在看下面的这个示例:
var element = document.getElementById('element')
element.innerHTML = '<button id="button">点击</button>'
var button = document.getElementById('button')
button.addEventListener('click', function() {
// ...
})
element.innerHTML = ''
这段代码执行后,因为 element.innerHTML = '', button 元素已经从DOM 中移除了,但是由于其事件处理句柄还在,所以依然无法被垃圾回收。我们还需要增加 removeEventListener,防止内存泄露。
另一个示例
function foo() {
var name = 'lucas'
window.setInterval(function() {
console.log(name)
}, 1000)
}
foo()
这段代码由于 window.setInterval 的存在,导致 name 内存空间始终无法被释放,如果不是业务要求的话,一定要记得在合适的时机使用 clearInterval 进行清理。
浏览器垃圾回收
当然,除了开发者主动保证以外,大部分的场景浏览器都会依靠:
标记清除
引用计数
关于内存泄漏和垃圾回收,要在实战中分析,不能完全停留在理论层面,毕竟如今浏览器千变万化且一直在演进当中。 从以上示例我们可以看出,借助闭包来绑定数据变量,可以保护这些数据变量的内存块在闭包存活时,始终不被垃圾回收机制回收。因此,闭包使用不当,极可能引发内存泄漏,需要格外注意。以下代码:
function foo() {
let value = 123
function bar() { alert(value) }
return bar
}
let bar = foo()
这种情况下,变量 value 将会保存在内存中,如果加上:
bar = null
这样的话,随着 bar 不再被引用,value 也会被清除。
结合浏览器引擎的优化情况,我们对上述代码进行改动:
function foo() {
let value = Math.random()
function bar() {
debugger
}
return bar
}
let bar = foo()
bar()
在 Chrome 浏览器 V8 最新引擎中,执行上述代码。我们在函数 bar 中打断点,会发现 value 没有被引用,如下图:

而我们在 bar 函数中加入对 value 的引用:
function foo() {
let value = Math.random()
function bar() {
console.log(value)
debugger
}
return bar
}
let bar = foo()
bar()

下面我们来看一个实战,借助 Chrome devtool,排查发现内存泄漏的场景。
代码:
var array = []
function createNodes() {
let div
let i = 100
let frag = document.createDocumentFragment()
for (; i > 0; i--) {
div = document.createElement("div")
div.appendChild(document.createTextNode(i))
frag.appendChild(div)
}
document.body.appendChild(frag)
}
function badCode() {
array.push([...Array(100000).keys()])
createNodes()
setTimeout(badCode, 1000)
}
badCode()
我们递归调用 badCode,这个函数每次向 array 数组中写入新的由 100000 项从 0 到 1 组成的新数组,在 badCode 函数使用完全局变量 array 之后,并没有手动释放内存,垃圾回收不会处理 array,导致内存泄漏;同时,badCode 函数调用 createNodes 函数,每 1s 创建 100 个 div 节点。
这时候,打开 Chrome devtool,我们选中 performance 标签,拍下快照得到:

在看一个没有泄露的

这个图是没有内存泄露的我对内存进行了清理

由此可以发现,JS heap(蓝线)和 Nodes(绿线)线,随着时间线一直在上升,并没有被垃圾回收。因此,可以判定存在较大的内存泄漏风险。如果我们不知道有问题的代码位置,具体如何找出风险点,那需要在 Chrome memory 标签中,对 JS heap 中每一项,尤其是 size 较大的前几项展开调查。如图:

后记:原版是大佬的收费课程,我更新出来呢反正也没人看,私人博客。所以就不贴链接了,因为贴了你们也链接不进去。毕竟有人家大佬版权在。有版权问题可以联系我。。。
文章采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接。