《memo 明明加了,为什么子组件还是渲染了?》

React 性能优化的 1 个坑:我以为 memo 够了,直到我传了一个函数

开篇

先看一段代码。你能说出它想优化什么吗?

javascript 复制代码
function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('少林队');

    return (
        <>
            <button onClick={() => setCount(count + 1)}>
                点击计数 {count}
            </button>
            <button onClick={() => setName('wxh')}>
                改变名字
            </button>
            <RegularChild name={name} />
            <MemoChild name={name} />
        </>
    );
}

控制台输出: 每次点「点击计数」:

markdown 复制代码
App render
RegularChild render
           ← MemoChild 没渲染!memo 生效了

看起来 memo 很完美。 MemoChildmemo 包裹,count 变了 name 没变,所以跳过了渲染。

但等等------如果我加一行代码,给 MemoChild 传一个回调函数呢?

javascript 复制代码
<MemoChild name={name} onDone={() => console.log('done')} />

你再猜一次。点「点击计数」,控制台会输出什么?

复制代码
App render
RegularChild render
MemoChild render    ← 说好的跳过呢???

memo 失效了。 就因为多传了一个箭头函数------一个你写了几百遍的东西。你的 memo、你的优化、你的性能优化文章------全部白费。

这篇文章就是解决这个问题的。


先搞清楚 memo 的判断逻辑

React.memo 的本质是浅比较 。每次父组件渲染,React 把新的 props 和旧的 props 做一个 === 对比。只有 props 变了,子组件才渲染。

name="少林队" 这种原始值,"少林队" === "少林队"true,所以跳过渲染。一切顺利。

但问题是------不是所有值都能 ===

graph TD A[&#34;父组件渲染&#34;] --> B[&#34;生成新的 props 对象&#34;] B --> C{&#34;memo 浅比较&#34;} C -->|&#34;所有 prop === 旧值&#34;| D[&#34;跳过子组件渲染&#34;] C -->|&#34;任一 prop !== 旧值&#34;| E[&#34;子组件重新渲染&#34;] F[&#34;原始值: '少林队' === '少林队'&#34;] --> C G[&#34;对象/函数: {} !== {}&#34;] --> C H[&#34;箭头函数: () => ... !== 上一次的 () => ...&#34;] --> C

函数是对象,对象是引用。 每次渲染时 () => console.log('done') 都是一个全新的函数引用------即使它做的事完全一样。浅比较看到的就是「不一样」,然后放行渲染。

memo 检查的是引用是否相同 ,不是内容是否相同


亲手造一个「memo 失效」现场

在原代码基础上,加一个场景:传回调。

javascript 复制代码
import { useState, memo } from 'react';

const MemoChild = memo(({ name, onDone }) => {
    console.log('MemoChild render');
    return (
        <div>
            Hello {name}
            <button onClick={onDone}>完成</button>
        </div>
    );
});

function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('少林队');

    // ⚠️ 每次 App 渲染,这里都生成一个全新的函数
    const handleDone = () => {
        console.log('done:', name);
    };

    return (
        <>
            <button onClick={() => setCount(count + 1)}>
                点击计数 {count}
            </button>
            <MemoChild name={name} onDone={handleDone} />
        </>
    );
}

运行结果:

复制代码
点击「点击计数」:
App render
MemoChild render    ← 又渲染了!count 跟 MemoChild 毫无关系

name 没变。但 onDone 变了。 不是内容变了------是引用变了。每次 App 渲染,JavaScript 重新执行 const handleDone = () => {...},生成一个内存里全新的对象。对 memo 来说这就是「新 props」,于是放行渲染。


解法:让引用稳定下来

useCallback 做的事很简单:除非依赖变了,否则永远返回同一个函数引用。

javascript 复制代码
import { useState, memo, useCallback } from 'react';

function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('少林队');

    // 🔑 关键:name 不变,handleDone 的引用就不变
    const handleDone = useCallback(() => {
        console.log('done:', name);
    }, [name]); // ← name 不变,handleDone 永远是同一个函数对象

    return (
        <>
            <button onClick={() => setCount(count + 1)}>
                点击计数 {count}
            </button>
            <MemoChild name={name} onDone={handleDone} />
        </>
    );
}

现在再点「点击计数」:

markdown 复制代码
App render
                        ← MemoChild 安静了!

因为 name 没变 → handleDone 引用没变 → memo 浅比较通过 → 跳过渲染。

useCallback 不是「让函数变快」,是「让同一个函数一直用同一个引用」。


对照实验:可视化 memo 的失效与修复

让我们用一个实验把两边的行为放在一起对比:

javascript 复制代码
// 实验:对比两种写法对 MemoChild 渲染次数的影响
import { useState, memo, useCallback } from 'react';

let renderCountGood = 0;
let renderCountBad = 0;

const GoodChild = memo(({ onClick }) => {
    renderCountGood++;
    return <button onClick={onClick}>Good</button>;
});

const BadChild = memo(({ onClick }) => {
    renderCountBad++;
    return <button onClick={onClick}>Bad</button>;
});

export default function Experiment() {
    const [count, setCount] = useState(0);

    // ✅ 引用稳定
    const stableFn = useCallback(() => {}, []);

    // ❌ 每轮渲染都是新引用
    const unstableFn = () => {};

    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>
                Count: {count}
            </button>
            <GoodChild onClick={stableFn} />
            <BadChild onClick={unstableFn} />
            <p>GoodChild 渲染: {renderCountGood}</p>
            <p>BadChild 渲染: {renderCountBad}</p>
        </div>
    );
}

点击 5 次 Count 按钮后的真实输出:

复制代码
GoodChild 渲染: 1       ← 只有初始渲染
BadChild 渲染: 6        ← 初始 + 5 次跟着父组件重渲染

同一个 memo,同一个结构,唯一的区别就是传入的函数是否用了 useCallback。 差了 6 倍。你的应用里几十个组件、几百次渲染------累积起来就是卡顿。


但是------不要每个函数都套 useCallback

你可能会想:「那我所有函数都用 useCallback 好了,防患于未然。」

别。 React 团队的设计哲学是------优化有成本,不要为不需要的东西买单。useCallback 的成本是什么?

  1. 内存成本:React 要额外存储依赖数组和缓存的函数引用
  2. 比对成本:每次渲染都要检查依赖数组的每一项是否变了
  3. 代码复杂度 :多一层 useCallback 包裹,多一层心智负担

如果子组件没被 memo 包裹 ,传 useCallback 进去没有任何收益------子组件反正每次都会渲染。React 的 diffing 算法本身就很快,不要在没有瓶颈的地方「优化」。

useCallback 的价值只存在于 memo + useCallback 这个 pair 中。单独使用任何一个都是在浪费代码行数。

graph TD A[&#34;子组件用了 memo&#34;] -->|是| B[&#34;prop 是函数&#34;] A -->|否| C[&#34;不需要 useCallback&#34;] B -->|是| D[&#34;函数内有依赖&#34;] B -->|否| C D -->|是| E[&#34;useCallback(fn, [deps])&#34;] D -->|否| F[&#34;useCallback(fn, [])&#34;]

回到设计哲学:React 为什么不做自动 memo?

现在你真正理解了 memo + useCallback 的组合------你不会再想:「有没有一个 babel 插件,自动给我所有的组件包 memo、所有函数包 useCallback?」

React 核心团队确实讨论过这个方案。React Forget(后来的 React Compiler)的早期方向就是这个思路。但为什么默认行为是不优化?

因为 JavaScript 的函数和对象天生就是「每次创建新的」。React 选择不做额外工作,除非你明确需要:

  1. React 不假设你的性能需求:大多数应用的渲染足够快,memo 的开销大于收益
  2. 浅比较本身也有成本 :每个 props 都要 === 一次,如果 props 很多,比重新渲染还慢的情况是存在的
  3. 正确性优先于性能:React 保证 UI 正确。优化是「正确之后的事」

React 核心成员 Dan Abramov 的原话精神是:「在你测量到性能问题之前,不要做优化。」 而 React DevTools Profiler 和 React Scan 这类工具,就是用来告诉你「哪里需要优化」的------不是凭空猜。


结尾

回到开篇那个场景------你加了 memo,以为优化好了,结果传了一个 arrow function 就全毁了。这不是你的错,是 JavaScript 引用语义和 React 渲染模型的「设计摩擦」。

记住一句话:memo 的优化要成立,传给它的每一个引用类型 props 都必须引用稳定。useCallback 是稳定函数引用,useMemo 是稳定对象引用。三者是一套,缺一个就漏。

memo 帮你关上了门,useCallback / useMemo 负责把门锁上。只关门不锁,风一吹就开了。

下次你写 memo 的时候,扫一眼传给它的 props------如果里面有函数或对象,就问自己:「这个引用,这一轮和上一轮是同一个吗?」

你的项目里有几个 memo 是真正的「关上门还锁了」的?去扫一眼,评论区告诉我你发现了多少。


相关推荐
lvv1 小时前
不会被渲染的组件,为什么让我的首屏白屏了?(一次前端问题总结)
前端·webpack·性能优化
码上成长1 小时前
微前端 Invalid hook call?先把「window 注入 + externals」整明白
前端·react.js·前端框架
人间凡尔赛2 小时前
2026 前端全栈新范式:Server Actions + Edge Runtime,告别传统 API 路由
前端·全栈·next.js
小月土星3 小时前
useCallback 深度解析:React 性能优化的核心利器
react.js·前端框架
GitLqr6 小时前
Flutter 居然能直接跑在 Apple Watch 上了?玩转 flutter-watchos
flutter·全栈·apple watch
不好听6139 小时前
React 的 useRef vs useState:响应式与非响应式的分界线
前端·react.js
想要成为糕糕手9 小时前
面试必问!React 受控与非受控组件,这篇彻底讲透了
react.js
喜欢睡觉9 小时前
React 受控组件与非受控组件
react.js
触底反弹9 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js