首页
/ Cal.diy 的 React/Next.js 性能规则手册:45 条优化规则与 Skill 化落地实践

Cal.diy 的 React/Next.js 性能规则手册:45 条优化规则与 Skill 化落地实践

2026-09-05 12:46:30作者:宣聪麟

本文围绕 cal.diy 仓库中内置的 Vercel React 最佳实践 Skill(.opencode/skill/vercel-react-best-practices/SKILL.md)展开,完整梳理其 8 大类 45 条性能规则的优先级体系、分类速查与使用方式,并结合仓库中真实的 Next.js 配置与配套工程规则,展示这套规则如何在大型 React/Next.js 代码库中被检索、引用与落地执行。

一、Skill 的定位:把性能准则变成可被 Agent 调用的知识

该 Skill 位于 .opencode/skill/vercel-react-best-practices/SKILL.md,由 Vercel 工程团队维护(frontmatter 中标注 author: vercelversion: "1.0.0"license: MIT)。它不是一个普通的 Markdown 文档,而是一份"技能"(skill):当 AI Agent 或开发者在以下场景中工作时,应当主动引用这份准则:

  • 编写新的 React 组件或 Next.js 页面;
  • 实现客户端或服务端的数据获取;
  • 在 Code Review 中排查性能问题;
  • 重构既有 React/Next.js 代码;
  • 优化 Bundle 体积或页面加载耗时。

Skill 目录采用"入口 + 规则库 + 完整编译文档"的三层结构:

文件 作用
SKILL.md 入口:适用范围、优先级总表、45 条规则速查索引
rules/ 目录下 45 个规则文件 每条规则一份独立文件,包含"为什么重要"、错误示例、正确示例与补充上下文
AGENTS.md 全部规则展开后的完整编译文档(约 2200 行),面向 Agent 和 LLM 自动化执行

SKILL.md 中"Full Compiled Document"一节明确指引:需要逐条规则的详细解释与代码示例时,阅读对应的 rules/*.md 文件;需要一次性加载全部规则时,阅读 AGENTS.md。每个规则文件遵循统一的内容结构:先说明该规则为何重要,再给出带解释的错误代码示例,然后给出正确实现,最后补充适用边界与参考资料。

二、规则优先级体系:8 大类按影响程度分级

SKILL.md 给出的核心骨架是一张优先级总表,将 45 条规则划分为 8 个类别,并按对性能的影响程度排序。这是整份文档的组织中枢:

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

前缀命名法(async-bundle- 等)与规则文件名一一对应,例如 async-parallel 对应 rules/async-parallel.md。这种"前缀 + 语义"的命名让规则既可作为文档标题,也可直接作为文件名被检索和引用。

三、第一优先级:消除瀑布式等待(async-)

AGENTS.md 开篇即指出:"Waterfalls are the #1 performance killer. Each sequential await adds full network latency."(瀑布式等待是头号性能杀手,每多一个串行 await 就多付一次完整的网络延迟)。本类包含 5 条规则:

  • async-defer-await:把 await 推迟到真正使用结果的分支中;
  • async-parallel:对相互独立的操作使用 Promise.all()
  • async-dependencies:存在部分依赖关系时使用 better-all 最大化并行度;
  • async-api-routes:在 API 路由中尽早发起 Promise、尽晚 await;
  • async-suspense-boundaries:用 Suspense 边界实现内容流式输出。

3.1 Promise.all() 并行独立操作(async-parallel)

规则文件标注该规则影响级别为 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()
])

3.2 把 await 推迟到使用的分支(async-defer-await)

如果一个异步结果只在某个分支中用到,提前 await 会让不走该分支的请求白白等待:

// 错误:两个分支都先等待了 userData
async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    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)
}

当被跳过的分支经常被命中、或推迟的操作代价高昂时,该优化的收益尤其明显。同一模式也适用于权限校验场景:先做廉价的资源存在性检查,通过后再去拉取权限数据,避免在"资源不存在"时还白付一次权限查询。

3.3 依赖感知的并行化(async-dependencies / async-api-routes)

当操作之间存在部分依赖时(例如 profile 依赖 user,但 config 与两者无关),Promise.all 无法表达"尽早启动每个任务"的语义,此时应使用 better-all:

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    // config 与 profile 并行启动,profile 内部再依赖 user
    return fetchProfile((await this.$.user).id)
  }
})

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

3.4 战略性 Suspense 边界(async-suspense-boundaries)

在异步组件中 await 完数据再返回整棵 JSX 树,会让不需要数据的侧边栏、页头等布局也被阻塞。正确做法是用 Suspense 把"等数据"压缩到最小区域:

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

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

若多个组件消费同一份数据,可以把 Promise 提升到父级并通过 use() 解包,保证只发一次请求:

function Page() {
  const dataPromise = fetchData() // 立即发起,但不 await
  return (
    <div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
    </div>
  )
}

文档同时给出了不适用该模式的边界,这也是容易被照搬时踩坑的地方:数据参与布局决策、首屏 SEO 关键内容、查询本身很快(Suspense 开销不值得)、或需要避免布局跳动时,都应保持同步 await。其本质是"更快的首屏绘制"与"潜在布局偏移"之间的权衡。

四、第二优先级:Bundle 体积优化(bundle-)

降低首屏 Bundle 直接改善 Time to Interactive 与 LCP。本类 5 条规则:bundle-barrel-importsbundle-conditionalbundle-defer-third-partybundle-dynamic-importsbundle-preload

4.1 避免桶文件(Barrel)导入——cal.diy 仓库中的真实落地

规则文件将桶文件导入列为 CRITICAL 级问题(200-800ms 导入成本、拖慢构建)。核心结论有三点:

  1. 热门图标/组件库的入口文件可能有上万条 re-export,仅 import 就可能耗时 200-800ms,同时拖慢开发环境与生产冷启动;
  2. Tree-shaking 救不了被标记为 external(不参与打包)的库;若改为打包它来启用 tree-shaking,构建器要分析整个模块图,构建速度会显著下降;
  3. 直接导入源文件可获得 15-70% 更快的开发启动、约 28% 的构建提速、约 40% 的冷启动提速以及更快的 HMR。
// 错误:导入整个库(lucide-react 加载约 1,583 个模块)
import { Check, X, Menu } from 'lucide-react'
import { Button, TextField } from '@mui/material'

// 正确:只导入用到的模块
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'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'

对于 Next.js 13.5+,还有一个替代方案:在 next.config.js 中配置 experimental.optimizePackageImports,让构建器在编译期自动把桶导入转换为直接导入,从而保留桶导入的书写体验:

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

规则在本仓库中的实际证据: cal.diy 的 Web 应用正在使用这一机制——apps/web/next.config.ts 中配置了 optimizePackageImports: ["@calcom/ui"],即在构建期自动把 @calcom/ui 的桶导入转为直接导入,与其自身维护的大型 UI 组件库体积相配合。此外,仓库还有一条与 Vercel 规则同源、但针对内部包的工程规则 agents/rules/quality-avoid-barrel-imports.md,其示例正是要求从 @calcom/ui 桶导入改为 @calcom/ui/components/button 直接导入。可以看到,"45 条通用规则 + 项目内部规则 + 构建配置"三层共同构成了该仓库的 bundle 治理链路。

4.2 动态导入与条件加载

重组件(如代码编辑器,单体打包约 300KB)不应进入主 chunk,应通过 next/dynamic 按需加载:

import dynamic from 'next/dynamic'

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

条件模块加载(bundle-conditional)与预加载(bundle-preload)两个规则都强调一个细节:在 useEffect 中执行 import() 时加 typeof window !== 'undefined' 判断,可以避免该模块被打进 SSR 服务端 Bundle,同时减小服务端产物体积、加快构建。预加载则绑定"用户意图"信号——鼠标悬停、元素获得焦点、或 feature flag 被打开时提前 void import('./monaco-editor'),把网络耗时藏在用户决策时间内。

4.3 第三方库延后到水合之后

分析、日志、错误追踪不阻塞用户交互,应在水合完成后再加载:

import dynamic from 'next/dynamic'

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

五、第三优先级:服务端性能(server-)

5.1 两级缓存:React.cache() 与跨请求 LRU

两者作用域互补,容易混淆,规则中区分得很清楚:

  • 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 }
  })
})
  • 跨请求共享(用户先点按钮 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
}

文档特别注明了部署形态差异:在函数实例可跨请求复用的运行环境(如 Vercel Fluid Compute)中,LRU 缓存可被并发请求共享、无需 Redis 等外部存储;在传统 Serverless 冷启动隔离模型下,跨进程缓存要考虑 Redis。

5.2 最小化 RSC 边界的序列化数据

React 服务端/客户端边界会把对象的所有属性序列化为字符串嵌入 HTML 与后续 RSC 请求,字段数量直接决定页面权重。客户端只用 1 个字段就不要传 50 个字段的对象:

// 错误:把 50 个字段的 user 全量序列化过去
async function Page() {
  const user = await fetchUser()
  return <Profile user={user} />
}

// 正确:只传客户端实际使用的字段
async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

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

5.3 组件组合实现服务端并行抓取

Server Components 在同一棵组件树内是顺序执行的——父组件 await 完数据后子组件才开始渲染,等于把子组件的 fetch 也排进瀑布。解法是通过组件组合把 fetch 下推到兄弟组件,让两者并行:

// 错误:Sidebar 的 fetch 被 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>
}

// 正确:Header 与 Sidebar 各自抓取,天然并行
async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}
export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

5.4 after() 执行非阻塞操作

日志、分析、通知、缓存失效等副作用不应阻塞响应返回。Next.js 的 after() 在响应发出后继续执行任务,且"即使响应失败或重定向也会运行",可在 Server Actions、Route Handlers 与 Server Components 中使用:

import { after } from 'next/server'
import { headers, cookies } from 'next/headers'

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

六、客户端数据获取(client-)

6.1 用 SWR 获得自动请求去重

多个组件实例各自 fetch 同一接口会造成重复请求;useSWR('/api/users', fetcher) 让同 key 的实例共享一次请求,并获得缓存与再验证能力。对于只增不改的不可变数据可配置 useImmutableSWR 一类封装;对于变更操作则用 useSWRMutation('/api/user', updateUser)trigger() 模式。

6.2 全局事件监听器去重

每个组件实例各自 window.addEventListener('keydown', ...) 会注册 N 份监听器。规则给出的方案是模块级 Map 收集各组件的回调,再通过 useSWRSubscription 共享"一条"全局监听器,组件卸载时从 Set 中移除自身回调——N 个实例最终只存在 1 个监听器。

七、重渲染优化(rerender-)

本类 7 条规则共同目标是最小化无效计算与重渲染:

  • rerender-defer-reads:只在回调里读的动态状态(如 searchParams)不必订阅,在事件处理函数内用 new URLSearchParams(window.location.search) 按需读取即可,避免组件随每次 URL 变化重渲染;
  • rerender-memo:把昂贵计算提取到 memo 化的子组件中,父组件就能在 if (loading) return <Skeleton /> 时提前返回、跳过计算;
  • rerender-dependencies:Effect 依赖项用原始值而非对象——依赖 user 会在任意字段变化时重跑,依赖 user.id 只在 id 变化时重跑;响应式断点应订阅"布尔值"而非"像素值"(useMediaQuery('(max-width: 767px)') 替代 width < 768);
  • rerender-derived-state:与上条呼应,订阅派生布尔值以显著降低重渲染频率;
  • rerender-functional-setstate:基于当前状态更新时一律使用函数式更新,一举解决三个问题——回调引用稳定(useCallback 无需依赖状态、不被重建)、无陈旧闭包、依赖数组更简单:
// 稳定回调:永不重建,且永远操作最新状态
const addItems = useCallback((newItems: Item[]) => {
  setItems(curr => [...curr, ...newItems])
}, [])
const removeItem = useCallback((id: string) => {
  setItems(curr => curr.filter(item => item.id !== id))
}, [])

文档同时划定了边界:设置为静态值(setCount(0))或仅来自 props/参数的状态可直接赋值,无需函数式更新;

  • rerender-lazy-state-init:昂贵的初始值(解析 localStorage 的 JSON、构建索引)必须传函数给 useState(() => buildSearchIndex(items)),否则初始化表达式会在每次渲染执行;简单字面量则无需函数形式;
  • rerender-transitions:高频、非紧急的更新(如滚动位置跟踪)用 startTransition(() => setScrollY(window.scrollY)) 包裹,保持 UI 对高优先级交互的响应性。

文档还统一提示:若项目启用了 React Compiler,memo()/useMemo() 这类手工记忆化多数情况下不再必要,编译器会自动优化——但函数式 setState 因涉及正确性(防陈旧闭包)仍然推荐。

八、渲染性能(rendering-)

7 条规则覆盖浏览器渲染管线层面的优化:

  • rendering-content-visibility:长列表(如上千条消息)对每行加 content-visibility: auto; contain-intrinsic-size: 0 80px;,浏览器可跳过约 99% 屏外条目的布局与绘制,文档称首帧渲染可快约 10 倍;
  • rendering-animate-svg-wrapper:多数浏览器对 SVG 元素的 CSS 动画没有硬件加速,应把 animate-spin 之类的类放到包裹 <div> 上而非 <svg> 本身;
  • rendering-hoist-jsx:静态 JSX(尤其是大 SVG)提升到模块级常量,避免每次渲染重建;
  • rendering-svg-precision:用 SVGO(npx svgo --precision=1 --multipass icon.svg)把坐标精度降到 1 位小数以减小文件体积;
  • rendering-hydration-no-flicker:依赖 localStorage 等内容(主题、偏好)时,直接读会破坏 SSR、在 useEffect 中读又会产生"闪烁"。正解是注入一段同步内联脚本,在 React 水合前就把 DOM 修正为正确值,兼顾"无闪烁"与"无 hydration mismatch":
<script
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        try {
          var theme = localStorage.getItem('theme') || 'light';
          var el = document.getElementById('theme-wrapper');
          if (el) el.className = theme;
        } catch (e) {}
      })();
    `,
  }}
/>
  • rendering-activity:对频繁显隐且重建昂贵的组件,用 React 的 <Activity mode={isOpen ? 'visible' : 'hidden'}> 保留状态与 DOM,避免昂贵的重新挂载;
  • rendering-conditional-render:条件值可能为 0NaN 时不要用 &&{count && <Badge/>} 在 count 为 0 时会把字面量 0 渲染出来),应使用显式三元 count > 0 ? <Badge/> : null

九、JavaScript 微优化(js-)

这一类 12 条规则针对热路径,单条收益虽小但累积可观。规则名即速查表:

规则 要点
js-batch-dom-css 批量 CSS 变更走 class 或 cssText,一次 reflow 而非多次
js-index-maps 反复 .find() 同一 key 时先建 Map;1000 订单 × 1000 用户从 100 万次操作降到约 2 千次
js-cache-property-access 循环内缓存 obj.config.settings.valuearr.length
js-cache-function-results 模块级 Map 缓存重复函数调用(如 slugify),用 Map 而非 hook 以便在非组件代码中复用
js-cache-storage localStorage/sessionStorage/cookie 是同步且昂贵的 I/O,读值放内存缓存并处理失效(storage 事件、visibilitychange
js-combine-iterations 多个 filter/map 合并为一次 for 循环
js-length-check-first 昂贵数组比较前先比长度,长度不同必然不等
js-early-exit 结果已定立即 return,不要跑完全量校验
js-hoist-regexp RegExp 提升到模块级或用 useMemo;注意全局正则 /g 携带可变 lastIndex 状态
js-min-max-loop 求最值用 O(n) 单遍循环而非 O(n log n) 排序
js-set-map-lookups 成员判断用 Set.has()(O(1))替代 includes()(O(n))
js-tosorted-immutable toSorted() 替代 sort(),避免原地修改 props/state 违反 React 不可变模型

其中 js-tosorted-immutable 值得单独强调:.sort() 原地修改数组,会破坏 React 对 props/state 只读的假设并引发陈旧闭包问题;toSorted() 在所有现代浏览器(Chrome 110+、Safari 16+、Firefox 115+、Node.js 20+)可用,老环境可用 [...items].sort(...) 兜底。同族 API 还有 toReversed()toSpliced().with()

十、高级模式(advanced-)

  • advanced-event-handler-refs:回调用于 effect 订阅但不应触发重新订阅时,把它存入稳定引用。错误写法以 handler 作为依赖,每次渲染都解绑再绑定;正确写法用 ref 持有最新回调,effect 依赖只剩 event(最新 React 版本可用 useEffectEvent 达成同样效果);
  • advanced-use-latestuseLatest(value) 返回一个始终指向最新值的 ref,让防抖搜索这类场景既不必把 onSearch 放进依赖数组、又不会读到陈旧闭包:
function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

十一、如何在仓库中使用这套规则体系

结合 SKILL.md 的 "How to Use" 一节,实际操作路径是:

  1. 日常编码:按类别前缀直接查规则文件,例如处理"瀑布式请求"时读 rules/async-parallel.mdrules/async-defer-await.md
  2. Code Review / 重构:以第二节优先级表为 checklist,从 CRITICAL 的两类(waterfalls、bundle)开始扫描;
  3. Agent 自动化:把 AGENTS.md 整体注入 AI 工作流。该文档开头明确声明其首要受众是"维护、生成、重构 React/Next.js 代码库的 Agent 与 LLM",每条规则附错误/正确对照示例,正是为了让自动化流程可一致执行;
  4. 与项目自身规则联动:该 Skill 在仓库中还有 agents/skills/vercel-react-best-practices/ 的镜像副本,与 agents/rules/ 下的内部工程规则(如 quality-avoid-barrel-imports.md)配合使用,前者提供 45 条通用准则,后者把它们约束到 cal.diy 的具体包与路径(如 @calcom/ui 直接导入);构建层面则由 apps/web/next.config.tsoptimizePackageImports 兜底。

需要说明的适用前提:这份准则面向使用 React Server Components 与 App Router 的 Next.js 项目,部分规则(React.cache()after()use()<Activity>)依赖较新的 Next.js/React 版本能力;仓库中 apps/web 的 Next.js 配置(standalone 输出、serverExternalPackagestranspilePackages 等,见 apps/web/next.config.ts)表明该前端应用正运行在这一技术栈之上,规则可直接适用。

小结

这份 Skill 的价值不在"罗列技巧",而在于三点工程化设计:其一,优先级分级——用 8 级影响程度告诉执行者"先做什么"(消除瀑布与 Bundle 问题是 CRITICAL);其二,可检索的规则索引——45 条规则以统一前缀命名、一规则一文件,既可被人速查,也可被 Agent 按需加载;其三,错误/正确成对示例——每条规则都给出可直接对照的代码模式,并明确列出"何时不该用"的边界(如 Suspense 的四个反例、函数式 setState 的免用场景)。对 cal.diy 这样的多包 Next.js 仓库而言,这套通用规则通过与项目内部规则和构建配置(optimizePackageImports)的衔接,从"文档"变成了可执行、可验证的性能治理流程。

登录后查看全文
热门项目推荐
相关项目推荐