Files
2026-05-24 11:42:38 +08:00

460 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [React, Migration, Upgrade, Frontend]
create time: 2026-04-29 22:17
---
# 迁移与升级
## 概述
大型 React 项目不可避免地面临技术债务和版本升级。本文档提供从 Class 组件到函数组件、从旧版 Router 到 v7、以及应对 React Major Version 变更的系统化迁移策略。
## Class → Function + Hooks 迁移
### 逐步对照表
| Class 特性 | Hook 等效写法 |
|-----------|--------------|
| `this.state` | `useState` |
| `componentDidMount` | `useEffect(() => { ... }, [])` |
| `componentDidUpdate(prev)` | `useEffect(() => { ... }, [dep])` |
| `componentWillUnmount` | `useEffect(() => { return () => { cleanup } })` |
| `this.setState(fn)` | `setState(prev => ({ ... }))` |
| `ref = React.createRef()` | `useRef` |
| `shouldComponentUpdate` | `React.memo` / `useMemo` / `useCallback` |
| Context.Consumer | `useContext` |
| 派生计算值 | `useMemo(() => expensiveCalc(data), [data])` |
| 传递函数避免重渲染 | `useCallback(fn, deps)` |
### 性能相关 Hooks 迁移
在实际迁移中,Class 组件往往隐含了 `shouldComponentUpdate` 或手动优化逻辑。用 Hooks 重写时需要显式声明:
```tsx
// ❌ Before: Class Component — 隐式全量渲染
class TodoList extends React.Component {
state = { filter: "", todos: [] };
// shouldComponentUpdate 需要手动写,否则每次都全量渲染
render() {
return (
<div>
<input onChange={e => this.setState({ filter: e.target.value })} />
{/* todos 每次都会重新创建新数组引用,子列表无法优化 */}
<TodoItems todos={this.state.todos.filter(t => t.text.includes(this.state.filter))} />
</div>
);
}
}
// ✅ After: useMemo + useCallback 显式控制
function TodoList() {
const [filter, setFilter] = useState("");
const todos = useTodos(); // custom hook
// 只在 todos/filter 变化时重新过滤
const filteredTodos = useMemo(
() => todos.filter(t => t.text.includes(filter)),
[todos, filter]
);
// setFilter 引用稳定,不会导致子组件无效重渲染
const handleFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
return (
<div>
<input onChange={handleFilter} />
<TodoItems todos={filteredTodos} />
</div>
);
}
```
> [!tip] 何时使用 useMemo vs useCallback
> - **memorize value** → `useMemo`: 用于昂贵计算结果或防止子组件 props 引用变化
> - **memorize function** → `useCallback`: 用于传递给子组件回调或作为 effect 依赖
> - **不要过早优化**: 没有 profiling 数据支持时,默认不用 memo——可读性优先
### 迁移工具推荐
> [!note] react-codemod 自动化迁移
> Facebook 官方提供的 codemod 可以批量转换大部分 Class → Function 代码:
> ```bash
> npx jscodeshift -t node_modules/react-codemod/transforms/class-to-hook.js src/
> ```
> ⚠️ Codemod 只做机械转换,`useEffect` cleanup、`useCallback` 依赖数组等仍需人工审查。
> 建议将 `git diff --name-only` 结果加入 Code Review。
> [!warning] 迁移 Checklist
> - [ ] 所有 `componentDidMount/Update/Unmount` 转译为 useEffect
> - [ ] State 合并:将互不相关的 state 拆分,减少不必要的重渲染
> - [ ] 移除 `this.` —— 检查所有闭包中的变量引用
> - [ ] Props 类型从 class props 改为 interface 解构
> - [ ] 审查 `useEffect` 依赖数组,避免 stale closure
> - [ ] 为传给子组件的函数添加 `useCallback`(如经 profiling 确认需要)
## React Router v6 → v7 迁移
```mermaid
graph LR
A["v6 BrowserRouter + Switch"] -->|"Routes + createBrowserRouter"| B["v7 Data Router"]
C["v6 <br/>Route path prop"] -->|"简化"| D["v7 route config object"]
E["v6 <br/>Redirect"] -->|"Navigate replace"| F["v7 Navigate"]
G["v6 Outlet"] -->|"保留,位置不变"| H["v7 Outlet"]
style A fill:#F4DBD6,color:#000
style B fill:#61DAFB,color:#000
```
### API 映射速查
| v6 | v7 | 备注 |
|----|----|------|
| `<BrowserRouter>` | `<RouterProvider>` + `createBrowserRouter` | data router 模式 |
| `Switch` | `Routes` | v6 已有 |
| `<Redirect to="/x" />` | `<Navigate to="/x" />` | 语义更清晰 |
| `useHistory` | `useNavigate` | 名称统一 |
| `<Route component={Page}>` | `<Route element={<Page />} >` | JSX 方式 |
| `match.params` | `useParams()` | hook 方式 |
| `useLocation()` | 同 v6 | `location.key` 不再等于 `"default"` |
### Data Router 配置示例
v7 引入 **Data Router** 概念,路由配置从 JSX 变为纯数据驱动:
```tsx
// ❌ v6: JSX 声明式路由
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="users/:id" element={<Profile />} />
</Route>
</Routes>
</BrowserRouter>
);
}
// ✅ v7: createBrowserRouter 数据驱动
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <Profile /> },
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
```
> [!note] 为什么 Data Router?
> Data Router 将路由状态与 React 状态分离,使得导航、加载、错误处理可以脱离组件树独立管理。这为后续 Loader / Action / Error Boundary 等数据功能奠定了基础。如果需要保留 JSX 写法,可以使用 `createRoutesFromElements` 桥接。
```tsx
// 折中方案:用 JSX 创建 route config
import { createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
</Route>
)
);
```
> [!warning] 常见陷阱
> - `useParams()` 在 route 未匹配时返回空对象 `{}` 而非 undefined,需要显式判断
> - `useSearchParams()` 行为与 v6 一致,但 URL 解析基于 Data Router
> - 嵌套路由的 Outlet 必须渲染在父元素中,否则子路由不会显示
## React 17 → 18 兼容性
React 18 是一个 **渐进式** 升级,不需要整体重写。核心变更如下:
| 注意事项 | 说明 |
|----------|------|
| `ReactDOM.render` 已废弃 | 改用 `createRoot().render()` |
| `ReactDOM.unmountComponentAtNode` 已废弃 | 改用 `root.unmount()` |
| 事件处理在微任务中执行 | 批处理行为改变;`event.persist()` 已移除 |
| StrictMode 双重渲染 | 开发环境 effect 先 mount → unmount → remount(用于检测不安全 effect) |
| 自动批处理扩展 | React 事件、promise、setTimeout 等全部自动批处理 |
```tsx
// React 17
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// React 18
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
```
### Suspense on Waterfall & Streaming SSR
React 18 的 Suspense 可以配合 `SuspenseList` 和 streaming SSR 实现渐进式加载:
```tsx
// Server Component 场景(Next.js App Router)
// 外层 UI 立即呈现,内部大组件用 Suspense 包裹懒加载
export default function Page() {
return (
<main>
<Header /> // 立即渲染
<Navigation /> // 立即渲染
<Suspense fallback={<DashboardSkeleton />}>
<HeavyChart data={data} /> // 等待数据加载后渲染
</Suspense>
</main>
);
}
```
### useTransition & useDeferredValue
React 18 引入两个新的 concurrency primitives,用于优化用户体验:
```tsx
import { useState, useTransition, useDeferredValue } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
// 输入更新是"普通"优先级,结果渲染是"低"优先级
const handleInput = (value: string) => {
setQuery(value); // 保持输入响应
startTransition(() => {
doExpensiveSearch(value); // 不阻塞输入
});
};
return (
<>
<input value={query} onChange={e => handleInput(e.target.value)} />
{isPending && <Spinner />}
<SearchResults query={query} />
</>
);
}
```
### createPortal 变更
v18 起 `createPortal` 的宿主容器不再要求必须是 DOM 树中的元素——这意味着可以在 off-screen 容器中创建 portal,延迟挂载到 DOM:
```tsx
// v17: 必须先将容器添加到 DOM
const container = document.createElement("div");
document.body.appendChild(container);
const portal = createRoot(container);
// v18: 可以直接创建,稍后再挂载
const container = document.createElement("div");
const portal = createRoot(container);
// ... later ...
document.body.appendChild(container.children[0]);
```
### React 18 升级 Checklist
> [!note] React 17 → 18 升级清单
> - [ ] `ReactDOM.render` → `createRoot().render()`
> - [ ] `StrictMode` 下确认无副作用泄漏(effect unmount/remount 测试)
> - [ ] 检查第三方库是否声明了 `react@^18` peerDep
> - [ ] `useTransition` / `useDeferredValue` 可逐步引入到高延迟场景
> - [ ] 流式 SSR 项目需验证 hydration 兼容性
> - [ ] `act()` 警告更严格,确保测试中 await act()
## Next.js Pages → App Router 迁移
### 目录与 API 对照表
| Pages Router | App Router |
|-------------|-----------|
| `pages/` 目录 | `app/` 目录 |
| `_document.tsx` | **不再需要**,由框架自动生成 HTML |
| `_app.tsx` | `app/layout.tsx`(根布局) |
| `getServerSideProps` | async Page 组件 |
| `getStaticProps` | async Page + `revalidate` |
| `next/router` | `next/navigation` |
| `getInitialProps` | **不支持**,需用 Server Component 替代 |
| `_middleware.ts` | `middleware.ts` |
| Dynamic Route `[slug]` | `[slug]/page.tsx` |
### 核心差异:Server Components vs Client Components
App Router 中默认页面组件是 **React Server Component (RSC)**:
```tsx
// ❌ Pages Router: getServerSideProps — 必须显式导出
export async function getServerSideProps() {
const data = await api.getData();
return { props: { data } };
}
function Page({ data }) {
return <View data={data} />;
}
// ✅ App Router: async 组件直接运行在服务端
async function Page() {
const data = await api.getData();
// data 不会序列化发送到客户端,减少 bundle size
return <View data={data} />;
}
// ⚡ 需要交互的组件用 "use client" 标记为 Client Component
"use client";
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
```
### Loading UI & Error Boundary
Pages Router 需要手动实现 loading 和 error 页面;App Router 内置支持:
```tsx
// app/dashboard/loading.tsx — 过渡 UI 自动展示
export default function Loading() {
return <div className="animate-pulse">Loading dashboard...</div>;
}
// app/dashboard/error.tsx — boundary 捕获错误,无需 try/catch
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
```
### Middleware 变化
```ts
// ❌ Pages Router: _middleware.ts — 匹配规则有限
import { NextResponse } from "next/server";
export function middleware(req) {
if (!req.cookies.get("auth")) {
return NextResponse.redirect("/login");
}
}
// ✅ App Router: middleware.ts — 更强大的匹配和响应能力
import { NextResponse } from "next/server";
export function middleware(req) {
const token = req.cookies.get("auth")?.value;
if (!token && !req.nextUrl.pathname.startsWith("/login")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next(); // 放行
}
export const config = { matcher: "/dashboard/:path*" }; // 只匹配需要的路由
```
### 迁移路径建议
> [!tip] 渐进式迁移策略
> - **方案 A(推荐)**:在 `next.config.js` 中设置 `experimental.appDir = true`,双模式并行,按页面逐个迁移到 `app/`
> - **方案 B**:新项目直接使用 App Router,老项目保持 Pages Router 不动
> - 注意:`getInitialProps` **无法**迁移到 App Router,需要用 fetch + 缓存替代
## Major Version 升级通用流程
```mermaid
flowchart TD
A["New Version Released"] --> B["Read Changelog / Breaking Changes"]
B --> C{"Impact Assessment"}
C --> D["Minor version bump"]
C --> E["API changes / new config"]
F["Architecture change"]
D --> G["Upgrade + CI verify"]
E --> H["Fix per file"]
F --> I["Phased migration + parallel test"]
G --> J["Update deps + run tests"]
H --> J
I --> J
J --> K["Staging verification"]
K --> L["Canary release / Feature Flag"]
L --> M["Full rollout"]
style F fill:#F5A87D,color:#000
style I fill:#F5A87D,color:#000
```
### 通用升级最佳实践
> [!quote] 经验法则
> **永远不要在同一次 PR 中升级依赖 + 重构业务逻辑。** 如果测试失败,你无法判断是版本破坏还是你的代码有问题。分步进行:先升级跑通 CI,再逐步优化。
| 实践 | 说明 |
|------|------|
| **锁定大版本** | `package.json` 中使用 `"^18.2.0"` 而非 `"*"`,防止意外拉取 patch 外的变更 |
| **CI 前置** | 升级后首先确保所有测试通过(含 snapshot test) |
| **逐个升级** | 先修 core(React),再修生态(Router、Redux 等),最后修 UI 库 |
| **Feature Flag** | 对新路由或新页面用 feature flag 控制,观察指标后再全量开放 |
| **监控告警** | 上线后关注 Error Boundary 捕获的异常数量和性能指标(LCP、FCP) |
## 常见陷阱汇总
> [!danger] Class → Function 常见陷阱
> - **stale closure**: `useEffect` 中没有把依赖放入数组,导致闭包中读取到旧值
> ```tsx
> // ❌ name 变化时 effect 不会重新执行
> useEffect(() => { console.log(name); }, []);
> // ✅ 正确写法
> useEffect(() => { console.log(name); }, [name]);
> ```
> - **过度使用 memo**: `React.memo` / `useMemo` / `useCallback` 本身有比较成本, profiling 确认瓶颈后再加
> - **忽略 cleanup**: `useEffect` 返回的 cleanup 函数必须清除定时器、取消 fetch、移除 listener
> [!danger] Router 常见陷阱
> - Data Router 下 `navigate()` 不会自动停止正在进行的 loader fetch,需要 `AbortController`
> - v7 的 `future.v7_relativeSplatPath` 改变了 splat route 的路径解析规则
> - 嵌套路由中忘记在父元素渲染 `<Outlet />` 导致子路由不显示
> [!danger] React 18 常见陷阱
> - StrictMode 双重渲染不是 bug,是有意为之的检测机制——利用它发现 effect cleanup 问题
> - `ReactDOM.render` 移除后老代码直接报错,需要用 codemod 批量替换
> - `act()` 包装缺失会导致测试异步状态更新丢失,务必 `await act(async () => {...})`
> [!danger] Next.js 常见陷阱
> - Client Component 中不能直接 `import` Server Component
> - 动态路由组 `(group)` 只影响 URL 路径,不影响目录结构
> - App Router 中的 layout 会跨页面保持状态,需用 `key` 强制重建
## 关联笔记
- [[hhs/REACT/2. Hooks 篇/05-核心 Hooks]]
- [[hhs/REACT/3. 生态工具篇/08-路由管理]]
- [[hhs/REACT/4. 进阶篇/13-并发特性]]
- [[hhs/REACT/4. 进阶篇/14-Serverside Rendering]]
- [[hhs/REACT/5. 工程实践篇/15-性能优化]]
- [[hhs/REACT/5. 工程实践篇/16-测试]]