阅读视图

发现新文章,点击刷新页面。

Vercel React 最佳实践 中文版

React 最佳实践

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

注意:
本文档主要供 Agent 和 LLM 在 Vercel 维护、生成或重构 React 及 Next.js 代码库时遵循。人类开发者也会发现其对于保持一致性和自动化优化非常有帮助。


摘要

这是一份针对 React 和 Next.js 应用程序的综合性能优化指南,专为 AI Agent 和 LLM 设计。包含 8 个类别的 40 多条规则,按影响力从关键(消除瀑布流、减少打包体积)到增量(高级模式)排序。每条规则都包含详细的解释、错误与正确实现的真实代码对比,以及具体的影响指标,以指导自动重构和代码生成。


目录

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

1. 消除瀑布流

影响力: 关键

瀑布流(Waterfalls)是头号性能杀手。每一个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的收益。

1.1 推迟 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)
}

当被跳过的分支经常被执行,或者被推迟的操作非常昂贵时,这种优化通过尤为有价值。

1.2 基于依赖的并行化

影响力: 关键 (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)
  }
})

参考: github.com/shuding/bet…

1.3 防止 API 路由中的瀑布链

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

在 API 路由和 Server Actions 中,即使此时还不 await 它们,也要立即启动独立的操作。

错误:config 等待 auth,data 等待两者

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.4 对独立操作使用 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.5 策略性 Suspense 边界

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

不要在异步组件中等待数据后再返回 JSX,而是使用 Suspense 边界在数据加载时更快地显示包装器 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>
  )
}

即便只有中间部分需要数据,整个布局也会等待数据。

正确:包装器立即显示,数据流式传输

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,因此只会进行一次获取。布局立即渲染,而两个组件一起等待。

何时不使用此模式:

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

  • 首屏(Above the fold)的 SEO 关键内容

  • Suspense 开销不值得的小型快速查询

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

权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的 UX 优先级进行选择。


2. 打包体积优化

影响力: 关键

减少初始打包体积可以改善可交互时间 (TTI) 和最大内容绘制 (LCP)。

2.1 避免 Barrel 文件导入

影响力: 关键 (200-800ms 导入成本, 缓慢的构建)

直接从源文件导入而不是从 Barrel 文件导入,以避免加载数千个未使用的模块。Barrel 文件是重新导出多个模块的入口点(例如,执行 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

正确:仅导入你需要的内容

import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// 仅加载 3 个模块 (~2KB vs ~1MB)

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

替代方案:Next.js 13.5+

// next.config.js - 使用 optimizePackageImports
module.exports = {
  experimental: {
    optimizePackageImports: ['lucide-react', '@mui/material']
  }
}

// 这样你可以保留符合人体工程学的 Barrel 导入:
import { Check, X, Menu } from 'lucide-react'
// 在构建时自动转换为直接导入

直接导入可提供 15-70% 更快的开发启动速度,28% 更快的构建速度,40% 更快的冷启动速度,以及显著更快的 HMR。

受影响的常见库:lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use

参考: vercel.com/blog/how-we…

2.2 条件模块加载

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

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

例子:懒加载动画帧

function AnimationPlayer({ enabled }: { enabled: 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])

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

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

2.3 推迟非关键第三方库

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

分析、日志记录和错误跟踪不会阻塞用户交互。应当在水合(Hydration)之后加载它们。

错误:阻塞初始包

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 随主 chunk 打包 ~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 基于用户意图预加载

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

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

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

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

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      打开编辑器
    </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 跨请求 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: DB 查询, 结果被缓存
// 请求 2: 缓存命中, 无 DB 查询

当顺序的用户操作在几秒钟内命中多个需要相同数据的端点时,请使用此方法。

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

在传统 Serverless 中: 每次调用都是隔离运行的,因此请考虑使用 Redis 进行跨进而缓存。

参考: github.com/isaacs/node…

3.2 最小化 RSC 边界序列化

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

React Server/Client 边界会将所有对象属性序列化为字符串,并将它们嵌入到 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.3 通过组件组合并行获取数据

影响力: 关键 (消除服务端瀑布流)

React Server Components 在树中顺序执行。使用组合重构以并行化数据获取。

错误: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 Layout({ children }: { children: ReactNode }) {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      {children}
    </div>
  )
}

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

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

3.4 使用 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() 的多次调用只会执行一次查询。

3.5 使用 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 Actions、Route Handlers 和 Server Components

参考: nextjs.org/docs/app/ap…


4. 客户端数据获取

影响力: 中高

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

4.1 去重全局事件监听器

影响力: 低 (N 个组件共用单个监听器)

使用 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 钩子时,每个实例都会注册一个新的监听器。

正确: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 使用 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)
}

对于变异 (Mutations):

import { useSWRMutation } from 'swr/mutation'

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

参考: swr.vercel.app


5. 重渲染优化

影响力: 中

减少不必要的重渲染可最大限度地减少浪费的计算并提高 UI 响应能力。

5.1 推迟状态读取到使用点

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

如果你只在回调中读取动态状态(搜索参数、localStorage),则不要订阅它。

错误:订阅所有 searchParams 更改

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

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

  return <button onClick={handleShare}>分享</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}>分享</button>
}

5.2 提取为记忆化组件

影响力: 中 (启用提前返回)

将昂贵的工作提取到记忆化 (memoized) 组件中,以便在计算及以前提前返回。

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

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.3 缩小 Effect 依赖范围

影响力: 低 (最小化 effect 重新运行)

指定原始值依赖项而不是对象,以最大限度地减少 effect 的重新运行。

错误:在任何用户字段更改时重新运行

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.4 订阅派生状态

影响力: 中 (降低重渲染频率)

订阅派生的布尔状态而不是连续值,以降低重渲染频率。

错误:在每个像素变化时重渲染

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.5 使用函数式 setState 更新

影响力: 中 (防止闭包陷阱和不必要的回调重建)

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

错误:需要 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 更改时都会重建,这可能会导致子组件不必要地重渲染。第二个回调有一个闭包陷阱 bug——它将始终引用初始的 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 闭包 bug 的最常见来源

何时使用函数式更新:

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

  • 在需要 state 的 useCallback/useMemo 内部

  • 引用 state 的事件处理程序

  • 更新 state 的异步操作

何时直接更新是可以的:

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

  • 仅从 props/参数设置 state:setName(newName)

  • State 不依赖于先前的值

注意: 如果你的项目启用了 React Compiler,编译器可以自动优化某些情况,但仍建议使用函数式更新以确保证正确性并防止闭包陷阱 bug。

5.6 使用惰性状态初始化

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

将函数传递给 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 计算初始值、构建数据结构(索引、Map)、从 DOM 读取或执行繁重的转换是,请使用惰性初始化。

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

5.7 对非紧急更新使用 Transitions

影响力: 中 (保持 UI 响应及)

将频繁的、非紧急的状态更新标记为 transitions,以保持 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)
  }, [])
}

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 变换和过渡(transform, opacity, translate, scale, rotate)。包装器 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 使用 Activity 组件进行显示/隐藏

影响力: 中 (保留状态/DOM)

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

用法:

import { Activity } from 'react'

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

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

6.7 使用显式条件渲染

影响力: 低 (防止渲染 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>

7. JavaScript 性能

影响力: 中低

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

7.1 批量 DOM CSS 更改

影响力: 中 (减少重排/重绘)

避免通过一次修改一个属性的方式更改样式。通过类或 cssText 将多个 CSS 更改组合在一起,以最大程度地减少浏览器重排 (reflows)。

错误:多次重排

function updateElementStyles(element: HTMLElement) {
  // 每一行都会触发一次重排
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'
}

正确:添加类 - 单次重排

// CSS 文件
.highlighted-box {
  width: 100px;
  height: 200px;
  background-color: blue;
  border: 1px solid black;
}

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

正确:改变 cssText - 单次重排

function updateElementStyles(element: HTMLElement) {
  element.style.cssText = `
    width: 100px;
    height: 200px;
    background-color: blue;
    border: 1px solid black;
  `
}

React 例子:

// 错误:逐个更改样式
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  const ref = useRef<HTMLDivElement>(null)
  
  useEffect(() => {
    if (ref.current && isHighlighted) {
      ref.current.style.width = '100px'
      ref.current.style.height = '200px'
      ref.current.style.backgroundColor = 'blue'
    }
  }, [isHighlighted])
  
  return <div ref={ref}>内容</div>
}

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

尽可能使用 CSS 类而不是内联样式。类会被浏览器缓存,并提供更好的关注点分离。

7.2 为重复查找构建索引 Map

影响力: 中低 (1M 操作 -> 2K 操作)

同一键的多次 .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万次操作 → 2000 次操作。

7.3 在循环中缓存属性访问

影响力: 中低 (减少查找)

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

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

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
}

// 身份验证更改时清除缓存
function onAuthChange() {
  isLoggedInCache = null
}

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

参考: vercel.com/blog/how-we…

7.5 缓存 Storage API 调用

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

localStorage, sessionStoragedocument.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 数组比较前先检查长度

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

在通过昂贵操作(排序、深度相等、序列化)比较数组时,先检查长度。如果长度不同,数组就不可能相等。

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

错误:总是运行昂贵的比较

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.8 函数提前返回

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

确当定结果时提前返回,以跳过不必要的处理。

错误:即使找到答案也处理所有项目

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.9 提升 RegExp 创建

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

不要在 render 内部创建 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) => ...)}</>
}

警告:全局 regex 具有可变状态

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

全局 regex (/g) 具有可变的 lastIndex 状态。

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

影响力: 低 (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)

这对于小数组有效,但对于非常大的数组,由于展开运算符的限制,可能会更慢。为了可靠性,建议使用循环方法。

7.11 使用 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.12 使用 toSorted() 代替 sort() 以保证不可变性

影响力: 中高 (防止 React 状态中的变异 bug)

.sort() 会原地修改数组,这可能会导致 React 状态和 props 出现 bug。使用 .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. 导致闭包陷阱 bug - 在闭包(回调、effects)内修改数组可能导致意外行为

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

// 旧版浏览器的回退
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 在 Refs 中存储事件处理程序

影响力: 低 (稳定的订阅)

当在不应因回调更改而重新订阅的 effect 中使用时,将回调存储在 refs 中。

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

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

正确:稳定的订阅

import { useEffectEvent } from 'react'

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

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

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

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

8.2 使用 useLatest 获取稳定的回调 Refs

影响力: 低 (防止 effect 重新运行)

在不将值添加到依赖数组的情况下访问回调中的最新值。防止 effect 重新运行,同时避免闭包陷阱。

实现:

function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

错误:在每次回调更改时重新运行 effect

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

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

正确:稳定的 effect,新鲜的回调

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

  useEffect(() => {
    const timeout = setTimeout(() => onSearchRef.current(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

参考资料

  1. react.dev
  2. nextjs.org
  3. swr.vercel.app
  4. github.com/shuding/bet…
  5. github.com/isaacs/node…
  6. vercel.com/blog/how-we…
  7. vercel.com/blog/how-we…
❌