JavaScript基础之闭包


闭包

定义

MDN定义:

闭包是指那些能够访问自由变量的函数。

自由变量:

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的的变量。

《你不知道的JavaScript》里的定义:

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

解释

观察下面的代码:

var a = 1
function foo() {
    console.log(a)
}

foo() // 1

这是闭包吗?

按照我们原来的理解,这不就是作用域的查找规则嘛!我们根据对定义的理解,foo函数访问了a变量,但是a变量是自由变量,于是构成闭包。换一种说法,foo函数记住了全局作用域并进行了访问,于是产生了闭包。

从上面的代码很难理解闭包,因为我们很容易通过作用域的查找规则去理解它。

观察以下代码:

function foo() {
    var a = 1
    function bar() {
        console.log(a)
    }
    bar()
}

foo() // 1

这个代码是不是稍微容易理解了一点点,但它本质上和上面的代码相同,还是不容易观察到闭包。

于是我们再改一下:

function foo() {
    var a = 1
    function bar() {
        console.log(a)
    }
    return bar
}

var baz = foo()
baz() // 1

这次的代码要认真分析一下了:

  • foo函数里声明了一个变量a并赋值为1和一个函数barbar函数输出变量a,最后foo函数返回bar函数
  • 在全局中声明了一个变量a并赋值为2和另一个变量baz并将foo函数的返回值赋给它,然后执行baz函数

我们惊讶地发现baz函数竟然输出了foo函数作用域内的变量a,按照我们对作用域和执行上下文的理解,foo函数执行完毕后不是应该销毁其作用域的吗,此时我们观察到了闭包。

bar函数访问了自由变量a,构成闭包。这个定义感觉和上面两个例子没什么区别,太笼统了,我们用《你不知道的JavaScript》里的定义去理解:bar函数记住并访问了它所在的词法作用域foo函数作用域,即使bar函数在全局作用域执行(实际上baz变量引用了bar函数),这时产生了闭包

或许我们可以换一种形式观察闭包:

function foo() {
    var a = 1
    function bar() {
        console.log(a)
    }
    baz(bar)
}

function baz(fn) {
    fn()
}

foo() // 1

或者再换一种形式:

var fn
function foo() {
    var a = 1
    function bar() {
        console.log(a)
    }
    fn = bar
}

function baz() {
    fn()
}

foo()
baz() // 1

观察上面两例代码,我们可以再次通过作用域去理解闭包:

无论通过何种手段将内部函数传递到所在词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

关于闭包,冴羽大大在它的深入系列之闭包中提到了闭包可以从两个角度定义:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2. 在代码中引用了自由变量

因此,MDN对于闭包的定义是如此的笼统以至于难以观察到。

如果你还是对MDN的定义耿耿于怀,那么我推荐你去看看这篇文章:探索闭包(原文:a closure)

身影

回调函数

function wait(message) {
    setTimeout(function timer() {
        console.log(message)
    }, 1000)
}

wait('Hello, closure!')

将一个内部函数(timer)传递给setTimeou(..)timer具有涵盖wait(..)作用域的闭包,因此还保有对变量message的引用。

无论何时何地,如果将函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。

在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其它的异步(或同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

IIFE——立即执行函数表达式

var a = 1
(function () {
    console.log(a) // 1
})()

尽管IIFE本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具。

循环

观察下面代码:

var arr = []
for(var i = 0; i < 5; i++) {
    arr[i] = function () {
        console.log(i)
    }
}

for(var j = 0; j < 5; j++) {
    arr[j]()
}

上面代码会输出什么?

我们想要得到是5个不同的函数,每个函数分别输出0、1、2、3、4,然而结果却是输出5个5,我们分析造成这个结果的原因:

  • 首先是输出的5是哪来的,我们可以在全局中console.log(i),发现结果为5,由此知道了5是循环结束后i的值,我们产生疑问:为什么在循环过程中i的值没有被保存下来呢?
  • 实际上,我们陷入了一个误区:我们试图假设在循环中每个迭代在运行时都会给自己”捕获“一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的5个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i
  • 因此所有函数都是共享一个i的引用,而在函数调用时,i的值已经是5了,所以会输出5个5

怎么解决这个问题呢?我们需要使用闭包,在循环过程中每个迭代都需要一个闭包作用域。

我们使用IIFE来尝试一下这个例子:

var arr = []
for(var i = 0; i < 5; i++) {
    arr[i] = (function (j) {
        console.log(j)
    })(i)
}

上面的代码中,循环内部使用IIFE会为每个迭代都生成一个新的作用域,使得每个函数可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

块作用域也能解决这个问题,但和闭包关系不大,此处略去。

应用

单例模式

单例模式是一种常见的设计模式,它保证了一个类只有一个实例。实现方法一般是先判断实例是否存在,如果存在就直接返回,否则就创建了再返回。单例模式的好处就是避免了重复实例化带来的内存开销.

function Singleton(){
  this.data = 'singleton'
}

Singleton.getInstance = (function () {
  var instance

  return function(){
    if (instance) {
      return instance
    } else {
      instance = new Singleton()
      return instance
    }
  }
})();

var sa = Singleton.getInstance()
var sb = Singleton.getInstance()
console.log(sa === sb); // true
console.log(sa.data); // 'singleton'

模拟私有属性

let fn = (function () {
    let privateCounter = 0
    function changeBy(val) {
        privateCounter += val
    }
    return {
        increment() {
            changeBy(1)
        },
        decrement() {
            changeBy(-1)
        },
        getValue() {
            console.log(privateCounter)
        }
    }
})()

fn.getValue() // 0

fn.increment()

fn.getValue() // 1

fn.decrement()
fn.decrement()
fn.decrement()

fn.getValue() // -2

在这个例子中,fn函数返回了3个闭包方法,除了这3个方法能够访问privateCounter变量和changeBy函数外,你无法通过其他手段操作它们。

函数柯里化

柯里化(currying),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

  • 工厂函数
function makeAdder(x) {
    return function (y) {
        console.log(x + y)
    }
}

var a = makeAdder(5)
var b = makeAdder(10)
a(2) // 7
b(2) // 12

在这个例子中,我们利用了闭包自带执行环境的特性(即使外层作用域已销毁),仅仅使用一个形参完成了两个形参求和的操作。

  • bind方法的实现
Function.prototype.myBind = function(context = window) {
    if (typeof this !== 'function') throw new Error('Error')
    let selfFunc = this
    let args = [...arguments].slice(1)

    return function F () {
        // 因为返回了一个函数,可以 new F(),所以需要判断
        if (this instanceof F) {
            return new selfFunc(...args, arguments)
        } else  {
            // bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以需要将两边的参数拼接起来
            return selfFunc.apply(context, args.concat(arguments))
        }
    }
}
  • 类型判断函数
function typeOf (value) {
    return function (obj) {
        const toString = Object.prototype.toString;
        const map = {
            '[object Boolean]'     : 'boolean',
            '[object Number]'      : 'number',
            '[object String]'      : 'string',
            '[object Function]'  : 'function',
            '[object Array]'     : 'array',
            '[object Date]'      : 'date',
            '[object RegExp]'    : 'regExp',
            '[object Undefined]' : 'undefined',
            '[object Null]'      : 'null',
            '[object Object]'      : 'object'
        };
        return map[toString.call(obj)] === value;
    }
}

var isNumber = typeOf('number');
var isFunction = typeOf('function');
var isRegExp = typeOf('regExp');

isNumber(0); // => true
isFunction(function () {}); // true
isRegExp({}); // => false

通过向 typeOf 里传入不同的类型字符串参数,就可以生成对应的类型判断函数,作为语法糖在业务代码里重复使用。

问题

内存泄露

由于闭包使用过度而导致的内存占用无法释放的情况,我们称之为:内存泄露。

内存泄露 是指当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者内存池的现象。内存泄漏可能会导致应用程序卡顿或者崩溃。

相比普通函数,闭包对于内存的占用比普通函数大,毕竟外层函数的自由变量无法释放。

function bindEvent(){
    let ele = document.querySelector('.ele')
    ele.onclick = function () {
        console.log(ele.style.color)
    }
}
bindEvent()

在上面的例子中点击事件函数中使用到了外层函数中的DOM ele,导致 ele 始终无法释放,造成大量的内存占用。

解决:

function bindEvent(){
    let ele = document.querySelector('.ele')
    let color = ele
    ele.onclick = function () {
        console.log(color)
    }
    ele = null
}
bindEvent()

闭包中的this值

var name = '听风是风'
var obj = {
    name: '行星飞行',
    sayName: function () {
        return function () {
            console.log(this.name)
        }
    }
}

obj.sayName()() // '听风是风'

上面的代码可以理解为以下代码:

var name = '听风是风'
var obj = {
    name: '行星飞行',
    sayName: function () {
        return function () {
            console.log(this.name)
        }
    }
}
var fn = obj.sayName()

fn() // '听风是风'

因此在闭包中的this一般指向全局对象window

如果想要使用外层函数的this,可以:

  • 保存this
var name = '听风是风'
var obj = {
  name: '行星飞行',
  sayName: function () {
    var that = this
    return function () {
      console.log(that.name)
    }
  }
}

obj.sayName()() // '行星飞行'
  • 使用箭头函数
var name = '听风是风'
var obj = {
  name: '行星飞行',
  sayName: function () {
    return () => {
      console.log(this.name)
    }
  }
}

obj.sayName()() // '行星飞行'

参考

《你不知道的JavaScript上卷》

JavaScript深入之闭包

面试官:说说作用域和闭包吧

一篇文章看懂JS闭包,都要2020年了,你怎么能还不懂闭包?


评论
 上一篇
JavaScript基础之this/call/apply/bind JavaScript基础之this/call/apply/bind
thisthis 是什么 当一个函数被调用时,会创建一个活动记录(或者称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this 就是这个记录的一个属性,会在函数执行的过程中用到。 如果对这个解
2020-08-17
下一篇 
JavaScript基础之作用域/作用域链 JavaScript基础之作用域/作用域链
作用域/作用域链作用域定义 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。 作用域是在运行时代码中的某些特定部分中的变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。 通过定义我们可以知
2020-08-13
  目录