Files
2026-07-13 21:36:17 +08:00

104 KiB
Raw Permalink Blame History

React 最佳实践

版本 1.0.0 Vercel 工程团队 2026 年 1 月

注意: 本文档主要供 AI 智能体与大型语言模型在维护、生成或重构 React 与 Next.js 代码库时参考使用。人类开发者也可从中获益,但此处指南针对 AI 辅助工作流的自动化与一致性进行了优化。


摘要

面向 React 与 Next.js 应用程序的综合性能优化指南,专为 AI 智能体与大型语言模型设计。包含 40 余条规则,覆盖 8 大类别,按影响程度从关键(消除瀑布请求、减小打包体积)到渐进(高级模式)排序。每条规则均附带详细解释、对比错误与正确实现的实际示例,以及具体的量化影响指标,用以指导自动化重构与代码生成。


目录

  1. 消除瀑布请求关键
  2. 打包体积优化关键
  3. 服务端性能
  4. 客户端数据获取中高
  5. 重渲染优化
  6. 渲染性能
  7. JavaScript 性能低中
  8. 高级模式

1. 消除瀑布请求

影响:关键

瀑布请求是性能的头号杀手。每个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的性能提升。

1.1 在异步标志之前检查廉价条件

影响:高(当同步守卫已失败时,避免不必要的异步工作)

当一个分支使用 await 获取标志或远程值,并且还需要一个廉价的同步条件(本地 props、请求元数据、已加载的状态)时,应评估廉价条件。否则,即使组合条件永远不可能成立,你仍然要为异步调用付出代价。

这是将 await 推迟到需要时flag && cheapCondition 风格检查中的特化形式。

错误:

const someFlag = await getFlag()

if (someFlag && someCondition) {
  // ...
}

正确:

if (someCondition) {
  const someFlag = await getFlag()
  if (someFlag) {
    // ...
  }
}

getFlag 涉及网络请求、功能标志服务或 React.cache / 数据库操作时,这一点尤为重要:当 someCondition 为 false 时跳过它,能在冷路径上消除该开销。

如果 someCondition 本身很昂贵、依赖于该标志,或者必须按固定顺序执行副作用,则保持原有顺序。

1.2 将 await 推迟到需要时

影响:高(避免阻塞未使用的代码路径)

await 操作移到实际使用它们的分支中,以避免阻塞不需要这些操作的代码路径。

错误:两个分支都被阻塞

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    // 立即返回,但仍然等待了 userData
    return { skipped: true }
  }

  // 只有这个分支使用 userData
  return processUserData(userData)
}

正确:仅在需要时阻塞

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 立即返回,无需等待
    return { skipped: true }
  }

  // 仅在需要时获取
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

另一个示例:提前返回优化

// 错误:总是获取权限
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId)
  const resource = await getResource(resourceId)

  if (!resource) {
    return { error: 'Not found' }
  }

  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }

  return await updateResourceData(resource, permissions)
}

// 正确:仅在需要时获取
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId)

  if (!resource) {
    return { error: 'Not found' }
  }

  const permissions = await fetchPermissions(userId)

  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }

  return await updateResourceData(resource, permissions)
}

当跳过的分支被频繁命中,或推迟的操作本身很昂贵时,此优化尤其有价值。

关于 await getFlag() 与廉价同步守卫的组合使用(flag && someCondition),请参阅在异步标志之前检查廉价条件

1.3 基于依赖的并行化

影响:关键(2-10 倍提升)

对于存在部分依赖关系的操作,使用 better-all 来最大化并行度。它会自动在最早的可能时刻启动每个任务。

错误:profile 不必要地等待 config

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)

正确:config 与 profile 并行运行

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    return fetchProfile((await this.$.user).id)
  }
})

无需额外依赖的替代方案:

const userPromise = fetchUser()
const profilePromise = userPromise.then(user => fetchProfile(user.id))

const [user, config, profile] = await Promise.all([
  userPromise,
  fetchConfig(),
  profilePromise
])

我们也可以先创建所有 Promise,最后再执行 Promise.all()

参考:https://github.com/shuding/better-all

1.4 防止 API 路由中的瀑布链

影响:关键(2-10 倍提升)

在 API 路由和 Server Action 中,立即启动独立操作,即使你还不打算 await 它们。

错误:config 等待 authdata 等待两者

export async function GET(request: Request) {
  const session = await auth()
  const config = await fetchConfig()
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

正确:auth 和 config 立即启动

export async function GET(request: Request) {
  const sessionPromise = auth()
  const configPromise = fetchConfig()
  const session = await sessionPromise
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id)
  ])
  return Response.json({ data, config })
}

对于依赖链更复杂的操作,使用 better-all 自动最大化并行度(参见基于依赖的并行化)。

1.5 对独立操作使用 Promise.all()

影响:关键(2-10 倍提升)

当异步操作之间没有相互依赖时,使用 Promise.all() 并发执行它们。

错误:顺序执行,3 次往返

const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

正确:并行执行,1 次往返

const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

1.6 战略性 Suspense 边界

影响:高(更快的首次绘制)

在异步组件返回 JSX 之前,不要等待数据加载,而是使用 Suspense 边界在数据加载过程中更快地显示外层 UI。

错误:外层 UI 被数据获取阻塞

async function Page() {
  const data = await fetchData() // 阻塞整个页面

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

整个布局都要等待数据,尽管只有中间部分需要它。

正确:外层 UI 立即显示,数据流式加载

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData() // 仅阻塞此组件
  return <div>{data.content}</div>
}

Sidebar、Header 和 Footer 立即渲染。只有 DataDisplay 等待数据。

替代方案:在组件间共享 Promise

function Page() {
  // 立即开始获取,但不 await
  const dataPromise = fetchData()

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 解包 Promise
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 复用同一个 Promise
  return <div>{data.summary}</div>
}

两个组件共享同一个 Promise,因此只会发起一次请求。布局立即渲染,同时两个组件一起等待。

何时不应使用此模式:

  • 布局决策所需的关键数据(影响定位)

  • 首屏之上的 SEO 关键内容

  • 小而快的查询,Suspense 开销不划算

  • 当你想避免布局偏移时(加载 → 内容跳变)

权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的用户体验优先级选择。


2. 打包体积优化

影响:关键

减小初始打包体积可以改善交互时间和最大内容绘制。

2.1 避免使用桶文件导入

影响:关键(200-800ms 导入开销,构建变慢)

直接从源文件导入,而非通过桶文件,以避免加载数千个未使用的模块。桶文件是重新导出多个模块的入口点(例如,执行 export * from './module'index.js)。

流行的图标和组件库在其入口文件中可能有多达 10,000 个重新导出。对于许多 React 包,导入它们就需要 200-800ms,既影响开发速度也影响生产环境冷启动。

为什么 tree-shaking 无济于事: 当一个库被标记为外部依赖(不被打包)时,打包器无法优化它。如果你将其打包以启用 tree-shaking,构建会因分析整个模块图而变得极其缓慢。

错误:导入整个库

import { Check, X, Menu } from 'lucide-react'
// 加载 1,583 个模块,开发环境额外耗时 ~2.8s
// 运行时开销:每次冷启动 200-800ms

import { Button, TextField } from '@mui/material'
// 加载 2,225 个模块,开发环境额外耗时 ~4.2s

正确 —— Next.js 13.5+(推荐):

// 保留标准导入 —— Next.js 会将其转换为直接导入
import { Check, X, Menu } from 'lucide-react'
// 完整的 TypeScript 支持,无需手动路径处理

这是推荐的做法,因为它保留了 TypeScript 类型安全性和编辑器自动补全,同时仍然消除了桶文件的导入开销。

正确 —— 直接导入(非 Next.js 项目):

import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// 只加载你使用的内容

TypeScript 警告: 某些库(特别是 lucide-react)不会为其深层导入路径提供 .d.ts 文件。从 lucide-react/dist/esm/icons/check 导入会解析为隐式的 any 类型,在 strictnoImplicitAny 模式下会导致错误。当可用时优先使用 optimizePackageImports,或者在使用直接导入前确认该库为其子路径导出了类型。

这些优化可带来 15-70% 更快的开发环境启动、28% 更快的构建、40% 更快的冷启动,以及显著更快的 HMR。

常见受影响的库:lucide-react@mui/material@mui/icons-material@tabler/icons-reactreact-icons@headlessui/react@radix-ui/react-*lodashramdadate-fnsrxjsreact-use

参考:https://vercel.com/blog/how-we-optimized-package-imports-in-next-js

2.2 条件性模块加载

影响:高(仅在需要时加载大数据)

仅在某个功能被激活时才加载大数据或模块。

示例:惰性加载动画帧

function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
  const [frames, setFrames] = useState<Frame[] | null>(null)

  useEffect(() => {
    if (enabled && !frames && typeof window !== 'undefined') {
      import('./animation-frames.js')
        .then(mod => setFrames(mod.frames))
        .catch(() => setEnabled(false))
    }
  }, [enabled, frames, setEnabled])

  if (!frames) return <Skeleton />
  return <Canvas frames={frames} />
}

typeof window !== 'undefined' 检查可防止该模块被包含在 SSR 打包中,从而优化服务端打包体积和构建速度。

2.3 推迟非关键的第三方库

影响:中(在水合后加载)

分析、日志和错误追踪不会阻塞用户交互。在水合之后加载它们。

错误:阻塞初始打包

import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

正确:在水合后加载

import dynamic from 'next/dynamic'

const Analytics = dynamic(
  () => import('@vercel/analytics/react').then(m => m.Analytics),
  { ssr: false }
)

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

2.4 对重型组件使用动态导入

影响:关键(直接影响 TTI 和 LCP)

使用 next/dynamic 懒加载初始渲染时不需要的大型组件。

错误:Monaco 随主包一起打包,约 300KB

import { MonacoEditor } from './monaco-editor'

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

正确:Monaco 按需加载

import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(
  () => import('./monaco-editor').then(m => m.MonacoEditor),
  { ssr: false }
)

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

2.5 优先使用静态可分析路径

影响:高(避免意外的宽打包和文件追踪)

构建工具在导入和文件系统路径在构建时显而易见时工作效果最佳。如果将真实路径隐藏在变量内部或过于动态地组合路径,工具要么必须包含一组广泛的可能性文件,要么警告无法分析该导入,要么扩大文件追踪范围以确保安全。

优先使用显式映射或字面量路径,以便可达文件集合保持狭窄和可预测。无论你是在用 import() 选择模块,还是在服务器/构建代码中读取文件,这条规则同样适用。

当分析范围过宽时,代价是真实存在的:

  • 更大的服务端打包体积

  • 更慢的构建

  • 更差的冷启动

  • 更多的内存使用

错误:打包器无法判断可能导入的内容

const PAGE_MODULES = {
  home: './pages/home',
  settings: './pages/settings',
} as const

const Page = await import(PAGE_MODULES[pageName])

正确:使用允许模块的显式映射

const PAGE_MODULES = {
  home: () => import('./pages/home'),
  settings: () => import('./pages/settings'),
} as const

const Page = await PAGE_MODULES[pageName]()

错误:即使是 2 值的枚举也仍会将最终路径隐藏在静态分析之外

const baseDir = path.join(process.cwd(), 'content/' + contentKind)

正确:在调用点使每个最终路径成为字面量

const baseDir =
  kind === ContentKind.Blog
    ? path.join(process.cwd(), 'content/blog')
    : path.join(process.cwd(), 'content/docs')

在 Next.js 服务端代码中,这对输出文件追踪也很重要。path.join(process.cwd(), someVar) 可能会扩大追踪的文件集合,因为 Next.js 会静态分析 importrequirefs 的使用情况。

参考:https://nextjs.org/docs/app/api-reference/config/next-config-js/outputhttps://nextjs.org/learn/seo/dynamic-importshttps://vite.dev/guide/features.htmlhttps://esbuild.github.io/api/https://www.npmjs.com/package/@rollup/plugin-dynamic-import-varshttps://webpack.js.org/guides/dependency-management/

2.6 基于用户意图预加载

影响:中(减少感知延迟)

在需要之前预加载重型包,以减少感知延迟。

示例:悬停/聚焦时预加载

function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    if (typeof window !== 'undefined') {
      void import('./monaco-editor')
    }
  }

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      Open Editor
    </button>
  )
}

示例:功能标志启用时预加载

function FlagsProvider({ children, flags }: Props) {
  useEffect(() => {
    if (flags.editorEnabled && typeof window !== 'undefined') {
      void import('./monaco-editor').then(mod => mod.init())
    }
  }, [flags.editorEnabled])

  return <FlagsContext.Provider value={flags}>
    {children}
  </FlagsContext.Provider>
}

typeof window !== 'undefined' 检查可防止预加载的模块被包含在 SSR 打包中,从而优化服务端打包体积和构建速度。


3. 服务端性能

影响:高

优化服务端渲染和数据获取可以消除服务端瀑布请求并减少响应时间。

3.1 像 API 路由一样对 Server Action 进行鉴权

影响:关键(防止对服务端变更的未授权访问)

Server Action(带有 "use server" 的函数)会作为公共端点暴露,就像 API 路由一样。务必在每个 Server Action 内部验证身份验证和授权——不要仅依赖中间件、布局守卫或页面级别检查,因为 Server Action 可以被直接调用。

Next.js 文档明确声明:"对待 Server Action 的安全考虑应与面向公众的 API 端点相同,并验证用户是否有权执行变更操作。"

错误:未做身份验证检查

'use server'

export async function deleteUser(userId: string) {
  // 任何人都可以调用这个!没有身份验证检查
  await db.user.delete({ where: { id: userId } })
  return { success: true }
}

正确:在 action 内部进行身份验证

'use server'

import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'

export async function deleteUser(userId: string) {
  // 始终在 action 内部检查身份验证
  const session = await verifySession()

  if (!session) {
    throw unauthorized('Must be logged in')
  }

  // 也检查授权
  if (session.user.role !== 'admin' && session.user.id !== userId) {
    throw unauthorized('Cannot delete other users')
  }

  await db.user.delete({ where: { id: userId } })
  return { success: true }
}

带输入验证:

'use server'

import { verifySession } from '@/lib/auth'
import { z } from 'zod'

const updateProfileSchema = z.object({
  userId: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email()
})

export async function updateProfile(data: unknown) {
  // 先验证输入
  const validated = updateProfileSchema.parse(data)

  // 然后进行身份验证
  const session = await verifySession()
  if (!session) {
    throw new Error('Unauthorized')
  }

  // 然后进行授权
  if (session.user.id !== validated.userId) {
    throw new Error('Can only update own profile')
  }

  // 最后执行变更操作
  await db.user.update({
    where: { id: validated.userId },
    data: {
      name: validated.name,
      email: validated.email
    }
  })

  return { success: true }
}

参考:https://nextjs.org/docs/app/guides/authentication

3.2 避免 RSC Props 中的重复序列化

影响:低(通过避免重复序列化减少网络负载)

RSC 到客户端的序列化按对象引用去重,而不是按值。相同引用 = 序列化一次;新引用 = 再次序列化。在客户端进行转换(.toSorted().filter().map()),而不是在服务端。

错误:数组被重复

// RSC:发送 6 个字符串(2 个数组 × 3 个元素)
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />

正确:发送 3 个字符串

// RSC:发送一次
<ClientList usernames={usernames} />

// 客户端:在那里进行转换
'use client'
const sorted = useMemo(() => [...usernames].sort(), [usernames])

嵌套去重行为:

// string[] - 所有内容都重复
usernames={['a','b']} sorted={usernames.toSorted()} // 发送 4 个字符串

// object[] - 仅数组结构重复
users={[{id:1},{id:2}]} sorted={users.toSorted()} // 发送 2 个数组 + 2 个独立对象(非 4 个)

去重是递归进行的。影响因数据类型而异:

  • string[]number[]boolean[]影响高——数组及所有基本类型完全重复

  • object[]影响低——数组重复,但嵌套对象按引用去重

破坏去重的操作:创建新引用

  • 数组:.toSorted().filter().map().slice()[...arr]

  • 对象:{...obj}Object.assign()structuredClone()JSON.parse(JSON.stringify())

更多示例:

// ❌ 不好
<C users={users} active={users.filter(u => u.active)} />
<C product={product} productName={product.name} />

// ✅ 好
<C users={users} />
<C product={product} />
// 在客户端进行过滤/解构

例外: 当转换成本很高或客户端不需要原始数据时,可以传递派生数据。

3.3 避免为请求数据使用共享模块状态

影响:高(防止并发错误和请求数据泄漏)

对于 React Server Component 和 SSR 期间渲染的客户端组件,避免使用可变的模块级变量来共享请求作用域的数据。服务端渲染可以在同一进程中并发运行。如果一个渲染向共享模块状态写入数据,而另一个渲染读取它,可能会出现竞态条件、跨请求污染以及安全漏洞——一个用户的数据出现在另一个用户的响应中。

将服务端的模块作用域视为进程范围的共享内存,而非请求局部状态。

错误:请求数据在并发渲染之间泄漏

let currentUser: User | null = null

export default async function Page() {
  currentUser = await auth()
  return <Dashboard />
}

async function Dashboard() {
  return <div>{currentUser?.name}</div>
}

如果两个请求重叠,请求 A 设置了 currentUser,然后请求 B 在请求 A 完成渲染 Dashboard 之前将其覆盖。

正确:将请求数据保持在渲染树本地

export default async function Page() {
  const user = await auth()
  return <Dashboard user={user} />
}

function Dashboard({ user }: { user: User | null }) {
  return <div>{user?.name}</div>
}

安全的例外情况:

  • 在模块作用域中仅加载一次的不变静态资源或配置

  • 专门设计用于跨请求复用且正确键控的共享缓存

  • 不存储请求或用户特定的可变数据的进程级单例

关于静态资源和配置,请参见将静态 I/O 提升至模块级别

3.4 跨请求 LRU 缓存

影响:高(跨请求缓存)

React.cache() 仅在一个请求内有效。对于跨顺序请求共享的数据(用户先点击按钮 A 再点击按钮 B),使用 LRU 缓存。

实现:

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 分钟
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

// 请求 1:数据库查询,结果缓存
// 请求 2:命中缓存,无数据库查询

当用户的连续操作在几秒内多次命中需要相同数据的端点时使用。

配合 Vercel 的 Fluid Compute LRU 缓存尤其有效,因为多个并发请求可以共享同一个函数实例和缓存。这意味着缓存可以在请求之间持续存在,而无需 Redis 等外部存储。

在传统 Serverless 环境中: 每次调用独立运行,因此跨进程缓存需考虑 Redis。

参考:https://github.com/isaacs/node-lru-cache

3.5 将静态 I/O 提升至模块级别

影响:高(避免每次请求重复的文件/网络 I/O)

在路由处理函数或服务端函数中加载静态资源(字体、Logo、图片、配置文件)时,将 I/O 操作提升到模块级别。模块级代码在模块首次导入时运行一次,而非每次请求都运行。这可以消除本应在每次调用时都执行的冗余文件系统读取或网络请求。

错误:每次请求都读取字体文件

// app/api/og/route.tsx
import { ImageResponse } from 'next/og'

export async function GET(request: Request) {
  // 每次请求都运行——代价高昂!
  const fontData = await fetch(
    new URL('./fonts/Inter.ttf', import.meta.url)
  ).then(res => res.arrayBuffer())

  const logoData = await fetch(
    new URL('./images/logo.png', import.meta.url)
  ).then(res => res.arrayBuffer())

  return new ImageResponse(
    <div style={{ fontFamily: 'Inter' }}>
      <img src={logoData} />
      Hello World
    </div>,
    { fonts: [{ name: 'Inter', data: fontData }] }
  )
}

正确:在模块初始化时加载一次

// app/api/og/route.tsx
import { ImageResponse } from 'next/og'

// 模块级别:在模块首次导入时运行一次
const fontData = fetch(
  new URL('./fonts/Inter.ttf', import.meta.url)
).then(res => res.arrayBuffer())

const logoData = fetch(
  new URL('./images/logo.png', import.meta.url)
).then(res => res.arrayBuffer())

export async function GET(request: Request) {
  // 等待已经启动的 Promise
  const [font, logo] = await Promise.all([fontData, logoData])

  return new ImageResponse(
    <div style={{ fontFamily: 'Inter' }}>
      <img src={logo} />
      Hello World
    </div>,
    { fonts: [{ name: 'Inter', data: font }] }
  )
}

正确:在模块级别同步读取 fs

// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
import { readFileSync } from 'fs'
import { join } from 'path'

// 模块级别同步读取——仅在模块初始化时阻塞
const fontData = readFileSync(
  join(process.cwd(), 'public/fonts/Inter.ttf')
)

const logoData = readFileSync(
  join(process.cwd(), 'public/images/logo.png')
)

export async function GET(request: Request) {
  return new ImageResponse(
    <div style={{ fontFamily: 'Inter' }}>
      <img src={logoData} />
      Hello World
    </div>,
    { fonts: [{ name: 'Inter', data: fontData }] }
  )
}

错误:每次调用都读取配置

import fs from 'node:fs/promises'

export async function processRequest(data: Data) {
  const config = JSON.parse(
    await fs.readFile('./config.json', 'utf-8')
  )
  const template = await fs.readFile('./template.html', 'utf-8')

  return render(template, data, config)
}

正确:将配置和模板提升到模块级别

import fs from 'node:fs/promises'

const configPromise = fs
  .readFile('./config.json', 'utf-8')
  .then(JSON.parse)
const templatePromise = fs.readFile('./template.html', 'utf-8')

export async function processRequest(data: Data) {
  const [config, template] = await Promise.all([
    configPromise,
    templatePromise,
  ])

  return render(template, data, config)
}

何时使用此模式:

  • 加载 OG 图片生成的字体

  • 加载静态 Logo、图标或水印

  • 读取运行时不会更改的配置文件

  • 加载邮件模板或其他静态模板

  • 所有请求都相同的任何静态资源

何时不应使用此模式:

  • 因请求或用户而异的资源

  • 运行时可能更改的文件(改用带 TTL 的缓存)

  • 如果持续加载会消耗过多内存的大文件

  • 不应在内存中持久存在的敏感数据

配合 Vercel 的 Fluid Compute,模块级缓存尤其有效,因为多个并发请求共享同一个函数实例。静态资源会在请求之间保持在内存中加载,没有冷启动惩罚。

在传统 Serverless 环境中,每次冷启动会重新执行模块级代码,但随后的热调用会复用已加载的资源,直到实例被回收。

3.6 最小化 RSC 边界的序列化

影响:高(减少数据传输大小)

React 服务端/客户端边界会将所有对象属性序列化为字符串,并将其嵌入 HTML 响应及随后的 RSC 请求中。这些序列化数据直接影响页面权重和加载时间,因此大小至关重要。只传递客户端实际使用的字段。

错误:序列化全部 50 个字段

async function Page() {
  const user = await fetchUser()  // 50 个字段
  return <Profile user={user} />
}

'use client'
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>  // 使用了 1 个字段
}

正确:仅序列化 1 个字段

async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

'use client'
function Profile({ name }: { name: string }) {
  return <div>{name}</div>
}

3.7 通过组件组合实现并行数据获取

影响:关键(消除服务端瀑布请求)

React Server Component 在组件树中按顺序执行。通过组合重构以实现并行数据获取。

错误:Sidebar 等待 Page 的 fetch 完成

export default async function Page() {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

正确:两者同时获取

async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

使用 children prop 的替代方案:

async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

function Layout({ children }: { children: ReactNode }) {
  return (
    <div>
      <Header />
      {children}
    </div>
  )
}

export default function Page() {
  return (
    <Layout>
      <Sidebar />
    </Layout>
  )
}

3.8 并行嵌套数据获取

影响:关键(消除服务端瀑布请求)

在并行获取嵌套数据时,在每个元素的 Promise 内部链式调用依赖的 fetch,这样某个慢元素就不会阻塞其余元素。

错误:单个慢元素阻塞所有嵌套 fetch

const chats = await Promise.all(
  chatIds.map(id => getChat(id))
)

const chatAuthors = await Promise.all(
  chats.map(chat => getUser(chat.author))
)

如果 100 个 getChat(id) 中有一个极其缓慢,其他 99 个聊天的作者即使数据已就绪也无法开始加载。

正确:每个元素链式调用自己的嵌套 fetch

const chatAuthors = await Promise.all(
  chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
)

每个元素独立地链式执行 getChatgetUser,因此一个慢聊天不会阻塞其他聊天的作者获取。

3.9 使用 React.cache() 实现单请求去重

影响:中(在请求内部去重)

使用 React.cache() 实现服务端请求去重。身份验证和数据库查询受益最大。

用法:

import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

在单个请求内,多次调用 getCurrentUser() 只会执行一次查询。

避免将内联对象作为参数:

React.cache() 使用浅相等(Object.is)来判断缓存是否命中。内联对象每次调用都会创建新引用,从而阻止缓存命中。

错误:总是缓存未命中

const getUser = cache(async (params: { uid: number }) => {
  return await db.user.findUnique({ where: { id: params.uid } })
})

// 每次调用都创建新对象,永远不会命中缓存
getUser({ uid: 1 })
getUser({ uid: 1 })  // 缓存未命中,再次执行查询

正确:缓存命中

const params = { uid: 1 }
getUser(params)  // 查询执行
getUser(params)  // 缓存命中(相同引用)

如果必须传递对象,请传递相同的引用。

Next.js 特定说明:

在 Next.js 中,fetch API 会自动扩展为带有请求记忆化功能。具有相同 URL 和选项的请求会在单个请求内自动去重,因此 fetch 调用不需要 React.cache()。然而,React.cache() 对于其他异步任务仍然必不可少:

  • 数据库查询(Prisma、Drizzle 等)

  • 重型计算

  • 身份验证检查

  • 文件系统操作

  • 任何非 fetch 的异步工作

使用 React.cache() 在组件树中对这些操作进行去重。

参考:https://react.dev/reference/react/cache

3.10 对非阻塞操作使用 after()

影响:中(更快的响应时间)

使用 Next.js 的 after() 来安排在响应发送后执行的工作。这可以防止日志记录、分析和其他副作用阻塞响应。

错误:阻塞响应

import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)

  // 日志记录阻塞了响应
  const userAgent = request.headers.get('user-agent') || 'unknown'
  await logUserAction({ userAgent })

  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

正确:非阻塞

import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)

  // 在响应发送后记录日志
  after(async () => {
    const userAgent = (await headers()).get('user-agent') || 'unknown'
    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'

    logUserAction({ sessionCookie, userAgent })
  })

  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

响应立即发送,同时日志记录在后台进行。

常见用例:

  • 分析追踪

  • 审计日志

  • 发送通知

  • 缓存失效

  • 清理任务

重要说明:

  • 即使响应失败或重定向,after() 也会执行

  • 适用于 Server Action、路由处理函数和 Server Component

参考:https://nextjs.org/docs/app/api-reference/functions/after


4. 客户端数据获取

影响:中高

自动去重和高效的数据获取模式可减少冗余的网络请求。

4.1 去重全局事件监听器

影响:低(N 个实例共享 1 个监听器)

使用 useSWRSubscription() 在组件实例之间共享全局事件监听器。

错误:N 个实例 = N 个监听器

function useKeyboardShortcut(key: string, callback: () => void) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        callback()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [key, callback])
}

多次使用 useKeyboardShortcut hook 时,每个实例都会注册一个新的监听器。

正确:N 个实例 = 1 个监听器

import useSWRSubscription from 'swr/subscription'

// 模块级别的 Map,用于跟踪每个按键的回调
const keyCallbacks = new Map<string, Set<() => void>>()

function useKeyboardShortcut(key: string, callback: () => void) {
  // 将该回调注册到 Map 中
  useEffect(() => {
    if (!keyCallbacks.has(key)) {
      keyCallbacks.set(key, new Set())
    }
    keyCallbacks.get(key)!.add(callback)

    return () => {
      const set = keyCallbacks.get(key)
      if (set) {
        set.delete(callback)
        if (set.size === 0) {
          keyCallbacks.delete(key)
        }
      }
    }
  }, [key, callback])

  useSWRSubscription('global-keydown', () => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && keyCallbacks.has(e.key)) {
        keyCallbacks.get(e.key)!.forEach(cb => cb())
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
}

function Profile() {
  // 多个快捷键将共享同一个监听器
  useKeyboardShortcut('p', () => { /* ... */ })
  useKeyboardShortcut('k', () => { /* ... */ })
  // ...
}

4.2 为滚动性能使用被动事件监听器

影响:中(消除由事件监听器引起的滚动延迟)

为触摸和滚轮事件监听器添加 { passive: true } 以启用即时滚动。浏览器通常会等待监听器执行完毕以检查是否调用了 preventDefault(),这会导致滚动延迟。

错误:

useEffect(() => {
  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)

  document.addEventListener('touchstart', handleTouch)
  document.addEventListener('wheel', handleWheel)

  return () => {
    document.removeEventListener('touchstart', handleTouch)
    document.removeEventListener('wheel', handleWheel)
  }
}, [])

正确:

useEffect(() => {
  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)

  document.addEventListener('touchstart', handleTouch, { passive: true })
  document.addEventListener('wheel', handleWheel, { passive: true })

  return () => {
    document.removeEventListener('touchstart', handleTouch)
    document.removeEventListener('wheel', handleWheel)
  }
}, [])

何时使用被动: 追踪/分析、日志记录、任何不调用 preventDefault() 的监听器。

不要使用被动: 实现自定义滑动手势、自定义缩放控制或任何需要 preventDefault() 的监听器。

4.3 使用 SWR 实现自动去重

影响:中高(自动去重)

SWR 支持跨组件实例的请求去重、缓存和重新验证。

错误:无去重,每个实例独立请求

function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
  }, [])
}

正确:多个实例共享一个请求

import useSWR from 'swr'

function UserList() {
  const { data: users } = useSWR('/api/users', fetcher)
}

对于不可变数据:

import { useImmutableSWR } from '@/lib/swr'

function StaticContent() {
  const { data } = useImmutableSWR('/api/config', fetcher)
}

对于变更操作:

import { useSWRMutation } from 'swr/mutation'

function UpdateButton() {
  const { trigger } = useSWRMutation('/api/user', updateUser)
  return <button onClick={() => trigger()}>Update</button>
}

参考:https://swr.vercel.app

4.4 对 localStorage 数据添加版本号并最小化

影响:中(防止模式冲突,减少存储大小)

为键添加版本前缀,并仅存储所需的字段。防止模式冲突和敏感数据的意外存储。

错误:

// 无版本号,存储所有内容,无错误处理
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
const data = localStorage.getItem('userConfig')

正确:

const VERSION = 'v2'

function saveConfig(config: { theme: string; language: string }) {
  try {
    localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
  } catch {
    // 在无痕/私密浏览、超出配额或存储被禁用时会抛出异常
  }
}

function loadConfig() {
  try {
    const data = localStorage.getItem(`userConfig:${VERSION}`)
    return data ? JSON.parse(data) : null
  } catch {
    return null
  }
}

// 从 v1 迁移到 v2
function migrate() {
  try {
    const v1 = localStorage.getItem('userConfig:v1')
    if (v1) {
      const old = JSON.parse(v1)
      saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
      localStorage.removeItem('userConfig:v1')
    }
  } catch {}
}

从服务端响应中仅存储最小字段:

// User 对象有 20+ 个字段,只存储 UI 所需的内容
function cachePrefs(user: FullUser) {
  try {
    localStorage.setItem('prefs:v1', JSON.stringify({
      theme: user.preferences.theme,
      notifications: user.preferences.notifications
    }))
  } catch {}
}

始终用 try-catch 包裹: getItem()setItem() 在无痕/私密浏览(Safari、Firefox)、超出配额或被禁用时会抛出异常。

好处: 通过版本控制实现模式演进,减小存储大小,防止存储令牌/PII/内部标志。


5. 重渲染优化

影响:中

减少不必要的重渲染可以最小化浪费的计算资源并提高 UI 响应性。

5.1 在渲染期间计算派生状态

影响:中(避免冗余渲染和状态漂移)

如果一个值可以从当前的 props/state 计算得出,则不要将其存储在 state 中或通过 effect 更新它。在渲染期间派生它以避免额外的渲染和状态漂移。不要仅仅为了响应 prop 变化而在 effect 中设置 state;优先使用派生值或基于 key 的重置。

错误:冗余的 state 和 effect

function Form() {
  const [firstName, setFirstName] = useState('First')
  const [lastName, setLastName] = useState('Last')
  const [fullName, setFullName] = useState('')

  useEffect(() => {
    setFullName(firstName + ' ' + lastName)
  }, [firstName, lastName])

  return <p>{fullName}</p>
}

正确:在渲染期间派生

function Form() {
  const [firstName, setFirstName] = useState('First')
  const [lastName, setLastName] = useState('Last')
  const fullName = firstName + ' ' + lastName

  return <p>{fullName}</p>
}

参考:https://react.dev/learn/you-might-not-need-an-effect

5.2 将状态读取推迟到使用点

影响:中(避免不必要的订阅)

如果你只在回调内部读取动态状态(searchParams、localStorage),则不要订阅它。

错误:订阅所有 searchParams 变化

function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams()

  const handleShare = () => {
    const ref = searchParams.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>Share</button>
}

正确:按需读取,无订阅

function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search)
    const ref = params.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>Share</button>
}

5.3 不要为返回基本类型的简单表达式包裹 useMemo

影响:低中(每次渲染浪费计算)

当一个表达式很简单(少量逻辑或算术运算符)且返回基本类型(boolean、number、string)时,不要用 useMemo 包裹它。

调用 useMemo 并比较 hook 依赖所消耗的资源可能超过表达式本身。

错误:

function Header({ user, notifications }: Props) {
  const isLoading = useMemo(() => {
    return user.isLoading || notifications.isLoading
  }, [user.isLoading, notifications.isLoading])

  if (isLoading) return <Skeleton />
  // 返回一些标记
}

正确:

function Header({ user, notifications }: Props) {
  const isLoading = user.isLoading || notifications.isLoading

  if (isLoading) return <Skeleton />
  // 返回一些标记
}

5.4 不要在组件内部定义组件

影响:高(防止每次渲染时重新挂载)

在另一个组件内部定义组件会在每次渲染时创建一个新的组件类型。React 每次都会将其视为不同的组件并完全重新挂载,销毁所有状态和 DOM。

开发者这样做的常见原因是为了在不传递 props 的情况下访问父级变量。务必改为传递 props。

错误:每次渲染都重新挂载

function UserProfile({ user, theme }) {
  // 为访问 `theme` 而在内部定义 —— 不好
  const Avatar = () => (
    <img
      src={user.avatarUrl}
      className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
    />
  )

  // 为访问 `user` 而在内部定义 —— 不好
  const Stats = () => (
    <div>
      <span>{user.followers} followers</span>
      <span>{user.posts} posts</span>
    </div>
  )

  return (
    <div>
      <Avatar />
      <Stats />
    </div>
  )
}

每次 UserProfile 渲染时,AvatarStats 都是新的组件类型。React 会卸载旧实例并挂载新实例,丢失所有内部状态,重新运行 effect,并重新创建 DOM 节点。

正确:改为传递 props

function Avatar({ src, theme }: { src: string; theme: string }) {
  return (
    <img
      src={src}
      className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
    />
  )
}

function Stats({ followers, posts }: { followers: number; posts: number }) {
  return (
    <div>
      <span>{followers} followers</span>
      <span>{posts} posts</span>
    </div>
  )
}

function UserProfile({ user, theme }) {
  return (
    <div>
      <Avatar src={user.avatarUrl} theme={theme} />
      <Stats followers={user.followers} posts={user.posts} />
    </div>
  )
}

此错误的症状:

  • 输入框每次按键时失去焦点

  • 动画意外重新开始

  • useEffect 的清理/设置每次父级渲染时都执行

  • 组件内部的滚动位置重置

5.5 将记忆化组件的默认非基本类型参数提取为常量

影响:中(通过使用常量作为默认值恢复记忆化)

当记忆化组件对某个非基本类型可选参数(如数组、函数或对象)具有默认值时,在不传该参数的情况下调用组件会导致记忆化失效。这是因为每次重渲染都会创建新的值实例,而这些实例无法通过 memo() 的严格相等比较。

要解决此问题,将默认值提取为常量。

错误:每次重渲染时 onClick 的值都不同

const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
  // ...
})

// 不使用可选参数 onClick 时
<UserAvatar />

正确:稳定的默认值

const NOOP = () => {};

const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
  // ...
})

// 不使用可选参数 onClick 时
<UserAvatar />

5.6 提取为记忆化组件

影响:中(支持提前返回)

将昂贵的计算提取到记忆化组件中,以便在计算之前就可以提前返回。

错误:即使在加载时也计算 avatar

function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user)
    return <Avatar id={id} />
  }, [user])

  if (loading) return <Skeleton />
  return <div>{avatar}</div>
}

正确:加载时跳过计算

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user])
  return <Avatar id={id} />
})

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  )
}

注意: 如果你的项目已启用 React Compiler,则无需手动使用 memo()useMemo() 进行记忆化。编译器会自动优化重渲染。

5.7 收窄 Effect 依赖

影响:低(最小化 Effect 的重新执行)

指定基本类型的依赖而非对象,以最小化 Effect 的重新执行。

错误:任何 user 字段变化都会重新执行

useEffect(() => {
  console.log(user.id)
}, [user])

正确:仅在 id 变化时重新执行

useEffect(() => {
  console.log(user.id)
}, [user.id])

对于派生状态,在 effect 外部计算:

// 错误:在 width=767, 766, 765... 时都执行
useEffect(() => {
  if (width < 768) {
    enableMobileMode()
  }
}, [width])

// 正确:仅在布尔值变化时执行
const isMobile = width < 768
useEffect(() => {
  if (isMobile) {
    enableMobileMode()
  }
}, [isMobile])

5.8 将交互逻辑放入事件处理函数

影响:中(避免 Effect 重新执行和重复的副作用)

如果某个副作用是由特定的用户操作(提交、点击、拖拽)触发的,则应在该事件处理函数中执行。不要将操作建模为 state + effect;这会导致 effect 在无关变化时重新执行,并且可能重复执行该操作。

错误:事件建模为 state + effect

function Form() {
  const [submitted, setSubmitted] = useState(false)
  const theme = useContext(ThemeContext)

  useEffect(() => {
    if (submitted) {
      post('/api/register')
      showToast('Registered', theme)
    }
  }, [submitted, theme])

  return <button onClick={() => setSubmitted(true)}>Submit</button>
}

正确:在事件处理函数中执行

function Form() {
  const theme = useContext(ThemeContext)

  function handleSubmit() {
    post('/api/register')
    showToast('Registered', theme)
  }

  return <button onClick={handleSubmit}>Submit</button>
}

参考:https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler

5.9 拆分合并的 Hook 计算

影响:中(避免重新计算独立步骤)

当一个 hook 包含多个具有不同依赖的独立任务时,将它们拆分为单独的 hook。合并的 hook 在任何依赖变化时都会重新运行所有任务,即使某些任务不使用变化的值。

错误:更改 sortOrder 会重新计算过滤

const sortedProducts = useMemo(() => {
  const filtered = products.filter((p) => p.category === category)
  const sorted = filtered.toSorted((a, b) =>
    sortOrder === "asc" ? a.price - b.price : b.price - a.price
  )
  return sorted
}, [products, category, sortOrder])

正确:过滤仅在 products 或 category 变化时重新计算

const filteredProducts = useMemo(
  () => products.filter((p) => p.category === category),
  [products, category]
)

const sortedProducts = useMemo(
  () =>
    filteredProducts.toSorted((a, b) =>
      sortOrder === "asc" ? a.price - b.price : b.price - a.price
    ),
  [filteredProducts, sortOrder]
)

此模式也适用于将不相关的副作用拆分到多个 useEffect 中:

错误:任一依赖变化时两个 effect 都执行

useEffect(() => {
  analytics.trackPageView(pathname)
  document.title = `${pageTitle} | My App`
}, [pathname, pageTitle])

正确:effect 独立执行

useEffect(() => {
  analytics.trackPageView(pathname)
}, [pathname])

useEffect(() => {
  document.title = `${pageTitle} | My App`
}, [pageTitle])

注意: 如果你的项目已启用 React Compiler,它会自动优化依赖追踪,并可能为你处理其中一些情况。

5.10 订阅派生状态

影响:中(减少重渲染频率)

订阅派生的布尔状态而非连续值,以减少重渲染频率。

错误:每次像素变化都重渲染

function Sidebar() {
  const width = useWindowWidth()  // 持续更新
  const isMobile = width < 768
  return <nav className={isMobile ? 'mobile' : 'desktop'} />
}

正确:仅在布尔值变化时重渲染

function Sidebar() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  return <nav className={isMobile ? 'mobile' : 'desktop'} />
}

5.11 使用函数式 setState 更新

影响:中(防止闭包过期和不必要的回调重建)

当基于当前状态值更新状态时,使用 setState 的函数式更新形式,而不是直接引用状态变量。这可以防止闭包过期、消除不必要的依赖,并创建稳定的回调引用。

错误:需要将 state 作为依赖

function TodoList() {
  const [items, setItems] = useState(initialItems)

  // 回调必须依赖 items,每次 items 变化时重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems([...items, ...newItems])
  }, [items])  // ❌ items 依赖导致重建

  // 如果忘记依赖,存在闭包过期的风险
  const removeItem = useCallback((id: string) => {
    setItems(items.filter(item => item.id !== id))
  }, [])  // ❌ 缺少 items 依赖 —— 将使用过期的 items!

  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

第一个回调在每次 items 变化时都重建,这可能导致子组件不必要地重渲染。第二个回调存在闭包过期的错误——它将始终引用初始的 items 值。

正确:稳定的回调,无闭包过期

function TodoList() {
  const [items, setItems] = useState(initialItems)

  // 稳定的回调,永不重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems(curr => [...curr, ...newItems])
  }, [])  // ✅ 无需依赖

  // 始终使用最新的状态,无闭包过期风险
  const removeItem = useCallback((id: string) => {
    setItems(curr => curr.filter(item => item.id !== id))
  }, [])  // ✅ 安全且稳定

  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

好处:

  1. 稳定的回调引用 —— 回调无需在状态变化时重建

  2. 无闭包过期 —— 始终操作最新的状态值

  3. 更少的依赖 —— 简化依赖数组,减少内存泄漏

  4. 防止错误 —— 消除最常见的 React 闭包错误来源

何时使用函数式更新:

  • 任何依赖于当前状态值的 setState

  • 在 useCallback/useMemo 中需要状态时

  • 引用状态的事件处理函数

  • 更新状态的异步操作

何时直接更新即可:

  • 将状态设置为静态值:setCount(0)

  • 仅基于 props/参数设置状态:setName(newName)

  • 状态不依赖于先前值

注意: 如果你的项目已启用 React Compiler,编译器可以自动优化某些情况,但为正确性和防止闭包过期错误,仍建议使用函数式更新。

5.12 使用惰性状态初始化

影响:中(每次渲染浪费计算)

对于昂贵的初始值,向 useState 传递一个函数。不使用函数形式时,初始化器会在每次渲染时都执行,即使该值仅使用一次。

错误:每次渲染都执行

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 在每次渲染时都执行,甚至在初始化之后
  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
  const [query, setQuery] = useState('')

  // 当 query 变化时,buildSearchIndex 再次不必要地执行
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 在每次渲染时都执行
  const [settings, setSettings] = useState(
    JSON.parse(localStorage.getItem('settings') || '{}')
  )

  return <SettingsForm settings={settings} onChange={setSettings} />
}

正确:仅执行一次

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 仅在初始渲染时执行
  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
  const [query, setQuery] = useState('')

  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 仅在初始渲染时执行
  const [settings, setSettings] = useState(() => {
    const stored = localStorage.getItem('settings')
    return stored ? JSON.parse(stored) : {}
  })

  return <SettingsForm settings={settings} onChange={setSettings} />
}

当初始值来自 localStorage/sessionStorage、构建数据结构(索引、映射)、从 DOM 读取或执行重型转换时,使用惰性初始化。

对于简单基本类型(useState(0))、直接引用(useState(props.value))或廉价字面量(useState({})),函数形式是不必要的。

5.13 对非紧急更新使用 Transition

影响:中(保持 UI 响应性)

将频繁的、非紧急的状态更新标记为 Transition,以保持 UI 响应性。

错误:每次滚动都阻塞 UI

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

正确:非阻塞更新

import { startTransition } from 'react'

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => {
      startTransition(() => setScrollY(window.scrollY))
    }
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

5.14 对昂贵的派生渲染使用 useDeferredValue

影响:中(在重型计算期间保持输入响应性)

当用户输入触发昂贵的计算或渲染时,使用 useDeferredValue 保持输入响应性。延迟的值会滞后,允许 React 优先处理输入更新,并在空闲时渲染昂贵的结果。

错误:过滤时输入感觉卡顿

function Search({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('')
  const filtered = items.filter(item => fuzzyMatch(item, query))

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ResultsList results={filtered} />
    </>
  )
}

正确:输入保持流畅,结果在就绪时渲染

function Search({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)
  const filtered = useMemo(
    () => items.filter(item => fuzzyMatch(item, deferredQuery)),
    [items, deferredQuery]
  )
  const isStale = query !== deferredQuery

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <ResultsList results={filtered} />
      </div>
    </>
  )
}

何时使用:

  • 过滤/搜索大型列表

  • 响应输入的昂贵可视化(图表、图形)

  • 任何导致明显渲染延迟的派生状态

注意: 将昂贵的计算包裹在 useMemo 中,并将延迟值作为依赖,否则它仍然会在每次渲染时执行。

参考:https://react.dev/reference/react/useDeferredValue

5.15 对临时值使用 useRef

影响:中(避免频繁更新时的不必要重渲染)

当一个值频繁变化且你不想每次更新都触发重渲染时(例如鼠标追踪器、定时器、临时标志),将其存储在 useRef 中而非 useState 中。将组件状态保留给 UI;对临时 DOM 相关值使用 ref。更新 ref 不会触发重渲染。

错误:每次更新都渲染

function Tracker() {
  const [lastX, setLastX] = useState(0)

  useEffect(() => {
    const onMove = (e: MouseEvent) => setLastX(e.clientX)
    window.addEventListener('mousemove', onMove)
    return () => window.removeEventListener('mousemove', onMove)
  }, [])

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: lastX,
        width: 8,
        height: 8,
        background: 'black',
      }}
    />
  )
}

正确:追踪时不重渲染

function Tracker() {
  const lastXRef = useRef(0)
  const dotRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const onMove = (e: MouseEvent) => {
      lastXRef.current = e.clientX
      const node = dotRef.current
      if (node) {
        node.style.transform = `translateX(${e.clientX}px)`
      }
    }
    window.addEventListener('mousemove', onMove)
    return () => window.removeEventListener('mousemove', onMove)
  }, [])

  return (
    <div
      ref={dotRef}
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        width: 8,
        height: 8,
        background: 'black',
        transform: 'translateX(0px)',
      }}
    />
  )
}

6. 渲染性能

影响:中

优化渲染过程可以减少浏览器所需的工作量。

6.1 动画化 SVG 容器而非 SVG 元素

影响:低(启用硬件加速)

许多浏览器对 SVG 元素上的 CSS3 动画没有硬件加速支持。将 SVG 包裹在 <div> 中,改为动画化容器。

错误:直接动画化 SVG —— 无硬件加速

function LoadingSpinner() {
  return (
    <svg
      className="animate-spin"
      width="24"
      height="24"
      viewBox="0 0 24 24"
    >
      <circle cx="12" cy="12" r="10" stroke="currentColor" />
    </svg>
  )
}

正确:动画化容器 div —— 硬件加速

function LoadingSpinner() {
  return (
    <div className="animate-spin">
      <svg
        width="24"
        height="24"
        viewBox="0 0 24 24"
      >
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  )
}

这适用于所有 CSS 变换和过渡(transformopacitytranslatescalerotate)。容器 div 允许浏览器使用 GPU 加速以实现更流畅的动画。

6.2 对长列表使用 CSS content-visibility

影响:高(更快的初始渲染)

应用 content-visibility: auto 以推迟屏幕外内容的渲染。

CSS

.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

示例:

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="overflow-y-auto h-screen">
      {messages.map(msg => (
        <div key={msg.id} className="message-item">
          <Avatar user={msg.author} />
          <div>{msg.content}</div>
        </div>
      ))}
    </div>
  )
}

对于 1000 条消息,浏览器会跳过约 990 条屏幕外项目的布局/绘制(初始渲染快 10 倍)。

6.3 提升静态 JSX 元素

影响:低(避免重新创建)

将静态 JSX 提取到组件外部以避免重新创建。

错误:每次渲染都重新创建元素

function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />
}

function Container() {
  return (
    <div>
      {loading && <LoadingSkeleton />}
    </div>
  )
}

正确:复用同一个元素

const loadingSkeleton = (
  <div className="animate-pulse h-20 bg-gray-200" />
)

function Container() {
  return (
    <div>
      {loading && loadingSkeleton}
    </div>
  )
}

这对于大型和静态的 SVG 节点尤其有用,它们在每次渲染时重新创建的成本很高。

注意: 如果你的项目已启用 React Compiler,编译器会自动提升静态 JSX 元素并优化组件重渲染,使手动提升变得不必要。

6.4 优化 SVG 精度

影响:低(减小文件大小)

降低 SVG 坐标精度以减小文件大小。最佳精度取决于 viewBox 的大小,但通常应考虑降低精度。

错误:精度过高

<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

正确:保留 1 位小数

<path d="M 10.3 20.8 L 30.9 40.2" />

使用 SVGO 自动化:

npx svgo --precision=1 --multipass icon.svg

6.5 防止水合不匹配而不产生闪烁

影响:中(避免视觉闪烁和水合错误)

当渲染依赖于客户端存储(localStorage、cookies)的内容时,通过注入一个在 React 水合之前同步更新 DOM 的脚本来避免 SSR 中断和水合后闪烁。

错误:破坏 SSR

function ThemeWrapper({ children }: { children: ReactNode }) {
  // localStorage 在服务端不可用 —— 抛出错误
  const theme = localStorage.getItem('theme') || 'light'

  return (
    <div className={theme}>
      {children}
    </div>
  )
}

服务端渲染会因为 localStorage 未定义而失败。

错误:视觉闪烁

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    // 在水合后执行 —— 导致可见的闪烁
    const stored = localStorage.getItem('theme')
    if (stored) {
      setTheme(stored)
    }
  }, [])

  return (
    <div className={theme}>
      {children}
    </div>
  )
}

组件首先使用默认值(light)渲染,然后在水合后更新,导致错误内容的可见闪烁。

正确:无闪烁,无水合不匹配

function ThemeWrapper({ children }: { children: ReactNode }) {
  return (
    <>
      <div id="theme-wrapper">
        {children}
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                var theme = localStorage.getItem('theme') || 'light';
                var el = document.getElementById('theme-wrapper');
                if (el) el.className = theme;
              } catch (e) {}
            })();
          `,
        }}
      />
    </>
  )
}

内联脚本在显示元素之前同步执行,确保 DOM 已经具有正确的值。无闪烁,无水合不匹配。

此模式对于主题切换、用户偏好、身份验证状态以及任何应在不闪烁默认值的情况下立即渲染的仅客户端数据尤其有用。

6.6 抑制预期内的水合不匹配

影响:低中(避免已知差异产生的水合警告噪音)

在 SSR 框架(如 Next.js)中,某些值在服务端和客户端有意不同(随机 ID、日期、区域设置/时区格式化)。对于这些预期内的不匹配,将动态文本包裹在带有 suppressHydrationWarning 的元素中,以防止产生烦人的警告。不要用它来隐藏真正的错误。不要过度使用。

错误:已知不匹配的警告

function Timestamp() {
  return <span>{new Date().toLocaleString()}</span>
}

正确:仅抑制预期的不匹配

function Timestamp() {
  return (
    <span suppressHydrationWarning>
      {new Date().toLocaleString()}
    </span>
  )
}

6.7 使用 Activity 组件控制显示/隐藏

影响:中(保留状态/DOM

使用 React 的 <Activity> 来为频繁切换可见性的昂贵组件保留状态和 DOM。

用法:

import { Activity } from 'react'

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? 'visible' : 'hidden'}>
      <ExpensiveMenu />
    </Activity>
  )
}

避免昂贵的重渲染和状态丢失。

6.8 在脚本标签上使用 defer 或 async

影响:高(消除渲染阻塞)

没有 deferasync 的脚本标签会在脚本下载和执行期间阻塞 HTML 解析。这会延迟首次内容绘制和交互时间。

  • defer:并行下载,在 HTML 解析完成后执行,保持执行顺序

  • async:并行下载,准备就绪后立即执行,不保证顺序

对于依赖 DOM 或其他脚本的脚本使用 defer。对于分析等独立脚本使用 async

错误:阻塞渲染

export default function Document() {
  return (
    <html>
      <head>
        <script src="https://example.com/analytics.js" />
        <script src="/scripts/utils.js" />
      </head>
      <body>{/* 内容 */}</body>
    </html>
  )
}

正确:非阻塞

import Script from 'next/script'

export default function Page() {
  return (
    <>
      <Script src="https://example.com/analytics.js" strategy="afterInteractive" />
      <Script src="/scripts/utils.js" strategy="beforeInteractive" />
    </>
  )
}

注意: 在 Next.js 中,优先使用带有 strategy prop 的 next/script 组件,而非原生 script 标签。

参考:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer

6.9 使用显式条件渲染

影响:低(防止渲染 0 或 NaN

当条件可能为 0NaN 或其他会渲染为文本的假值时,使用显式三元运算符(? :)而非 && 进行条件渲染。

错误:当 count 为 0 时渲染 "0"

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count && <span className="badge">{count}</span>}
    </div>
  )
}

// 当 count = 0 时,渲染:<div>0</div>
// 当 count = 5 时,渲染:<div><span class="badge">5</span></div>

正确:当 count 为 0 时不渲染任何内容

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count > 0 ? <span className="badge">{count}</span> : null}
    </div>
  )
}

// 当 count = 0 时,渲染:<div></div>
// 当 count = 5 时,渲染:<div><span class="badge">5</span></div>

6.10 使用 React DOM 资源提示

影响:高(减少关键资源的加载时间)

React DOM 提供了向浏览器提示它将需要的资源的 API。这些在服务端组件中尤其有用,可以在客户端收到 HTML 之前就开始加载资源。

  • prefetchDNS(href):为你期望连接的域名解析 DNS

  • preconnect(href):建立到服务器的连接(DNS + TCP + TLS

  • preload(href, options):获取你即将使用的资源(样式表、字体、脚本、图片)

  • preloadModule(href):获取你即将使用的 ES 模块

  • preinit(href, options):获取并求值样式表或脚本

  • preinitModule(href):获取并求值 ES 模块

示例:预连接到第三方 API

import { preconnect, prefetchDNS } from 'react-dom'

export default function App() {
  prefetchDNS('https://analytics.example.com')
  preconnect('https://api.example.com')

  return <main>{/* 内容 */}</main>
}

示例:预加载关键字体和样式

import { preload, preinit } from 'react-dom'

export default function RootLayout({ children }) {
  // 预加载字体文件
  preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })

  // 立即获取并应用关键样式表
  preinit('/styles/critical.css', { as: 'style' })

  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

示例:为代码分割路由预加载模块

import { preloadModule, preinitModule } from 'react-dom'

function Navigation() {
  const preloadDashboard = () => {
    preloadModule('/dashboard.js', { as: 'script' })
  }

  return (
    <nav>
      <a href="/dashboard" onMouseEnter={preloadDashboard}>
        Dashboard
      </a>
    </nav>
  )
}

何时使用每种方法:

| API | 使用场景 |

|-----|----------|

| prefetchDNS | 稍后要连接的第三方域名 |

| preconnect | 将要立即请求的 API 或 CDN |

| preload | 当前页面所需的关键资源 |

| preloadModule | 可能下一次导航的 JS 模块 |

| preinit | 必须尽早执行的样式表/脚本 |

| preinitModule | 必须尽早执行的 ES 模块 |

参考:https://react.dev/reference/react-dom#resource-preloading-apis

6.11 使用 useTransition 替代手动加载状态

影响:低(减少重渲染并提高代码清晰度)

使用 useTransition 替代手动的 useState 来管理加载状态。它提供内置的 isPending 状态并自动管理过渡。

错误:手动的加载状态

function SearchResults() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isLoading, setIsLoading] = useState(false)

  const handleSearch = async (value: string) => {
    setIsLoading(true)
    setQuery(value)
    const data = await fetchResults(value)
    setResults(data)
    setIsLoading(false)
  }

  return (
    <>
      <input onChange={(e) => handleSearch(e.target.value)} />
      {isLoading && <Spinner />}
      <ResultsList results={results} />
    </>
  )
}

正确:使用 useTransition 并内置 pending 状态

import { useTransition, useState } from 'react'

function SearchResults() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()

  const handleSearch = (value: string) => {
    setQuery(value) // 立即更新输入

    startTransition(async () => {
      // 获取并更新结果
      const data = await fetchResults(value)
      setResults(data)
    })
  }

  return (
    <>
      <input onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  )
}

好处:

  • 自动的 pending 状态:无需手动管理 setIsLoading(true/false)

  • 错误恢复能力:即使 transition 抛出异常,pending 状态也能正确重置

  • 更好的响应性:在更新期间保持 UI 响应

  • 中断处理:新的 transition 会自动取消 pending 中的 transition

参考:https://react.dev/reference/react/useTransition


7. JavaScript 性能

影响:低中

热路径上的微优化可以累积成有意义的改进。

7.1 避免布局抖动

影响:中(防止强制同步布局,减少性能瓶颈)

避免将样式写入与布局读取交错进行。当你在样式更改之间读取布局属性(如 offsetWidthgetBoundingClientRect()getComputedStyle())时,浏览器被迫触发同步回流。

这是可以的:浏览器批量处理样式更改

function updateElementStyles(element: HTMLElement) {
  // 每行都使样式失效,但浏览器会批量处理重新计算
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'
}

错误:交错的读写强制回流

function layoutThrashing(element: HTMLElement) {
  element.style.width = '100px'
  const width = element.offsetWidth  // 强制回流
  element.style.height = '200px'
  const height = element.offsetHeight  // 再次强制回流
}

正确:批量写入,然后一次性读取

function updateElementStyles(element: HTMLElement) {
  // 将所有写入批处理在一起
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'

  // 在所有写入完成后读取(单次回流)
  const { width, height } = element.getBoundingClientRect()
}

正确:批量读取,然后写入

function updateElementStyles(element: HTMLElement) {
  element.classList.add('highlighted-box')

  const { width, height } = element.getBoundingClientRect()
}

更好:使用 CSS 类

React 示例:

// 错误:样式更改与布局查询交错
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (ref.current && isHighlighted) {
      ref.current.style.width = '100px'
      const width = ref.current.offsetWidth // 强制布局
      ref.current.style.height = '200px'
    }
  }, [isHighlighted])

  return <div ref={ref}>Content</div>
}

// 正确:切换类
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  return (
    <div className={isHighlighted ? 'highlighted-box' : ''}>
      Content
    </div>
  )
}

尽可能优先使用 CSS 类而非内联样式。CSS 文件会被浏览器缓存,类提供了更好的关注点分离且更易于维护。

更多关于强制布局操作的信息,请参见此 gistCSS Triggers

7.2 为重复查找构建索引映射

影响:低中(100 万次操作降至 2 千次操作)

对同一键的多次 .find() 调用应使用 Map。

错误(每次查找 O(n)):

function processOrders(orders: Order[], users: User[]) {
  return orders.map(order => ({
    ...order,
    user: users.find(u => u.id === order.userId)
  }))
}

正确(每次查找 O(1)):

function processOrders(orders: Order[], users: User[]) {
  const userById = new Map(users.map(u => [u.id, u]))

  return orders.map(order => ({
    ...order,
    user: userById.get(order.userId)
  }))
}

构建 Map 一次(O(n)),然后所有查找都是 O(1)。

对于 1000 个订单 × 1000 个用户:100 万次操作 → 2 千次操作。

7.3 在循环中缓存属性访问

影响:低中(减少查找次数)

在热路径中缓存对象属性查找。

错误:N 次迭代 × 3 次查找

for (let i = 0; i < arr.length; i++) {
  process(obj.config.settings.value)
}

正确:总共 1 次查找

const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
  process(value)
}

7.4 缓存重复的函数调用

影响:中(避免冗余计算)

当同一函数在渲染期间以相同输入被重复调用时,使用模块级别的 Map 来缓存函数结果。

错误:冗余计算

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // slugify() 对相同的项目名称被调用 100+ 次
        const slug = slugify(project.name)

        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

正确:缓存结果

// 模块级别缓存
const slugifyCache = new Map<string, string>()

function cachedSlugify(text: string): string {
  if (slugifyCache.has(text)) {
    return slugifyCache.get(text)!
  }
  const result = slugify(text)
  slugifyCache.set(text, result)
  return result
}

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // 每个唯一项目名称只计算一次
        const slug = cachedSlugify(project.name)

        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

单值函数的更简单模式:

let isLoggedInCache: boolean | null = null

function isLoggedIn(): boolean {
  if (isLoggedInCache !== null) {
    return isLoggedInCache
  }

  isLoggedInCache = document.cookie.includes('auth=')
  return isLoggedInCache
}

// 在 auth 变化时清除缓存
function onAuthChange() {
  isLoggedInCache = null
}

使用 Map(而非 hook),以便它可以在任何地方工作:工具函数、事件处理函数,不仅仅是 React 组件。

参考:https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast

7.5 缓存 Storage API 调用

影响:低中(减少昂贵的 I/O

localStoragesessionStoragedocument.cookie 是同步且昂贵的。在内存中缓存读取结果。

错误:每次调用都读取存储

function getTheme() {
  return localStorage.getItem('theme') ?? 'light'
}
// 调用 10 次 = 10 次存储读取

正确:Map 缓存

const storageCache = new Map<string, string | null>()

function getLocalStorage(key: string) {
  if (!storageCache.has(key)) {
    storageCache.set(key, localStorage.getItem(key))
  }
  return storageCache.get(key)
}

function setLocalStorage(key: string, value: string) {
  localStorage.setItem(key, value)
  storageCache.set(key, value)  // 保持缓存同步
}

使用 Map(而非 hook),以便它可以在任何地方工作:工具函数、事件处理函数,不仅仅是 React 组件。

Cookie 缓存:

let cookieCache: Record<string, string> | null = null

function getCookie(name: string) {
  if (!cookieCache) {
    cookieCache = Object.fromEntries(
      document.cookie.split('; ').map(c => c.split('='))
    )
  }
  return cookieCache[name]
}

重要:在外部变化时使缓存失效

window.addEventListener('storage', (e) => {
  if (e.key) storageCache.delete(e.key)
})

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    storageCache.clear()
  }
})

如果存储可能从外部发生变化(另一个标签页、服务端设置的 cookie),使缓存失效。

7.6 合并多次数组迭代

影响:低中(减少迭代次数)

多次 .filter().map() 调用会多次遍历数组。合并为一次循环。

错误:3 次迭代

const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)

正确:1 次迭代

const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []

for (const user of users) {
  if (user.isAdmin) admins.push(user)
  if (user.isTester) testers.push(user)
  if (!user.isActive) inactive.push(user)
}

7.7 使用 requestIdleCallback 推迟非关键工作

影响:中(在后台任务期间保持 UI 响应)

使用 requestIdleCallback() 在浏览器空闲期间安排非关键工作。这使主线程可以自由处理用户交互和动画,减少卡顿并改善感知性能。

错误:在用户交互期间阻塞主线程

function handleSearch(query: string) {
  const results = searchItems(query)
  setResults(results)

  // 这些立即阻塞主线程
  analytics.track('search', { query })
  saveToRecentSearches(query)
  prefetchTopResults(results.slice(0, 3))
}

正确:将非关键工作推迟到空闲时间

function handleSearch(query: string) {
  const results = searchItems(query)
  setResults(results)

  // 将非关键工作推迟到空闲时段
  requestIdleCallback(() => {
    analytics.track('search', { query })
  })

  requestIdleCallback(() => {
    saveToRecentSearches(query)
  })

  requestIdleCallback(() => {
    prefetchTopResults(results.slice(0, 3))
  })
}

带超时的必需工作:

// 确保分析在 2 秒内触发,即使浏览器一直忙碌
requestIdleCallback(
  () => analytics.track('page_view', { path: location.pathname }),
  { timeout: 2000 }
)

分块处理大型任务:

function processLargeDataset(items: Item[]) {
  let index = 0

  function processChunk(deadline: IdleDeadline) {
    // 在有空闲时间时处理项目(目标:每块 <50ms)
    while (index < items.length && deadline.timeRemaining() > 0) {
      processItem(items[index])
      index++
    }

    // 如果还有更多项目,安排下一块
    if (index < items.length) {
      requestIdleCallback(processChunk)
    }
  }

  requestIdleCallback(processChunk)
}

为不支持此 API 的浏览器提供回退:

const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))

scheduleIdleWork(() => {
  // 非关键工作
})

何时使用:

  • 分析和遥测

  • 将状态保存到 localStorage/IndexedDB

  • 为可能的下一个操作预取资源

  • 处理非紧急的数据转换

  • 非关键功能的惰性初始化

何时不应使用:

  • 需要即时反馈的用户操作

  • 用户正在等待的渲染更新

  • 时间敏感的操作

7.8 数组比较时先检查长度

影响:中高(长度不同时避免昂贵的操作)

当比较需要昂贵操作的数组(排序、深度相等、序列化)时,先检查长度。如果长度不同,则数组不可能相等。

在实际应用中,当比较在热路径(事件处理函数、渲染循环)中运行时,此优化尤其有价值。

错误:始终运行昂贵的比较

function hasChanges(current: string[], original: string[]) {
  // 即使长度不同,也总是排序和连接
  return current.sort().join() !== original.sort().join()
}

即使 current.length 为 5 而 original.length 为 100,也会执行两次 O(n log n) 的排序。还有连接数组和比较字符串的开销。

正确(先进行 O(1) 长度检查):

function hasChanges(current: string[], original: string[]) {
  // 如果长度不同,提前返回
  if (current.length !== original.length) {
    return true
  }
  // 仅在长度相同时排序
  const currentSorted = current.toSorted()
  const originalSorted = original.toSorted()
  for (let i = 0; i < currentSorted.length; i++) {
    if (currentSorted[i] !== originalSorted[i]) {
      return true
    }
  }
  return false
}

这种新方法更高效,因为:

  • 它在长度不同时避免了排序和连接数组的开销

  • 它避免了为连接的字符串消耗内存(对大型数组尤其重要)

  • 它避免了修改原始数组

  • 它在发现差异时提前返回

7.9 函数尽早返回

影响:低中(避免不必要的计算)

当结果已经确定时尽早返回,以跳过不必要的处理。

错误:即使在找到答案后仍处理所有项目

function validateUsers(users: User[]) {
  let hasError = false
  let errorMessage = ''

  for (const user of users) {
    if (!user.email) {
      hasError = true
      errorMessage = 'Email required'
    }
    if (!user.name) {
      hasError = true
      errorMessage = 'Name required'
    }
    // 即使在找到错误后仍继续检查所有用户
  }

  return hasError ? { valid: false, error: errorMessage } : { valid: true }
}

正确:在第一个错误时立即返回

function validateUsers(users: User[]) {
  for (const user of users) {
    if (!user.email) {
      return { valid: false, error: 'Email required' }
    }
    if (!user.name) {
      return { valid: false, error: 'Name required' }
    }
  }

  return { valid: true }
}

7.10 提升 RegExp 创建

影响:低中(避免重复创建)

不要在渲染内部创建 RegExp。提升到模块作用域或使用 useMemo() 记忆化。

错误:每次渲染都创建新的 RegExp

function Highlighter({ text, query }: Props) {
  const regex = new RegExp(`(${query})`, 'gi')
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

正确:记忆化或提升

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function Highlighter({ text, query }: Props) {
  const regex = useMemo(
    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
    [query]
  )
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

警告:全局正则表达式具有可变状态

const regex = /foo/g
regex.test('foo')  // true, lastIndex = 3
regex.test('foo')  // false, lastIndex = 0

全局正则表达式(/g)具有可变的 lastIndex 状态。

7.11 使用 flatMap 一次性完成映射与过滤

影响:低中(消除中间数组)

链式调用 .map().filter(Boolean) 会创建一个中间数组并迭代两次。使用 .flatMap() 在一次遍历中完成转换和过滤。

错误:2 次迭代,中间数组

const userNames = users
  .map(user => user.isActive ? user.name : null)
  .filter(Boolean)

正确:1 次迭代,无中间数组

const userNames = users.flatMap(user =>
  user.isActive ? [user.name] : []
)

更多示例:

// 从响应中提取有效邮箱
// 之前
const emails = responses
  .map(r => r.success ? r.data.email : null)
  .filter(Boolean)

// 之后
const emails = responses.flatMap(r =>
  r.success ? [r.data.email] : []
)

// 解析并过滤有效数字
// 之前
const numbers = strings
  .map(s => parseInt(s, 10))
  .filter(n => !isNaN(n))

// 之后
const numbers = strings.flatMap(s => {
  const n = parseInt(s, 10)
  return isNaN(n) ? [] : [n]
})

何时使用:

  • 在过滤掉某些项目的同时进行转换

  • 某些输入不产生输出的条件映射

  • 应跳过无效输入的解析/验证

7.12 使用循环求最小/最大值而非排序

影响:低(O(n) 而非 O(n log n)

查找最小或最大元素只需要对数组进行一次遍历。排序既浪费又慢。

错误(O(n log n)——排序以找到最新的):

interface Project {
  id: string
  name: string
  updatedAt: number
}

function getLatestProject(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
  return sorted[0]
}

仅仅为了找到最大值就对整个数组进行排序。

错误(O(n log n)——排序以找到最旧和最新):

function getOldestAndNewest(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}

当只需要最小/最大值时仍然不必要地排序。

正确(O(n)——单次循环):

function getLatestProject(projects: Project[]) {
  if (projects.length === 0) return null

  let latest = projects[0]

  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt > latest.updatedAt) {
      latest = projects[i]
    }
  }

  return latest
}

function getOldestAndNewest(projects: Project[]) {
  if (projects.length === 0) return { oldest: null, newest: null }

  let oldest = projects[0]
  let newest = projects[0]

  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
  }

  return { oldest, newest }
}

单次遍历数组,无需复制,无需排序。

替代方案:对小数组使用 Math.min/Math.max

const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)

这适用于小数组,但对于非常大的数组,由于展开运算符的限制,可能更慢或直接抛出错误。在 Chrome 143 中最大数组长度约为 124000,在 Safari 18 中约为 638000;确切数字可能有所不同——请参见此 fiddle。为了可靠性,使用循环方法。

7.13 使用 Set/Map 实现 O(1) 查找

影响:低中(从 O(n) 到 O(1))

将数组转换为 Set/Map 以进行重复的成员资格检查。

错误(每次检查 O(n)):

const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))

正确(每次检查 O(1)):

const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))

7.14 使用 toSorted() 替代 sort() 以保持不可变性

影响:中高(防止 React 状态中的突变错误)

.sort() 会原地修改数组,这可能导致 React 状态和 props 出现错误。使用 .toSorted() 创建一个新的排序数组,而不改变原数组。

错误:修改原始数组

function UserList({ users }: { users: User[] }) {
  // 修改了 users prop 数组!
  const sorted = useMemo(
    () => users.sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

正确:创建新数组

function UserList({ users }: { users: User[] }) {
  // 创建新的排序数组,原数组不变
  const sorted = useMemo(
    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

为什么这在 React 中很重要:

  1. Props/state 的突变会破坏 React 的不可变性模型——React 期望 props 和 state 被视为只读

  2. 导致闭包过期错误——在闭包(回调、effect)内部修改数组可能导致意外行为

浏览器支持:旧版浏览器的回退

// 旧版浏览器的回退
const sorted = [...items].sort((a, b) => a.value - b.value)

.toSorted() 在所有现代浏览器中可用(Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+)。对于较旧的环境,使用展开运算符。

其他不可变数组方法:

  • .toSorted() —— 不可变排序

  • .toReversed() —— 不可变反转

  • .toSpliced() —— 不可变拼接

  • .with() —— 不可变元素替换


8. 高级模式

影响:低

针对特定情况的高级模式,需要谨慎实现。

8.1 不要将 Effect Event 放入依赖数组

影响:低(避免不必要的 Effect 重执行和 lint 错误)

Effect Event 函数不具有稳定的标识。它们的标识有意在每次渲染时改变。不要将 useEffectEvent 返回的函数包含在 useEffect 的依赖数组中。将实际的响应式值保留为依赖,并从 effect 体或该 effect 创建的订阅中调用 Effect Event。

错误:Effect Event 被添加为依赖

import { useEffect, useEffectEvent } from 'react'

function ChatRoom({ roomId, onConnected }: {
  roomId: string
  onConnected: () => void
}) {
  const handleConnected = useEffectEvent(onConnected)

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', handleConnected)
    connection.connect()

    return () => connection.disconnect()
  }, [roomId, handleConnected])
}

将 Effect Event 包含在依赖中会导致 effect 在每次渲染时都重新执行,并触发 React Hooks lint 规则。

正确:依赖响应式值,而非 Effect Event

import { useEffect, useEffectEvent } from 'react'

function ChatRoom({ roomId, onConnected }: {
  roomId: string
  onConnected: () => void
}) {
  const handleConnected = useEffectEvent(onConnected)

  useEffect(() => {
    const connection = createConnection(roomId)
    connection.on('connected', handleConnected)
    connection.connect()

    return () => connection.disconnect()
  }, [roomId])
}

参考:https://react.dev/reference/react/useEffectEvent#effect-event-in-deps

8.2 应用初始化一次,而非每次挂载

影响:低中(避免开发环境中的重复初始化)

不要在组件的 useEffect([]) 中放置必须在每次应用加载时运行一次的全局初始化。组件可能会重新挂载,effect 也会重新执行。改为使用模块级别的守卫或在入口模块中进行顶层初始化。

错误:在开发环境中执行两次,重新挂载时再次执行

function Comp() {
  useEffect(() => {
    loadFromStorage()
    checkAuthToken()
  }, [])

  // ...
}

正确:每次应用加载执行一次

let didInit = false

function Comp() {
  useEffect(() => {
    if (didInit) return
    didInit = true
    loadFromStorage()
    checkAuthToken()
  }, [])

  // ...
}

参考:https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application

8.3 将事件处理函数存储在 Refs 中

影响:低(稳定的订阅)

当回调在不应因回调变化而重新订阅的 effect 中使用时,将其存储在 ref 中。

错误:每次渲染都重新订阅

function useWindowEvent(event: string, handler: (e) => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

正确:稳定的订阅

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: (e) => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

替代方案:如果你使用的是最新版 React,可以使用 useEffectEvent

useEffectEvent 为相同的模式提供了更清晰的 API:它创建一个始终调用最新版本处理函数的稳定函数引用。

8.4 使用 useEffectEvent 实现稳定的回调引用

影响:低(防止 Effect 重执行)

在回调中访问最新值,而无需将它们添加到依赖数组中。防止 effect 重执行,同时避免闭包过期。

错误:每次回调变化时 effect 重执行

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

正确:使用 React 的 useEffectEvent

import { useEffectEvent } from 'react';

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchEvent = useEffectEvent(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchEvent(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

参考

  1. https://react.dev
  2. https://nextjs.org
  3. https://swr.vercel.app
  4. https://github.com/shuding/better-all
  5. https://github.com/isaacs/node-lru-cache
  6. https://vercel.com/blog/how-we-optimized-package-imports-in-next-js
  7. https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast