之前我们曾经介绍过了 作用域与执行上下文栈 的相关内容,这一章我们就来看看与其联系十分密切的闭包
什么是闭包
所谓闭包,官方的解释是
是指拥有多个变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分
看起来有点绕,换个说法,简而言之,闭包就是
- 『闭包就是函数的局部变量集合,只是这些局部变量在函数返回后会继续存在』
- 闭包就是就是函数的堆栈在函数返回后并不释放,我们也可以理解为这些函数堆栈并不在栈上分配而是在堆上分配
- 通常而言,如果在一个函数内返回了另外一个函数,这种情况下就会产生闭包
做为局部变量都可以被函数内的代码访问,这个和静态语言是没有差别,闭包的差别在于局部变变量可以在函数执行结束后仍然被函数外的代码访问,这意味着函数必须返回一个指向闭包的引用,或将这个引用赋值给某个外部变量,才能保证闭包中局部变量被外部代码访问
闭包产生的原因
在本质上来说,闭包就是将函数内部和函数外部连接起来的一座桥梁,闭包可以用在许多地方,它的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中,在之前 作用域与执行上下文栈 一文当中我们已经介绍过执行上下文,这里我们再来简单的复习一下
ECMAscript 的脚本的函数运行时,每个函数关联都有一个执行上下文场景(Execution Context) ,这个执行上下文场景中包含三个部分
- 文法环境(
The LexicalEnvironment) - 变量环境(
The VariableEnvironment) this绑定
我们可以将文法环境想象成一个对象,该对象包含了两个重要组件,环境记录(Enviroment Recode),和外部引用(指针),环境记录包含了函数内部声明的局部变量和参数变量,外部引用指向了外部函数对象的上下文执行场景,全局的上下文场景中此引用值为 null,这样的数据结构就构成了一个单向的链表,每个引用都指向外层的上下文场景
而这也就是『闭包产生的原因』,我们都知道,在 ES5 中只存在两种作用域『全局作用域』和『函数作用域』,当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是『作用域链』,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条,比如
1 | var a = 1 |
在这段代码中,f1 的作用域指向有全局作用域(window)和它本身,而 f2 的作用域指向全局作用域(window)、f1 和它本身,而且作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错,就这么简单一件事情
而闭包产生的本质就是,当前环境中存在指向父级作用域的引用
1 | function f1() { |
这里 x 会拿到父级作用域中的变量,输出 2,因为在当前环境中,含有对 f2 的引用,f2 恰恰引用了 window、f1 和 f2 的作用域,因此 f2 可以访问到 f1 的作用域的变量
看到这里我们可能会有一些疑问,那是不是只有返回函数才算是产生了闭包呢?让我们回到闭包的本质,我们需要做的只是让父级作用域的引用存在即可,因此我们可以调整上面的示例
1 | var f3 |
这里我们让 f1 执行,给 f3 赋值后,等于说现在 f3 拥有了 window、f1 和 f3 本身这几个作用域的访问权限,还是自底向上查找,最近是在 f1 中找到了 a,因此输出 2
在这里是外面的变量 f3 还存在着父级作用域的引用,因此产生了闭包,虽然形式变了,但是本质没有改变
闭包中 this 的指向
浏览器中的顶级域,其实就是 window 对象,所谓的闭包中的 this 指向,通俗点说就是,谁调用这个函数(即 xx.fn() 中的 xx),谁就是这个函数(fn)的 this,JavaScript 中的 this 指向函数调用时的上下文,可以想像成每个函数在被调用时,动态注入了一个 this 对象,所以在非严格模式下内部的 this 指向 window 对象,严格模式下应为 undefined,其实,引入 this 的初衷就是想在原型继承的情况下,得到函数的调用者,如下实例
1 | var obj = { |
如果函数没有指明调用者呢,那就让 this 指向全局对象吧
1 | var global = this |
不过针对与下面这种情况,如果想让 this 的指向指回去的话,可以使用 bind 方法
1 | var g = obj.method.bind(obj) |
再看一个实例
1 | var name = 'window' |
当完成 person.say() 之后,这个函数就调用结束了,在这个函数调用结束之前 this 是指向 preson,但是在调用匿名函数的时候,this 就指向了 window,所以得到的结果是 window,针对于以上这种情况,我们可以把函数中的 this 用一个临时变量保存起来,就可以得到我们想要的结果
1 | var name = 'window' |
此时 that 就是指向 person 的,所以调用 that.name 就是 person 中的 name
闭包的表现形式
我们下面来简单的看看,在真实的场景当中,有哪些地方可以体现闭包的存在,主要有以下几种情况
首先是函数内部再次返回一个函数,这个也就是我们上面介绍的示例,就不过多提及了
作为函数参数传递,比如下面这个示例
1 | var a = 1 |
- 在定时器、事件监听、
Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包,比如下面这个示例,其中的闭包保存的仅仅是window和当前作用域
1 | // 定时器 |
IIFE(立即执行函数表达式)创建闭包,保存了全局作用域window和当前函数的作用域,因此可以全局的变量
1 | var a = 2 |
柯里化
我们可以先从一道面试题看起,如下
1 | var result = add(1)(2)(3) |
当然,没有什么特殊要求的话,很好实现,如下
1 | // 一般形式 |
我们虽然实现了,但是可以发现,它的通用性并不是很好,比如我们像 add(1)(2)(3)(4) 这样传递四个参数,又或者现在需求有变化,变成了求 multiple(1)(2)(3) 结果的话,我们可能就需要去调整函数内部的结构来适应需求,这样一来上面这个方法的通用性就不太行了,所以我们下面将会设计一个更为通用的方法来实现它,这也就是所谓的『柯里化』的应用了
柯里化通常也称部分求值,其要求被传入函数所有参数都被明确的定义,因此当使用部分参数调用时,他会返回一个新的函数,在真正调用之前等待外部提供其余的参数,可以简单的理解为,在所有参数被提供之前,挂起或延迟函数的执行,我们就按照这个思路来实现一个版本
1 | var curry = function (fn) { |
下面我们来定义一个 add 函数来测试一下
1 | var add = function () { |
也可以使用下面这种方式来进行调用
1 | sum(1) |
另外,计算 multiple 也是可以的
1 | var multiple = function (a, b, c) { |
但是我们可以发现,有一点不算太完美的地方,就是我们每次需要空白调用的时候才会返回最后的计算结果,那么有没有可以自动计算出结果的方式呢?方法是有的,如下
1 | function curry(fn, args) { |
我们可以来试一下上面的这个方法
1 | var multiple = function (a, b, c) { |
最后我们再来看一个在网上比较常见的使用 ES6 的实现,十分简洁
1 | const curry = fn => (judge = (...args) => |
反柯里化
下面我们再来简单的看一下反柯里化,从名字就可以得知,它就是柯里化操作的反向操作,类似于下面这样
1 | obj.func(arg1, arg2) => func(obj, arg1, arg2) |
实现如下
1 | Function.prototype.uncurrying = function () { |
偏函数
既然提到了柯里化,这里就顺带着介绍一下偏函数,那么什么是偏函数呢?偏函数,即固定函数的某一个或几个参数,返回一个新的函数来接收剩下的变量参数,比如下面这个例子
1 | function mul(a, b) { |
以上就是偏函数应用,我们创造一个新函数,让现有的一些参数值固定,从而使函数更加灵活,我们来看一下与柯里化之间的区别
- 『柯里化』是将一个多参数函数转换成多个单参数函数,也就是将一个
n元函数转换成n个一元函数 - 『偏函数』则是固定一个函数的一个或者多个参数,也就是将一个
n元函数转换成一个n - x元函数
那么问题来了,按照上面的示例所示,要实现偏函数应用,我们直接使用 bind 不就好了吗,但是了解过 bind 的原理就应该会知道,bind 是会改变 this 的指向的,所以,我们就来实现一个通用的偏函数,这里需要注意,this 的指向是不改变的
1 | // ES5 写法 |
我们来稍微测试一下
1 | function mul(a) { |
一道经典的闭包面试题
题目是这样的,要求为示例当中的三个 li 绑定点击事件,并输出对应的 index
1 | <ul> |
第一印象就是直接获取到这三个元素,然后每个元素绑定一个点击事件,如下
1 | var list = document.querySelector('li') |
很明显这样写并没有实现我们想要的结果,不管点击哪一个 li 都只打印了一个结果就是 3,那么这是什么原因呢?onclick 是一个事件,这个事件委托了并没有去触发,只有触发的时候才会调用回调函数,代码自上而下运行这时候 i 的值已经变为 3 了,所以每个点击事件的回调结果都是 3
我们来稍微调整一下,使用一个匿名函数将其包裹一下(也有其他解决方式,比如 let 等,但是我们这里主要介绍闭包的形式),并且在每次循环的时候将当前 i 的值传递给匿名函数
1 | var list = document.querySelector('li') |
这样的话点击不同的 li 就会打印对应的 index 值,简单来说就是改变 i 的作用域,保留它的值,因为之前的代码当中,i 的作用域是全局的,所以打印的结果都是 3,现在是作为实参传递到匿名函数当中,并调用,就变成形参写传递到了事件当中,这样就改变掉了其作用域,也就是将原来有的值保留了下来,所以结果就是打印对应的 index 值
上面的这种解决方式,也是之前一种比较常见的方式,但是问题来了,虽然可以解决这样的问题,但是它内部的原理究竟是什么样子的呢,为什么会形成这样的结果呢?这里就要用到我们之前介绍过的 执行上下文栈和变量对象 的相关知识了
我们将上面的例子稍微简化调整一下,让我们从另一个方向来看看它在运行过程中到底发生了什么,简化后的示例如下
1 | var data = [] |
原理都是一样的,结果在上面我们已经知晓了,都是 3,这是因为当执行到 data[0] 函数之前,此时全局上下文的 VO 为
1 | globalContext = { |
当执行 data[0] 函数的时候,data[0] 函数的作用域链为
1 | data[0]Context = { |
data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,此时的 i 为 3,所以打印的结果就是 3,data[1] 和 data[2] 是一样的道理,下面我们将其修改为闭包再来看看
1 | var data = [] |
当执行到 data[0] 函数之前,此时全局上下文的 VO 为
1 | globalContext = { |
跟没改之前是一模一样的,但是当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变
1 | data[0]Context = { |
此时匿名函数执行上下文的 AO 为
1 | 匿名函数Context = { |
data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会找 i 为 0,但是因为找到了,所以就不会再往 globalContext.VO 当中进行查找了,即使 globalContext.VO 也有 i 的值(值为 3),所以打印的结果就是 0,同理,data[1] 和 data[2] 也是一样的
闭包的实例
前面我们大致了解了 JavaScript 中的闭包是什么,闭包在 JavaScript 是怎么实现的,下面我们来看一些例子来更加深入的理解闭包,先来看五个摘自 JavaScript Closures for Dummies 的案例
实例一:闭包中局部变量是引用而非拷贝
1 | function say667() { |
因此执行结果应该弹出的 667 而非 666
实例二:多个函数绑定同一个闭包,因为他们定义在同一个函数内
1 | function setupSomeGlobals() { |
输出的结果依次喂 666,667,12
实例三:当在一个循环中赋值函数时,这些函数将绑定同样的闭包
1 | function buildList(list) { |
因为这三个函数绑定了同一个闭包,而且 item 的值为最后计算的结果,所以会输出三次 item3 undefined
实例四:外部函数所有局部变量都在闭包内,即使这个变量声明在内部函数定义之后
1 | function sayAlice() { |
执行结果是弹出 'Hello Alice' 的窗口,即使局部变量声明在函数 sayAlert 之后,局部变量仍然可以被访问到
实例五:每次函数调用的时候创建一个新的闭包
1 | function newClosure(someNum, someRef) { |
下面我们再来看看一些在平时开发过程中遇到的坑
实例六:闭包引用的局部变量,不会自动清除
1 | function f1() { |
在上述代码中,result 实际上就是闭包 f2 函数,它一共运行了两次,第一次的值是 999,第二次的值是 1000,这证明了,函数 f1 中的局部变量 n 一直保存在内存中,并没有在 f1 调用后被自动清除
原因在于 f1 是 f2 的父函数,而 f2 被赋给了一个全局变量,这导致 f2 始终在内存中,而 f2 的存在依赖于 f1 ,因此 f1 也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage-collection)回收
这段代码中另一个值得注意的地方,就是 nAdd = function () { n += 1 } 这一行,首先在 nAdd 前面没有使用 var 关键字,因此 nAdd 是一个全局变量,而不是局部变量,其次,nAdd 的值是一个 『匿名函数』(anonymous-function),而这个匿名函数本身也是一个闭包,所以 nAdd 相当于是一个 setter,可以在函数外部对函数内部的局部变量进行操作
实例七:闭包中的 this
1 | // 第一种情况 |
- 第一个打印结果为
The window,因为第一个this为全局对象,所以alert处理的name为The window - 第二个打印结果为
My Object,因为第二个that为Object对象,所以alert处理的name为My object,因为在调用前用that保存了Object自己的this,所以在闭包内可以调用
实例八:闭包中的作用域
1 | // 第一种情况 |
在第一种情况当中,不管执行多少次,输出当值都为 16,因为 bar 能访问 foo 的参数 x,也能访问 foo 的变量 tmp,但这还不是闭包,只有当你 return 的是内部 function 时,就是一个闭包(即这时才会产生一个闭包)
关于第二种情况,虽然 bar 不直接处于 foo 的内部作用域,但 bar 还是能访问 x 和 tmp,但是由于 tmp 仍存在于 bar 闭包的内部,所以它还是会自加 1,而且你每次调用 bar 时它都会自加 1
上面的 x 是一个字面值(值传递),和 JavaScript 里其他的字面值一样,当调用 foo 时,实参 x 的值被复制了一份,复制的那一份作为了 foo 的参数 x,那么问题来了,JavaScript 里处理 Object 时是用到引用传递的,那么,你调用 foo 时传递一个 Object,foo 函数 return 的闭包也会引用最初那个 Object,也就有了下面的第三种情况
1 | // 第三种情况 |
不出我们意料,每次运行 bar(10),x.memb 都会自加 1,但需要注意的是 x 每次都指向同一个 Object,运行两次 bar(10) 后,age.memb 会变成 2,这里还有一个不用 return 关键字的闭包例子
1 | function closureExample(objID, text, timedelay) { |
注意,外部函数不是必需的
通过访问外部变量,一个闭包可以维持(keep alive)这些变量,在内部函数和外部函数的例子中,外部函数可以创建局部变量,并且最终退出,但是,如果任何一个或多个内部函数在它退出后却没有退出,那么内部函数就维持了外部函数的局部数据,闭包经常用于创建含有隐藏数据的函数(但并不总是这样),看下面这段代码
1 | var db = (function () { |
从上面的示例我们可以发现,我们不可能访问 data 这个对象本身,但是我们可以设置它的成员
实例九:下面两个函数有什么不同
1 | // 示例一 |
两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?答案就是执行上下文栈的变化不一样,让我们分别来模拟两段代码的执行过程
第一段代码
1 | ECStack.push(<checkscope> functionContext) |
第二段代码
1 | ECStack.push(<checkscope> functionContext) |
关于两者更详细的不同,可以见 一道面试题引发的思考