陌上人如玉
公子世无双

5.8 大对象与引用稳定性

5.8 大对象与引用稳定性

一句话:引用不稳定是 React 性能问题最常见的根源——「每次渲染都新建的对象/数组/函数」会让 memo 失效、让 useEffect 反复执行、让缓存失去意义;解法是「提到模块外 → useMemo/useCallback → 依赖原始值 → 用 ref → data-* + 事件委托」。

一、五种「不稳定引用」的来源

function Parent({ items, onSelect }: Props) {
  // ① 对象字面量(每次渲染都是新对象)
  const options = { sort: 'asc', page: 1 };

  // ② 数组字面量
  const columns = ['name', 'age', 'email'];

  // ③ 内联箭头函数
  const handleClick = () => onSelect(1);

  // ④ 渲染中计算出的新数组/新对象
  const visible = items.filter((it) => it.active);
  const stats = { total: items.length, active: visible.length };

  // ⑤ 用对象作为默认参数
  // function Child({ options = {} }) {}   ← 每次渲染都是新的 {}
}
为什么它们是「新引用」?
  因为「每次渲染都会重新执行组件函数」→
  函数体里的「对象/数组/函数字面量」每次都会重新创建。

⚠️ 这不是 bug,而是「JS 的语义」——React 的设计就是「渲染 = 重新执行函数」。
   所以「稳定引用」需要你显式处理。

二、三个后果

// ① memo 失效(子组件每次都重渲染)
const MemoChild = React.memo(Child);
<MemoChild options={{ a: 1 }} />                    // ⚠️ 每次都是新对象 → 一直渲染

// ② useEffect 反复执行(甚至无限循环)
useEffect(() => {
  fetchData(config);
}, [config]);                                        // ⚠️ config 每次都是新对象 → 每次执行

// 更严重的:effect 里又 setState → 重新渲染 → 新对象 → 再执行 → 死循环

// ③ useMemo/useCallback 完全失效
const value = useMemo(() => compute(a), [a, { extra: 1 }]);   // ⚠️ 依赖不稳定 → 每次重算
const fn = useCallback(() => f(a), [{ a }]);                   // ⚠️ 同理
后果 表现
memo 失效 子组件无谓重渲染(Profiler 里「Parent re-rendered」)
useEffect 反复执行 重复请求、订阅被反复重建、内存增长
无限循环 「渲染 → effect setState → 渲染 → …」
useMemo/useCallback 失效 「看起来在优化」但毫无作用
子组件 local state 重置 如果配合 key 变化或组件重建

三、解法一:提到模块作用域(最优)

// ✅ 纯常量 → 提到组件外(引用天然稳定、无需 memo)
const OPTIONS = { sort: 'asc', page: 1 };
const COLUMNS = ['name', 'age', 'email'];
const EMPTY_ARRAY: never[] = [];
const noop = () => {};

function Parent({ items }: Props) {
  return (
    <>
      <Child options={OPTIONS} columns={COLUMNS} />
      <List items={items} emptyFallback={EMPTY_ARRAY} onRetry={noop} />
    </>
  );
}
// ✅ 默认参数的坑:默认值也要提到外面
// ❌ 每次渲染都创建新的 {}
function Child({ options = {} }: { options?: Options }) {}

// ✅
const DEFAULT_OPTIONS: Options = {};
function Child({ options = DEFAULT_OPTIONS }: { options?: Options }) {}

// ✅ 数组同理
const EMPTY: Item[] = [];
function List({ items = EMPTY }: { items?: Item[] }) {}
判断标准:
  「这个值依赖任何 props/state 吗?」
  ├─ 不依赖 → 提到模块作用域(最优)
  └─ 依赖 → 用 useMemo/useCallback(或依赖原始值)

四、解法二:useMemo / useCallback

function Parent({ items, sortBy, onSelect }: Props) {
  // ✅ 依赖 props/state 时用 useMemo
  const options = useMemo(() => ({ sort: sortBy, page: 1 }), [sortBy]);

  const visible = useMemo(
    () => items.filter((it) => it.active).sort(byScore),
    [items]
  );

  // ✅ 函数用 useCallback
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);

  return <Child options={options} items={visible} onSelect={handleSelect} />;
}
⚠️ 两个常见的失效写法:
① 依赖里有新对象
   useMemo(() => f(a), [a, { extra: 1 }])              // ❌ 依赖不稳定 → 每次都算
② 依赖里调用函数
   useMemo(() => f(a), [getKey()])                       // ❌ 每次渲染都调用 getKey()

五、解法三:依赖「原始值」而不是对象

// ❌ 依赖整个对象(且对象引用不稳定)
function UserCard({ user }: { user: User }) {
  useEffect(() => {
    loadProfile(user);
  }, [user]);                     // ⚠️ user 每次都是新对象(如果上游没稳定它)
}

// ✅ 依赖用到的原始值
function UserCard2({ user }: { user: User }) {
  useEffect(() => {
    loadProfile(user.id);          // ⭐ 只依赖 id
  }, [user.id]);
}
// ✅ 列表依赖的常见写法
// ❌ 依赖 items(如果 items 每次都是新数组 → 每次重算)
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);

// ✅ 依赖「能代表内容的原始值」(在确有必要时)
const ids = useMemo(() => items.map((i) => i.id).join(','), [items]);
const total2 = useMemo(() => computeTotal(ids), [ids]);   // 只在 id 集合变化时重算
// ⚠️ 这是「权衡」:牺牲了「内容变化时重算」的精确性,换取了稳定性
//    更推荐的做法是「让上游把 items 稳定下来」

六、解法四:用 ref 保存最新值

// 场景:Effect 需要「读最新值」但「不想因它重新执行」
function Component({ onSave }: { onSave: () => void }) {
  const onSaveRef = useRef(onSave);
  useEffect(() => {
    onSaveRef.current = onSave;              // ⭐ 每次渲染同步(不触发执行)
  });

  useEffect(() => {
    const id = setInterval(() => onSaveRef.current(), 5000);
    return () => clearInterval(id);
  }, []);                                    // ⭐ 依赖为空 → 不会因 onSave 变化重建
}
// 同理:把「不稳定的回调」通过 ref 稳定下来
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
  const ref = useRef(fn);
  useEffect(() => { ref.current = fn; });

  return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T;
}

// 使用
function Parent({ onSelect }: { onSelect: (id: string) => void }) {
  const stableSelect = useStableCallback((id: string) => onSelect(id));
  // ⭐ stableSelect 引用永久稳定,但调用的是最新的 onSelect

  return <MemoChild onSelect={stableSelect} />;
}

七、解法五:data-* + 事件委托(列表场景最优)

// ❌ 每行一个内联函数 → memo 失效
{items.map((it) => (
  <MemoRow key={it.id} item={it} onClick={() => handleSelect(it.id)} />
))}

// ✅ 整列一个监听器 + 永久稳定的处理函数
const MemoRow = React.memo(function MemoRow({ item }: { item: Item }) {
  return <li data-id={item.id}>{item.name}</li>;      {/* ⭐ 用 data-* 携带身份 */}
});

function List({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
  const onSelectRef = useRef(onSelect);
  useEffect(() => { onSelectRef.current = onSelect; });

  const handleClick = useCallback((e: React.MouseEvent<HTMLUListElement>) => {
    const el = (e.target as HTMLElement).closest('[data-id]');
    const id = el?.getAttribute('data-id');
    if (id) onSelectRef.current(id);
  }, []);

  return (
    <ul onClick={handleClick}>
      {items.map((it) => (
        <MemoRow key={it.id} item={it} />
      ))}
    </ul>
  );
}

八、大对象的额外问题

// ① 用「大对象」作为依赖 → 浅比较本身也有成本
// ② 用「深比较」作为 memo 的比较函数 → 成本可能超过渲染
const Row = React.memo(RowComponent, (a, b) => {
  return JSON.stringify(a.item) === JSON.stringify(b.item);   // ❌ 大对象时非常慢
});
// ✅ 只比较「影响渲染的关键字段」
const Row2 = React.memo(RowComponent, (a, b) => {
  return a.item.id === b.item.id && a.item.name === b.item.name;
});

// ③ 大对象传 props 时的「序列化/深拷贝」
const cloned = structuredClone(hugeObject);      // ⚠️ 很贵,尽量别在渲染里做
// ✅ 用不可变更新(只复制路径上的对象)

// ④ Context value 含大对象 → 变化时所有消费者重渲染
const value = { user, permissions, menu, config };   // ⚠️ 任何一项变化都全量重渲染
// ✅ 拆分成多个 Context(见 5.9)
// ⑤ 一个容易忽略的:把「整个对象」放进 useEffect 依赖
useEffect(() => {
  doSomething(user);
}, [user]);        // ⚠️ user 可能频繁变化(即使只是引用变化)

// ✅ 依赖「用到的字段」
useEffect(() => {
  doSomething(user.id, user.role);
}, [user.id, user.role]);

九、常见坑

// ① 对象/数组字面量直接传 props
<MemoChild options={{ a: 1 }} columns={['a']} />            // ❌

// ② 渲染中计算新数组传 props
<MemoList items={items.filter((x) => x.on)} />              // ❌
// ✅ useMemo

// ③ 默认参数是字面量
function Child({ options = {} }) {}                          // ❌
// ✅ 提到模块外

// ④ Effect 依赖整个对象
useEffect(() => {}, [config]);                               // ⚠️ 依赖不稳定 → 反复执行
// ✅ 依赖具体字段,或用 ref

// ⑤ useMemo 的依赖里有新对象 → 完全失效
useMemo(() => f(a), [a, { extra: 1 }]);                      // ❌

// ⑥ 用 JSON.stringify 做深比较(大对象时很慢)
// ⑦ 把「大对象」当 Context value(全量重渲染)
// ⑧ 在渲染里做「深拷贝」(structuredClone / JSON 复制)
// ⑨ 认为「直接写 useCallback 就能稳定」但依赖里有新对象
const cb = useCallback(() => f(id), [{ id }]);               // ❌
// ⑩ 忘了「内联箭头函数」也是「不稳定引用」
// ⑪ 「所有回调都 useCallback」但子组件没 memo(无意义)
// ⑫ 把「ref 保存最新值」当成 hack 而不用(它是官方推荐的做法)
// ⑬ 依赖里放了「每次渲染创建的 Map/Set」
useEffect(() => {}, [new Map()]);                            // ❌
// ⑭ 「依赖数组」写了但传了非数组(如 [obj] 写成 obj)
useEffect(() => {}, config);                                 // ⚠️ 语义错(不是数组)

十、面试延伸

  1. 「什么是『引用不稳定』?它会导致什么问题?」
    「引用不稳定」指「每次渲染都产生新的对象/数组/函数引用」——因为 React 每次渲染都会重新执行组件函数,函数体里的字面量(对象、数组、箭头函数)都会重新创建。它会导致三类问题:① React.memo 失效(浅比较发现 props 引用变了 → 子组件无谓重渲染);② useEffect 反复执行(甚至「渲染 → effect setState → 渲染」的死循环);③ useMemo/useCallback 失效(依赖不稳定 → 每次重算)。它是 React 性能问题最常见的根源。

  2. 「怎么解决引用不稳定?」
    五种手段:① 提到模块作用域——不依赖 props/state 的常量与函数(最优,无需任何 Hook);② useMemo / useCallback——依赖 props/state 时用;③ 依赖原始值而不是对象——[user.id] 而不是 [user];④ 用 ref 保存最新值——需要「稳定引用 + 读最新值」时(useEffect(() => { ref.current = fn; }));⑤ data-* + 事件委托——列表场景下让处理函数永久稳定。另外注意「默认参数也要提到模块外」(options = EMPTY_OBJECT)。

  3. 「为什么默认参数也要提到模块外?」
    因为 function Child({ options = {} }) 里的 {} 每次渲染都会创建一个新对象——这会让 React.memo 失效(props 引用每次不同)。所以规范是「把默认值定义为模块级常量」:const EMPTY = {}; function Child({ options = EMPTY }) {...}。数组同理(const EMPTY_LIST: Item[] = [])。

  4. 「useMemo 的依赖里有新对象会怎样?」
    会让 useMemo 完全失效——因为每次渲染依赖都与上次不同(引用比较失败),React 每次都会重新执行计算函数。这不仅没有收益,还额外增加了「比较依赖 + 保存缓存」的开销,属于负优化。同理 useCallback 也一样。所以「先修依赖,再谈 memo」——如果发现 useMemo 没效果,第一件事就是检查依赖里是否有字面量。

  5. 「用 JSON.stringify 作比较函数有什么问题?」
    问题在于成本:JSON.stringify 需要遍历整个对象并生成字符串——对于大对象,这个成本可能超过「重新渲染组件」的成本。所以「深比较」通常得不偿失。更好的做法是:① 只比较「影响渲染的关键字段」(a.item.id === b.item.id && a.item.name === b.item.name);② 或从源头保证引用稳定(让数据本身是不可变更新的,比较引用即可);③ 或改变结构,让需要比较的数据尽量小(如提前把「渲染所需的最小字段」提取出来)。

  6. 「为什么 Context 的 value 含大对象是问题?」
    因为 Context 的订阅粒度是整个 value——value 里任何一项变化都会通知所有消费者重渲染。所以如果 value 是 { user, permissions, menu, config } 这样的大对象,那么「改一下 menu」就会让「只用到 user 的组件」也重渲染。解法:① 拆分 Context(按变化频率/使用方);② 值与操作分离(ActionsContext 永不更新);③ 用 useMemo 包装 value(至少消除「伪更新」)。详见 5.9。

  7. 「ref 保存最新值的模式为什么是官方推荐?」
    因为它是「引用稳定 + 读取最新值」两个需求的唯一简洁解法:useEffect(() => { ref.current = fn; })(无依赖数组,所以每次渲染都更新、且不触发任何执行),然后在「只建立一次」的副作用里读 ref.current。React 官方文档在 useEffect 的 FAQ 里有明确示例(如「如何在 Effect 里读最新 props」),useInterval、useEventListener 等常用 Hook 也都用这个模式。它不是 hack,而是标准做法。

  8. 「引用稳定性和『不可变更新』有什么关系?」
    两者是互补的:① 不可变更新保证「变化时一定产生新引用」(让 React 能通过引用比较发现变化);② 引用稳定保证「没变化时不要产生新引用」(让 memo/useMemo 能通过引用比较跳过工作)。如果只做不可变更新而不控制「无关的新引用」,就会出现「变化被发现了,但无关的也变了」;如果只追求引用稳定而不做不可变更新(如直接改对象),就会出现「变化没被发现」。两者一起才能让 React 的比较机制正常工作。

一句话速记

引用不稳定(每次渲染都新建对象/数组/函数)会让 memo 失效、useEffect 反复执行、useMemo 完全失效,是 React 性能问题最常见的根源;五种解法按优先级是「提到模块作用域(含默认值)→ useMemo/useCallback → 依赖原始值(user.id 而非 user)→ 用 ref 保存最新值(useEffect(() => { ref.current = fn; }))→ data-* + 事件委托」;useMemo 依赖里有字面量就等于没写(先修依赖);别用 JSON.stringify 深比较(成本可能超过渲染);Context value 含大对象会导致全量重渲染(拆分/分离/useMemo);「引用稳定」与「不可变更新」是一对互补要求。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.8 大对象与引用稳定性

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫