这是一个「已复制!」按钮。每个代码库里都有一个,而这个版本有三个 bug:
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
setTimeout(() => setCopied(false), 2000);
}, [copied]);
return (
<button onClick={() => { navigator.clipboard.writeText(text); setCopied(true); }}>
{copied ? "已复制!" : "复制"}
</button>
);
}
它从不清除定时器,所以在倒计时中途卸载会留下一个指向已死组件的回调。它在每次 copied 变化时重新挂载,而不是干净地重启。而在 React 18 的 StrictMode 下,effect 在挂载时会跑两次,于是你本想要一个定时器,却拿到了两个。补上漏掉的 clearTimeout 能修掉泄漏,但修不掉问题的形状:定时器的生命周期现在被绑进了依赖数组,而你依然没有办法在点击事件里取消它、按需重启它,或者问一句「它还在跑吗?」
setTimeout 是一个「发射后不管」的浏览器原语。React 组件可不是发射后不管的——它们会卸载、会重渲染、会改主意。@reactuses/core 里的 useTimeout 和 useTimeoutFn 把这个鸿沟填上了:交给你的不是一个需要小心伺候的数字,而是一份状态加两个控制函数。这篇文章讲它们底层到底做了什么、那个所有人都会踩的行为(延迟是依赖,回调不是)、一个会悄悄污染你参数的 start() 陷阱,以及值得直接抄走的几个模式。
快速开始
npm install @reactuses/core
import { useTimeoutFn } from "@reactuses/core";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const [, startReset] = useTimeoutFn(() => setCopied(false), 2000, {
immediate: false,
});
return (
<button
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
startReset();
}}
>
{copied ? "已复制!" : "复制"}
</button>
);
}
没有 effect,没有依赖数组,没有需要记着写的清理。定时器由一次点击、而不是由一次渲染来启动;卸载时自动清除;在提示还挂着的时候再点一次「复制」,会重新开始这两秒,而不是在第一个定时器上再叠一个。
两个 Hook,一台引擎
两个 hook 返回的是同一个三元组——库里把它叫做 Stoppable:
type Stoppable = [isPending: boolean, start: Fn, cancel: Fn];
它们的区别只在于:时间到了之后发生什么。
useTimeoutFn(cb, ms, options?) 执行你的回调。当这个「到期」本身有事要做时用它——关掉 toast、重置标志位、发一个埋点。
useTimeout(ms?, options?) 不跑你的任何回调。它把 isPending 从 true 翻成 false 并触发一次重渲染。当这个「到期」本身就是状态时用它——「300ms 过了没?」就是全部的问题。
useTimeout 字面上就是把回调那个位置让给「强制重渲染」的 useTimeoutFn:
export const useTimeout: UseTimeout = (ms = 0, options = {}) => {
const update = useUpdate();
return useTimeoutFn(update, ms, options);
};
这个 useUpdate 是一个两行的 useReducer,对一百万取模递增计数器——就是那个「不发明假 state 也能强制重渲染」的标准技巧,取模是为了让长命组件不会一路飘向 Number.MAX_SAFE_INTEGER。它保证了到期那一刻一定会有一次渲染,哪怕在光靠 isPending 不足以触发渲染的场景下也是如此。正是这一点,让 useTimeout 可以当作一个纯粹的「N 毫秒后重新渲染我」原语来用——比如你要重新读一个并不是 React state 的值时。
默认两者都在挂载时启动。传 { immediate: false },在你自己调用 start() 之前什么都不会发生。
它到底做了什么
实现大约二十行,而每一行都在回答开头那个例子里的某个 bug:
export const useTimeoutFn = (cb, interval, options = {}) => {
const { immediate = true } = options;
const [pending, setPending] = useState(() => immediate);
const savedCallback = useLatest(cb);
const timer = useRef<ReturnType<typeof setTimeout>>();
const stop = useEvent(() => {
setPending(false);
if (timer.current) clearTimeout(timer.current);
});
const start = useEvent((...args: unknown[]) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setPending(false);
savedCallback.current(...args);
}, interval);
setPending(true);
});
useEffect(() => {
if (immediate) start();
return stop;
}, [stop, immediate, interval, start]);
return [pending, start, stop];
};
这里塞进了五个决策,每一个都值得知道,因为每一个之后都会出现在你自己的代码里。
回调住在 ref 里,不在依赖里。 useLatest 在每次渲染提交后把 savedCallback.current 指向最新的那个函数,定时器透过它来调用。所以到期时执行的闭包是你最近一次渲染里的那个——过期闭包的 bug 没了——但换掉回调不会重启倒计时。一个 5 秒的定时器跑到第 4 秒时还剩 1 秒,哪怕它将要调用的那个函数在这期间已经被重新创建了十次。这是正确的行为,仓库里有测试覆盖,但对于「一个长得像 useEffect 的 hook,输入变了就该重跑」这种预期来说,会让人意外。
延迟确实在依赖里。 interval 位于依赖数组中,所以改动它会拆掉当前定时器、从零开始一个新的。这是刻意的,通常也正是你想要的——但请看下面的坑,因为在渲染里现算的延迟,是造出一个永远跑不完的倒计时的最快方式。
start 和 stop 的引用永不改变。 useEvent 把两者都包进一个空依赖的 useCallback 并转发给 ref,所以你在第 1 次渲染拿到的函数,和第 500 次渲染拿到的是同一个引用。你可以把它们放进依赖数组、传给被 memo 的子组件、或者塞进 context,都不会引发常见的抖动。
start() 先清后设。 在定时器已经在跑的时候调用它不会叠加——而是取消并重启。这就是「再点一次复制」表现正常的原因;也意味着在每次按键时反复调用 start(),你就白得了一份防抖语义(不过 useDebounceFn 把意图说得更清楚)。
pending 是被预置的,不是闪出来的。 useState(() => immediate) 意味着当 immediate 打开时,第一次渲染读到的就已经是 true——挂载时没有 false → true 的闪烁,也没有浪费掉的一次渲染。而且因为 immediate 只是一个普通选项、服务端和客户端取值一致,这个预置值在两边完全相同。这个 hook 里没有任何东西碰 window、document 或 Date,所以它无需守卫就能在服务端渲染,也不会出现 hydration 不匹配。
effect 的清理函数就是 stop 本身,这就是泄漏的修复:卸载时一定清除定时器,无论它当时处于什么状态。
值得抄走的模式
延迟出现的 loading
useTimeout 最好的用途。一个出现 80ms 就消失的 spinner 读起来只是一次闪烁——比完全不显示还糟。解法是只在加载真的慢的时候才显示,而这恰好就是「300ms 过了没?」:
function UserList() {
const { data, isLoading } = useUsers();
const [tooSoon] = useTimeout(300);
if (isLoading) return tooSoon ? null : <Spinner />;
return <List items={data} />;
}
tooSoon 初始为 true,挂载 300ms 后翻成 false。快速返回的请求在这段间隙里什么都不渲染;慢的才会拿到 spinner。一行,无 state,无 effect。
自动消失 + 悬停暂停
元组里的 cancel 和 start 让这件事变得很简单——手写版本需要一个 ref 和两个 effect:
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
const [, start, cancel] = useTimeoutFn(onDismiss, 5000);
return (
<div role="status" onMouseEnter={cancel} onMouseLeave={() => start()}>
{message}
</div>
);
}
注意 onMouseLeave 上的 () => start()。这不是风格偏好——见下面的坑。
冷却按钮
function ResendCodeButton({ onResend }: { onResend: () => void }) {
const [cooling, startCooldown] = useTimeout(30_000, { immediate: false });
return (
<button
disabled={cooling}
onClick={() => { onResend(); startCooldown(); }}
>
{cooling ? "验证码已发送,请稍候" : "重新发送验证码"}
</button>
);
}
immediate: false 是关键:按钮在挂载时是可用的,只有被用过一次之后才进入冷却。如果你想渲染剩余秒数而不是一个布尔值,那是另一个 hook 的活——useCountDown 会帮你倒数并把数字交给你。
把控制权交还给浏览器
不带参数的 useTimeout() 默认 ms = 0,它依然会推迟到一个宏任务——在绘制之后、在待处理的微任务之后。偶尔这正是你想要的那个逃生口:「先让浏览器把这一帧画出来,我再做那件耗时的事」,而且比一个 requestIdleCallback polyfill 更好推理。如果你要的是每帧执行而不是执行一次,用 useRafFn。
值得知道的坑
-
start会把参数转发给你的回调。 这是一个真实的特性——start(userId)会把userId透传给定时器回调——同时也是一个真实的陷阱,只要调用方是 DOM 事件处理器。onMouseLeave={start}会把 React 的合成MouseEvent直接塞进你的onDismiss(...)。如果那个回调是onDismiss(id?: string),你就用一个事件对象当 id 关掉了一个 toast,而 TypeScript 不会拦你,因为start的类型是Fn。包一层:onMouseLeave={() => start()}。onClick、onBlur以及任何会传事件的地方,同理。 -
延迟一变,倒计时就重启——每一次都是。
interval是依赖,所以下面这个永远不会触发:// 有 bug:每次渲染都是新的延迟,定时器被无限重启 useTimeoutFn(onDone, Math.max(0, deadline - Date.now()));任何按渲染重算的延迟,都会在它跑完之前把时钟清零。传一个稳定的数字,或者 memo 掉它。反过来这个特性也有用:当延迟是真的变了——用户在「3 秒后消失 / 10 秒后消失 / 不消失」之间切换——重启正是对的。
-
回调变了不会重启它。 上一条的镜像,同样值得记进肌肉记忆。你的回调永远是最新的那个,但它的到期时刻是
start()执行时定下的那个。 -
cancel()会把isPending设为false。 它是停止,不是暂停——没有「用剩余时间继续」这回事。cancel()之后再start(),走的是一个完整的新延迟。如果你需要真正的暂停/恢复语义,得自己记录已经过去的时间,并把剩余时间作为新的延迟传进去。 -
卸载之后
isPending会冻结在最后一次渲染的值上。 清理函数调用了stop(),它清除了定时器并调用setPending(false)——但这个状态更新落在了一个已卸载的组件上,React 会丢弃它。如果你在测试里快照了这个元组、在unmount()之后再读,isPending依然会是true。这不是泄漏,也不会有警告;定时器是真的被清掉了。 -
StrictMode 会挂两次,但结果收敛。 在 React 18 的开发模式下,挂载 effect 会执行、清理、再执行一次,所以在 dev 里你会看到两次
setTimeout调用。永远不会重复触发——stop清掉了第一个,start在排新的之前又清了一次——但倒计时实际上是从第二次执行开始算的。实际使用中这是亚毫秒级的差别;但在一个用假定时器精确推进时间的测试里,这个差别是会咬人的。 -
immediate在挂载时被读取,同时也是依赖。 在后续某次渲染里把immediate从false翻成true,会启动定时器,因为它在 effect 的依赖里。用切换它的方式来声明式地武装一个定时器是完全合理的做法——只是别惊讶于它并不是惰性的。
什么时候不该用它
这两个 hook 是对单个 setTimeout 的一层薄而诚实的封装。当你的问题有专门的名字时,对应的 hook 已经处理好了那些你否则要重新踩一遍的边界情况:
- 按周期重复执行 → 用
useInterval,而不是让一个 timeout 自己重新武装自己。自排程的 timeout 会漂移,而且取消起来极其难受。 - 「等用户停止输入」 → 回调用
useDebounceFn,值用useDebounce。你可以靠每次按键调start()来搭出来,但专用 hook 一眼就能读懂。 - 「每 N 毫秒最多一次」 →
useThrottleFn/useThrottle。timeout 是限流的错误原语;第一次调用就该立刻通过。 - 可见的倒数 →
useCountDown。用单个 timeout 渲染「4… 3… 2…」意味着你要自己跑一个 tick 循环。 - 「用户是不是不动了?」 →
useIdle,它已经监听了正确的那组活动事件。 - 逐帧动画 →
useRafFn。setTimeout不与合成器对齐,而且在后台标签页里还会继续跑。 - 只是想在卸载时清理 →
useUnmount。根本不需要定时器。
要点回顾
- 在
useEffect里写setTimeout,逼着你同时手动管四件事:清理、依赖数组、过期闭包、以及缺失的控制能力。对三个错一个,是常态。 useTimeoutFn返回[isPending, start, cancel],并且天然在卸载时清除。useTimeout是同一台引擎,只是把回调那格用来触发重渲染——适合「到期本身就是你关心的状态」的场景。- 延迟是依赖,回调不是——改延迟会重启倒计时,改回调只会悄悄换掉将要执行的函数。两者都是刻意设计;分清哪个是哪个能省下一个下午。
start会转发参数,所以永远不要把它直接传给 DOM 事件处理器。写onMouseLeave={() => start()},不要写onMouseLeave={start}。start和cancel的引用永久稳定,isPending被预置所以挂载时不会闪,而且 hook 里没有任何东西碰浏览器全局对象——它能原封不动地在服务端渲染。
useTimeout、useTimeoutFn、useInterval,以及另外 110+ 个 SSR 安全、TypeScript 优先的 hook 都在 @reactuses/core 里——一次安装,支持 tree-shaking,没有需要伺候的依赖。
npm install @reactuses/core