React 19 从入门到工程实践:核心原理、新特性与项目实战

本文以 React 19.2、TypeScript 和 Vite 为技术基线,从 JSX、组件、状态、Hooks 开始,逐步讲解 React 19 的 Actions、useActionStateuseOptimisticuseActivityuseEffectEvent 等能力,并完成一个可以独立运行的 TaskFlow 任务管理项目。

本文更新时间:2026 年 8 月。


1. React 是什么

React 是一个用于构建用户界面的 JavaScript 库。

它主要解决以下问题:

  • 如何把复杂页面拆分成可复用组件。
  • 如何根据数据状态自动更新界面。
  • 如何组织组件之间的数据传递。
  • 如何管理用户交互和异步操作。
  • 如何构建大型、可维护的前端应用。

React 的核心思想可以概括为:

text 复制代码
界面 = 状态的映射
UI = f(state)

当状态发生变化时,我们不需要手动找到某个 DOM 节点并修改它,而是描述"当前状态应该渲染出什么界面",React 负责完成更新。

例如:

tsx 复制代码
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      当前计数:{count}
    </button>
  );
}

当调用 setCount 后,React 会重新执行组件函数,根据新的 count 生成新的界面。

1.1 React 不是什么

React 本身主要负责 UI,并不直接提供完整的:

  • 路由系统。
  • 服务端数据库访问。
  • HTTP 请求缓存。
  • 权限系统。
  • 表单 Schema 校验。
  • 国际化方案。
  • 完整的工程构建方案。

大型项目通常还需要框架、构建工具和其他库。

1.2 React 的主要优势

  • 组件化,适合拆分复杂界面。
  • 声明式开发,减少手动操作 DOM。
  • 单向数据流,数据变化更容易追踪。
  • 生态成熟,可用于 SPA、SSR、SSG、移动端等场景。
  • TypeScript 支持良好。
  • 可以渐进式引入现有项目。

2. React 19 带来了什么

React 19.0 于 2024 年 12 月发布稳定版,随后发布了 React 19.1 和 React 19.2。

React 19 的重点不是改变 JSX 或组件的基本写法,而是进一步完善异步交互、表单提交、服务端渲染、资源管理和开发体验。

2.1 React 19.0 的主要变化

  • Actions 异步操作模型。
  • useActionState
  • useOptimistic
  • React DOM 表单 Action。
  • useFormStatus
  • use API。
  • 函数组件可以直接接收 ref 属性。
  • Context 可以直接作为 Provider。
  • 回调 ref 可以返回清理函数。
  • 原生支持渲染 <title><meta><link>
  • 更好的 Hydration 错误信息。
  • 更完善的 Custom Elements 支持。
  • React Server Components 相关能力稳定。

2.2 React 19.2 的主要变化

  • <Activity>
  • useEffectEvent
  • cacheSignal
  • React Performance Tracks。
  • Partial Pre-rendering。
  • Node.js 环境中的 Web Streams SSR 支持。
  • eslint-plugin-react-hooks v6。

2.3 本文使用的项目类型

本文项目采用:

text 复制代码
React 19 + TypeScript + Vite + 浏览器端 SPA

这是一个纯客户端项目,因此:

  • 可以学习 React 的核心能力。
  • 不依赖 Node.js 服务端。
  • 不使用 React Server Components。
  • 数据暂存在浏览器 localStorage
  • 后面可以把数据层替换成 REST API。

如果项目需要 SEO、服务端渲染、Server Components、Server Functions 或路由级数据加载,React 官方更推荐从支持完整 React 架构的框架开始,例如 Next.js App Router 或 React Router Framework Mode。


3. 创建 React 19 项目

3.1 环境要求

建议准备:

  • Node.js 24 LTS 或其他受当前 Vite 支持的 Node.js LTS 版本。
  • npm、pnpm、Yarn 或 Bun。
  • Visual Studio Code、WebStorm 等编辑器。
  • React Developer Tools 浏览器扩展。

检查环境:

bash 复制代码
node -v
npm -v

Vite 官方当前文档要求 Node.js 20.19+22.12+,部分模板可能要求更高版本。新项目建议直接使用仍在维护的 Node.js LTS。

3.2 使用 Vite 创建项目

bash 复制代码
npm create vite@latest taskflow -- --template react-ts
cd taskflow
npm install
npm run dev

打开:

text 复制代码
http://localhost:5173

如果需要显式更新到 React 19 最新补丁版本:

bash 复制代码
npm install react@latest react-dom@latest
npm install -D @types/react@latest @types/react-dom@latest

检查最终版本:

bash 复制代码
npm ls react react-dom

不建议再使用 Create React App 创建新项目。React 官方已经将其标记为弃用,新项目可以选择 React 框架,或者使用 Vite、Parcel、Rsbuild 等构建工具。

3.3 项目目录

Vite 创建后的核心结构如下:

text 复制代码
taskflow/
├─ public/
├─ src/
│  ├─ assets/
│  ├─ App.tsx
│  ├─ index.css
│  └─ main.tsx
├─ index.html
├─ package.json
├─ tsconfig.json
└─ vite.config.ts

3.4 React 应用入口

src/main.tsx

tsx 复制代码
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';

const rootElement = document.getElementById('root');

if (!rootElement) {
  throw new Error('找不到 #root 元素');
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

StrictMode 会在开发环境中帮助发现:

  • 不纯的渲染逻辑。
  • Effect 缺少清理的问题。
  • 已废弃 API。
  • 不安全的生命周期逻辑。

开发环境中某些组件、初始化函数或 Effect 可能执行两次,这是用于发现副作用问题的检查机制,不代表生产环境也会执行两次。


4. JSX 基础

JSX 是 JavaScript 的语法扩展,它允许我们在 JavaScript 或 TypeScript 中描述 UI。

tsx 复制代码
const element = <h1>Hello React</h1>;

JSX 最终会由构建工具转换成 JavaScript。

4.1 JSX 必须有一个根节点

错误写法:

tsx 复制代码
function Profile() {
  return (
    <h1>用户信息</h1>
    <p>这里是用户简介</p>
  );
}

正确写法:

tsx 复制代码
function Profile() {
  return (
    <>
      <h1>用户信息</h1>
      <p>这里是用户简介</p>
    </>
  );
}

<>...</> 是 Fragment,不会生成额外 DOM 元素。

4.2 标签必须闭合

tsx 复制代码
<img src="/avatar.png" alt="用户头像" />
<input type="text" />

4.3 属性使用驼峰命名

tsx 复制代码
<button
  className="primary-button"
  onClick={() => console.log('clicked')}
>
  点击
</button>

常见区别:

HTML JSX
class className
for htmlFor
onclick onClick
tabindex tabIndex

aria-*data-* 属性仍然保留短横线:

tsx 复制代码
<button aria-label="关闭弹窗" data-testid="close-button">
  ×
</button>

4.4 在 JSX 中使用 JavaScript

tsx 复制代码
function Greeting() {
  const username = '小明';
  const unreadCount = 3;

  return (
    <section>
      <h1>你好,{username}</h1>
      <p>你有 {unreadCount} 条未读消息</p>
      <p>{new Date().toLocaleString('zh-CN')}</p>
    </section>
  );
}

花括号中可以放表达式,但不能直接放 iffor 等语句。


5. 组件与 Props

React 应用由组件组成。

组件通常是一个以大写字母开头的函数:

tsx 复制代码
function Welcome() {
  return <h1>欢迎学习 React</h1>;
}

使用组件:

tsx 复制代码
function App() {
  return (
    <main>
      <Welcome />
    </main>
  );
}

5.1 使用 Props 传递数据

tsx 复制代码
type UserCardProps = {
  name: string;
  age: number;
  online?: boolean;
};

function UserCard({
  name,
  age,
  online = false,
}: UserCardProps) {
  return (
    <article>
      <h2>{name}</h2>
      <p>年龄:{age}</p>
      <p>状态:{online ? '在线' : '离线'}</p>
    </article>
  );
}

调用组件:

tsx 复制代码
<UserCard name="小明" age={20} online />
<UserCard name="小红" age={22} />

Props 应视为只读数据,子组件不应该修改父组件传入的对象。

5.2 使用 children 组合组件

tsx 复制代码
import type { ReactNode } from 'react';

type CardProps = {
  title: string;
  children: ReactNode;
};

function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      <div>{children}</div>
    </section>
  );
}

使用:

tsx 复制代码
<Card title="系统通知">
  <p>系统将在今晚进行维护。</p>
  <button>我知道了</button>
</Card>

组件组合通常比大量继承关系更适合 React。


6. 条件渲染与列表渲染

6.1 条件渲染

tsx 复制代码
function LoginStatus({ loggedIn }: { loggedIn: boolean }) {
  if (!loggedIn) {
    return <button>登录</button>;
  }

  return <p>欢迎回来</p>;
}

也可以使用三元表达式:

tsx 复制代码
<p>{loggedIn ? '已登录' : '未登录'}</p>

或使用逻辑与:

tsx 复制代码
{errorMessage && <p role="alert">{errorMessage}</p>}

6.2 列表渲染

tsx 复制代码
type Product = {
  id: string;
  name: string;
  price: number;
};

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.name}:¥{product.price}
        </li>
      ))}
    </ul>
  );
}

6.3 key 的作用

key 用于帮助 React 识别列表项身份。

正确做法:

tsx 复制代码
<li key={product.id}>{product.name}</li>

不推荐在会插入、删除或排序的列表中使用数组下标:

tsx 复制代码
<li key={index}>{product.name}</li>

错误的 key 可能导致:

  • 输入框内容错位。
  • 组件局部状态复用错误。
  • 不必要的 DOM 更新。
  • 动画错乱。

key 只需要在当前同级列表中唯一。


7. State 与事件处理

7.1 使用 useState

tsx 复制代码
import { useState } from 'react';

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

  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>
        加一
      </button>
    </div>
  );
}

7.2 State 是一次渲染的快照

下面的代码不会一次增加 3:

tsx 复制代码
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

三次代码读取的都是当前渲染中的同一个 count

需要基于上一次结果更新时,应使用函数式更新:

tsx 复制代码
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);

7.3 不要直接修改对象和数组

错误写法:

tsx 复制代码
user.name = '新名称';
setUser(user);

正确写法:

tsx 复制代码
setUser((current) => ({
  ...current,
  name: '新名称',
}));

更新数组:

tsx 复制代码
setTasks((current) => [
  ...current,
  newTask,
]);

删除数组元素:

tsx 复制代码
setTasks((current) =>
  current.filter((task) => task.id !== deletedId),
);

更新数组中的某个对象:

tsx 复制代码
setTasks((current) =>
  current.map((task) =>
    task.id === updatedTask.id ? updatedTask : task,
  ),
);

React 状态应该按照不可变数据处理,这样 React 才能通过引用变化正确判断更新。


8. 表单与受控组件

8.1 受控输入框

tsx 复制代码
import { useState } from 'react';

function SearchBox() {
  const [keyword, setKeyword] = useState('');

  return (
    <label>
      搜索:
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </label>
  );
}

输入框的值由 React State 控制,因此称为受控组件。

8.2 提交表单

传统 React 写法:

tsx 复制代码
function LoginForm() {
  const [username, setUsername] = useState('');

  function handleSubmit(
    event: React.FormEvent<HTMLFormElement>,
  ) {
    event.preventDefault();
    console.log(username);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={username}
        onChange={(event) => setUsername(event.target.value)}
      />
      <button type="submit">登录</button>
    </form>
  );
}

React 19 还可以通过表单 action 属性直接接收函数,后文会详细介绍。


9. Hooks 核心规则

Hook 是以 use 开头的 React API。

常见 Hook:

Hook 主要用途
useState 保存局部状态
useReducer 管理复杂状态转换
useEffect 同步外部系统
useRef 保存可变引用或访问 DOM
useContext 读取跨层级数据
useMemo 缓存计算结果
useCallback 缓存函数引用
useTransition 标记非紧急更新
useDeferredValue 延迟更新某个值
useActionState 管理 Action 状态
useOptimistic 管理乐观状态
useEffectEvent 分离 Effect 中的事件逻辑
use 读取 Promise 或 Context

9.1 Hooks 的基本规则

一般 Hook 必须:

  • 在组件顶层调用。
  • 在自定义 Hook 顶层调用。
  • 不能放在普通函数中。
  • 不能放在条件语句和循环中。
  • 不能在事件处理函数中临时调用。

错误写法:

tsx 复制代码
if (loggedIn) {
  const [user, setUser] = useState(null);
}

正确写法:

tsx 复制代码
const [user, setUser] = useState<User | null>(null);

if (!loggedIn) {
  return <LoginPage />;
}

use API 是一个特殊例外,它可以在条件和循环中调用,但仍然只能在组件或自定义 Hook 中使用。


10. 正确理解 useEffect

useEffect 用于让 React 组件与外部系统同步,例如:

  • 发起网络请求。
  • 建立 WebSocket。
  • 注册浏览器事件。
  • 控制第三方组件。
  • 启动定时器。
  • 订阅外部数据源。
tsx 复制代码
import { useEffect } from 'react';

function OnlineStatus() {
  useEffect(() => {
    function handleOnline() {
      console.log('网络已恢复');
    }

    window.addEventListener('online', handleOnline);

    return () => {
      window.removeEventListener('online', handleOnline);
    };
  }, []);

  return <p>正在监听网络状态</p>;
}

10.1 不要用 Effect 计算派生数据

不推荐:

tsx 复制代码
const [fullName, setFullName] = useState('');

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

推荐直接计算:

tsx 复制代码
const fullName = `${firstName} ${lastName}`;

10.2 不要用 Effect 处理普通点击逻辑

不推荐:

tsx 复制代码
useEffect(() => {
  if (submitted) {
    sendForm();
  }
}, [submitted]);

推荐:

tsx 复制代码
function handleSubmit() {
  sendForm();
}

判断原则:

text 复制代码
由页面显示触发的外部同步 → Effect
由用户操作触发的业务行为 → 事件处理函数或 Action
可以根据现有 Props/State 直接计算 → 渲染时计算

10.3 正确声明依赖

tsx 复制代码
useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();

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

不要为了"只执行一次"而随意删除依赖,也不要直接关闭 Hooks ESLint 规则。


11. useRef、useReducer 与 Context

11.1 useRef

useRef 可以保存一个跨渲染存在、但修改后不触发重新渲染的值。

tsx 复制代码
function Timer() {
  const timerRef = useRef<number | null>(null);

  function start() {
    timerRef.current = window.setInterval(() => {
      console.log('tick');
    }, 1000);
  }

  function stop() {
    if (timerRef.current !== null) {
      window.clearInterval(timerRef.current);
      timerRef.current = null;
    }
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </>
  );
}

它也可以访问 DOM:

tsx 复制代码
function SearchInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>
        聚焦
      </button>
    </>
  );
}

11.2 useReducer

当状态变化规则复杂时,可以使用 useReducer

tsx 复制代码
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 };
    default:
      return state;
  }
}

使用:

tsx 复制代码
const [state, dispatch] = useReducer(reducer, { count: 0 });

dispatch({ type: 'increment' });

11.3 Context

Context 用于跨越多层组件传递主题、当前用户、语言等数据。

tsx 复制代码
import { createContext, useContext } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<Theme>('light');

function ThemeText() {
  const theme = useContext(ThemeContext);

  return <p>当前主题:{theme}</p>;
}

React 19 可以直接使用 Context 作为 Provider:

tsx 复制代码
function App() {
  return (
    <ThemeContext value="dark">
      <ThemeText />
    </ThemeContext>
  );
}

React 19 之前常见写法是:

tsx 复制代码
<ThemeContext.Provider value="dark">
  <ThemeText />
</ThemeContext.Provider>

Context 不应该成为所有状态的默认存放位置。Context 值变化会让读取它的组件重新渲染,应根据职责拆分 Context,并避免每次渲染都创建不必要的新对象。


12. React 19 Actions

Action 是 React 19 中处理异步数据变更的重要模型。

常见数据变更包括:

  • 创建任务。
  • 修改用户名。
  • 提交订单。
  • 删除数据。
  • 加入购物车。
  • 更新评论。

Action 通常是一个异步函数,并在 Transition 中执行。

tsx 复制代码
import { startTransition } from 'react';

function updateName(newName: string) {
  startTransition(async () => {
    await saveName(newName);
  });
}

Actions 带来的能力包括:

  • 自动管理异步 Transition。
  • 保持页面交互响应。
  • 支持 Pending 状态。
  • 支持乐观更新。
  • 与表单提交深度集成。
  • 更统一地处理异步结果和错误。

需要注意:

  • Action 不是普通 HTTP 客户端。
  • Action 不会自动把数据写入数据库。
  • 在纯客户端项目中,仍需在 Action 内调用 fetch 或其他数据服务。
  • Server Function 是框架提供的服务端能力,与普通客户端 Action 不完全相同。

13. useActionState

useActionState 用于管理 Action 的返回状态。

基本结构:

tsx 复制代码
const [state, action, isPending] = useActionState(
  actionFunction,
  initialState,
);

示例:

tsx 复制代码
import { useActionState } from 'react';

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

const initialState: FormState = {
  success: false,
  message: '',
};

async function updateProfile(
  _previousState: FormState,
  formData: FormData,
): Promise<FormState> {
  const nickname = String(formData.get('nickname') ?? '').trim();

  if (nickname.length < 2) {
    return {
      success: false,
      message: '昵称至少需要两个字符',
    };
  }

  await saveProfile({ nickname });

  return {
    success: true,
    message: '资料保存成功',
  };
}

function ProfileForm() {
  const [state, formAction, isPending] = useActionState(
    updateProfile,
    initialState,
  );

  return (
    <form action={formAction}>
      <input name="nickname" />
      <button disabled={isPending}>
        {isPending ? '保存中......' : '保存'}
      </button>

      {state.message && (
        <p role={state.success ? 'status' : 'alert'}>
          {state.message}
        </p>
      )}
    </form>
  );
}

Action 函数会收到:

text 复制代码
第一个参数:上一次 Action 返回的状态
第二个参数:提交给 Action 的数据

14. useFormStatus

useFormStatus 用于读取所属表单的提交状态。

它必须在 <form> 内部的子组件中调用。

tsx 复制代码
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

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

使用:

tsx 复制代码
function UserForm() {
  return (
    <form action={saveUserAction}>
      <input name="username" />
      <SubmitButton />
    </form>
  );
}

下面的写法无法读取当前表单状态,因为 Hook 与 <form> 位于同一个组件层级:

tsx 复制代码
function WrongForm() {
  const { pending } = useFormStatus();

  return (
    <form action={saveUserAction}>
      <button disabled={pending}>提交</button>
    </form>
  );
}

应该将按钮提取成表单内部的子组件。


15. useOptimistic 乐观更新

乐观更新表示:在服务器返回结果之前,先假设操作成功并更新界面。

例如用户点击点赞时,不必等待服务器响应后才显示新数量。

tsx 复制代码
import { startTransition, useOptimistic } from 'react';

function LikeButton({
  likes,
  saveLike,
}: {
  likes: number;
  saveLike: () => Promise<number>;
}) {
  const [optimisticLikes, addOptimisticLike] =
    useOptimistic(likes, (current) => current + 1);

  function handleLike() {
    startTransition(async () => {
      addOptimisticLike(null);
      await saveLike();
    });
  }

  return (
    <button onClick={handleLike}>
      点赞 {optimisticLikes}
    </button>
  );
}

完整的业务还应该:

  • 防止重复提交。
  • 处理请求失败。
  • 失败时提示用户。
  • 确保最终状态以服务端结果为准。
  • 对支付、余额等敏感操作避免盲目乐观展示。
  • 为重复请求设计幂等机制。

乐观更新适合:

  • 点赞。
  • 收藏。
  • 标记完成。
  • 排序。
  • 删除普通列表项。
  • 发送即时消息。

不一定适合:

  • 支付成功。
  • 余额变更。
  • 库存最终扣减。
  • 权限授予。
  • 不可逆操作。

16. use API 与 Suspense

React 19 的 use 可以读取 Promise 或 Context。

16.1 读取 Promise

tsx 复制代码
import { Suspense, use } from 'react';

type User = {
  id: string;
  name: string;
};

function UserInfo({
  userPromise,
}: {
  userPromise: Promise<User>;
}) {
  const user = use(userPromise);

  return <h2>{user.name}</h2>;
}

function UserPage({
  userPromise,
}: {
  userPromise: Promise<User>;
}) {
  return (
    <Suspense fallback={<p>正在加载用户......</p>}>
      <UserInfo userPromise={userPromise} />
    </Suspense>
  );
}

Promise 未完成时,组件会挂起,最近的 Suspense 显示 fallback。

Promise 拒绝时,错误会传播到最近的 Error Boundary。

16.2 不要在渲染中创建新 Promise

错误写法:

tsx 复制代码
function UserInfo() {
  const user = use(fetch('/api/user'));
  return <p>{user.name}</p>;
}

每次渲染都会创建新的 Promise,可能反复触发 Suspense。

正确方向:

  • 由支持 Suspense 的框架创建 Promise。
  • 从服务端组件传入 Promise。
  • 使用具备缓存能力的数据层。
  • 确保同一个资源在渲染期间使用稳定的 Promise。

16.3 use 读取 Context

tsx 复制代码
function ThemeButton({ enabled }: { enabled: boolean }) {
  if (enabled) {
    const theme = use(ThemeContext);
    return <button className={theme}>主题按钮</button>;
  }

  return <button>普通按钮</button>;
}

与普通 Hook 不同,use 可以在条件和循环中调用。


17. React 19 的其他改进

17.1 ref 可以作为普通 Prop

React 19 中,新的函数组件通常不再需要 forwardRef

tsx 复制代码
type InputProps = {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
};

function FormInput({ label, ref }: InputProps) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}

使用:

tsx 复制代码
const inputRef = useRef<HTMLInputElement>(null);

<FormInput label="用户名" ref={inputRef} />

17.2 回调 ref 支持清理函数

tsx 复制代码
<div
  ref={(node) => {
    if (!node) {
      return;
    }

    const observer = new ResizeObserver(() => {
      console.log(node.clientWidth);
    });

    observer.observe(node);

    return () => {
      observer.disconnect();
    };
  }}
/>

TypeScript 项目升级时要注意旧代码中的隐式返回:

tsx 复制代码
// 不推荐:赋值表达式会产生返回值
<div ref={(node) => (instance = node)} />

改为:

tsx 复制代码
<div
  ref={(node) => {
    instance = node;
  }}
/>

17.3 组件中直接渲染元数据

tsx 复制代码
function ProductPage({ name }: { name: string }) {
  return (
    <>
      <title>{`${name} - 商品详情`}</title>
      <meta
        name="description"
        content={`查看 ${name} 的详细信息`}
      />

      <main>
        <h1>{name}</h1>
      </main>
    </>
  );
}

React 会把这些元素放到文档 <head>

同一时刻应只渲染一个有效的 <title>

17.4 资源加载 API

React DOM 还提供:

  • preload
  • preinit
  • preconnect
  • prefetchDNS

例如:

tsx 复制代码
import { preload } from 'react-dom';

preload('/fonts/inter.woff2', {
  as: 'font',
  crossOrigin: '',
});

资源提示应该根据真实性能数据使用,过度预加载会抢占关键资源带宽。

17.5 Hydration 错误信息改进

服务端 HTML 与客户端首次渲染不一致时,会发生 Hydration Mismatch。

常见原因:

  • 渲染时直接使用 Date.now()
  • 渲染时使用随机数。
  • 服务端和客户端语言环境不同。
  • 在渲染中读取 window
  • 服务端与客户端数据不一致。
  • HTML 标签嵌套不合法。

React 19 提供了更清晰的差异信息,但仍然应该从根源保证服务端与客户端首屏一致。


18. React 19.2 新能力

18.1 Activity

Activity 可以隐藏一部分 UI,同时保留它的内部状态。

tsx 复制代码
import { Activity } from 'react';

function SettingsPanel({
  visible,
}: {
  visible: boolean;
}) {
  return (
    <Activity mode={visible ? 'visible' : 'hidden'}>
      <SettingsForm />
    </Activity>
  );
}

hidden 模式下:

  • 子内容通过 display: none 隐藏。
  • Effect 会被卸载。
  • 组件状态可以保留。
  • 隐藏区域的更新被降低优先级。

visible 模式下:

  • 内容重新显示。
  • Effect 重新挂载。
  • 原来的表单状态可以恢复。

适用场景:

  • Tab 页面切换。
  • 返回页面时保留搜索条件。
  • 提前渲染用户可能访问的页面。
  • 保留多步骤表单状态。

不要把 Activity 当成权限或数据安全边界。隐藏的 UI 不等于用户没有拿到相关数据。

18.2 useEffectEvent

useEffectEvent 用于把 Effect 中的非响应式逻辑提取出来,同时读取最新 Props 和 State。

tsx 复制代码
import {
  useEffect,
  useEffectEvent,
} from 'react';

function ChatRoom({
  roomId,
  theme,
}: {
  roomId: string;
  theme: string;
}) {
  const onConnected = useEffectEvent(() => {
    showNotification('连接成功', theme);
  });

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

    connection.on('connected', () => {
      onConnected();
    });

    connection.connect();

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

  return <p>当前房间:{roomId}</p>;
}

这里:

  • roomId 变化时需要重新连接。
  • theme 变化时只需要通知使用新主题。
  • 不应该因为主题变化而断开并重新连接聊天室。

注意:

  • Effect Event 只能从 Effect 或其他 Effect Event 中调用。
  • 不应传给子组件。
  • 不应放进依赖数组。
  • 不要用它隐藏本来应该声明的 Effect 依赖。

18.3 Performance Tracks

React 19.2 可以在 Chrome DevTools Performance 面板中提供 React 相关性能轨道,帮助分析:

  • 组件渲染。
  • Scheduler 调度。
  • Suspense。
  • 并发更新。
  • 阻塞主线程的任务。

性能优化应先测量再修改,不要仅凭"组件重新执行了"就增加大量 memouseMemouseCallback

18.4 Partial Pre-rendering

Partial Pre-rendering 允许先预渲染静态页面外壳,再在请求阶段恢复并填充动态内容。

它主要面向框架和服务端渲染基础设施,不是普通 Vite SPA 添加一个配置就能自动获得的功能。


19. 项目实战:TaskFlow 任务管理器

下面实现一个可以独立运行的任务管理项目。

功能包括:

  • 创建任务。
  • 展示任务。
  • 标记完成。
  • 删除任务。
  • 按状态筛选。
  • 关键词搜索。
  • 异步 Pending 状态。
  • 表单错误提示。
  • 乐观更新。
  • localStorage 持久化。
  • 单元测试。
  • 响应式布局。

整体结构:
#mermaid-svg-FIEuDwzUnYcgxqsX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FIEuDwzUnYcgxqsX .error-icon{fill:#552222;}#mermaid-svg-FIEuDwzUnYcgxqsX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FIEuDwzUnYcgxqsX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FIEuDwzUnYcgxqsX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FIEuDwzUnYcgxqsX .marker.cross{stroke:#333333;}#mermaid-svg-FIEuDwzUnYcgxqsX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FIEuDwzUnYcgxqsX p{margin:0;}#mermaid-svg-FIEuDwzUnYcgxqsX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster-label text{fill:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster-label span{color:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster-label span p{background-color:transparent;}#mermaid-svg-FIEuDwzUnYcgxqsX .label text,#mermaid-svg-FIEuDwzUnYcgxqsX span{fill:#333;color:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX .node rect,#mermaid-svg-FIEuDwzUnYcgxqsX .node circle,#mermaid-svg-FIEuDwzUnYcgxqsX .node ellipse,#mermaid-svg-FIEuDwzUnYcgxqsX .node polygon,#mermaid-svg-FIEuDwzUnYcgxqsX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FIEuDwzUnYcgxqsX .rough-node .label text,#mermaid-svg-FIEuDwzUnYcgxqsX .node .label text,#mermaid-svg-FIEuDwzUnYcgxqsX .image-shape .label,#mermaid-svg-FIEuDwzUnYcgxqsX .icon-shape .label{text-anchor:middle;}#mermaid-svg-FIEuDwzUnYcgxqsX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FIEuDwzUnYcgxqsX .rough-node .label,#mermaid-svg-FIEuDwzUnYcgxqsX .node .label,#mermaid-svg-FIEuDwzUnYcgxqsX .image-shape .label,#mermaid-svg-FIEuDwzUnYcgxqsX .icon-shape .label{text-align:center;}#mermaid-svg-FIEuDwzUnYcgxqsX .node.clickable{cursor:pointer;}#mermaid-svg-FIEuDwzUnYcgxqsX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FIEuDwzUnYcgxqsX .arrowheadPath{fill:#333333;}#mermaid-svg-FIEuDwzUnYcgxqsX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FIEuDwzUnYcgxqsX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FIEuDwzUnYcgxqsX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FIEuDwzUnYcgxqsX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FIEuDwzUnYcgxqsX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FIEuDwzUnYcgxqsX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster text{fill:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX .cluster span{color:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FIEuDwzUnYcgxqsX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FIEuDwzUnYcgxqsX rect.text{fill:none;stroke-width:0;}#mermaid-svg-FIEuDwzUnYcgxqsX .icon-shape,#mermaid-svg-FIEuDwzUnYcgxqsX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FIEuDwzUnYcgxqsX .icon-shape p,#mermaid-svg-FIEuDwzUnYcgxqsX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FIEuDwzUnYcgxqsX .icon-shape .label rect,#mermaid-svg-FIEuDwzUnYcgxqsX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FIEuDwzUnYcgxqsX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FIEuDwzUnYcgxqsX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FIEuDwzUnYcgxqsX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} React 组件
useTasks
React Actions
taskApi 数据层
localStorage

19.1 项目目录

src 下创建:

text 复制代码
src/
├─ components/
│  ├─ TaskComposer.tsx
│  └─ TaskList.tsx
├─ domain/
│  └─ task.ts
├─ hooks/
│  └─ useTasks.ts
├─ services/
│  └─ taskApi.ts
├─ test/
│  └─ setup.ts
├─ App.tsx
├─ index.css
└─ main.tsx

20. 定义领域模型

创建 src/domain/task.ts

ts 复制代码
export type Task = Readonly<{
  id: string;
  title: string;
  completed: boolean;
  createdAt: string;
}>;

使用单独的领域模型有以下好处:

  • 组件和数据层共享统一类型。
  • 避免不同文件重复定义。
  • 后续可以扩展负责人、优先级、截止日期。
  • 更容易将接口 DTO 与页面模型分离。

21. 实现数据访问层

创建 src/services/taskApi.ts

ts 复制代码
import type { Task } from '../domain/task';

const STORAGE_KEY = 'taskflow.tasks.v1';

function wait(milliseconds = 250): Promise<void> {
  return new Promise((resolve) => {
    window.setTimeout(resolve, milliseconds);
  });
}

function isTask(value: unknown): value is Task {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const task = value as Record<string, unknown>;

  return (
    typeof task.id === 'string' &&
    typeof task.title === 'string' &&
    typeof task.completed === 'boolean' &&
    typeof task.createdAt === 'string'
  );
}

function readTasks(): Task[] {
  const rawValue = localStorage.getItem(STORAGE_KEY);

  if (!rawValue) {
    return [];
  }

  try {
    const parsedValue: unknown = JSON.parse(rawValue);

    if (!Array.isArray(parsedValue)) {
      return [];
    }

    return parsedValue.filter(isTask);
  } catch {
    return [];
  }
}

function writeTasks(tasks: Task[]): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export const taskApi = {
  async list(): Promise<Task[]> {
    await wait();

    return [...readTasks()].sort((left, right) =>
      right.createdAt.localeCompare(left.createdAt),
    );
  },

  async create(title: string): Promise<Task> {
    await wait();

    const task: Task = {
      id: crypto.randomUUID(),
      title,
      completed: false,
      createdAt: new Date().toISOString(),
    };

    writeTasks([task, ...readTasks()]);

    return task;
  },

  async toggle(id: string): Promise<Task> {
    await wait();

    const tasks = readTasks();
    const target = tasks.find((task) => task.id === id);

    if (!target) {
      throw new Error('任务不存在或已经被删除');
    }

    const updatedTask: Task = {
      ...target,
      completed: !target.completed,
    };

    writeTasks(
      tasks.map((task) =>
        task.id === id ? updatedTask : task,
      ),
    );

    return updatedTask;
  },

  async remove(id: string): Promise<void> {
    await wait();

    const tasks = readTasks();
    const exists = tasks.some((task) => task.id === id);

    if (!exists) {
      throw new Error('任务不存在或已经被删除');
    }

    writeTasks(tasks.filter((task) => task.id !== id));
  },
};

这里没有直接在组件中操作 localStorage,而是封装成 taskApi

未来接入后端时,只需要替换数据层,组件不需要知道数据到底来自:

  • localStorage
  • REST API。
  • GraphQL。
  • IndexedDB。
  • Electron。
  • 服务端函数。

JSON.parse 的结果使用 unknown 接收并进行运行时校验。TypeScript 类型只在编译期存在,不能保证浏览器存储或接口响应一定符合类型。


22. 实现任务数据 Hook

创建 src/hooks/useTasks.ts

tsx 复制代码
import {
  useCallback,
  useEffect,
  useState,
} from 'react';
import type { Task } from '../domain/task';
import { taskApi } from '../services/taskApi';

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return '发生未知错误';
}

export function useTasks() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let disposed = false;

    setError(null);

    void taskApi
      .list()
      .then((result) => {
        if (!disposed) {
          setTasks(result);
        }
      })
      .catch((requestError: unknown) => {
        if (!disposed) {
          setError(getErrorMessage(requestError));
        }
      })
      .finally(() => {
        if (!disposed) {
          setIsLoading(false);
        }
      });

    return () => {
      disposed = true;
    };
  }, []);

  const addTask = useCallback((task: Task) => {
    setTasks((current) => [task, ...current]);
  }, []);

  const replaceTask = useCallback((updatedTask: Task) => {
    setTasks((current) =>
      current.map((task) =>
        task.id === updatedTask.id ? updatedTask : task,
      ),
    );
  }, []);

  const removeTask = useCallback((id: string) => {
    setTasks((current) =>
      current.filter((task) => task.id !== id),
    );
  }, []);

  return {
    tasks,
    isLoading,
    error,
    addTask,
    replaceTask,
    removeTask,
  };
}

真实 HTTP 请求建议使用 AbortController 取消已经不再需要的请求。


23. 实现新增任务表单

创建 src/components/TaskComposer.tsx

tsx 复制代码
import {
  useActionState,
  useState,
} from 'react';
import { useFormStatus } from 'react-dom';
import type { Task } from '../domain/task';
import { taskApi } from '../services/taskApi';

type TaskComposerProps = {
  onCreated: (task: Task) => void;
};

type SubmitState = {
  status: 'idle' | 'success' | 'error';
  message: string;
};

const initialState: SubmitState = {
  status: 'idle',
  message: '',
};

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      className="primary-button"
      type="submit"
      disabled={pending}
    >
      {pending ? '创建中......' : '创建任务'}
    </button>
  );
}

export function TaskComposer({
  onCreated,
}: TaskComposerProps) {
  const [title, setTitle] = useState('');

  const [state, submitAction] = useActionState<
    SubmitState,
    FormData
  >(
    async (_previousState, formData) => {
      const normalizedTitle = String(
        formData.get('title') ?? '',
      ).trim();

      if (normalizedTitle.length < 2) {
        return {
          status: 'error',
          message: '任务名称至少需要两个字符',
        };
      }

      if (normalizedTitle.length > 80) {
        return {
          status: 'error',
          message: '任务名称不能超过 80 个字符',
        };
      }

      try {
        const task = await taskApi.create(normalizedTitle);
        onCreated(task);
        setTitle('');

        return {
          status: 'success',
          message: '任务创建成功',
        };
      } catch (error: unknown) {
        return {
          status: 'error',
          message:
            error instanceof Error
              ? error.message
              : '任务创建失败',
        };
      }
    },
    initialState,
  );

  return (
    <section className="panel">
      <h2>创建任务</h2>

      <form className="composer" action={submitAction}>
        <label htmlFor="task-title">任务名称</label>

        <div className="composer-row">
          <input
            id="task-title"
            name="title"
            value={title}
            maxLength={80}
            placeholder="例如:完成 React 19 学习笔记"
            onChange={(event) => {
              setTitle(event.target.value);
            }}
          />

          <SubmitButton />
        </div>
      </form>

      {state.message && (
        <p
          className={`message ${state.status}`}
          role={
            state.status === 'error' ? 'alert' : 'status'
          }
        >
          {state.message}
        </p>
      )}
    </section>
  );
}

这里同时使用了:

  • React 19 表单 Action。
  • useActionState
  • useFormStatus
  • 表单验证。
  • Pending 状态。
  • 错误反馈。
  • 受控输入框。

24. 实现任务列表

创建 src/components/TaskList.tsx

tsx 复制代码
import type { Task } from '../domain/task';

type TaskListProps = {
  tasks: Task[];
  onToggle: (id: string) => void;
  onRemove: (id: string) => void;
};

const dateFormatter = new Intl.DateTimeFormat('zh-CN', {
  dateStyle: 'medium',
  timeStyle: 'short',
});

export function TaskList({
  tasks,
  onToggle,
  onRemove,
}: TaskListProps) {
  if (tasks.length === 0) {
    return (
      <div className="empty-state">
        <p>暂无符合条件的任务。</p>
        <p>创建一个任务开始吧。</p>
      </div>
    );
  }

  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <li
          className={`task-item ${
            task.completed ? 'completed' : ''
          }`}
          key={task.id}
        >
          <label className="task-main">
            <input
              type="checkbox"
              checked={task.completed}
              aria-label={`完成任务:${task.title}`}
              onChange={() => onToggle(task.id)}
            />

            <span>
              <strong>{task.title}</strong>
              <time dateTime={task.createdAt}>
                {dateFormatter.format(
                  new Date(task.createdAt),
                )}
              </time>
            </span>
          </label>

          <button
            className="danger-button"
            type="button"
            aria-label={`删除任务:${task.title}`}
            onClick={() => onRemove(task.id)}
          >
            删除
          </button>
        </li>
      ))}
    </ul>
  );
}

25. 组装应用并实现乐观更新

修改 src/App.tsx

tsx 复制代码
import {
  startTransition,
  useDeferredValue,
  useMemo,
  useOptimistic,
  useState,
} from 'react';
import { TaskComposer } from './components/TaskComposer';
import { TaskList } from './components/TaskList';
import type { Task } from './domain/task';
import { useTasks } from './hooks/useTasks';
import { taskApi } from './services/taskApi';

type Filter = 'all' | 'open' | 'done';

type OptimisticAction =
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string };

function optimisticReducer(
  currentTasks: Task[],
  action: OptimisticAction,
): Task[] {
  switch (action.type) {
    case 'toggle':
      return currentTasks.map((task) =>
        task.id === action.id
          ? {
              ...task,
              completed: !task.completed,
            }
          : task,
      );

    case 'remove':
      return currentTasks.filter(
        (task) => task.id !== action.id,
      );

    default:
      return currentTasks;
  }
}

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return '操作失败,请稍后重试';
}

export default function App() {
  const {
    tasks,
    isLoading,
    error,
    addTask,
    replaceTask,
    removeTask,
  } = useTasks();

  const [filter, setFilter] = useState<Filter>('all');
  const [query, setQuery] = useState('');
  const [operationError, setOperationError] =
    useState<string | null>(null);

  const deferredQuery = useDeferredValue(query);

  const [optimisticTasks, applyOptimisticAction] =
    useOptimistic(tasks, optimisticReducer);

  const visibleTasks = useMemo(() => {
    const normalizedQuery = deferredQuery
      .trim()
      .toLocaleLowerCase('zh-CN');

    return optimisticTasks.filter((task) => {
      const matchesFilter =
        filter === 'all' ||
        (filter === 'open' && !task.completed) ||
        (filter === 'done' && task.completed);

      const matchesQuery =
        normalizedQuery.length === 0 ||
        task.title
          .toLocaleLowerCase('zh-CN')
          .includes(normalizedQuery);

      return matchesFilter && matchesQuery;
    });
  }, [deferredQuery, filter, optimisticTasks]);

  const openCount = optimisticTasks.filter(
    (task) => !task.completed,
  ).length;

  const doneCount = optimisticTasks.length - openCount;

  function handleToggle(id: string): void {
    setOperationError(null);

    startTransition(async () => {
      applyOptimisticAction({
        type: 'toggle',
        id,
      });

      try {
        const updatedTask = await taskApi.toggle(id);
        replaceTask(updatedTask);
      } catch (requestError: unknown) {
        setOperationError(getErrorMessage(requestError));
      }
    });
  }

  function handleRemove(id: string): void {
    setOperationError(null);

    startTransition(async () => {
      applyOptimisticAction({
        type: 'remove',
        id,
      });

      try {
        await taskApi.remove(id);
        removeTask(id);
      } catch (requestError: unknown) {
        setOperationError(getErrorMessage(requestError));
      }
    });
  }

  return (
    <>
      <title>TaskFlow - React 19 任务管理器</title>
      <meta
        name="description"
        content="使用 React 19、TypeScript 和 Vite 构建的任务管理器"
      />

      <main className="app-shell">
        <header className="hero">
          <p className="eyebrow">React 19 Project</p>
          <h1>TaskFlow</h1>
          <p>
            一个包含 Actions、乐观更新和工程化实践的任务管理器。
          </p>
        </header>

        <TaskComposer onCreated={addTask} />

        <section
          className="panel"
          aria-busy={isLoading}
        >
          <div className="toolbar">
            <div>
              <h2>任务列表</h2>
              <p>
                待完成 {openCount} 项,已完成 {doneCount} 项
              </p>
            </div>

            <label className="search-box">
              <span>搜索任务</span>
              <input
                type="search"
                value={query}
                placeholder="输入关键词"
                onChange={(event) => {
                  setQuery(event.target.value);
                }}
              />
            </label>
          </div>

          <div
            className="filters"
            role="group"
            aria-label="任务状态筛选"
          >
            {(
              [
                ['all', '全部'],
                ['open', '待完成'],
                ['done', '已完成'],
              ] as const
            ).map(([value, label]) => (
              <button
                type="button"
                key={value}
                aria-pressed={filter === value}
                onClick={() => setFilter(value)}
              >
                {label}
              </button>
            ))}
          </div>

          {isLoading && <p role="status">正在加载任务......</p>}

          {error && <p role="alert">{error}</p>}

          {operationError && (
            <p role="alert">{operationError}</p>
          )}

          {!isLoading && !error && (
            <TaskList
              tasks={visibleTasks}
              onToggle={handleToggle}
              onRemove={handleRemove}
            />
          )}
        </section>
      </main>
    </>
  );
}

这个组件演示了:

  • useOptimistic
  • startTransition
  • useDeferredValue
  • 派生状态。
  • 搜索和筛选。
  • React 19 元数据。
  • 错误反馈。
  • UI 状态与服务端状态分离。

如果异步修改失败,Action 结束后乐观状态会回到基础状态,并显示错误信息。


26. 添加页面样式

修改 src/index.css

css 复制代码
:root {
  color: #172033;
  background: #f3f6fb;
  font-family:
    Inter,
    "PingFang SC",
    "Microsoft YaHei",
    system-ui,
    sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
}

button,
input {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}

.app-shell {
  width: min(960px, calc(100% - 32px));
  margin: 0 auto;
  padding: 56px 0 80px;
}

.hero {
  margin-bottom: 28px;
}

.hero h1 {
  margin: 6px 0;
  color: #111827;
  font-size: clamp(2.5rem, 8vw, 4.5rem);
  line-height: 1;
}

.hero p {
  color: #596579;
}

.eyebrow {
  margin: 0;
  color: #4f46e5 !important;
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.panel {
  margin-top: 20px;
  padding: 24px;
  border: 1px solid #dce3ee;
  border-radius: 20px;
  background: #ffffff;
  box-shadow: 0 16px 45px rgb(15 23 42 / 8%);
}

.panel h2 {
  margin-top: 0;
}

.composer {
  display: grid;
  gap: 10px;
}

.composer-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 12px;
}

input {
  width: 100%;
  min-height: 44px;
  padding: 10px 13px;
  border: 1px solid #cbd5e1;
  border-radius: 10px;
  color: #111827;
  background: #ffffff;
}

input:focus-visible,
button:focus-visible {
  outline: 3px solid rgb(79 70 229 / 25%);
  outline-offset: 2px;
}

.primary-button,
.danger-button,
.filters button {
  min-height: 42px;
  padding: 8px 16px;
  border-radius: 10px;
}

.primary-button {
  border: 1px solid #4f46e5;
  color: #ffffff;
  background: #4f46e5;
}

.primary-button:hover:not(:disabled) {
  background: #4338ca;
}

.message {
  margin-bottom: 0;
}

.message.success {
  color: #047857;
}

.message.error,
[role="alert"] {
  color: #b42318;
}

.toolbar {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 24px;
}

.toolbar h2 {
  margin-bottom: 4px;
}

.toolbar p {
  margin: 0;
  color: #64748b;
}

.search-box {
  display: grid;
  min-width: min(280px, 100%);
  gap: 7px;
  color: #475569;
  font-size: 0.9rem;
}

.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 22px 0;
}

.filters button {
  border: 1px solid #cbd5e1;
  color: #334155;
  background: #ffffff;
}

.filters button[aria-pressed="true"] {
  border-color: #4f46e5;
  color: #4338ca;
  background: #eef2ff;
}

.task-list {
  display: grid;
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.task-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 16px;
  border: 1px solid #e2e8f0;
  border-radius: 14px;
  background: #f8fafc;
}

.task-main {
  display: flex;
  align-items: flex-start;
  min-width: 0;
  gap: 12px;
}

.task-main input {
  width: 18px;
  min-height: 18px;
  margin-top: 3px;
}

.task-main span {
  display: grid;
  min-width: 0;
  gap: 5px;
}

.task-main strong {
  overflow-wrap: anywhere;
}

.task-main time {
  color: #64748b;
  font-size: 0.82rem;
}

.task-item.completed strong {
  color: #64748b;
  text-decoration: line-through;
}

.danger-button {
  border: 1px solid #fecaca;
  color: #b42318;
  background: #fff7f7;
}

.danger-button:hover {
  background: #fee2e2;
}

.empty-state {
  padding: 42px 20px;
  border: 1px dashed #cbd5e1;
  border-radius: 14px;
  color: #64748b;
  text-align: center;
}

.empty-state p {
  margin: 4px;
}

@media (max-width: 680px) {
  .app-shell {
    width: min(100% - 20px, 960px);
    padding-top: 30px;
  }

  .panel {
    padding: 18px;
    border-radius: 16px;
  }

  .composer-row {
    grid-template-columns: 1fr;
  }

  .toolbar {
    align-items: stretch;
    flex-direction: column;
  }

  .search-box {
    width: 100%;
  }

  .task-item {
    align-items: stretch;
    flex-direction: column;
  }

  .danger-button {
    width: 100%;
  }
}

运行项目:

bash 复制代码
npm run dev

现在可以:

  1. 创建任务。
  2. 搜索任务。
  3. 筛选任务。
  4. 标记完成。
  5. 删除任务。
  6. 刷新页面后保留数据。

27. 替换为真实 REST API

实际项目可以统一封装请求函数。

创建 src/services/http.ts

ts 复制代码
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

export class ApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly body: unknown,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export async function request<T>(
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...init.headers,
    },
  });

  const contentType =
    response.headers.get('content-type') ?? '';

  const body: unknown = contentType.includes(
    'application/json',
  )
    ? await response.json()
    : await response.text();

  if (!response.ok) {
    throw new ApiError(
      `请求失败:HTTP ${response.status}`,
      response.status,
      body,
    );
  }

  return body as T;
}

使用:

ts 复制代码
const tasks = await request<Task[]>('/tasks');

需要注意:

ts 复制代码
return body as T;

只是 TypeScript 类型断言,不会在运行时验证响应。

生产项目应使用:

  • 类型守卫。
  • Zod。
  • Valibot。
  • ArkType。
  • 由 OpenAPI 生成的客户端和 Schema。
  • 其他运行时校验方案。

27.1 环境变量

.env.development

dotenv 复制代码
VITE_API_BASE_URL=http://localhost:8080/api

.env.production

dotenv 复制代码
VITE_API_BASE_URL=https://api.example.com

使用:

ts 复制代码
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;

所有以 VITE_ 开头的变量都会进入客户端构建结果,因此不能存放:

  • 数据库密码。
  • 私钥。
  • 第三方服务端密钥。
  • JWT 签名密钥。
  • 内网管理员凭证。

客户端代码和环境变量对用户都是可见的。


28. 状态管理的工程选择

不要一开始就把所有状态放进全局状态库。

可以把状态分为以下几类:

状态类型 示例 推荐方式
局部 UI 状态 弹窗、输入框、Tab useState
复杂局部状态 多步骤表单、编辑器 useReducer
跨层级配置 主题、语言、当前用户 Context
URL 状态 页码、搜索词、筛选项 路由参数
服务端状态 用户列表、订单、缓存 数据请求库或框架数据层
外部状态 WebSocket、浏览器存储 useSyncExternalStore 或封装 Hook
Action 状态 提交结果、Pending useActionState
临时乐观状态 点赞、任务完成 useOptimistic

常见错误是把服务端状态复制到多个全局 Store 中,导致:

  • 缓存失效困难。
  • 数据来源不明确。
  • 页面之间状态不同步。
  • 更新和回滚逻辑重复。
  • 请求竞态越来越复杂。

29. 路由与代码分割

React 本身不包含路由。

项目需要多个页面时,可以选择:

  • React Router。
  • Next.js。
  • 其他支持 React 的框架。

不要用普通 State 模拟正式路由,因为这样无法获得:

  • 可分享的 URL。
  • 浏览器前进和后退。
  • 路由级数据加载。
  • 嵌套路由。
  • 路由错误边界。
  • 路由级代码分割。

普通组件也可以通过 lazy 做代码分割:

tsx 复制代码
import {
  lazy,
  Suspense,
} from 'react';

const ReportsPage = lazy(
  () => import('./pages/ReportsPage'),
);

function App() {
  return (
    <Suspense fallback={<p>页面加载中......</p>}>
      <ReportsPage />
    </Suspense>
  );
}

适合按路由拆分:

  • 后台报表。
  • 富文本编辑器。
  • 图表中心。
  • 大型设置页面。
  • 低频管理功能。

不要把首页每个小组件都拆成独立异步包,否则可能产生大量请求和加载闪烁。


30. 错误边界

Error Boundary 可以捕获子组件渲染、生命周期和构造过程中的错误。

目前常见写法仍是类组件:

tsx 复制代码
import {
  Component,
  type ErrorInfo,
  type ReactNode,
} from 'react';

type ErrorBoundaryProps = {
  children: ReactNode;
};

type ErrorBoundaryState = {
  hasError: boolean;
};

export class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
  };

  static getDerivedStateFromError(): ErrorBoundaryState {
    return {
      hasError: true,
    };
  }

  componentDidCatch(
    error: Error,
    errorInfo: ErrorInfo,
  ): void {
    console.error('页面渲染失败', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <section role="alert">
          <h1>页面出现错误</h1>
          <button
            type="button"
            onClick={() => window.location.reload()}
          >
            重新加载
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}

使用:

tsx 复制代码
<ErrorBoundary>
  <App />
</ErrorBoundary>

Error Boundary 不会自动捕获:

  • 普通事件处理函数中的错误。
  • 没有交给 React 的异步回调错误。
  • 服务端日志错误。
  • Error Boundary 自己渲染过程中的错误。

异步请求仍应使用 try/catch、请求库错误状态或路由错误处理机制。


31. 可访问性实践

可访问性不是项目完成后的附加功能,而是组件设计的一部分。

31.1 优先使用语义化元素

推荐:

tsx 复制代码
<button type="button">保存</button>

不推荐:

tsx 复制代码
<div onClick={save}>保存</div>

原生按钮已经具备:

  • 键盘访问。
  • 焦点管理。
  • 屏幕阅读器语义。
  • 禁用状态。
  • Enter 和 Space 操作。

31.2 表单控件需要标签

tsx 复制代码
<label htmlFor="email">邮箱</label>
<input id="email" name="email" type="email" />

31.3 动态消息需要合适的角色

tsx 复制代码
<p role="status">保存成功</p>
<p role="alert">保存失败</p>

31.4 不要只使用颜色表示状态

除了颜色,还应提供:

  • 图标。
  • 文本。
  • 描述。
  • aria-label
  • 可见状态标识。

31.5 管理焦点

弹窗打开后应把焦点移入弹窗,关闭后将焦点还给触发按钮。路由切换后也应考虑页面标题和主要内容焦点。


32. 测试 React 项目

测试通常分为:

测试类型 关注内容
单元测试 纯函数、Reducer、工具方法
组件测试 渲染、输入、点击、状态反馈
集成测试 多组件与数据层协作
E2E 测试 浏览器中的完整用户流程
性能测试 加载速度、交互延迟、长任务
可访问性测试 语义、键盘、焦点、ARIA

32.1 安装 Vitest 和 Testing Library

bash 复制代码
npm install -D vitest jsdom
npm install -D @testing-library/react
npm install -D @testing-library/jest-dom
npm install -D @testing-library/user-event

修改 vite.config.ts

ts 复制代码
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});

创建 src/test/setup.ts

ts 复制代码
import '@testing-library/jest-dom/vitest';

package.json 中加入:

json 复制代码
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

32.2 测试任务表单

创建 src/components/TaskComposer.test.tsx

tsx 复制代码
import {
  cleanup,
  render,
  screen,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
  afterEach,
  describe,
  expect,
  it,
  vi,
} from 'vitest';
import { taskApi } from '../services/taskApi';
import { TaskComposer } from './TaskComposer';

vi.mock('../services/taskApi', () => ({
  taskApi: {
    create: vi.fn(),
  },
}));

afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

describe('TaskComposer', () => {
  it('任务名称太短时显示错误', async () => {
    const user = userEvent.setup();

    render(<TaskComposer onCreated={vi.fn()} />);

    await user.type(
      screen.getByLabelText('任务名称'),
      'A',
    );

    await user.click(
      screen.getByRole('button', {
        name: '创建任务',
      }),
    );

    expect(
      await screen.findByRole('alert'),
    ).toHaveTextContent('至少需要两个字符');
  });

  it('创建成功后通知父组件并清空输入框', async () => {
    const user = userEvent.setup();
    const onCreated = vi.fn();

    const createdTask = {
      id: 'task-1',
      title: '学习 React 19',
      completed: false,
      createdAt: '2026-08-25T08:00:00.000Z',
    };

    vi.mocked(taskApi.create).mockResolvedValue(
      createdTask,
    );

    render(<TaskComposer onCreated={onCreated} />);

    const input = screen.getByLabelText('任务名称');

    await user.type(input, '学习 React 19');
    await user.click(
      screen.getByRole('button', {
        name: '创建任务',
      }),
    );

    await waitFor(() => {
      expect(onCreated).toHaveBeenCalledWith(
        createdTask,
      );
    });

    expect(input).toHaveValue('');
    expect(
      screen.getByRole('status'),
    ).toHaveTextContent('任务创建成功');
  });
});

运行:

bash 复制代码
npm run test

测试应该尽量模拟用户行为,而不是测试组件内部实现细节。

推荐查询:

tsx 复制代码
screen.getByRole('button', {
  name: '创建任务',
});

不应过度依赖:

  • CSS 类名。
  • 内部 State。
  • 私有函数。
  • DOM 层级细节。
  • 大量 data-testid

32.3 E2E 测试

可以使用 Playwright:

bash 复制代码
npm init playwright@latest

示例:

ts 复制代码
import {
  expect,
  test,
} from '@playwright/test';

test('用户可以创建并完成任务', async ({ page }) => {
  await page.goto('/');

  await page
    .getByLabel('任务名称')
    .fill('完成 React 项目实战');

  await page
    .getByRole('button', {
      name: '创建任务',
    })
    .click();

  await expect(
    page.getByText('完成 React 项目实战'),
  ).toBeVisible();

  await page
    .getByLabel('完成任务:完成 React 项目实战')
    .check();
});

E2E 应优先覆盖:

  • 登录。
  • 核心创建流程。
  • 支付前关键流程。
  • 权限控制。
  • 数据编辑和保存。
  • 路由跳转。
  • 错误恢复。

33. React 性能优化

33.1 先测量再优化

使用:

  • React DevTools Profiler。
  • React 19.2 Performance Tracks。
  • Chrome Performance。
  • Lighthouse。
  • Web Vitals。
  • 真实用户监控。

关注:

  • LCP:主要内容加载时间。
  • INP:交互响应。
  • CLS:布局稳定性。
  • JavaScript 执行时间。
  • 长任务。
  • 重复请求。
  • 大型资源。
  • 不必要的组件更新。

33.2 缩小状态影响范围

不要把输入框状态放到页面最顶层,除非其他组件确实需要它。

状态越靠近真正使用它的组件,重新渲染影响范围通常越小。

33.3 正确使用 useMemo

tsx 复制代码
const visibleTasks = useMemo(
  () => filterTasks(tasks, filter),
  [tasks, filter],
);

适合:

  • 计算明显昂贵。
  • 数据量较大。
  • 需要稳定引用作为其他 Hook 依赖。
  • 已经通过性能分析确认存在问题。

不适合:

tsx 复制代码
const total = useMemo(
  () => price * count,
  [price, count],
);

简单乘法没必要缓存。

33.4 正确使用 useCallback

useCallback 主要用于缓存函数引用:

tsx 复制代码
const handleSelect = useCallback((id: string) => {
  setSelectedId(id);
}, []);

它本身也有成本,不要把每个函数都包起来。

33.5 useTransition

将非紧急更新标记为 Transition:

tsx 复制代码
const [isPending, startTransition] = useTransition();

function handleKeywordChange(keyword: string) {
  setInputValue(keyword);

  startTransition(() => {
    setSearchKeyword(keyword);
  });
}

输入框更新保持紧急,复杂列表筛选可以稍后执行。

33.6 useDeferredValue

tsx 复制代码
const deferredKeyword = useDeferredValue(keyword);

适合让复杂内容跟随输入值延迟更新,但它:

  • 不会减少网络请求。
  • 不是防抖函数。
  • 不等于 setTimeout
  • 主要用于降低非紧急渲染优先级。

33.7 大列表虚拟化

当页面有数千或数万行时,不应一次渲染全部 DOM。

可以使用虚拟列表,只渲染视口附近内容。

33.8 React Compiler

React Compiler 是构建时优化工具,可以自动处理许多组件和表达式的记忆化。

当前 Vite 模板已经提供:

bash 复制代码
npm create vite@latest taskflow -- --template react-compiler-ts

但工程上仍然需要:

  • 遵守 Rules of React。
  • 保持渲染纯净。
  • 先检查现有代码是否符合编译规则。
  • 增量启用。
  • 对比构建结果和运行性能。
  • 不要未经验证就批量删除已有手动缓存。

Compiler 不能修复错误的数据流、巨大的依赖包、低效接口或不合理组件结构。


34. React 项目安全

34.1 防止 XSS

React 默认会转义普通字符串:

tsx 复制代码
<p>{userInput}</p>

不要直接渲染不可信 HTML:

tsx 复制代码
<div
  dangerouslySetInnerHTML={{
    __html: userInput,
  }}
/>

如果业务必须展示 HTML,应使用可靠的 HTML Sanitizer,并在服务端和客户端建立统一安全策略。

34.2 不要把密钥放进前端

前端不能安全保存:

  • 数据库密码。
  • 私钥。
  • 管理员密钥。
  • 服务端 API Secret。
  • JWT 签名密钥。

即使经过压缩、混淆或 Base64,用户仍然可以提取。

如果使用 Cookie:

  • 设置 HttpOnly
  • 设置 Secure
  • 设置合理的 SameSite
  • 对需要的场景实施 CSRF 防护。

如果把 Token 放在 Web Storage,需要认真评估 XSS 风险。

34.4 CORS 不是权限控制

CORS 是浏览器跨域读取限制,不是用户认证和接口授权。

服务端仍然必须检查:

  • 用户身份。
  • 资源归属。
  • 角色权限。
  • 租户边界。
  • 操作范围。

34.5 React Server Components 安全更新

React Server Components 在 2025 年底至 2026 年初发布过多项安全修复。

如果项目使用:

  • react-server-dom-webpack
  • react-server-dom-parcel
  • react-server-dom-turbopack
  • 支持 RSC 的框架或插件。

应至少使用已经修复相关问题的 19.0.419.1.519.2.4 或更高安全版本,并优先按照框架官方公告升级到当前最新补丁。

本文的纯客户端 Vite 项目没有使用 RSC 包,不受这些特定 RSC 漏洞影响,但仍应定期检查依赖安全公告。


35. 工程规范与目录设计

中大型项目可以按业务模块组织:

text 复制代码
src/
├─ app/
│  ├─ router/
│  ├─ providers/
│  └─ config/
├─ features/
│  ├─ auth/
│  │  ├─ components/
│  │  ├─ hooks/
│  │  ├─ services/
│  │  └─ types/
│  └─ tasks/
│     ├─ components/
│     ├─ hooks/
│     ├─ services/
│     └─ types/
├─ shared/
│  ├─ components/
│  ├─ hooks/
│  ├─ services/
│  ├─ styles/
│  └─ utils/
└─ main.tsx

推荐原则:

  • 按业务能力组织,而不是只按文件类型组织。
  • 公共组件与业务组件分开。
  • 页面组件不直接散落大量请求代码。
  • 数据访问层与 UI 分离。
  • 外部数据进入系统时进行运行时校验。
  • 避免形成无边界的 utilscommon 目录。
  • 控制跨模块引用。
  • 为公共模块建立清晰 API。

35.1 TypeScript 严格配置

建议启用:

json 复制代码
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}

开启严格选项后,需要更明确地处理:

  • undefined
  • 可选属性。
  • 数组越界。
  • 空值。
  • 外部数据。
  • 类方法覆盖。

36. 构建与部署

36.1 生产构建

bash 复制代码
npm run build

Vite 默认输出到:

text 复制代码
dist/

本地预览:

bash 复制代码
npm run preview

preview 只用于检查生产构建,不是正式生产服务器。

36.2 Nginx 部署 SPA

示例配置:

nginx 复制代码
server {
    listen 80;
    server_name example.com;

    root /var/www/taskflow/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location = /index.html {
        add_header Cache-Control "no-cache";
    }
}

try_files 很重要,否则用户直接访问前端路由时可能得到 404。

36.3 Docker 多阶段构建

Dockerfile

dockerfile 复制代码
FROM node:24-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM nginx:alpine

COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

.dockerignore

text 复制代码
node_modules
dist
.git
coverage
playwright-report
*.log

构建:

bash 复制代码
docker build -t taskflow:1.0.0 .
docker run --rm -p 8080:80 taskflow:1.0.0

37. 持续集成

创建 .github/workflows/ci.yml

yaml 复制代码
name: CI

on:
  push:
  pull_request:

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm run test

      - name: Build
        run: npm run build

推荐合并门禁:

text 复制代码
格式检查
→ ESLint
→ TypeScript 检查
→ 单元测试
→ 组件测试
→ 生产构建
→ 必要的 E2E
→ 依赖安全检查

CI 使用 npm ci,要求提交锁文件 package-lock.json


38. 从 React 18 升级到 React 19

38.1 先升级到 React 18.3

React 官方提供 React 18.3,用于在升级 19 前暴露废弃 API 警告。

bash 复制代码
npm install react@18.3 react-dom@18.3

修复警告后再升级:

bash 复制代码
npm install react@latest react-dom@latest
npm install -D @types/react@latest @types/react-dom@latest

38.2 确认使用现代 JSX Transform

React 19 要求现代 JSX Transform。

现代 Vite、Next.js 和主流构建工具通常已经默认启用。

38.3 检查废弃 API

重点检查:

  • ReactDOM.render
  • ReactDOM.hydrate
  • unmountComponentAtNode
  • 字符串 ref。
  • 旧 Context API。
  • 函数组件的 propTypesdefaultProps
  • 旧测试工具。
  • TypeScript ref 回调隐式返回值。

旧入口:

tsx 复制代码
ReactDOM.render(<App />, rootElement);

新入口:

tsx 复制代码
createRoot(rootElement).render(<App />);

38.4 不要一次重写整个项目

推荐升级顺序:

  1. 锁定当前功能基线。
  2. 补充关键路径测试。
  3. 升级 React 18.3。
  4. 修复警告。
  5. 升级 React 19 和类型包。
  6. 执行 TypeScript 检查。
  7. 执行单元测试和 E2E。
  8. 验证 SSR 或 Hydration。
  9. 最后逐步采用 Actions 等新能力。

React 19 新 API 不是升级后必须立即使用的功能。旧的 useStateonSubmituseEffect 代码仍然可以继续工作。


39. 常见错误与排查

39.1 页面没有更新

检查是否直接修改了 State:

tsx 复制代码
tasks.push(newTask);
setTasks(tasks);

应该创建新数组:

tsx 复制代码
setTasks((current) => [
  ...current,
  newTask,
]);

39.2 Effect 无限执行

tsx 复制代码
useEffect(() => {
  setOptions({
    theme: 'dark',
  });
}, [options]);

每次更新都会生成新对象,引发下一次 Effect。

应重新设计数据流,而不是简单删除依赖。

39.3 列表输入内容错位

检查是否使用了数组下标作为 key:

tsx 复制代码
items.map((item, index) => (
  <Editor key={index} item={item} />
));

改用稳定业务 ID。

39.4 请求返回 404,但 catch 没执行

fetch 在 HTTP 404 或 500 时通常不会自动 reject。

必须检查:

ts 复制代码
const response = await fetch('/api/tasks');

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

39.5 useFormStatus 一直是 false

检查:

  • 调用它的组件是否位于目标 <form> 内部。
  • 表单是否使用函数 Action。
  • 是否在表单同级组件中错误调用。
  • 提交按钮是否确实是 type="submit"

39.6 useOptimistic 没有效果

乐观更新函数需要在 Action 或 Transition 中执行:

tsx 复制代码
startTransition(async () => {
  applyOptimisticAction(action);
  await save();
});

39.7 use(fetch()) 一直重新加载

不要在组件渲染中创建新 Promise。

应使用:

  • 框架数据加载。
  • 缓存的 Promise。
  • 从父组件或服务端传入的 Promise。
  • 支持 Suspense 的数据层。

39.8 开发环境请求执行两次

如果代码位于 Effect 中,并且应用启用了 StrictMode,开发环境会执行额外的 setup 和 cleanup 检查。

应确保:

  • Effect 可以安全清理。
  • GET 请求可以取消或去重。
  • 写操作不放在由页面显示触发的 Effect 中。
  • 后端写接口具有幂等性。

不要仅为隐藏问题而删除 StrictMode


40. React 工程实践清单

组件设计

  • 组件职责单一。
  • Props 类型明确。
  • Props 不被子组件修改。
  • 业务组件与通用组件分离。
  • 列表使用稳定 key。
  • 没有在渲染过程中产生副作用。

状态设计

  • 只保存最小必要状态。
  • 派生数据在渲染时计算。
  • 状态放在最近的共同父组件。
  • 不直接修改对象和数组。
  • 服务端状态没有被无意义地多处复制。

Effect

  • Effect 只用于同步外部系统。
  • 依赖完整。
  • 订阅和定时器有清理函数。
  • 请求支持取消、忽略过期响应或去重。
  • 没有通过关闭 ESLint 掩盖依赖问题。

异步交互

  • 提交过程有 Pending 状态。
  • 按钮防止重复提交。
  • 成功和失败都有反馈。
  • 乐观更新可以回滚。
  • 服务端接口考虑幂等。
  • 错误日志包含必要上下文。

TypeScript

  • 启用严格模式。
  • 避免滥用 any
  • 外部数据以 unknown 接收。
  • 接口数据有运行时校验。
  • 类型断言集中在明确边界。

测试

  • 核心纯函数有单元测试。
  • 关键组件有交互测试。
  • 核心用户流程有 E2E。
  • 测试关注行为而不是实现细节。
  • CI 中执行测试和构建。

性能

  • 使用性能工具测量。
  • 路由和大模块按需加载。
  • 大列表使用虚拟化。
  • 没有无意义地到处使用 useMemo
  • 关注 LCP、INP 和 CLS。
  • 静态资源配置了合理缓存。

安全

  • 不直接渲染不可信 HTML。
  • 客户端不包含服务端密钥。
  • 服务端执行身份和权限检查。
  • Cookie 和 CSRF 策略明确。
  • 依赖定期升级。
  • RSC 项目使用安全补丁版本。

41. 学习路线建议

第一阶段:React 基础

掌握:

  • JSX。
  • 组件。
  • Props。
  • State。
  • 事件。
  • 条件渲染。
  • 列表渲染。
  • 表单。

第二阶段:Hooks

掌握:

  • useState
  • useEffect
  • useRef
  • useReducer
  • useContext
  • 自定义 Hook。

第三阶段:React 19

掌握:

  • Actions。
  • useActionState
  • useFormStatus
  • useOptimistic
  • use
  • Suspense。
  • Activity
  • useEffectEvent

第四阶段:工程化

掌握:

  • TypeScript。
  • Vite。
  • ESLint。
  • 路由。
  • 数据请求。
  • 运行时数据校验。
  • 测试。
  • 性能。
  • 安全。
  • CI/CD。
  • Docker 和 Nginx。

第五阶段:框架与架构

根据项目学习:

  • Next.js。
  • React Router Framework Mode。
  • SSR。
  • SSG。
  • React Server Components。
  • Server Functions。
  • Streaming。
  • Partial Pre-rendering。
  • React Compiler。

42. 总结

React 的难点不只是记住 Hook,而是建立正确的数据和渲染模型:

text 复制代码
Props 和 State 决定 UI
事件处理用户行为
Action 处理异步修改
Effect 同步外部系统
Context 解决合理的跨层传递
Suspense 描述等待边界
Error Boundary 描述失败边界

React 19 进一步完善了异步交互:

  • useActionState 统一 Action 状态。
  • useFormStatus 提供表单 Pending 状态。
  • useOptimistic 简化乐观更新。
  • use 统一读取 Promise 和 Context。
  • Activity 可以隐藏 UI 并保留状态。
  • useEffectEvent 帮助区分 Effect 中的响应式与非响应式逻辑。
  • React Compiler 可以逐步减少手动记忆化代码。

真正可维护的 React 工程还需要关注:

  • 清晰的模块边界。
  • 最小状态设计。
  • 正确的异步和错误处理。
  • TypeScript 与运行时校验。
  • 可访问性。
  • 自动化测试。
  • 性能测量。
  • 安全和依赖更新。
  • 可重复的构建与部署。

本文的 TaskFlow 项目虽然规模不大,但已经具备一个真实 React 项目的基本结构。可以继续扩展:

  • 用户登录。
  • 任务分类。
  • 优先级。
  • 截止时间。
  • 拖拽排序。
  • 分页。
  • REST API。
  • WebSocket 实时同步。
  • React Router。
  • 离线模式。
  • 多端数据同步。

掌握这些内容后,就已经从"会写 React 组件"进入了"能够设计和交付 React 工程"的阶段。


43. 官方参考资料

相关推荐
tedcloud1231 小时前
diagram-design 怎么安装?用 AI 自动生成更专业的架构图、流程图
linux·运维·前端·人工智能·开源·流程图
何智超1 小时前
React 18 并发更新:高优先级先渲染,为什么最终状态不会算错?
前端
前端小万1 小时前
两个半月的时间如何赚到 3000 块钱。
前端
YIAN1 小时前
端侧大模型:DeepSeek-R1 WebGPU 推理全流程源码深度解析
前端·typescript·deepseek
请你吃div1 小时前
个人 Nuxt4 博客 SEO 优化实战教程
前端·nuxt.js·seo
网安蟹佬霸1 小时前
Android安全攻防实战:从APK逆向到Frida动态Hook全流程详解(附脚本)
android·前端·安全·web安全·逆向·csrf·网安
计算机魔术师1 小时前
芯片不跌反涨?两家公司的赌局正在重塑全球算力格局
前端
IT_陈寒1 小时前
Python多进程池的坑:子进程竟然不会退出
前端·人工智能·后端
snow@li1 小时前
前端:全景深度分析/前端岗位起源,以及“美国没有前端岗位”的真相
前端