JavaScript 节流装饰器 throttle 实现详解:从需求到源码级原理分析(zh.javascript.info 实战)

原创2026-10-06 09:42:19422 阅读
文章标签:文档教程

JavaScript 节流装饰器 throttle 实现详解:从需求到源码级原理分析(zh.javascript.info 实战)

throttle(f, ms) 是 JavaScript 装饰器模式中最经典的限频方案之一,它保证被包装函数在任意 ms 毫秒窗口内最多执行一次,常用于滚动、鼠标移动、窗口 resize 等高频事件场景。本文以 zh.javascript.info 教程中节流装饰器任务的官方解答为骨架,结合仓库内真实的 源码实现 与 单元测试,带你读懂节流的核心状态机、this/arguments 转发机制,以及它与防抖(debounce)的本质区别,最终能够独立实现并正确使用自己的 throttle 装饰器。

任务背景:为什么需要节流

在 任务描述 中,要求实现一个“节流”装饰器 throttle(f, ms) —— 它返回一个包装器(wrapper),当该包装器被多次调用时,它最多每 ms 毫秒将调用传递给 f 一次。

节流最典型的现实需求是跟踪鼠标移动:

  • 浏览器中,为 mousemove 绑定的处理函数在鼠标移动期间会以极高频执行,大约每秒 100 次(每 10ms 一次);
  • 如果我们要在鼠标移动时更新页面上的某些信息,而 update() 函数足够“重”,无法承受如此高频的调用,且超过每 100ms 更新一次也没有意义;
  • 于是把 update 包装进装饰器:throttle(update, 100) 作为每次鼠标移动时实际执行的处理函数。装饰器会被频繁调用,但最多每 100ms 才向 update 转发一次调用。

从视觉行为上,节流后的调用序列是这样演进的:

  1. 第一个鼠标移动到来时,装饰后的变体立即将调用传递给 update —— 用户能马上看到对其动作的响应;
  2. 随后鼠标继续移动,在接下来的 100ms 内,所有调用都被忽略;
  3. 100ms 结束时,用最后一个坐标再执行一次 update;
  4. 最后鼠标停在某处,装饰器会等到 100ms 窗口到期,再用最终坐标执行一次 update —— 因此处理最终的鼠标坐标非常关键。

一个直观的代码示例(来自任务描述):

function f(a) {
  console.log(a);
}

// f1000 最多每 1000ms 将调用传递给 f 一次
let f1000 = throttle(f, 1000);

f1000(1); // 显示 1
f1000(2); // (节流,尚未到 1000ms)
f1000(3); // (节流,尚未到 1000ms)

// 当 1000ms 时间到...
// ...输出 3,中间值 2 被忽略

注意最后一行注释的语义:冷却期结束后执行的不是中间值 2,而是最后一次调用 3。

节流 vs 防抖:两个容易被混淆的装饰器

任务描述特别强调了 throttle 与上一节练习 debounce(防抖装饰器任务)的截然不同:

  • debounce 会在“冷却(cooldown)”期之后运行函数一次,适用于处理最终结果(例如等待用户停止输入后再发送请求);
  • throttle 运行函数的频率不会大于给定的 ms 毫秒,适用于不应该频繁进行的定期更新(例如跟踪鼠标位置、滚动进度)。

官方解答用一个秘书接电话的比喻概括节流:throttle 就像接电话的秘书,但“打扰老板”(实际调用 f)的频率不能超过每 ms 毫秒一次。

两者的实现差异也一目了然。防抖解答(03-debounce/solution.md)用 clearTimeout 反复重置计时器,确保只有在停止调用 ms 毫秒后才执行:

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

而节流则维护一个“冷却状态”标志,在冷却期内记住最后一次调用,到期后用最后的参数补执行一次。下面进入核心实现。

官方解答:throttle 的完整实现

来自 04-throttle/solution.md 的完整解答代码如下:

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }
    isThrottled = true;

    func.apply(this, arguments); // (1)

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

仓库中带注释的英文源码版本位于 04-throttle/_js.view/solution.js,逻辑完全一致。整个装饰器只依赖三个闭包变量:

  • isThrottled:布尔标志,表示当前是否处于冷却状态(节流窗口内);
  • savedArgs:冷却期内最后一次被忽略调用的参数列表;
  • savedThis:与之对应的 this 上下文。

逐行原理:节流的三步状态机

throttle(func, ms) 调用后返回 wrapper,对外表现与原始函数 func 基本无差(这正是“装饰器 + 调用转发”思想的体现,父章节 装饰器模式和转发,call/apply 有系统讲解)。wrapper 内部是一个典型的三段式状态机:

第一步:非冷却状态下,立即执行并进入冷却(第 1 次调用)

if (isThrottled) { ... return; }  // 冷却中则走分支 (2)
isThrottled = true;               // 立即进入冷却状态
func.apply(this, arguments);      // 立即执行原始函数

在第一次调用期间,wrapper 直接运行 func(通过 func.apply(this, arguments) 把上下文与参数原样转发),并把 isThrottled 置为 true,进入冷却状态。第一次调用必须立即执行,这是节流与防抖的关键差异之一:用户要立刻看到对操作的响应。

第二步:冷却状态下,只保存最后一次调用(忽略中间调用)

if (isThrottled) {
  savedArgs = arguments;
  savedThis = this;
  return;
}

在冷却状态下,所有调用都不会执行 func,而是被保存在 savedArgs / savedThis 中。注意:这里的保存是“覆盖式”的——如果冷却期内发生了多次调用,只有最后一次的参数和上下文会留在变量里,中间的调用被丢弃。这正是任务示例中“输出 3,中间值 2 被忽略”的原因。

特别重要的是:上下文 this 和参数 arguments 都要保存。因为我们要在冷却结束后“重现”这最后一次调用,而 func 很可能依赖 this(例如对象方法内部访问 this.someMethod()),只保存参数不保存 this 会导致 this 丢失为 undefined 而报错。

第三步:冷却到期,清除状态并补执行最后一次调用

setTimeout(function() {
  isThrottled = false;
  if (savedArgs) {
    wrapper.apply(savedThis, savedArgs);
    savedArgs = savedThis = null;
  }
}, ms);

经过 ms 毫秒后,setTimeout 回调触发:

  1. 冷却状态被移除(isThrottled = false);
  2. 如果存在被忽略的调用(savedArgs 非空),则用保存的参数和上下文运行 wrapper;
  3. 运行后清空 savedArgs / savedThis(置为 null),为下一个周期做准备。

关键细节:第 3 步为什么调用的是 wrapper 而不是 func?

这是本解答最值得注意的一个设计。官方解答明确说明:第 3 步运行的不是 func,而是 wrapper,因为不仅需要执行 func,还需要再次进入冷却状态并重新设置 setTimeout 以重置节流。

如果第 3 步直接调用 func.apply(savedThis, savedArgs),那么这次补执行虽然立即完成,但 isThrottled 依然是 false,不会开启新一轮冷却——紧接着到来的下一次鼠标移动又会被立即执行,节流的频率上限就被打破了。

而通过 wrapper.apply(savedThis, savedArgs) 递归调用包装器自身:

  • 此时 isThrottled 已被置回 false,所以会走“第一步”分支:立即执行 func,并把 isThrottled 重新置为 true,同时注册一个新的 setTimeout;
  • 效果就是:补执行的那一次调用同时也开启了下一个节流窗口,保证整体上仍然是“每 ms 毫秒最多执行一次”。

这正是节流与防抖在结构上的核心差异所在:防抖靠 clearTimeout 重置计时器,节流靠“冷却标志 + 定时重置 + 尾调用补执行”。

源码级佐证:仓库中的实现与测试

仓库中与本文对应的可运行源码与测试如下,可作为验证与深入学习材料:

测试用例完整覆盖了节流的三个关键行为,使用 sinon.useFakeTimers() 伪造计时器来精确控制时间流逝:

用例 1:第一次调用立即执行

it("the first call runs now", function() {
  f1000(1); // runs now
  assert.equal(log, "1");
});

用例 2:冷却期内调用被忽略,到期后补执行最后一次调用

it("then calls are ignored till 1000ms when the last call works", function() {
  f1000(2); // (throttling - less than 1000ms since the last run)
  f1000(3); // (throttling - less than 1000ms since the last run)
  // after 1000 ms f(3) call is scheduled

  assert.equal(log, "1"); // right now only the 1st call done

  this.clock.tick(1000); // after 1000ms...
  assert.equal(log, "13"); // log==13, the call to f1000(3) is made
});

这里验证了任务示例的核心结论:立即调用 f1000(1) 后日志为 "1",冷却期内 f1000(2)、f1000(3) 被忽略;clock.tick(1000) 推进 1000ms 后,日志变为 "13" —— 补执行的是最后一次调用 3,中间值 2 被丢弃。

用例 3:补执行的那一次也会开启新的节流窗口

it("the third call waits 1000ms after the second call", function() {
  this.clock.tick(100);
  f1000(4); // (throttling - less than 1000ms since the last run)
  this.clock.tick(100);
  f1000(5); // (throttling - less than 1000ms since the last run)
  this.clock.tick(700);
  f1000(6); // (throttling - less than 1000ms since the last run)

  this.clock.tick(100); // now 100 + 100 + 700 + 100 = 1000ms passed

  assert.equal(log, "136"); // the last call was f(6)
});

这个用例间接验证了“第 3 步调用 wrapper 而非 func”的设计:在第一次补执行(f(3))之后,即使没有新的调用,也会因为补执行自身重新进入冷却状态;后续 f(4)、f(5)、f(6) 依然全部被节流,只有距上次执行满 1000ms 后的 f(6) 被补执行,最终日志为 "136"。

this 与 arguments 的完整转发:调用转发的基石

节流装饰器全程使用 func.apply(this, arguments) 和 wrapper.apply(savedThis, savedArgs) 完成上下文与参数的转发,这直接建立在父章节 装饰器模式和转发,call/apply 讲解的两个内建方法之上:

  • func.call(context, ...args):以显式 context 作为 this、逐个传入参数调用 func;
  • func.apply(context, args):以显式 context 作为 this、传入类数组 args 作为参数列表调用 func。

两者唯一的语法区别是 call 期望参数列表,而 apply 期望包含这些参数的类数组对象:

func.call(context, ...args);
func.apply(context, args);

节流解答中统一使用 apply,因为 wrapper 拿到的正是类数组对象 arguments,直接透传最简洁。在节流语境下,this 的转发同样至关重要:

  • 若 func 是被包装的对象方法(如 worker.slow),包装器被以 worker.slow(...) 的形式调用时,wrapper 内部的 this 就是 worker;
  • func.apply(this, arguments) 把这个 this 原样交给原始方法,方法内的 this.someMethod() 等依赖才能正常工作;
  • 冷却期内保存 savedThis 正是为了在补执行时恢复这个上下文,避免“重现调用”时 this 丢失。

这种“把所有参数连同上下文一起传递给另一个函数”的写法,在父章节中被称为调用转发(call forwarding),其最简形态是:

let wrapper = function() {
  return func.apply(this, arguments);
};

节流装饰器正是在这个转发骨架之上,叠加了“冷却标志 + 最后一次调用记忆 + 定时补执行”的限频逻辑。

使用注意事项与边界

基于实现源码,可以总结以下几点实战注意:

  1. 节流不等待“安静期”:与 debounce 不同,throttle 不关心调用是否停止,它只保证执行频率上限,因此特别适合需要“持续但低频”更新状态的场景(位置追踪、进度条、滚动条位置等);
  2. 最后一次调用一定会被补执行:只要冷却期内发生过调用,窗口到期后必然用最后保存的参数再执行一次,因此不会丢失“最终状态”——这正是鼠标停下后要输出最终坐标的需求;
  3. 第一次调用立即执行:这保证了交互的即时反馈,是节流 UX 上的关键设计;
  4. 装饰器不保留原函数属性:如父章节 装饰器和函数属性 所述,被装饰的函数若带有自定义属性(如 func.calledCount),包装器不会继承这些属性;若需要保留,需借助 Proxy(见父章节对 <info:proxy#proxy-apply> 的指引);
  5. 测试与调参:仓库测试使用 sinon.useFakeTimers() 精确控制时间,实际业务中建议为 ms 选取符合业务节奏的窗口(如 100ms 用于鼠标移动、1000ms 用于滚动位置上报),并可用真实事件回调配合 performance.now() 观察实际触发频率。

总结

节流装饰器 throttle(f, ms) 的核心是一套三态闭包状态机:

  1. 非冷却:立即执行 func,置冷却标志;
  2. 冷却中:忽略调用,仅覆盖保存最后一次的 arguments 与 this;
  3. 冷却到期:清除标志,若存在被忽略的调用则以保存的上下文与参数递归调用 wrapper(而非直接调用 func),从而在补执行的同时开启新的节流窗口。

它通过 func.apply(this, arguments) 与 wrapper.apply(savedThis, savedArgs) 完整转发上下文与参数,是父章节“调用转发(call forwarding)”思想的直接应用。与 debounce 的“冷却期后执行一次最终结果”相比,throttle 强调的是“以不超过 ms 的频率持续执行且不丢失最终调用”,两者共同构成了高频事件限频的标准工具箱。完整源码与测试可分别查阅 04-throttle/_js.view/solution.js 与 04-throttle/_js.view/test.js,自行运行测试即可直观验证每一步的时间行为。

登录后查看全文
zh.javascript.info