React 的 API 不算多。学会 useState、事件处理和列表渲染,很快就能写出一个可用的页面。难点在于换一种方式理解界面:组件在每次渲染时根据输入重新计算 UI,数据沿组件树向下流动,用户操作再通过事件把变化送回拥有数据的地方。

这篇教程写给有 JavaScript 基础、尤其是熟悉 Vue 的读者。正文从数据如何进入组件、如何发生变化讲起,再逐步讨论 Effect、性能和底层实现。文中的示例默认使用函数组件、TypeScript 和现代 JSX 转换。

0x00. Vue 与 React

1. 本质

Vue 与 React 面对的是同一个问题:根据数据声明界面,并在数据变化后更新 DOM。两者的分歧主要落在“框架如何知道哪里变了”。

Vue 通过 refreactive 等响应式 API 跟踪依赖。模板或渲染函数读取响应式数据时,Vue 会记录这次读取;数据变化后,框架便能为相关部分安排更新。模板编译器还会提前区分静态节点与动态绑定,进一步减少运行时工作。

React 的基本模型更接近一次普通的函数求值。State 更新后,React 会再次调用相关组件,得到一棵新的元素树,再通过协调过程找出需要提交到 DOM 的宿主节点。组件函数重新执行时,DOM 并不会随之整体重建;React 只提交两次渲染结果之间的必要变化。

可以用一个近似公式概括这套模型:

UI = f(props, state, context)

Vue 把响应式系统做成显式能力;React 默认重新计算组件,再借助协调、调度和编译期优化控制成本。脱离项目场景比较两条路线的高下没有多少意义。选型时,团队经验、生态和工程约束通常比细微的渲染差异更能决定开发成本。

2. 不再 ref()reactive()

从 Vue 转到 React,人们很容易为 ref()reactive() 寻找一一对应的 API。useState 同样保存状态,但它的读写语义与 Vue 的响应式容器不同。

import { useState } from "react";

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

  return (
    <button type="button" onClick={() => setCount((value) => value + 1)}>
      点击了 {count} 次
    </button>
  );
}

count 是本次渲染拿到的快照,setCount 则把更新排入队列,请求下一次渲染。执行 setCount 后,当前函数作用域里的 count 仍保持原值。useState 保存的正是这样一组“当前快照与更新入口”,并非一个省略了 .value 的 Vue Ref。

React 也有一个名为 useRef 的 Hook。Ref 可以保存 DOM 节点,或保存需要跨渲染保留的可变值;修改 ref.current 不会触发渲染。需要显示在页面上的数据通常放进 State,Ref 则留给命令式代码使用。

3. Vue Vine

Vue Vine 恰好说明,组件如何组织与框架如何响应数据变化是两个独立问题。Vine 允许在 .vine.ts 文件里用普通函数组织多个 Vue 组件,把逻辑、模板与样式放在相近的位置,减少编写小组件时的文件跳转。

这种组织方式会让 React 开发者感到熟悉,不过 Vine 仍然运行在 Vue 的响应式与模板编译体系之上。函数式文件没有改变 Vue 的更新机制,JSX 也无法概括 React 的全部。学习 React 时,更值得留意的是纯渲染、不可变更新和明确的数据所有权;这些原则放回 Vue 项目依然成立。

0x01. 前置知识

1. ECMAScript 6 之后的 JavaScript

React 应用大量使用现代 JavaScript。开始之前,至少要熟悉解构赋值、数组的 mapfilter、展开语法、模板字符串、模块、Promise、async/await 和闭包。

const user = { id: 7, name: "Alex", roles: ["author", "editor"] };
const { name, roles } = user;

const labels = roles.map((role) => `${name}: ${role}`);
const nextUser = { ...user, roles: [...roles, "reviewer"] };

这段代码的关键在于引用与不可变更新。{ ...user } 只完成一层浅复制;修改嵌套对象时,变化路径上的每一层都要复制。React 会通过对象身份判断许多更新能否跳过,直接修改原对象会抹掉新旧值的边界,也让渲染行为变得难以追踪。

闭包同样绕不开。事件处理函数会捕获创建它的那次渲染中的 Props 和 State。回调读到旧值,是因为它仍持有旧渲染的快照,React 并没有延迟修改变量。

类、迭代器和生成器仍是 JavaScript 的组成部分,但学习现代 React 无须先掌握它们。Class Component 主要出现在旧项目中,新组件通常直接写成函数。

2. JSX

JSX 是 JavaScript 的语法扩展,用来把界面结构写在相关逻辑附近。它看起来像 HTML,编译后得到的则是描述界面的普通 JavaScript 对象,也就是 React 元素。

const greeting = <strong className="greeting">你好,{user.name}</strong>;

JSX 可以由 TypeScript、Babel、SWC 等工具转换。现代的 automatic JSX runtime 通常生成 jsx/jsxs 调用,也不要求每个文件都显式导入 ReactReact.createElement() 更多见于旧工具链。可以确定的是 JSX 最终会变成 JavaScript,至于由谁转换、生成什么调用,则取决于构建配置。

JSX 的规则不多:

  • 一个表达式只能返回一个根节点;不需要额外 DOM 时使用 Fragment,也就是 <>...</>
  • 标签必须闭合,<img /><input /> 也不例外。
  • DOM 属性大多使用小驼峰形式,例如 classNamehtmlForonClick
  • 小写标签被视为宿主元素,大写名称被视为自定义组件。
  • 花括号中可以放 JavaScript 表达式,但不能直接放 iffor 这类语句。

JSX 让一起变化的结构与逻辑靠得更近,但这并不鼓励把所有代码塞进一个文件。组件仍应按照数据归属和职责划分。

3. TypeScript

TypeScript 能在重构 Props、接口返回值和状态分支时提前发现错误。React 开发中常用到 typeinterface、联合类型、泛型,以及 Pick<T, K>Omit<T, K>Partial<T>Record<K, V> 等工具类型。

type UserCardProps = {
  name: string;
  role: "admin" | "member";
  avatarUrl?: string;
};

export function UserCard({ name, role, avatarUrl }: UserCardProps) {
  return (
    <article>
      {avatarUrl ? <img src={avatarUrl} alt="" /> : null}
      <strong>{name}</strong>
      <span>{role === "admin" ? "管理员" : "成员"}</span>
    </article>
  );
}

React.FC<UserCardProps> 是一种合法写法,却没有必要成为项目规定。直接标注参数更简洁,TypeScript 也能推导返回类型。确实需要约束返回值或统一公共签名时,再显式使用 ReactNode 等类型。

对于只表示一组固定值的业务状态,字符串字面量联合往往比 enum 更轻,序列化后也更直观:

type RequestStatus = "idle" | "pending" | "success" | "error";

0x02. 拥抱 React 思想

1. UI = f(props, state)

组件负责把输入映射为 UI。给定相同的 Props、State 和 Context,渲染结果也应相同。网络请求、订阅、写入 DOM 和修改外部变量都应留在渲染过程之外。

type PriceProps = {
  amount: number;
  currency: "CNY" | "USD";
};

function Price({ amount, currency }: PriceProps) {
  const formatted = new Intl.NumberFormat("zh-CN", {
    style: "currency",
    currency,
  }).format(amount);

  return <data value={amount}>{formatted}</data>;
}

formatted 完全由 Props 决定,可以在渲染时直接计算。若再存一份 State,同一个值便有了两个需要保持同步的来源。

2. 脱离原生与 Vue 的思维

原生 DOM 编程常写成一连串命令:找到节点、修改文字、切换类名、禁用按钮。React 组件描述的是每种数据状态对应的页面结果。

function SubmitButton({ pending }: { pending: boolean }) {
  return (
    <button type="submit" disabled={pending}>
      {pending ? "提交中..." : "提交"}
    </button>
  );
}

点击时只需更新 pending,文字和 disabled 属性会随下一次渲染一起变化。若一种变化能由 State 表达,便无须再查询节点、修改 textContent 或操作 classList

从 Vue 转来时,尤其要留意“创建一个响应式对象,再到处修改字段”的习惯。在 React 中,每份 State 都应有明确的所有者;数据通过 Props 向下传递,变化意图通过事件回到所有者。

3. 数据为先

面对一张设计稿,沿着视觉边框拆组件往往为时过早。梳理页面需要的数据,分清原始数据、可推导数据和局部交互状态,组件边界随后会清晰许多。

以商品搜索页为例,服务端返回的 products 是原始数据,用户输入的 queryinStockOnly 是交互状态,过滤后的列表则是派生数据:

const visibleProducts = products.filter((product) => {
  const matchesQuery = product.name
    .toLocaleLowerCase()
    .includes(query.toLocaleLowerCase());

  return matchesQuery && (!inStockOnly || product.stock > 0);
});

visibleProducts 由现有输入完整决定,无须进入 State,渲染时计算即可。只有当数据量已经拖慢交互时,才需要考虑 useMemo

4. 思考数据的流向

React 默认使用单向数据流:父组件通过 Props 向下传递数据,子组件通过回调报告发生了什么。回调名称应表达用户意图,例如 onQueryChangeonRemovesetParentValue 这类名字会把父组件的实现细节泄漏给子组件。

type SearchBoxProps = {
  query: string;
  onQueryChange: (nextQuery: string) => void;
};

function SearchBox({ query, onQueryChange }: SearchBoxProps) {
  return (
    <input
      value={query}
      onChange={(event) => onQueryChange(event.target.value)}
      placeholder="搜索商品"
    />
  );
}

这是一个受控输入框:显示内容由 query 决定,输入事件只报告下一值。父组件始终持有这份数据。

5. 沿数据流自然地构建组件

一段 UI 有独立职责、同一数据结构需要重复渲染,或者某份局部状态只影响一个子树时,组件边界通常已经出现。完整的数据流会暴露这些边界,据此提取组件即可;过早建立多层抽象,反而会遮住数据的去向。

在商品页中,ProductPage 持有查询条件,SearchBox 负责输入,ProductList 负责列表,ProductRow 负责单项展示。数据一路向下,事件一路向上。每个组件从哪里取数据、又把事件交给谁,都可以顺着这条路线找到。

0x03. 构建纯粹的组件

1. 组件原型

最普通的 React 组件就是一个首字母大写的函数。它接收 Props,并返回 React 可以渲染的内容:元素、字符串、数字、数组、Fragment 或 null

type BadgeProps = {
  children: React.ReactNode;
  tone?: "neutral" | "success";
};

function Badge({ children, tone = "neutral" }: BadgeProps) {
  return <span data-tone={tone}>{children}</span>;
}

State 不会作为函数的第二个参数传入,函数组件要通过 Hook 读取自身状态。业务代码也不应直接调用组件函数;写成 <Badge /> 后,React 才能管理组件身份与对应的 Hook 状态。

2. Props

Props 是父组件传入的只读快照。子组件若要引发变化,应通过回调报告事件,再由数据所有者更新 State 并传入新的 Props。

children 也是普通 Prop,表示开始标签与结束标签之间的内容。默认值可以直接写在参数解构中。基础 UI 组件常会透传其余属性;业务组件则要明确筛选,避免把无效属性带到 DOM 上。

type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
  variant?: "solid" | "quiet";
};

function Button({ variant = "solid", type = "button", ...props }: ButtonProps) {
  return <button type={type} data-variant={variant} {...props} />;
}

3. 条件渲染

简单条件可以使用提前返回、三元表达式或逻辑与。分支变多后,把判断移到 JSX 外,通常比嵌套表达式更容易读。

function Result({ error, data }: ResultProps) {
  if (error) return <p role="alert">加载失败:{error.message}</p>;
  if (!data) return <p>正在加载...</p>;

  return <Report data={data} />;
}

count && <Badge /> 有一个容易忽略的细节:count0 时,页面会渲染数字 0。左侧不一定是布尔值时,写成 count > 0 && <Badge /> 可以避开这个问题。

4. 列表渲染

列表通常通过 map 转成元素。每个同级元素都需要稳定且唯一的 key,React 才能在插入、删除和排序后把节点与组件状态对应到正确的数据项。

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

数据库主键或创建数据时生成的 ID 都适合作为 key。数组下标不适合会重排的列表,渲染期间调用 Math.random() 则会让 key 每次都变化。key 只供 React 判断身份,不会自动进入子组件的 Props;子组件需要 ID 时仍要单独传入 id

5. 嗯,记得保持纯粹

组件渲染期间可以读取 Props、State 和 Context,也可以创建局部对象、执行纯计算。修改外部变量、改写传入对象、启动计时器和发送请求都会让渲染产生副作用,应放到事件或 Effect 中处理。

开发环境中的 Strict Mode 可能额外调用组件、State 初始化函数和更新函数,以暴露不纯逻辑。日志出现两次时,应检查渲染过程是否产生了副作用。纯组件无论调用一次还是两次,结果都应一致。

0x04. 组件的记忆:State

1. State 与响应式

普通局部变量无法充当组件记忆。组件重新渲染时,函数会再次执行,局部变量随之重新创建;修改普通变量也不会通知 React 更新页面。

useState 同时解决这两个问题:React 在组件身份对应的位置保存值,并提供一个触发更新的函数。

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

State 应尽量精简,只保留无法从 Props 或其他 State 推导的数据。同一个事实保存两份,很容易失去同步。例如,selectedItemselectedId 通常不必同时存在;保存 ID,再从列表中查找对象即可。

2. 快照

每次渲染都会拿到固定的 State 快照,事件处理函数也属于那次渲染。下面的代码不会改变当前回调中的 count

function handleClick() {
  setCount(count + 1);
  console.log(count); // 仍然是本次渲染的值
}

因此,延迟回调经常会读到旧值。反过来看,一个事件开始执行后,其中的 Props 和 State 也不会在半路突然变化;整段回调始终基于同一次渲染。

3. 操作 State

下一状态依赖前一状态时,传入更新函数:

setCount((count) => count + 1);

React 会把同一事件中的更新加入队列并进行批处理。连续三次执行 setCount(count + 1),读取的都是同一个旧快照;改用函数式更新后,三个更新才会依次以上一次结果为输入。

对象和数组 State 应视为只读值,通过复制产生下一版本:

setProfile((profile) => ({
  ...profile,
  address: {
    ...profile.address,
    city: "杭州",
  },
}));

setTodos((todos) =>
  todos.map((todo) =>
    todo.id === targetId ? { ...todo, completed: true } : todo,
  ),
);

直接执行 profile.address.city = "杭州" 再交回同一个对象,会破坏对象身份所表示的新旧边界。按身份比较、撤销记录和调试工具都会因此失去可靠前提。

4. 用 State 响应输入

复杂交互不适合用多组布尔值拼接状态。表单请求若同时拥有 isLoadingisSuccesshasError,就可能出现多个值一起为 true。联合类型可以把状态限制在合法分支内:

type FormState =
  | { status: "editing" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string };

事件只负责在这些分支之间转换:输入发生时回到 editing,提交时进入 submitting,请求结束后进入 successerror。状态和事件一一对应后,DOM 如何显示便只是渲染结果。

5. 在组件间共享 State

两个组件需要保持同步时,可以把 State 移到最近的共同父组件,再通过 Props 分发。这个过程称为“提升 State”。

function TemperaturePanel() {
  const [celsius, setCelsius] = useState(20);

  return (
    <>
      <CelsiusInput value={celsius} onChange={setCelsius} />
      <FahrenheitOutput value={(celsius * 9) / 5 + 32} />
    </>
  );
}

这里的唯一数据源是摄氏温度,华氏温度由它计算得出。若两个输入框分别保存 State,再用 Effect 互相同步,循环更新与精度偏差便很难避免。

State 还与组件在树中的位置绑定。相同类型的组件留在相同位置时会保留 State;类型或 key 改变后,React 会创建新的组件身份,并重置对应 State。表单需要随实体切换而整体重置时,一个随实体变化的 key 通常就能表达意图。

0x05. 更进一步的 State 管理

1. Context

Context 让深层组件读取上方 Provider 提供的值,适合主题、语言、当前用户和依赖注入等需要跨越多层传递的数据。

const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext value={theme}>
      <Toolbar
        onToggleTheme={() =>
          setTheme((value) => (value === "light" ? "dark" : "light"))
        }
      />
    </ThemeContext>
  );
}

Context 缩短了传递路径,却不会自动整理状态结构、更新逻辑或渲染性能。只经过一两层的数据继续使用 Props,来源通常更容易追踪。Provider 的值频繁变化时,所有消费者都可能重新渲染,因此不同变化频率、不同职责的数据应拆成独立 Context。

2. Reducer

多个 State 总是一起变化,或者更新分支已经散落到许多事件中时,可以用 useReducer 集中描述状态转换。Action 也会明确记录每次更新“发生了什么”。

type State = { count: number };
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset"; value: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: action.value };
  }
}

Reducer 必须保持纯粹:接收当前状态与 Action,返回下一状态。发送请求和写入本地存储都应留在 Reducer 之外。TypeScript 的可辨识联合还能让 switch 的每个分支获得准确类型。

Reducer 与 Context 经常组合使用:前者集中更新规则,后者把状态和 dispatch 传到深层组件。这种组合适合更新较多的局部业务域;状态简单的页面继续使用 useState 即可。

3. Redux?爆金币了。

当状态跨越许多无关页面,更新链路还涉及中间件、时间旅行调试或严格的团队约定时,外部状态库可以把这些规则集中起来。Redux 拥有成熟的单向数据流和工具生态,同时也会引入额外概念与样板。现代项目通常直接使用 Redux Toolkit,省去早期 Redux 的大量模板代码。

引入之前,需要确认这份状态是否真的属于客户端全局,组件组合或 Context 是否已经足够,以及数据是否来自服务端。需要缓存、重试和重新验证的服务端数据,通常应交给 SWR、TanStack Query 或框架的数据层管理,复制进 Redux 只会增加一层同步。

状态库不会替你决定数据归属。把所有数据都放进全局 Store,会让组件之间形成更隐蔽的耦合。局部交互状态留在组件附近,确实出现跨页面共享需求时再提升。

0x06. 锋利的双刃剑:Hook

Hook 让函数组件接入 State、Context、Effect 等 React 能力。除 use 外,Hook 必须在组件或自定义 Hook 的顶层调用,不能放进条件、循环、事件回调或普通函数。稳定的调用顺序让 React 可以把每次 useStateuseEffect 对应到同一条内部记录。React 19 的 use 可以出现在条件和循环中,但仍只能在组件或自定义 Hook 内调用,也不能放进 try/catch

1. 使用 Ref 操作 DOM

聚焦、滚动、选区和媒体播放等命令无法只靠声明式 Props 表达,此时可以用 Ref 取得 DOM 节点:

function SearchForm() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <form>
      <input ref={inputRef} type="search" />
      <button type="button" onClick={() => inputRef.current?.focus()}>
        聚焦搜索框
      </button>
    </form>
  );
}

DOM Ref 应在事件或 Effect 中读写,渲染过程不要依赖 ref.current。节点的文字、类名或显隐仍然适合由 JSX 与 State 描述。

Ref 也能保存不参与渲染的数据,例如计时器 ID、上一次指针位置或第三方实例。如果某个值的变化需要显示在页面上,它就应进入 State。

2. 使用 Effect 与外部系统同步

Effect 负责让组件与 React 之外的系统保持同步,例如 WebSocket、浏览器事件、地图实例、原生媒体 API 或第三方组件。一段 Effect 通常同时包含连接与对称的清理:

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

  return () => connection.disconnect();
}, [roomId]);

依赖数组列出的是 Effect 读取的响应式值,不能拿来随意控制执行时机。代码读取了 roomId,依赖中就要包含 roomId。清理函数会在依赖变化、下一次连接建立之前执行,组件卸载时也会执行。

浏览器端可以直接在 Effect 中请求数据,但竞态、取消、缓存、服务端渲染和瀑布请求都要自行处理。框架或数据请求库通常已经覆盖了这些问题。

3. 你真需要用 Effect 吗?

如果没有外部系统,大多数情况都用不到 Effect。下面这些工作各有更直接的位置:

  • 根据 Props 或 State 计算展示数据:直接在渲染期间计算。
  • 响应一次点击或提交:放进对应事件处理函数。
  • 通知父组件输入变化:在同一个事件里调用回调。
  • 因实体变化而重置整个子树:使用稳定边界上的 key
  • 缓存昂贵计算:确认瓶颈后使用 useMemo,不要用 Effect 复制结果。

下面的写法会先渲染一次旧值,再由 Effect 触发第二次渲染,同时还要维护两份数据的同步:

// 不需要的 Effect
const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

直接写成一行即可:

const fullName = `${firstName} ${lastName}`;

4. 移除对 Effect 的依赖

Lint 提示依赖缺失时,直接删掉依赖项只会掩盖问题。由用户操作触发的逻辑应移入事件,派生数据应回到渲染过程,订阅则保留在 Effect 中,并让建立与清理只读取必要值。

对象和函数在每次渲染时都会获得新身份,作为依赖时可能让 Effect 反复执行。对象可以在 Effect 内创建,与渲染无关的常量则可以移到组件外。缩小 Effect 的职责后,许多 useMemouseCallback 也就没有必要了。

有时 Effect 需要读取最新值,却不希望这个值变化时重新建立外部连接。可以重新划分事件边界,或在支持的 React 版本中用 useEffectEvent 表达非响应式的 Effect 事件。用 Ref 隐藏所有依赖虽然能让 Lint 安静下来,却也隐藏了同步关系。

5. 使用 Memo 做性能优化

useMemo 缓存计算结果,useCallback 缓存函数身份,memo 则可以在 Props 未变化时跳过组件重新渲染。这些 API 只用于性能优化,代码不能依赖缓存来保证正确性。

const visibleRows = useMemo(() => filterRows(rows, query), [rows, query]);

filterRows 确实耗时,或者稳定身份能让下游的 memo、Effect 依赖减少工作时,这层缓存才有收益。缓存本身也要比较依赖、保存值,还会增加阅读成本。

React Compiler 可以在构建期自动加入许多等价的记忆化优化,从而减少手写 Memo。它无法修复不纯组件、错误的数据流或昂贵的业务算法。启用之前仍要检查构建链支持情况,并确认现有代码遵守 React 规则。

6. 你的优化,真有必要吗?没必要。

组件纯粹、State 靠近使用位置、结构清晰之后,再用 React DevTools Profiler 和浏览器性能工具测量。相比一次普通的组件函数调用,错误的全局状态、重复网络请求和巨大的列表 DOM 更容易造成可感知的卡顿。

常见的性能问题包括过早提升 State、把许多高频值塞进同一个 Context、Effect 形成更新链,以及一次渲染创建过多节点。此时应先修正数据所有权和组件边界,在每个函数外套缓存只会遮住问题。

一次有依据的优化应该能说清卡顿发生在什么交互、性能记录把时间花在了哪里,以及改动后指标改善了多少。缺少这些信息时,继续测量比添加缓存更有用。

7. 其他 Hooks

其余常用 Hook 可以按职责归类:

  • useReducer 管理结构化状态转换,useContext 读取跨层数据。
  • useTransitionuseDeferredValue 降低非紧急更新的优先级,保持输入响应。
  • useId 生成适合可访问性属性的稳定 ID,不能用来生成列表 key。
  • useSyncExternalStore 以并发安全的方式订阅 React 外部 Store。
  • React 19 的 use 可以在支持 Suspense 的边界中读取 Promise 或 Context,但数据源应提供稳定、可缓存的 Promise。

自定义 Hook 复用的是有状态逻辑,每次调用仍拥有独立状态,不会自动创建全局共享 State。名称必须以 use 开头,便于 Lint 和读者识别其中的 Hook 调用。

0x07. React 的底层

1. Compiler

JSX 编译器负责把 JSX 语法转换为 JavaScript;React Compiler 则分析组件与 Hook,自动对值和元素进行记忆化,尽量跳过无意义的重新计算。两者处在不同的编译阶段,也解决不同问题。

React Compiler 已经稳定,目前仍作为可选的构建期能力接入。它依赖代码遵守 React 规则:纯组件、不可变数据和正确的 Hook 调用顺序,都是静态分析成立的前提。Compiler 只优化 React 组件与 Hook 内的计算,也不会生成跨组件共享的通用缓存。

2. Fiber

React 元素是一次渲染得到的不可变描述,Fiber 是 React 在多次渲染之间维护的工作节点。每个 Fiber 对应组件树中的一个位置,记录组件类型、Props、State、子节点关系和待处理工作等信息。

Fiber 通过 childsiblingreturn 等关系把树拆成可遍历的工作单元。React 使用当前树保存已提交界面,同时在工作树上计算下一候选结果。协调阶段找出变化,提交阶段再把必要修改应用到 DOM。

3. Scheduler

更新有不同的紧急程度。输入回显应该立刻响应,搜索结果或复杂图表则可以稍晚完成。React 按优先级组织更新,渲染阶段可以暂停、继续,也可以放弃已经过时的工作。

startTransition 不会缩短计算时间,它只是告诉 React 这次更新可以让位给更紧急的交互。渲染阶段允许中断,提交阶段必须一次完成,用户不会看到只更新了一半的组件树。

因此,组件函数可能已经执行,其结果却从未提交。React 有权重试或丢弃尚未提交的计算,渲染代码也就必须保持纯粹。

4. useState 的原理

从概念上看,函数组件对应的 Fiber 保存着一组按调用顺序排列的 Hook 记录。首次渲染时,useState 创建状态与更新队列;后续渲染时,React 按相同顺序找到原记录,再依次应用队列中的更新,得到本次渲染使用的 State。

setState 会把更新加入队列,标记相关 Fiber 有待处理工作,并安排一次渲染;当前函数里的变量不会随调用发生变化。React 根据更新优先级决定处理时机。若 Object.is 判断新旧值相同,后续工作还可能被跳过。

这套内部实现可能随版本变化,业务代码不应依赖其中细节。它仍能帮助我们理解两条公开规则:Hook 必须保持调用顺序;State 是渲染快照,Setter 只负责安排下一次更新。

0x08. 实际应用中的 React

1. Next.js

完整应用除了客户端组件,还需要路由、构建、数据获取、服务端渲染、缓存和部署约定。React 官方建议新应用从框架开始,Next.js 是生态中常见的选择。

在 Next.js App Router 中,组件默认运行在服务端。Server Component 可以直接读取服务端数据,并减少发送到浏览器的 JavaScript。只有需要 State、事件或浏览器 API 的交互边界才使用 "use client"。Client Component 的范围太大会增加客户端负担,拆得太碎又会让一次小交互跨越许多文件,边界应跟随实际交互划分。

Next.js 的路由、缓存和渲染模型也需要额外学习。对于已有独立 API 的纯客户端后台,Vite 加客户端路由可能更直接。部署方式与数据来源决定框架选择,流行程度不应成为唯一理由。

2. SWR 与 Alova.js

服务端状态要处理请求去重、缓存、失效、重试、分页、乐观更新和竞态,本地 UI State 很少面对这些问题。SWR 用较小的 API 实现 stale-while-revalidate 数据获取模型;Alova.js 提供的请求策略更多,也支持多个前端框架。

同一份远程数据不应同时复制到 useState、Context 和全局 Store。缓存由请求库管理,组件只保存筛选条件、选中项和弹窗状态等本地交互数据。在 Next.js 等全栈框架中,还要判断数据能否直接在服务端读取,许多请求无须再经过客户端 Hook。

选择请求库时,应检查它如何接入当前渲染模式,怎样定义缓存失效,是否妥善处理取消与竞态,以及 TypeScript 类型是否完整。API 数量和项目适配程度没有必然关系。

3. Zustand 与 Jotai

Zustand 和 Jotai 都提供了较轻的客户端共享状态方案。Zustand 以 Store 和 Selector 为中心,适合组织一组相关状态与动作;Jotai 通过 Atom 组合状态,适合细粒度依赖和自下而上的派生关系。

两者的起步代码都少于 Redux,但 Store 放在哪里、哪些状态允许全局修改、持久化数据如何迁移、服务端渲染如何隔离,仍需要明确约定。局部 State 足以覆盖大多数页面交互;出现跨页面共享、脱离组件树访问或复杂订阅后,再引入外部 Store。

回看全文,React 的开发顺序并不复杂:用纯组件描述 UI,用 Props 接收输入,用 State 保存最少的组件记忆,再沿单向数据流组织交互。与外部系统同步时使用 Effect,测到性能问题后再考虑 Memo;Context、Reducer 和状态库留给确实跨越组件边界的数据。

React 的 API 还会变化,数据所有权与纯渲染却很少过时。掌握这两点后,新的 Hook、Compiler 和框架能力都能放回同一套模型中理解。

延伸阅读