第三章 闭包和高阶函数

3.1 闭包

闭包的形成与变量的作用域以及变量的生存周期密切相关

3.1.1 变量的作用域

变量的作用域指的是变量的有效范围。我们最常提到的是函数中声明的变量作用域。

当在函数中声明一个变量时,如果没有var关键字,那么这个变量就会变成全局变量。

当拥有var关键字时,这时候的变量是局部变量,只有在函数内部才能访问得到。

js
1var func = function () {
2  var a = 1
3  alert(a) // 输出: 1
4}
5func()
6alert(a) // 输出:Uncaught ReferenceError: a is not defined

函数可以创造函数作用域。此时函数外面访问不到函数内部的变量,函数内部可以访问函数外部的变量。

这是因为当在函数内搜索一个变量时,如果该函数内没有声明这个变量,那么此次搜索就会顺着代码执行环境所创建的作用域链往外层搜索,一直搜索到全局变量为止。变量的搜索是从内到外的。

就像下面这段代码

js
1var a = 1
2var func1 = function () {
3  var b = 2
4  var func2 = function () {
5    var c = 3
6    alert(a) //输出1
7    alert(b) //输出2
8  }
9  func2()
10  alert(c) //"ReferenceError: c is not defined
11}
12func1()

3.1.2 变量的生存周期

全局变量的生存周期是永久的,除非我们主动销毁这个全局变量。

但对于在函数内使用var关键字声明的局部变量来说,当退出函数时,这些局部变量就失去了它们的价值,它们会随着函数的调用结束而销毁。

js
1var func = function () {
2  var a = 1 // 退出函数后局部变量a将被销毁
3  alert(a)
4}
5
6func()

如果把这段代码修改一下:

js
1var func = function () {
2  var a = 1
3  return function () {
4    a++
5    alert(a)
6  }
7}
8
9var f = func()
10
11f() // 输出:2
12f() // 输出:3
13f() // 输出:4
14f() // 输出:5

从结论中可以发现a变量并没有销毁掉,而是一直存在于内存中。

这是因为var f =func()时,f保存了一个匿名函数的引用,它可以访问到func()被调用时产生的环境,而局部变量a则一直存在于这个环境内。如果说局部环境内所在的局部变量依然能被外界所访问,那么这个局部变量就有了不被销毁的理由。

下面看关于闭包的经典应用

html
1<div>1</div>
2<div>2</div>
3<div>3</div>
4<div>4</div>
5<div>5</div>
6<div>6</div>
js
1var nodes = document.querySelectorAll('div')
2for (var i = 0; i < nodes.length; i++) {
3  nodes[i].onclick = function () {
4    alert(i)
5  }
6}

上面的代码经过测试,不管点击哪个div,都会打印6。

这是因为div节点的onclick事件是异步的,当事件触发时,for循环已结束。此时 i 为 5,所以 div 的 onclick 事件顺着作用域链找变量i时,查找到的值永远是6。

解决的方法是使用闭包,用一个立即执行函数把每次循环的 i 值给包起来。这样当事件函数内的代码顺着作用域链从内到外查找变量i时,会找到闭包环境中的i,如果有 6 个div,那么就有 6 个i,这里分别是 0,1,2,3,4,5

js
1var nodes = document.querySelectorAll('div')
2for (var i = 0; i < nodes.length; i++) {
3  ;(function (i) {
4    nodes[i].onclick = function () {
5      alert(i)
6    }
7  })(i)
8}

3.1.3 闭包的更多作用

  1. 封装变量

    闭包可以将一些不需要暴露在全局的变量给封装成“私有变量”。

    比如做“函数缓存”:

    js
    1var mult = (function () {
    2  var cache = {}
    3  return function (arg) {
    4    console.log(cache)
    5    if (String(arg) in cache) {
    6      return cache[arg]
    7    }
    8    return (cache[arg] = arg * arg)
    9  }
    10})()
    11
    12console.log(mult(2))

    mult 这个函数是用来计算平方数的,对于一些相同的参数来说,每次计算可能都是一次性能浪费,可以加入缓存机制来提高这个函数的性能。

    处理的手段就是运用一个 cache 的对象来保存键(传入的参数)值(第一次计算后的结果)。

    由于 cache 仅仅在 mult 函数中使用,所以运用闭包的技巧将 cache 变量封闭在 mult 函数内部,可以减少代码中的全局变量,还可以避免这个变量在其他地方被不小心修改而引发错误。

    提炼函数是代码重构的一种常见技巧。如果一个大函数中有一些代码块能够独立出来,我们将这些代码封装在独立的小函数中,独立出来的小函数有助于代码复用,这些小函数本身也可能起到注释的作用,我们也可以将这些小函数用闭包给封闭起来。

    上面的代码还可以这样修改

    js
    1var mult = (function () {
    2  var cache = {}
    3
    4  function calculate(number) {
    5    //计算逻辑
    6    return number * number
    7  }
    8
    9  return function (arg) {
    10    if (String(arg) in cache) {
    11      console.log(cache)
    12      return cache[arg]
    13    }
    14    return (cache[arg] = calculate(arg))
    15  }
    16})()
    17
    18console.log(mult(2))
    19console.log(mult(2))
  2. 延续局部变量的寿命

    js
    1var report = function (src) {
    2  var img = new Image()
    3  img.src = src
    4}
    5
    6report('http://xxx.com/getUserInfo')

    因为一些低版本浏览器的实现存在 bug,在浏览器下使用 report 函数进行数据上报时会丢失 30%左右的数据,也就是说,report 函数并不是每一次都成功发起 http 请求。丢失数据的原因是 img 是 report 函数的局部变量,当 report 函数的调用结束后,img 局部变量就被销毁了,此时或者还没来得及发送 http 请求,所以这次请求会丢失。

    现在我们用闭包的原理将 img 变量给封闭起来,就可以解决请求丢失的问题。

    js
    1var report = (function () {
    2  var imgs = []
    3  return function (src) {
    4    var img = new Image()
    5    imgs.push(img)
    6    img.src = src
    7  }
    8})()

3.1.4 闭包和面向对象设计

运用闭包能实现通常面向对象才能够实现的功能。

比如下面这段代码

js
1const obj = {
2  value: 1,
3  call: function () {
4    this.value += 1
5    console.log(this.value)
6  },
7}
8obj.call() // 2
9obj.call() // 3
10obj.call() // 4

使用闭包也可能过实现

js
1function fnc() {
2  let value = 1
3  return function () {
4    value++
5    console.log(value)
6  }
7}
8const call = fnc()
9call() // 2
10call() // 3
11call() // 4

3.1.5 用闭包实现命令模式

命令模式的意图是将请求封装为对象,从而分离请求的发起者和请求执行者之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接受者。

比如下面示例

html
1<button id="execute">点击我执行命令</button> <button id="undo">点击我执行命令</button>
js
1var Tv = {
2  // 命令接收者:它具备所有要做的操作
3  open: function () {
4    console.log('打开电视机')
5  },
6  close: function () {
7    console.log('关上电视机')
8  },
9}
10//命令对象构造器
11var OpenTvCommand = function (receiver) {
12  // 命令会被这个构造器所构造的命令对象当作属性保存起来
13  this.receiver = receiver
14}
15
16OpenTvCommand.prototype.execute = function () {
17  // 命令对象的命令放在原型上
18  this.receiver.open() // 执行命令,打开电视机
19}
20
21OpenTvCommand.prototype.undo = function () {
22  this.receiver.close() // 撤销命令,关闭电视机
23}
24//设置命令-接收一个命令对象
25var setCommand = function (command) {
26  document.getElementById('execute').onclick = function () {
27    command.execute() // 输出:打开电视机
28  }
29  document.getElementById('undo').onclick = function () {
30    command.undo() // 输出:关闭电视机
31  }
32}
33
34setCommand(new OpenTvCommand(Tv))

上面的代码可以使用函数而不是普通对象来封装命令请求,这样更加自然。如果需要往函数对象中预先植入命令的接收者,那么闭包就可以完成这个工作。

js
1var Tv = {
2  open: function () {
3    console.log('打开电视机')
4  },
5  close: function () {
6    console.log('关上电视机')
7  },
8}
9
10// 创建命令的函数,传入命令接收者返回一个命令对象
11const createCommand = function (receiver) {
12  const execute = function () {
13    receiver.open()
14  }
15  const undo = function () {
16    receiver.close()
17  }
18
19  return {
20    execute,
21    undo,
22  }
23}
24
25var setCommand = function (command) {
26  document.getElementById('execute').onclick = function () {
27    command.execute() // 输出:打开电视机
28  }
29  document.getElementById('undo').onclick = function () {
30    command.undo() // 输出:关闭电视机
31  }
32}
33
34setCommand(createCommand(Tv))

在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来。而在闭包版本的命令模式中,命令的接收者则会被封闭在闭包形成的环境中。

3.1.6 闭包与内存管理

闭包是非常强大的特性,人们对它有一种误解:闭包会造成内存泄漏,所以要尽量减少闭包的使用。

局部变量本来应该在函数退出时被解除引用。如果局部变量在闭包形成的环境里,那么这个局部变量的确可以一直生存下去,这些数据也无法被及时销毁。

但使用闭包的一个原因是开发者可能以后还需要用到这些变量,把这些变量放在闭包环境中还是全局环境中对内存的影响是一致的。所以不能说是内存泄漏。

唯一跟内存泄漏有关系的是,使用闭包时,比较容易形成循环引用。如果闭包的作用域链中保存着一些 DOM 节点,这时候就可能造成内存泄漏。这本身的原因并非 JavaScript 或者闭包的问题。而是由于早期 IE 浏览器中,由于 BOM 和 DOM 对象是使用 C++以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略。在基于技术策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么两个对象都无法被回收。循环引用造成的内存泄漏在本质上也并非闭包造成的。

如果我们想要解决循环引用带来的内存泄漏问题,只需要把循环引用中的变量设置为 null 即可,这意味着切断变量与它引用的值之间的连接。当这些值不能被访问到时,垃圾回收器在运行时就会删除这些值并回收它们占用的内存。

3.2 高阶函数

高阶函数是具备以下任一条件的函数:

  • 函数作为参数被输入
  • 函数作为返回值被输出

下面介绍高阶函数的应用场景

3.2.1 函数作为参数传递

  1. 回调函数

    当我们想在 ajax 请求返回时做一些操作,但又不知道请求返回的时间时,可以传递一个回调函数给调用 ajax 的方法,等到请求完成后调用回调函数。

    js
    1var getUserInfo = function (userId, callback) {
    2  $.ajax('http://xxx.com/getUserInfo? ' + userId, function (data) {
    3    if (typeof callback === 'function') {
    4      callback(data)
    5    }
    6  })
    7}
    8
    9getUserInfo(13157, function (data) {
    10  alert(data.userName)
    11})

    回调函数除了用于异步请求外,目前更多的场景是应用于拆分业务逻辑,通过封装互相关系并不大的代码,降低代码之间的耦合,使之可读性更高。比如说下面将一个拆分出逻辑的函数当作参数传递给另一个函数,来委托它执行。

    js
    1var appendDiv = function (callback) {
    2  for (var i = 0; i < 100; i++) {
    3    var div = document.createElement('div')
    4    div.innerHTML = i
    5    document.body.appendChild(div)
    6    if (typeof callback === 'function') {
    7      callback(div)
    8    }
    9  }
    10}
    11
    12const hiddenElement = function (element) {
    13  element.style.display = 'none'
    14}
    15appendDiv(hiddenElement)

    将元素隐藏的逻辑跟添加元素的逻辑代码关联并不大,所以我们将其拆分出来,通过 callback 的形式传递给 appendDiv,这样 appendDiv 函数的复用性要强很多。

  2. Array.prototype.sort

    Array.prototype.sort 接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从它的使用来看,我们的目的是对数组排序,这是不可变的部分,而如何排序,这属于可变部分。把可变部分封装到函数参数中,动态传递给 sort 函数,使 sort 函数变成一个非常灵活的方法

    js
    1var numbers = [4, 2, 5, 1, 3]
    2numbers.sort((a, b) => a - b)
    3console.log(numbers)
    4
    5// [1, 2, 3, 4, 5]

3.2.2 函数作为返回值输出

将函数作为返回值输出的场景比函数作为参数的场景更多。

  1. 判断数据的类型

    判断数据是否为数组有很多种方法,比如使用 instanceOf关键字、isArray方法,比较好的方法是用Object.prototype.toString.call

    js
    1console.log(Object.prototype.toString.call([1, 2, 3])) // "[object Array]"
    2console.log(Object.prototype.toString.call(1)) // "[object Number]"
    3console.log(Object.prototype.toString.call('1')) // "[object String]"
    4console.log(Object.prototype.toString.call(function () {})) // "[object Function]"
    5console.log(Object.prototype.toString.call(null)) // "[object Null]"
    6console.log(Object.prototype.toString.call(undefined)) // "[object Undefined]"

    我们可以封装一个专用于判断类型的函数

    js
    1var isString = function (obj) {
    2  return Object.prototype.toString.call(obj) === '[object String]'
    3}
    4
    5var isArray = function (obj) {
    6  return Object.prototype.toString.call(obj) === '[object Array]'
    7}
    8
    9var isNumber = function (obj) {
    10  return Object.prototype.toString.call(obj) === '[object Number]'
    11}

    这些函数大部分逻辑都是一样的,区别只是[objct xxx]字符串的值。我们可以将封装一个isType函数,然后将这些不同的字符串当成参数传递给isType函数

    js
    1function isType(type) {
    2  return (obj) => Object.prototype.toString.call(obj) === `[object ${type}]`
    3}
    4const isString = isType('String')
    5const isArray = isType('Array')
    6const isNumber = isType('Number')
    7
    8console.log(isNumber(1))
    9console.log(isString('1'))
    10console.log(isArray([1, 2, 3]))

    仔细看,isType函数的调用也重复写了很多次,所以我们还可以封装一下来自动注册isType函数

    js
    1const Type = {}
    2;['String', 'Array', 'Number'].map((type) => {
    3  Type[`is${type}`] = (obj) => Object.prototype.toString.call(obj) === `[object ${type}]`
    4})
    5
    6console.log(Type.isString('123'))
    7console.log(Type.isArray([1, 2, 3]))
    8console.log(Type.isNumber(123))
  2. getSingle

    下面是一个单例模式的例子,它接受一个函数作为参数,又让函数执行返回另外一个函数

    js
    1var getSingle = function (fn) {
    2  var ret
    3  return function () {
    4    return ret || (ret = fn.apply(this, arguments))
    5  }
    6}
    7var getScript = getSingle(function () {
    8  return document.createElement('script')
    9})
    10var script1 = getScript()
    11var script2 = getScript()
    12console.log(script1 === script2) // true

    这个单例模式传递一个函数,并返回一个函数,当调用返回的这个函数时,拿到的都是闭包中的变量。

3.2.3 高阶函数实现 AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来后,再通过动态织入的方式掺入业务逻辑模块中。这样做的好处是保持业务逻辑模块的纯净和高内聚性,其次是可以方便复用。

比如目前有一个业务模块是需要实现以下的代码效果:

js
1function func2() {
2  console.log(2)
3  return 888
4}
5
6const action = func2
7  .before(() => {
8    console.log(1)
9  })
10  .after(() => {
11    console.log(3)
12  })
13const result = action() // 1 2 3
14result // 888

在 javascript 中实现 AOP,都是把一个函数动态织入另一个函数之中,这里通过扩展Function.prototype来做到这一点。

js
1Function.prototype.before = function (beforeFunc) {
2  const _self = this
3  return function () {
4    beforeFunc.apply(this, arguments)
5    //返回主函数的结果
6    return _self.apply(this, arguments)
7  }
8}
9
10Function.prototype.after = function (afterFunc) {
11  const _self = this
12  return function () {
13    const result = _self.apply(this, arguments)
14    afterFunc.apply(this, arguments)
15    //返回主函数的结果
16    return result
17  }
18}

这种 AOP 的方式给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式的实现,这种模式在实际开发中非常有用。

3.2.4 高阶函数的其他应用

  1. currying

    函数柯里化又称部分求值。一个柯里化函数首先会接受一些参数,接受这些参数后,该函数不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。等到函数真正需要求值的时候,之前传入的所有参数都会被用于一次性求值。

    比如,我每天都将开销传入一个函数,直到某天我想查看所有开销:

    js
    1cost(100) // 未真正求值
    2cost(200) // 未真正求值
    3cost(300) // 未真正求值
    4
    5console.log(cost()) // 求值并输出:600

    我们可以利用柯里化的思想来帮助我们完成 cost 函数

    js
    1const cost = (function () {
    2  const args = []
    3  return function () {
    4    if (arguments.length === 0) {
    5      const result = args.reduce((pre, cur) => pre + cur, 0)
    6      return result
    7    }
    8    args.push.apply(args, arguments)
    9    return args
    10  }
    11})()
    12
    13cost(100) // 未真正求值
    14cost(200) // 未真正求值
    15cost(300) // 未真正求值
    16
    17console.log(cost()) //600
  2. 节流

    在有些情况下,函数会被频繁调用,造成性能问题。常见的场景分三种:

    • window.onresize 事件

      我们给 window 绑定了 resize 事件,当浏览器窗口被拖动而改变时,这个事件函数触发的频繁非常高,如果我们在里面放一些 DOM 节点相关的操作,那么浏览器可能会吃不消而卡顿。

    • mousemove 事件

      我们给一个 div 绑定拖拽事件,当 div 被拖动时,也会频繁触发事件函数

    • 上传进度

      微云的上传功能使用了公司提供的一个浏览器插件。该浏览器插件在真正开始上传文件之前,会对文件进行扫描并随时通知 JavaScript 函数,以便在页面中显示当前的扫描进度。但该插件通知的频率非常之高,大约一秒钟 10 次,很显然我们在页面中不需要如此频繁地去提示用户。

    节流主要用于函数被触发的频率太高的问题。很多时候,我们并不需要频繁调用这些函数,这就需要我们按照时间来忽略掉某些函数处理的过程。我们可以借助setTimeout来完成这件事。

    下面的 throttle 函数的原理是,将即将被执行的函数用 setTimeout 延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求

    js
    1function throttle(handle, delay) {
    2  var flag = false //开关
    3  var timer //定时器id
    4  return function () {
    5    const _self = this //谁调用这个函数
    6    if (flag) {
    7      return
    8    }
    9    flag = true
    10    timer = setTimeout(() => {
    11      handle.apply(_self, arguments)
    12      flag = false
    13      clearTimeout(timer)
    14    }, delay)
    15  }
    16}
  3. 分时函数

    上面的节流函数时采用限制频繁调用函数的方式来优化性能,下面有一种新的需求,我不得不频繁调用函数。

    比如我需要创建用户列表,一次性创建 1000 个节点的话,浏览器很有可能就吃不消了。

    js
    1let array = []
    2for (let i = 0; i < 1000; i++) {
    3  array.push(i) //这里的i假设为用户数据
    4}
    5
    6function renderList() {
    7  for (let data of array) {
    8    const div = document.createElement('div')
    9    div.innerHTML = data
    10    document.body.appendChild(div)
    11  }
    12}
    13renderList()

    这个问题的解决方案之一是封装一个timeChunk函数,每次都让创建节点的函数分批进行,而不是一次性渲染完成,比如每隔 200 毫秒来渲染 8 个节点。

    js
    1let array = []
    2for (let i = 0; i < 1000; i++) {
    3  array.push(i) //这里的i假设为用户数据
    4}
    5
    6function renderItem(data) {
    7  const div = document.createElement('div')
    8  div.innerHTML = data
    9  document.body.appendChild(div)
    10}
    11
    12function timeChunk(array, handler, count) {
    13  let data
    14  let timer
    15
    16  function start() {
    17    for (let i = 0; i < count; i++) {
    18      data = array.shift()
    19      handler(data)
    20    }
    21  }
    22  timer = setInterval(function () {
    23    if (array.length === 0) {
    24      return clearInterval(timer)
    25    }
    26    start()
    27  }, 200)
    28}
    29timeChunk(array, renderItem, 8)

    上面的代码是将原来的 renderList 函数里面的循环逻辑提炼出来,只专注于渲染节点(renderItem),将渲染次数交给 timechunk 函数来处理,这个 timechunk 函数的特点是会间隔一段时间不断调用 renderItem 函数。

    可以看出分时函数跟节流函数的关注点大相径庭,分时函数注重的是将函数执行次数的单位周期拉长,而节流函数注重将函数的执行次数减短。

  4. 惰性加载函数

    为了兼容新老版本浏览器,下面是一段封装过的绑定事件的代码

    js
    1var addEvent = function (target, type, handler) {
    2  if (window.addEventListener) {
    3    return target.addEventListener(type, handler, false)
    4  }
    5  if (window.attachEvent) {
    6    return target.attachEvent(`on${type}`, handler)
    7  }
    8}

    这个函数有个缺点,每次绑定事件时都会执行 if 语句,虽然没多大影响,但是能不能优化一下呢?

    我直接写一个立即执行函数,然后在里面做判断,再返回一个新的绑定事件函数不就可以了?这样的话条件判断就只是执行了一次。

    js
    1var addEvent = (function () {
    2  if (window.addEventListener) {
    3    return function (target, type, handler) {
    4      target.addEventListener(type, handler, false)
    5    }
    6  }
    7  if (window.attachEvent) {
    8    return function (target, type, handler) {
    9      target.attachEvent(`on${type}`, handler)
    10    }
    11  }
    12})()

    但是这个问题可能还有问题,假设我从来没绑定过事件,那么这个函数立即执行一次就没有任何意义。

    是不是能封装一个更高级的函数?

    答案是惰性加载函数。

    惰性加载函数的原理是,在函数内部重写这个函数,重写的方式就是将变量名的引用连接到一个新函数上。

    重写之后的函数就是我们期望的函数,而且还已经帮助我们做好了条件判断。

    js
    1var addEvent = function (target, type, handler) {
    2  if (window.addEventListener) {
    3    addEvent = function (target, type, handler) {
    4      target.addEventListener(type, handler)
    5    }
    6  } else if (window.attachEvent) {
    7    addEvent = function (target, type, handler) {
    8      target.attachEvent(`on${type}`, handler)
    9    }
    10  }
    11  // 第一次调用时需要执行一次
    12  addEvent(target, type, handler)
    13}
    14console.log(addEvent) // 没执行前依然是原函数
    15addEvent(window, 'click', function () {}) // 原函数依然执行了一次
    16console.log(addEvent) // 执行一次后就重写了这个函数

3.3 小结

由于 JavaScript 语言的特点,它的设计模式的实现跟传统面向对象语言差别非常大。

在 JavaScript 中,很多设计模式都是借助闭包和高阶函数来完成的,闭包和高阶函数的应用非常多。

相对于其实现过程,我们更应该关注设计模式可以帮助我们完成什么。