前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

JavaScript防抖节流原理与手写实现

首页2018-12-21 21:20:43Front-End
JavaScript防抖节流性能优化

一个长列表页,滚动的时候要根据当前位置算高亮的目录项。功能写完了,滚起来页面明显一顿一顿的。打开 Performance 面板录一段,scroll 回调在一秒内被调了两百多次,每次都要遍历一遍 DOM 算位置。

另一头,提交按钮被用户连点三下,后端收到三条一模一样的订单。

这两个问题的解法方向相反,一个是「等你停下来我再算」,一个是「第一下我就干活,后面的都不理」。它们对应的就是防抖和节流。这篇把两者的差别、immediate 选项的取舍讲清楚,再逐段读一遍 underscore 的节流实现。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 防抖和节流各自解决什么问题,区别到底在哪
  • 袖珍版防抖的实现,以及它丢了什么
  • 为什么需要 immediate 立即执行选项
  • 带立即执行的完整防抖实现
  • 节流的两种思路,时间戳和定时器
  • underscore 的 throttle 源码逐段拆解
  • leading 和 trailing 两个配置项分别在管什么
  • 现在的项目里该怎么选

# 一、防抖debounce

你是否在日常开发中遇到一个问题,在滚动事件中需要做个复杂计算,或者实现一个按钮的防二次点击操作。

这些需求都可以通过函数防抖动来实现。如果在频繁的事件回调中做复杂计算,很有可能导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作。

防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于 wait,防抖的情况下只会调用一次,而节流的情况会每隔一定时间(参数 wait)调用函数。

持续触发 scroll 事件时,并不执行 handle 函数,当 1000 毫秒内没有触发 scroll 事件时,才会延时触发 scroll 事件。

防抖时序示意图,持续触发期间不执行回调,停止触发后延迟 wait 毫秒执行一次

# 1.1 最简单的版本

// 防抖
function debounce(fn, wait) {    
    var timeout = null;    
    return function() {        
        if(timeout !== null)   clearTimeout(timeout);        
        timeout = setTimeout(fn, wait);    
    }
}
// 处理函数
function handle() {    
    console.log(Math.random()); 
}
// 滚动事件
// 当持续触发scroll事件时,事件处理函数handle只在停止滚动1000毫秒之后才会调用一次,也就是说在持续触发scroll事件的过程中,事件处理函数handle一直没有执行
window.addEventListener('scroll', debounce(handle, 1000));
@前端进阶之旅: 代码已经复制到剪贴板

核心逻辑只有两行,每次触发先把上一次的定时器清掉,再重开一个。只要触发间隔小于 wait,上一个定时器永远等不到执行就被清了,最后一次触发之后没人再来打断,wait 毫秒后才真正跑一次。

这版有个缺陷,setTimeout(fn, wait) 直接把函数丢进去,fn 里的 this 会指向全局对象,事件对象也拿不到。所以稍微正式一点的实现都会用 apply 转发上下文和参数。

我们先来看一个袖珍版的防抖理解一下防抖的实现。

// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
  // 缓存一个定时器id
  let timer = 0
  // 这里返回的函数是每次用户实际调用的防抖函数
  // 如果已经设定过定时器了就清空上一次的定时器
  // 开始一个新的定时器,延迟执行用户传入的方法
  return function(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}
// 可以看出,如果用户调用该函数的间隔小于wait的情况下,上一次的时间还未到就被清除了,并不会执行函数
@前端进阶之旅: 代码已经复制到剪贴板

注意这里返回的是 function 而不是箭头函数。返回箭头函数的话 this 会被绑死在定义时的作用域上,事件回调里就拿不到触发元素了。内层用箭头函数包住 func.apply(this, args) 反而是对的,因为它要借外层 function 的 this。

# 1.2 为什么需要立即执行选项

这是一个简单版的防抖,但是有缺陷,这个防抖只能在最后调用。一般的防抖会有 immediate 选项,表示是否立即调用。这两者的区别,举个栗子来说:

  • 例如在搜索引擎搜索问题的时候,我们当然是希望用户输入完最后一个字才调用查询接口,这个时候适用延迟执行的防抖函数,它总是在一连串(间隔小于 wait 的)函数触发之后调用
  • 例如用户给 interviewMap 点 star 的时候,我们希望用户点第一下的时候就去调用接口,并且成功之后改变 star 按钮的样子,用户就可以立马得到反馈是否 star 成功了。这个情况适用立即执行的防抖函数,它总是在第一次调用,并且下一次调用必须与前一次调用的时间间隔大于 wait 才会触发

这两种模式的分界线其实是「用户在不在等反馈」。搜索联想是用户还在输入,晚一点没关系;点赞按钮是用户等着看状态变化,延迟 300 毫秒他就会怀疑是不是没点上。

回到我们要解决的问题,开头那个重复提交的按钮,要的就是立即执行这一档。

# 1.3 完整实现

下面我们来实现一个带有立即执行选项的防抖函数。

// 这个是用来获取当前时间戳的
function now() {
  return +new Date()
}
/**
 * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        回调函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,是否立即调用函数
 * @return {function}             返回客户调用函数
 */
function debounce (func, wait = 50, immediate = true) {
  let timer, context, args

  // 延迟执行函数
  const later = () => setTimeout(() => {
    // 延迟函数执行完毕,清空缓存的定时器序号
    timer = null
    // 延迟执行的情况下,函数会在延迟函数中执行
    // 使用到之前缓存的参数和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 这里返回的函数是每次实际调用的函数
  return function(...params) {
    // 如果没有创建延迟执行函数(later),就创建一个
    if (!timer) {
      timer = later()
      // 如果是立即执行,调用函数
      // 否则缓存参数和调用上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
    // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
    // 这样做延迟函数会重新计时
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

对于按钮防点击来说的实现:如果函数是立即执行的,就立即调用;如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中去执行。一旦我开始一个定时器,只要我定时器还在,你每次点击我都重新计时。一旦你点累了,定时器时间到,定时器重置为 null,就可以再次点击了。

对于延时执行函数来说的实现:清除定时器 ID,如果是延迟调用就调用函数。

这段代码里最巧妙的是用 timer 是不是 null 来当「冷却中」的标志位。immediate 模式下真正的函数在 if (!timer) 分支里同步跑掉了,后面的定时器纯粹是个计时器,它到期只做一件事,把 timer 置回 null 解除冷却。

这里有个坑要注意,context 和 args 在执行完之后被置成了 null。这不是强迫症,而是为了断开闭包对上一次调用参数的引用。事件对象可能挂着整个 DOM 子树,不主动断开的话,只要这个防抖函数还挂在监听器上,那份数据就一直回收不掉。这类由闭包引起的内存问题,可以看 JS内存泄漏与垃圾回收机制完全梳理。

# 二、节流throttle

防抖动和节流要解决的问题是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行。

如下图,持续触发 scroll 事件时,并不立即执行 handle 函数,每隔 1000 毫秒才会执行一次 handle 函数。

节流时序示意图,持续触发期间每隔 wait 毫秒稳定执行一次回调

差别在图上看得很清楚。防抖那张图里,只要你不停手,中间一次都不执行;节流这张图里,你不停手它也按固定频率在执行。

那什么时候用哪个?我的判断很简单,看这个操作「中间过程有没有意义」。搜索联想的中间输入没意义,用防抖;滚动进度条、拖拽跟随、播放器进度上报,中间过程本身就是要展示的,必须用节流。

# 2.1 两种实现思路

节流有两种基础写法。时间戳版本记录上次执行的时刻,每次触发算一下过去多久了,够 wait 就执行。它的特点是第一次触发立刻执行,但最后一次触发如果不够 wait 就会被丢掉。

定时器版本是触发时如果没有定时器就设一个,wait 后执行并清空。它的特点相反,第一次要等 wait 才执行,但最后一次一定会被执行到。

underscore 的做法是把两者结合起来,用 leading 和 trailing 两个开关让调用方自己选。

# 2.2 underscore 源码拆解

fe
  • 一、防抖debounce
    • 1.1 最简单的版本
    • 1.2 为什么需要立即执行选项
    • 1.3 完整实现
  • 二、节流throttle
    • 2.1 两种实现思路
    • 2.2 underscore 源码拆解
  • 三、现在的项目里怎么用
  • 总结
  • 参考

← JavaScript事件机制冒泡捕获与事件委托JavaScript深浅拷贝原理与手写实现 →