Langfuse 前端性能工程实践:基于 Vercel React Best Practices 的 40+ 条规则详解

原创2026-09-09 18:01:52318 阅读
文章标签:人工智能LLMOps可观测性AI 评测LLM 网关后端前端

Langfuse 前端性能工程实践:基于 Vercel React Best Practices 的 40+ 条规则详解

本文以 langfuse 仓库内置的 web/.agents/skills/vercel-react-best-practices/AGENTS.md 为骨架,系统讲解一套面向 React 与 Next.js 应用的性能优化规则体系。该文档由 Vercel Engineering 编写(Version 1.0.0),专为 AI Agent 与 LLM 在维护、生成、重构 React/Next.js 代码库时提供自动化一致性指导,同时适用于人工阅读。读完本文,你将掌握 8 大类别、40+ 条按影响优先级排序的规则,理解每条规则的错误/正确写法对比与影响指标,并能结合 langfuse 前端实际代码(Next.js 16.3.3 + React 19.2.4)落地这些模式。

规则体系概览:优先级驱动的重构清单

这套指南的核心价值在于"按影响排序",让自动化工具与工程师都能优先处理收益最大的问题。完整规则索引见 AGENTS.md,配套的规则速查文件为同目录下的 SKILL.md,每条规则的独立详解存放在 rules/ 目录(如 rules/async-parallel.md、rules/bundle-barrel-imports.md),每个规则文件均包含:为什么重要的简要说明、错误代码示例及解释、正确代码示例及解释、附加上下文与参考。

8 大类别按优先级划分如下:

优先级 类别 影响级别 规则前缀
1 消除瀑布流(Eliminating Waterfalls) CRITICAL async-
2 包体积优化(Bundle Size) CRITICAL bundle-
3 服务端性能(Server-Side) HIGH server-
4 客户端数据获取(Client-Side) MEDIUM-HIGH client-
5 重渲染优化(Re-render) MEDIUM rerender-
6 渲染性能(Rendering) MEDIUM rendering-
7 JavaScript 性能 LOW-MEDIUM js-
8 高级模式(Advanced) LOW advanced-

整体思路是:先消除最致命的串行等待与包体积问题,再优化服务端渲染与数据获取,最后才是微观层面的重渲染与 JS 热路径优化。langfuse 前端恰好是这套体系的理想验证对象——web/package.json 显示其技术栈为 next: 16.3.3、react: 19.2.4,并使用 @tanstack/react-query(^5.85.1)与 superjson(2.2.2)处理数据获取与序列化。

1. 消除瀑布流(Eliminating Waterfalls):CRITICAL

瀑布流是性能的头号杀手。每一次串行的 await 都会叠加一整次网络延迟,消除瀑布流能带来最大的性能收益。

1.1 延迟 await 到真正需要时(Defer Await Until Needed)

把 await 移入实际使用它的分支,避免阻塞不需要该数据的代码路径。当被跳过的分支是高频路径、或被延迟的操作本身很昂贵时,这一优化价值尤其大。

错误写法会阻塞两个分支:

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

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

  return processUserData(userData)  // 只有这个分支用到 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 基于依赖关系的并行化(Dependency-Based Parallelization)

影响级别:CRITICAL(2-10 倍提升)。对于存在部分依赖关系的操作,文档推荐使用 better-all 库自动让每个任务在最早期开始:

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)  // 自动等待 user,但与其他任务并行
  }
})

如果不引入额外依赖,也可以手写等价模式——先创建所有 Promise,最后统一 Promise.all():

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

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

1.3 防止 API 路由中的瀑布链(Prevent Waterfall Chains in API Routes)

影响级别:CRITICAL(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 })
}

1.4 独立操作用 Promise.all()(Promise.all() for Independent Operations)

影响级别:CRITICAL(2-10 倍提升)。三个独立请求串行是 3 次往返,并行则只需 1 次:

// 错误:串行执行,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 边界(Strategic Suspense Boundaries)

影响级别:HIGH(更快的首屏绘制)。不要在异步组件返回 JSX 前 await 数据,而应使用 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>
}

进阶变体是跨组件共享同一个 Promise:在父组件中立即启动 fetch 但不 await,把 Promise 作为 props 传给多个组件,配合 React 的 use() 解包,多个组件共享一次请求:

function Page() {
  const dataPromise = fetchData()  // 立即启动,不 await
  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>
}

需要谨慎使用该模式的场景:影响布局决策的关键数据、首屏以上的 SEO 关键内容、小型快速查询(Suspense 开销不值当)、以及希望避免布局位移(loading → 内容跳变)时。本质权衡是"更快的首屏绘制" vs "潜在的布局位移"。

2. 包体积优化(Bundle Size Optimization):CRITICAL

减小初始包体积能直接改善 Time to Interactive(TTI)与 Largest Contentful Paint(LCP)。

2.1 避免 Barrel 文件导入(Avoid Barrel File Imports)

影响级别:CRITICAL(200-800ms 导入成本、构建变慢)。Barrel 文件是指仅做再导出的入口文件(如 export * from './module' 的 index.js)。流行图标与组件库的入口文件可能有高达 10,000 个再导出,许多 React 包仅导入就要花费 200-800ms,同时影响开发速度与生产冷启动。

为什么 tree-shaking 帮不上忙:当库被标记为 external(不打进 bundle)时,打包器无法优化它;若打包以启用 tree-shaking,构建会因分析整个模块图而显著变慢。

// 错误:导入整个库(加载 1,583 个模块,dev 下额外耗时约 2.8s,每次冷启动运行时成本 200-800ms)
import { Check, X, Menu } from 'lucide-react'

// 正确:只导入需要的模块(仅 3 个模块,约 2KB vs 约 1MB)
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'

Next.js 13.5+ 提供了 optimizePackageImports 作为替代方案,保留 ergonomic 的 barrel 导入语法,构建时自动转换为直接导入:

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ['lucide-react']
  }
}

// 之后仍可写 ergonomic 导入
import { Check, X, Menu } from 'lucide-react'

文档给出的收益数据:直接导入可带来 15-70% 更快的 dev 启动、28% 更快的构建、40% 更快的冷启动以及显著更快的 HMR。受影响最典型的库包括 lucide-react、@tabler/icons-react、react-icons、@radix-ui/react-*、lodash、ramda、date-fns、rxjs、react-use 等。langfuse 前端恰好使用 react-icons(5.5.0,见 web/package.json),重构时优先从具体路径导入图标,可避免引入大量无关模块。

2.2 条件模块加载(Conditional Module Loading)

影响级别:HIGH(仅在需要时加载大数据)。只有功能被激活时才加载大数据或模块:

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 bundle,从而优化服务端包体积与构建速度。

2.3 延迟非关键第三方库(Defer Non-Critical Third-Party Libraries)

影响级别:MEDIUM(水合后加载)。分析、日志、错误追踪不阻塞用户交互,应在水合之后再加载:

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 重型组件使用动态导入(Dynamic Imports for Heavy Components)

影响级别:CRITICAL(直接影响 TTI 与 LCP)。用 next/dynamic 懒加载首屏不需要的大组件——例如 Monaco 编辑器(文档指出直接打包会让主 chunk 增加约 300KB):

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} />
}

langfuse 前端正是这样做的:在 web/src/components/layouts/app-layout/variants/AuthenticatedLayout.tsx 中,CommandMenu、PaymentBanner、PreviewDeploymentBanner 等非首屏必需组件全部通过 next/dynamic 延迟加载,避免它们进入初始 bundle。

2.5 基于用户意图预加载(Preload Based on User Intent)

影响级别:MEDIUM(降低感知延迟)。在需要之前预加载重型 bundle:

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>
  )
}

也可以结合特性开关预加载:当 flags.editorEnabled 为真时立即执行 void import('./monaco-editor').then(mod => mod.init())。同样地,typeof window !== 'undefined' 检查可避免预加载模块进入 SSR bundle。

3. 服务端性能(Server-Side Performance):HIGH

优化服务端渲染与数据获取,消除服务端瀑布流并降低响应时间。

3.1 像 API 路由一样认证 Server Actions

影响级别:CRITICAL(防止未授权访问服务端变更)。带 "use server" 的 Server Actions 与 API 路由一样是公开端点,必须在每个 Server Action 内部校验认证与授权,不能只依赖 middleware、布局守卫或页面级检查——Server Actions 可被直接调用。Next.js 官方文档明确要求:"以对待公开 API 端点同样的安全考量对待 Server Actions,并验证用户是否被允许执行变更。"

'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 }
}

标准顺序是:先输入校验(如 zod schema)→ 再认证 → 再授权 → 最后执行变更:

'use server'

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)  // 1. 先校验输入
  const session = await verifySession()              // 2. 再认证
  if (!session) throw new Error('Unauthorized')
  if (session.user.id !== validated.userId) {        // 3. 再授权
    throw new Error('Can only update own profile')
  }
  await db.user.update({                             // 4. 最后变更
    where: { id: validated.userId },
    data: { name: validated.name, email: validated.email }
  })
  return { success: true }
}

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

影响级别:LOW。RSC→client 的序列化去重基于对象引用而非值:相同引用只序列化一次,新引用会再次序列化。因此应在客户端做变换(.toSorted()、.filter()、.map()),而不是服务端:

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

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

// 客户端再变换
'use client'
const sorted = useMemo(() => [...usernames].sort(), [usernames])

去重是递归的,影响因数据类型而异:string[]/number[]/boolean[] 影响高(数组与全部原始值被完全复制);object[] 影响低(数组被复制,但嵌套对象按引用去重)。破坏去重的操作都是"创建新引用"的操作:数组的 .toSorted()、.filter()、.map()、.slice()、[...arr],对象的 {...obj}、Object.assign()、structuredClone()、JSON.parse(JSON.stringify())。例外情况:当变换很昂贵或客户端不需要原始数据时,可以传派生数据。

3.3 跨请求 LRU 缓存(Cross-Request LRU Caching)

影响级别:HIGH(跨请求缓存)。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 下效果尤其好:多个并发请求共享同一函数实例与缓存,无需 Redis 等外部存储;而在传统 serverless 中每次调用相互隔离,跨进程缓存需考虑 Redis。

3.4 最小化 RSC 边界序列化

影响级别:HIGH(降低传输体积)。RSC/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.5 组件组合实现并行数据获取

影响级别:CRITICAL(消除服务端瀑布流)。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>
}

// 正确:两者同时 fetch
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 组合:把 Sidebar 作为 children 传入 Layout,使其与 Header 并行执行。

3.6 用 React.cache() 做请求内去重

影响级别:MEDIUM(请求内去重)。认证与数据库查询最受益:

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 params = { uid: 1 }
getUser(params)  // 查询执行
getUser(params)  // 缓存命中(同一引用)

Next.js 专属提示:Next.js 已为 fetch 自动扩展请求记忆化(同 URL 同选项的请求在单请求内自动去重),无需对 fetch 使用 React.cache();但它对数据库查询(Prisma、Drizzle 等)、重计算、认证检查、文件系统操作等非 fetch 异步工作仍然不可或缺。

3.7 用 after() 处理非阻塞操作

影响级别:MEDIUM(更快的响应时间)。用 Next.js 的 after() 把应在响应发送后执行的工作排到后面,避免日志、分析等副作用阻塞响应:

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。langfuse 当前使用的 Next.js 16.3.3 完全支持该 API。

4. 客户端数据获取(Client-Side Data Fetching):MEDIUM-HIGH

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

4.1 去重全局事件监听器

影响级别:LOW(N 个组件 → 1 个监听器)。用 useSWRSubscription() 在组件实例间共享全局事件监听器,配合模块级 Map 按 key 管理回调:

import useSWRSubscription from 'swr/subscription'

// 模块级 Map,按 key 跟踪回调
const keyCallbacks = new Map<string, Set<() => void>>()

function useKeyboardShortcut(key: string, callback: () => void) {
  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)
  })
}

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

影响级别:MEDIUM(消除事件监听器导致的滚动延迟)。给 touch 与 wheel 事件监听器加 { 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, { passive: true })
  document.addEventListener('wheel', handleWheel, { passive: true })

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

适用 passive 的场景:埋点/分析、日志、任何不调用 preventDefault() 的监听器。不适用场景:实现自定义滑动手势、自定义缩放控制等需要 preventDefault() 的监听器。

4.3 用 SWR 实现自动去重

影响级别:MEDIUM-HIGH(自动去重)。SWR 支持跨组件实例的请求去重、缓存与重新验证:

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>
}

值得说明的是,langfuse 前端并未使用 SWR,而是采用功能等价的 @tanstack/react-query(^5.85.1,见 web/package.json)并通过 tRPC 集成实现跨组件的查询去重、缓存与失效——本规则的核心思想(去重、缓存、重新验证)在两个生态中完全通用。

4.4 版本化并最小化 localStorage 数据

影响级别:MEDIUM(防止 schema 冲突、减小存储体积)。给 key 加版本前缀,只存需要的字段:

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 {}
}

从服务端响应只缓存 UI 需要的字段(如 20+ 字段的 user 对象只存 theme 与 notifications)。始终用 try-catch 包裹:getItem()/setItem() 在 Safari、Firefox 的隐私模式下、配额超限或存储被禁用时会抛错。收益:通过版本号实现 schema 演进、减小存储体积、避免存储 token/PII/内部标志。

5. 重渲染优化(Re-render Optimization):MEDIUM

减少不必要的重渲染,降低浪费的计算并提升 UI 响应性。

5.1 渲染期间计算派生状态

若某个值可由当前 props/state 计算得出,就不要存入 state 或通过 effect 更新。渲染期派生可避免额外渲染与状态漂移:

// 错误:冗余的 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>
}

不要仅仅为了响应 prop 变化而在 effect 中 setState;应优先派生值或使用 keyed reset。

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 依赖本身可能比表达式更消耗资源:

// 错误
const isLoading = useMemo(() => {
  return user.isLoading || notifications.isLoading
}, [user.isLoading, notifications.isLoading])

// 正确
const isLoading = user.isLoading || notifications.isLoading

5.4 将 memo 组件的非原始默认参数提取为常量

memo 组件若给某非原始可选参数(数组、函数、对象)设默认值,调用时省略该参数会破坏 memo 化——每次重渲染都会创建新值实例,无法通过 memo() 的严格相等比较:

// 错误:每次重渲染 onClick 值都不同
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
  // ...
})
<UserAvatar />  // 省略可选 onClick 时 memo 失效

// 正确:稳定的默认值
const NOOP = () => {};

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

5.5 提取为 memo 组件

把昂贵工作提取到 memo 组件中,从而在计算前支持提前返回:

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.6 收窄 effect 依赖

指定原始类型依赖而非对象,以最小化 effect 重跑:

// 错误:user 的任何字段变化都重跑
useEffect(() => { console.log(user.id) }, [user])

// 正确:仅 id 变化时重跑
useEffect(() => { console.log(user.id) }, [user.id])

// 派生状态在 effect 外计算
const isMobile = width < 768  // 只在布尔值切换时触发
useEffect(() => {
  if (isMobile) enableMobileMode()
}, [isMobile])

5.7 交互逻辑放入事件处理器

如果副作用由特定用户动作(提交、点击、拖拽)触发,就直接在事件处理器中执行,不要把动作建模为"state + 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>
}

5.8 订阅派生状态

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

// 错误:每个像素变化都重渲染
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.9 使用函数式 setState 更新

基于当前 state 更新时,用函数式更新而非直接引用 state 变量,可防止陈旧闭包、消除不必要依赖、创建稳定回调引用:

// 错误:回调必须依赖 items,每次 items 变化都重建;或缺失依赖导致陈旧闭包
function TodoList() {
  const [items, setItems] = useState(initialItems)

  const addItems = useCallback((newItems: Item[]) => {
    setItems([...items, ...newItems])
  }, [items])  // items 依赖导致反复重建

  const removeItem = useCallback((id: string) => {
    setItems(items.filter(item => item.id !== id))
  }, [])  // 缺失 items 依赖,将始终引用初始值——陈旧闭包 bug!

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

// 正确:稳定回调、无陈旧闭包
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))
  }, [])  // 始终使用最新 state

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

收益:稳定回调引用、无陈旧闭包、更少的依赖数组、消除最常见的 React 闭包 bug 来源。适用场景:任何依赖当前 state 的 setState、useCallback/useMemo 内需要 state、引用 state 的事件处理器、异步更新 state。直接更新(setCount(0)、setName(newName))在静态值或仅由 props/参数决定时是没问题的。即使启用 React Compiler,函数式更新仍推荐用于正确性与防陈旧闭包。

5.10 惰性 state 初始化

昂贵的初始值请向 useState 传函数。不用函数形式时,初始化器在每次渲染都会执行,即使值只使用一次:

// 错误:每次渲染都执行
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [settings, setSettings] = useState(JSON.parse(localStorage.getItem('settings') || '{}'))

// 正确:仅初始渲染执行一次
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [settings, setSettings] = useState(() => {
  const stored = localStorage.getItem('settings')
  return stored ? JSON.parse(stored) : {}
})

适用场景:从 localStorage/sessionStorage 计算初始值、构建数据结构(索引、Map)、读取 DOM、重型变换。简单原始值(useState(0))、直接引用(useState(props.value))或廉价字面量(useState({}))不需要函数形式。

这一模式在 langfuse 前端已被实际使用:例如 web/src/components/editor/mediaTagWidget.tsx 中的 useState(() => new MediaTagWidgetStore()) 与 web/src/components/ChatMessages/messageSearch/context.tsx 中的 useState(() => ...) 都通过惰性初始化避免在每次渲染时重新构造实例。

5.11 非紧急更新使用 Transitions

把频繁、非紧急的 state 更新标记为 transition,保持 UI 响应性:

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.12 瞬时值使用 useRef

值频繁变化且不想每次更新都触发重渲染时(鼠标追踪、定时器、瞬时标志),存入 useRef。组件 state 留给 UI,ref 用于临时 DOM 相关值:

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)`  // 直接操作 DOM,不触发重渲染
      }
    }
    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. 渲染性能(Rendering Performance):MEDIUM

优化渲染过程,减少浏览器的工作量。

6.1 动画 SVG 包装层而非 SVG 元素

很多浏览器对 SVG 元素上的 CSS3 动画没有硬件加速。把 SVG 包在 <div> 中,动画包装层:

function LoadingSpinner() {
  return (
    <div className="animate-spin">  {/* 动画作用于 div,可 GPU 加速 */}
      <svg width="24" height="24" viewBox="0 0 24 24">
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  )
}

适用于所有 CSS transform 与 transition(transform、opacity、translate、scale、rotate)。

6.2 长列表使用 CSS content-visibility

对屏外内容应用 content-visibility: auto 延迟渲染。文档示例:1000 条消息时,浏览器跳过约 990 条屏外项的布局/绘制(首屏渲染快约 10 倍):

.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>
  )
}

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 崩溃与闪烁:

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 框架中有意在服务端与客户端不同的值(随机 ID、日期、本地化/时区格式),用 suppressHydrationWarning 包裹动态文本以消除噪音警告。不要用它掩盖真实 bug,也不要过度使用:

// 已知不匹配:时间戳
function Timestamp() {
  return (
    <span suppressHydrationWarning>
      {new Date().toLocaleString()}
    </span>
  )
}

6.7 显隐切换使用 Activity 组件

对频繁切换可见性的昂贵组件,用 React 的 <Activity> 保留 state/DOM:

import { Activity } from 'react'

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

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

6.8 使用显式条件渲染

当条件可能为 0、NaN 等会被渲染出来的 falsy 值时,用三元表达式(? :)替代 &&:

// 错误:count 为 0 时渲染出 "0"
function Badge({ count }: { count: number }) {
  return (
    <div>
      {count && <span className="badge">{count}</span>}
    </div>
  )
}

// 正确:count 为 0 时什么都不渲染
function Badge({ count }: { count: number }) {
  return (
    <div>
      {count > 0 ? <span className="badge">{count}</span> : null}
    </div>
  )
}

6.9 用 useTransition 替代手动加载状态

用 useTransition 内置的 isPending 状态替代手动的 useState 加载状态:

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 状态也能正确重置)、更好的响应性、中断处理(新 transition 自动取消未完成的)。

7. JavaScript 性能:LOW-MEDIUM

热路径的微优化累积起来也能带来可观的改进。

7.1 避免布局抖动(Layout Thrashing)

避免在样式写入之间穿插布局读取。在样式更改之间读取布局属性(offsetWidth、getBoundingClientRect()、getComputedStyle())会强制浏览器触发同步回流:

// 错误:读写交错,强制多次回流
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()
}

React 场景中更推荐使用 CSS 类而非内联样式(CSS 文件可被浏览器缓存、关注点分离、更易维护):

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

7.2 重复查找建立索引 Map

同一 key 的多次 .find() 应改用 Map(1000 订单 × 1000 用户:100 万次操作 → 2000 次操作):

// 错误:每次查找 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)
  }))
}

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 缓存结果:

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>
  )
}

单值函数可用更简单的模块级变量缓存,并在 auth 变化时置空。用 Map(而非 hook)使其在工具函数、事件处理器等 React 组件之外也能工作。

7.5 缓存 Storage API 调用

localStorage、sessionStorage、document.cookie 是同步且昂贵的,把读取缓存在内存中:

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)  // 保持缓存同步
}

Cookie 缓存:document.cookie.split('; ') 解析为对象后缓存。重要:外部变化时失效——监听 storage 事件(其他标签页)与 visibilitychange(回到可见时)清空缓存。

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 数组比较先检查长度

用昂贵操作(排序、深比较、序列化)比较数组时先查长度;长度不同数组必不相等。这避免了长度不同时仍执行两个 O(n log n) 排序,也避免了 join 字符串的内存消耗与对原数组的修改:

function hasChanges(current: string[], original: string[]) {
  // 先做 O(1) 长度检查
  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():

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) => ...)}</>
}

警告:全局正则(/g)有可变的 lastIndex 状态,regex.test('foo') 第一次返回 true 后 lastIndex 变为 3,第二次再调用同一正则可能返回 false——复用全局正则时要特别注意。

7.10 找 Min/Max 用循环而非排序

找最小或最大元素只需一次遍历,排序是浪费且更慢(O(n) 替代 O(n log 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(...numbers)/Math.max(...numbers),但展开运算符对大数组有限制(文档提及 Chrome 143 约为 124000、Safari 18 约为 638000 的最大数组长度),为可靠性建议用循环。

7.11 用 Set/Map 实现 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() 保证不可变

.sort() 原地修改数组,会破坏 React 的 state 与 props 不可变性模型,并可能引发陈旧闭包 bug。用 .toSorted() 创建新数组:

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

.toSorted() 在 Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+ 可用;旧环境回退:const sorted = [...items].sort((a, b) => a.value - b.value)。其他不可变数组方法还有 .toReversed()、.toSpliced()、.with()。

这一规则在 langfuse 前端已有落地:如 web/src/components/editor/CodeMirrorEditor.tsx、web/src/features/monitors/components/MonitorsTable.tsx 以及服务端测试 web/src/tests/server/queryBuilder.servertest.ts 等文件中均使用了 toSorted() 等不可变数组方法,避免在排序时修改 React 状态与查询参数数组。

8. 高级模式(Advanced Patterns):LOW

适用于特定场景、需要谨慎实现的高级模式。

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

不要把必须每次应用加载只执行一次的全局初始化放进组件 useEffect([])——组件会重挂载导致 effect 重跑。用模块级守卫或入口模块的顶层初始化:

let didInit = false

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

  // ...
}

8.2 事件处理器存入 Refs

当 effect 不应因回调变化而重新订阅时,把回调存入 refs:

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: (e) => void) {
  const onEvent = useEffectEvent(handler)  // 稳定的函数引用,始终调用最新 handler

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])  // 不依赖 handler,不会反复重订阅
}

useEffectEvent 创建稳定函数引用,始终调用最新版本的 handler。

8.3 useEffectEvent 提供稳定回调引用

在回调中访问最新值而不把它们加入依赖数组,防止 effect 重跑同时避免陈旧闭包:

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])  // onSearch 变化不再触发 effect 重跑
}

在 langfuse 前端中的印证与实践建议

这套规则不是纸上谈兵,langfuse 前端本身就是很好的印证样本:

实践建议:把本文的 40+ 条规则作为代码评审与自动化重构的检查清单,按优先级排序处理——先消除服务端与客户端瀑布流、削减 bundle,再落实服务端缓存与序列化最小化,最后再处理重渲染与 JS 微观优化。每条规则对应的独立详解文件均位于 web/.agents/skills/vercel-react-best-practices/rules/ 目录,命名规则为前缀 + 主题(如 async-parallel.md、bundle-barrel-imports.md、server-cache-react.md、js-tosorted-immutable.md),便于按图索骥、逐条落地。

登录后查看全文
langfuse