34. JS高级-手写防抖节流函数与实现事件总线

1,359 阅读45分钟

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:coderwhy666,拉你进群参与共学计划,一起成长进步
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 在这章节,我们会总结前面JS所学,实现几个JS中的经典高频面试题:防抖节流、深拷贝和事件总线
    • 在手写这些经典题目之前,会使用各种案例来帮助大家先理清这些内容的概念,再去使用第三方库是如何实现的,有一个立体形象
    • 在使用过第三方库之后,我们会开始进行手写这些工具函数,从基础的实现到一步步优化,了解这些工具都需要从哪些角度去考虑问题,从哪些地方去优化
    • 以及工具函数对我们到底意味着什么,什么时候需要手写工具函数,什么情况下直接进行使用

一、认识防抖和节流函数

  • 防抖和节流的概念其实最早并不是出现在软件工程中,防抖是出现在电子元件中,节流出现在流体流动中
    • 而JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理
    • 而对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生
  • 那么什么是防抖节流呢?
    1. 防抖:在事件停止触发后的延迟时间内只执行一次,可以理解为游戏回城,回城无冷却,但是否达到回城生效条件,以最后一次开始回城时间为准
    2. 节流:在一定时间间隔内最多执行一次,可以理解为游戏技能,固定时间只能释放一次,冷却时无法再次使用
  • 防抖和节流函数目前已经是前端实际开发中两个非常重要的函数,也是面试经常被问到的面试题
  • 但是很多前端开发者面对这两个功能,有点摸不着头脑
    • 某些开发者根本无法区分防抖和节流有什么区别(面试经常会被问到)
    • 某些开发者可以区分,但是不知道如何应用
    • 某些开发者会通过一些第三方库来使用,但是不知道内部原理,更不会编写
  • 接下来我们会一起来学习防抖和节流函数:
    • 我们不仅仅要区分清楚防抖和节流两者的区别,也要明白在实际工作中哪些场景会用到
    • 并且会带着大家一点点来编写一个自己的防抖和节流的函数,不仅理解原理,也学会自己来编写

1.1 防抖debounce函数

  • 防抖对应的英文为debounce,在使用防抖节流的第三方库时,防抖API的名称往往为该英文,所以我们命名规范就沿用大家的做法
  • 第三方库使用防抖函数,通常都是如何使用的?
    • 假设我们需要防抖的内容为fn函数,如何使防抖作用于fn函数?
    • 通常会对fn函数包裹一层防抖函数,进而对包裹后的函数进行正常使用
    • 因为fn函数与实际操作的中间间隔了一层防抖函数,因此才可以进行拦截处理操作
  • 我们也可以直接认为这相当于一个拦截器

深度优先与广度优先搜索

图34-1 防抖函数拦截过程

//需要防抖功能的内容
const fn = ()=> {
    console.log('Hello, World!')
}
// 参数1:需要防抖功能的内容,参数2:防抖时间间隔
//假设debounce方法来自名为lodash的第三方库
const newFn = lodash.debounce(fn, 500)
// 对包裹后的防抖函数进行一系列操作
console.log(newFn)//...
  • 在正式使用手写和使用之前,我们需要对其中概念有清晰的理解,在前面只是一个粗浅的举例,还不足以让我们足够了解防抖,那么什么是防抖呢?
    • 这需要我们理解事件停止触发后的延迟时间,这句话到底什么意思?
    • 指防抖机制会等待事件的连续触发停止,然后开始计时一个延迟时间,比如 500 毫秒。如果在这 500 毫秒内事件没有再次触发,那么函数就会执行。如果在这段时间内事件再次被触发,延迟时间会重新计时
  • 那防抖有哪些具体的应用场景吗?在搜索引擎中就有它的身影
    • 在各类搜索引擎中,当我们输入内容时,会弹出对应的内容提示,这些内容提示算一种数据,由后端接口所返回
    • 在该过程中意味发生网络请求事件,如果每一个单词都有提示,说明每一次输入都会对服务器发送一次请求,输入内容一多,请求次数就多,但通常只有输入完成所返回的提示词才是我们所需要的
    • 多次网络请求实际有绝大部分是没有必要的,且多次请求会对服务器造成极大负荷(高并发),像Google这类搜索引擎,全世界是搜索量是十分夸张的,每个客户端请求数量都这么多的话,在使用基数加持下,会造成极大的浪费
  • 因此通常使用防抖功能,当用户连贯输入内容时,不会马上向服务器发起网络请求,而是内容输入结束,再一次性将内容发给服务器,进行提示词返回

深度优先与广度优先搜索

图34-2 搜索引擎提示

1.1.1 防抖应用场景

  • 而如何确定用户输入结束了,则需要手动设定时间,可以0.5s为周期结束,也可以其他时间,这些都可以在网上找到对应的研究报告,例如可以这样检索:人均打字速度(每秒几个字),进而推导出用户正常情况下,什么时间间隔停止输入代表输入结束的情况

    • 比如想要搜索一个MacBook,当我输入m时,为了更好的用户体验,通常会出现对应的联想内容,这些联想内容通常是保存在服务器的,所以需要一次网络请求
    • 当继续输入ma时,再次发送网络请求,那么macbook一共需要发送7次网络请求
    • 但是我们需要这么多次的网络请求吗?不需要,正确的做法应该是在合适的情况下再发送网络请求
    • 比如如果用户快速的输入一个macbook,那么只是发送一次网络请求,比如如果用户是输入一个m想了一会儿,这个时候m确实应该发送一次网络请求
    • 也就是我们应该监听用户在某个时间,比如500ms内,没有再次触发时间时,再发送网络请求
  • 这就是防抖的操作:只有在某个时间内,没有再次触发某个函数时,才真正的调用这个函数

  • 下方图片中,蓝色间隔是输入时间间隔,当等待时间达到对应设置限度后,触发黄色线条对应的响应函数(发起网络请求)

深度优先与广度优先搜索

图34-3 防抖等待响应图

而防抖函数远不止这一个应用场景,包括但不限于:

  1. 频繁的点击按钮,触发某个事件
  2. 监听浏览器滚动事件,完成某些特定操作
  3. 用户缩放浏览器的resize事件等等

1.2 节流throttle函数

  • 那么什么是节流?用技能冷却的概念确实已经很形象了,我们从专业的定义中再来理解一遍,会更加准确
  • 节流的核心思想是:不管事件触发的频率有多高,函数也会按照预定的时间间隔固定执行,事件触发后,不会每次都执行绑定的函数,而是每隔一个固定的时间间隔(例如 200 毫秒)才会执行一次函数,即函数被限制在特定的时间间隔内执行,即使事件在这段时间内不断被触发
  • 因为在浏览器中,很多事件可能会频繁触发,例如用户滚动页面或不断地移动鼠标。这些事件在用户交互时会被高频触发,这意味着如果我们在这些事件上绑定某些函数,函数可能会在非常短的时间内被执行数百次,从而导致浏览器的性能问题,例如卡顿或者页面反应迟缓,所以我们需要节流来优化这些问题

深度优先与广度优先搜索

图34-4 节流等待响应图

1.2.1 节流应用场景

  • 很多人都玩过类似于飞机大战的游戏,在飞机大战的游戏中,我们按下空格会发射一个子弹
    • 很多飞机大战的游戏中会有这样的设定,即使按下的频率非常快,子弹也会保持一定的频率来发射
    • 比如1秒钟只能发射一次,即使用户在这1秒钟按下了10次,子弹会保持发射一颗的频率来发射
    • 因为事件在短时间内是触发了10次的,但响应的函数只触发了一次

深度优先与广度优先搜索

图34-5 飞机大战(子弹频率固定)

  • 生活中防抖的例子:
  • 比如说有一天我上完课,我说大家有什么问题来问我,我会等待五分钟的时间
    • 如果在五分钟的时间内,没有同学问我问题,那么我就下课了
    • 在此期间,a同学过来问问题,并且帮他解答,解答完后,我会再次等待五分钟的时间看有没有其他同学问问题
    • 如果我等待超过了5分钟,就点击了下课(才真正执行这个时间)
  • 生活中节流的例子:
    • 比如说有一天我上完课,我说大家有什么问题来问我,但是在一个5分钟之内,不管有多少同学来问问题,我只会解答一个问题
    • 如果在解答完一个问题后,5分钟之后还没有同学问问题,那么就下课


表34-1 节流与防抖对比

特性节流(Throttling)防抖(Debouncing)
执行频率在事件频繁触发时,以固定的时间间隔执行在事件停止触发的延迟时间内执行一次
应用场景页面滚动、鼠标移动、窗口调整大小等高频事件输入框搜索、按钮点击防重复等需要延迟响应
目的限制执行频率,降低函数调用的次数延迟执行,确保只在停止操作后执行一次

二、第三方库实现防抖节流

2.1 Underscore库的介绍

  • 事实上我们可以通过一些第三方库来实现防抖操作,其中较为出名的有lodash或者underscore
    • Lodash:提供内置的 debounce()throttle() 函数,用于实现防抖和节流
    • Underscore:也提供了 debounce()throttle() 函数,用于实现防抖和节流,功能与 Lodash 类似,但在性能和灵活性上相对不如 Lodash,特别是在优化细节和边界情况的处理上
  • 我们可以理解成lodash是underscore的升级版,它更重量级,功能也更多,但就防抖节流部分相差不大,因此我们在这里采用underscore进行演示,安装下来的包也会更轻量级一些
  • Underscore的官网: underscorejs.org/
  • Underscore的安装有很多种方式:
    1. 下载Underscore,本地引入
    2. 通过CDN直接引入
    3. 通过包管理工具(npm)管理安装,地址:underscore - npm (npmjs.com)
  • 这里我们直接通过CDN引入,源码位于服务器中,不占据本地存储空间,作为测试更加便捷
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.7/underscore-umd-min.js"></script>
  • 在正式使用之前,我们先模拟输入框中的网络请求,监听输入框的输入操作,每一次输入都触发响应函数
<body>
  <input type="text">
  <script>
    const inputEl = document.querySelector("input")
    let counter = 0
    inputEl.oninput = function () {
      console.log(`发送第${++counter}次网络请求`)
    }
  </script>
</body>

深度优先与广度优先搜索

图34-6 模拟输入(网络请求测试)

  • 在该基础上,使用Underscore库中的防抖节流API
    • 首先需要将要触发的回调响应函数抽离出来,使用防抖节流API进行包裹,设定间隔时间
    • 再将做好准备的内容放入input输入框的输入事件之中
  • 这里可以看到,我们直接利用_来使用debounce(防抖)或者throttle(节流)函数,这是一种工具库中约定俗成的做法
    • 库的设计者会将这个库绑定为一个全局变量,通常使用字母 _ 作为这个变量的名称
    • 这种设计方式可以在JS环境中直接通过该全局变量访问该库的所有函数,之所以使用_,首先简洁,其次无实义,则所有工具函数库都可以这样使用,而不用担心该全局变量与自身库含义冲突
  • 虽然该方式很好用,但更多情况下适合测试使用,防止在正式项目中不小心产生冲突问题,而且无实义虽然从命名规范上知道来自第三方工具库,但在一定程度上也降低了阅读性(多个工具库都在该全局变量里如何区分?)
    • 所以可以在需要引用的地方将 _ 改为项目自定义的变量名称,以确保不会影响项目中的其他代码
<body>
  <input type="text">
  <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.7/underscore-umd-min.js"></script>
  <script>
    const inputEl = document.querySelector("input")
    let counter = 0
    const inputChange = function () {
      console.log(`发送第${++counter}次网络请求`)
    }
    //防抖操作(与节流二选一进行测试,注释其中之一)
    inputEl.oninput = _.debounce(inputChange,2000)
    //节流处理也是类似操作
    inputEl.oninput = _.throttle(inputChange, 2000)
  </script>
</body>

深度优先与广度优先搜索

图34-7 Underscore库的debounce效果

三、自定义防抖和节流函数

  • 通过使用第三方库使用和基础概念了解,我们已经清楚防抖与节流两者概念,以及之间的区别,那么开始让我们来手写一遍,深入了解内部的原理吧!我们会先基础实现,然后一步步进行优化

3.1 手写防抖

  • 防抖需要两个参数,参数1:目标对象、参数2:防抖时间间隔,返回防抖函数
  • 且作为工具函数,需要足够良好的提示用法,我们采用文档注释JSDoc 格式,后续讲解代码过程,我们会将该文档注释暂时省略,减少重复
/**
 * @param {function} func - 目标对象
 * @param {number} wait - 防抖间隔时间
 * @return {function} - 防抖函数
 * @author XiaoYu
 */
function debounce(func, wait) {

}

深度优先与广度优先搜索

图34-8 文档注释带来的良好提示

  • 由于返回为一个函数,因此在该函数方法内需要重新定义一个函数,用于返回
    • 内部定义的_debounce函数调用传入的函数
function debounce(func, wait) {
    const _debounce = () => {
		func()
    }
    return _debounce
}
  • 在完成对参数1的调用后,需要根据参数2进行防抖操作
  • 防抖:规定时间内重复触发则重置触发时间,超出规定时间无重复触发则执行响应函数,也就是func()
    • 如果单纯使用定时器进行延时,只会有延迟效果,但响应函数依旧会多次执行,没有实质性的改变
//错误做法
function debounce(func, wait) {
    const _debounce = () => {
        setTimeout(()=> {
            func()
        }, wait)
    }
    return _debounce
}
  • 那么,我们需要怎么做,才能让响应函数只执行一次?
    • 首先我们清楚定时器为异步宏任务,在定时阶段不由JS线程执行,而是由浏览器线程进行计时,而这就是我们能够操作的空间,一旦多次点击则取消上一次定时器任务,开始重新计时
    • 这需要我们拿到定时器的返回值timer,放入clearTimeout方法中进行取消定时
  • 取消定时需要有一定的条件判断,首先必须在规定时间内重复触发,因此我们在调用第二次_debounce时,需要取消第一次定时
    • 那我们需要如何在第二次调用中拿到第一次调用的timer?创建一个局部变量进行存储
    • 每一次调用前都将当前局部变量中的timer取消定时(上一次的timer),置为空,然后存入新的timer,反复循环。知道没有新的timer且被置为空后判断不符合清除定时条件后结束

深度优先与广度优先搜索

图34-9 setTimeout定时器返回值解释

/**
 * @param {function} func - 目标对象
 * @param {number} wait - 防抖间隔时间
 * @return {function} - 防抖函数
 * @author XiaoYu
 */
function debounce(func, wait) {
    // 保存每次定时器操作,在下一次规定时间内 重复调用提供取消信息
    let timer = null
    const _debounce = () => {
        // 排除第一次调用无timer,其余规定时刻清除上一次定时器
        if (timer) clearTimeout(timer)
        // 定时
        timer = setTimeout(()=> {
            func()
        }, wait)
    }
    return _debounce
}
  • 以上实现基础防抖效果,在该基础上,我们会进一步的优化
  • 优化一:使用防抖函数时,传入函数内可接受this和event事件参数,该this参数直接指向于绑定的元素本身
    • 在我们尚未设置时,this取决于函数调用位置,且我们内部采用箭头函数,不受this影响,因此直接指向于Window对象
    • 正常情况this会绑定于显式调用,这也是我们所需求的,元素本身调用的是_debounce函数,因此该函数不能够使用箭头函数,其次将传入的fn函数参数绑定上_debounce的this
    • 而event事件一样会通过调用方传递到_debounce,因此可以对传入的fn函数使用apply绑定对应this和传入对应剩余参数即可
//index.html
const inputEl = document.querySelector("input")
let counter = 0
const inputChange = function (event) {
  console.log(`发送第${++counter}次网络请求`,this,event)
}
inputEl.oninput = debounce(inputChange,2000)
//防抖工具类
function debounce(func, wait) {
    let timer = null
    const _debounce = function (...args){
        if (timer) clearTimeout(timer)
        timer = setTimeout(()=> {
            func.apply(this,args)//绑定this为元素本身以及event事件
        }, wait)
    }
    return _debounce
}

深度优先与广度优先搜索

图34-10 event事件和this指向元素本身显示

  • 这里可以看到,在_debounce防抖函数中,有event事件参数的传入,但我们明明没有传入,这是怎么回事?
    • 因为用户点击了 button,浏览器自动将事件对象 event 传递给绑定的事件处理函数 _debounce
    • _debounce 捕获了所有传入的参数,包括 event,并将其收集到数组 args 中。由于我们实际情况没有传入任何参数,因此浏览器自动传入的事件对象event位于剩余参数中的第一个参数
  • 以上内容,需要大家对回调概念有足够的了解,但在学习手写Promise中,已经经过大量回调的考验,我想这些不是问题,到目前为止,基本上是足以日常使用,接下来的优化都是为了进一步让防抖工具更加强大
  • 优化二:立即执行功能
    • 立即执行主要作用为在用户第一次输入内容时,先立刻请求一次,这种立即见效的效果会给用户带来先入为主的良好感观,是利用人的心理想法的一种做法
    • 这种都是需要根据具体场景所决定,因此需要给工具使用者选择模式的机会,这时候需要在第三个参数中加入选项来进行调控,该参数名命名为immediate(立即)
    • 后续防抖效果不变
  • 默认immediate参数为false,对应默认模式,当传入为true时,开启立即执行模式(初次传入内容)
    • 由于只有初次执行才需要立即执行,因此当立即执行后,将immediate参数转回false
/**
 * @param {boolean} immediate - 开启初次立即执行
 */
function debounce(func, wait, immediate) {
    let timer = null
    const _debounce = function (...args){
        if (timer) clearTimeout(timer)
        // 判断初次是否立即执行
        if(immediate){
            func.apply(this,args)
            // 初次立即执行,后续正常防抖模式
            immediate = false
        }else {
            // 正常防抖模式内容
            timer = setTimeout(()=> {
                func.apply(this,args)
            }, wait)
        }

    }
    return _debounce
}
  • 在初次执行时,确实能够做到立即执行,但还有一个问题
    • 当我们内容输入结束,超出防抖间隔时间后,在页面没有刷新的前提下,继续输入内容为防抖模式,因为后续输入内容因为immediate被调回false
    • 但超出防抖间隔重新输入内容,其实应该算作一次新的开始,依旧需要做到立刻执行,这需要我们对immediate参数进行一层判断,根据时间是否超出防抖间隔来决定是否立即执行
  • 一旦进入立即执行,说明开始执行;进入防抖模式,说明超出防抖间隔
    • 那么我们能否以immediate参数为主,立即执行后依旧转入防抖模式,而在防抖模式执行后,则转为立即执行模式
    • 这样做是可以实现我们的效果,但我们不建议这么做,因为对传入参数的不断变动,是不良的编程习惯,会引入副作用,降低代码的可读性和可维护性,破坏数据的不可变性
  • 那么原有在立即执行模式中对immediate转为false也应该去除,immediate参数不发生变化,只作为用户需求对应模式的信息表达
    • 那么,在immediate参数不再变化,我们需要如何进行if判断?
    • 再定义一个全局变量,专门用来判断当前是否是立即执行还是防抖模式,用来取代原有immediate参数的位置
    • 而immediate参数则作为立即执行函数的另一层判断,由双重判断作为保险
function debounce(func, wait, immediate=false) {
    let timer = null
    // 作为判断当前为立即执行 or 防抖模式
    let isInvoke = false
    const _debounce = function (...args){
        if (timer) clearTimeout(timer)
        // 判断初次是否立即执行
        if(immediate && !isInvoke){
            func.apply(...args)
            // 初次立即执行,后续正常防抖模式
            isInvoke = true;
        }else {
            // 正常防抖模式内容
            timer = setTimeout(()=> {
                func.apply(this,args)
                // 防抖模式结束,下次输入内容立即执行
                isInvoke = false
            }, wait)
        }
    }
    return _debounce
}

//index.html中使用
inputEl.oninput = debounce(inputChange,2000,true)
  • 优化四:取消执行功能
    • 我们前面了解防抖应用场景时,知道防抖函数不止可以运用于搜索引擎中,还可以用在各类表单上
    • 假如用户输入内容进行请求中,觉得请求时间太长或者干脆不是想要的,就点击了对应的取消请求按钮或者直接干脆退出界面了,那我们该次请求是有必要进行取消的,因为该次请求没有实际意义
  • 那么在面对该情况时,我们应该怎么做?
    • 首先函数本身也是一个对象,因此可以往函数身上添加属性方法
    • 我们可以在_debounce的身上封装取消功能,因为_debounce作用到元素本身
  • 封装函数方法名为cancel(取消),当调用该方法时,判断timer是否有值,从而推断是否有定时器正在运行准备发起网络请求,若有值则消除网络请求,若没有则无需变化。同时作为取消方法,代表着"初始化"的含义,最好将timer和isInvoke重置一下。当该工具类迭代次数起来,封装更为完善,功能更多后,则可以专门创建一个初始化函数对这些内容进行封装
// 封装取消功能
_debounce.cancel = function() {
  if (timer) clearTimeout(timer)
  timer = null
  isInvoke = false
}
//index.html
// 取消功能
const cancelBtn = document.querySelector("#cancel")//获取取消按钮
cancelBtn.onclick = function() {
  debounceChange.cancel()
}
  • 优化五:返回值的两种方案
  • 如果在使用防抖函数时,传入的内容是有可能有对应的返回值的,我们要怎么拿到这个返回值呢?
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
  // 返回值
  return "coderwhy & XiaoYu"
}
//如何拿到inputChange的返回值?
const debounceChange = debounce(inputChange, 3000, false)
  • 我们主要有两种思路:
  1. 常见的回调参数,对debounce防抖函数添加一个回调函数参数,在debounce内部将参数一inputChange的返回值接收后,调用该回调函数参数时,将该返回值传递进去
//function debounce(func, wait, immediate=false,resultCallback) .... 添加第四参数-回调函数
if(immediate && !isInvoke){
    const result = func.apply(this,args)
    // 将目标对象返回值返回后,能够进行回调执行处理
    if (resultCallback) resultCallback(result)
    isInvoke = true;
}else {
    // 正常防抖模式内容
    timer = setTimeout(()=> {
        const result = func.apply(this,args)
        // 将目标对象返回值返回后,能够进行回调执行处理
        if (resultCallback) resultCallback(result)
        isInvoke = false
    }, wait)
}
//index.html中进行处理
const debounceChange = debounce(inputChange, 2000, false, (res) => {
  console.log("拿到真正执行函数的返回值:", res)
})
  • 同时需要注意,我们这些内容是不能够在代码后面直接返回的,如果直接返回,该返回代码是同步代码,而定时器是异步代码(宏任务),错开的代码执行会导致同步代码在后面直接return一个undefined
  • 因此,如果想要直接通过常规方式,在结果后面直接"return"结果,并且能够正确接收到,我们就需要使用Promise,将这些涉及第一参数(目标对象)的内容包裹进去,再通过resolve代替return进行处理返回。这种方式则不存在同步异步代码错位的问题,Promise中的resolve会以回调的形式来在有结果时进行返回
    • 如果想要进一步完善,可以使用try...catch进行捕获处理
    • 而对应的返回值就会是一个Promise,通过then方法来接收对应的返回值进行回调处理
const _debounce = function(...args) {
  return new Promise((resolve, reject) => {
    // 取消上一次的定时器
    if (timer) clearTimeout(timer)

    // 判断是否需要立即执行
    if (immediate && !isInvoke) {
      const result = fn.apply(this, args)
      if (resultCallback) resultCallback(result)
      resolve(result)
      isInvoke = true
    } else {
      // 延迟执行
      timer = setTimeout(() => {
        // 外部传入的真正要执行的函数
        const result = fn.apply(this, args)
        if (resultCallback) resultCallback(result)
        resolve(result)
        isInvoke = false
        timer = null
      }, delay)
    }
  })
}
  • 通过Promise进行处理的话,由于我们_debounce是直接赋值给元素事件,这就没办法进行then方法回调处理了,因此需要在两者之间再添加一层函数进行处理
  • 因此在当前应用场景,使用Promise不是太好的选择,会多上一层处理,相对而言更推荐思路一的做法
const debounceChange = debounce(inputChange, 3000, false, (res) => {
      console.log("拿到真正执行函数的返回值:", res)
    })
//中间层处理then方法,再进行赋值   
const tempCallback = () => {
  debounceChange().then(res => {
    console.log("Promise的返回值结果:", res)
  })
}
inputEl.oninput = tempCallback
  • 完整代码如下:
/**
 * @param {function} func - 目标对象
 * @param {number} wait - 防抖间隔时间
 * @param {boolean} immediate - 开启初次立即执行
 * @param {function} resultCallback - 目标对象返回值的回调函数
 * @return {function} - 防抖函数
 * @author XiaoYu
 */
function debounce(func, wait, immediate=false,resultCallback) {
    // 保存每次定时器操作,在下一次规定时间内 重复调用提供取消信息
    let timer = null
    // 作为判断当前为立即执行 or 防抖模式
    let isInvoke = false
    const _debounce = function (...args){
        // 排除第一次调用无timer,其余规定时刻清除上一次定时器
        if (timer) clearTimeout(timer)
        // 判断初次是否立即执行
        if(immediate && !isInvoke){
            const result = func.apply(this,args)
            // 将目标对象返回值返回后,能够进行回调执行处理
            if (resultCallback) resultCallback(result)
            // 初次立即执行,后续正常防抖模式
            isInvoke = true;
        }else {
            // 正常防抖模式内容
            timer = setTimeout(()=> {
                const result = func.apply(this,args)
                // 将目标对象返回值返回后,能够进行回调执行处理
                if (resultCallback) resultCallback(result)
                // 防抖模式结束,下次输入内容立即执行
                isInvoke = false
            }, wait)
        }
    }

    // 封装取消功能
    _debounce.cancel = function() {
        if (timer) clearTimeout(timer)
        //初始化
        timer = null
        isInvoke = false
    }

    return _debounce
}

3.2 手写节流

  • 节流对应的英文为"throttle",初始步骤于防抖函数一致:
    1. 接收参数一:目标对象、参数二:节流时间
    2. 返回一个节流函数
    3. 节流时间内,响应函数只执行一次
  • 第一步,完成基础框架搭建,目前是任何对应操作都会调用一次响应函数func,能够正常交互
/**
 * @param {function} func - 目标对象
 * @param {number} interval - 节流间隔时间
 * @return {function} - 节流函数
 * @author XiaoYu
 */
function throttle(func, interval) {
    function _throttle() {
        func()
    }
    return _throttle
}
  • 第二步,实现在节流时间内,响应函数只执行一次
    • 这需要我们进行计算时间,判断当响应间隔时间达到节流时间后,才能继续响应
    • 这需要我们有几个参数数据:1.节流时间、2.第一次响应的时间、3.每次发起响应的时间
  • 当发起响应时间≥第一次响应时间+节流时间,说明"冷却结束了",可以继续调用了。一旦重新调用,则需要重置当前的"第一次响应时间",进而循环
    • interval:表示函数执行之间的最小间隔时间,即节流的冷却时间
    • nowTime:表示当前调用 _throttle 函数的时间(通过 new Date().getTime() 获取)
    • lastTime:记录上一次执行原始函数 func() 的时间,也就是"第一次响应时间"
  • 且因为第一次执行时,默认发起响应时间为0,remainTime为负数小于0,因此能够实现立即执行的效果
/**
 * @param {function} func - 目标对象
 * @param {number} interval - 节流间隔时间
 * @return {function} - 节流函数
 * @author XiaoYu
 */
function throttle(func, interval) {
    // 每次点击时,重置当前最新时间
    let lastTime = 0
    function _throttle() {
        // 获取当前调用时间
        let nowTime = new Date().getTime()
        // 获取冷却时间:节流间隔时间-第一次响应时间+发起响应时间
        const remainTime = interval - (nowTime - lastTime)
        //达到冷却,可以执行响应函数
        if(remainTime <= 0 ){
            func()
            //记录函数上一次被成功调用的时间戳
            lastTime = nowTime
        }
    }
    return _throttle
}
  • 因此,基础的节流函数就这样实现的,关键步骤就在于计算间隔时间:调用时间-响应时间=节流时间,接下来我们要进行优化
  • 优化一:节流第一次立即执行可选
    • 和防抖函数一致的操作,不同之处在于节流函数由于计算判断,第一次默认为立即执行,与防抖函数默认情况相反
    • 想要实现可选,需要我们继续增加节流函数参数来进行判断是否可选
    • 通过在防抖函数中编写的经验,我们清楚知道后面还需要继续增添用来判断节流最后一次是否执行获取目标对象返回值的回调函数等操作的参数
  • 因此在这里,我们采用另一种集成规范来归纳多个参数:使用对象进行收集并且根据情况赋值默认值
//使用options对象参数来归纳总结多个参数
function throttle(fn, interval, options = { leading: true, trailing: false }){
  //....
}
  • leading是头部的含义,在这里指是否需要第一次就立即执行,也可以通俗的理解为,一开始就是处于"刚放完技能的冷却状态"
    • 如果想要规避第一次执行,只需要关键判断remainTime常量为正数即可,因此我们将nowTime赋值给lastTime,则一开始remainTime就刚好等于interval(节流间隔)时间
    • 且需要满足第二个条件,那就是初始值lastTime必须为0,这意味着这是第一次执行,也是我们要处理的阶段
// 1.记录上一次的开始时间
const { leading, trailing } = options
//必须第一次执行(lastTime) 且 用户传参调节为首次不立即执行(leading)
if (!lastTime && !leading) lastTime = nowTime

//index.html
inputEl.oninput = throttle(inputChange,2000,{loading:false})
  • 通过该判断,我们成功做到传入内容时,不立即执行函数

  • 优化二:节流最后一次也可以执行

    • 在使用节流时,如果最后一次操作未被执行,那么用户的某些重要操作可能会被忽略
    • 例如在页面滚动事件中,如果不执行最后一次操作,当用户停止滚动时,可能不会触发对最终页面位置的处理,从而导致页面布局或动画未被正确更新
    • 在输入框的验证过程中,若节流函数未执行最后一次用户输入,则可能导致用户看到的页面状态与数据不一致(例如未触发最终的表单校验)
    • 高频触发事件(如滚动鼠标移动窗口调整大小)是非常消耗性能的。如果简单地每隔一段时间触发一次函数,节流可以控制频率。但如果用户停止了操作,却没有处理最后一次事件,那么某些逻辑上的操作可能会因为缺少最后一次的处理而导致不完整的行为
  • 因此,最后一次操作能够被执行,是为了保证代码的健壮性以及需求的完整性

  • 那么,我们要怎么做到最后一次操作,哪怕没有达到冷却时间,也能够被执行?

    • 首先我们需要拿到最后的剩余时间,也就是remainTime,由于没有达到冷却时间,意味着remainTime处于一个大于0但小于interval(节流间隔)的阶段
    • 我们进行判断用户是否需要最后一次执行,拿到trailing参数进行判断,同时设定一个定时器,定时interval后执行一次响应函数。之所以定时interval,是因为这是距离"冷却结束"的时间,冷却都结束了,还没有继续,我就当你已经结束调用了,自动给你调一次当前的响应函数
  • 但这里存在一个问题,定时器会不断触发,这个问题和之前防抖函数中的问题是一样的,在冷却阶段不断的输入内容,最终都会在该定时器中进行调用,在这里没有拦截住,我们只需要最后一次的执行

    • 我们可以进行一个判断,当正常情况触发,则将冷却阶段触发定时器进行释放
    • 间隔之间调用之后存在正常调用,正常调用时间晚于间隔之间调用,因此最后一次响应为正常调用,冷却时间内的定时器调用理应取消
    • 只有冷却间隔调用之后的下一个节流间隔不存在正常响应,符合条件,才能够触发定时器的固定响应
  • 要么正常执行,要么最后一次执行,在节流间隔中,不应该存在"假的最后一次执行",所有在节流间隔间打算执行的定时器都需要被清除,节流间隔是绝对不能执行,哪怕是最后一次固定响应执行也会在节流间隔结束后,才会触发

    • 一旦正常响应调用,则没必要触发定时器,因此正常响应处的最后直接return返回,不再执行定时器逻辑

深度优先与广度优先搜索

图34-11 节流最后一次调用原理

function throttle(func, interval,options= {leading: true, trailing: false}) {
    // 1.记录上一次的开始时间
    const { leading, trailing } = options
    // 每次点击时,重置当前最新时间
    let lastTime = 0
    // 记录
    let timer = null
    function _throttle() {
        // 获取"第一次"调用时间
        let nowTime = new Date().getTime()
        //必须第一次执行(lastTime) 且 用户传参调节为首次不立即执行(leading)
        if (!lastTime && !leading) lastTime = nowTime
        // 获取冷却时间:节流间隔时间-第一次响应时间+发起响应时间
        const remainTime = interval - (nowTime - lastTime)
        //达到冷却,可以执行响应函数
        if(remainTime <= 0 ){
            // 如果节流间隔后存在正常响应函数,将"最后一次响应函数"的定时器清除
            if (timer) {
                clearTimeout(timer)
                timer = null
            }
            func()
            //记录函数上一次被成功调用的时间戳
            lastTime = nowTime
            return
        }
        //节流最后一次也可以执行的判断逻辑
        if (trailing && !timer) {
            // 返回timer,只有冷却归零后还未存在正常调用,timer尚未清零才能调用,一旦调用则重置timer
            timer = setTimeout(() => {
                func()
            }, remainTime)
        }
    }

    return _throttle
}
  • 且一旦进入最后执行阶段,意味着结束,那么对原有全局变量需要进行一个初始化,以防出现bug
  • lastTime不能够简单的直接置为0,因为一旦时间卡得太过完美,容易出现细微的时间差,例如我们没有卡在节流间隔刚好结束时进行输入,很容易产生连续调用两次
    • 因此我们需要判断leading是否为true(也就是默认第一次立即调用),更新 lastTime 为当前时间的主要目的是为了确保节流间隔时间的连续性,保证响应函数的执行频率正常
    • 如果不更新为当前时间,而是保持为0,那么在下一次调用时,会被视为重新调用,则正常调用和立即调用会同时触发,导致连续调用,因此需要对leading模式进行判定,补上漏洞
    • 如果首次不立即调用,可以回归原点继续执行。如果首次立即调用,那就继续把时间线往后延后吧!立即执行只有第一次
//节流最后一次也可以执行的判断逻辑
if (trailing && !timer) {
    // 返回timer,只有冷却归零后还未存在正常调用,timer尚未清零才能调用,一旦调用则重置timer
    timer = setTimeout(() => {
        //初始化
        timer = null
        lastTime = !leading ? 0: new Date().getTime()
        //最后一次节流执行
        func()
    }, remainTime)
}
//index.html
inputEl.oninput = throttle(inputChange,2000,{leading:true,trailing:true})
  • 优化三:this参数改变以及event事件参数

    • 把防抖中对this的绑定复刻过来即可:func.apply(this, args)
  • 优化四:取消功能实现

    • 与防抖中的做法一致,在返回函数身上添加属性方法(cancel)
// 取消方法
_throttle.cancel = function() {
    // 有值的情况才进行取消
    if(timer) clearTimeout(timer)
    // 初始化
    timer = null
    lastTime = 0
}
  • 优化五:函数返回值实现
    • 与防抖中的做法一致,两种方案,这里演示防抖中说明的思路一回调处理
//主要代码
if (resultCallback) resultCallback(result)

//index.html
const _throttle = throttle(inputChange, 3000, {
    leading: false,
    trailing: true,
    resultCallback: function(res) {
        console.log("resultCallback:", res)
    }
})
  • 完整节流代码如下:
/**
 * 创建一个节流函数,在给定的时间间隔内最多执行一次目标函数
 * @param {function} func - 需要节流的目标函数
 * @param {number} interval - 节流的间隔时间(毫秒)
 * @param {Object} [options] - 节流的配置选项
 * @param {boolean} [options.leading=true] - 是否在第一次调用时立即执行
 * @param {boolean} [options.trailing=false] - 是否在冷却时间结束后追加一次执行
 * @param {function} [options.resultCallback] - 每次节流函数执行后的回调函数,用于处理目标函数的返回值
 * @return {function} - 返回包装后的节流函数
 * @author XiaoYu
 * @example
 * // 创建一个节流函数,每 1000 毫秒最多执行一次
 * const throttledFn = throttle(() => {
 *   console.log('Throttled function executed');
 * }, 1000);
 *
 * // 绑定事件
 * document.addEventListener('scroll', throttledFn);
 *
 * // 调用 cancel 方法
 * throttledFn.cancel();
 */
function throttle(func, interval,options= {leading: true, trailing: false,resultCallback}) {
    // 1.记录上一次的开始时间
    const { leading, trailing ,resultCallback} = options
    // 每次点击时,重置当前最新时间
    let lastTime = 0
    // 记录
    let timer = null
    function _throttle(...args) {
        // 获取"第一次"调用时间
        let nowTime = new Date().getTime()
        //必须第一次执行(lastTime) 且 用户传参调节为首次不立即执行(leading)
        if (!lastTime && !leading) lastTime = nowTime
        // 获取冷却时间:节流间隔时间-第一次响应时间+发起响应时间
        const remainTime = interval - (nowTime - lastTime)
        //达到冷却,可以执行响应函数
        if(remainTime <= 0 ){
            // 如果节流间隔后存在正常响应函数,将"最后一次响应函数"的定时器清除
            if (timer) {
                clearTimeout(timer)
                timer = null
            }
            const result = func.apply(this, args)
            // 返回值
            if (resultCallback) resultCallback(result)
            //记录函数上一次被成功调用的时间戳
            lastTime = nowTime
            return
        }
        //节流最后一次也可以执行的判断逻辑
        if (trailing && !timer) {
            // 返回timer,只有冷却归零后还未存在正常调用,timer尚未清零才能调用,一旦调用则重置timer
            timer = setTimeout(() => {
                timer = null
                lastTime = !leading ? 0: new Date().getTime()
                const result = func.apply(this, args)
                // 返回值
                if (resultCallback) resultCallback(result)
            }, remainTime)
        }
    }
    // 取消方法
    _throttle.cancel = function() {
        // 有值的情况才进行取消
        if(timer) clearTimeout(timer)
        // 初始化
        timer = null
        lastTime = 0
    }

    return _throttle
}

深度优先与广度优先搜索

图34-12 JSDoc文档注释对于工具函数带来的优势

  • 到目前为止,防抖节流的手写实现就告一段落了,让我们来继续往下学习手写深拷贝和事件总线吧!

四、自定义深拷贝函数

  • 前面我们已经学习了对象相互赋值的一些关系,这里进行简单的复习,分别包括:
    • 引入的赋值:指向同一个对象,相互之间会影响
    • 对象的浅拷贝:只是浅层的拷贝,内部引入对象时,依然会相互影响
    • 对象的深拷贝:两个对象不再有任何关系,不会相互影响
  • 前面我们已经可以通过一种方法来实现深拷贝了:JSON.parse
    • 这种深拷贝的方式其实对于函数、Symbol等是无法处理的
    • 并且如果存在对象的循环引用,也会报错的,对应的缺陷在学习JSON序列化和解析时也已经探讨过
//先序列化后解析
const info = JSON.parse(JSON.stringify(obj))
  • 那么,我们能不能自己来实现一个深拷贝工具函数,把JSON序列化解析无法处理的部分也处理掉

4.1 深拷贝基本功能实现

  • 深拷贝的主要功能为完全复刻一个一模一样的数据内容出来,并且与原来内容没有任何关联
    • 首先我们需要传入一个数据,最终也需要返回一个数据,两个数据值毫无瓜葛
    • 对传入数据值进行判断处理,看类型为null还是说object或者function等复杂数据类型,值必须不为null且为object或者function类型
    • 若为所需类型则遍历递归重新赋值处理,递归保证所有层数据都能够遍历到
  • 工具函数名为deepClone,将判断数据类型的逻辑抽离为一个小的工具函数进行导入deepClone进行使用,这种做法在各类源码中也经常体现
function isObject(value) {
  const valueType = typeof value
  return (value !== null) && (valueType === "object" || valueType === "function")
}

function deepClone(originValue) {
  // 判断传入的originValue是否是一个对象类型
  if (!isObject(originValue)) {
    return originValue
  }

  const newObject = {}
  for (const key in originValue) {
    newObject[key] = deepClone(originValue[key])
  }
  //返回通过递归深层遍历赋值后,全新的数据对象
  return newObject
}
// 测试代码
const obj = {
  name: "coderwhy",
  age: 18,
  friend: {
    name: "小余",
    address: {
      city: "广州"
    }
  }
}

const newObj = deepClone(obj)
//测试新数据与旧数据之间是否有关联,判断是否为同一块内存地址,判断深拷贝是否成功
console.log(newObj === obj)

obj.friend.name = "kobe"
obj.friend.address.city = "成都"
console.log(newObj)

4.2 其他类型处理

  • 深拷贝的基础实现与JSON.parse效果是差不多的,因此如果到目前为止,绝对是直接使用JSON解析来得更加方便简洁,如果需求简单,直接使用该方式会更好
  • 那么当传入的内容需要深拷贝,且为特殊类型,例如Proxy、Map、WeakMap、Set等等,多种特殊的数据结构,需要我们分类进行判断处理
    • 处理了特殊类型数据(如 SetMapSymbol、函数)以及普通对象和数组的深拷贝
    • 对于对象的Symbol 键进行单独的处理,以确保所有属性都被完整拷贝
function deepClone(originValue) {
  // 判断是否是一个Set类型(判断是否为真实某个构造函数的类型,通过instanceof)
  if (originValue instanceof Set) {
    return new Set([...originValue])
  }

  // 判断是否是一个Map类型
  if (originValue instanceof Map) {
    return new Map([...originValue])
  }

  // 判断如果是Symbol的value, 那么创建一个新的Symbol。因为Symbol是唯一的,深拷贝时需要创建新的
  if (typeof originValue === "symbol") {
    return Symbol(originValue.description)
  }

  // 判断如果是函数类型, 那么直接使用同一个函数
  if (typeof originValue === "function") {
    return originValue
  }

  // 判断传入的originValue是否是一个对象类型
  if (!isObject(originValue)) {
    return originValue
  }

  // 判断传入的对象是数组, 还是对象。因为对象与数组的数据结构稍有不同,并不完全一致
  const newObject = Array.isArray(originValue) ? []: {}
  for (const key in originValue) {
    newObject[key] = deepClone(originValue[key])
  }

  // 对Symbol的key进行特殊的处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const sKey of symbolKeys) {
    // const newSKey = Symbol(sKey.description)
    newObject[sKey] = deepClone(originValue[sKey])
  }
  
  return newObject
}
  • 根据测试代码结果,可以看到已经能够适应多种数据类型的深拷贝,每一种类型的数据都分别进行处理
    • 包括对象内的数组不会被深拷贝为对象形式(键为索引的特殊对象,但我们想要的有序的数组格式),而是直接拷贝为另一个数组
  • 如果传入类型为function函数,有两种思路:
    1. 函数也是特殊的对象,我们直接new Function()创建一个新的函数出来,这样两个函数从内存地址的角度就做到了不同,但这种做法并不好做,那哪里不好做?假如这个函数是一个非常复杂的函数(逻辑非常多还有多个参数),这是非常不好拷贝的,包括其中参数函数体需要我们使用正则表达式去处理
    2. 直接返回函数本身,但我们不是要深拷贝吗?函数的封装目的是为了能够多次调用并且复用函数体的逻辑,因此我们完全没有必要重新拷贝一个新的函数出来,直接使用同一个函数就好了,第三方库lodash就是这样处理函数的,但有时候函数可能依赖于闭包内的数据,在深拷贝时需要评估是否需要重新创建闭包环境
  • 在深拷贝过程中,原始对象的**prototype(原型链)**需要保留,以保持新对象与原对象行为一致
// 测试代码
let s1 = Symbol("aaa")
let s2 = Symbol("bbb")

const obj = {
  name: "coderwhy",
  age: 18,
  friend: {
    name: "小余",
    address: {
      city: "广州"
    }
  },
  // 数组类型
  hobbies: ["abc", "cba", "nba"],
  // 函数类型
  foo: function(m, n) {
    console.log("foo function")
    console.log("100代码逻辑")
    return 123
  },
  // Symbol作为key和value
  [s1]: "abc",
  s2: s2,
  // Set/Map
  set: new Set(["aaa", "bbb", "ccc"]),
  map: new Map([["aaa", "abc"], ["bbb", "cba"]])
}

const newObj = deepClone(obj)
console.log(newObj === obj)

obj.friend.name = "kobe"
obj.friend.address.city = "成都"
console.log(newObj)
console.log(newObj.s2 === obj.s2)


表34-2 自定义深拷贝处理类型

类型处理描述
集合类型SetMap 通过扩展运算符将集合元素或键值对拷贝到新集合中
基础类型Symbol 通过描述符创建新的 Symbol;函数直接返回引用
非对象类型对于非对象类型(如数字、字符串等),直接返回值
对象类型创建新的对象或数组,通过递归遍历深拷贝所有属性,包括 Symbol

4.3 循环引用处理

  • 深拷贝中的循环引用处理问题是一个常见的复杂问题,特别是在递归拷贝对象时。循环引用是指一个对象内部的属性指向了对象自身,或者多个对象之间互相引用,形成了一个循环的结构
  • 例如objAobjB 互相引用,形成了一个循环引用。如果直接对这样的对象进行深拷贝,递归会进入无限循环,导致栈溢出(Stack Overflow)
//循环引用是指对象内部存在指向自身或彼此的引用,导致出现一个循环结构
const objA = {};
const objB = {};
objA.ref = objB;
objB.ref = objA;
  • 不过更常见的还是对象自身的引用
//person 对象有一个属性 self,指向了自身,形成了循环引用
const person = {
  name: 'XiaoYu',
};
person.self = person;  // person 对象自身的引用
  • 前面实现深拷贝时,我们使用递归的方法来遍历对象的每一个属性并进行拷贝,虽然该方式很好用。但循环引用会导致递归进入无限循环,因为:

    • 在递归的过程中,深拷贝函数会不断访问对象本身,因为递归原理是在自身运行的前置条件下继续调用自身,会导致本该结束的任务无法结束,一旦无限调用下去,迟早会消耗完电脑的内存

    • 因为对象有循环引用,递归永远不会到达终点,从而造成程序崩溃,内存耗尽,甚至浏览器或服务器进程崩溃

  • 为了防止对象自身的引用问题,我们使用WeakMap来达成目的,因为它适合用作深拷贝的记录,本身具备弱引用的性质,不会妨碍垃圾回收机制,一旦原对象没有引用,它就会自动从 WeakMap 中被移除,不会造成内存泄漏

    • 在深拷贝过程中,将每一个对象都记录在 WeakMap 中,是原对象,是拷贝后的新对象
    • 当需要递归拷贝一个对象的属性时,首先检查 WeakMap 是否已经存在该对象,如果存在,说明它已经被拷贝过,直接返回 WeakMap 中保存的新对象,避免了重复递归
  • new WeakMap()直接就作为了deepClone函数的默认参数了,意味着一旦发生递归调用 deepClone 时,这个 WeakMap 都会继续传递下去

    • 这比直接放在全局更加合适,因为放在全局是销毁不掉的
    • 而放在deepClone函数内部也有问题,第二次递归都会是一个新的map,失去了全局的一个优势
    • 这也是促使我们放入默认参数的原因,利用递归性质,能够保证默认参数下的WeakMap保持传递
function deepClone(originValue, map = new WeakMap()) {
  // 判断是否是一个Set类型
  if (originValue instanceof Set) {
    return new Set([...originValue]);
  }

  // 判断是否是一个Map类型
  if (originValue instanceof Map) {
    return new Map([...originValue]);
  }

  // 判断如果是Symbol的value, 那么创建一个新的Symbol
  if (typeof originValue === "symbol") {
    return Symbol(originValue.description);
  }

  // 判断如果是函数类型, 那么直接使用同一个函数
  if (typeof originValue === "function") {
    return originValue;
  }

  // 判断传入的originValue是否是一个对象类型
  if (!isObject(originValue)) {
    return originValue;
  }

  // 检查是否已经存在于 WeakMap 中,存在则直接取出来使用,避免重复拷贝(处理循环引用)
  if (map.has(originValue)) {
    return map.get(originValue);
  }

  // 判断传入的对象是数组还是对象,创建新对象
  const newObject = Array.isArray(originValue) ? [] : {};
  
  // 将原对象和新对象的引用关系存储到 WeakMap 中,以键值对形式
  //如果在后续递归中再次遇到相同的对象,就可以直接使用,而不需要再次拷贝
  map.set(originValue, newObject);

  // 递归拷贝属性
  for (const key in originValue) {
    if (originValue.hasOwnProperty(key)) {
      newObject[key] = deepClone(originValue[key], map);
    }
  }

  // 对 Symbol 的 key 进行特殊处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue);
  for (const sKey of symbolKeys) {
    newObject[sKey] = deepClone(originValue[sKey], map);
  }
  
  return newObject;
}
  • 因为map.has(originValue)的判断检测,所以每个对象只会被拷贝一次,所有对同一对象的引用都指向同一个拷贝的对象,保持了数据结构的完整性和正确性

五、自定义事件总线

  • 事件总线(Event Bus)是一种设计模式或机制,用于在不同组件之间传递消息或数据,通常用于实现解耦合的通信。通过发布-订阅(publish-subscribe)模式来实现

    • 核心目的是:组件之间能够互相通信,而不需要直接引用对方
    • 这是Vue2所采纳的方法,但在Vue3中被移除掉了,因为这不应该属于Vue的功能,与Vue的特性不够贴切,但Vue3可以使用外部库(如 mitt)或者手动实现事件总线
  • 既然都被移除掉了,那还有必要进行学习吗?我认为其中蕴含的思想值得借鉴,这对于我们之后学习组件通信状态管理工具会很有好处,在这里我们不学习各种边界的处理,而是主攻理念相通部分

  • 详细的源码学习可以从GitHub中进行:github.com/coderwhy/hy…

    • 社区也有像mitt等优秀的第三方库可以学习参考
    • 没有项目一开始就是完美的,很多框架、第三方库都是一开始发到GitHub等社区,因各种好的idea,吸引来各类程序员进行参与贡献,不管是提出issue或者是PR等方式,经过多次迭代,努力打造得更好
  • 自定义事件总线属于一种观察者模式,其中包括三个角色:

    • 发布者(Publisher):发出事件(Event)
    • 订阅者(Subscriber):订阅事件(Event),并且会进行响应(Handler)
    • 事件总线(EventBus):无论是发布者还是订阅者都是通过事件总线作为中台的
  • 观察者模式,是可以做到没有事件总线的,直接A过去观察B的变化,B变化后直接通知A,不经过C(事件总线),AB直接产生关系

  • 设计模式是很多的,主要有23个,想要体系化学习可以去看由机械工业出版社所出版的《设计模式:可复用面向对象软件的基础》,也被称为大黑书系列之一

深度优先与广度优先搜索

图34-13 事件总线角色图

  • 我们也可以实现自己的事件总线,主要实现以下三大模块:
    • 事件的监听方法on
    • 事件的发射方法emit
    • 事件的取消监听off
  • 由于事件总线会在不同组件中进行使用,所以我们通常采用类形式进行编写,可以实例化对象进行使用,复用度高
  • 首先依旧是搭建出整体框架
class HYEventBus {
    constructor() {
    }

    on(eventName, eventCallback, thisArg) {
    }
    off(eventName, eventCallback) {
    }
    emit(eventName, ...payload) {
    }
}

const eventBus = new HYEventBus()
  • 三个方法都有对应参数,无返回值
    • on:监听特定事件,当该事件被触发时,会调用指定的处理函数
    • emit:发射特定事件,通知所有订阅了该事件的监听器
    • off:取消对某个事件的监听


表34-3 事件监听主要三方法

方法参数类型与描述
oneventName: string:事件名称 listener: function:处理函数
emiteventName: string:事件名称 ...args: any:可选参数,事件数据
offeventName: string:事件名称 listener: function:要取消的处理函数
  • 我们来模拟一个场景,在main.js中进行发射事件,在utils.js中进行监听
    • 在使用回调函数时,我们更推荐直接使用function,而非箭头函数,因为该方法可以绑定this,而箭头函数是不受this影响的
// main.js
eventBus.on("abc",function (){
})
// utils.js
eventBus.emit("abc",123)
  • 通常情况下,我们会在constructor中初始化一个空对象,用来存储信息
    • on方法中,在该对象中存储键值对,键是传入的事件名,值是事件处理函数,形成关联
    • 通常第一次取的时候,什么都没有,因为还没有存入事件名和对应的事件处理函数,这时候可以进行一个判断,若为空则默认存入一个数组,这数组中后续用来存放事件处理函数和对应this(通过键,存入对应的数组值)
  • off 方法中,我们的目标是找到特定的回调函数并将其从事件处理函数数组中删除。如果直接遍历原数组 handlers 并在遍历过程中进行删除操作,这样会对数组本身的长度和顺序产生影响,导致一些问题
    • 跳过某些元素:当删除数组中的某个元素时,数组会重新排列,后续的元素会向前移动,这会导致在遍历时跳过某些元素,进而导致无法正确找到并删除所有匹配的回调函数
    • 遍历索引错乱:数组删除元素后,数组长度减小,如果不小心继续用原索引遍历,可能会导致超出边界遍历不完全的问题
    • 所以我们通过扩展运算符创建副本数组newHandlers来用于遍历,但实际操作依旧针对原数组,遍历时进行删除不会导致索引发生变化进而引起长度和顺序问题
on(eventName, eventCallback, thisArg) {
  // 从事件总线中获取指定事件的处理函数数组(handlers)
  let handlers = this.eventBus[eventName];
  
  // 如果指定事件名的处理函数数组不存在,则创建一个空数组
  if (!handlers) {
    handlers = [];
    this.eventBus[eventName] = handlers;
  }

  // 将新的处理函数和对应的this上下文作为对象添加到事件的处理函数数组中
  handlers.push({
    eventCallback,
    thisArg
  });
}

off(eventName, eventCallback) {
  // 获取指定事件名的处理函数数组
  const handlers = this.eventBus[eventName];
  
  // 如果指定事件名没有处理函数数组,则直接返回
  if (!handlers) return;

  // 复制当前的处理函数数组,防止遍历过程中发生数组的修改(边界处理)
  const newHandlers = [...handlers];
  
  // 遍历所有的处理函数
  for (let i = 0; i < newHandlers.length; i++) {
    const handler = newHandlers[i];
    // 如果找到指定的处理函数,则从原数组中删除
    if (handler.eventCallback === eventCallback) {
      const index = handlers.indexOf(handler);
      handlers.splice(index, 1);
    }
  }
}

emit(eventName, ...payload) {
  // 获取指定事件名的处理函数数组
  const handlers = this.eventBus[eventName];
  
  // 如果没有处理函数数组,则直接返回
  if (!handlers) return;

  // 遍历所有的处理函数,并调用它们,传递payload参数
  handlers.forEach(handler => {
    // 使用apply方法,确保处理函数的上下文是注册时传入的thisArg
    handler.eventCallback.apply(handler.thisArg, payload);
  });
}
  • 接着能够正常使用,总体方法对应逻辑如下:
    • on注册事件,存储事件名、对应回调函数
    • emit用于触发事件,将on存入数组中的处理函数进行遍历调用
    • off从对应事件名中,移除不需要的事件处理函数
// main.js
eventBus.on("abc", function() {
  console.log("监听abc1", this)
}, {name: "why"})

const handleCallback = function() {
  console.log("监听abc2", this)
}
eventBus.on("abc", handleCallback, {name: "why"})

// utils.js
eventBus.emit("abc", 123)

// 移除监听
eventBus.off("abc", handleCallback)
eventBus.emit("abc", 123)

剧终

  • 通过防抖节流函数、深拷贝、事件总线的手写,我们其实能够一定程度上察觉到工具函数的精髓,在实现基础功能后,会做出非常多的边界处理,以及优化流程。而这也是为什么平时更多时候使用第三方库而非自己手写,手写固然好,但平时更追求效率和稳定性,因此当遇到类似以下问题:我为什么要用这个工具函数的第三方库呢?自己实现不也很简单吗?
    • 心里想必拥有了答案,但了解追求对计算机领域每一个细节的精神是值得去保持的
    • 我们常说"了解真相,才能获得真正的自由",而浙江大学的翁恺老师也说过,计算机之中没有黑魔法,所有的代码都是人写出来的,总有一天,我们会全部搞明白
  • 那么在这里和大家进行共勉学习进步,本次JS高级系列很高兴也很荣幸和大家一起度过为期数月的共学计划,当学完该系列后,对JS也会多上一些了解,并且在未来会了解得更多,因为JS是一门非常具备潜力的语言
  • 保持对知识的探索欲,能让我们走得更远,当我们觉得这门语言的所有内容都学习完,没有新东西的时候,则是我们局限住自己的时候,固步自封往往最难打破,把自己定义为一个coder,保持终身学习的做法,能让我们受益终生