首页 / 文章

为何 React.memo 仍会持续重新渲染:属性中的引用标识问题

了解 React.memo 是如何比较属性的,为何内联函数、对象和数组会使其失效,如何稳定引用,以及为何应在使用 memoize 之前进行性能分析。

发布者:REACTAPP.TOP阅读约 5 分钟1092 词

来源: React.memo: Useful, but Often Misused

React.memo常被视为一种提升性能的手段:将组件包裹起来即可避免不必要的重新渲染。但实际上,充斥着memo的应用仍可能运行缓慢,因为React界面性能低下通常源于组件结构问题、不稳定的属性值、耗时的计算操作,或是渲染内容过多,而非缺少包裹层。本指南将阐明memo真正用于比较的内容、那些会悄悄削弱其效果的属性使用方式,以及如何判断是否有必要使用它。

memo实际上会检查什么

memo并不会阻止组件的渲染。它会使用Object.is逐个属性地将新属性与之前的属性进行比较,只有当所有属性都相同时才会跳过渲染。即使组件的自身状态或它所读取的上下文发生了变化,组件仍会进行渲染。字符串、数字和布尔值这类原始类型属性是通过值来比较的,因此可以正常工作。

import { memo } from "react";

const App = () => {
  const name = "John";
  const age = 25;

  return <UserCard name={name} age={age} />
};

const UserCard = memo(({ name, age }) => {
  return (
    <div>
      <h3>{name}</h3>
      <p>Age: {age}</p>
    </div>
  );
});

如果name和age没有变化,React就可以重用之前的渲染结果。问题则出在函数、对象和数组上,因为对于这些类型,Object.is比较的是引用而非内容。

内联回调在每次渲染时都会重新创建

这里,父组件将一个箭头函数传递给经过memo处理的按钮。

import React, { useState, memo } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h2>Count: {count}</h2>

      <CounterButton
        onIncrement={() => setCount((prev) => prev + 1)}
      />
    </div>
  );
};

const CounterButton = memo(({ onIncrement }) => {
  return (
    <button onClick={onIncrement}>
      Increment
    </button>
  );
});

每次渲染 App 都会创建一个新函数。虽然这些函数的主体内容相同,但由于引用不同,memo 会认为属性发生了变化,因此每次都会重新渲染 CounterButton。

解决办法是使用 useCallback 保持引用稳定。由于使用了更新器形式 prev => prev + 1,依赖数组可以保持为空。

import React, { useState, useCallback } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  const handleIncrement = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  return (
    <div>
      <h2>Count: {count}</h2>

      <CounterButton
        onIncrement={handleIncrement}
      />
    </div>
  );
};

const CounterButton = ({ onIncrement }) => {
  return (
    <button onClick={onIncrement}>
      Increment
    </button>
  );
};

注意,在这段代码中 CounterButton 已不再被 memo 包裹。没有这个包装层的话,子组件会始终与其父组件一起重新渲染,此时 useCallback 就没有任何作用。只有将之前的示例中的 memo(...) 保持在外层,同时不随意在所有地方添加 useCallback,二者才能发挥效果——因为 useCallback 本身也有成本,只应在能解决实际问题的地方使用。

内联对象与数组的行为方式相同

组件体内的对象字面量会在每次渲染时被重新创建。

import { memo } from "react";

const App = () => {

  const config = {
    theme: "dark",
    pageSize: 20,
    showFilters: true,
  };

  return (
    <div>
      <DataGrid config={config} />
    </div>
  );
};

const DataGrid = memo(({ config }) => {
  return (
    <div>
      <h3>Theme: {config.theme}</h3>
      <p>Page Size: {config.pageSize}</p>
    </div>
  );
});

选项数组也是如此。

import { memo } from "react";

const App = () => {
  const options = [
    { label: "Admin", value: "admin" },
    { label: "Editor", value: "editor" },
    { label: "User", value: "user" },
  ];

  return (
    <div>
      <Select options={options} />
    </div>
  );
};

const Select = memo(({ options }) => {

  return (
    <select>
      {options.map((option) => (
        <option key={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
});

这两种情况每次都会生成新的引用,因此它们被缓存的子元素会再次渲染。

将永不变化的数据提升到上层

当数据是静态的,应将其移至模块作用域,以便只创建一次。选项列表可以放在组件外部。

import { useState, useMemo, memo } from "react";

const options = [
      { label: role.toUpperCase(), value: role },
      { label: "Editor", value: "editor" },
      { label: "User", value: "user" },
    ]

const App = () => {

  return (
    <div>
      <Select options={options} />
    </div>
  );
};

const Select = memo(({ options }) => {
  return (
    <select>
      {options.map((option) => (
        <option key={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
});

export default App;

如打印所示,第一个条目引用了模块作用域中并不存在的role变量,因此这段代码会抛出ReferenceError错误。应使用诸如{ label: "Admin", value: "admin" }这样的字面量。如果某个条目确实依赖于props或state,应将数组保留在组件内部,并用useMemo将其包裹起来,同时将这些值作为依赖项(这大概也是需要导入useMemo的原因)。

配置对象也会被同样处理。

import { memo } from "react";

const config = {
  theme: "dark",
  pageSize: 20,
  showFilters: true,
};

const App = () => {
  return (
    <div>
      <DataGrid config={config} />
    </div>
  );
};

const DataGrid = memo(({ config }) => {

  return (
    <div>
      <h3>Theme: {config.theme}</h3>
      <p>Page Size: {config.pageSize}</p>
    </div>
  );
});

在使用memo化之前先进行测量

在探讨任何优化方案之前,首先需要回答一些关键问题:到底是哪个组件导致了性能问题、它为何会被渲染、这种渲染操作是否代价高昂,以及能否通过减少工作量而非隐藏问题来解决。React DevTools Profiler 能够回答其中的大部分问题,它会显示哪些组件被渲染、为何被渲染、每个组件的渲染耗时以及哪些更新是多余的。在应用记忆化之前先减少工作量一文中的清单进一步细化了这些操作步骤。

核心要点

  • 只有当所有属性都保持稳定的引用时,memo 才能发挥作用。
  • 内联函数、对象、数组以及上下文值往往会破坏这种稳定性,从而使 memo 失效。
  • 如果没有对应的记忆化子组件,使用 useCallback 只是白费力气。
  • 应将 useMemo 用于那些计算成本较高或必须保持稳定的引用场景。
  • 对长列表进行虚拟化处理以及拆分大型组件通常比使用记忆化技术能带来更大收益。
  • 先进行性能分析,再由数据来决定方案。