第三章 闭包和高阶函数
3.1 闭包
闭包的形成与变量的作用域以及变量的生存周期密切相关
3.1.1 变量的作用域
变量的作用域指的是变量的有效范围。我们最常提到的是函数中声明的变量作用域。
当在函数中声明一个变量时,如果没有var关键字,那么这个变量就会变成全局变量。
当拥有var关键字时,这时候的变量是局部变量,只有在函数内部才能访问得到。
js1var func = function () { 2 var a = 1 3 alert(a) // 输出: 1 4} 5func() 6alert(a) // 输出:Uncaught ReferenceError: a is not defined
函数可以创造函数作用域。此时函数外面访问不到函数内部的变量,函数内部可以访问函数外部的变量。
这是因为当在函数内搜索一个变量时,如果该函数内没有声明这个变量,那么此次搜索就会顺着代码执行环境所创建的作用域链往外层搜索,一直搜索到全局变量为止。变量的搜索是从内到外的。
就像下面这段代码
js1var 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关键字声明的局部变量来说,当退出函数时,这些局部变量就失去了它们的价值,它们会随着函数的调用结束而销毁。
js1var func = function () { 2 var a = 1 // 退出函数后局部变量a将被销毁 3 alert(a) 4} 5 6func()
如果把这段代码修改一下:
js1var 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则一直存在于这个环境内。如果说局部环境内所在的局部变量依然能被外界所访问,那么这个局部变量就有了不被销毁的理由。
下面看关于闭包的经典应用
html1<div>1</div> 2<div>2</div> 3<div>3</div> 4<div>4</div> 5<div>5</div> 6<div>6</div>
js1var 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
js1var 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 闭包的更多作用
-
封装变量
闭包可以将一些不需要暴露在全局的变量给封装成“私有变量”。
比如做“函数缓存”:
js1var 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 函数内部,可以减少代码中的全局变量,还可以避免这个变量在其他地方被不小心修改而引发错误。
提炼函数是代码重构的一种常见技巧。如果一个大函数中有一些代码块能够独立出来,我们将这些代码封装在独立的小函数中,独立出来的小函数有助于代码复用,这些小函数本身也可能起到注释的作用,我们也可以将这些小函数用闭包给封闭起来。
上面的代码还可以这样修改
js1var 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)) -
延续局部变量的寿命
js1var 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 变量给封闭起来,就可以解决请求丢失的问题。
js1var 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 闭包和面向对象设计
运用闭包能实现通常面向对象才能够实现的功能。
比如下面这段代码
js1const 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
使用闭包也可能过实现
js1function 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 用闭包实现命令模式
命令模式的意图是将请求封装为对象,从而分离请求的发起者和请求执行者之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接受者。
比如下面示例
html1<button id="execute">点击我执行命令</button> <button id="undo">点击我执行命令</button>
js1var 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))
上面的代码可以使用函数而不是普通对象来封装命令请求,这样更加自然。如果需要往函数对象中预先植入命令的接收者,那么闭包就可以完成这个工作。
js1var 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 函数作为参数传递
-
回调函数
当我们想在 ajax 请求返回时做一些操作,但又不知道请求返回的时间时,可以传递一个回调函数给调用 ajax 的方法,等到请求完成后调用回调函数。
js1var 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})回调函数除了用于异步请求外,目前更多的场景是应用于拆分业务逻辑,通过封装互相关系并不大的代码,降低代码之间的耦合,使之可读性更高。比如说下面将一个拆分出逻辑的函数当作参数传递给另一个函数,来委托它执行。
js1var 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 函数的复用性要强很多。
-
Array.prototype.sort
Array.prototype.sort 接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从它的使用来看,我们的目的是对数组排序,这是不可变的部分,而如何排序,这属于可变部分。把可变部分封装到函数参数中,动态传递给 sort 函数,使 sort 函数变成一个非常灵活的方法
js1var 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 函数作为返回值输出
将函数作为返回值输出的场景比函数作为参数的场景更多。
-
判断数据的类型
判断数据是否为数组有很多种方法,比如使用 i
nstanceOf关键字、isArray方法,比较好的方法是用Object.prototype.toString.calljs1console.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]"我们可以封装一个专用于判断类型的函数
js1var 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函数js1function 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函数js1const 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)) -
getSingle
下面是一个单例模式的例子,它接受一个函数作为参数,又让函数执行返回另外一个函数
js1var 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(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来后,再通过动态织入的方式掺入业务逻辑模块中。这样做的好处是保持业务逻辑模块的纯净和高内聚性,其次是可以方便复用。
比如目前有一个业务模块是需要实现以下的代码效果:
js1function 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来做到这一点。
js1Function.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 高阶函数的其他应用
-
currying
函数柯里化又称部分求值。一个柯里化函数首先会接受一些参数,接受这些参数后,该函数不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。等到函数真正需要求值的时候,之前传入的所有参数都会被用于一次性求值。
比如,我每天都将开销传入一个函数,直到某天我想查看所有开销:
js1cost(100) // 未真正求值 2cost(200) // 未真正求值 3cost(300) // 未真正求值 4 5console.log(cost()) // 求值并输出:600我们可以利用柯里化的思想来帮助我们完成 cost 函数
js1const 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 -
节流
在有些情况下,函数会被频繁调用,造成性能问题。常见的场景分三种:
-
window.onresize 事件
我们给 window 绑定了 resize 事件,当浏览器窗口被拖动而改变时,这个事件函数触发的频繁非常高,如果我们在里面放一些 DOM 节点相关的操作,那么浏览器可能会吃不消而卡顿。
-
mousemove 事件
我们给一个 div 绑定拖拽事件,当 div 被拖动时,也会频繁触发事件函数
-
上传进度
微云的上传功能使用了公司提供的一个浏览器插件。该浏览器插件在真正开始上传文件之前,会对文件进行扫描并随时通知 JavaScript 函数,以便在页面中显示当前的扫描进度。但该插件通知的频率非常之高,大约一秒钟 10 次,很显然我们在页面中不需要如此频繁地去提示用户。
节流主要用于函数被触发的频率太高的问题。很多时候,我们并不需要频繁调用这些函数,这就需要我们按照时间来忽略掉某些函数处理的过程。我们可以借助
setTimeout来完成这件事。下面的 throttle 函数的原理是,将即将被执行的函数用 setTimeout 延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求
js1function 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} -
-
分时函数
上面的节流函数时采用限制频繁调用函数的方式来优化性能,下面有一种新的需求,我不得不频繁调用函数。
比如我需要创建用户列表,一次性创建 1000 个节点的话,浏览器很有可能就吃不消了。
js1let 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 个节点。js1let 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 函数。
可以看出分时函数跟节流函数的关注点大相径庭,分时函数注重的是将函数执行次数的单位周期拉长,而节流函数注重将函数的执行次数减短。
-
惰性加载函数
为了兼容新老版本浏览器,下面是一段封装过的绑定事件的代码
js1var 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 语句,虽然没多大影响,但是能不能优化一下呢?
我直接写一个立即执行函数,然后在里面做判断,再返回一个新的绑定事件函数不就可以了?这样的话条件判断就只是执行了一次。
js1var 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})()但是这个问题可能还有问题,假设我从来没绑定过事件,那么这个函数立即执行一次就没有任何意义。
是不是能封装一个更高级的函数?
答案是惰性加载函数。
惰性加载函数的原理是,在函数内部重写这个函数,重写的方式就是将变量名的引用连接到一个新函数上。
重写之后的函数就是我们期望的函数,而且还已经帮助我们做好了条件判断。
js1var 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 中,很多设计模式都是借助闭包和高阶函数来完成的,闭包和高阶函数的应用非常多。
相对于其实现过程,我们更应该关注设计模式可以帮助我们完成什么。