Files
cs-note/hhs/REACT/5. 工程实践篇/18-迁移与升级.md
T
2026-05-24 11:42:38 +08:00

16 KiB
Raw Blame History

tags, create time
tags create time
React
Migration
Upgrade
Frontend
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 重写时需要显式声明:

// ❌ 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 代码:

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 迁移

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 变为纯数据驱动:

// ❌ 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 桥接。

// 折中方案:用 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 等全部自动批处理
// 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 实现渐进式加载:

// 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,用于优化用户体验:

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:

// 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):

// ❌ 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 内置支持:

// 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 变化

// ❌ 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 升级通用流程

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 中没有把依赖放入数组,导致闭包中读取到旧值
    // ❌ 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-测试