本文以 React 19.2、TypeScript 和 Vite 为技术基线,从 JSX、组件、状态、Hooks 开始,逐步讲解 React 19 的 Actions、
useActionState、useOptimistic、use、Activity、useEffectEvent等能力,并完成一个可以独立运行的 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。useAPI。- 函数组件可以直接接收
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-hooksv6。
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>
);
}
花括号中可以放表达式,但不能直接放 if、for 等语句。
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。
- 并发更新。
- 阻塞主线程的任务。
性能优化应先测量再修改,不要仅凭"组件重新执行了"就增加大量 memo、useMemo 和 useCallback。
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
现在可以:
- 创建任务。
- 搜索任务。
- 筛选任务。
- 标记完成。
- 删除任务。
- 刷新页面后保留数据。
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,用户仍然可以提取。
34.3 Token 与 Cookie
如果使用 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.4、19.1.5、19.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 分离。
- 外部数据进入系统时进行运行时校验。
- 避免形成无边界的
utils和common目录。 - 控制跨模块引用。
- 为公共模块建立清晰 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。
- 函数组件的
propTypes和defaultProps。 - 旧测试工具。
- TypeScript ref 回调隐式返回值。
旧入口:
tsx
ReactDOM.render(<App />, rootElement);
新入口:
tsx
createRoot(rootElement).render(<App />);
38.4 不要一次重写整个项目
推荐升级顺序:
- 锁定当前功能基线。
- 补充关键路径测试。
- 升级 React 18.3。
- 修复警告。
- 升级 React 19 和类型包。
- 执行 TypeScript 检查。
- 执行单元测试和 E2E。
- 验证 SSR 或 Hydration。
- 最后逐步采用 Actions 等新能力。
React 19 新 API 不是升级后必须立即使用的功能。旧的 useState、onSubmit、useEffect 代码仍然可以继续工作。
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 工程"的阶段。