React 性能优化:memo、useMemo、useCallback 到底什么时候用
React 性能优化:memo、useMemo、useCallback 到底什么时候用写 React 的人几乎都干过这件事:页面一卡,就把useMemo、useCallback到处乱撒,memo包一层,以为这样就优化了。结果呢?代码里全是括号套括号,性能没见得好多少,反而多了一堆维护负担。这三个 API 不是加了就快的魔法。它们的本质是用内存和一点点比较开销,换取跳过重复计算或重复渲染。用对了是净收益,用错了是纯负担。这篇我们把三者的边界讲清楚,再给出一套能落地的判断标准。先搞懂:重渲染到底贵在哪React 组件重渲染分两步:先跑一遍组件函数(执行 JSX、算 props),再把结果和上一次的虚拟 DOM diff,只有变化的部分才动真实 DOM。真正贵的往往不是 diff,而是函数体里的计算和子组件被连带重渲染。先看一个典型的连带渲染问题:function Parent() { const [count, setCount] useState(0); const [text, setText] useState(); return ( div input value{text} onChange{e setText(e.target.value)} / button onClick{() setCount(count 1)}count: {count}/button {/* 每次 Parent 渲染,Child 都跟着重渲染 */} Child data{[1, 2, 3]} / /div ); } function Child({ data }) { console.log(Child 渲染了); // 敲一下键盘就打印一次 return ul{data.map(n li key{n}{n}/li)}/ul; }你在 input 里打字,text变了,Parent重渲染,Child明明和text无关,却每次都跟着渲染。这就是可以被优化掉的浪费。React.memo:跳过 props 没变的子组件React.memo给组件套一层浅比较:父组件重渲染时,如果传给这个子组件的 props 浅比较相等,就跳过它的重渲染。import { memo } from react; const Child memo(function Child({ data }) { console.log(Child 渲染了); return ul{data.map(n li key{n}{n}/li)}/ul; });但上面的例子光加memo还不够,因为data{[1, 2, 3]}每次渲染都创建一个新数组,浅比较[1,2,3] ! [1,2,3](引用不同),memo直接失效。这就引出了useMemo。useMemo:缓存值,稳定引用 省重复计算useMemo(fn, deps)缓存fn的返回值,只有deps变化时才重新计算。它有两个用途:用途一:稳定引用,让 memo 生效。function Parent() { const [count, setCount] useState(0); const [text, setText] useState(); // deps 为空,data 引用永远稳定,Child 打字时不再重渲染 const data useMemo(() [1, 2, 3], []); return ( input value{text} onChange{e setText(e.target.value)} / button onClick{() setCount(count 1)}count: {count}/button Child data{data} / / ); }用途二:缓存昂贵计算。这才是useMemo更该被用的地方:function List({ items, keyword }) { // items 有几万条时,每次渲染都 filter 会明显卡顿 const filtered useMemo( () items.filter(it it.name.includes(keyword)), [items, keyword] // 只有这两者变了才重新过滤 ); return ul{filtered.map(it li key{it.id}{it.name}/li)}/ul; }如果filtered不做缓存,那么任何触发List重渲染的因素(哪怕和过滤无关)都会重跑一遍几万条的 filter。useCallback:专门缓存函数useCallback(fn, deps)是useMemo(() fn, deps)的语法糖,专门缓存函数引用。它存在的核心理由,同样是别让子组件的 memo 失效:const Child memo(function Child({ onClick }) { console.log(Child 渲染了); return button onClick{onClick}click/button; }); function Parent() { const [count, setCount] useState(0); // 不用 useCallback,每次渲染 handleClick 都是新函数,Child 的 memo 失效 const handleClick useCallback(() { console.log(clicked); }, []); // deps 为空:函数引用一直稳定 return ( button onClick{() setCount(count 1)}count: {count}/button Child onClick{handleClick} / / ); }注意一个常见误区:useCallback本身不会让组件变快。它只有在这个函数被当作 props 传给了 memo 化的子组件或被当作某个 useEffect 的依赖时才有意义。如果函数只是在本组件内部用(比如直接写在 onClick 里的内联函数),包useCallback纯属浪费。依赖数组:最容易翻车的地方三者的deps遵循同一套规则:函数体里用到的、会变的外部变量,都得列进去。漏了会拿到过期的旧值(闭包陷阱),多了会导致缓存频繁失效。function Search({ userId }) { const [keyword, setKeyword] useState(); const fetchData useCallback(() { // 用到了 userId 和 keyword,两个都得进 deps return fetch(/api/search?uid${userId}q${keyword}); }, [userId, keyword]); // 漏掉任何一个都会请求到旧参数 // ... }不确定 deps 填得对不对?装eslint-plugin-react-hooks,开exhaustive-deps规则,它会自动帮你揪出遗漏的依赖。这条规则的价值远大于你手动优化几个 memo。什么时候该用,什么时候别用给一套务实的判断标准:默认先别加。React 本身的渲染在多数场景足够快,过早优化只会增加复杂度。等到出现可感知的卡顿再动手。用 Profiler 先定位。打开 React DevTools 的 Profiler,录一段操作,看哪个组件渲染耗时长、渲染次数多,针对它优化,而不是凭感觉。useMemo缓存昂贵计算:大数组的 filter/sort/reduce、复杂对象构造。轻量计算(比如a b)缓存的收益还不够比较 deps 的开销。memouseCallback/useMemo成套用:给一个渲染开销大的子组件加memo,同时把传给它的函数和对象 props 用useCallback/useMemo稳定引用。三者是配合关系,单独用memo常常因为 props 引用变化而失效。列表项优先:长列表的每一项包memo,收益通常最明显——父组件状态一变,能省下成百上千个列表项的重渲染。小结memo跳过props 没变的子组件渲染;useMemo缓存值(稳定引用 省计算);useCallback缓存函数引用。三者本质都是花比较开销换跳过开销,不是免费加速,没有实测卡顿就别乱加。memo单独用常失效,要和useMemo/useCallback配合稳定住 props 引用才有效。依赖数组是重灾区,交给eslint-plugin-react-hooks的exhaustive-deps把关。一句话记忆:先 Profiler 定位,再对准热点成套优化;缓存是手段,不是信仰。