首页 / 文章 / 先减少工作量:使用记忆化前的 React 性能检查清单

先减少工作量:使用记忆化前的 React 性能检查清单

通过避免不必要的操作来降低 React 应用成本:对搜索功能使用防抖处理、实现分页、将状态集中管理、使用稳定的键值、在服务器端进行处理并采用懒加载方式,必要时再进行记忆化处理。

2118 词

当 React 应用运行缓慢时,人们的第一反应往往是使用 useMemo、useCallback 或 React.memo。这些工具确实能降低现有代码的运行成本,但在许多应用中,真正的开销其实来自那些本就不该执行的操作:多余的请求、过大的数据包、会影响到整个状态结构的更新,以及用户根本不需要的一些代码。本指南将介绍在优化剩余代码之前可以消除的七类冗余操作,并提供一份可在代码审查时使用的检查清单。

贯穿始终的指导性问题很简单:这项操作能否完全避免?

减少请求次数:对搜索输入进行防抖处理

搜索框是导致请求浪费的典型场景。一个简单的处理方式会在每次输入变化时都调用 API:

const handleSearch = (value) => {
  fetchUsers(value);
};

在搜索框中输入“React”这个词时,每次按键都会触发一次请求:

R
Re
Rea
Reac
React

一次搜索需要进行五次往返请求,其中四次返回的結果根本没人会查看。更好的方法是等待用户短暂停顿后再发送查询。這就是防抖功能的原理:每次新的調用都會重置計時器,只有當計時器在未受干擾的情況下到期時,所包裹的函數才會執行一次。

const handleSearch = debounce((value) => {
  fetchUsers(value);
}, 300);

在300毫秒的時間窗口內,打字速度快的用戶最終只會觸發一次請求。這樣就能節省網絡流量、服務器負載以及對被丟棄的回應進行客戶端處理的開銷。需要注意的是,這裏並未影響渲染過程;其優勢完全來自於避免了不必要的操作。

在组件中使用此类辅助函数时需注意一点:如果在组件主体中直接调用 debounce(...),那么每次渲染都会创建一个新的去抖函数(以及新的定时器),从而导致去抖功能失效。应将其仅创建一次,例如使用 useMemo 或 useRef,或者采用下面的基于效应的模式。

一个独立的去抖搜索组件

仅使用 React 原生功能也能实现相同效果,无需任何辅助库。首先导入相关模块并定义两个状态:当前的输入文本以及已获取的用户列表。

import { useEffect, useState } from "react";
function UserSearch() {
  const [search, setSearch] = useState("");
  const [users, setUsers] = useState([]);

每当 search 的值发生变化时,就会触发一个效应。它不会立即执行数据获取操作,而是通过 setTimeout 来安排任务。在回调函数中,如果查询内容为空或仅包含空白字符,就会清除结果并直接退出,而不会发起请求。

useEffect(() => {
    const timer = setTimeout(async () => {
      if (!search.trim()) {
        setUsers([]);
        return;
      }

对于实际查询,回调函数会请求匹配的用户,并对搜索词进行编码,以防止特殊字符破坏URL结构:

const response = await fetch(
        `/api/users?search=${encodeURIComponent(search)}`
      );

随后它会解析JSON并存储结果,整个过程仍在300毫秒的计时限制内完成:

const data = await response.json();
      setUsers(data);
    }, 300);

实际的去抖处理发生在清理函数中。React会在效果重新运行之前执行该函数,因此每次按键都会取消之前的待处理计时器:

return () => clearTimeout(timer);
  }, [search]);

最后,该组件会渲染一个与search属性绑定的受控输入框:

return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />

并且会按照用户ID的顺序列出这些用户:

{users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

每次输入字符都会重置计时器,只有在300毫秒无输入后才会发送请求。需注意,去抖机制虽然能减少请求次数,但无法保证请求按顺序处理;较早返回的慢响应仍可能覆盖 newer的响应。如果这对您的用户界面很重要,应结合请求取消功能,具体方法可参考解决搜索界面中去抖无法处理的竞态条件一文。

更广泛的启示是:预防问题出现通常比加快现有工作的速度更为重要。

仅获取屏幕所需的数据

另一个常见的资源消耗问题在于下载的数据量远超过显示的数量。假设某个接口返回10,000名用户,而界面每次只展示20名。浏览器仍需下载、解析这些数据并将它们全部存储在内存中,React也必须处理远大于实际需要的数组。

在适用的情况下,采用分页方式,让每次请求仅加载一页内容:

API
 ↓
20 users
 ↓
Browser
 ↓
Display

每当用户向下滚动时,再请求下一页数据。这样可以减少网络使用量、内存占用、客户端处理负担以及流经组件的数据量。无限滚动和基于光标的API也遵循相同原理。调整数据获取方式往往比优化单个组件能带来更显著的效果。

将变化频繁的状态置于其使用者附近

状态所在的位置决定了当该状态发生变化时需要重新渲染的组件范围。以一个管理搜索文本的仪表板为例:

function Dashboard() {
  const [search, setSearch] = useState("");
return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <Analytics />
      <UserTable />
    </>
  );
}

每次按键都会更新Dashboard,因此即使Analytics和UserTable并未使用该搜索值,它们也会被重新渲染。在大型仪表板中,这种影响会累积起来。如果某个状态仅对UI的很小一部分有用,那么将其移至该部分(此处即直接放在SearchBox中),就能将更新限制在需要的地方,同时让组件结构更易于理解。

这并非要求始终将状态下放到最底层。当多个组件确实需要相同的值时,它们最近的公共父组件就是合适的状态管理者。其目标是在仍能为所有实际读取该状态的组件所共享的最低层级上维护状态。

为动态列表项提供稳定的键值

在列表中,细微的差异往往会产生巨大的影响。一种常见的做法是使用数组索引作为键:

{users.map((user, index) => (
  <UserCard key={index} user={user} />
))}

索引键并不总是错误的。对于顺序永远不会改变的静态列表,这种做法是可行的。而对于动态列表,则可以通过数据中的稳定标识为每个元素赋予持久的身份:

{users.map((user) => (
  <UserCard key={user.id} user={user} />
))}

两者的区别在于键所代表的含义:

index → position
id    → identity

如果列表中的元素可以被添加、删除、重新排序或过滤,那么元素的顺序就会发生变化,此时React可能会匹配到错误的元素:它不仅可能不必要的重新渲染,更糟糕的是,还可能将一个元素的内部状态附加到另一个元素上。当列表项包含输入框、本地状态或其他交互式元素时,这种情况就会显现为明显的错误——例如,当某一行发生变化时,其下方的文本框仍会保留之前输入的值。只要列表有可能发生变化,就应优先使用稳定标识。

不仅要关注处理速度,更要审视处理逻辑

复杂的客户端处理操作也是可避免成本的来源之一。在这种情况下,用户会被过滤、排序并映射为新的对象:

const filteredUsers = users
  .filter((user) => user.isActive)
  .sort((a, b) => a.name.localeCompare(b.name))
  .map((user) => ({
    ...user,
    displayName: user.name.toUpperCase()
  }));

对于几千条记录而言,每次渲染时都重复执行这些操作会带来高昂的成本。虽然可以使用缓存来解决这个问题,但首先应考虑客户端是否真的需要执行这些操作。通常情况下,接口本身就可以过滤出活跃用户,数据库借助索引可以高效地完成排序,而分页功能则能缩小数据集规模,从而使剩余的处理工作变得简单。减少输入数据往往比加快计算速度更有效。

按需加载功能

大型应用会包含许多用户在单次会话中从未使用过的功能。例如,报告页面可能与主控制面板完全独立。与其将其打包在初始下载中,不如采用延迟加载的方式:

const Reports = lazy(() => import("./Reports"));

使用 React.lazy 时,模块会在组件首次渲染时被加载,而这一过程必须发生在 Suspense 区域内,以便在加载期间显示备用内容。在具有众多路由、大型功能模块、重量级依赖(如图表或编辑器库)或用户访问频率较低的页面的应用中,这种方式的优势最为明显。

需要明确它带来的好处:更小的初始 JavaScript 加载量以及更快的首次加载速度。不过,一旦加载完成,懒加载组件内的代码运行速度并不会加快,而且首次使用该功能时还会出现短暂的加载延迟。

有理由地使用记忆化

一个常见的错误是默认在代码库中到处使用优化 API,比如给每个处理函数都加上包装:

const handleClick = useCallback(() => {
  setSelectedUser(id);
}, [id]);

或者给每个派生值都加上包装:

const data = useMemo(() => {
  return processData(users);
}, [users]);

这些钩子确实有实际用途,但每个都会增加代码量、需要保持正确的依赖数组,以及一定的运行时开销。在添加之前,请检查:

  • 该计算真的如此耗资源吗?
  • 它会被频繁执行吗?
  • 其结果会在多次渲染中被重复使用吗?
  • 是否有已缓存的子组件或效应依赖于稳定的引用?
  • 这一改动能否带来可测量的差异?

如果答案大多是否,那么更简单的代码就是更好的代码。性能取决于在正确位置进行的恰当优化,而非优化代码的总量。您所使用的 React Compiler 已自动处理了大部分记忆化操作,这又是不必在所有地方手动实现记忆化的原因之一;详情可参阅React Compiler 优化了什么以及它将哪些任务留给了您。

审查检查清单

在审核 React 功能时,请逐一思考以下问题:

  1. 是否有不必要的请求?检查每次按键是否都会触发请求、是否存在重复请求,以及是否有针对当前未显示数据的获取操作。
  2. 数据量是否过多?采用分页方式、在服务器端进行过滤,并将数据获取操作推迟到真正需要时再执行。
  • 状态值过高?检查某次更新是否导致树结构的较大部分重新渲染,超出了必要范围。
  • 列表键不稳定?当项目可能移动或成员发生变化时,应使用稳定的标识符。
  • 处理开销过大?在优化之前,先看看这些工作是否可以转移到服务器上处理或直接跳过。
  • 过早启用高级功能?对于大型或很少使用的模块,应采用延迟加载的方式。
  • 不必要的记忆化?不要仅仅因为这些函数存在就使用useMemo、useCallback或React.memo。
  • 性能是一个流程,而非单次渲染

    React的性能问题并不仅限于React本身。成本会贯穿整个工作流程逐步累积:

    API calls
       ↓
    Amount of data
       ↓
    State updates
       ↓
    Component structure
       ↓
    Data processing
       ↓
    Rendering
       ↓
    Bundle size
    

    仅关注渲染层可能会掩盖上游存在的更严重问题。即便将渲染时间缩短几毫秒,如果页面仍会发送多余的请求,那也收效甚微;而当需要下载数千条根本不会显示的记录时,对组件进行缓存也毫无帮助。应从整个流程的源头开始逐步排查。

    关键要点

    • 减少工作量(请求、数据量、更新操作、代码量)通常比单纯提升处理速度更能带来效果。
    • 对由用户输入触发的请求进行防抖处理,而在顺序至关重要的情况下,则需将防抖与取消机制结合使用。
    • 让服务器负责过滤、排序和分页处理,仅将需要显示的内容发送给客户端。
    • 将状态置于能被所有读取者访问的最底层,并按标识符对动态列表进行排序。
    • 采用懒加载方式以减轻首次加载的负担,只有在确实存在明显成本优势时才使用缓存机制。
  • 在询问如何优化某事物之前,先思考是否有必要进行优化。
  • 相关阅读

  • 保持页面响应式:何时将CPU任务移至Web Worker中 — 了解为何复杂的循环会导致浏览器界面卡顿,Web Worker消息机制如何分担这些任务,以及如何判断某项任务是否真的需要使用Worker。
  • JavaScript性能预算:在CI环境中限制代码包大小 — 了解为何JavaScript的实际成本远高于其下载时间,如何设定合理的性能预算,以及如何让webpack在超出预算时阻止构建。
  • 通过示例理解可读性强的 JavaScript:十个修改前后的对比案例 —— 十个针对 JavaScript 和 React 的小型代码优化示例,涵盖命名、参数对象、错误处理及代码格式等方面,旨在让后续开发者更易于阅读和修改代码。
  • 为何 React.lazy() 需要默认导出以及如何加载命名导出 —— 了解 React.lazy() 从动态导入中接收的内容,为何命名导出会破坏其功能,如何将它们重新映射为默认导出,以及 Suspense 和代码分割机制的作用。
  • 将离线优先前端设计为副本:队列、重试与冲突处理 — 了解为何离线优先模式能将浏览器转变为数据副本,以及本地存储、持久化变更队列、幂等重试机制和冲突规则是如何协同工作的。