陌上人如玉
公子世无双

5.2 React.memo 与浅比较

5.2 React.memo 与浅比较

一句话:React.memo 是一个「高阶组件」,它对 props 做浅比较(Object.is 逐项),相等就跳过该组件的重渲染——但它只在所有 props 引用都稳定时才生效,而且拦不住 Context 更新。

一、作用与原理

// 基本用法
const Child = React.memo(function Child({ value }: { value: number }) {
  console.log('Child 渲染');
  return <div>{value}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <Child value={1} />            {/* ✅ props 没变 → 跳过渲染 */}
    </>
  );
}
原理(简化):

const MemoComponent = memo(Component);

function MemoComponent(props) {
  const prevProps = /* 上一次渲染的 props */;

  if (prevProps !== null && shallowEqual(prevProps, props)) {
    // ⭐ 浅比较相等 → 复用上一次的元素与子树,跳过渲染
    return prevPropsOrMemoizedElement;
  }

  return <Component {...props} />;
}

// 浅比较(shallowEqual)
function shallowEqual(a, b) {
  if (Object.is(a, b)) return true;
  const keysA = Object.keys(a);
  if (keysA.length !== Object.keys(b).length) return false;

  for (const key of keysA) {
    if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
    if (!Object.is(a[key], b[key])) return false;      // ⭐ 逐项用 Object.is
  }
  return true;
}
⚠️ 关键点:memo 只比较【当前组件的 props】
   · 它不会阻止「组件自己 setState 导致的渲染」
   · 它不会阻止「订阅的 Context 变化导致的渲染」
   · 它的比较是【浅】的(不递归比较对象内部)

二、生效条件

两条缺一不可:
① 组件被 React.memo 包裹
② 本次渲染的所有 props 与上次「浅比较相等」
// ✅ 生效:原始值 props
const A = React.memo(function A({ value }: { value: number }) { ... });
<A value={1} />                                    // 1 === 1 → 跳过

// ✅ 生效:稳定的对象/函数引用
function Parent() {
  const options = useMemo(() => ({ page: 1 }), []);
  const handleClick = useCallback(() => { ... }, []);

  return <A options={options} onClick={handleClick} />;   // 引用稳定 → 跳过
}

// ❌ 失效:内联函数
const B = React.memo(function B({ onClick }: { onClick: () => void }) { ... });
<B onClick={() => {}} />                           // 每次都是新函数 → 不跳过

// ❌ 失效:对象/数组字面量
<B options={{ page: 1 }} />                        // 每次都是新对象 → 不跳过
<B items={[1, 2, 3]} />                            // 每次都是新数组 → 不跳过
props 类型 浅比较结果 memo 是否生效
1 / 'a' / true / null 相等(Object.is) ✅
同一个对象引用 相等 ✅
内容相同的新对象 不等 ❌
同一个函数引用 相等 ✅
内联箭头函数 不等 ❌
每次 filter/map 产生的新数组 不等 ❌
外部创建、父组件未重渲染的 children 相等 ✅

三、失效场景清单

// ① 内联函数
<MemoChild onSelect={() => handle(id)} />          // ❌

// ② 对象/数组字面量
<MemoChild style={{ color: 'red' }} />             // ❌
<MemoChild items={list.filter((x) => x.active)} /> // ❌

// ③ 上游传来的不稳定引用
function Middle({ onChange }: { onChange: () => void }) {
  return <MemoChild onChange={onChange} />;         // ⚠️ onChange 若上游不稳定,这里也没用
}

// ④ 上游自己没 memo(父组件渲染 → 它渲染 → 它的子组件也渲染)
function Parent() {
  return <Middle />;                                // ⚠️ Parent 重渲染时 Middle 重渲染
}
// → 只给最底层的 Child 加 memo 是无效的,需要在「链路上」逐层阻断

// ⑤ 每次渲染重新创建的「派生数据」
const sorted = [...items].sort();                   // ⚠️ 每次都是新数组
<MemoChild items={sorted} />                        // ❌
// ✅ const sorted = useMemo(() => [...items].sort(), [items]);

// ⑥ Context 更新(memo 拦不住)
const Consumer = React.memo(function Consumer() {
  const ctx = useContext(Ctx);                      // ⚠️ Ctx 变化 → 仍会渲染
  return <div>{ctx.a}</div>;
});
// ④ 的完整示例:链路上的「层层阻断」
function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <Middle />
    </>
  );
}

function Middle() {                        // ❌ 没 memo → 每次 App 渲染都会执行
  return <Leaf />;
}

const Leaf = React.memo(function Leaf() {
  console.log('Leaf 渲染');                 // ⚠️ 依然会渲染(因为 Middle 重新创建了 <Leaf />)
  return <div>叶子</div>;
});
// ✅ 修复:给 Middle 也加 memo,或用 children 透传
const Middle2 = React.memo(function Middle2() {
  return <Leaf />;
});

四、自定义比较函数

// memo 的第二个参数:(prevProps, nextProps) => boolean
// ⭐ 返回 true 表示「相等 → 跳过渲染」
const Child = React.memo(
  function Child({ user, onSave }: ChildProps) {
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    // 只比较我们关心的字段
    return prevProps.user.id === nextProps.user.id;
    // ⚠️ 注意:如果 user.name 变了但 id 没变 → 不会重渲染(可能不符合预期)
  }
);
// 更安全的写法:显式列出「影响渲染的字段」
const Child2 = React.memo(
  function Child2({ user, theme }: ChildProps) {
    return <div>{user.name}</div>;
  },
  (prev, next) => {
    return (
      prev.user.id === next.user.id &&
      prev.user.name === next.user.name &&           // ✅ 显式列出
      prev.theme === next.theme
    );
  }
);
比较函数 风险
只比 id ⚠️ 内容变化不会重渲染(bug 隐患)
显式列出全部影响渲染的字段 ✅ 安全(但要记得随需求更新)
深比较(如 JSON.stringify) ⚠️ 成本可能高于重渲染本身
直接用默认(浅比较) ✅ 大多数情况够用
⚠️ 自定义比较函数的三个注意事项:
① 它是一个「性能优化点」,本身也要花时间(每次渲染都执行)
② 返回 true(相等)会跳过渲染 → 必须确保「真的不影响输出」
③ 深比较通常得不偿失(大对象比较的成本可能超过重新渲染)

五、与 PureComponent 的关系

// 类组件的等价物
class Child extends React.PureComponent {
  render() {
    return <div>{this.props.value}</div>;
  }
}
// PureComponent = 在 shouldComponentUpdate 里做「浅比较 props 与 state」

// ⚠️ 两者有两个差异:
// ① memo 只能比较 props;PureComponent 还比较 state(但 state 变化时本来就该渲染)
// ② memo 支持自定义比较函数;PureComponent 需要自己写 shouldComponentUpdate
维度 React.memo PureComponent
适用 函数组件 类组件
比较对象 props(浅比较) props + state(浅比较)
自定义比较 ✅ 第二个参数 需要手写 shouldComponentUpdate
现状 推荐 类组件遗留

六、什么时候不该用 memo

❌ 不该用的四种情况:

① 组件「本来就每次都要更新」(props 每次都不同)
   → memo 只是白白增加了比较开销

② 组件渲染成本极低(就几个 div)
   → 比较成本 ≈ 渲染成本,没有收益

③ 父组件只渲染一次(如只挂载一次的路由页面)
   → memo 永远不会生效(props 没变过,也没重渲染过)

④ props 里有「无法稳定的东西」(如第三方库给的回调)
   → 除非配自定义比较函数,否则一定失效
// ① 的反例
const Row = React.memo(function Row({ item, index }: RowProps) {
  return <li>{index}. {item.name}</li>;
});
// ⚠️ 如果 index 每次都变(如每次重排),memo 永远失效

// ② 的反例(渲染成本极低)
const Label = React.memo(function Label({ text }: { text: string }) {
  return <span>{text}</span>;                 // 成本 ≈ 比较成本
});

七、memo + 稳定引用的完整实践

// ✅ 一个完整正确的用法(三层配合)
interface RowProps {
  item: Item;
  onSelect: (id: string) => void;
}

const Row = React.memo(function Row({ item, onSelect }: RowProps) {
  console.log('Row 渲染', item.id);
  return (
    <li onClick={() => onSelect(item.id)}>
      {item.name}
    </li>
  );
});

function List({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
  // ① 稳定回调引用
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);

  // ② 稳定 items 引用(若 items 来自 props,由上游负责稳定)
  const memoItems = useMemo(() => items, [items]);      // 这里其实不需要(items 已稳定)

  return (
    <ul>
      {memoItems.map((item) => (
        <Row key={item.id} item={item} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

// ⭐ 优化后的效果:
// 父组件因「其它状态」重渲染时,Row 不会重渲染
//(前提:items 里的元素引用不变、onSelect 稳定)
// 更彻底的做法:用「data-* + 事件委托」让处理函数彻底稳定
function List2({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
  // ⭐ 这个函数引用永久稳定(依赖为空)
  const handleClick = useCallback((e: React.MouseEvent<HTMLUListElement>) => {
    const id = (e.target as HTMLElement).closest('[data-id]')?.getAttribute('data-id');
    if (id) onSelectRef.current(id);
  }, []);

  const onSelectRef = useRef(onSelect);
  useEffect(() => { onSelectRef.current = onSelect; });

  return (
    <ul onClick={handleClick}>
      {items.map((item) => (
        <RowWithoutCallback key={item.id} item={item} />
      ))}
    </ul>
  );
}
// → RowWithoutCallback 的 props 只有 item(稳定)→ memo 永久生效

八、常见坑

// ① 加了 memo 但 props 里有内联函数/对象
<MemoChild onClick={() => {}} options={{ a: 1 }} />       // ⚠️ 失效

// ② 只给「最底层」加 memo,但链路上层没加
<Parent><Middle><MemoLeaf /></Middle></Parent>            // ⚠️ Middle 没 memo → 依然渲染

// ③ 自定义比较函数写错(该更新时返回了 true)
(prev, next) => prev.user.id === next.user.id             // ⚠️ name 变了也不更新

// ④ 认为 memo 能阻止 Context 引起的渲染
const Consumer = React.memo(function Consumer() {
  const ctx = useContext(Ctx);                            // ⚠️ 无效
});

// ⑤ 认为 memo 能阻止「自己 setState 引起的渲染」
const Comp = React.memo(function Comp() {
  const [n, setN] = useState(0);                          // ✅ setN 仍会渲染
  return <button onClick={() => setN(n + 1)}>{n}</button>;
});

// ⑥ 用 memo 包裹「渲染成本极低」的组件(得不偿失)
// ⑦ 用深比较(JSON.stringify)作为比较函数(成本可能更高)
// ⑧ 依赖 memo 而忽略了「父组件传播」这个根源(更好的解法是 children 透传/状态下移)
// ⑨ 给「每次 props 都变」的组件加 memo(永远不会跳过)
// ⑩ 忘记 memo 比较的是「浅层」——对象内部的字段变化不会触发(引用没变时)
const [user, setUser] = useState({ name: 'a' });
user.name = 'b';                                          // ❌ 引用没变 → memo 认为「没变」
setUser({ ...user, name: 'b' });                          // ✅ 新引用 → 正确更新

// ⑪ memo + forwardRef 的顺序写错(应该 memo(forwardRef(...)))
// ⑫ 在 memo 组件里用「默认参数创建的对象」作为默认值(每次都是新对象)
function Comp({ options = {} }: Props) { ... }            // ⚠️ options 每次都是新对象
// ⑪ 的正确顺序
const Comp = React.memo(
  React.forwardRef<HTMLDivElement, Props>(function Comp(props, ref) {
    return <div ref={ref} {...props} />;
  })
);

// ⑫ 的修复
const EMPTY = {};
function Comp({ options = EMPTY }: Props) { ... }         // ✅ 引用稳定

九、面试延伸

  1. 「React.memo 是什么?它怎么工作?」
    React.memo 是一个高阶组件,它对组件的 props 做浅比较(对每个字段用 Object.is);如果与上一次渲染的所有 props 都相等,就跳过这个组件的重新渲染(复用上一次的元素)。注意它只比较当前组件的 props:不会阻止「组件自己的 setState」引起的渲染,也拦不住订阅的 Context 更新。

  2. 「React.memo 什么时候会失效?」
    只要任何一个 prop 的引用发生变化就失效:① 内联箭头函数(onClick={() => {}});② 对象/数组字面量(options={{ a: 1 }});③ 每次渲染新创建的派生数据(items={list.filter(...)});④ 上游传递的不稳定引用(父组件没用 useCallback/useMemo)。此外,如果链路上层组件没有 memo,即使最底层有 memo 也没用(上层重渲染会重新创建子元素 → props 变化)。

  3. 「memo 和 useMemo/useCallback 是什么关系?」
    三者是配套使用的:React.memo 决定「要不要跳过渲染」,它的判断依据是「props 是否浅相等」;而 useMemo/useCallback 是「稳定 props 引用」的手段。所以典型组合是「子组件 memo + 父组件用 useCallback/useMemo 稳定传给它的 props」——缺一不可。只加 memo 不稳定引用 → 失效;只稳定引用不加 memo → 没有意义。

  4. 「memo 能阻止 Context 变化引起的重渲染吗?」
    不能。React.memo 只比较 props,而 Context 的更新是「直接通知消费者组件」的(走的是 Context 传播路径,不经过 props 比较),因此 memo 无法拦截。要优化 Context 的重渲染只能靠「拆分 Context」「值与操作分离」「children 透传」或改用「带选择器订阅的状态库」。

  5. 「自定义比较函数要注意什么?」
    三个注意点:① 语义——函数返回 true 表示「相等、跳过渲染」(容易记反);② 完整性——只比较部分字段会导致「该更新时不更新」的 bug(如只比 user.id,那么 user.name 变化就不会渲染);③ 成本——比较函数本身每次渲染都会执行,深比较(如 JSON.stringify)的成本可能超过重新渲染。所以推荐「显式列出所有影响输出的字段」,并且只在确实必要时才用自定义比较。

  6. 「什么时候不该用 memo?」
    四种情况:① 组件本来就每次都要更新(props 每次都不同,如 index 变化)→ memo 只会增加比较开销;② 组件渲染成本极低(几个 div)→ 比较成本 ≈ 渲染成本;③ 父组件只渲染一次 → memo 永不生效;④ props 里有无法稳定的东西(第三方库的回调、Context 值)。判断标准是「Profiler 显示这个组件确实因为父组件重渲染而反复渲染,且渲染耗时不可忽略」。

  7. 「为什么『只给最底层组件加 memo』往往无效?」
    因为 React 的更新是自顶向下传播的:父组件重渲染 → 它会重新创建子元素(<Middle />)→ Middle 没有 memo 所以会重新执行 → Middle 又创建 <Leaf /> → 即使 Leaf 有 memo,它收到的 props(如果有)也是新创建的。所以阻断必须在「链路上」——从「状态变化的组件」到「目标组件」之间的每一层都要能跳过,或者用 children 透传 / 状态下移从结构上解决。

  8. 「memo 的代价是什么?」
    两个:① 比较开销——每次渲染都要逐项浅比较 props(字段极多时不可忽略);② 维护成本——为了让 memo 生效,你必须在父组件里维护「稳定的引用」(useCallback/useMemo),这增加了代码复杂度与依赖数组的维护负担(且写错会失效)。所以「无脑加 memo」经常是负优化——应该先用 Profiler 确认这个组件是热点。

一句话速记

React.memo 对 props 做浅比较(Object.is 逐项),相等就跳过渲染——但只在所有 props 引用稳定时生效(内联函数、对象字面量、filter 出的新数组都会让它失效),且拦不住 Context 更新与自己的 setState;只给最底层加没用(上层要能一起跳过,或用 children/状态下移);自定义比较函数返回 true 表示跳过(写错会导致「该更新不更新」,深比较通常得不偿失);与 PureComponent 等价(memo 只比 props);判断标准是 Profiler 的耗时数据,而不是「感觉该加」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.2 React.memo 与浅比较

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫