本文最早发布于个人我的网站: https://wintc.top/article/33。转载请注明出处。
本文介绍一下JS中的一个重要概念——闭包。其实即使是最初级的前端开发人员,应该都已经接触过它。javascript
首先看个闭包的例子:前端
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,以致于在后续的调用中可以返回不一样的值。能记住函数自己所在做用域的变量,这就是闭包和普通函数的区别所在。vue
MDN中给出的闭包的定义是:函数与对其状态即词法环境的引用共同构成闭包。这里的“词法环境的引用”,能够简单理解为“引用了函数外部的一些变量”,例如上述例子中每次调用makeFab都会建立并返回inner函数,引用了last和current两个变量。java
Javascript和python这两门动态语言都强调一个概念:万物皆对象。天然,函数也是对象。
在Javascript里,咱们能够像操做普通变量同样,把函数在咱们的代码里抛来抛去,而后在某个时刻调用一下,这就是所谓的函数式编程。函数式编程灵活简洁,而语言对闭包的支持,让函数式编程拥有了灵魂。python
以实现一个可复用的确认框为例,好比在用户进行一些删除或者重要操做的时候,为了防止误操做,咱们可能会经过弹窗让用户再次确认操做。由于确认框是通用的,因此确认框组件的逻辑应该足够抽象,仅仅是负责弹窗、触发确认、触发取消事件,而触发确认/取消事件是异步操做,这时候咱们就须要使用两个回调函数完成操做,弹窗函数confirm接收三个参数:一个提示语句,一个确认回调函数,一个取消回调函数:ios
function confirm (confirmText, confirmCallback, cancelCallback) { // 插入提示框DOM,包含提示语句、确认按钮、取消按钮 // 添加确认按钮点击事件,事件函数中作dom清理工做并调用confirmCallback // 添加取消按钮点击事件,事件函数中作dom清理工做并调用cancelCallback }
这样咱们能够经过向confirm传递回调函数,而且根据不一样结果完成不一样的动做,好比咱们根据id删除一条数据能够这样写:ajax
function removeItem (id) { confirm('确认删除吗?', () => { // 用户点击确认, 发送远程ajax请求 api.removeItem(id).then(xxx) }, () => { // 用户点击取消, console.log('取消删除') }) }
这个例子中,confirmCallback正是利用了闭包,建立了一个引用了上下文中id变量的函数,这样的例子在回调函数中比比皆是,而且大多数时候引用的变量是不少个。 试想,若是语言不支持闭包,那这些变量要怎么办?做为参数所有传递给confirm函数,而后在调用confirmCallback/cancelCallback时再做为参数传递给它们?显然,这里闭包提供了极大便利。编程
前端很常见的一个需求是远程搜索,根据用户输入框的内容自动发送ajax请求,而后从后端把搜索结果请求回来。为了简化用户的操做,有时候咱们并不会专门放置一个按钮来点击触发搜索事件,而是直接监听内容的变化来搜索(好比像vue的官网搜索栏)。这时候为了不请求过于频繁,咱们可能就会用到“防抖”的技巧,即当用户中止输入一段时间(好比500ms)后才执行发送请求。能够写一个简单的防抖函数实现这个功能:axios
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) } }
用户点击一个表单提交按钮,前端会向后台发送一个异步请求,请求还没返回,焦急的用户又多点了几下按钮,形成了额外的请求。有时候多发几回请求最多只是多消耗了一些服务器资源,而另一些状况是,表单提交自己会修改后台的数据,那屡次提交就会致使意料以外的后果了。不管是为了减小服务器资源消耗仍是避免屡次修改后台数据,给表单提交按钮添加点击限制是颇有必要的。
怎么解决呢?一个经常使用的办法是打个标记,即在响应函数所在做用域声明一个布尔变量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函数,都是一个闭包。
“封装”是面向对象的特性之一,所谓“封装”,即一个对象对外隐藏了其内部的一些属性或者方法的实现细节,外界仅能经过暴露的接口操做该对象。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中的强大特性之一,然而至于闭包怎么使用,我以为不算是一个问题,甚至咱们彻底不必研究闭包怎么使用。个人观点是,闭包应该是天然而言地出如今你的代码里,由于它是解决当前问题最直截了当的办法;而当你刻意想去使用它的时候,每每可能已经走了弯路。