本文最先发布于我的个人网站:https://wintc.top/article/33。转载请注明出处。
本文介绍一下 JS 中的一个重要概念——闭包。其实即便是最初级的前端开发人员,应该都已经接触过它。
一、闭包的概念和特性
首先看个闭包的例子:
function makeFab () {
let last = 1, current = 1
return function inner() {[current, last] = [current + last, current]
return last
}
}
let fab = makeFab()
console.log(fab()) // 1
console.log(fab()) // 2
console.log(fab()) // 3
console.log(fab()) // 5
这是一个生成斐波那契数列的例子。makeFab 的返回值就是一个闭包,makeFab 像一个工厂函数,每次调用都会创建一个闭包函数,如例子中的 fab。fab 每次调用不需要传参数,都会返回不同的值,因为在闭包生成的时候,它记住了变量 last 和 current,以至于在后续的调用中能够返回不同的值。能记住函数本身所在作用域的变量,这就是闭包和普通函数的区别所在。
MDN 中给出的闭包的定义是:函数与对其状态即词法环境的引用共同构成闭包。这里的“词法环境的引用”,可以简单理解为“引用了函数外部的一些变量”,例如上述例子中每次调用 makeFab 都会创建并返回 inner 函数,引用了 last 和 current 两个变量。
二、闭包——函数式编程之魂
Javascript 和 python 这两门动态语言都强调一个概念:万物皆对象。自然,函数也是对象。
在 Javascript 里,我们可以像操作普通变量一样,把函数在我们的代码里抛来抛去,然后在某个时刻调用一下,这就是所谓的函数式编程。函数式编程灵活简洁,而语言对闭包的支持,让函数式编程拥有了灵魂。
以实现一个可复用的确认框为例,比如在用户进行一些删除或者重要操作的时候,为了防止误操作,我们可能会通过弹窗让用户再次确认操作。因为确认框是通用的,所以确认框组件的逻辑应该足够抽象,仅仅是负责弹窗、触发确认、触发取消事件,而触发确认 / 取消事件是异步操作,这时候我们就需要使用两个回调函数完成操作,弹窗函数 confirm 接收三个参数:一个提示语句,一个确认回调函数,一个取消回调函数:
function confirm (confirmText, confirmCallback, cancelCallback) {
// 插入提示框 DOM,包含提示语句、确认按钮、取消按钮
// 添加确认按钮点击事件,事件函数中做 dom 清理工作并调用 confirmCallback
// 添加取消按钮点击事件,事件函数中做 dom 清理工作并调用 cancelCallback
}
这样我们可以通过向 confirm 传递回调函数,并且根据不同结果完成不同的动作,比如我们根据 id 删除一条数据可以这样写:
function removeItem (id) {confirm('确认删除吗?', () => {
// 用户点击确认, 发送远程 ajax 请求
api.removeItem(id).then(xxx)
}, () => {
// 用户点击取消,console.log('取消删除')
})
}
这个例子中,confirmCallback 正是利用了闭包,创建了一个引用了上下文中 id 变量的函数,这样的例子在回调函数中比比皆是,并且大多数时候引用的变量是很多个。试想,如果语言不支持闭包,那这些变量要怎么办?作为参数全部传递给 confirm 函数,然后在调用 confirmCallback/cancelCallback 时再作为参数传递给它们?显然,这里闭包提供了极大便利。
三、闭包的一些例子
1. 防抖、节流函数
前端很常见的一个需求是远程搜索,根据用户输入框的内容自动发送 ajax 请求,然后从后端把搜索结果请求回来。为了简化用户的操作,有时候我们并不会专门放置一个按钮来点击触发搜索事件,而是直接监听内容的变化来搜索(比如像 vue 的官网搜索栏)。这时候为了避免请求过于频繁,我们可能就会用到“防抖”的技巧,即当用户停止输入一段时间(比如 500ms)后才执行发送请求。可以写一个简单的防抖函数实现这个功能:
function debounce (func, time) {
let timer = 0
return function (...args) {timer && clearTimeout(timer)
timer = setTimeout(() => {
timer = 0
func.apply(this, args)
}, time)
}
}
input.onkeypress = debounce(function () {console.log(input.value) // 事件处理逻辑
}, 500)
debounce 函数每次调用时,都会创建一个新的闭包函数,该函数保留了对事件逻辑处理函数 func 以及防抖时间间隔 time 以及定时器标志 timer 的引用。类似的还有节流函数:
function throttle(func, time) {
let timer = 0 // 定时器标记相当于一个锁标志
return function (...args) {if (timer) return
func.apply(this, args)
timer = setTimeout(() => timer = 0, time)
}
}
2. 优雅解决按钮多次连续点击问题
用户点击一个表单提交按钮,前端会向后台发送一个异步请求,请求还没返回,焦急的用户又多点了几下按钮,造成了额外的请求。有时候多发几次请求最多只是多消耗了一些服务器资源,而另外一些情况是,表单提交本身会修改后台的数据,那多次提交就会导致意料之外的后果了。无论是为了减少服务器资源消耗还是避免多次修改后台数据,给表单提交按钮添加点击限制是很有必要的。
怎么解决呢?一个常用的办法是打个标记,即在响应函数所在作用域声明一个布尔变量 lock,响应函数被调用时,先判断 lock 的值,为 true 则表示上一次请求还未返回,此次点击无效;为 false 则将 lock 设置为 true,然后发送请求,请求结束再将 lock 改为 false。
很显然,这个 lock 会污染函数所在的作用域,比如在 Vue 组件中,我们可能就要将这个标记记录在组件属性上;而当有多个这样的按钮,则还需要不同的属性来标记(想想给这些属性取名都是一件头疼的事情吧!)。而生成闭包伴随着新的函数作用域的创建,利用这一点,刚好可以解决这个问题。下面是一个简单的例子:
let clickButton = (function () {
let lock = false
return function (postParams) {if (lock) return
lock = true
// 使用 axios 发送请求
axios.post('urlxxx', postParams).then(// 表单提交成功).catch(error => {
// 表单提交出错
console.log(error)
}).finally(() => {
// 不管成功失败 都解锁
lock = false
})
}
})()
button.addEventListener('click', clickButton)
这样 lock 变量就会在一个单独的作用域里,一次点击的请求发出以后,必须等请求回来,才会开始下一次请求。
当然,为了避免各个地方都声明 lock,修改 lock,我们可以把上述逻辑抽象一下,实现一个装饰器,就像节流 / 防抖函数一样。以下是一个通用的装饰器函数:
function singleClick(func, manuDone = false) {
let lock = false
return function (...args) {if (lock) return
lock = true
let done = () => lock = false
if (manuDone) return func.call(this, ...args, done)
let promise = func.call(this, ...args)
promise ? promise.finally(done) : done()
return promise
}
}
默认情况下,需要原函数返回一个 promise 以达到 promise 决议后将 lock 重置为 false,而如果没有返回值,lock 将会被立即重置(比如表单验证不通过,响应函数直接返回),调用示例:
let clickButton = singleClick(function (postParams) {if (!checkForm()) return
return axios.post('urlxxx', postParams).then(// 表单提交成功).catch(error => {
// 表单提交出错
console.log(error)
})
})
button.addEventListener('click', clickButton)
在一些不方便返回 promise 或者请求结束还要进行其它动作之后才能重置 lock 的地方,singleClick 提供了第二个参数 manuDone,允许你可以手动调用一个 done 函数来重置 lock,这个 done 函数会放在原函数参数列表的末尾。使用例子:
let print = singleClick(function (i, done) {console.log('print is called', i)
setTimeout(done, 2000)
}, true)
function test () {for (let i = 0; i < 10; i++) {setTimeout(() => {print(i)
}, i * 1000)
}
}
print 函数使用 singleClick 装饰,每次调用 2 秒后重置 lock 变量,测试每秒调用一次 print 函数,执行代码输出如下图:
可以看到,其中一些调用没有打印结果,这正是我们想要的结果!singleClick 装饰器比每次设置 lock 变量要方便许多,这里 singleClick 函数的返回值,以及其中的 done 函数,都是一个闭包。
3. 闭包模拟私有方法或者变量
“封装”是面向对象的特性之一,所谓“封装”,即一个对象对外隐藏了其内部的一些属性或者方法的实现细节,外界仅能通过暴露的接口操作该对象。JS 是比较“自由”的语言,所以并没有类似 C ++ 语言那样提供私有变量或成员函数的定义方式,不过利用闭包,却可以很好地模拟这个特性。
比如游戏开发中,玩家对象身上通常会有一个经验属性,假设为 exp,” 打怪 ”、“做任务”、“使用经验书”等都会增加 exp 这个值,而在升级的时候又会减掉 exp 的值,把 exp 直接暴露给各处业务来操作显然是很糟糕的。在 JS 里面我们可以用闭包把它隐藏起来,简单模拟如下:
function makePlayer () {
let exp = 0 // 经验值
return {getExp () {return exp},
changeExp (delta, sReason = '') {// log(xxx), 记录变动日志
exp += delta
}
}
}
let p = makePlayer()
console.log(p.getExp()) // 0
p.changeExp(2000)
console.log(p.getExp()) // 2000
这样我们调用 makePlayer() 就会生成一个玩家对象 p,p 内通过方法操作 exp 这个变量,但是却不可以通过 p.exp 访问,显然更符合“封装”的特性。
四、总结
闭包是 JS 中的强大特性之一,然而至于闭包怎么使用,我觉得不算是一个问题,甚至我们完全没必要研究闭包怎么使用。我的观点是,闭包应该是自然而言地出现在你的代码里,因为它是解决当前问题最直截了当的办法;而当你刻意想去使用它的时候,往往可能已经走了弯路。