React 的默认行为是「状态变化 → 组件及其子树重渲染」。重渲染本身很便宜,贵的是渲染过程中做的计算与 DOM 操作。所以优化的第一步不是加 memo,而是找出真正贵的地方。

React 重渲染优化:memo、useMemo 与「状态下放」的优先级 示意图

先定位,再优化

  • 打开 React DevTools 的 Profiler,录制一次交互;
  • 在 flamegraph 里找耗时最高的组件,看「为什么渲染」一栏的触发源;
  • 只有确认某个组件被无关状态反复带动,才值得做隔离。

没有测量就加 memo,最常见的结局是依赖数组写了一堆,界面依旧卡。

收益最高:把状态放到真正需要它的地方

function Page() {
    const [keyword, setKeyword] = useState('');

    return (
        <>
            <SearchInput value={keyword} onChange={setKeyword} />
            <ExpensiveTable />
        </>
    );
}

输入框的每次按键都会让 Page 重渲染,连带整张表格。把 keyword 移进 SearchInput 内部,只在提交时回调出去,父组件状态不变,表格就不会被带动。这类「状态下放」几乎不需要额外 API,收益却最大。

次高:把子树作为 children 传入

function Layout({ children }) {
    const [collapsed, setCollapsed] = useState(false);

    return (
        <div className={collapsed ? 'w-16' : 'w-64'}>
            <Sidebar onToggle={() => setCollapsed(c => !c)} />
            {children}
        </div>
    );
}

children 是父组件渲染时创建好的元素对象,Layout 自身状态变化不会重新创建它,React 会直接复用。侧边栏这类「只切换自己 UI」的容器组件特别适合这种写法。

再考虑 memo

const Row = memo(function Row({ item, onSelect }) {
    return <tr onClick={() => onSelect(item.id)}>{item.name}</tr>;
});

memo 做的是浅比较,两个前提必须满足:props 真的没变,且比较成本低于渲染成本。最常见的失效原因是父组件每次都传新对象或新函数,此时要么用 useCallback / useMemo 固定引用,要么把回调改到子组件内部处理。

const handleSelect = useCallback((id) => setSelected(id), []);
const options = useMemo(() => buildOptions(raw), [raw]);

useMemo 不是默认选项

它自身要保存依赖、做比较,对简单计算反而是负担。判断标准:计算量明显(大数组排序、复杂格式化、递归)或结果作为依赖被别的 Hook 使用,才值得包。useMemo 也不保证一定缓存,只能当性能提示,不要用它承担正确性。

列表:key 与组件粒度

用稳定 id 作 key;列表项组件尽量小,把「只有某一项会变」的状态留在项内部。整表重排时 key 变化会让 React 重建 DOM,代价远高于一次重渲染。

小结

优化顺序:状态下放 → children 组合 → memo 隔离 → useMemo 缓存计算。反过来做,往往得到一堆依赖数组却看不到提升。Hooks 依赖本身的问题见依赖数组与闭包陷阱。