防抖节流不是背原理,是把 leading/trailing/maxWait 这套边界设计清楚

防抖和节流的手写实现,团队里但凡面过几次前端的人基本都能背出来。上个月给一个搜索组件的防抖逻辑补单元测试,把 leading、trailing 两个选项的组合逐一过了一遍,才发现"一次点击和连续点击分别执行几次"这种问题,光凭脑子里那套"等停下来再执行"的直觉根本推不出答案——lodash 文档里这几个选项交叉起来的行为,比项目里那份手写实现能覆盖的场景复杂一个量级。手写版本大多只处理了"等停下来再执行"这一种最朴素的行为,遇到稍微复杂点的业务预期——首次要不要立即响应、连续触发多久之后必须强制执行一次、组件卸载时挂起的调用该扔还是该提前兑现——手写版本要么没考虑,要么考虑得含糊,这些恰恰是 lodash 这类工具库花了大量篇幅去补的这些容易被忽略的选项组合。

leading 和 trailing 不是二选一,是四种组合

大多数人对 leading 的理解停留在"第一次立即执行",对 trailing 的理解停留在"默认行为,等停下来再执行"。这两个开关其实是独立的两个维度,true/false 交叉起来一共四种组合,行为差别很大,直接决定了一个防抖函数在业务里能不能用对。

先说最常见的默认组合,leading: false, trailing: true

1const debounced = debounce(fn, 300)
2// 等价于 debounce(fn, 300, { leading: false, trailing: true })

这就是最原始那版实现的行为——不管调用多少次,只有停下来满 300ms 之后才执行一次,用的是最后一次调用的参数。

leading: true, trailing: false 是另一个极端:第一次调用立即执行,接下来 300ms 内不管再调用多少次都被吞掉,一次都不会在尾部补执行:

1function debounce(fn, wait, options = {}) {
2  const { leading = false, trailing = true } = options
3  let timer = null
4  let lastArgs = null
5  let lastThis = null
6
7  function invoke() {
8    fn.apply(lastThis, lastArgs)
9    lastArgs = lastThis = null
10  }
11
12  function debounced(...args) {
13    lastArgs = args
14    lastThis = this
15    const isFirstCall = !timer
16
17    if (isFirstCall && leading) {
18      invoke()
19    }
20
21    clearTimeout(timer)
22    timer = setTimeout(() => {
23      if (trailing && !(isFirstCall && leading)) {
24        invoke()
25      }
26      timer = null
27    }, wait)
28  }
29
30  return debounced
31}

这段代码有个地方容易写错:isFirstCall && leading 这个判断必须在 setTimeout 回调触发时也保持"当初那次调用是不是第一次"的记忆,不能用 timer 是否存在去实时判断,因为等回调真正执行的时候 timer 早就不是 null 了。我第一次写这个版本时图省事直接在回调里判断 !timer,结果 leading: true, trailing: false 组合下尾部还是会多执行一次,调了半天才发现是这个时序问题——闭包变量捕获的是调用那一刻的状态,不是执行那一刻的状态,这两个时间点在防抖场景里天然是错开的。

最有意思的是 leading: true, trailing: true 同时开启这种组合,也是写测试用例时最容易把自己绕进去的那道题。单独一次调用,只会触发一次(leading 那次),因为没有后续调用可以让 trailing 有事可做;但连续多次调用,会变成两次:开头立即执行一次,停下来之后再补执行一次,用的是最后一次调用的参数。这个组合在"按钮防重复提交"这类场景里其实很贴切:第一下立刻响应给用户看,如果用户手抖点了好几下,最后那次的最新状态也不会丢。lodash 的 throttle 默认给的就是这个组合,这不是巧合,下面会讲到为什么。

这四种组合不是靠脑内推演就能放心的,我习惯拿控制台跑一遍再下结论。验证 leading: true, trailing: true 单次调用只执行一次:

1const fn = debounce((x) => console.log('执行', x), 300, { leading: true, trailing: true })
2fn('a')
3// 立即打印一次 "执行 a"
4// 等 300ms 之后控制台不会再有第二行输出——因为没有第二次调用,trailing 没有新参数可用

再验证连续调用会变成两次,且第二次用的是最后的参数:

1const fn2 = debounce((x) => console.log('执行', x), 300, { leading: true, trailing: true })
2fn2('a')            // 立即打印 "执行 a"
3setTimeout(() => fn2('b'), 100)   // 100ms 时再调一次,不满 300ms 不会触发新的 leading
4// 大约 400ms 时(100 + 300)打印 "执行 b",一共两行输出,不是三行

这段代码里最容易看漏的地方是:第二次调用 fn2('b') 本身并不会立即执行,因为它落在第一次调用之后的 300ms 窗口内,isFirstCall 判断为假,只有等窗口到期后 trailing 补上一次,用的是 'b' 这个最新参数。如果把这段代码里的 setTimeout(() => fn2('b'), 100) 改成 setTimeout(() => fn2('b'), 350),因为已经超过了 300ms 窗口,第二次调用会被当成全新的一轮,同样触发 leading 立即执行,这时候一共会打印三行——"两次调用是否落在同一轮防抖窗口内"这条判断,光看选项名字是看不出来的,必须紧盯着时间线过一遍。

maxWait:没有它,持续触发的函数可能永远不执行

防抖有个理论上的陷阱:如果事件触发的间隔一直小于 wait,计时器会被无限次重置,函数就永远不会执行。这不是危言耸听,resize 拖拽窗口、scroll 平滑滚动这类场景,只要用户手速够连贯,两次事件间隔完全可能长期小于 300ms,纯防抖的话,图表重新布局这种操作可能拖到用户彻底停手才姗姗来迟,体验上会显得反应迟钝。

maxWait 就是补这个洞的。它的语义是:不管中间触发多频繁,从第一次调用算起,最多等 maxWait 这么久,必须执行一次。实现上要多维护一个"本轮开始时间":

1function debounce(fn, wait, options = {}) {
2  const { leading = false, trailing = true, maxWait } = options
3  let timer = null
4  let lastArgs = null
5  let lastThis = null
6  let lastInvokeTime = 0
7  let callStartTime = 0
8
9  function invoke(time) {
10    fn.apply(lastThis, lastArgs)
11    lastInvokeTime = time
12    lastArgs = lastThis = null
13  }
14
15  function debounced(...args) {
16    const now = Date.now()
17    lastArgs = args
18    lastThis = this
19
20    if (!timer) {
21      callStartTime = now
22      if (leading) invoke(now)
23    }
24
25    clearTimeout(timer)
26
27    const sinceStart = now - callStartTime
28    const remainingMax = maxWait !== undefined ? maxWait - sinceStart : Infinity
29
30    timer = setTimeout(() => {
31      timer = null
32      if (trailing && lastArgs) invoke(Date.now())
33    }, Math.min(wait, remainingMax))
34  }
35
36  return debounced
37}

关键在 Math.min(wait, remainingMax) 这一行:正常情况下等待时间就是 wait,但如果距离本轮开始已经过去了很久、快要撞上 maxWait 上限,就把等待时间缩短,逼一次提前执行。callStartTime 只在计时器从空到有的那一刻才刷新,中间反复触发不会推迟它,这样才能保证"最多等 maxWait"这句话不被打破。

maxWait 的效果同样值得跑一遍验证,不然很容易把"maxWait 生效"和"trailing 本身就会执行"这两件事搞混。构造一个持续高频触发、间隔恒小于 wait 的场景:

1const fn3 = debounce((n) => console.log('执行', n, Date.now()), 300, { maxWait: 1000 })
2let i = 0
3const iv = setInterval(() => {
4  fn3(i++)
5  if (i > 40) clearInterval(iv)
6}, 100) // 每 100ms 触发一次,持续 4 秒,间隔恒小于 300ms 的 wait

不设 maxWait 的话,这段代码里的 fn3 在整个 4 秒里一次都不会执行,因为每 100ms 就有新调用把计时器重置一次,直到最后一次调用后再等 300ms 才会触发唯一一次输出。加上 maxWait: 1000 之后,输出会变成大约每 1000ms 一次,中间那些还在连续触发的调用不会打断这个节奏——这就是 remainingMax 那段逻辑在起作用:一旦发现再拖下去会撞上 1000ms 上限,就提前把等待时间缩短,逼一次执行,然后重新开始计算下一轮的 1000ms 上限。这个验证也顺带说明了 maxWaitwait 谁更小谁说了算——maxWait 只在快要触顶的时候才会插手,正常节奏下该等的还是等满 wait

throttle 其实是 debounce 的一个特例

节流最朴素的实现是时间戳判断"距上次执行是否超过间隔",这是很多人第一次接触节流时写的版本,直观但和 debounce 完全是两套代码。lodash 的做法更省事,也更能体现这两个概念的本质关系:throttle 就是 maxWait 等于 wait 的 debounce

想明白为什么,回到 maxWait 的语义——"不管怎么触发,最多等这么久必须执行一次"。当 maxWait 缩小到和 wait 相等时,意味着"再怎么连续触发也不能拖过 wait",这正好就是节流"固定频率最多执行一次"的定义。用上面那份 debounce 实现直接复用:

1function throttle(fn, wait, options = {}) {
2  const { leading = true, trailing = true } = options
3  return debounce(fn, wait, { leading, trailing, maxWait: wait })
4}

注意 throttle 默认把 leading 设成 true,这也是符合直觉的——节流一般希望第一下立刻有反应,不然会显得"卡了一下才开始"。这份实现拿去跑一下之前那个验证套路,往一个高频定时器里塞调用、量固定窗口内放行几次,结果和手写时间戳版是一致的,因为 maxWait 强制介入那条路径在持续触发下会稳定地按 wait 的节奏放行——两者殊途同归,只是 debounce+maxWait 这条路径的状态机更统一,leadingtrailingmaxWait 三个维度可以任意搭配,不需要给节流单独再维护一套判断代码。这也是为什么成熟的工具库宁可多绕一层抽象,也不愿意维护两份平行的时序控制逻辑——所有的时序判断收敛到一处,出了 bug 只用改一个地方,两个函数的行为保证也天然一致。

cancel 和 flush:状态机要保留什么

上一次接触防抖的时候,cancel() 只是简单地 clearTimeout 一下,这次要补的是 flush()——强制立即执行一个还挂着的调用,常见场景是"页面即将卸载但有一次防抖还没触发,希望这次操作不要丢",比如表单自动保存这类需求,用户切标签页离开前,最后一次编辑不该被防抖吞掉。

要支持 flush,防抖函数内部不能只存一个 timer,还得把"如果现在执行该用什么参数、用什么 this"存下来,也就是上面代码里已经出现的 lastArgslastThiscancelflush 只是对这份状态做不同的收尾:

1debounced.cancel = function () {
2  clearTimeout(timer)
3  timer = null
4  lastArgs = lastThis = null
5}
6
7debounced.flush = function () {
8  if (timer) {
9    clearTimeout(timer)
10    timer = null
11    if (lastArgs) invoke(Date.now())
12  }
13}

cancel 是把挂起的这次作废,flush 是把挂起的这次提前兑现,两者共享同一份"挂起状态",区别只在于最后是清空还是执行。这个设计思路一旦理清楚,"debounce 支持 cancel/flush"就不再是背下来的两个方法名,而是自然而然从"必须持久化调用现场"这个前提推出来的结果——凡是需要支持"取消"或"提前执行"的异步控制结构,本质上都要面对同一个问题:怎么把"将来要做的事"完整地存起来,而不是让它随着调用栈弹出就丢失。这一点在 Promise 的取消令牌、状态机库的挂起任务里都是同一套思路,防抖只是其中最简单的一个样本。

组件销毁时该调用哪个,取决于业务语义而不是习惯动作。表单自动保存这种"数据不能丢"的场景,销毁前应该 flush,把最后一次编辑落盘;纯粹的搜索联想这种"用户都走了,结果给谁看"的场景,cancel 更合适,没必要在组件已经销毁的情况下还发一次无意义的请求。这两个方法看着对称,实际决策完全不对称,混着用反而会出新问题——我见过有人图省事统一在 beforeDestroy 里都调 flush,结果一个刷新计数器的防抖函数在组件卸载瞬间又执行了一次,往一个已经不存在的 DOM 节点上写文本,控制台报了一堆警告。

防抖挡不住的乱序:搜索请求的竞态

防抖能做的事情,是把"高频触发"压缩成"停下来之后发一次",但它管不住已经发出去之后的事。搜索框接了防抖之后,用户输入 vue 停顿了一下又追加成 vue3,如果两次都触发了请求(防抖间隔比较短,或者用户输入节奏正好卡在边界上),后发的 vue3 请求完全可能因为后端处理时间不同,比先发的 vue 请求更早返回。这时候如果只是简单地"谁回来就渲染谁",界面上最终展示的是 vue 的结果,尽管输入框里明明是 vue3

AbortController 是常见的第一层修补,发新请求前先把上一个未完成的请求中断掉:

1let controller = null
2
3const search = debounce(async (keyword) => {
4  controller?.abort()
5  controller = new AbortController()
6
7  try {
8    const res = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal })
9    render(await res.json())
10  } catch (err) {
11    if (err.name !== 'AbortError') throw err
12  }
13}, 300)

这段代码在浏览器发请求层面确实能掐断旧请求,但它有个前提:中断信号必须传导到位。如果接口走的是某些不支持 signal 透传的旧封装(比如内部套了一层不支持 AbortController 的请求库,或者请求经过了缓存层,缓存命中时根本不会真正发起网络请求、abort 也就无从谈起),旧请求依然可能在某个你看不见的角落完成并把结果塞进一个共享的响应处理函数里。

更稳的兜底方式是不依赖网络层的中断能力,而是在应用层维护一个自增的请求序号,只认最后发出的那个序号对应的结果:

1let requestSeq = 0
2
3const search = debounce(async (keyword) => {
4  const seq = ++requestSeq
5
6  const res = await fetch(`/api/search?q=${keyword}`)
7  const data = await res.json()
8
9  if (seq !== requestSeq) return // 有更新的请求已经发出,这次结果作废
10  render(data)
11}, 300)

这个写法不需要真的中断网络请求,旧请求该发还发、该收还收,只是它的结果在渲染前被序号挡住了,不会污染界面。它比 AbortController 更朴素也更可靠,因为它不依赖底层请求库是否正确支持中断信号,唯一的前提是"发起请求"和"渲染结果"之间必须能拿到同一个闭包里的 seq 变量,这个前提在绝大多数封装方式下都能满足。生产环境里我倾向两层都上:AbortController 能省掉浏览器端已经发出但还没用上的那部分网络开销,序号判断则是最后一道兜底,哪怕中断没生效,结果也不会渲染错。

这里还有一个容易被忽略的角落:如果搜索结果要写入的不是一个简单变量、而是一个共享的列表状态(比如联想词直接 push 进一个数组,而不是整体替换),单纯判断序号还不够,因为哪怕挡住了渲染,某些副作用(写缓存、上报埋点)可能已经在判断之前就执行掉了。稳妥的做法是把"序号校验"放在尽可能靠前的位置,所有可能产生副作用的语句之前,而不是只挡在最后一步的渲染函数入口。

序号方案还有个变体值得一提,就是把"只认最新"换成"必须按顺序应用"。多数联想搜索场景确实只关心最新结果,丢弃过期响应没有副作用;但如果这个防抖包裹的异步操作是一连串必须顺序生效的状态变更(比如连续的表单自动保存,每次保存都是在上一次的基础上做增量更新),简单粗暴地按序号丢弃旧结果就不对了,因为哪怕旧请求"过期",它承载的变更依然需要被应用,只是应用的顺序不能乱。这种场景需要一个真正的请求队列,保证响应按发出的顺序依次处理,哪怕后发的先回来,也要等前面的都处理完才轮到它:

1let queue = Promise.resolve()
2
3const save = debounce((payload) => {
4  queue = queue.then(() => doSave(payload))
5  return queue
6}, 500)

这里用 queue 这个变量把每一次保存串成一条链,新的保存请求永远追加在上一个 .then() 之后,不管网络返回顺序如何,真正落盘的执行顺序永远和发起顺序一致。这个写法的代价是牺牲了并发——所有保存操作变成完全串行,吞吐量比"谁先回来算谁"要低,所以只应该用在"顺序错误的代价大于等待时间"的场景,日常搜索联想这种容错度高的场景没必要上这么重的方案。判断该用序号丢弃还是顺序队列,本质上是在问一句:过期的那次操作,是可以直接扔掉,还是必须排队等着生效。

单元测试防抖节流函数,真实时钟等不起

前面验证选项组合都是拿控制台跑,真拿到单元测试里就不能真的等 300ms、1000ms 了——测试套件跑几百个用例,每个都真等几百毫秒,整体跑起来会慢得没法接受。Jest 提供的假定时器能把这个问题解决掉,把 setTimeout/clearTimeout 换成可以手动"拨动"的假实现:

1jest.useFakeTimers()
2
3test('trailing 模式下只在停止调用之后执行一次', () => {
4  const spy = jest.fn()
5  const debounced = debounce(spy, 300)
6
7  debounced('a')
8  debounced('b')
9  debounced('c')
10
11  expect(spy).not.toHaveBeenCalled() // 还没到 300ms,一次都不该执行
12
13  jest.advanceTimersByTime(300) // 手动把时间拨快 300ms,不是真的等
14
15  expect(spy).toHaveBeenCalledTimes(1)
16  expect(spy).toHaveBeenCalledWith('c') // 用的是最后一次调用的参数
17})

jest.advanceTimersByTime 这一行是关键,它不需要测试真的暂停 300ms,而是让 Jest 内部维护的一份虚拟时钟直接往前跳,所有注册在这段时间内该触发的 setTimeout 回调会被同步执行完。这样一条防抖测试跑下来是毫秒级的,哪怕有几百条用例覆盖各种 wait 取值,整个测试套件也不会因为这些定时器变慢。

测试 maxWait 这类需要模拟"持续触发但间隔恒定"的场景,可以配合 jest.advanceTimersByTime 分段推进,模拟真实的触发节奏:

1test('maxWait 保证持续触发下也会按时执行', () => {
2  const spy = jest.fn()
3  const debounced = debounce(spy, 300, { maxWait: 1000 })
4
5  for (let i = 0; i < 12; i++) {
6    debounced(i)
7    jest.advanceTimersByTime(100) // 每次只推进 100ms,小于 wait,模拟持续触发
8  }
9
10  expect(spy).toHaveBeenCalled() // 12 次 * 100ms = 1200ms,中途必然撞上 maxWait
11})

这种分段推进能验证的东西比一次性跳到底更有说服力——它模拟的是"事件确实一直在发生",而不是"时间突然消失了 1200ms",两者对状态机内部记录的"最近一次调用时间"这类字段的影响是不一样的,一次性跳过去的写法反而可能掩盖某些只有在真实持续触发下才会暴露的时序 bug。测试 cancelflush 也是类似思路:调用后立即断言状态,不依赖真实时间流逝,唯一要小心的是每个测试用例结束后记得 jest.clearAllTimers(),不然上一条用例挂起的定时器可能串到下一条用例里,报出没有关联的奇怪失败。

Vue 组件里,防抖函数和响应式数据容易打架

我们项目主力还是 Vue 2.6,watch 配合防抖是个常见组合,写法上有个坑之前没细想过:watch 的回调如果直接包一层防抖,immediate: true 这个选项就会失效,因为组件创建时那次立即触发的调用,同样会被防抖逻辑塞进定时器里等待,而不是真的立即执行。

1export default {
2  watch: {
3    keyword: {
4      // 错误示范:immediate 和防抖叠在一起,immediate 形同虚设
5      handler: debounce(function (val) {
6        this.doSearch(val)
7      }, 300),
8      immediate: true
9    }
10  }
11}

组件挂载那一刻,immediate 触发的调用照样要走一遍 debounce 的排队逻辑,实际执行时间还是 300ms 之后,如果这个搜索接口是页面首屏就要展示的数据,用户会看到一个明显的空白等待,跟直接不设 immediate 没有区别。想让首次真正立即执行、后续输入才走防抖,得把 debounceleading: true 用上,或者更直接地把首次调用从防抖里摘出来单独处理:

1created() {
2  this.debouncedSearch = debounce(this.doSearch, 300)
3},
4watch: {
5  keyword(val) {
6    this.debouncedSearch(val)
7  }
8},
9mounted() {
10  this.doSearch(this.keyword) // 首次直接调用原始函数,不经过防抖
11}

还有一处常被忽略的细节是防抖函数和 this.$nextTick 的执行顺序。Vue 2 的响应式更新是异步的,数据变化之后 DOM 不会立刻更新,要等到下一个 tick。如果防抖回调里既要读最新的响应式数据、又要操作依赖这份数据渲染出来的 DOM,两个"延迟"叠在一起容易让人搞不清此刻 DOM 到底更新到了哪一步:

1methods: {
2  handleResize: debounce(function () {
3    this.columns = computeColumns(window.innerWidth) // 触发响应式更新,DOM 还没变
4    this.$nextTick(() => {
5      this.scrollToActiveColumn() // 这里才能保证 DOM 已经按新的 columns 渲染完
6    })
7  }, 200)
8}

debounce 管的是"这个回调多久之后被调用一次",$nextTick 管的是"回调内部这行代码执行之后,DOM 什么时候能读到最新状态",这是两条完全独立的时间线,混在一起最容易犯的错误是想当然地认为"防抖已经等了 200ms,DOM 肯定早就更新完了"——防抖的延迟发生在回调执行之前,$nextTick 的延迟发生在回调执行之后,两者互不覆盖,该等的 tick 还是要显式等。

另一个容易被忽视的组合是防抖函数和 computed 的关系。computed 是同步求值的,天然不能塞进防抖逻辑里——防抖函数的返回值是 undefined(因为真正的执行被推迟到了将来某个时间点),如果有人尝试写 computed: { result() { return this.debouncedCompute() } },这个 computed 属性永远拿不到期待的值,因为它读的是防抖函数调用瞬间的返回值,而不是将来才会产生的那个结果。防抖只适合包在会产生副作用(发请求、写状态)的函数上,纯计算属性这类需要同步返回值的场景,从设计上就不该用防抖包装,这是两种完全不同的数据流:computed 是"拉"模型,调用即取值;防抖包裹的函数是"推"模型,调用只是登记一次意图,真正的动作在未来才发生。

节流的 leading 语义在拖拽场景里会放大

前面提到节流默认 leading: true,这在滚动监听、按钮防抖里通常是好事,但放到"拖拽跟随鼠标"这类场景,leading 反而可能造成观感上的跳跃。设想一个可拖拽的面板,鼠标按下瞬间位置更新了一次,然后进入节流窗口,中间那几十毫秒鼠标已经移动了一段距离,直到下一个窗口才追上——如果窗口设得稍大,肉眼能看出面板"先跳一下,然后卡一下,再追上",而不是平滑跟随。

这种场景比起时间戳节流,更适合用 requestAnimationFrame 顶上而不是固定毫秒数,因为 rAF 的执行时机和浏览器实际重绘对齐,不存在"窗口设大了会卡顿感明显、设小了退化成没节流"这种两难。但如果项目里已经统一用了 lodash 的 throttle,也可以把 wait 直接设成一个接近单帧时长的值(16ms 左右),配合 leading: true, trailing: true,效果和 rAF 节流已经相当接近,区别只在 rAF 会在页面不可见时自动暂停,而 setTimeout 类的实现不会,这个差异在长时间挂后台标签页的场景里才会体现出来,日常开发环境里两者几乎感知不到差别。

防抖函数本身也可能是一处内存泄漏

防抖函数内部持有的 lastArgslastThis 这些状态,如果参数里传的是大对象、DOM 节点,或者组件实例本身,会形成一条隐蔽的引用链——只要这个防抖函数的闭包还活着,它记住的最后一次调用参数就不会被回收,哪怕外部已经不再需要这些数据了。

比较容易踩到这一条的场景是把整个事件对象或者一个大的表单数据对象传给防抖函数:

1// 每次 input 事件都把完整的表单对象传进去
2const debouncedValidate = debounce((formData) => {
3  validate(formData)
4}, 300)
5
6input.addEventListener('input', () => {
7  debouncedValidate(hugeFormDataObject) // hugeFormDataObject 被 lastArgs 一直捏在手里
8})

如果这个防抖函数是模块级的单例、生命周期跟页面一样长,lastArgs 里存的最后一次 hugeFormDataObject 引用就会一直挂在闭包里,直到下一次调用把它替换掉。多数场景这个引用活得不长,问题不大;但如果防抖函数本身被存在一个全局缓存、或者被多个组件共享,某次调用之后这个组件被销毁了,防抖函数却继续存活,那次调用传入的大对象、甚至组件实例本身,就会被这条闭包引用一直吊着,迟迟等不到垃圾回收,表现出来就是页面内存曲线只涨不降,切换几个类似页面之后越来越卡。

cancel() 在这里除了"取消挂起的调用",还顺带做了一件事:把 lastArgslastThis 置空,主动断掉这条引用链。这也是为什么组件销毁时调用 cancel 不只是为了防止"销毁后还执行"这个逻辑错误,同时也是一次主动的内存清理动作——哪怕这次防抖没有挂起的调用需要取消,调一下 cancel 把内部状态清空,也能提前切断一条可能存在的引用。团队里现在的约定是:只要一个防抖/节流函数绑定到了组件生命周期上,销毁钩子里无条件调用 cancel(或者按前面说的语义判断该用 cancel 还是 flush),不因为"这次好像没有挂起的调用"就跳过这一步,因为闭包里到底攒着什么引用,光看调用方代码是看不出来的。

评审时怎么把这几个选项讲清楚

后来在小组内部分享的时候,把这套东西提炼成了三句判断标准,比单纯背选项名更管用:第一句,先问业务要的是"第一下响应"还是"最后一下响应",决定 leadingtrailing 怎么配;第二句,再问"能不能容忍无限期拖延",能容忍就是纯防抖,不能容忍就要么用节流、要么给防抖加 maxWait;第三句,问"卸载或者离开时,挂起的这次操作是该扔还是该保",决定组件销毁时调 cancel 还是 flush。这三句话覆盖了前面几节展开的所有选项细节,评审的时候不需要每次都把状态机画出来,团队里新人也能照着这三句话把自己的选择说清楚,而不是含糊地说"我加了个防抖"。

这套三句话标准落到评审实操里,效果比我预想的更直接。之前组里评审防抖节流相关的代码,讨论经常卡在"这个 300ms 是不是太长"这种没有共同标尺的争论上,谁也说服不了谁;现在先过一遍"第一下要不要响应、能不能无限拖延、卸载时挂起的调用该扔还是该保"这三问,很多时候数字本身反而不是争论焦点了,选项配置对不对才是重点,数字只是在选项确定之后才需要微调的细节。

代码评审时我现在会多问一句"这个 wait 的数字是拍脑袋定的还是量过的"。很多人写 debounce(fn, 300) 里的 300 纯粹是抄来的经验值,但不同接口的响应时间差异很大——如果后端接口本身就要 800ms 才能返回,300ms 的防抖窗口设得再精确也没什么意义,真正决定用户体验的是接口本身的耗时,这时候优化方向应该是接口性能或者骨架屏这类手段,而不是继续抠防抖参数。防抖节流处理的是"触发频率"这一层问题,一旦触发频率已经不是瓶颈,再纠结这几个选项的组合就是在错的位置上使劲。

wait 数字最简单的办法是直接在 Network 面板里看接口的实际耗时分布,取一个比 P75 响应时间略短的值作为防抖窗口,而不是所有接口一刀切用同一个 300。这个数字后续也不是定死的,接口性能变化、用户网络环境变化,都值得回头再量一次,不是写死进代码就一劳永逸的常量。

引入 lodash 时顺手注意一下包体积

我们这批项目今年陆续从 webpack 4 升级到了 5,顺带也把一些历史遗留的引入方式清理了一遍,其中就包括 debounce/throttle 这类工具函数怎么从 lodash 里取。老代码里常见的写法是整体引入:

1import _ from 'lodash'
2_.debounce(fn, 300)

这种写法在 webpack 5 下即便开了 usedExports,也很难被有效摇树,因为 lodash 主包是一个巨大的 CommonJS 模块,内部各函数之间互相引用,打包工具没办法安全地把没用到的部分砍掉,最终整个 lodash 都会被塞进产物里,实际用到的可能只有两三个函数。换成按需引入的写法能省下这部分体积:

1import debounce from 'lodash/debounce'
2import throttle from 'lodash/throttle'

或者更彻底一点,直接换成 ES Module 版本的 lodash-es,配合 webpack 5 更成熟的 tree-shaking 支持,未使用的函数在生产构建里基本能被完全剔除。团队内部对比过打包体积差异之后,现在新项目一律约定用 lodash-es 按需引入,旧项目里遇到还在整体 import _ from 'lodash' 的地方,评审时会顺手建议换掉,这算是这次 webpack 升级窗口期里一并清理的一项小债务,跟这批文章另一篇讲的模块联邦评估是同一轮升级里的事情,只是体量小得多,不需要单独立项,顺手改掉就是了。

该用现成的库还是自己维护

把 leading、trailing、maxWait、cancel、flush 这一整套选项组合都手写一遍之后,我现在的判断是:业务代码里能引入 lodash 就直接用 lodash-esdebounce/throttle,没必要重复造轮子,尤其是 maxWaitflush 这两处,手写版本很容易漏掉某个时序细节,出问题也不容易一眼看出来。自己实现的价值不在于替换生产代码,而在于把这套状态机想透——真正遇到诡异 bug(比如某个防抖函数在特定组合下多执行了一次,或者少执行了一次)的时候,能对照这几个变量一步步推演,而不是对着一个不熟悉的黑盒瞎猜。这跟去年评审那批工具函数时的判断是一致的:库解决的是"稳定可靠地跑起来",自己动手解决的是"出问题时知道往哪查",两者不冲突,各自有各自的用处。

带着这几个问题重新过了一遍 lodash 的选项文档和源码思路之后,"leading 和 trailing 都开的时候执行几次"这类问题,现在换成任何一种组合都能顺着状态机直接推出结果,不需要死记硬背对照表。这套东西真正的价值也不在于记住四种组合各自是什么,而在于往后遇到任何一个"高频触发、需要控制执行时机"的新场景,都能从"要不要立即响应""要不要保证不无限拖延""挂起的操作该扔还是该保"这三个维度直接拆解出该用哪种配置,不用每次都重新试错。