Skip to content

React 性能优化 ​

汇总 React 页面级优化的常用手段:跳过无变化的重新渲染、缓存计算结果与函数引用、用并发特性拆解大更新,以及 key、懒加载与副作用清理。示例基于 React 18 + TypeScript。

目录 ​

  1. memo
  2. useMemo
  3. useCallback
  4. useTransition
  5. useDeferredValue
  6. Fragment
  7. 合理使用 Context
  8. 避免用 index 作为 key
  9. 懒加载
  10. 组件卸载时的清理

一、memo ​

memo 允许组件在 props 没有改变的情况下跳过重新渲染。它默认通过 Object.is 比较每个 prop,也可以通过第二个参数传入自定义函数来控制对比过程:

tsx
import { memo } from "react";

const Chart = memo(function Chart({ dataPoints }) {
  // ...
}, arePropsEqual);

function arePropsEqual(oldProps, newProps) {
  return (
    oldProps.dataPoints.length === newProps.dataPoints.length &&
    oldProps.dataPoints.every((oldPoint, index) => {
      const newPoint = newProps.dataPoints[index];
      return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
    })
  );
}

arePropsEqual 返回 true 表示新旧 props 等价、可以跳过渲染。上面的实现只比较数组长度与每个点的坐标,因此父组件即使传入新的数组对象,只要内容一致,Chart 也不会重新渲染。

二、useMemo ​

useMemo 在每次重新渲染时缓存计算的结果,只在依赖项改变时重新计算:

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

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

  const memoizedValue = useMemo(() => {
    // 创建 1000 位数组
    const list = new Array(1000).fill(null).map((_, i) => i);

    // 对数组求和
    const total = list.reduce((res, cur) => (res += cur), 0);

    // 返回计算的结果
    return count + total;

    // 添加依赖项,只有 count 改变时,才会重新计算
  }, [count]);

  return (
    <div>
      {memoizedValue}
      <button onClick={() => setCount((prev) => prev + 1)}>按钮</button>
    </div>
  );
}

export default App;

三、useCallback ​

useCallback 缓存函数的引用地址,仅在依赖项改变时才会更新。先看没有缓存时的问题:

1. memo 挡不住的原因 ​

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

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

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <div>
      {count}
      <MyButton handleClick={handleClick} />
    </div>
  );
};

const MyButton = memo(function MyButton({
  handleClick,
}: {
  handleClick: () => void;
}) {
  console.log("子组件渲染");
  return <button onClick={handleClick}>按钮</button>;
});

export default App;

点击按钮,会发现即使子组件已经用 memo 包裹,还是会重新渲染,控制台打印出「子组件渲染」。原因是父组件 App 每次更新时,handleClick 都返回了新的引用地址,对子组件来说每次传入的都是不同的值,因此触发重渲染。

2. 内联函数同样会重建引用 ​

tsx
<MyButton handleClick={() => setCount((prev) => prev + 1)} />

每次父组件更新时,内联的匿名函数都会返回新的引用地址,同样会触发子组件重渲染,因此应尽量减少在 JSX 中直接绑定内联函数。

3. 用 useCallback 缓存引用 ​

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

再点击按钮,子组件不会再重新渲染。

四、useTransition ​

useTransition 提供的 startTransition 可以把一次更新标记为不紧急:这段任务可以接受延迟或被打断渲染,从而优先执行更重要的任务,页面会先显示 list2 的内容,之后再显示 list1 的内容。

tsx
import { useState, useEffect, useTransition } from "react";

const App = () => {
  const [list1, setList1] = useState<null[]>([]);
  const [list2, setList2] = useState<null[]>([]);
  const [isPending, startTransition] = useTransition();
  useEffect(() => {
    startTransition(() => {
      // 将状态更新标记为 transition
      setList1(new Array(10000).fill(null));
    });
  }, []);
  useEffect(() => {
    setList2(new Array(10000).fill(null));
  }, []);
  return (
    <>
      {isPending ? "pending" : "nopending"}
      {list1.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
      -----------------list2
      {list2.map((_, i) => (
        <div key={i}>6666</div>
      ))}
    </>
  );
};

export default App;

对比两次执行(原笔记配图):

  • 去掉 list1、只同步更新 list2:layout 之后一直在函数调用,页面会堵塞大约 0.3 秒。 list1 未使用过渡更新时的执行堆栈,长任务堵塞约 0.3 秒

  • 去掉 list2、只看被 startTransition 标记的 list1:任务被拆分到每一帧不同的 task 中,浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。 使用 useTransition 后更新被拆分成多个小任务,分散到每一帧

五、useDeferredValue ​

useDeferredValue 用于延迟渲染不紧急的部分,类似防抖但没有固定的延迟时间:

tsx
import { useState, useDeferredValue } from "react";

function SearchPage() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  // ...
}

六、Fragment ​

当需要呈现多个元素、又不想引入额外的容器元素时,用 React.Fragment 可以减少 DOM 节点的数量,从而提高渲染性能:

tsx
const MyComponent = () => {
  return (
    <React.Fragment>
      <div>Element 1</div>
      <div>Element 2</div>
      <div>Element 3</div>
    </React.Fragment>
  );
};

不指定 key 时,<React.Fragment> 也可以简写为 <>...</>。

七、合理使用 Context ​

Context 能够在组件树间跨层级传递数据。正因为它绕过了 props 逐层传递,Context 也绕过了 React.memo 或 shouldComponentUpdate 设定的比较过程:

一旦 Context 的 value 变动,所有通过 useContext 获取该 Context 的组件都会强制更新,即使该组件已经用 memo 包裹、且变动的那部分 value 与其无关。

要让组件仅在相关的 context 值变化时才重新渲染,可以把组件拆成两层:外层组件从 context 中读取所需内容,再作为 props 传给用 memo 优化过的子组件。

提示:zustand 借助 useShallow 不会出现这种情况。

八、避免用 index 作为 key ​

渲染元素列表时,尽量避免把数组索引作为组件的 key。如果列表项存在添加、删除及重新排序的操作,用 index 作为 key 会降低节点复用率,进而影响性能。应改用数据源的 id:

tsx
const MyComponent = () => {
  const items = [
    { id: 1, name: "Item 1" },
    { id: 2, name: "Item 2" },
    { id: 3, name: "Item 3" },
  ];

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
};

九、懒加载 ​

通过 React.lazy 与 React.Suspense 做代码分割,把应用拆成更小的模块,在真正需要时才按需加载对应部分。

1. 定义路由 ​

tsx
import { lazy } from "react";
import { createBrowserRouter } from "react-router-dom";

const Login = lazy(() => import("../pages/login"));

const routes = [
  {
    path: "/login",
    element: <Login />,
  },
];

// 可传第二个参数,配置 base 路径 { basename: '/app' }
const router = createBrowserRouter(routes);

export default router;

2. 引用路由 ​

懒加载的组件必须放在 Suspense 之内,fallback 是加载期间显示的内容:

tsx
import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";

import ReactDOM from "react-dom/client";

import router from "./router";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement,
);

root.render(
  <Suspense fallback={<div>Loading...</div>}>
    <RouterProvider router={router} />
  </Suspense>,
);

十、组件卸载时的清理 ​

在组件卸载时清理全局监听器、定时器等资源,防止内存泄漏拖慢性能:

tsx
import { useState, useEffect, useRef } from "react";

function MyComponent() {
  const [count, setCount] = useState(0);
  const timer = useRef<NodeJS.Timeout>();

  useEffect(() => {
    // 定义定时器
    timer.current = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);

    const handleOnResize = () => {
      console.log("Window resized");
    };

    // 定义监听器
    window.addEventListener("resize", handleOnResize);

    // 在组件卸载时清除定时器和监听器
    return () => {
      clearInterval(timer.current);
      window.removeEventListener("resize", handleOnResize);
    };
  }, []);

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default MyComponent;

注意:React 19 起 useRef 必须传入初始值,上面的 useRef<NodeJS.Timeout>() 在 @types/react 19 下会报错,需改为 useRef<NodeJS.Timeout | undefined>(undefined)。