Skip to content

React 18/19 并发渲染与 Server Components ​

React 18 带来的最大变化不是某个 API,而是渲染模型从"一次性同步完成"变成了"可中断、可调度"。React 19 则把这种模型延伸到服务端和表单交互。

理解并发渲染和 Server Components(RSC),关键是分清两件事:哪些工作可以在客户端被调度,哪些工作根本不需要发送到客户端。

并发渲染解决了什么 ​

在 React 18 之前,一次 setState 会触发同步渲染:React 开始计算新的 UI,直到整棵树 diff 完成才把控制权交还浏览器。组件树很大时,这会阻塞输入和动画,用户感觉卡顿。

并发渲染允许 React:

  • 中断渲染,先处理更高优先级的更新(例如用户输入)。
  • 在后台准备新的 UI,准备好后再提交。
  • 对同一状态产生多个版本的 UI,但只提交最终结果。

注意,并发渲染不等于"多线程渲染"。JavaScript 仍然是单线程,React 只是把渲染工作切成可中断的小块,在浏览器帧之间调度。

Transition:区分紧急与非紧急更新 ​

用户输入、点击反馈是紧急更新,必须立即响应;列表过滤、图表重算可以延后。useTransition 用来标记非紧急更新:

tsx
import { useState, useTransition } from 'react'

export function SearchPanel() {
  const [keyword, setKeyword] = useState('')
  const [results, setResults] = useState<string[]>([])
  const [isPending, startTransition] = useTransition()

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const value = event.target.value
    setKeyword(value)

    startTransition(() => {
      setResults(search(value))
    })
  }

  return (
    <div>
      <input value={keyword} onChange={handleChange} placeholder="搜索" />
      {isPending && <span>搜索中...</span>}
      <ResultList items={results} />
    </div>
  )
}

输入框的更新是紧急的,列表更新被标记为 transition 后可以被中断。即使用户连续输入,React 也不会为了渲染旧结果而阻塞新输入。

useDeferredValue 适合"用一个延迟版本的值渲染非关键部分":

tsx
import { useDeferredValue, useMemo, useState } from 'react'

export function FilterableList({ items }: { items: string[] }) {
  const [keyword, setKeyword] = useState('')
  const deferredKeyword = useDeferredValue(keyword)

  const filtered = useMemo(
    () => items.filter((item) => item.includes(deferredKeyword)),
    [items, deferredKeyword]
  )

  return (
    <>
      <input value={keyword} onChange={(event) => setKeyword(event.target.value)} />
      <ul>
        {filtered.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </>
  )
}

keyword 立即更新输入框,deferredKeyword 稍后更新列表。React 会在空闲时重新渲染列表,而不是阻塞输入。

自动批处理与 StrictMode ​

React 18 将自动批处理扩展到 Promise、setTimeout 和原生事件回调:

tsx
setTimeout(() => {
  setCount((value) => value + 1)
  setFlag((value) => !value)
  // 只触发一次渲染
}, 0)

开发环境下,StrictMode 会故意将 Effect 执行两次,用来暴露副作用清理不完整的问题。不要用 useRef 标记去"绕过"双执行,而应该确保 Effect 可重复执行:

tsx
useEffect(() => {
  const controller = new AbortController()
  loadData(controller.signal)
  return () => controller.abort()
}, [])

Server Components:把组件留在服务端 ​

RSC 的核心思想是:不是所有组件都需要在浏览器运行。数据格式化、Markdown 渲染、权限判断、依赖大型库的组件,如果只在服务端执行,就可以完全不进入客户端 bundle。

在 Next.js App Router 中,组件默认是 Server Component:

tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Comments } from './comments'

export default async function PostPage({
  params
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      <Comments postId={post.id} />
    </article>
  )
}

Server Component 的特点:

  • 可以是 async 函数,直接 await 数据。
  • 可以使用数据库、文件系统、服务端密钥。
  • 不能使用 useState、useEffect、浏览器 API。
  • 不向客户端发送组件代码,只发送渲染结果和客户端组件引用。

需要交互的组件通过 "use client" 标记:

tsx
'use client'

import { useState } from 'react'

export function Comments({ postId }: { postId: string }) {
  const [content, setContent] = useState('')

  async function submit() {
    await fetch(`/api/posts/${postId}/comments`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content })
    })
    setContent('')
  }

  return (
    <div>
      <textarea value={content} onChange={(event) => setContent(event.target.value)} />
      <button onClick={submit}>发表评论</button>
    </div>
  )
}

"use client" 标记的是模块边界,不是单个组件。被标记模块导入的所有组件都会进入客户端 bundle,所以应尽量把客户端组件放在树的叶子位置。

Server Actions ​

React 19 把表单提交和服务端变更整合得更自然。服务端定义 Action,客户端直接调用:

ts
// app/posts/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createComment(postId: string, formData: FormData) {
  const content = String(formData.get('content') ?? '')
  if (!content.trim()) {
    return { error: '评论不能为空' }
  }

  await db.comment.create({ postId, content })
  revalidatePath(`/posts/${postId}`)
  return { error: null }
}

客户端使用 useActionState 处理状态:

tsx
'use client'

import { useActionState } from 'react'
import { createComment } from '../actions'

export function CommentForm({ postId }: { postId: string }) {
  const [state, formAction, isPending] = useActionState(
    async (_previousState: { error: string | null }, formData: FormData) => {
      return createComment(postId, formData)
    },
    { error: null as string | null }
  )

  return (
    <form action={formAction}>
      <textarea name="content" required />
      {state.error && <p role="alert">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '提交中...' : '发表评论'}
      </button>
    </form>
  )
}

useOptimistic 可以先把预期结果渲染出来,失败后回滚,适合点赞、评论、购物车这类交互。

边界与数据流 ​

RSC 架构有三条重要边界:

  1. 序列化边界:Server Component 传给 Client Component 的 props 必须可序列化,不能传普通函数、类实例、Symbol。
  2. 客户端边界:"use client" 以下的整棵子树都在客户端执行,边界要尽量下沉。
  3. 服务端边界:Server Action 和 Route Handler 是客户端访问服务端的入口,必须做鉴权和输入校验。

常见的数据流是:Server Component 取数 → 传给 Client Component 做交互 → 通过 Server Action 提交变更 → revalidatePath 刷新缓存。

React 19 与编译器 ​

React 19 的几个变化值得关注:

  • Actions:表单提交、异步状态和错误处理有统一模型。
  • use():在客户端读取 Promise 或 Context,配合 Suspense 使用。
  • useOptimistic:声明式乐观更新。
  • React Compiler:自动分析组件依赖并插入缓存,减少手写 useMemo、useCallback 和 memo 的需求。

编译器能降低性能优化的心智负担,但它不是"性能银弹"。数据结构、渲染边界和网络请求设计仍然决定上限。

常见坑 ​

  1. 滥用 transition。所有更新都包 startTransition,会让紧急反馈也变慢。
  2. 客户端边界过大。在页面顶层加 "use client",整个页面失去 RSC 的收益。
  3. 在 Server Component 中使用浏览器 API。window、localStorage、事件监听都不存在。
  4. 忘记 props 序列化限制。从服务端传函数给客户端会直接报错,应改为 Server Action 或事件契约。
  5. 缓存语义不清。Next.js 15 起 fetch 默认不再缓存,需要显式声明 cache 或 revalidate,升级时要逐页确认。
  6. hydration 不一致。服务端与客户端首次渲染结果不同,常见于时间、随机数和浏览器环境判断。

小结 ​

并发渲染让客户端更新变得可调度,RSC 让一部分渲染工作根本不需要到达客户端。两者组合起来,React 应用的性能优化从"减少重渲染"扩展到了"减少发送到浏览器的代码和数据"。理解边界,比记住 API 更重要。

基于 VitePress 构建 · 持续更新中