Skip to content

第11章 Server Actions与Hydration

本章将深入React 19的Server Actions机制和Hydration水合过程,理解服务端函数调用、表单处理、选择性水合、不匹配处理等核心技术。这是理解全栈React应用数据流的关键章节。

在前面的章节中,我们学习了React Server Components如何在服务端渲染组件、Flight协议如何传输数据。但在实际应用中,客户端如何调用服务端函数?服务端渲染的HTML如何与客户端React实例关联?如何处理用户交互?

本章将逐一解答这些问题,带你深入理解Server Actions和Hydration的完整流程。


11.1 Server Actions概述

Server Actions是React 19引入的革命性特性,允许客户端直接调用服务端函数,无需手动编写API端点。

11.1.1 什么是Server Actions

为什么需要Server Actions?

在传统的全栈应用中,客户端与服务端的数据交互需要:

  1. 在服务端定义API路由(如 /api/createUser
  2. 在客户端使用fetch调用API
  3. 处理请求/响应的序列化
  4. 管理加载状态和错误处理

这种模式存在几个问题:

  • 代码分散:业务逻辑分散在客户端和服务端
  • 类型安全:客户端和服务端的类型容易不一致
  • 样板代码:需要大量重复的fetch、错误处理代码
  • 网络瀑布:多个数据依赖导致串行请求

Server Actions的解决方案

Server Actions允许你在服务端定义函数,然后在客户端直接调用,就像调用本地函数一样:

javascript
// app/actions.js - 服务端文件
'use server';

export async function createUser(formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  // 直接访问数据库
  const user = await db.users.create({ name, email });
  
  return { success: true, userId: user.id };
}
javascript
// app/UserForm.jsx - 客户端组件
'use client';

import { createUser } from './actions';

export function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <input name="email" />
      <button type="submit">创建用户</button>
    </form>
  );
}

核心优势

  1. 类型安全:TypeScript可以跨越客户端/服务端边界
  2. 渐进增强:表单在JavaScript加载前就能工作
  3. 自动序列化:React自动处理参数和返回值的序列化
  4. 统一错误处理:服务端错误可以在客户端捕获

11.1.2 "use server"指令

"use server"指令标记一个函数或文件为Server Action。

文件级指令

javascript
// actions.js
'use server';

// 文件中所有导出的函数都是Server Actions
export async function createUser(data) { /* ... */ }
export async function deleteUser(id) { /* ... */ }
export async function updateUser(id, data) { /* ... */ }

函数级指令

javascript
// UserList.jsx - Server Component
export async function UserList() {
  const users = await db.users.findAll();
  
  // 内联Server Action
  async function deleteUser(userId) {
    'use server';
    await db.users.delete(userId);
    revalidatePath('/users');
  }
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <form action={deleteUser.bind(null, user.id)}>
            <button type="submit">删除</button>
          </form>
        </li>
      ))}
    </ul>
  );
}

指令的作用

  1. 编译时标记:打包工具识别Server Action
  2. 生成引用:创建可序列化的函数引用
  3. 网络端点:自动生成HTTP端点
  4. 安全检查:确保只在服务端执行

11.1.3 与传统API的区别

特性传统APIServer Actions
定义方式手动创建路由直接定义函数
调用方式fetch('/api/...')直接调用函数
类型安全需要手动同步自动推导
表单集成需要手动处理原生支持
渐进增强需要额外代码内置支持
错误处理手动try-catch统一错误边界

传统API示例

javascript
// 服务端
app.post('/api/users', async (req, res) => {
  const { name, email } = req.body;
  const user = await db.users.create({ name, email });
  res.json({ success: true, userId: user.id });
});

// 客户端
async function handleSubmit(e) {
  e.preventDefault();
  const formData = new FormData(e.target);
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: formData.get('name'),
      email: formData.get('email'),
    }),
  });
  const data = await response.json();
  // 处理响应...
}

Server Actions示例

javascript
// 服务端
'use server';
export async function createUser(formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  const user = await db.users.create({ name, email });
  return { success: true, userId: user.id };
}

// 客户端
<form action={createUser}>
  <input name="name" />
  <input name="email" />
  <button type="submit">提交</button>
</form>

代码量减少了70%,且更加类型安全!


11.2 Server Actions实现

Server Actions的实现涉及编译时转换、运行时引用、网络传输等多个环节。

11.2.1 函数引用的序列化

Server Actions需要在客户端和服务端之间传递函数引用,但函数本身无法序列化。React通过生成唯一标识符来解决这个问题。

编译时转换

打包工具(如Webpack、Turbopack)会扫描"use server"指令,为每个Server Action生成唯一ID:

javascript
// 源代码
'use server';
export async function createUser(data) {
  return await db.users.create(data);
}

// 编译后(简化)
export const createUser = registerServerReference(
  async function(data) {
    return await db.users.create(data);
  },
  'file://app/actions.js',  // 文件路径
  'createUser'              // 函数名
);

registerServerReference源码

javascript
// 文件:packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js
// 行号:89-105(React 19.3.0,简化版)

export function registerServerReference(
  proxy: any,
  reference: string,
  encodeFormAction: void | EncodeFormActionCallback,
): any {
  // 创建一个代理对象
  return Object.defineProperties(proxy, {
    $$typeof: { value: SERVER_REFERENCE_TAG },
    $$id: { value: reference },
    $$bound: { value: null },
    bind: { value: bind },
  });
}

关键属性

  1. $$typeof:标记为SERVER_REFERENCE_TAG
  2. $$id:唯一标识符(文件路径 + 函数名)
  3. $$bound:绑定的参数(用于.bind())
  4. bind:自定义bind方法,支持参数预绑定

Flight序列化

当Server Component引用Server Action时,Flight协议会序列化函数引用:

F1:{"id":"file://app/actions.js#createUser","name":"createUser","chunks":[]}
  • F:表示Server Reference(Function)
  • id:函数的唯一标识符
  • chunks:依赖的客户端模块(通常为空)

11.2.2 客户端调用机制

客户端接收到Server Action引用后,如何调用服务端函数?

callServer实现

javascript
// 文件:packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js
// 行号:45-85(React 19.3.0,简化版)

function callServer(actionId: string, args: any[]) {
  // 1. 序列化参数
  const body = encodeReply(args);
  
  // 2. 发送POST请求
  const response = await fetch('/__action', {
    method: 'POST',
    headers: {
      'Content-Type': 'text/x-component',
      'X-Action-Id': actionId,
    },
    body: body,
  });
  
  // 3. 解析Flight响应
  const stream = response.body;
  return createFromReadableStream(stream);
}

调用流程

表单提交流程

当Server Action用作表单action时:

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1850-1920(React 19.3.0,简化版)

function setInitialProperties(
  domElement: Element,
  tag: string,
  props: Object,
) {
  // ...
  if (tag === 'form') {
    if (props.action != null) {
      // 如果action是Server Action
      if (typeof props.action === 'function') {
        // 拦截表单提交
        domElement.addEventListener('submit', (event) => {
          event.preventDefault();
          const formData = new FormData(event.target);
          // 调用Server Action
          props.action(formData);
        });
      }
    }
  }
  // ...
}

11.2.3 参数的编码与传输

Server Actions的参数需要序列化后通过网络传输。

encodeReply实现

javascript
// 文件:packages/react-server-dom-webpack/src/client/ReactFlightReplyClient.js
// 行号:580-650(React 19.3.0,简化版)

export function encodeReply(
  value: ReactServerValue,
): string | FormData {
  // 1. 创建FormData
  const formData = new FormData();
  const pendingParts = 0;
  
  // 2. 序列化值
  const json = JSON.stringify(value, (key, val) => {
    if (val === null) return null;
    
    // 处理特殊类型
    if (typeof val === 'object') {
      // Promise
      if (typeof val.then === 'function') {
        const promiseId = pendingParts++;
        val.then(
          result => resolveModelToJSON(formData, promiseId, result),
          error => rejectModelToJSON(formData, promiseId, error)
        );
        return '$@' + promiseId.toString(16);
      }
      
      // File/Blob
      if (val instanceof File || val instanceof Blob) {
        const fileId = pendingParts++;
        formData.append(fileId.toString(), val);
        return '$F' + fileId.toString(16);
      }
      
      // Date
      if (val instanceof Date) {
        return '$D' + val.toISOString();
      }
    }
    
    return val;
  });
  
  // 3. 添加到FormData
  formData.append('0', json);
  
  return formData;
}

支持的数据类型

类型编码格式示例
基本类型JSON"hello", 123, true
对象/数组JSON{"name":"Alice"}
Date$D + ISO字符串$D2024-01-01T00:00:00.000Z
File/Blob$F + ID$F1(FormData附件)
Promise$@ + ID$@2(异步解析)
Server Action$F + 引用ID$Ffile://app/actions.js#fn

示例:复杂参数传输

javascript
// 客户端调用
const file = document.querySelector('input[type=file]').files[0];
await uploadFile({
  name: 'avatar.png',
  file: file,
  metadata: {
    uploadedAt: new Date(),
    userId: 123,
  },
});

// 编码后的FormData
// Part 0 (JSON):
{
  "name": "avatar.png",
  "file": "$F1",
  "metadata": {
    "uploadedAt": "$D2024-01-01T10:30:00.000Z",
    "userId": 123
  }
}
// Part 1 (File):
[Binary data of avatar.png]

服务端解码

javascript
// 文件:packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js
// 行号:250-320(React 19.3.0,简化版)

export async function decodeReply(
  body: FormData,
): Promise<any> {
  // 1. 读取主JSON
  const json = await body.get('0');
  
  // 2. 解析并还原特殊类型
  return JSON.parse(json, (key, value) => {
    if (typeof value === 'string') {
      // File引用
      if (value.startsWith('$F')) {
        const id = parseInt(value.slice(2), 16);
        return body.get(id.toString());
      }
      
      // Date
      if (value.startsWith('$D')) {
        return new Date(value.slice(2));
      }
      
      // Promise引用
      if (value.startsWith('$@')) {
        const id = parseInt(value.slice(2), 16);
        return getPromiseForId(id);
      }
    }
    
    return value;
  });
}

这样,客户端和服务端就能安全地传输复杂数据结构!


11.3 表单处理

React 19提供了专门的Hooks来处理表单状态和提交状态,使Server Actions与表单的集成更加优雅。

11.3.1 useFormState Hook

useFormState用于管理表单提交后的状态(如成功消息、错误信息)。

基本用法

javascript
'use client';

import { useFormState } from 'react-dom';
import { createUser } from './actions';

export function UserForm() {
  const [state, formAction] = useFormState(createUser, { message: '' });
  
  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">创建用户</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
javascript
// actions.js
'use server';

export async function createUser(prevState, formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  try {
    await db.users.create({ name, email });
    return { message: '用户创建成功!' };
  } catch (error) {
    return { message: '创建失败:' + error.message };
  }
}

useFormState源码分析

javascript
// 文件:packages/react-dom/src/client/ReactDOMFormActions.js
// 行号:45-120(React 19.3.0,简化版)

export function useFormState<S, P>(
  action: (state: S, payload: P) => Promise<S>,
  initialState: S,
  permalink?: string,
): [S, (payload: P) => void, boolean] {
  // 1. 使用useState管理状态
  const [state, setState] = useState(initialState);
  const [isPending, setIsPending] = useState(false);
  
  // 2. 包装action
  const formAction = useCallback(async (payload) => {
    setIsPending(true);
    try {
      // 调用Server Action,传入当前状态
      const newState = await action(state, payload);
      setState(newState);
    } finally {
      setIsPending(false);
    }
  }, [action, state]);
  
  return [state, formAction, isPending];
}

关键特性

  1. 状态持久化:表单提交后状态保留
  2. prevState参数:Server Action接收上一次的状态
  3. pending状态:自动跟踪提交中状态
  4. 渐进增强:JavaScript未加载时也能工作

渐进增强示例

javascript
'use server';

export async function createUser(prevState, formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  // 验证
  if (!name || name.length < 2) {
    return { 
      message: '名字至少2个字符',
      errors: { name: '名字太短' }
    };
  }
  
  if (!email || !email.includes('@')) {
    return { 
      message: '请输入有效的邮箱',
      errors: { email: '邮箱格式错误' }
    };
  }
  
  // 创建用户
  try {
    const user = await db.users.create({ name, email });
    // 重定向到用户页面
    redirect(`/users/${user.id}`);
  } catch (error) {
    return { 
      message: '创建失败',
      errors: { _form: error.message }
    };
  }
}
javascript
'use client';

export function UserForm() {
  const [state, formAction, isPending] = useFormState(createUser, {
    message: '',
    errors: {},
  });
  
  return (
    <form action={formAction}>
      <div>
        <label>名字</label>
        <input name="name" required />
        {state.errors?.name && (
          <span className="error">{state.errors.name}</span>
        )}
      </div>
      
      <div>
        <label>邮箱</label>
        <input name="email" type="email" required />
        {state.errors?.email && (
          <span className="error">{state.errors.email}</span>
        )}
      </div>
      
      <button type="submit" disabled={isPending}>
        {isPending ? '提交中...' : '创建用户'}
      </button>
      
      {state.message && (
        <div className="message">{state.message}</div>
      )}
    </form>
  );
}

11.3.2 useFormStatus Hook

useFormStatus用于获取父表单的提交状态,常用于提交按钮组件。

基本用法

javascript
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

export function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <input name="email" />
      <SubmitButton />
    </form>
  );
}

useFormStatus源码分析

javascript
// 文件:packages/react-dom/src/client/ReactDOMFormActions.js
// 行号:180-220(React 19.3.0,简化版)

export function useFormStatus(): FormStatus {
  // 1. 从Context读取表单状态
  const status = useContext(FormStatusContext);
  
  if (status === null) {
    // 不在表单内部
    return {
      pending: false,
      data: null,
      method: null,
      action: null,
    };
  }
  
  return status;
}

FormStatusContext的设置

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1900-1950(React 19.3.0,简化版)

function handleFormSubmit(event, action) {
  event.preventDefault();
  
  const formData = new FormData(event.target);
  
  // 设置pending状态
  const formStatus = {
    pending: true,
    data: formData,
    method: event.target.method,
    action: action,
  };
  
  // 通过Context传递给子组件
  startTransition(() => {
    FormStatusContext.Provider({
      value: formStatus,
      children: /* 重新渲染表单 */
    });
    
    // 调用Server Action
    action(formData).finally(() => {
      // 清除pending状态
      formStatus.pending = false;
    });
  });
}

返回值说明

属性类型说明
pendingboolean表单是否正在提交
dataFormData | null提交的表单数据
methodstring | null表单method(get/post)
actionstring | function | null表单action

高级示例:带进度的提交按钮

javascript
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton({ children }) {
  const { pending, data } = useFormStatus();
  
  // 根据表单数据显示不同文本
  const buttonText = pending
    ? `正在${data?.get('action') === 'delete' ? '删除' : '保存'}...`
    : children;
  
  return (
    <button type="submit" disabled={pending} className={pending ? 'loading' : ''}>
      {pending && <Spinner />}
      {buttonText}
    </button>
  );
}

function Spinner() {
  return <span className="spinner">⏳</span>;
}

11.3.3 渐进增强

Server Actions的一大优势是支持渐进增强:即使JavaScript未加载,表单也能正常工作。

工作原理

  1. 服务端渲染:表单的action属性指向一个真实的HTTP端点
  2. JavaScript未加载:浏览器执行标准表单提交
  3. JavaScript加载后:React拦截提交,使用fetch调用Server Action

实现细节

javascript
// 服务端渲染时
<form action="/__action?id=file://app/actions.js#createUser" method="POST">
  <input name="name" />
  <button type="submit">提交</button>
</form>

// JavaScript加载后,React拦截submit事件
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const formData = new FormData(e.target);
  callServer('file://app/actions.js#createUser', [formData]);
});

Next.js中的实现

javascript
// app/actions.js
'use server';

export async function createUser(formData) {
  const name = formData.get('name');
  await db.users.create({ name });
  
  // 重定向(无论是否有JavaScript都能工作)
  redirect('/users');
}
javascript
// app/UserForm.jsx
export function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" required />
      <button type="submit">创建用户</button>
      <noscript>
        <p>此表单在没有JavaScript的情况下也能工作!</p>
      </noscript>
    </form>
  );
}

渐进增强的好处

  1. 更快的首次交互:无需等待JavaScript加载
  2. 更好的可访问性:屏幕阅读器友好
  3. 更强的鲁棒性:JavaScript加载失败时仍可用
  4. SEO友好:搜索引擎可以理解表单

11.3.4 示例:Server Actions表单提交

让我们实现一个完整的用户注册表单,展示所有特性。

javascript
// app/actions.js
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

// 验证schema
const userSchema = z.object({
  name: z.string().min(2, '名字至少2个字符'),
  email: z.string().email('请输入有效的邮箱'),
  age: z.number().min(18, '必须年满18岁'),
});

export async function registerUser(prevState, formData) {
  // 1. 解析表单数据
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    age: parseInt(formData.get('age')),
  };
  
  // 2. 验证
  const result = userSchema.safeParse(rawData);
  if (!result.success) {
    return {
      success: false,
      message: '验证失败',
      errors: result.error.flatten().fieldErrors,
    };
  }
  
  // 3. 检查邮箱是否已存在
  const existingUser = await db.users.findByEmail(result.data.email);
  if (existingUser) {
    return {
      success: false,
      message: '该邮箱已被注册',
      errors: { email: ['邮箱已存在'] },
    };
  }
  
  // 4. 创建用户
  try {
    const user = await db.users.create(result.data);
    
    // 5. 重新验证缓存
    revalidatePath('/users');
    
    // 6. 重定向到用户页面
    redirect(`/users/${user.id}`);
  } catch (error) {
    return {
      success: false,
      message: '创建失败,请稍后重试',
      errors: { _form: [error.message] },
    };
  }
}
javascript
// app/RegisterForm.jsx
'use client';

import { useFormState } from 'react-dom';
import { registerUser } from './actions';
import { SubmitButton } from './SubmitButton';

export function RegisterForm() {
  const [state, formAction] = useFormState(registerUser, {
    success: false,
    message: '',
    errors: {},
  });
  
  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block font-medium">
          名字
        </label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="mt-1 block w-full rounded border p-2"
          aria-describedby={state.errors?.name ? 'name-error' : undefined}
        />
        {state.errors?.name && (
          <p id="name-error" className="mt-1 text-sm text-red-600">
            {state.errors.name[0]}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="email" className="block font-medium">
          邮箱
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="mt-1 block w-full rounded border p-2"
          aria-describedby={state.errors?.email ? 'email-error' : undefined}
        />
        {state.errors?.email && (
          <p id="email-error" className="mt-1 text-sm text-red-600">
            {state.errors.email[0]}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="age" className="block font-medium">
          年龄
        </label>
        <input
          id="age"
          name="age"
          type="number"
          required
          min="18"
          className="mt-1 block w-full rounded border p-2"
          aria-describedby={state.errors?.age ? 'age-error' : undefined}
        />
        {state.errors?.age && (
          <p id="age-error" className="mt-1 text-sm text-red-600">
            {state.errors.age[0]}
          </p>
        )}
      </div>
      
      {state.message && !state.success && (
        <div className="rounded bg-red-50 p-3 text-red-800">
          {state.message}
        </div>
      )}
      
      <SubmitButton />
    </form>
  );
}
javascript
// app/SubmitButton.jsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:bg-gray-400"
    >
      {pending ? (
        <>
          <span className="inline-block animate-spin mr-2">⏳</span>
          注册中...
        </>
      ) : (
        '注册'
      )}
    </button>
  );
}

这个示例展示了:

  • ✅ 表单验证(客户端和服务端)
  • ✅ 错误处理和显示
  • ✅ 加载状态
  • ✅ 渐进增强
  • ✅ 可访问性(aria-describedby)
  • ✅ 缓存重新验证
  • ✅ 成功后重定向

11.4 Hydration水合机制

Hydration(水合)是将服务端渲染的静态HTML与客户端React实例关联的过程,使页面变得可交互。

11.4.1 什么是Hydration

为什么需要Hydration?

服务端渲染(SSR)生成的HTML是静态的,没有事件监听器、没有状态管理。用户看到页面内容,但无法交互(点击按钮没反应)。

Hydration的作用是:

  1. 在客户端重新执行React组件
  2. 将React实例与现有DOM节点关联
  3. 绑定事件监听器
  4. 恢复组件状态

Hydration vs 客户端渲染

阶段用户可见可交互说明
服务端HTML到达用户看到内容,但无法交互
JavaScript加载下载React和应用代码
Hydration完成页面完全可交互

客户端渲染(CSR)流程

CSR的问题:用户需要等待JavaScript加载和执行才能看到内容。

11.4.2 hydrateRoot源码分析

hydrateRoot是客户端Hydration的入口函数。

基本用法

javascript
// 客户端入口文件
import { hydrateRoot } from 'react-dom/client';
import App from './App';

// 服务端渲染的HTML已经在DOM中
hydrateRoot(document.getElementById('root'), <App />);

hydrateRoot源码

javascript
// 文件:packages/react-dom/src/client/ReactDOMRoot.js
// 行号:180-250(React 19.3.0,简化版)

export function hydrateRoot(
  container: Element | Document,
  initialChildren: ReactNodeList,
  options?: HydrateRootOptions,
): RootType {
  // 1. 验证容器
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }
  
  // 2. 创建FiberRoot
  const root = createContainer(
    container,
    ConcurrentRoot,  // 并发模式
    null,
    true,            // isHydration = true
    null,
    '',
    options?.onRecoverableError || null,
    options?.identifierPrefix || null,
    options?.onUncaughtError || null,
    options?.onCaughtError || null,
    options?.onShellError || null,
    options?.onShellReady || null,
    options?.onAllReady || null,
  );
  
  // 3. 标记为Hydration模式
  markContainerAsRoot(root.current, container);
  
  // 4. 监听所有支持的事件
  listenToAllSupportedEvents(container);
  
  // 5. 开始Hydration
  updateContainer(initialChildren, root, null, null);
  
  return root;
}

关键参数

  1. container:服务端渲染的HTML容器
  2. initialChildren:React组件树(应与服务端渲染的一致)
  3. options
    • onRecoverableError:可恢复错误回调
    • identifierPrefix:ID前缀(用于多个React根)

Hydration标记

javascript
// 文件:packages/react-reconciler/src/ReactFiberRoot.js
// 行号:120-150(React 19.3.0,简化版)

function createContainer(
  containerInfo: Container,
  tag: RootTag,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  // ...
) {
  const root = createFiberRoot(
    containerInfo,
    tag,
    hydrationCallbacks,
    isStrictMode,
    // ...
  );
  
  // 标记为Hydration模式
  if (isHydration) {
    root.current.mode |= HydrationMode;
  }
  
  return root;
}

11.4.3 事件绑定恢复

Hydration的核心任务之一是恢复事件监听器。

服务端渲染的HTML

html
<!-- 服务端生成的HTML,没有事件监听器 -->
<button>点击我</button>

客户端Hydration后

javascript
// React在Hydration时会:
// 1. 找到对应的DOM节点
// 2. 绑定事件监听器
button.addEventListener('click', handleClick);

事件委托机制

React使用事件委托,在根容器上监听所有事件:

javascript
// 文件:packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
// 行号:450-520(React 19.3.0,简化版)

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  // 1. 获取所有支持的事件类型
  const allNativeEvents = [
    'click', 'input', 'change', 'submit', 'keydown', 'keyup',
    'mousedown', 'mouseup', 'touchstart', 'touchend',
    // ... 更多事件
  ];
  
  // 2. 在根容器上监听
  allNativeEvents.forEach(domEventName => {
    // 捕获阶段
    rootContainerElement.addEventListener(
      domEventName,
      dispatchEvent,
      true  // useCapture
    );
    
    // 冒泡阶段
    rootContainerElement.addEventListener(
      domEventName,
      dispatchEvent,
      false
    );
  });
}

事件分发

javascript
// 文件:packages/react-dom-bindings/src/events/ReactDOMEventListener.js
// 行号:180-250(React 19.3.0,简化版)

function dispatchEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
) {
  // 1. 找到触发事件的Fiber节点
  const targetInst = getClosestInstanceFromNode(nativeEvent.target);
  
  // 2. 收集事件路径上的所有监听器
  const listeners = accumulateEventListeners(
    targetInst,
    domEventName,
    nativeEvent.type,
  );
  
  // 3. 创建合成事件
  const syntheticEvent = createSyntheticEvent(
    domEventName,
    nativeEvent,
    targetInst,
  );
  
  // 4. 按顺序执行监听器
  for (let i = 0; i < listeners.length; i++) {
    const listener = listeners[i];
    listener(syntheticEvent);
    
    if (syntheticEvent.isPropagationStopped()) {
      break;
    }
  }
}

Hydration时的事件绑定

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1200-1280(React 19.3.0,简化版)

function hydrateInstance(
  instance: Instance,
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
) {
  // 1. 关联Fiber节点与DOM节点
  precacheFiberNode(internalInstanceHandle, instance);
  
  // 2. 更新属性(包括事件监听器)
  updateFiberProps(instance, props);
  
  // 3. 验证属性是否匹配
  const updatePayload = hydrateProperties(
    instance,
    type,
    props,
    rootContainerInstance,
    hostContext,
  );
  
  return updatePayload;
}

示例:按钮点击事件的Hydration

javascript
// 服务端渲染
function Button() {
  return <button onClick={() => alert('Clicked!')}>点击我</button>;
}

// 生成的HTML
<button>点击我</button>

// 客户端Hydration
// 1. React找到<button>节点
// 2. 创建对应的Fiber节点
// 3. 将onClick处理器存储在Fiber.memoizedProps中
// 4. 根容器已经监听了'click'事件
// 5. 用户点击时,事件冒泡到根容器
// 6. React找到对应的Fiber节点和onClick处理器
// 7. 执行处理器

Hydration流程图


11.5 Selective Hydration

Selective Hydration(选择性水合)是React 18引入的优化技术,允许页面部分区域优先Hydration,提升用户体验。

11.5.1 选择性水合的原理

传统Hydration的问题

在React 18之前,Hydration是全有或全无的:

javascript
// 整个应用必须等待所有组件Hydration完成
hydrateRoot(document, <App />);

// 问题:
// 1. 如果某个组件很慢(如大型图表),整个页面都无法交互
// 2. 用户点击按钮,但因为Hydration未完成而无响应
// 3. 浪费资源:用户可能只关心页面的一小部分

Selective Hydration的解决方案

使用Suspense边界,React可以:

  1. 优先Hydration用户正在交互的区域
  2. 延迟Hydration不重要的区域
  3. 流式Hydration:边接收HTML边Hydration
javascript
import { Suspense } from 'react';

function App() {
  return (
    <div>
      <Header />
      <Suspense fallback={<Spinner />}>
        <Comments />  {/* 可以延迟Hydration */}
      </Suspense>
      <Suspense fallback={<Spinner />}>
        <Chart />     {/* 可以延迟Hydration */}
      </Suspense>
      <Footer />
    </div>
  );
}

工作流程

11.5.2 优先级调度

React使用Lane优先级模型来调度Hydration任务。

Hydration优先级

javascript
// 文件:packages/react-reconciler/src/ReactFiberLane.js
// 行号:80-120(React 19.3.0,简化版)

// Lane优先级(数字越小优先级越高)
export const SyncLane: Lane = 0b0000000000000000000000000000001;
export const InputContinuousLane: Lane = 0b0000000000000000000000000000100;
export const DefaultLane: Lane = 0b0000000000000000000000000010000;
export const IdleLane: Lane = 0b0100000000000000000000000000000;

// Hydration使用的Lane
export const SyncHydrationLane: Lane = 0b0000000000000000000000000000010;
export const DefaultHydrationLane: Lane = 0b0000000000000000000000000100000;

优先级提升

当用户与未Hydration的区域交互时,React会提升该区域的优先级:

javascript
// 文件:packages/react-reconciler/src/ReactFiberHydrationContext.js
// 行号:580-650(React 19.3.0,简化版)

function attemptHydrationAtPriority(
  fiber: Fiber,
  lane: Lane,
) {
  // 1. 检查是否已经Hydration
  if (fiber.mode & HydrationMode) {
    // 2. 提升优先级
    const root = enqueueConcurrentRenderForLane(fiber, lane);
    
    // 3. 调度Hydration任务
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
    }
  }
}

用户交互触发优先Hydration

javascript
// 文件:packages/react-dom-bindings/src/events/ReactDOMEventListener.js
// 行号:320-380(React 19.3.0,简化版)

function dispatchEventForHydration(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
) {
  // 1. 找到目标节点
  const targetInst = getClosestInstanceFromNode(nativeEvent.target);
  
  // 2. 检查是否需要Hydration
  if (targetInst !== null) {
    const nearestMounted = getNearestMountedFiber(targetInst);
    
    if (nearestMounted === null) {
      // 3. 节点未Hydration,优先处理
      attemptHydrationAtPriority(
        targetInst,
        SyncLane  // 最高优先级
      );
    }
  }
  
  // 4. 继续分发事件
  dispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent);
}

11.5.3 用户交互优先

Selective Hydration的核心优势是响应用户交互。

示例:评论区延迟Hydration

javascript
// App.jsx
import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>文章标题</h1>
      <article>文章内容...</article>
      
      {/* 评论区可以延迟Hydration */}
      <Suspense fallback={<div>加载评论中...</div>}>
        <Comments />
      </Suspense>
    </div>
  );
}

场景1:用户不关心评论

场景2:用户想查看评论

实现细节

javascript
// 文件:packages/react-reconciler/src/ReactFiberWorkLoop.js
// 行号:1850-1920(React 19.3.0,简化版)

function performConcurrentWorkOnRoot(root, didTimeout) {
  // 1. 获取下一个要处理的Lane
  const lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  
  if (lanes === NoLanes) {
    return null;
  }
  
  // 2. 检查是否包含Hydration Lane
  const includesHydrationLane = includesSomeLane(
    lanes,
    SyncHydrationLane | DefaultHydrationLane,
  );
  
  if (includesHydrationLane) {
    // 3. 优先处理Hydration
    renderRootSync(root, lanes);
  } else {
    // 4. 正常并发渲染
    renderRootConcurrent(root, lanes);
  }
  
  // ...
}

性能对比

场景传统HydrationSelective Hydration
首次可交互时间等待全部完成(5秒)部分立即可用(1秒)
用户点击响应可能无响应立即响应
资源利用一次性加载全部按需加载
用户体验较差优秀

最佳实践

  1. 为慢组件添加Suspense
javascript
<Suspense fallback={<Skeleton />}>
  <HeavyChart data={data} />
</Suspense>
  1. 为非关键内容添加Suspense
javascript
<Suspense fallback={<div>加载中...</div>}>
  <RecommendedArticles />
</Suspense>
  1. 嵌套Suspense实现细粒度控制
javascript
<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</Suspense>
  1. 避免过度使用
javascript
// ❌ 不好:每个小组件都包裹Suspense
<Suspense><Button /></Suspense>
<Suspense><Input /></Suspense>

// ✅ 好:为逻辑区域添加Suspense
<Suspense>
  <Form>
    <Button />
    <Input />
  </Form>
</Suspense>

调试Selective Hydration

javascript
// 在React DevTools中查看Hydration状态
// 1. 打开React DevTools
// 2. 选择Profiler标签
// 3. 开始录制
// 4. 观察Hydration时间线

// 或者使用console.log
if (__DEV__) {
  console.log('Hydrating:', fiber.type);
}

Selective Hydration是React 18+的重要优化,显著提升了大型应用的用户体验!


11.6 Hydration不匹配处理

Hydration不匹配是指服务端渲染的HTML与客户端React组件生成的结构不一致,这会导致错误和性能问题。

11.6.1 不匹配的检测

React在Hydration时会比较服务端HTML与客户端组件的输出。

常见不匹配场景

  1. 条件渲染差异
javascript
// ❌ 错误:服务端和客户端渲染不同内容
function Component() {
  const [isClient, setIsClient] = useState(false);
  
  useEffect(() => {
    setIsClient(true);
  }, []);
  
  return <div>{isClient ? '客户端' : '服务端'}</div>;
}

// 服务端渲染:<div>服务端</div>
// 客户端Hydration:<div>客户端</div>  ❌ 不匹配!
  1. 随机值或时间戳
javascript
// ❌ 错误:每次渲染生成不同的值
function Component() {
  return <div>随机数:{Math.random()}</div>;
}

// 服务端:<div>随机数:0.123</div>
// 客户端:<div>随机数:0.456</div>  ❌ 不匹配!
  1. 浏览器专属API
javascript
// ❌ 错误:服务端没有window对象
function Component() {
  return <div>宽度:{window.innerWidth}px</div>;
}

// 服务端:报错或返回undefined
// 客户端:<div>宽度:1920px</div>  ❌ 不匹配!

不匹配检测源码

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1450-1520(React 19.3.0,简化版)

function hydrateTextInstance(
  textInstance: TextInstance,
  text: string,
  internalInstanceHandle: Object,
): boolean {
  // 1. 获取DOM节点的文本内容
  const textNode: Text = textInstance;
  const textContent = textNode.textContent;
  
  // 2. 比较服务端和客户端的文本
  if (textContent !== text) {
    // 3. 检测到不匹配
    if (__DEV__) {
      warnForTextDifference(textContent, text);
    }
    return true;  // 需要更新
  }
  
  return false;  // 匹配,无需更新
}
javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1550-1620(React 19.3.0,简化版)

function hydrateElement(
  element: Element,
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): boolean {
  // 1. 检查标签类型
  if (element.nodeName.toLowerCase() !== type.toLowerCase()) {
    if (__DEV__) {
      warnForDeletedHydratableElement(element, type);
    }
    return true;  // 不匹配
  }
  
  // 2. 检查属性
  const updatePayload = hydrateProperties(
    element,
    type,
    props,
    rootContainerInstance,
    hostContext,
  );
  
  // 3. 关联Fiber节点
  precacheFiberNode(internalInstanceHandle, element);
  updateFiberProps(element, props);
  
  return updatePayload !== null;
}

开发环境警告

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:450-500(React 19.3.0,简化版)

function warnForTextDifference(
  serverText: string,
  clientText: string,
) {
  if (__DEV__) {
    console.error(
      'Text content did not match. Server: "%s" Client: "%s"',
      serverText,
      clientText,
    );
  }
}

function warnForDeletedHydratableElement(
  parentNode: Element,
  expectedType: string,
) {
  if (__DEV__) {
    console.error(
      'Expected server HTML to contain a matching <%s> in <%s>.',
      expectedType,
      parentNode.nodeName.toLowerCase(),
    );
  }
}

11.6.2 错误恢复策略

当检测到不匹配时,React会尝试恢复。

恢复策略

  1. 客户端渲染覆盖:删除服务端HTML,重新渲染
  2. 部分更新:只更新不匹配的节点
  3. 抛出错误:严重不匹配时终止Hydration
javascript
// 文件:packages/react-reconciler/src/ReactFiberHydrationContext.js
// 行号:850-920(React 19.3.0,简化版)

function tryToClaimNextHydratableInstance(fiber: Fiber): void {
  const nextInstance = nextHydratableInstance;
  
  if (!nextInstance) {
    // 1. 没有可Hydration的节点,切换到客户端渲染
    fiber.flags |= Placement;
    isHydrating = false;
    hydrationParentFiber = fiber;
    return;
  }
  
  const expectedType = getExpectedType(fiber);
  const actualType = getActualType(nextInstance);
  
  if (expectedType !== actualType) {
    // 2. 类型不匹配,删除服务端节点
    deleteHydratableInstance(fiber, nextInstance);
    
    // 3. 标记需要插入新节点
    fiber.flags |= Placement;
  } else {
    // 4. 类型匹配,继续Hydration
    fiber.stateNode = nextInstance;
    hydrationParentFiber = fiber;
    nextHydratableInstance = getNextHydratableSibling(nextInstance);
  }
}

删除不匹配的节点

javascript
// 文件:packages/react-reconciler/src/ReactFiberHydrationContext.js
// 行号:650-700(React 19.3.0,简化版)

function deleteHydratableInstance(
  returnFiber: Fiber,
  instance: HydratableInstance,
) {
  // 1. 创建删除Fiber
  const deletionFiber = createFiberFromHostInstanceForDeletion();
  deletionFiber.stateNode = instance;
  deletionFiber.return = returnFiber;
  deletionFiber.flags = Deletion;
  
  // 2. 添加到副作用链
  if (returnFiber.deletions === null) {
    returnFiber.deletions = [deletionFiber];
    returnFiber.flags |= ChildDeletion;
  } else {
    returnFiber.deletions.push(deletionFiber);
  }
}

恢复流程图

11.6.3 suppressHydrationWarning

对于已知的不匹配(如时间戳),可以使用suppressHydrationWarning抑制警告。

基本用法

javascript
function Component() {
  return (
    <div suppressHydrationWarning>
      {new Date().toLocaleString()}
    </div>
  );
}

源码实现

javascript
// 文件:packages/react-dom-bindings/src/client/ReactDOMComponent.js
// 行号:1480-1520(React 19.3.0,简化版)

function hydrateTextInstance(
  textInstance: TextInstance,
  text: string,
  internalInstanceHandle: Object,
): boolean {
  const textNode: Text = textInstance;
  const textContent = textNode.textContent;
  
  // 检查是否抑制警告
  const fiber = internalInstanceHandle;
  const shouldSuppressWarning = 
    fiber.memoizedProps.suppressHydrationWarning === true;
  
  if (textContent !== text) {
    // 只在未抑制时发出警告
    if (__DEV__ && !shouldSuppressWarning) {
      warnForTextDifference(textContent, text);
    }
    return true;
  }
  
  return false;
}

使用场景

  1. 时间戳
javascript
function Clock() {
  return (
    <time suppressHydrationWarning>
      {new Date().toISOString()}
    </time>
  );
}
  1. 用户特定内容
javascript
function Greeting() {
  const [name, setName] = useState('');
  
  useEffect(() => {
    setName(localStorage.getItem('userName') || 'Guest');
  }, []);
  
  return (
    <div suppressHydrationWarning>
      欢迎,{name}!
    </div>
  );
}
  1. 第三方脚本注入的内容
javascript
function AdContainer() {
  return (
    <div 
      id="ad-container" 
      suppressHydrationWarning
    >
      {/* 广告脚本会在这里注入内容 */}
    </div>
  );
}

注意事项

⚠️ suppressHydrationWarning只抑制直接子节点的警告,不会递归抑制:

javascript
// ✅ 抑制<div>的文本内容警告
<div suppressHydrationWarning>
  {Math.random()}
</div>

// ❌ 不会抑制<span>的警告
<div suppressHydrationWarning>
  <span>{Math.random()}</span>
</div>

// ✅ 需要在<span>上也添加
<div suppressHydrationWarning>
  <span suppressHydrationWarning>{Math.random()}</span>
</div>

最佳实践:避免不匹配

  1. 使用useEffect处理客户端专属逻辑
javascript
// ✅ 好:服务端和客户端初始渲染一致
function Component() {
  const [isClient, setIsClient] = useState(false);
  
  useEffect(() => {
    setIsClient(true);
  }, []);
  
  if (!isClient) {
    return <div>加载中...</div>;
  }
  
  return <div>客户端内容</div>;
}
  1. 使用环境检测
javascript
// ✅ 好:检测环境
function Component() {
  const width = typeof window !== 'undefined' 
    ? window.innerWidth 
    : 0;
  
  return <div>宽度:{width}px</div>;
}
  1. 使用固定的初始值
javascript
// ✅ 好:服务端和客户端使用相同的初始值
function Component() {
  const [count, setCount] = useState(0);  // 固定初始值
  
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}
  1. 延迟渲染动态内容
javascript
// ✅ 好:动态内容在客户端渲染
function Component() {
  const [mounted, setMounted] = useState(false);
  
  useEffect(() => {
    setMounted(true);
  }, []);
  
  return (
    <div>
      <h1>标题</h1>
      {mounted && <DynamicContent />}
    </div>
  );
}

调试不匹配

javascript
// 在React DevTools中查看Hydration错误
// 1. 打开Console
// 2. 查找"Hydration"相关警告
// 3. 点击警告查看详细信息

// 或者使用自定义错误处理
function App() {
  return (
    <ErrorBoundary
      onError={(error, errorInfo) => {
        if (error.message.includes('Hydration')) {
          console.log('Hydration错误:', error, errorInfo);
        }
      }}
    >
      <YourApp />
    </ErrorBoundary>
  );
}

正确处理Hydration不匹配是构建可靠SSR应用的关键!


11.7 综合案例:全栈React应用的数据流

让我们通过一个完整的博客应用,理解Server Actions、RSC、Hydration的协作流程。

案例概述

功能需求

  1. 展示博客文章列表(Server Component)
  2. 文章详情页(Server Component + Client Component)
  3. 评论功能(Server Actions + 表单)
  4. 点赞功能(Server Actions + 乐观更新)

技术栈

  • React 19
  • Next.js 15 (App Router)
  • PostgreSQL数据库
  • Prisma ORM

项目结构

app/
├── layout.jsx              # 根布局
├── page.jsx                # 首页(文章列表)
├── posts/
│   └── [id]/
│       └── page.jsx        # 文章详情页
├── actions.js              # Server Actions
└── components/
    ├── PostList.jsx        # 文章列表(Server Component)
    ├── PostDetail.jsx      # 文章详情(Server Component)
    ├── CommentForm.jsx     # 评论表单(Client Component)
    ├── LikeButton.jsx      # 点赞按钮(Client Component)
    └── CommentList.jsx     # 评论列表(Server Component)

数据库Schema

prisma
// prisma/schema.prisma
model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String
  author    String
  likes     Int       @default(0)
  createdAt DateTime  @default(now())
  comments  Comment[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  author    String
  postId    Int
  post      Post     @relation(fields: [postId], references: [id])
  createdAt DateTime @default(now())
}

实现代码

1. Server Actions定义

javascript
// app/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

// 评论验证schema
const commentSchema = z.object({
  content: z.string().min(1, '评论不能为空').max(500, '评论最多500字'),
  author: z.string().min(2, '名字至少2个字符'),
  postId: z.number(),
});

// 创建评论
export async function createComment(prevState, formData) {
  // 1. 解析表单数据
  const rawData = {
    content: formData.get('content'),
    author: formData.get('author'),
    postId: parseInt(formData.get('postId')),
  };
  
  // 2. 验证
  const result = commentSchema.safeParse(rawData);
  if (!result.success) {
    return {
      success: false,
      message: '验证失败',
      errors: result.error.flatten().fieldErrors,
    };
  }
  
  // 3. 创建评论
  try {
    await prisma.comment.create({
      data: result.data,
    });
    
    // 4. 重新验证缓存
    revalidatePath(`/posts/${result.data.postId}`);
    
    return {
      success: true,
      message: '评论发布成功!',
      errors: {},
    };
  } catch (error) {
    return {
      success: false,
      message: '发布失败,请稍后重试',
      errors: { _form: [error.message] },
    };
  }
}

// 点赞文章
export async function likePost(postId) {
  try {
    const post = await prisma.post.update({
      where: { id: postId },
      data: { likes: { increment: 1 } },
    });
    
    // 重新验证缓存
    revalidatePath(`/posts/${postId}`);
    
    return { success: true, likes: post.likes };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

// 删除评论
export async function deleteComment(commentId, postId) {
  try {
    await prisma.comment.delete({
      where: { id: commentId },
    });
    
    revalidatePath(`/posts/${postId}`);
    
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

2. 首页 - 文章列表(Server Component)

javascript
// app/page.jsx
import { prisma } from '@/lib/prisma';
import Link from 'next/link';

export default async function HomePage() {
  // 在服务端直接查询数据库
  const posts = await prisma.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">博客文章</h1>
      
      <div className="space-y-6">
        {posts.map(post => (
          <article key={post.id} className="border rounded-lg p-6">
            <Link href={`/posts/${post.id}`}>
              <h2 className="text-2xl font-semibold hover:text-blue-600">
                {post.title}
              </h2>
            </Link>
            
            <p className="text-gray-600 mt-2">
              {post.content.substring(0, 150)}...
            </p>
            
            <div className="flex items-center gap-4 mt-4 text-sm text-gray-500">
              <span>作者:{post.author}</span>
              <span>❤️ {post.likes}</span>
              <span>{post.createdAt.toLocaleDateString()}</span>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

3. 文章详情页(Server Component + Client Component)

javascript
// app/posts/[id]/page.jsx
import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
import { LikeButton } from '@/components/LikeButton';
import { CommentForm } from '@/components/CommentForm';
import { CommentList } from '@/components/CommentList';

export default async function PostPage({ params }) {
  const postId = parseInt(params.id);
  
  // 并行获取文章和评论
  const [post, comments] = await Promise.all([
    prisma.post.findUnique({ where: { id: postId } }),
    prisma.comment.findMany({
      where: { postId },
      orderBy: { createdAt: 'desc' },
    }),
  ]);
  
  if (!post) {
    notFound();
  }
  
  return (
    <div className="container mx-auto px-4 py-8 max-w-3xl">
      {/* 文章内容 - Server Component */}
      <article>
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        
        <div className="flex items-center gap-4 mb-8 text-gray-600">
          <span>作者:{post.author}</span>
          <span>{post.createdAt.toLocaleDateString()}</span>
        </div>
        
        <div className="prose max-w-none mb-8">
          {post.content}
        </div>
        
        {/* 点赞按钮 - Client Component */}
        <LikeButton postId={post.id} initialLikes={post.likes} />
      </article>
      
      <hr className="my-12" />
      
      {/* 评论区 */}
      <section>
        <h2 className="text-2xl font-bold mb-6">
          评论 ({comments.length})
        </h2>
        
        {/* 评论表单 - Client Component */}
        <CommentForm postId={post.id} />
        
        {/* 评论列表 - Server Component */}
        <Suspense fallback={<div>加载评论中...</div>}>
          <CommentList comments={comments} postId={post.id} />
        </Suspense>
      </section>
    </div>
  );
}

4. 点赞按钮(Client Component + 乐观更新)

javascript
// app/components/LikeButton.jsx
'use client';

import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions';

export function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();
  
  const handleLike = () => {
    // 乐观更新:立即更新UI
    setLikes(prev => prev + 1);
    
    // 调用Server Action
    startTransition(async () => {
      const result = await likePost(postId);
      
      if (!result.success) {
        // 失败时回滚
        setLikes(prev => prev - 1);
        alert('点赞失败:' + result.error);
      } else {
        // 使用服务端返回的真实值
        setLikes(result.likes);
      }
    });
  };
  
  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="flex items-center gap-2 px-4 py-2 bg-red-100 hover:bg-red-200 rounded-lg disabled:opacity-50"
    >
      <span className="text-2xl">❤️</span>
      <span className="font-semibold">{likes}</span>
      {isPending && <span className="text-sm">(更新中...)</span>}
    </button>
  );
}

5. 评论表单(Client Component)

javascript
// app/components/CommentForm.jsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createComment } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button
      type="submit"
      disabled={pending}
      className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
    >
      {pending ? '发布中...' : '发布评论'}
    </button>
  );
}

export function CommentForm({ postId }) {
  const [state, formAction] = useFormState(createComment, {
    success: false,
    message: '',
    errors: {},
  });
  
  return (
    <form action={formAction} className="mb-8 space-y-4">
      <input type="hidden" name="postId" value={postId} />
      
      <div>
        <label htmlFor="author" className="block font-medium mb-2">
          名字
        </label>
        <input
          id="author"
          name="author"
          type="text"
          required
          className="w-full px-4 py-2 border rounded-lg"
          aria-describedby={state.errors?.author ? 'author-error' : undefined}
        />
        {state.errors?.author && (
          <p id="author-error" className="mt-1 text-sm text-red-600">
            {state.errors.author[0]}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="content" className="block font-medium mb-2">
          评论内容
        </label>
        <textarea
          id="content"
          name="content"
          required
          rows={4}
          className="w-full px-4 py-2 border rounded-lg"
          aria-describedby={state.errors?.content ? 'content-error' : undefined}
        />
        {state.errors?.content && (
          <p id="content-error" className="mt-1 text-sm text-red-600">
            {state.errors.content[0]}
          </p>
        )}
      </div>
      
      {state.message && (
        <div className={`p-3 rounded-lg ${
          state.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
        }`}>
          {state.message}
        </div>
      )}
      
      <SubmitButton />
    </form>
  );
}

6. 评论列表(Server Component)

javascript
// app/components/CommentList.jsx
import { deleteComment } from '@/app/actions';
import { DeleteButton } from './DeleteButton';

export function CommentList({ comments, postId }) {
  if (comments.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        还没有评论,来发表第一条吧!
      </p>
    );
  }
  
  return (
    <div className="space-y-4 mt-6">
      {comments.map(comment => (
        <div key={comment.id} className="border rounded-lg p-4">
          <div className="flex items-start justify-between">
            <div className="flex-1">
              <div className="flex items-center gap-2 mb-2">
                <span className="font-semibold">{comment.author}</span>
                <span className="text-sm text-gray-500">
                  {comment.createdAt.toLocaleDateString()}
                </span>
              </div>
              <p className="text-gray-700">{comment.content}</p>
            </div>
            
            {/* 删除按钮 - Client Component */}
            <DeleteButton 
              commentId={comment.id} 
              postId={postId}
              onDelete={deleteComment}
            />
          </div>
        </div>
      ))}
    </div>
  );
}

7. 删除按钮(Client Component)

javascript
// app/components/DeleteButton.jsx
'use client';

import { useTransition } from 'react';

export function DeleteButton({ commentId, postId, onDelete }) {
  const [isPending, startTransition] = useTransition();
  
  const handleDelete = () => {
    if (!confirm('确定要删除这条评论吗?')) {
      return;
    }
    
    startTransition(async () => {
      const result = await onDelete(commentId, postId);
      
      if (!result.success) {
        alert('删除失败:' + result.error);
      }
    });
  };
  
  return (
    <button
      onClick={handleDelete}
      disabled={isPending}
      className="text-red-600 hover:text-red-800 disabled:opacity-50"
    >
      {isPending ? '删除中...' : '删除'}
    </button>
  );
}

完整数据流图解

关键技术点总结

  1. Server Components

    • 文章列表、文章详情、评论列表都是Server Component
    • 直接访问数据库,无需API层
    • 减少客户端JavaScript体积
  2. Client Components

    • 点赞按钮、评论表单、删除按钮需要交互
    • 使用"use client"指令标记
    • 通过Flight协议传递引用
  3. Server Actions

    • createComment、likePost、deleteComment
    • 类型安全的服务端函数调用
    • 自动处理序列化和错误
  4. Hydration

    • 服务端渲染HTML,客户端Hydration
    • Selective Hydration:评论区可延迟加载
    • 事件绑定自动恢复
  5. 乐观更新

    • 点赞立即更新UI
    • 失败时回滚
    • 提升用户体验
  6. 缓存重新验证

    • revalidatePath刷新缓存
    • 确保数据一致性
    • 自动更新相关页面

这个案例展示了React 19全栈架构的完整数据流,从服务端渲染到客户端交互,再到数据更新和缓存管理!


本章小结

本章深入讲解了React 19的Server Actions和Hydration机制:

  1. Server Actions:革命性的服务端函数调用机制,消除了传统API层,提供类型安全的全栈开发体验。

  2. 表单处理:useFormState和useFormStatus Hooks简化了表单状态管理,支持渐进增强。

  3. Hydration:将服务端HTML与客户端React实例关联,恢复事件监听器和组件状态。

  4. Selective Hydration:优先Hydration用户交互的区域,显著提升首次可交互时间。

  5. 不匹配处理:检测和恢复服务端与客户端的渲染差异,确保应用稳定性。

  6. 综合案例:通过博客应用展示了完整的全栈React数据流。

思考题

  1. Server Actions与传统REST API相比有哪些优势和劣势?在什么场景下应该使用传统API?

  2. 为什么Selective Hydration能够提升用户体验?它是如何检测用户交互并调整优先级的?

  3. 如何避免Hydration不匹配?如果必须在服务端和客户端渲染不同内容,应该如何处理?

  4. 乐观更新在什么情况下会失败?如何设计一个健壮的乐观更新机制?

  5. 在大型应用中,如何合理划分Server Components和Client Components的边界?


下一章预告

在第12章中,我们将学习React的提交阶段(commit phase),理解React如何将变更应用到DOM、Effect的执行时机、以及三个子阶段的详细流程。