React 基础体系 · 第 1/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构

React 的学习不能只按 API 顺序记忆。一个组件为什么重新渲染、状态为什么像“快照”、key 为什么不能随便写、服务端组件为什么不能调用浏览器 API,以及生产环境为什么需要区分构建配置、静态资源、CSP 和回滚,这些问题都指向同一个核心:

React 用声明式组件描述 UI;状态变化触发新的 UI 计算;React 再把新旧结果协调为尽可能小的宿主环境更新。

这条主线可以分为四层:

  1. 语言与类型层:JavaScript、JSX、TypeScript。
  2. React 核心层:元素、组件、渲染、Fiber、Reconciliation、key
  3. 状态与并发层:Hooks、状态快照、批处理、Reducer、Transition、Suspense。
  4. 应用与交付层:客户端/服务端边界、Server Components、SSR、框架路由、CSP、监控、灰度和回滚。

一、先建立 React 的基本心智模型

1. 元素、组件和真实 DOM 不是同一个概念

下面三者需要严格区分:

  • React 元素(element):描述“希望看到什么”的普通 JavaScript 对象。
  • React 组件(component):接收输入并返回元素的函数或类。
  • 宿主实例(host instance):浏览器中的真实 DOM 节点,例如 HTMLButtonElement

例如:

function Greeting({ name }: { name: string }) {
  return <h1 className="title">Hello, {name}</h1>;
}

const element = <Greeting name="Ada" />;

<Greeting name="Ada" /> 在 JSX 转换后类似于:

const element = React.createElement(Greeting, { name: "Ada" });

在现代 JSX 转换中,编译器通常会生成 jsxjsxs 等运行时调用,而不一定显式引用 React.createElement。具体生成形式由编译器配置决定,但语义仍然是创建一个描述 UI 的元素对象。

元素不是 DOM:

console.log(element);
// 类似:
// {
//   type: Greeting,
//   props: { name: "Ada" }
// }

调用 root.render(element) 后,React 才会根据元素树创建或更新 DOM。

2. JSX 是 JavaScript 表达式语法的扩展

JSX 允许把类似 HTML 的结构写进 JavaScript/TypeScript。它有几个重要规则:

function Profile({ user }: { user: { name: string; avatarUrl?: string } }) {
  return (
    <section>
      <h2>{user.name}</h2>
      {user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : null}
    </section>
  );
}
  • 使用 {} 插入 JavaScript 表达式。
  • 组件名必须以大写开头,<Profile /> 才会被当成组件;<profile /> 会被视为原生标签。
  • 返回多个兄弟节点时需要父节点或 Fragment。
  • className 对应 DOM 的 class 属性。
  • JSX 中的条件表达式必须返回元素、字符串、数字、数组或 null 等可渲染值。

一个常见陷阱是:

{count && <p>有消息</p>}

count0 时,React 会渲染数字 0,因为 0 && expression 的结果是 0。若意图是严格的布尔判断,应写成:

{count > 0 && <p>有消息</p>}

3. 渲染分为 Render 和 Commit

React 更新 UI 时,可以抽象为两个阶段:

状态或属性变化
      ↓
Render:计算新的元素树,确定哪些组件需要重新计算
      ↓
Commit:把确定的变更提交给 DOM 或其他宿主环境
      ↓
浏览器绘制

Render 阶段的工作是计算,理想情况下应当是纯的:

function Price({ amount }: { amount: number }) {
  const display = amount.toFixed(2);
  return <span>{display}</span>;
}

组件函数可能在 Render 阶段执行多次,因此不应在其中做不可逆副作用:

function BadComponent() {
  localStorage.setItem("visited", "1"); // 不应放在渲染期间
  return <div>Page</div>;
}

副作用是“读取或修改 React 外部系统”的行为,例如:

  • 修改 DOM;
  • 订阅 WebSocket;
  • 启动定时器;
  • 写入 localStorage
  • 发起不由框架数据层管理的请求。

这类逻辑通常应放在事件处理函数或 Effect 中:

import { useEffect } from "react";

function VisitMarker() {
  useEffect(() => {
    localStorage.setItem("visited", "1");
  }, []);

  return <div>Page</div>;
}

useEffect 在组件提交到 DOM 后运行。它不是“让组件初始化一次”的通用语法,而是用来同步 React 状态与外部系统。若只是根据已有 props 计算值,应直接计算,而不是用 Effect 再复制一份状态。

// 不必要的 Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// 更直接
const fullName = `${firstName} ${lastName}`;

二、Fiber、Reconciliation 和 key

1. Fiber 是 React 的内部工作单元

Fiber 是 React 内部表示组件树中一个节点及其工作状态的数据结构。一个 Fiber 通常会关联:

  • 当前元素的类型;
  • key
  • props;
  • hooks 链表;
  • 父、子、兄弟节点关系;
  • 上一次提交的状态;
  • 本次更新的优先级和待处理工作;
  • 对宿主节点的引用。

Fiber 不是公开业务 API,也不应直接依赖其字段。理解 Fiber 的价值在于理解 React 为什么能把更新拆分、暂停、恢复,并在 Commit 阶段统一提交。

在现代 React 中,Render 工作可以被调度。低优先级更新可能暂时让位给用户输入,但 Commit 不能被任意拆成导致 UI 半更新的状态。这里的“可中断”主要描述 Render 计算,而不是已经提交到 DOM 的一半变更。

2. Reconciliation 是新旧元素树的协调

Reconciliation 可译为“协调”:React 比较上一次 Render 产生的元素树和这一次产生的元素树,判断哪些节点可以复用、哪些需要卸载、哪些需要创建。

一个简化的同层比较规则是:

  1. 类型不同,通常认为是不同节点;
  2. 类型相同,复用节点并更新 props;
  3. 列表中的同类型节点通过 key 建立稳定身份;
  4. 子树变化后继续递归比较。

例如:

function App({ loggedIn }: { loggedIn: boolean }) {
  return loggedIn ? <Dashboard /> : <Login />;
}

<Dashboard /><Login /> 的类型不同,因此切换时 React 通常会卸载一个组件并挂载另一个组件。组件内部状态不会跨越这两个不同类型的节点自动保留。

相反:

function App({ name }: { name: string }) {
  return <Profile name={name} />;
}

前后都是 Profile 类型,React 通常复用这个组件实例,只更新 name

3. key 是身份,不是性能装饰

列表渲染时:

function TodoList({ todos }: { todos: { id: string; text: string }[] }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoRow key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

key 告诉 React:同一列表范围内,某个数据项在不同 Render 之间的稳定身份是什么。

考虑一个带输入框的列表:

function Row({ item }: { item: { id: string; name: string } }) {
  const [draft, setDraft] = useState(item.name);

  return <input value={draft} onChange={e => setDraft(e.target.value)} />;
}

初始数据:

[
  { id: "a", name: "A" },
  { id: "b", name: "B" }
]

用户把第二行改成 "B edited",随后在首部插入 X

使用索引作为 key

items.map((item, index) => (
  <Row key={index} item={item} />
))

旧身份与新身份可能变成:

旧:key 0 -> A,key 1 -> B
新:key 0 -> X,key 1 -> A,key 2 -> B

React 会认为 key 0 还是原来的 Row,于是原本属于 A 的本地状态被复用给 X;原本属于 B 的状态被复用给 A。结果可能表现为输入框内容跟着错误的数据项移动。

使用稳定 ID

旧:key a -> A,key b -> B
新:key x -> X,key a -> A,key b -> B

React 能识别出:

  • x 是新节点;
  • a 仍是 A;
  • b 仍是 B。

因此行组件的本地状态跟随数据身份,而不是跟随数组位置。

key 的约束是:

  • 在同一个父列表范围内唯一;
  • 在多次 Render 间稳定;
  • 不应每次 Render 都随机生成;
  • 不应在数据身份稳定时使用数组索引。

错误示例:

items.map(item => <Row key={Math.random()} item={item} />);

每次 Render 都得到新 key,React 会把每一行当成新组件,导致状态丢失、Effect 反复清理和重建,并增加不必要的工作。

需要注意,key 不会自动出现在组件 props 中:

function Row(props: { key: string }) {
  // props.key 不能依赖为 React 的 key
}

如果组件需要业务 ID,应显式传递:

<Row key={item.id} itemId={item.id} />

4. 通过改变 key 有意重置状态

key 不只用于数组,也可以用于控制组件身份:

function Editor({ documentId }: { documentId: string }) {
  return <DocumentForm key={documentId} documentId={documentId} />;
}

documentId 改变时,DocumentForm 被视为新组件,其本地状态和 Effect 会重新开始。这是有意重置状态的可靠方式,但不应把它当作修复状态同步问题的默认手段。


三、React 状态:快照、更新队列与批处理

1. State 是“下一次 Render 的输入”

组件函数中的 state 变量不是一个会被当前函数执行过程实时修改的普通变量,而是当前 Render 的快照。

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

当按钮点击时,这次事件处理函数闭包中的 count0。三个调用实际都在请求:

setCount(1)
setCount(1)
setCount(1)

因此最终通常是 1,不是 3

如果下一次状态依赖上一次状态,应使用函数式更新:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

React 会按队列顺序计算:

初始值:0
第一次:c = 0,结果 1
第二次:c = 1,结果 2
第三次:c = 2,结果 3

这两个写法的差异不是语法风格,而是依赖关系不同:

  • setCount(count + 1):根据当前 Render 的快照计算;
  • setCount(c => c + 1):根据更新队列中前一个结果计算。

2. 批处理减少提交次数,但不改变快照语义

React 会把同一时机内的多个状态更新合并处理,这称为 batching。React 18 以后,现代根 API 下,批处理范围不只局限于 React 事件,也通常覆盖 Promise、定时器等异步回调。

function Example() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1);
    setFlag(f => !f);
  }

  return (
    <button onClick={handleClick}>
      {count} / {String(flag)}
    </button>
  );
}

这通常会导致一次新的 Render 和一次 Commit,而不是每个 setState 都立即改 DOM。

批处理不等于同步修改变量:

function Example() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // 仍是当前 Render 的值
  }

  return <button onClick={handleClick}>{count}</button>;
}

如果必须在 DOM 提交后读取布局信息,flushSync 可以强制同步提交,但它是特殊工具,不是常规状态更新方式:

import { flushSync } from "react-dom";

flushSync(() => {
  setOpen(true);
});
// 此处可读取已经提交的 DOM,但会破坏部分调度空间

使用它会增加同步渲染成本,尤其在大型树或频繁交互中可能造成卡顿。

3. State 的身份取决于树中的位置和 key

下面两个组件各自维护独立状态:

function App() {
  return (
    <>
      <Counter />
      <Counter />
    </>
  );
}

它们函数代码相同,但位于不同的树位置,因此状态独立。

如果组件类型和位置保持不变,普通 props 更新不会自动重置 state:

function ContactEditor({ contact }: { contact: { id: string; name: string } }) {
  const [name, setName] = useState(contact.name);
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

切换 contact 后,name 不会自动变成新联系人名称,因为 state 属于这个位置上的 ContactEditor,而不是自动属于某个 prop 值。若编辑器确实应随联系人切换而重置,使用:

<ContactEditor key={contact.id} contact={contact} />

若不应重置,则应设计显式同步策略,例如只在用户尚未编辑时更新草稿,而不是无条件用 Effect 覆盖用户输入。

4. 状态应保持不可变更新

React 通常通过引用变化识别对象或数组是否需要进一步处理。不要直接修改已有 state:

// 错误
user.name = "Ada";
setUser(user);

应创建新对象:

setUser(previous => ({
  ...previous,
  name: "Ada",
}));

数组同理:

setItems(previous =>
  previous.map(item =>
    item.id === id ? { ...item, done: true } : item
  )
);

不可变更新的因果关系是:旧 Render 使用旧对象快照,新 Render 接收新对象引用;这让数据流可追踪,也避免已经执行的闭包观察到“对象内容被偷偷改写”的不一致状态。


四、Hooks:规则、生命周期和选择方式

1. Hook 是把 React 能力接入函数组件的协议

常用 Hooks 可以按目的理解:

  • useState:保存局部状态;
  • useReducer:用事件和纯 Reducer 管理状态转移;
  • useEffect:同步外部系统;
  • useLayoutEffect:在浏览器绘制前同步执行布局相关副作用;
  • useRef:保存跨 Render 的可变引用,修改不会触发 Render;
  • useMemo:缓存计算结果;
  • useCallback:缓存函数引用;
  • useContext:读取上下文;
  • useTransitionuseDeferredValue:表达非紧急更新;
  • useId:生成适合 SSR/CSR 对齐的标识符;
  • useSyncExternalStore:以符合 React 并发模型的方式订阅外部 store。

Hook 的核心规则是:

  1. 只能在组件函数或自定义 Hook 的顶层调用;
  2. 不能放在条件、循环、嵌套函数或事件处理器中;
  3. 每次 Render 必须以相同顺序调用同一组 Hooks。

错误示例:

function Panel({ enabled }: { enabled: boolean }) {
  if (enabled) {
    const [value] = useState(0); // 错误:调用顺序会变化
  }
  return null;
}

React 内部需要依靠稳定的调用顺序把当前 Hook 与上一次 Hook 状态对应起来。第一次 Render 如果顺序是:

useState -> useEffect -> useRef

下一次变成:

useEffect -> useState -> useRef

React 就无法可靠判断哪个状态属于哪个 Hook。ESLint 的 react-hooks 规则应作为工程检查的一部分,但规则的根本原因是运行时状态寻址依赖调用顺序。

2. useEffect 的正确边界

Effect 用于“同步外部系统”,例如订阅:

import { useEffect, useState } from "react";

function OnlineStatus({ roomId }: { roomId: string }) {
  const [online, setOnline] = useState(false);

  useEffect(() => {
    const connection = createConnection(roomId);

    function handleStatus(status: "online" | "offline") {
      setOnline(status === "online");
    }

    connection.on("status", handleStatus);
    connection.connect();

    return () => {
      connection.off("status", handleStatus);
      connection.disconnect();
    };
  }, [roomId]);

  return <span>{online ? "在线" : "离线"}</span>;
}

完整生命周期是:

首次提交
  ↓
运行 Effect
  ↓
roomId 变化或组件卸载
  ↓
运行 cleanup
  ↓
运行新的 Effect

Cleanup 不是只在卸载时执行。依赖变化时,旧 Effect 必须先清理,再建立新订阅。开发模式下的 Strict Mode 还可能故意执行额外的 setup/cleanup 检查,以发现不对称副作用;因此 Effect 必须可重复建立和清理。

请求也需要处理竞态:

function User({ userId }: { userId: string }) {
  const [state, setState] = useState<
    { status: "loading" } |
    { status: "success"; user: UserData } |
    { status: "error"; error: Error }
  >({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();

    setState({ status: "loading" });

    fetch(`/api/users/${encodeURIComponent(userId)}`, {
      signal: controller.signal,
    })
      .then(async response => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        return response.json() as Promise<UserData>;
      })
      .then(user => setState({ status: "success", user }))
      .catch(error => {
        if (error instanceof DOMException && error.name === "AbortError") {
          return;
        }
        setState({
          status: "error",
          error: error instanceof Error ? error : new Error(String(error)),
        });
      });

    return () => controller.abort();
  }, [userId]);

  if (state.status === "loading") return <p>加载中</p>;
  if (state.status === "error") return <p>{state.error.message}</p>;
  return <p>{state.user.name}</p>;
}

userId 从 A 变为 B 时,A 请求被取消。即使某些环境无法及时取消底层网络请求,cleanup 也应阻止旧请求结果覆盖新状态。真实项目还应考虑重试、超时、缓存和错误边界;仅依赖一个 Effect 并不自动得到完整的数据获取架构。

3. useRefuseMemouseCallback 的区别

useRef 适合保存不参与渲染的可变值:

const inputRef = useRef<HTMLInputElement>(null);

function focusInput() {
  inputRef.current?.focus();
}

return (
  <>
    <input ref={inputRef} />
    <button onClick={focusInput}>聚焦</button>
  </>
);

ref.current 改变不会触发组件重新渲染,因此不能用它替代需要显示在 UI 中的 state。

useMemo 缓存计算结果:

const visibleTodos = useMemo(
  () => todos.filter(todo => !todo.done),
  [todos]
);

useCallback 缓存函数引用:

const handleSelect = useCallback(
  (id: string) => onSelect(id),
  [onSelect]
);

它们不是“让任何组件都更快”的开关。只有当计算确实昂贵、或引用稳定性对子组件/Effect 有意义时才有合理收益。缓存本身也有依赖维护成本;依赖写错会产生陈旧闭包或错误结果。


五、用 useReducer 建模复杂状态

当状态变化由多个明确事件驱动,useReducer 比多个相互耦合的 useState 更容易推理。

type State = {
  status: "idle" | "submitting" | "success" | "error";
  error?: string;
};

type Action =
  | { type: "submit" }
  | { type: "success" }
  | { type: "failure"; message: string }
  | { type: "reset" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "submit":
      return { status: "submitting" };
    case "success":
      return { status: "success" };
    case "failure":
      return { status: "error", error: action.message };
    case "reset":
      return { status: "idle" };
    default: {
      const exhaustive: never = action;
      return exhaustive;
    }
  }
}

组件和副作用分离:

function SaveButton() {
  const [state, dispatch] = useReducer(reducer, { status: "idle" });

  async function handleSave() {
    dispatch({ type: "submit" });

    try {
      const response = await fetch("/api/save", { method: "POST" });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      dispatch({ type: "success" });
    } catch (error) {
      dispatch({
        type: "failure",
        message: error instanceof Error ? error.message : "保存失败",
      });
    }
  }

  return (
    <button disabled={state.status === "submitting"} onClick={handleSave}>
      {state.status === "submitting" ? "保存中" : "保存"}
    </button>
  );
}

这里有三个不同层次:

  • Reducer:纯函数,只负责 state + action -> nextState
  • 事件处理器:启动异步操作;
  • UI:根据状态渲染不同结果。

Reducer 不应直接发请求、写 DOM 或读取当前时间。纯 Reducer 便于测试状态转移:

idle + submit             -> submitting
submitting + success      -> success
submitting + failure      -> error
error + reset             -> idle

TypeScript 的判别联合让新增 Action 后可以在编译期发现未处理分支,但它不能替代运行时校验。来自网络的 JSON 仍应通过 Zod 等运行时 schema 或手写校验验证。


六、并发渲染、Transition、Suspense 与错误边界

1. 紧急更新和非紧急更新

用户输入通常是紧急更新,搜索结果列表可以是非紧急更新。useTransition 用于标记后者:

import { useState, useTransition } from "react";

function Search({ allItems }: { allItems: string[] }) {
  const [query, setQuery] = useState("");
  const [items, setItems] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  function handleChange(value: string) {
    setQuery(value);

    startTransition(() => {
      const next = allItems.filter(item =>
        item.toLowerCase().includes(value.toLowerCase())
      );
      setItems(next);
    });
  }

  return (
    <>
      <input value={query} onChange={e => handleChange(e.target.value)} />
      {isPending && <small>更新结果中…</small>}
      <ul>{items.map(item => <li key={item}>{item}</li>)}</ul>
    </>
  );
}

startTransition 不会让计算本身变快,也不会取消业务请求。它表达的是更新优先级:在结果更新尚未完成时,React 可以优先保证输入响应。

如果更新被延迟,组件必须能接受中间状态。不能假设每个 Render 都会立即 Commit,也不能把 Render 中的临时变量当作已提交事实。

2. Suspense 是声明式等待边界

Suspense 的作用是:当子树暂时无法完成渲染时显示 fallback。它不是任意 Promise 的通用错误处理器,具体哪些数据源支持 Suspense 取决于 React 集成和框架。

import { Suspense } from "react";

function Page() {
  return (
    <Suspense fallback={<p>加载页面中…</p>}>
      <ProductDetails />
    </Suspense>
  );
}

错误和等待是两条不同路径:

子树挂起 -> Suspense fallback
子树抛出错误 -> Error Boundary

错误边界目前通常通过类组件实现,或由框架提供:

class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { error: Error | null }
> {
  state = { error: null };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    reportError(error, info);
  }

  render() {
    if (this.state.error) {
      return <p>页面加载失败,请重试。</p>;
    }
    return this.props.children;
  }
}

错误边界不能捕获所有错误,例如事件处理器中的异常、服务端渲染错误和边界自身的错误需要分别处理。生产系统应同时记录错误、请求 ID、路由和构建版本,但不要把 access token 或个人敏感数据写入日志。


七、TypeScript:让组件契约和状态转移可验证

1. Props 应描述输入契约

type ButtonProps = {
  variant?: "primary" | "secondary";
  disabled?: boolean;
  onClick: () => void;
  children: React.ReactNode;
};

function Button({
  variant = "primary",
  disabled = false,
  onClick,
  children,
}: ButtonProps) {
  return (
    <button
      className={`button button-${variant}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

React.ReactNode 表示可以被 React 渲染的内容;若组件只接受单个元素,可以使用更窄的类型。不要为了省事把所有 props 写成 any,否则组件之间的边界失去检查价值。

DOM 事件应使用具体类型:

function NameInput() {
  const [name, setName] = useState("");

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setName(event.target.value);
  }

  return <input value={name} onChange={handleChange} />;
}

2. 使用判别联合表达互斥状态

不要把异步状态写成多个可互相矛盾的布尔值:

type BadState = {
  loading: boolean;
  success: boolean;
  error?: string;
};

这种类型允许 loading: truesuccess: true。更可靠的表达是:

type LoadState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

使用时 TypeScript 会根据 status 缩小类型:

function Result({ state }: { state: LoadState<UserData> }) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <p>加载中</p>;
    case "success":
      return <p>{state.data.name}</p>;
    case "error":
      return <p>{state.error.message}</p>;
  }
}

TypeScript 只在编译期检查类型。API 返回的 JSON 在运行时仍可能缺字段、类型错误或受到恶意构造,因此网络边界不能只靠 TypeScript 接口保护。


八、客户端组件、服务端渲染和服务端组件

1. 三个概念必须分开

客户端渲染(CSR):浏览器下载 JavaScript 后,调用 createRoot 创建 UI。

import { createRoot } from "react-dom/client";
import App from "./App";

const container = document.getElementById("root");
if (!container) throw new Error("Missing #root");

createRoot(container).render(<App />);

服务端渲染(SSR):服务器生成初始 HTML,浏览器先看到内容,再通过 hydration 接管已有标记。

import { hydrateRoot } from "react-dom/client";
import App from "./App";

hydrateRoot(document.getElementById("root")!, <App />);

hydrateRoot 的前提是服务端 HTML 与客户端首次 Render 能够匹配。随机数、当前时间、仅浏览器存在的 API、不同 locale 的格式化结果,都可能导致 hydration mismatch:

// 容易产生不一致
function Bad() {
  return <p>{Math.random()}</p>;
}

应把依赖浏览器或非确定性环境的内容延后到客户端 Effect,或由服务器把确定值传给客户端。

Server Components(服务端组件):组件在服务器环境执行,输出一种供 React 客户端处理的组件树/数据协议,而不是把该组件的 JavaScript 代码发送到浏览器执行。它是组件模型和传输协议能力,不等同于传统 SSR HTML。

2. Server Components 的边界

在支持 RSC 的框架中,通常可以这样组织:

// app/products/page.tsx
// 具体文件约定由框架决定;此处表示服务端组件
import ProductList from "./ProductList";
import FilterPanel from "./FilterPanel";

export default async function Page() {
  const response = await fetch("https://api.example.com/products", {
    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error(`Product API failed: ${response.status}`);
  }

  const products = (await response.json()) as Product[];
  return (
    <>
      <FilterPanel />
      <ProductList products={products} />
    </>
  );
}
// FilterPanel.tsx
"use client";

import { useState } from "react";

export default function FilterPanel() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={event => setQuery(event.target.value)}
      placeholder="筛选商品"
    />
  );
}

"use client" 是支持该约定的框架/工具链识别的模块边界,表示该模块需要进入客户端模块图。它不是 React 核心中可以在任意环境单独开启 RSC 的通用开关。

服务端组件通常不能:

  • 调用 useStateuseEffect 等客户端交互 Hooks;
  • 访问 windowdocumentlocalStorage
  • 注册浏览器事件;
  • 把任意不可序列化的对象传给客户端组件。

客户端组件可以作为服务端组件的子树,但一旦进入客户端边界,该子树及其依赖会进入客户端 JavaScript 构建。应把交互所需的最小部分放在客户端边界内。

3. 服务端组件不是“把服务器代码发给浏览器”

服务端组件中的数据库访问、私有凭证和文件系统操作不会因为组件被引用就自动暴露给浏览器;但必须信任并正确配置框架的 RSC 构建与传输链路。不要把服务端组件直接当作安全边界:

  • 用户输入仍需服务端鉴权和校验;
  • 返回给客户端的 props 不能包含秘密;
  • 日志、错误信息和序列化数据都可能泄露敏感内容;
  • 公开的环境变量与服务端私密变量必须由构建系统明确区分。

服务端组件的优势主要是减少不必要的客户端代码、让数据获取靠近服务器资源,以及把交互边界缩小;代价是需要理解序列化、缓存、请求生命周期和框架约定。

4. React 19 的 Action 相关能力

React 19 引入了面向异步表单和 Action 的能力,例如 useActionStateuseOptimistic 以及表单 Action 集成。它们在支持相应 React DOM/框架能力的环境中使用,不能简单等同于任意 API 路由或数据库事务。

一个客户端表单的示意:

"use client";

import { useActionState } from "react";

type FormState = {
  ok: boolean;
  message: string;
};

async function submit(
  previous: FormState,
  formData: FormData
): Promise<FormState> {
  const email = String(formData.get("email") ?? "").trim();

  if (!/^[^@]+@[^@]+$/.test(email)) {
    return { ok: false, message: "邮箱格式不正确" };
  }

  const response = await fetch("/api/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });

  if (!response.ok) {
    return { ok: false, message: "提交失败" };
  }

  return { ok: true, message: "订阅成功" };
}

export function SubscribeForm() {
  const [state, formAction, pending] = useActionState(submit, {
    ok: false,
    message: "",
  });

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <button disabled={pending}>
        {pending ? "提交中…" : "订阅"}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

这里的 Action 负责把表单输入转换为状态结果,但鉴权、CSRF 防护、限流、数据库唯一约束和幂等性仍必须在服务端实现。客户端禁用按钮不能防止重复请求,服务端应使用请求 ID、业务唯一键或数据库约束保证重复提交的安全性。


九、从入口到生产应用的架构分层

一个可维护的 React 应用通常可按数据流拆分:

路由/服务端数据获取
        ↓
页面组合组件
        ↓
领域组件与展示组件
        ↓
客户端交互组件
        ↓
浏览器事件、缓存、外部 store
        ↓
API / Server Action / 后端服务

关键原则不是目录名称,而是边界:

  • 页面层负责路由参数、服务端数据和错误/加载边界;
  • 领域组件负责业务状态和业务事件;
  • 展示组件尽量保持纯函数;
  • 客户端状态只保存真正需要在浏览器交互期间持有的数据;
  • 服务器数据不应无条件复制成多份本地 state;
  • API 返回值在边界处校验;
  • 权限判断必须在服务端重复执行。

1. 路由和数据加载

主流框架会提供文件路由、布局、错误页、加载页和服务端数据获取能力,但这些不是 React 核心本身。React 负责组件模型、渲染和状态;框架负责路由、打包、RSC/SSR 集成、缓存策略和部署适配。

无论使用何种框架,都应明确:

请求进入
  ↓
路由匹配
  ↓
鉴权与参数校验
  ↓
读取数据/调用后端
  ↓
服务端组件或 SSR 生成结果
  ↓
浏览器 hydration
  ↓
客户端交互接管

错误路径也要设计:

参数错误 -> 400
未认证   -> 401
无权限   -> 403
资源不存在 -> 404
上游或数据库失败 -> 5xx + 错误边界/降级页
客户端事件失败 -> 组件内错误状态或重试

不要把所有异常都渲染成“服务器错误”。用户需要可操作的提示,而日志需要保留真实错误和关联上下文。


十、生产交付:配置、静态资源和 CSP

1. 构建时配置和运行时配置不同

前端静态 JavaScript 一旦发送到浏览器,里面的值就不再是秘密。以下内容不能放入客户端环境变量:

  • 数据库密码;
  • 私有 API token;
  • 签名密钥;
  • 内部服务地址中包含的敏感凭证。

常见配置分为:

构建时配置:决定代码如何被打包,例如公开 API base URL
运行时服务端配置:服务器启动后读取,例如数据库连接串
客户端公开配置:明确允许浏览器看到,例如公开站点 ID

不同框架对环境变量前缀、注入时间和部署方式的约定不同,必须以具体框架文档和构建产物验证为准。安全检查不能只看变量名;应检查最终 JavaScript、Source Map 和响应内容中是否出现秘密。

2. 静态资源和缓存

带内容哈希的资源名可以支持长期缓存:

app.8f3a1c.js
styles.12ab90.css

如果文件内容改变,哈希也改变,因此可以发送:

Cache-Control: public, max-age=31536000, immutable

但入口 HTML 通常不能无限期缓存,否则用户可能继续拿到引用旧资源的 HTML。常见策略是:

index.html:
Cache-Control: no-cache

app.<hash>.js:
Cache-Control: public, max-age=31536000, immutable

发布流程必须保证 HTML 引用的资源已经上传完成。错误顺序可能是:

先发布新 HTML
  ↓
CDN 尚未拿到新 JS
  ↓
浏览器请求 404
  ↓
白屏

更安全的顺序通常是先上传带哈希的静态资源,再切换 HTML 或版本指针,并验证 CDN 命中结果。

3. CSP 的基本因果

Content Security Policy(CSP)通过响应头限制浏览器允许加载和执行的资源。例如:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-随机值';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

实际策略必须根据框架产物、内联脚本、第三方分析和 CDN 逐项收紧。unsafe-inline 会削弱脚本防护,不能为了快速消除报错而无条件加入 script-src

部署 CSP 时应经过:

  1. 先使用 Content-Security-Policy-Report-Only 收集违规报告;
  2. 区分真实资源依赖和误报;
  3. 去掉不必要的来源;
  4. 再切换为强制策略;
  5. 验证登录、SSR、hydration、图片、API 请求和错误上报。

CSP 不能替代输出转义、服务端鉴权和依赖安全更新;它是纵深防御的一层。


十一、灰度、监控和回滚

1. 灰度发布必须有稳定的版本标识

一次灰度请求至少应能关联:

用户/设备标识
  → 灰度规则
  → 应用版本
  → 静态资源版本
  → API 版本
  → 日志和指标

不要只按请求随机切流而不记录结果,否则出现问题时无法判断用户实际拿到哪个版本。灰度规则还要避免同一用户在不同请求间频繁跳版本;通常使用稳定哈希或持久化分组。

2. 监控不只是捕获异常

React 生产监控应覆盖不同失败层:

  • 构建失败:CI 中的 TypeScript、测试、Lint 和打包错误;
  • 加载失败:JS 404、模块加载错误、CDN 故障;
  • 渲染失败:错误边界捕获的组件异常;
  • hydration 失败:服务端和客户端输出不一致;
  • 交互失败:请求失败、Action 失败、超时;
  • 性能问题:首次内容、交互延迟、长任务、页面转场;
  • 业务问题:支付失败率、保存成功率、登录成功率。

错误事件应包含版本、路由、浏览器、请求 ID 和脱敏后的用户上下文。例如资源加载失败时,单纯记录“白屏”无法区分是发布错误、CDN 缓存错误还是浏览器扩展拦截。

3. 回滚要验证,而不是只执行命令

一个可验证的回滚流程是:

发现异常
  ↓
停止继续扩散灰度
  ↓
确认当前版本与影响范围
  ↓
切换到上一稳定版本
  ↓
验证入口 HTML、JS 资源、API 兼容性
  ↓
观察错误率和核心业务指标
  ↓
保留失败版本日志,定位根因

回滚静态资源时尤其要注意缓存。若旧 HTML 引用的哈希资源已经被删除,即使 HTML 回滚也可能继续 404。因此带哈希资源通常应保留多个发布周期,不能发布后立即清理。

如果数据库迁移不可逆,应用回滚还可能因为旧代码无法读取新 schema 而失败。生产发布应优先采用向后兼容的迁移顺序:

先增加兼容字段/接口
  ↓
发布能同时读旧新结构的应用
  ↓
迁移数据
  ↓
切换读写路径
  ↓
确认无旧版本实例后再删除旧字段

这属于应用与后端协同的发布问题,不能仅靠 React 层解决。


十二、一个完整的学习与验证顺序

阶段一:先掌握 JavaScript 和 TypeScript 前置能力

必须能够独立理解:

  • 函数、闭包和词法作用域;
  • Promise、async/await 和异常传播;
  • 数组不可变操作;
  • 模块导入导出;
  • TypeScript 泛型、联合类型、类型缩小;
  • DOM 事件和 HTTP 基础。

React 的状态快照本质上会放大闭包语义;如果不了解闭包,就容易误以为 setState 应立即改变当前变量。

阶段二:掌握渲染模型

依次验证:

  1. JSX 如何转换为元素描述;
  2. render 如何创建或更新宿主节点;
  3. Render 与 Commit 的区别;
  4. 同类型节点如何复用;
  5. 类型变化如何导致卸载与挂载;
  6. key 如何决定列表项身份;
  7. Strict Mode 为什么会帮助发现副作用问题。

可以通过一个带输入框的可插入列表实验 key

  • 先用索引作为 key;
  • 在首部插入一项;
  • 观察输入状态错位;
  • 再改用稳定 ID;
  • 比较组件身份变化。

这个实验比背诵“不要用 index key”更能说明因果。

阶段三:掌握状态和 Hooks

依次实现:

  • 计数器:比较直接更新和函数式更新;
  • 表单:理解受控组件;
  • Reducer 表单:建模提交、成功、失败;
  • WebSocket 订阅:练习 Effect cleanup;
  • 可取消请求:处理依赖变化和竞态;
  • useRef 聚焦 DOM;
  • useTransition 区分输入和列表更新;
  • Error Boundary 和 Suspense 边界。

每个练习都应观察三个问题:

什么触发了 Render?
哪些值属于当前 Render 快照?
哪些操作发生在 Commit 之后?

阶段四:理解 TypeScript 与数据边界

将组件 props、Reducer Action 和异步状态全部类型化;然后故意让 API 返回错误数据,验证编译期类型无法保护运行时边界,再加入运行时校验。

这一步可以避免一种危险误解:写了 interface User 并不意味着服务器真的返回了符合接口的对象。

阶段五:进入 SSR、Server Components 和框架

先理解 CSR 与 SSR 的区别,再学习:

  • createRoothydrateRoot
  • hydration 一致性;
  • 服务端数据获取;
  • 客户端模块边界;
  • Server Components 的序列化和代码发送范围;
  • 框架的路由、缓存、错误页和部署模型;
  • React 19 的表单 Action、useActionState 和乐观更新能力。

框架 API 的文件名和配置会变化,React 核心的渲染、状态和组件身份模型不会因此消失。遇到框架特性时,应先判断它属于:

React 核心 API
React DOM API
RSC/框架集成能力
构建工具能力
部署平台能力

这样能避免把框架约定误认为所有 React 项目都支持的规范。

阶段六:以生产故障验证架构

至少演练以下故障:

  • 发布 HTML 早于静态资源;
  • CDN 缓存了旧入口;
  • hydration 因时间和随机数不一致;
  • API 超时和重复提交;
  • CSP 阻止了必要脚本;
  • 灰度版本错误率上升;
  • 回滚后旧代码与新数据库 schema 不兼容;
  • 错误边界显示降级页但监控没有收到事件。

学习路线的终点不是“记住更多 Hook”,而是能够从一次点击开始,解释它如何进入事件处理器、产生更新队列、触发新的 Render、经过协调和 Commit 到达 DOM;也能够从一次生产白屏反向追踪到版本、资源、边界、网络和回滚路径。

React 的核心抽象可以最后压缩成一条可验证的链:

输入(props、state、context、外部 store)
  ↓
纯的组件计算
  ↓
元素树
  ↓
Fiber 工作与 Reconciliation
  ↓
Commit 到宿主环境
  ↓
Effect/事件/外部系统反馈
  ↓
下一次输入

掌握这条链,才能在 React 19、TypeScript、SSR、Server Components 或具体框架发生版本变化时,继续判断哪些行为是 React 的规范保证,哪些只是当前实现,哪些是应用层需要自行承担的工程取舍。

完整学习目录

一、核心模型

  1. React 项目工具链:Vite、TypeScript、Lint、环境变量和构建
  2. React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
  3. React 组件与 Props:纯渲染、组合、Children 和 API 契约

二、Hooks 与交互

  1. React State 与 Hooks:useState、useReducer、批处理和状态快照
  2. React Effect 完整指南:同步外部系统、依赖、清理和竞态
  3. React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
  4. React 表单工程:受控组件、校验、异步提交、错误与性能

三、状态与数据

  1. React Context 与 Reducer:跨层状态、更新边界和可测试设计
  2. React Router 完整指南:嵌套路由、Loader、Action、错误与权限
  3. React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
  4. React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界

四、工程质量

  1. React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型
  2. React 测试体系:Testing Library、Vitest、用户行为和端到端测试
  3. React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
  4. React 并发渲染:Transition、Deferred Value、Suspense 和一致性
  5. React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
  6. React 前端安全:XSS、dangerouslySetInnerHTML、URL、Token 和依赖
  7. React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断

五、架构与服务端

  1. React 组件库与设计系统:组合 API、Token、主题和版本治理
  2. React Server Components:服务端边界、序列化、缓存和客户端交互
  3. Next.js 应用架构:路由、渲染、数据、缓存、Server Actions 和部署
  4. React 生产交付:配置、静态资源、CSP、灰度、监控和回滚

一、核心模型

  1. React 事件系统:合成事件、传播、优先级、闭包和原生事件
  2. React 状态建模:最小状态、派生值、归一化和所有权
  3. React Reducer 深入:Action、不可变更新、初始化和状态机
  4. React 自定义 Hook:契约、组合、副作用、稳定性和测试
  5. React Memo、useMemo 与 useCallback:引用、成本和正确边界
  6. React useLayoutEffect 与 useInsertionEffect:时机、测量和闪烁
  7. React Ref 与 useImperativeHandle:焦点、测量和命令式 API
  8. React Portal:事件、焦点、层级、可访问性和弹窗架构
  9. React lazy 与 Suspense:代码加载、边界、错误和用户体验
  10. React useSyncExternalStore:外部状态、一致快照、订阅和 SSR
  11. React 身份与 useId:Key、DOM ID、水合和列表稳定性

二、Hooks 与交互

  1. React 表单与 Action:原生提交、状态、乐观更新和错误
  2. React Hook Form:字段注册、Schema、动态表单和性能
  3. React 文件上传:拖拽、分片、进度、取消、重试和预览
  4. React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
  5. React 表格与虚拟列表:列模型、窗口、动态高度和交互
  6. React 样式工程:CSS Modules、CSS-in-JS、原子 CSS 和主题
  7. React 动画:CSS、Transition、Motion、布局动画和减少动态
  8. React 与 Web Components:属性、事件、Ref、类型和互操作

三、状态与数据

  1. React Router 数据路由:Loader、Action、Revalidation 和错误边界
  2. React 登录与权限:路由、组件、Token 刷新、403 和状态恢复
  3. TanStack Query:缓存键、失效、乐观更新、分页和离线
  4. Redux Toolkit 深入:Slice、Thunk、Listener、RTK Query 和测试
  5. Zustand 状态管理:Store、Selector、订阅、持久化和 SSR
  6. React 实时数据:WebSocket、SSE、重连、心跳和缓存同步

四、工程质量

  1. React 单元测试:纯函数、Hook、时间、网络和稳定断言
  2. React 组件集成测试:用户行为、异步 UI、Provider 和边界
  3. React 端到端测试:Playwright、登录态、网络、并行和失败证据
  4. React 视觉回归:截图基线、字体、动画、阈值和审阅
  5. React Storybook:Story、交互测试、文档、主题和评审
  6. React DevTools 与 Profiler:提交、更新来源、火焰图和诊断
  7. React Bundle 分析:Tree Shaking、分包、预加载和性能预算
  8. React PWA:Service Worker、缓存更新、离线和安装体验
  9. React 国际化:消息目录、Locale、日期数字、路由和回退

五、架构与服务端

  1. React Monorepo:Workspace、共享包、构建图、版本和边界
  2. React 组件库发布:构建、类型、样式、Tree Shaking 和版本
  3. React 微前端:路由、状态、样式、依赖隔离和迁移取舍
  4. React 流式 SSR:Shell、Suspense、错误、缓存和断流恢复
  5. React 水合诊断:不一致、事件绑定、客户端边界和调试
  6. React CI 质量流水线:类型、Lint、测试、构建、预览和制品
  7. React 静态交付:CDN、缓存、路由回退、压缩和回滚
  8. Next.js 路由与布局:Segment、并行路由、拦截和错误边界
  9. Next.js 数据与缓存:请求记忆、Data Cache、Revalidation 和标签
  10. Next.js Server Actions:序列化、认证、校验、重放和渐进增强
  11. Next.js Node 与 Edge Runtime:API、限制、依赖和部署选择
  12. Next.js SEO:Metadata、结构化数据、Canonical、站点地图和 OG 图

四、工程质量

  1. React Compiler:自动记忆化、编译约束、迁移、诊断和性能验证

系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。