DrillLab

手写 debounce(带 cancel)Write debounce yourself (with cancel)

JavaScript简单 · Easy约 15 分钟~15 min浏览器里能跑Runs in the browser
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

把「每次都立刻调用」的半成品改成真正的 debounce: 连续调用只在停手 delay 毫秒后执行最后一次,cancel() 能取消挂着的那次。Turn this half-finished version, which calls through immediately every time, into a real debounce: a burst of calls runs only once, delay ms after the last one, and cancel() drops the pending run.
验收标准Acceptance criteria
  • 调用 debounced() 不许立刻执行 fnCalling debounced() must not run fn right away
  • 一串连续调用只在停手 delay 毫秒后执行一次,参数用最后那次的A burst of calls runs once, delay ms after the last call, with the arguments of that last call
  • 两串隔开的调用各自触发一次Two bursts separated by a pause each fire once
  • cancel() 取消还没发生的那次调用cancel() drops the call that has not happened yet

预计 15 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 15 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区Workspace

工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.

需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.

2 个起始文件 · 目标 4 passed2 starter files · target 4 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《计时两兄弟:debounce 与 throttle》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “计时两兄弟:debounce 与 throttle” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《计时两兄弟:debounce 与 throttle》(2 段 · 约 30 分钟)Expand “计时两兄弟:debounce 与 throttle” (2 sections · ~30 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 计时两兄弟:debounce 与 throttle

§01

debounce:把一串调用压成最后一次debounce: squeeze a burst of calls down to the last one

Write a debounce; when do you reach for it

一句话:debounce 的语义是「等你停手」—— 连续调用只在最后一次之后 delay 毫秒执行一次。 典型场景:搜索框输入(停止输入才发请求)、窗口 resize 结束后重排。

实现只有三个关键决定:

  • 状态放闭包里。timer 必须在返回的函数外面 —— 放在里面每次调用都是新的,永远清不掉上一次。
  • clearTimeoutsetTimeout这一清一设就是「重新计时」—— debounce 的全部灵魂。
  • 参数跟着 timer 走。最后一次调用的 args 被闭包捕获,之前的全部作废。

会追问:「加一个 leading 选项怎么改?」—— 进来时 timer 为空且 leading 为真,先立刻执行一次, 再设一个只负责「解锁」的 timer。追问的追问:「cancel 和 flush 有什么区别」—— cancel 丢弃挂着的调用,flush 立刻执行它。

In one line: debounce means “wait until you stop” — a burst of calls runs once, delay ms after the last one. Typical uses: search-as-you-type (fire the request when typing pauses), re-layout after window resize settles.

The implementation comes down to three decisions:

  • State lives in the closure. The timer must sit outside the returned function — inside, every call would get a fresh one and nothing could ever be cleared.
  • clearTimeout first, then setTimeout. That clear-and-reset pair is the whole soul of debounce: the clock restarts on every call.
  • Arguments ride along with the timer. The closure captures the last call’s args; every earlier set is discarded.

Follow-up: “Add a leading option” — if the timer is empty and leading is on, fire immediately, then set a timer whose only job is to unlock. And the follow-up’s follow-up: “cancel vs flush” — cancel drops the pending call, flush runs it right now.

TypeScriptdebounce.ts(参考解法 —— scratchpad vitest 4 / 4)debounce.ts (reference solution — scratchpad vitest 4 / 4)已跑通Verified
1export function debounce<T extends (...args: never[]) => void>(
2 fn: T,
3 delay: number,
4): ((...args: Parameters<T>) => void) & { cancel: () => void } {
5 let timer: ReturnType<typeof setTimeout> | null = null;
6
7 const debounced = (...args: Parameters<T>) => {
8 if (timer !== null) clearTimeout(timer); // 关键:先清旧的 —— 这就是「重新计时」
9 timer = setTimeout(() => {
10 timer = null;
11 fn(...args); // 只有最后一次的参数活到这里
12 }, delay);
13 };
14
15 debounced.cancel = () => {
16 if (timer !== null) clearTimeout(timer);
17 timer = null;
18 };
19
20 return debounced;
21}
1export function debounce<T extends (...args: never[]) => void>(
2 fn: T,
3 delay: number,
4): ((...args: Parameters<T>) => void) & { cancel: () => void } {
5 let timer: ReturnType<typeof setTimeout> | null = null;
6
7 const debounced = (...args: Parameters<T>) => {
8 if (timer !== null) clearTimeout(timer); // The key line: clear the old timer, so the clock restarts
9 timer = setTimeout(() => {
10 timer = null;
11 fn(...args); // Only the last call's arguments get this far
12 }, delay);
13 };
14
15 debounced.cancel = () => {
16 if (timer !== null) clearTimeout(timer);
17 timer = null;
18 };
19
20 return debounced;
21}
§02

throttle:不管多密,每个窗口最多一次throttle: however dense the calls, at most one per window

Write a throttle with leading and trailing calls

一句话:throttle 的语义是「匀速放行」—— 调用再密,每 interval 毫秒最多执行一次。典型场景:滚动位置上报、 拖拽跟随、按住按钮连点。

标准版是 leading + trailing:窗口开头立刻执行一次 (用户第一下操作马上有反馈),窗口里被压掉的调用, 在窗口结束时用最后一次的参数补执行一枪 (不丢最终状态)。所以要存两样东西:上次执行的时间戳lastTime,和窗口内最后一次的参数 lastArgs

和 debounce 的分界线一句话说死:debounce 在连续事件流里可能永远不执行(只要不停手);throttle 保证按节奏执行。 滚动进度条用 debounce 会直到停下才动 —— 那就是选错了。

会追问:「用 setTimeout 一个变量能不能写?」—— 能写 leading-only 或 trailing-only 的简版;两头都要就得 时间戳 + timer 双状态。「CSS 里有类似的东西吗」—— 没有直接等价物,但 scroll 事件配IntersectionObserver 常常能把节流的需求整个消掉。

In one line: throttle means “let it through at a steady rate” — no matter how dense the calls, it runs at most once per interval. Typical uses: reporting scroll position, drag tracking, rapid button presses.

The standard version is leading + trailing: fire once at the start of the window (the user’s first action gets instant feedback), and when the window closes, fire once more with the last suppressed call’s arguments (so the final state is not lost). That means two pieces of state: the timestamp of the last run, lastTime, and the window’s last arguments, lastArgs.

The dividing line, said once and hard: in a continuous event stream debounce may never run (as long as the stream never pauses); throttle guarantees a steady beat. A scroll progress bar built on debounce only moves when scrolling stops — that is the wrong pick.

Follow-up: “Can you write it with one setTimeout variable?” — yes for leading-only or trailing-only; both edges need the timestamp-plus-timer pair. “Anything similar in CSS?” — no direct equivalent, but scroll plus IntersectionObserver often removes the need to throttle at all.

TypeScriptthrottle.ts(参考解法 —— scratchpad vitest 4 / 4)throttle.ts (reference solution — scratchpad vitest 4 / 4)已跑通Verified
1export function throttle<T extends (...args: never[]) => void>(
2 fn: T,
3 interval: number,
4): (...args: Parameters<T>) => void {
5 let lastTime = 0;
6 let timer: ReturnType<typeof setTimeout> | null = null;
7 let lastArgs: Parameters<T> | null = null;
8
9 return (...args: Parameters<T>) => {
10 const now = Date.now();
11 const remaining = interval - (now - lastTime);
12
13 if (remaining <= 0) {
14 lastTime = now;
15 fn(...args); // leading:到点了,立刻执行
16 } else {
17 lastArgs = args; // 记住窗口内最后一次的参数
18 if (timer === null) {
19 timer = setTimeout(() => { // trailing:窗口结束补一枪
20 timer = null;
21 lastTime = Date.now();
22 if (lastArgs !== null) fn(...lastArgs);
23 lastArgs = null;
24 }, remaining);
25 }
26 }
27 };
28}
1export function throttle<T extends (...args: never[]) => void>(
2 fn: T,
3 interval: number,
4): (...args: Parameters<T>) => void {
5 let lastTime = 0;
6 let timer: ReturnType<typeof setTimeout> | null = null;
7 let lastArgs: Parameters<T> | null = null;
8
9 return (...args: Parameters<T>) => {
10 const now = Date.now();
11 const remaining = interval - (now - lastTime);
12
13 if (remaining <= 0) {
14 lastTime = now;
15 fn(...args); // leading: the window is open, run now
16 } else {
17 lastArgs = args; // keep the args of the last call inside the window
18 if (timer === null) {
19 timer = setTimeout(() => { // trailing: run once more when the window closes
20 timer = null;
21 lastTime = Date.now();
22 if (lastArgs !== null) fn(...lastArgs);
23 lastArgs = null;
24 }, remaining);
25 }
26 }
27 };
28}
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.