阅读视图

发现新文章,点击刷新页面。

在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate

React 用一套时钟,浏览器用另一套。React 的协调器根据 state 更新、effect、调度器对"尽快"的理解来决定何时重新渲染组件。浏览器的合成器则按显示器能撑住的速度刷屏——大多数显示器是 60Hz,少数是 120Hz。两套时钟并不同步。state 更新会落在两次绘制之间被合并;庞大的渲染树可能整个错过一帧;setInterval(handler, 16) 一分钟下来会漂移几百毫秒,因为它根本不关心 GPU 在干嘛。

标准解法是 requestAnimationFrame。它在下一次绘制之前调用你的回调,附带一个高精度时间戳,并且在标签页隐藏时自动节流。它就是所有要看起来"丝滑"的东西该用的原语。但它在 React 里手工接线很繁琐:你需要一个 ref 存帧 ID、一个 effect 启动循环、一段清理函数在卸载时取消、一个 useLatest 让回调看到最新的 props,再加一个 ref 才能做暂停/恢复。每个动画组件都重写一遍这套脚手架,而大多数人第一次写都会漏掉某个清理。

ReactUse 把这套脚手架收进了五个共享同一底层循环的 hook。本文逐个走读——useRafFn 提供循环本身,useRafState 做随循环更新的 state,useFps 量化这个循环,useDevicePixelRatio 让你在循环里以正确分辨率绘制,useUpdate 应付那些"需要推一下 React 但又没 state 可改"的场景。合起来基本能覆盖你在专门的动画库之外要做的所有事。

一个组件里的 bug

一张跟随鼠标的浮卡:

function FloatingCard() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', move);
    return () => window.removeEventListener('mousemove', move);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
      }}
    >
      card
    </div>
  );
}

看上去没毛病。打开 devtools 性能面板,鼠标在屏幕上甩一遍。在一台快点的笔记本上,mousemove 每秒触发 120 到 500 次,看输入设备和 OS。每次都会调用 setPos,每次都触发一次重渲染调度,React 把它们合并到下一个 microtask。你在做屏幕能展示的两到八倍的协调工作,多出来的渲染全是纯开销——真正有意义的只是下一次绘制之前的最后一次。

useRafState 把这件事压缩成每帧一次,不管事件多快。原地替换,同样的 [state, setState] API,每次鼠标抖动少三次协调。本文剩下的 hook 都遵循同一个模式:保留 React 风格的 API,把 requestAnimationFrame 的管道藏起来。

1. useRafFn——带暂停/恢复的循环

useRafFn 是其他一切的基石。它接收一个回调,在每个 requestAnimationFrame tick 上调用,并把高精度时间戳传进去。返回 [stop, start, isActive],让你可以在标签页失焦、用户交互或任何其他信号上暂停循环:

import { useRef } from 'react';
import { useRafFn } from '@reactuses/core';

function StarField({ count = 200 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const starsRef = useRef(
    Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      z: Math.random() * 0.5 + 0.5,
    })),
  );

  const [stop, start, isActive] = useRafFn((time) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const { width, height } = canvas;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, width, height);

    const t = time / 1000;
    for (const star of starsRef.current) {
      const x = ((star.x + t * 0.02 * star.z) % 1) * width;
      const y = star.y * height;
      ctx.fillStyle = `rgba(255, 255, 255, ${star.z})`;
      ctx.fillRect(x, y, 2, 2);
    }
  });

  return (
    <>
      <canvas ref={canvasRef} width={600} height={400} />
      <button onClick={() => (isActive() ? stop() : start())}>
        {isActive() ? '暂停' : '继续'}
      </button>
    </>
  );
}

这个 hook 有四个设计选择值得理解。回调在下一次绘制之前运行——这是 requestAnimationFrame 的语义——所以回调里做的任何 DOM 读取看到的都是即将绘制时的布局,不会额外触发强制回流。回调引用被 useLatest 包了一层,所以你可以闭包到新鲜的 props(count、作用域里任何东西)而不必重启循环。循环挂载时自动启动;第二个参数传 false 则从第一帧起就停在手动控制状态。清理注册在 effect 上,所以卸载时会取消挂起的帧——不会有野回调在死掉的组件上跑。

isActive 返回的是函数而不是布尔。在事件处理器里调用它总能拿到当前值;在渲染里调用只能看到渲染时的值。这种不对称容易踩。如果你要把激活标志用在 JSX 的 disabled={} 这种 prop 上,配合 useUpdatestop/start 调用方里手动 update()——上面示例没这么做是因为按钮文案下一次点击时本来就会重算。

useRafFn 真实场景下还有不少 canvas 之外的用法:任何要在两次事件之间追踪时间的活儿都用得到。一个要按 delta time 积分速度的物理模拟。一个 scrub bar 想紧跟媒体元素的 currentTime,而不是等那个粗糙的 timeupdate 事件(它按编解码器心情触发,不按你心情)。一个用弹簧拖尾跟随真实鼠标的自定义指针——useRafFn 读最新的目标位置,跑一步弹簧迭代,把结果写到 CSS 变量。这些都在替代那些会漂移、又会在后台标签里烧电池的 setInterval 模式。

2. useRafState——按帧合并的 useState

useRafState 是那张浮卡你真正会发布的版本:

import { useRafState } from '@reactuses/core';
import { useEventListener } from '@reactuses/core';

function FloatingCard() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener('mousemove', (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
        transition: 'transform 0.1s',
      }}
    >
      card
    </div>
  );
}

API 完全是 useState——同样的 setter 签名,同样支持 updater 函数——但写入会被 requestAnimationFrame 排队。同一帧内的五次 setPos 合并为一次 React 更新;React 更新每次绘制最多 flush 一次;DOM 更新的频率正好与屏幕刷新同步。mousemove 监听还是按 500Hz 触发,开销几乎等同于调一个空函数。协调成本掉到 60Hz,正好是屏幕能展示的。

几点要知道。这个 hook 给每个 state 槽位维护一个挂起的 requestAnimationFrame ID,所以同一帧内连续的 setter 是替换,不是排队——最后一个值赢。视觉 state 几乎总是想要这个语义:你不在乎中间的鼠标位置,只在乎绘制那一刻光标在哪。如果你真的在乎——比如你在采样传感器数据每个值都要——那就用普通 useState 并接受重渲染成本,或者写到 ref 里然后用 useRafFn tick 来 flush。

清理细节和 useRafFn 一样:挂起的帧在卸载时取消,所以快速点击-拖拽-卸载的连击不会冒出 setState on unmounted component 警告。内部实现是 useState + useRef(存帧 ID) + useUnmount 清理,总共大概二十行。你自己写得出来;这个 hook 只是省下了你每次都写一遍。

有个坑。因为 state 比事件慢一帧,调用 setter 立刻读 state 还是旧值:

setPos({ x: 100, y: 100 });
console.log(pos); // 还是 { x: 0, y: 0 } —— 更新还没跑

普通 useState 在同一次渲染周期内也是这样,但慢整整一帧这件事在拼命令式代码时容易让你意外。要回读这个值,旁边再放一个 ref 同步存。

3. useFps——量化你做出来的东西

useRafFnuseRafState 都在改善流畅度,但流畅度是一个可量化的指标,不是感觉。useFps 返回当前帧率(数字),通过统计底层 requestAnimationFrame 回调触发的频率算出来:

import { useFps } from '@reactuses/core';

function FpsOverlay() {
  const fps = useFps();
  const color = fps >= 55 ? 'green' : fps >= 30 ? 'orange' : 'red';

  return (
    <div
      style={{
        position: 'fixed',
        top: 8,
        right: 8,
        padding: '4px 8px',
        background: 'rgba(0,0,0,0.7)',
        color,
        fontFamily: 'monospace',
      }}
    >
      {fps} fps
    </div>
  );
}

丢进 dev build,你就有了平时要打开 Chrome rendering 面板才能看的 FPS 计数器。hook 接受一个 every 选项(默认 10),控制平均多少帧;小数字对卡顿响应快但抖动多,大数字读数更平滑但对突然掉帧反应慢。角落的常驻 overlay 用 10 很合适;如果你在调一段具体的卡顿过场动画,就用 1 或 2。

更有意思的用法是自适应渲染。读 FPS,掉到阈值以下就减少要做的事:

function ParticleSystem({ baseCount = 1000 }: { baseCount?: number }) {
  const fps = useFps({ every: 30 });
  const count =
    fps >= 55 ? baseCount : fps >= 40 ? baseCount / 2 : baseCount / 4;

  return <Particles count={count} />;
}

这正是 3A 游戏引擎在帧预算吃紧时的做法——降粒子数、调阴影分辨率、把流体模拟换成更粗的网格。对一个 React 应用来说,通常把动画背景的粒子数减半,或者干脆停掉一个非关键的 useRafFn 循环,就足够了。阈值数字凭口味;60Hz 显示器上 55 是一条合理的"我们基本还行"的线,因为平均值光被 GC 拽一下就能掉进 55 到 60 区间,没人会注意到。

关于 SSR:hook 在服务端返回 0,所以别把关键 UI 卡在"值非零"上。客户端第一次渲染在首个测量窗口结束前也是 0,下个 tick 才跳到真实值。如果你拿它做自适应渲染,第一个测量到达之前默认走"高保真"分支。

4. useDevicePixelRatio——以正确分辨率绘制

Canvas 元素有两套尺寸:CSS 尺寸决定它在页面上看起来多大;像素缓冲尺寸决定它看起来多精细。在 Retina 屏上设备像素比是 2,于是一个 CSS 尺寸 600px × 400px<canvas width="600" height="400"> 会显得糊——600×400 的像素缓冲被浏览器合成器拉伸到 1200×800 的物理像素上。修法是把缓冲设为 cssWidth × dprcssHeight × dpr,再把绘图上下文按 dpr 缩放,这样坐标还是按 CSS 单位写。

useDevicePixelRatio 响应式地追踪当前像素比——包括用户把窗口从 Retina 笔记本屏拖到外接 1x 显示器时:

import { useRef, useEffect } from 'react';
import { useDevicePixelRatio } from '@reactuses/core';

function CrispCanvas({ width, height, draw }: {
  width: number;
  height: number;
  draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { pixelRatio } = useDevicePixelRatio();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    const ctx = canvas.getContext('2d')!;
    ctx.scale(pixelRatio, pixelRatio);
    draw(ctx, width, height);
  }, [width, height, pixelRatio, draw]);

  return (
    <canvas
      ref={canvasRef}
      style={{ width, height }}
    />
  );
}

三行命令式 setup,但这三行恰好是几乎所有 React canvas 教程都写错的三行:把缓冲尺寸设为 css × dpr,再用内联 style 把 CSS 尺寸设回原始值,最后缩放上下文。这个 hook 让第三个依赖——像素比——变成响应式,所以把窗口从一个显示器拖到另一个会触发以新密度重绘。

内部用的是 matchMedia,针对当前像素比的 (resolution: <ratio>dppx) query。比率变化时 matchMedia 监听器触发,hook 重渲染,你的 effect 拿到新值再跑一次。监听器在挂载时加一次、卸载时移除——和本文所有 hook 一样的生命周期。

同样的模式适用于一切要画像素的东西:图像 canvas、WebGL 上下文、视频帧抽取。对 <img>srcset 选择也有意义,但浏览器会自动处理;只有你自己在做渲染时才需要这个 hook。SSR 返回 1,让服务端的布局计算保持合理,hydration 后第一次绘制时再更新到真实值。

5. useUpdate——一次无 state 的重渲染

本文最怪也是你最少用到的 hook。useUpdate 返回一个引用稳定的函数,调用时强制组件重渲染:

import { useRef } from 'react';
import { useUpdate, useRafFn } from '@reactuses/core';

function StopwatchDisplay() {
  const startRef = useRef(performance.now());
  const update = useUpdate();

  useRafFn(() => {
    update();
  });

  const elapsed = ((performance.now() - startRef.current) / 1000).toFixed(2);
  return <div>{elapsed}s</div>;
}

这个秒表每帧更新一次,并不把已用时间放到 React state 里。真相来源是 performance.now(),每次渲染重新读;useUpdate 的存在只是为了调度渲染。六行,没有 setState,没有对过期时间的闭包。你也可以用 useState((s) => s + 1) 做同样的事,但用 useUpdate 意图更清楚——"再渲一次这玩意",而不是"为了让它再渲一次而递增一个计数器"。

更实用的用法是和那些 React 不追踪其变化的命令式 API 互通。一个通过引用暴露当前相机位置的 WebGL 渲染器;一个 Three.js 场景图;一个你拿来当 state 用、但不想每次改都重建的 SetMap。改完之后调一下 update() 告诉 React 这个组件脏了:

function FavoritesList({ favorites }: { favorites: Set<string> }) {
  const update = useUpdate();

  return (
    <ul>
      {[...favorites].map((id) => (
        <li key={id}>
          {id}{' '}
          <button onClick={() => {
            favorites.delete(id);
            update();
          }}>
            remove
          </button>
        </li>
      ))}
    </ul>
  );
}

直接改 Set 再重渲,对大集合来说比 setFavorites(new Set([...favorites].filter(x => x !== id))) 快,还能让 Set 的引用在多次渲染间保持稳定,下游 memoize 的子组件就不用重算。它当然也是个一脚踏入坑里的好办法——React 的优化假设不可变,凡是靠引用变化检测更新的地方都会默默失灵。要刻意用、用要标注清楚、性能压不出问题就老老实实 useState

useUpdate 也常和 useTextSelection 这类与可变平台对象打交道的 hook 搭档(事件 hooks 那篇覆盖了这种情况)。如果底层对象在多次调用间是同一个引用,setState 是个空操作;useUpdate 就是绕路办法。

凑齐:60fps 弹簧拖尾指针

一次用上五个里的四个。一个用弹簧拖尾跟随真实鼠标的自定义指针,在 Retina 上以正确分辨率绘制,角落显示自己的 FPS,标签页隐藏时暂停:

import { useRef } from 'react';
import {
  useRafFn,
  useRafState,
  useFps,
  useDevicePixelRatio,
  useEventListener,
} from '@reactuses/core';

function SpringCursor() {
  const target = useRef({ x: 0, y: 0 });
  const [pos, setPos] = useRafState({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const fps = useFps();
  const { pixelRatio } = useDevicePixelRatio();

  useEventListener('mousemove', (e: MouseEvent) => {
    target.current = { x: e.clientX, y: e.clientY };
  });

  useRafFn(() => {
    const dx = target.current.x - pos.x;
    const dy = target.current.y - pos.y;
    const stiffness = 0.15;
    const damping = 0.7;
    velocity.current.x = velocity.current.x * damping + dx * stiffness;
    velocity.current.y = velocity.current.y * damping + dy * stiffness;
    setPos({
      x: pos.x + velocity.current.x,
      y: pos.y + velocity.current.y,
    });
  });

  useEventListener('visibilitychange', () => {
    if (document.hidden) velocity.current = { x: 0, y: 0 };
  });

  const size = 24;
  return (
    <>
      <div
        style={{
          position: 'fixed',
          left: pos.x,
          top: pos.y,
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          borderRadius: '50%',
          background: 'currentColor',
          pointerEvents: 'none',
          imageRendering: pixelRatio >= 2 ? 'auto' : 'pixelated',
        }}
      />
      <div style={{ position: 'fixed', top: 8, left: 8, fontFamily: 'monospace' }}>
        {fps} fps @ {pixelRatio}x
      </div>
    </>
  );
}

四个 hook 各干各的。useEventListener 以原生速率把鼠标坐标读到 ref——不触发 React 渲染。useRafFn 每帧跑一次弹簧积分,读最新目标位置、写当前弹簧位置。useRafState 把每帧的位置更新合并成一次渲染。useFps 反馈当前帧率。useDevicePixelRatio 影响 image-rendering 的选择(小细节,但正好是那种没人注意到、直到 1x 显示器上的用户来投诉的细节)。

朴素版本要么在每个 mousemove 上 setState(500Hz 渲染,烧电池),要么靠 setInterval(handler, 16)(漂移,并且在后台标签里继续跑),要么干脆不要弹簧、看上去很廉价。用这些 hook 之后,读取频率就是问题本身的频率——每帧一次,React 树永远不会以快于用户能看到的速度重渲染。

何时用哪个

你想
每个动画帧跑一个回调 useRafFn
每次绘制最多更新一次 state useRafState
测当前帧率 useFps
以显示器原生分辨率绘制 useDevicePixelRatio
改了 React 看不到的东西之后重新渲染 useUpdate

两条非规则。useRafFn 不是 setInterval 的替代——它按显示器刷新率跑,ProMotion 屏上是 120Hz,省电模式标签里是 30Hz。如果你要严格的"每秒 N 次"节拍,用 useInterval 然后接受视觉代价。还有 useUpdate 是逃生舱——一份代码库里反复用它超过一两次,背后的真问题往往是"我为了性能把 state 放到了 React 之外",正确的修法是修那个性能问题,而不是把逃生舱当常规。

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

五个 hook 都是单独 tree-shake——引 useRafState 不会把 useDevicePixelRatio 拖进来。每个都带 TypeScript 类型,在客户端渲染应用和 SSR 框架(Next.js、Remix、Astro)里都能用;基于循环的 hook 在服务端是 no-op,useDevicePixelRatiouseFps 在 hydration 之前返回安全默认值(分别是 10)。

相关 hook

如果你想要的渲染循环 hook 不在这份名单里,三篇邻居博客可以一起看。ref 逃生舱 那篇讲 useLatest——它就是 useRafFn 内部用来让回调看到新鲜闭包又不重启循环的那个 trick——如果你想理解这些 hook 怎么实现而不只是怎么用,从这一篇开始。事件 hooksuseEventListeneruseThrottleFn,它们和 useRafFn 在输入驱动的动画上配合得很自然。滚动效果 那篇讲的是在这些原语之上更高一层的滚动联动动画 hook。

reactuse.com 浏览完整列表,或者直接打开上面任意一个 hook 读源码——它们大多不到 40 行,五个 hook 底下的循环原语都是同一个八行的 useRef + useEffect 模式,你大概率已经自己写过半打了。

useEffect 之外:专门处理异步、深比较和 SSR 的 Effect Hook

React 只给了你一个 effect hook:useEffect。其他所有 effect 模式——挂载后只跑一次、跳过首次渲染、比较对象依赖、不带竞态地处理异步、不在服务端报警告地跑 layout effect——都得你自己拼。大多数团队最后都会在 utils/hooks.ts 里塞五六个 wrapper hook。不同团队写的是同一个东西的不同变体,其中有些版本是错的。

这种重复性的基础设施不应该出现在你的代码库里。ReactUse 已经把这些专门 effect hook 给你做好了——围绕 useEffectuseLayoutEffect 的一组小而专的封装,把最常见的缺口都补齐了。这篇文章过一遍其中九个:useEffect 在哪里别扭、专门 hook 做了什么不同的事、以及一个能用上的具体例子。

如果你已经在用 ReactUse 的计时器、observer 或者浏览器 API,可能已经无意识地导入过其中几个了。专门走一遍的意义是:在你下次再写那个 wrapper 之前,先知道工具箱里有什么。

为什么单个 useEffect 不够用

来看一个真实组件里的一行:

useEffect(() => {
  fetch(`/api/user/${id}`).then((r) => r.json()).then(setUser);
}, [id]);

这一段第一天就有四个问题,过一个月还会有第五个:

  1. 没有 abort。 如果 id 在请求飞行中变了,旧请求会在新请求之后才返回,把新数据覆盖掉——经典的竞态。
  2. 没法用 async/await。 你不能把 effect 回调标成 async,因为 React 要的是 undefined 或者一个清理函数,不是 Promise。所以每个异步 effect 不是用 .then 链就是包一个 IIFE。
  3. 没法跳过 mount。 有时候你只想在 id 变化时响应,而不是在组件首次渲染时跑(初始数据是父组件给的)。普通 useEffect 至少要跑一次。
  4. 依赖不会做深比较。 如果 id{ workspace: "a", user: "b" },父组件每次重渲染都会产生新的对象引用,effect 每次都会跑,即使内容没变。
  5. SSR + useLayoutEffect 一个月后有人把组件改成用 useLayoutEffect 做 DOM 测量,SSR 每次渲染都会打警告。

每个问题都能修,但修起来 5 到 30 行代码,而且很容易错得很隐蔽。下面这些 hook 直接把每个缺口堵上。

1. useAsyncEffect — 不需要 IIFE 的 async/await

第一次写都会写出来的模式:

useEffect(() => {
  let cancelled = false;
  (async () => {
    const r = await fetch(`/api/user/${id}`);
    const data = await r.json();
    if (!cancelled) setUser(data);
  })();
  return () => { cancelled = true; };
}, [id]);

这是对的。这也是 6 行样板代码,本来如果 React 允许的话,一句 async () => { setUser(await fetch(...).then((r) => r.json())); } 就能搞定。useAsyncEffect 就是把这个缺口补上:

import { useAsyncEffect } from "@reactuses/core";

useAsyncEffect(async () => {
  const r = await fetch(`/api/user/${id}`);
  setUser(await r.json());
}, [id]);

这个 hook 直接接受 async 回调,并忽略掉返回的 Promise(不会产生 cleanup 警告)。它不会帮你处理取消——那是下一个 hook 的事,或者你手动用 AbortController。当异步体很短、不需要中途退出时,用 useAsyncEffect。需要取消时,接一个 AbortController:

useAsyncEffect(async (signal) => {
  const r = await fetch(`/api/user/${id}`, { signal });
  setUser(await r.json());
}, [id]);

hook 把一个 AbortSignal 作为第一个参数传进来,清理时会 abort 它,所以飞行中的请求被取消,而不是回到一个过期的 state setter 上。

这一个 hook 大约能消除典型代码库里 80% 的「我本该写个 wrapper」时刻。大部分数据请求 effect 都是短的、异步的、希望在变化时被取消。useAsyncEffect 就是这个形状。

2. useUpdateEffect — 跳过 mount

useEffect 总是在第一次渲染后就跑一次。有时候这是错的:如果一个组件已经从 props 拿到初始值,在 mount 时跑 effect 要么重复了工作,要么在还没真正变化时就触发了「值变了」的通知。

普通 React 的绕过办法是一个 ref:

const isFirst = useRef(true);
useEffect(() => {
  if (isFirst.current) { isFirst.current = false; return; }
  onChange(value);
}, [value]);

这是对的,但每个团队的代码库里都至少有三个这样的版本。useUpdateEffectuseEffect 一样,只是少了第一次:

import { useUpdateEffect } from "@reactuses/core";

useUpdateEffect(() => {
  onChange(value);
}, [value]);

最常见的用法是受控组件的变更通知。你希望在内部 value 变化时调用 onChange,而不是在父组件第一次用初始值挂载组件时。普通 useEffect 版本会在 mount 时触发,父组件在用户什么都还没做的时候就收到了一个虚假的 onChange(initialValue)

第二个用法是埋点:「filter 变化时发 viewed_filter 事件。」mount 不是变化,它是起始状态。

3. useMount — 「挂载时跑一次」的惯用法

useEffect(() => { /* ... */ }, []) 在技术上确实是「mount 时跑一次」的正确写法。它也视觉上吵闹,而且经常被 lint 规则误伤(eslint 的 exhaustive-deps 会在回调闭包到任何变量时抱怨,即使你确实想要「mount 时的快照」)。

useMount 是一个单用途的别名,文档化了意图:

import { useMount } from "@reactuses/core";

useMount(() => {
  trackPageView();
  initialiseSentry();
});

功能上等同于 useEffect(fn, []),但名字就是文档。看到 useMount,你不用看依赖就知道回调正好跑一次。看到 useEffect(fn, []),你得扫一遍 body 才能确认没有闭包到本该出现在依赖里的响应式变量。

4. useUnmount — 不需要空 effect 的清理

useMount 的镜像。普通 React 写「卸载时做 X」是这样:

useEffect(() => () => doCleanup(), []);

这解析为「effect 回调返回一个清理函数」。是对的,但内层的双箭头属于没人会读第二遍的东西。useUnmount 是显式版本:

import { useUnmount } from "@reactuses/core";

useUnmount(() => {
  socket.close();
  flushAnalytics();
});

这个 hook 内部用 ref 捕获最新的回调,所以你在卸载时拿到的是最新的值,而不是 mount 时的值。这修了普通 React 版本里一个隐蔽的 bug:如果你写 useEffect(() => () => doCleanup(value), []),value 是 mount 时被捕获的,清理跑的是过期数据。useUnmount 没这个 bug。

5. useDeepCompareEffect — 当你的依赖是对象

React 用 Object.is 比较 effect 依赖。如果依赖是对象或数组,父组件每次重渲染都产生新引用,即使内容相同 effect 也会跑。大部分团队会去 JSON.stringify 依赖,这对浅数据有效,对带函数、Date 或不可序列化值的就崩了。

useDeepCompareEffectObject.is 换成结构化的深度相等检查:

import { useDeepCompareEffect } from "@reactuses/core";

useDeepCompareEffect(() => {
  fetcher.run(query);
}, [query]); // query 是 { workspace: "a", filters: { ... } }

当父组件重渲染,生成一个内容相同的新 query 对象时,effect 不会重跑。当内容真的变了,它才跑。代价是深度相等检查是 O(n) 的——不是免费的。当你有个小对象依赖、又无法在源头 memo 它时,选这个。如果能 useMemo,优先 useMemo

有一个坑:不要把 useDeepCompareEffect 用在只有原始值的依赖上。如果你传 [someString, someNumber],hook 会抛错——对那种情况 useEffect 才是对的工具,而 hook 会大声失败,免得你悄悄拖慢一个本来不需要的 effect。

6. useCustomCompareEffect — 深比较,但按你的规则

有时候你想要的相等性既不是浅的也不是完全结构化的。两种情况经常出现:

  • 按单个字段比较(比如 prev.id === next.id)。
  • 用你已经依赖的库比较(比如 lodash.isEqualdequal)。

useCustomCompareEffect 接受第三个参数:一个比较器,决定新依赖是否应该触发 effect。

import { useCustomCompareEffect } from "@reactuses/core";
import { dequal } from "dequal";

useCustomCompareEffect(
  () => loadDashboard(filters),
  [filters],
  (prev, next) => dequal(prev, next),
);

相比 useDeepCompareEffect 的好处是你控制成本。对 200 个字段的配置对象做深比较很慢;(prev, next) => prev.version === next.version 只比较一次。有 version 字段就用它。

这也是模糊相等的正确 hook——比如「两个滚动位置只要相差 5 像素以内就认为相等」。普通 useEffect 版本需要一个 wrapper ref 加一段 effect 内部的手写比较;custom-compare 版本把相等性逻辑跟依赖放在一起。

7. useOnceEffect — 跑且只跑一次,但依赖是响应式的

useEffect(fn, []) 在 mount 时跑一次,但回调闭包到的是那一刻依赖的值——通常是 undefined 或初始值。如果你真正想要的是**user 第一次非 loading 的值**触发 effect,那么 useEffect(fn, [user])(每次 user 变都跑)和 useEffect(fn, [])(mount 时跑而 user 还是 null)都不对。

useOnceEffect 在任一依赖第一次从初始值变化时跑 effect,然后再也不跑:

import { useOnceEffect } from "@reactuses/core";

function PersonalisedGreeting() {
  const { user } = useAuth(); // user 在加载完成前是 null

  useOnceEffect(() => {
    track("personalised_greeting_seen", { userId: user.id });
  }, [user]);

  return user ? <h1>Hi, {user.name}!</h1> : null;
}

effect 触发一次——user 第一次变成非 null 时——之后即使 user 再变也不会再触发。这是首屏埋点、一次性 onboarding 触发、以及「等前置条件就绪后做这件事」模式的正确形状。普通 React 版本是 ref 加 flag 的舞蹈,谁都写过,谁也不想再读一遍。

useOnceEffect 也有 layout-effect 的兄弟,useOnceLayoutEffect,用于同样的模式但需要在 paint 前做 DOM 测量。

8. useIsomorphicLayoutEffect — 让 SSR 警告消失

useLayoutEffect 在 DOM 变更后、paint 前同步运行。它是读取布局(测元素尺寸)和在同一个 tick 内写 DOM(把 tooltip 定位到触发器旁边)的正确 hook。它也是会在 SSR 时打这条警告的 hook:

useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format.

标准修法是在 typeof window === "undefined" 时把 useLayoutEffect 换成 useEffect。这就是 useIsomorphicLayoutEffect 做的事:

import { useIsomorphicLayoutEffect } from "@reactuses/core";

useIsomorphicLayoutEffect(() => {
  const { width } = ref.current!.getBoundingClientRect();
  setWidth(width);
}, []);

在服务端,这是 useEffect(SSR 期间是 no-op——没问题,因为根本没有可测的布局)。在客户端,这是 useLayoutEffect(同步触发,这正是你做布局读取时想要的)。一个 import,没警告,没特殊处理。

这是 React 生态里被复制最多的一段代码。如果你在 SSR 代码库(Next.js、Remix、Astro 带岛屿)里任何地方用了 useLayoutEffect,这个 hook 就该是默认选择。

9. useUpdateLayoutEffect — useUpdateEffect 的 layout 版本

useUpdateEffect 的 layout-effect 兄弟。同样的模式:跳过首次渲染,在之后每次依赖变化时跑,但在 layout-effect 时刻跑,所以 DOM 变更发生在 paint 之前。

useUpdateLayoutEffect 在 layout 驱动的动画里特别有用:

import { useUpdateLayoutEffect } from "@reactuses/core";

useUpdateLayoutEffect(() => {
  const el = listRef.current;
  if (!el) return;
  el.style.transform = `translateY(${activeIndex * itemHeight}px)`;
}, [activeIndex]);

为什么不用 useUpdateEffect?因为 useEffect 在 paint 之后触发,滑动动画会肉眼可见地从旧位置出发然后才闪到新位置。useLayoutEffect 在 paint 之前跑,新 transform 在同一帧应用。为什么不用普通 useLayoutEffect?因为首次渲染时 activeIndex 是初始值,没有动画要开始。

「跳过 mount 的 layout effect」组合,正好是「动画一个变化,但不是初始值」的形状。也是「受控焦点」的形状:在 activeTab 变化时把焦点移到新 tab 内容上,但不要在组件第一次以 activeTab="home" 挂载时这样做。

何时用哪个:决策表

完整一组,集中放在一处:

情景 选用
异步 effect 体,需要可取消 useAsyncEffect
跳过第一次,响应之后的每次变化 useUpdateEffect
同上,但用 layout effect useUpdateLayoutEffect
挂载时跑一次(意图更清晰) useMount
卸载时跑一次(不会捕获过期值) useUnmount
effect 依赖是对象,想要结构化相等 useDeepCompareEffect
effect 依赖需要自定义相等检查 useCustomCompareEffect
只跑一次,但要等某个依赖「就绪」 useOnceEffect
同上,layout effect 版本 useOnceLayoutEffect
SSR 时不会警告的 layout effect useIsomorphicLayoutEffect

记住三条:

  1. 默认还是 useEffect 专门 hook 是给上面这些情况用的;不要预防性地用。
  2. layout 配 layout,异步配异步。 如果你在做 DOM 测量,选 layout-effect 家族。如果在做数据请求,选 useAsyncEffect。混着用会有闪烁或竞态。
  3. useUpdateEffect 不是「useEffect 的性能优化」。 它改变行为,不是性能。第一次渲染仍然发生,你只是不在它上面跑 effect。如果你的目标是性能,看依赖数组,不是看 hook。

一个真实的组合

一个常见的 React 模式:一个「搜索结果」面板,在 query 变化时请求,在 mount 时跳过请求(父组件传了初始结果),并向屏幕阅读器宣布「搜索已更新」——但不在 mount 时宣布,因为标题已经传达了相同的信息。

import {
  useAsyncEffect,
  useUpdateEffect,
  useIsomorphicLayoutEffect,
} from "@reactuses/core";

function SearchResults({ query, initialResults }: {
  query: string;
  initialResults: Result[];
}) {
  const [results, setResults] = useState(initialResults);
  const announceRef = useRef<HTMLDivElement>(null);

  // 跳过 mount;之后每次 query 变化都请求。
  useUpdateEffect(() => {
    let cancelled = false;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((r) => r.json())
      .then((data) => { if (!cancelled) setResults(data); });
    return () => { cancelled = true; };
  }, [query]);

  // Layout effect:读取结果数并在 paint 前更新 aria-live。
  // 跳过 mount,因为初始标题已经说过了。
  useIsomorphicLayoutEffect(() => {
    if (!announceRef.current) return;
    announceRef.current.textContent = `${results.length}${query} 的结果`;
  }, [results, query]);

  return (
    <>
      <div ref={announceRef} role="status" aria-live="polite" className="sr-only" />
      <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
    </>
  );
}

三种行为,三个 hook,没有 ref 加 flag。如果第一个 useUpdateEffect 的 body 变复杂到想用 async/await,把它换成 useAsyncEffect;其余照旧。

上手试试

上面每个 hook 都有可运行的文档示例。读 demo,改依赖,看哪些会触发:

npm install @reactuses/core(或 pnpm add @reactuses/core)安装,直接 import。没有 provider,除了 React 16.8+ 之外没有 peer dependency。完整的 hook 列表和源代码在 reactuse.com

useEffect 是个原语。这些 hook 是你在它之上一次性建好、不再每个项目重新发明的那一层语言。

React 表单处理:防抖校验、自动保存草稿与受控输入

表单是每个 React 应用里被重写次数最多的部分。第一天看上去再简单不过——丢一个 <input>,把 onChange 接到 useState,发版。到了第三个月,同一个表单上多了异步用户名校验、一份自动保存的草稿、一个自定义日期浮层,以及一个必须和设计系统配合好的"受控/非受控"开关。每一项都拖进来自己的临时状态机、自己的 effect 清理逻辑,以及自己那一堆边界情况。表单文件成了仓库里最长的那一个,团队里没人愿意碰它。

本文将走过四个非平凡表单迟早都会用到的原语:用一个防抖值来限流异步校验、用一个"受控或非受控"包装让组件两种用法都接受、用 localStorage 撑起一份能在刷新中存活的草稿,以及一个不会泄漏监听器的"点击外部关闭"浮层方案。每一个原语,我们都会先写手动版本,把代价摆出来,再换成 ReactUse 中专门的 Hook。最后我们把四个 Hook 组合成一个完整的"账户设置"表单:边输入边校验、自动保存草稿、还包含一个国家选择浮层。

1. 防抖的异步校验

手动实现

异步校验最经典的错误,是每敲一个键就发一次请求。经典的修法是 setTimeout,经典的 bug 是忘了清理上一次的定时器:

import { useEffect, useState } from "react";

function ManualUsernameField() {
  const [username, setUsername] = useState("");
  const [debounced, setDebounced] = useState("");
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    const id = setTimeout(() => setDebounced(username), 400);
    return () => clearTimeout(id);
  }, [username]);

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

这里有两个 effect,干着两件不同的事,还必须保持同步。第一个是防抖器:把 username 的密集变化压成一个延迟后的 debounced 值。第二个是请求执行器:当 debounced 变化时发请求,并忽略掉过期返回。两个 effect 都需要自己的清理逻辑。忘了 clearTimeout,请求会重复;忘了 cancelled 标志,竞态会让旧响应覆盖新响应。

真正的代价不是行数——而是这段防抖逻辑被焊死在了这个具体字段上。要在 email 字段复用同样的能力,就得复制粘贴这五行。

ReactUse 的写法:useDebounce

useDebounce 返回一个比输入值落后固定延迟的值:

import { useEffect, useState } from "react";
import { useDebounce } from "@reactuses/core";

function UsernameField() {
  const [username, setUsername] = useState("");
  const debounced = useDebounce(username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

第一个 effect——专管防抖的那个——消失了。useDebounce 自己接管了定时器和清理。剩下的代码才是真正属于你这个表单的部分:当防抖值变化时跑一次校验请求,并丢弃过期返回。

这个 Hook 还和函数版的 useDebounceFn 天然搭配——当你想给的是一个事件处理器(比如"失焦保存")而不是一个值时,就用它。

2. 受控还是非受控——选一种,两种都支持

手动实现

库组件经常面对一个老问题:消费者应当传 valueonChange,还是让组件内部用 defaultValue 自己管状态?老实说答案是"看谁用"。大多数团队都得在每个字段上重新发明一遍这个模式:

function ManualToggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue);
  const current = isControlled ? value : internal;

  const handleClick = () => {
    const next = !current;
    if (!isControlled) setInternal(next);
    onChange?.(next);
  };

  return (
    <button role="switch" aria-checked={current} onClick={handleClick}>
      {current ? "开" : "关"}
    </button>
  );
}

模式本身不复杂,但它是一块吸 bug 的磁铁。如果消费者中途把 value 切回 undefined,模式就在受控和非受控间跳了一次。如果他们传了 value 却没传 onChange 呢?React 自己的表单输入会对这两种情况都给出警告,但自定义组件几乎从不写这些校验——而当设计系统不断扩张,每一个 input、switch、slider、date picker 都会复制一遍这堆样板。

ReactUse 的写法:useControlled

useControlled 把整个模式塌缩成一个 Hook 调用:

import { useControlled } from "@reactuses/core";

function Toggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [current, setCurrent] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <button
      role="switch"
      aria-checked={current}
      onClick={() => setCurrent(!current)}
    >
      {current ? "开" : "关"}
    </button>
  );
}

这个 Hook 替你做了三件你本来要自己写的事:

  1. 首次渲染时定型——决定是受控还是非受控,如果之后模式翻转就给出警告,和 React 内置 input 的诊断口径一致。
  2. 返回一个稳定的 setter,内部根据模式分支:非受控时更新内部状态;受控时只调 onChange,让父组件去重新渲染。
  3. 始终反映最新的事实。元组的第一个元素在受控时是 value、非受控时是内部状态,消费者永远不会看到不一致。

把它丢进设计系统里任何 input 形状的组件,从此不再为这个模式分心。

3. 自动保存表单草稿

手动实现

长表单——引导流、设置页、内容编辑器——绝不该让用户的工作毁于一次刷新。标准做法是把表单状态镜像到 localStorage;标准的失误是每敲一下键就写一次:

function ManualDraftForm() {
  const [draft, setDraft] = useState(() => {
    if (typeof window === "undefined") return { title: "", body: "" };
    const raw = localStorage.getItem("post-draft");
    return raw ? JSON.parse(raw) : { title: "", body: "" };
  });

  useEffect(() => {
    localStorage.setItem("post-draft", JSON.stringify(draft));
  }, [draft]);

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
      />
    </form>
  );
}

这十五行里藏着三个问题。第一,惰性初始化会在挂载时读一次 localStorage,但不会在另一个标签页更新它时再读——多标签页编辑会安静地翻车。第二,JSON.parse 遇到损坏数据会抛错,组件就在挂载时崩了。第三,localStorage.setItem 是同步的,每次渲染都跑一次,对一个手快的用户而言会顶住主线程。

最上面那行 SSR 检查就是个信号:这是一段会被仓库里其它组件复制过去、并大概率写错的"配方"。

ReactUse 的写法:useLocalStorage

useLocalStorage 长得像 useState、用起来也像 useState,但值住在存储里:

import { useLocalStorage } from "@reactuses/core";

function DraftForm() {
  const [draft, setDraft] = useLocalStorage("post-draft", {
    title: "",
    body: "",
  });

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft({ ...draft, title: e.target.value })}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft({ ...draft, body: e.target.value })}
      />
    </form>
  );
}

手动版本搞错或漏掉的四件事,这个 Hook 都帮你做好了:

  1. SSR 安全初始化。在服务端返回默认值;客户端首次渲染时无失配地完成水合。
  2. 跨标签页同步。监听 storage 事件,当另一个标签页写入同一个键时同步状态。
  3. JSON 容错。捕获解析错误并退回默认值,不再让组件崩溃。
  4. 稳定的 setter。返回的 setter 引用稳定,可以安全地放进 useEffect 依赖或 memo 化的子组件里。

对真的很长的表单,常常想要"自动保存 + 防抖"。把第一节的 useDebounce 搭进来——先防抖表单状态,再把防抖后的值写进存储——你就得到一个能在刷新中存活、又不会捶硬盘的编辑器。

4. 用"点击外部"关闭浮层

手动实现

国家选择器、日期选择器、自动补全菜单,以及一切浮在页面上的东西,都得在用户点别的地方时关掉自己。教科书式的实现是在 document 上监听:

function ManualPopover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button onClick={() => setOpen((v) => !v)}>切换</button>
      {open && <div className="popover">{children}</div>}
    </div>
  );
}

简单场景这能跑——直到你的浮层被 portal 渲染到别处。ref.current.contains(...) 假设浮层是触发器的 DOM 后代,但真实的设计系统里几乎从来不是:浮层会被挂到 body 根节点,绕开父容器的 overflow。你还得在 mousedownclick 之间做选择(多数情况下答案是 mousedown,这样浮层会在某个下游 click 处理器触发之前就关掉),而且记得在关闭时跳过监听,免得每次页面 click 都白跑一遍。

ReactUse 的写法:useClickOutside

useClickOutside 接收一个 ref(或一组 ref)和一个处理器:

import { useRef, useState } from "react";
import { useClickOutside } from "@reactuses/core";

function Popover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);

  useClickOutside([triggerRef, popoverRef], () => setOpen(false));

  return (
    <>
      <div ref={triggerRef}>
        <button onClick={() => setOpen((v) => !v)}>切换</button>
      </div>
      {open && (
        <div ref={popoverRef} className="popover">
          {children}
        </div>
      )}
    </>
  );
}

支持 ref 数组的形式,正是它能搞定 portal 浮层的关键:把触发器和浮动面板都标成"内部",点其它地方就触发处理器。Hook 也替你处理 mousedown 的选择,监听器只在 document 层挂一次(不会在每个组件里来回挂卸),并在卸载时清理干净。

它还有一个相近的兄弟 useClickAway——API 略有不同,适合只有单个 ref 的场景,按你组件里读起来更顺的那个挑就行。

组合在一起:账户设置表单

下面是一个完整的账户设置表单,把四个 Hook 都用上了。用户名边输入边校验。整个表单自动保存到 localStorage。通知开关是受控/非受控两可的组件。国家选择器是个对 portal 友好、点击外部就关的浮层。

import { useEffect, useRef, useState } from "react";
import {
  useDebounce,
  useControlled,
  useLocalStorage,
  useClickOutside,
} from "@reactuses/core";

interface Settings {
  username: string;
  country: string;
  notifications: boolean;
}

const COUNTRIES = ["中国", "日本", "德国", "巴西", "印度"];

function NotificationSwitch({
  value,
  defaultValue = true,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [on, setOn] = useControlled({ value, defaultValue, onChange });
  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      onClick={() => setOn(!on)}
      style={{
        width: 48,
        height: 24,
        borderRadius: 999,
        border: "none",
        background: on ? "#3b82f6" : "#cbd5e1",
        position: "relative",
        cursor: "pointer",
      }}
    >
      <span
        style={{
          position: "absolute",
          top: 2,
          left: on ? 26 : 2,
          width: 20,
          height: 20,
          borderRadius: "50%",
          background: "white",
          transition: "left 120ms ease",
        }}
      />
    </button>
  );
}

function CountryPicker({
  value,
  onChange,
}: {
  value: string;
  onChange: (next: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);

  useClickOutside([triggerRef, menuRef], () => setOpen(false));

  return (
    <div style={{ position: "relative", display: "inline-block" }}>
      <button
        ref={triggerRef}
        type="button"
        onClick={() => setOpen((v) => !v)}
        style={{
          padding: "6px 12px",
          borderRadius: 6,
          border: "1px solid #cbd5e1",
          background: "white",
          cursor: "pointer",
        }}
      >
        {value || "选择国家"} ▾
      </button>
      {open && (
        <ul
          ref={menuRef}
          style={{
            position: "absolute",
            top: "calc(100% + 4px)",
            left: 0,
            margin: 0,
            padding: 4,
            listStyle: "none",
            background: "white",
            border: "1px solid #cbd5e1",
            borderRadius: 8,
            boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
            minWidth: 180,
          }}
        >
          {COUNTRIES.map((c) => (
            <li
              key={c}
              onClick={() => {
                onChange(c);
                setOpen(false);
              }}
              style={{
                padding: "6px 10px",
                borderRadius: 4,
                cursor: "pointer",
                background: c === value ? "#eff6ff" : "transparent",
              }}
            >
              {c}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default function SettingsForm() {
  const [settings, setSettings] = useLocalStorage<Settings>("account-settings", {
    username: "",
    country: "",
    notifications: true,
  });

  const debouncedUsername = useDebounce(settings.username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debouncedUsername) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debouncedUsername)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      })
      .catch(() => {
        if (!cancelled) setStatus("idle");
      });
    return () => {
      cancelled = true;
    };
  }, [debouncedUsername]);

  return (
    <form
      style={{
        maxWidth: 480,
        display: "grid",
        gap: 16,
        fontFamily: "system-ui, sans-serif",
      }}
      onSubmit={(e) => e.preventDefault()}
    >
      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>用户名</span>
        <input
          value={settings.username}
          onChange={(e) =>
            setSettings({ ...settings, username: e.target.value })
          }
          style={{
            padding: "8px 10px",
            borderRadius: 6,
            border: "1px solid #cbd5e1",
          }}
        />
        <span style={{ fontSize: 12, color: "#64748b" }}>
          {status === "checking" && "校验中..."}
          {status === "ok" && "✓ 可用"}
          {status === "taken" && "✗ 已被占用"}
        </span>
      </label>

      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>国家</span>
        <CountryPicker
          value={settings.country}
          onChange={(country) => setSettings({ ...settings, country })}
        />
      </label>

      <label
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        }}
      >
        <span style={{ fontSize: 14, color: "#475569" }}>邮件通知</span>
        <NotificationSwitch
          value={settings.notifications}
          onChange={(notifications) =>
            setSettings({ ...settings, notifications })
          }
        />
      </label>
    </form>
  );
}

四个 Hook,四种职责,零重叠:

  • useDebounce 把密集敲击压成一次延迟值,让异步校验只在用户停顿后才发请求
  • useControlled 让开关组件同时接受 valuedefaultValue 两种用法,不必复制分支逻辑
  • useLocalStorage 把整个设置对象在刷新中持久化,附带 SSR 安全初始化与跨标签页同步
  • useClickOutside 在用户点击触发器与菜单之外的任何地方时关闭国家菜单——portal 渲染同样工作

整个表单文件最后大约 200 行,绝大部分是 JSX 与样式。那些容易写错的浏览器细枝末节——定时器清理、SSR 存储访问、受控/非受控判别、document 级监听——都被收进了那些已经被各种翻车场景打磨过的库 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useDebounce — 让一个值按固定延迟落后于其输入
  • useDebounceFn — 防抖一个回调而非一个值
  • useControlled — 构建同时接受受控/非受控用法的组件
  • useLocalStorage — 持久化到 localStorage 的 useState,自带 SSR 安全与跨标签页同步
  • useSessionStorage — 与 useLocalStorage 同形,但作用域为会话
  • useClickOutside — 检测一个或多个元素之外的点击
  • useClickAway — 单 ref 版本的点击外部检测
  • useToggle — 带显式 toggle setter 的布尔状态
  • usePrevious — 读取上一次的状态值,用于表单中的变更检测

ReactUse 提供 100+ 个 React Hook。全部探索 →

React 中的语音与摄像头输入:语音识别、媒体设备与权限

语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。

本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。

1. 实时语音识别

手动实现

Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:

function ManualSpeechRecognition() {
  const [transcript, setTranscript] = useState("");
  const [listening, setListening] = useState(false);
  const recognitionRef = useRef<any>(null);

  useEffect(() => {
    const SR =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;
    if (!SR) return;
    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "zh-CN";
    recognition.onresult = (event: any) => {
      const result = event.results[event.resultIndex];
      setTranscript(result[0].transcript);
    };
    recognition.onend = () => setListening(false);
    recognitionRef.current = recognition;
    return () => recognition.abort();
  }, []);

  const start = () => {
    recognitionRef.current?.start();
    setListening(true);
  };
  const stop = () => {
    recognitionRef.current?.stop();
    setListening(false);
  };

  return (
    <div>
      <button onClick={listening ? stop : start}>
        {listening ? "停止" : "开始"}识别
      </button>
      <p>{transcript}</p>
    </div>
  );
}

这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。

ReactUse 的方式:useSpeechRecognition

useSpeechRecognition 返回一个干净的对象,提供恰当的原语:

import { useSpeechRecognition } from "@reactuses/core";

function VoiceNote() {
  const { isSupported, isListening, isFinal, result, error, start, stop } =
    useSpeechRecognition({
      lang: "zh-CN",
      interimResults: true,
      continuous: true,
    });

  if (!isSupported) {
    return <p>当前浏览器不支持语音识别。</p>;
  }

  return (
    <div>
      <button onClick={isListening ? stop : start}>
        {isListening ? "停止" : "开始"}口述
      </button>
      <p
        style={{
          fontStyle: isFinal ? "normal" : "italic",
          color: isFinal ? "#0f172a" : "#64748b",
        }}
      >
        {result || "说点什么..."}
      </p>
      {error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
    </div>
  );
}

你不用写就能拿到的好处:

  1. isFinal —— Hook 会跟踪当前 result 是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。
  2. error 对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。
  3. 热配置start({ lang: "fr-FR" }) 让你能在会话中途切换语言,无需重建识别器。
  4. 卸载时清理。Hook 会自动调用 abort(),所以离开页面永远不会让麦克风一直开着。

最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。

2. 枚举摄像头和麦克风

手动实现

列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。

function ManualDeviceList() {
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

  useEffect(() => {
    let mounted = true;
    const refresh = async () => {
      try {
        // 触发权限以填充标签
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        stream.getTracks().forEach((t) => t.stop());
        const list = await navigator.mediaDevices.enumerateDevices();
        if (mounted) setDevices(list);
      } catch (e) {
        console.error(e);
      }
    };
    refresh();
    navigator.mediaDevices.addEventListener("devicechange", refresh);
    return () => {
      mounted = false;
      navigator.mediaDevices.removeEventListener("devicechange", refresh);
    };
  }, []);

  return (
    <ul>
      {devices.map((d) => (
        <li key={d.deviceId}>
          {d.kind}: {d.label || "(标签隐藏)"}
        </li>
      ))}
    </ul>
  );
}

形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。

ReactUse 的方式:useMediaDevices

useMediaDevices 把整套流程打包了起来:

import { useMediaDevices } from "@reactuses/core";

function CameraPicker({
  selected,
  onSelect,
}: {
  selected: string;
  onSelect: (id: string) => void;
}) {
  const [{ devices }, ensurePermissions] = useMediaDevices({
    requestPermissions: true,
    constraints: { video: true, audio: false },
  });

  const cameras = devices.filter((d) => d.kind === "videoinput");

  return (
    <div>
      <button onClick={() => ensurePermissions()}>刷新设备</button>
      <select
        value={selected}
        onChange={(e) => onSelect(e.target.value)}
        style={{ marginLeft: 8 }}
      >
        {cameras.map((cam) => (
          <option key={cam.deviceId} value={cam.deviceId}>
            {cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
          </option>
        ))}
      </select>
    </div>
  );
}

Hook 处理了三件你本来要自己写的事:

  • 权限协商。传 requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发 getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。
  • 实时设备列表。Hook 监听 devicechange 并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。
  • 手动刷新。返回的 ensurePermissions 让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。

constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。

3. 正确地查询权限

手动实现

要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:

function ManualMicPermission() {
  const [state, setState] = useState<PermissionState | "unknown">("unknown");

  useEffect(() => {
    let mounted = true;
    let status: PermissionStatus | null = null;
    (async () => {
      try {
        status = await navigator.permissions.query({
          name: "microphone" as PermissionName,
        });
        if (mounted) setState(status.state);
        status.onchange = () => mounted && status && setState(status.state);
      } catch {
        // 此名称的 Permissions API 不可用
      }
    })();
    return () => {
      mounted = false;
      if (status) status.onchange = null;
    };
  }, []);

  return <p>麦克风权限:{state}</p>;
}

三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。

ReactUse 的方式:usePermission

usePermission 把整段舞蹈减到一次调用:

import { usePermission } from "@reactuses/core";

function MicStatusBadge() {
  const state = usePermission("microphone");

  const color =
    state === "granted"
      ? "#10b981"
      : state === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <span style={{ color, fontWeight: 600 }}>
      麦克风:{state || "未知"}
    </span>
  );
}

state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。

你可以传一个像 "microphone""camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。

4. 用 useKeyModifier 实现按住说话

手动实现

按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。

function ManualPushToTalk() {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(true);
    };
    const onUp = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(false);
    };
    const onBlur = () => setPressed(false);
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}

这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。

ReactUse 的方式:useKeyModifier

useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:

import { useKeyModifier } from "@reactuses/core";

function ShiftToRecord({ onTalkStart, onTalkEnd }: {
  onTalkStart: () => void;
  onTalkEnd: () => void;
}) {
  const shift = useKeyModifier("Shift");

  useEffect(() => {
    if (shift) onTalkStart();
    else onTalkEnd();
  }, [shift, onTalkStart, onTalkEnd]);

  return (
    <div
      style={{
        padding: 16,
        background: shift ? "#fef3c7" : "#f1f5f9",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
    </div>
  );
}

相比 keydown/keyup 版本的好处:

  • OS 感知。Hook 读取 getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。
  • 支持任何修饰键。传 "Control""Alt""Meta""CapsLock""NumLock"——浏览器追踪的任何修饰键都行。
  • 初始值。如果你想让 React state 初始为 true,就配置 initial: true(不常见,但调试时有用)。

全部组合:带设备选择器的语音搜索

我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。

import { useEffect, useState } from "react";
import {
  useSpeechRecognition,
  useMediaDevices,
  usePermission,
  useKeyModifier,
} from "@reactuses/core";

function VoiceSearch() {
  const [selectedMic, setSelectedMic] = useState<string>("");
  const [query, setQuery] = useState("");

  const micPermission = usePermission("microphone");
  const [{ devices }, requestDevices] = useMediaDevices({
    requestPermissions: false,
    constraints: { audio: true, video: false },
  });

  const microphones = devices.filter((d) => d.kind === "audioinput");

  const {
    isSupported,
    isListening,
    isFinal,
    result,
    error,
    start,
    stop,
  } = useSpeechRecognition({
    lang: "zh-CN",
    interimResults: true,
    continuous: false,
  });

  const shiftDown = useKeyModifier("Shift");

  // 按住说话:按下 Shift 时开始,松开时停止
  useEffect(() => {
    if (!isSupported || micPermission !== "granted") return;
    if (shiftDown) {
      start();
    } else if (isListening) {
      stop();
    }
  }, [shiftDown, isSupported, micPermission, start, stop, isListening]);

  // 当识别最终化时,把结果提交到查询
  useEffect(() => {
    if (isFinal && result) {
      setQuery(result);
    }
  }, [isFinal, result]);

  const permissionColor =
    micPermission === "granted"
      ? "#10b981"
      : micPermission === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <div
      style={{
        maxWidth: 640,
        padding: 24,
        background: "#ffffff",
        borderRadius: 16,
        boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 16,
        }}
      >
        <h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
        <span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
          ● 麦克风:{micPermission || "未知"}
        </span>
      </header>

      {!isSupported && (
        <p style={{ color: "#64748b" }}>
          当前浏览器不支持语音识别。请试试 Chrome。
        </p>
      )}

      {isSupported && micPermission !== "granted" && (
        <button
          onClick={requestDevices}
          style={{
            width: "100%",
            padding: 12,
            background: "#3b82f6",
            color: "white",
            border: "none",
            borderRadius: 8,
            cursor: "pointer",
          }}
        >
          授权麦克风访问
        </button>
      )}

      {isSupported && micPermission === "granted" && (
        <>
          <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
            <select
              value={selectedMic}
              onChange={(e) => setSelectedMic(e.target.value)}
              style={{
                flex: 1,
                padding: 8,
                borderRadius: 6,
                border: "1px solid #cbd5e1",
              }}
            >
              <option value="">默认麦克风</option>
              {microphones.map((mic) => (
                <option key={mic.deviceId} value={mic.deviceId}>
                  {mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
                </option>
              ))}
            </select>
          </div>

          <div
            style={{
              padding: 16,
              background: shiftDown ? "#dcfce7" : "#f8fafc",
              borderRadius: 8,
              border: shiftDown
                ? "2px solid #10b981"
                : "2px dashed #cbd5e1",
              textAlign: "center",
              transition: "all 120ms ease",
            }}
          >
            <p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
              {shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
            </p>
            {result && (
              <p
                style={{
                  margin: "8px 0 0",
                  fontStyle: isFinal ? "normal" : "italic",
                  color: isFinal ? "#0f172a" : "#64748b",
                }}
              >
                {result}
              </p>
            )}
          </div>

          {error && (
            <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
              识别错误:{error.error}
            </p>
          )}

          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="搜索查询..."
            style={{
              width: "100%",
              marginTop: 12,
              padding: 10,
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              fontSize: 16,
            }}
          />
        </>
      )}
    </div>
  );
}

四个 Hook,四个相互正交的关注点:

  • usePermission 驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。
  • useMediaDevices 填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。
  • useSpeechRecognition 完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。
  • useKeyModifier 把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。

整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。

关于测试的一点说明

语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。

安装

npm i @reactuses/core

相关 Hook

  • useSpeechRecognition —— 实时语音转文字,跟踪中间和最终结果
  • useMediaDevices —— 枚举摄像头和麦克风,处理权限
  • usePermission —— 响应式地查询任意权限的 Permissions API
  • useKeyModifier —— 跟踪 OS 级别的修饰键状态(Shift、Control 等)
  • useSupported —— 响应式地检查浏览器 API 是否可用
  • useEventListener —— 声明式地附加事件监听器,可用于自定义语音流程
  • useObjectUrl —— 为录制的音频 blob 创建临时 URL 以预览

ReactUse 提供了 100+ 个 React Hook。全部探索 →

❌