返回列表

Claude Code 重构工作流完全指南:从小步重构到大规模改造的 AI 协作之道

2026-03-13·10 分钟阅读·AI教程

前言

重构这个话题,在这个系列里其实一直是"配角"——进阶指南的 9.3 节用了 5 行写了个流程清单,测试指南里把重构当作 TDD 循环的最后一步,Git 指南里提到了重构分支策略。但从来没有一篇文章正面回答过:怎么用 Claude Code 系统性地做重构

提取函数怎么提?大文件怎么拆?类型系统怎么迁移?API 怎么做 breaking change?跨几十个文件的重命名怎么保证不漏?这些问题,今天一次讲清楚。

这篇文章和调试指南是姊妹篇——调试是"代码出了问题怎么修",重构是"代码没问题但可以更好"。两篇搭配着看效果更好。

一、为什么重构需要 AI 搭档

重构的风险与收益

重构的本质是"在不改变外部行为的前提下改善代码内部结构"。听起来很美好,但现实是:

收益风险
提高可读性引入回归 bug
降低维护成本打断当前开发节奏
改善性能合并冲突增多
方便后续扩展测试覆盖不足时无法验证
减少技术债重构范围失控(越改越多)
统一代码风格团队成员不理解改动意图

这就是为什么很多团队"知道该重构,但一直不敢动"。

手动重构 vs Claude Code 重构

维度手动重构Claude Code 重构
速度一个函数 15-60 分钟一个函数 2-5 分钟
影响范围分析靠 IDE 搜索 + 经验自动分析所有引用和依赖
风险控制改完跑测试祈祷每步重构后自动验证
一致性改了这里忘了那里全局统一修改
文档同步经常忘记更新可以同步更新注释和文档
Code Review大 diff 难以审查可以按步骤拆分 commit

重构 vs 重写:怎么选?

信号选择
核心逻辑正确,只是结构混乱重构
测试覆盖率 > 60%重构(有安全网)
技术栈需要整体迁移重写
没有人理解现有代码先让 Claude Code 分析,再决定
改动范围 < 30% 的代码重构
改动范围 > 70% 的代码重写

拿不准的时候,让 Claude Code 帮你评估:

> 分析 src/legacy/ 目录的代码质量:
> 1. 有多少文件超过 300 行?函数平均复杂度如何?
> 2. 有多少 any 类型?测试覆盖率大概多少?
> ��我一个"重构 vs 重写"的建议。

二、重构前的准备

动手之前,先把安全网搭好。重构最怕的不是改错,而是改错了不知道。

检查测试覆盖率

没有测试的重构就是在走钢丝。详细的测试工作流参考测试指南

# 先看覆盖率
> 跑一下测试覆盖率,重点看我要重构的 components/Navigation.tsx
 
# 覆盖率不够就先补
> Navigation.tsx 覆盖率只有 40%,帮我补充测试:
> - 桌面端导航链接渲染
> - 移动端 hamburger 菜单开关
> - 活跃链接高亮
> - Escape 键关闭菜单

Git 分支策略

重构一定要在独立分支上做。推荐用 worktree 隔离,详见 Git 指南

# 方式一:普通分支
git checkout -b refactor/navigation-split
 
# 方式二:worktree 隔离(推荐)
git worktree add ../my-blog-refactor -b refactor/navigation-split

CLAUDE.md 重构规则

在开始重构前,给 CLAUDE.md 加上重构专用规则:

## 重构规范
- 每次只做一种类型的重构(不要同时重命名 + 拆分 + 改类型)
- 每步重构后必须通过所有现有测试
- 不改变公共 API 的行为签名
- 提取的工具函数:camelCase,放 lib/;组件:PascalCase,放 components/
- Commit: refactor: 结构调整 / refactor!: 有 breaking change

重构前检查清单

检查项状态说明
测试覆盖率 ≥ 70%重点覆盖要重构的模块
独立分支已创建不在 main 上直接改
CLAUDE.md 已更新加入重构规范
当前测试全部通过npm test 无失败
已识别重构范围明确哪些文件会被改动
已备份未完成的工作git stash 保存当前进度

三、提取与拆分

提取是最常见的重构手法——大函数拆小函数,大文件拆小模块,臃肿组件拆成职责单一的子组件。

3.1 Extract Function

当一个函数超过 50 行,或者你需要写注释来解释"这一段在干什么",就该提取了:

> 看一下 lib/posts.ts 的 getPostsByLocale 函数,
> 它现在做了太多事(读文件、解析 frontmatter、排序、分页)。
> 帮我把每个职责提取成独立函数,保持原函数作为编排层。
// Before: 一个 80 行的大函数
export function getPostsByLocale(locale: Locale, page?: number) {
  // 读取文件... 解析 frontmatter... 过滤排序... 分页... 生成元数据...
}
 
// After: 职责清晰的小函数 + 编排层
function readMdxFiles(locale: Locale): string[] { /* ... */ }
function parseFrontmatter(content: string): PostMeta { /* ... */ }
function sortByDate(posts: PostMeta[]): PostMeta[] { /* ... */ }
function paginate<T>(items: T[], page: number, perPage: number): PaginatedResult<T> { /* ... */ }
 
export function getPostsByLocale(locale: Locale, page: number = 1) {
  const files = readMdxFiles(locale)
  const posts = files.map(parseFrontmatter)
  const sorted = sortByDate(posts)
  return paginate(sorted, page, 10)
}

3.2 Extract Module

当一个文件超过 300 行,或者包含多个不相关的 export:

> lib/utils.ts 已经 500 多行了,里面有日期、字符串、URL、数组四类函数。
> 帮我拆成 lib/date.ts、lib/string.ts、lib/url.ts、lib/array.ts。
> 保留 lib/utils.ts 作为统一导出入口(re-export),更新所有 import。

关键提示词技巧(更多参考 Prompt 指南):

提示词效果
"保留原文件作为 re-export 入口"避免破坏现有 import
"更新所有 import 路径"确保全局一致性
"每个文件只包含相关函数"明确拆分标准
"加上 barrel export (index.ts)"简化导入路径

3.3 Split Component

组件拆分是前端重构的核心。判断标准:超过 200 行、有多个独立 UI 区域、有可复用子模式、状态管理混在渲染逻辑里。

> Navigation.tsx 现在 350 行,包含桌面导航、移动端菜单、主题切换。
> 帮我拆分成:
> - NavBar.tsx:外层容器 + 桌面端链接
> - MobileMenu.tsx:移动端 hamburger 菜单(含动画)
> - NavLink.tsx:单个导航链接(含 active 状态)
> 要求:用 props 传递数据,保持现有 aria 属性和键盘导航。

拆分后的文件结构:

components/navigation/
  index.ts          # re-export Navigation
  NavBar.tsx         # 主容器(~90 行)
  MobileMenu.tsx     # 移动端菜单(~80 行)
  NavLink.tsx        # 单个链接(~25 行)
  types.ts           # 共享类型

四、重命名与移动

重命名看起来简单,但跨文件重命名是最容易出错的重构之一。

4.1 安全重命名工作流

# 变量/函数重命名
> 把 lib/posts.ts 里的 getAllPosts 重命名为 getPostsByLocale。
> 更新所有引用(import、调用、测试、类型定义),列出所有被修改的文件。
 
# 文件重命名
> 把 components/Nav.tsx 重命名为 components/Navigation.tsx,更新所有 import。

4.2 跨文件重命名策略

涉及大量文件时,分步更安全:

# Step 1: 分析影响范围(先不改)
> 我想把 PostMeta 类型重命名为 PostFrontmatter。
> 先不要改,只告诉我这个类型在哪些文件中被使用,预计修改多少个文件。
 
# Step 2: 确认后执行
> 确认修改,每个文件改完后跑一次类型检查。
 
# Step 3: 验证
> 跑一下 tsc --noEmit 和 npm test,确认没有遗漏。

4.3 常见陷阱

陷阱原因解决方案
动态 import 没更新import() 路径是字符串提示检查动态 import
CSS module 引用断裂样式文件没跟着移动要求"连同样式文件一起移动"
测试文件路径没更新测试里的 import 被遗漏要求"包括 __tests__*.test.*"
barrel export 没更新index.ts 的 re-export 过时要求"更新所有 index.ts"
路径别名失效tsconfig.json paths 没更新要求"检查 tsconfig paths"

五、类型系统重构

类型重构是 TypeScript 项目中最有价值的重构之一。

5.1 JavaScript → TypeScript 渐进迁移

不要一次性迁移。让 Claude Code 按依赖顺序制定计划:配置 → 类型定义 → 工具函数(叶子节点)→ 数据层 → 组件(从小到大)→ 页面。关键是 allowJs: true 让 JS/TS 共存。

5.2 消除 any 类型

> 搜索项目中所有 any 类型,按分类统计(参数、返回值、变量、as any),
> 给每个 any 建议具体的替代类型。
// ❌ Before
function processData(data: any): any {
  const result: any = {}
  data.items.forEach((item: any) => { result[item.id] = item.value })
  return result
}
 
// ✅ After
interface DataItem { id: string; value: number }
interface DataInput { items: DataItem[] }
type ProcessedData = Record<string, number>
 
function processData(data: DataInput): ProcessedData {
  const result: ProcessedData = {}
  data.items.forEach((item) => { result[item.id] = item.value })
  return result
}

5.3 Union → Discriminated Union

当你有一个 union 类型需要在运行时区分,discriminated union 是最安全的模式:

> 把 Notification 类型从普通 union 重构为 discriminated union,
> 按 type 字段区分,每种通知类型只包含它需要的字段。
// ❌ Before: 所有字段都是可选的
type Notification = {
  type: string; title: string; message?: string; progress?: number; error?: Error
}
 
// ✅ After: 每种类型精确定义
type Notification =
  | { type: 'info'; title: string; message: string }
  | { type: 'success'; title: string; message: string }
  | { type: 'progress'; title: string; progress: number }
  | { type: 'error'; title: string; error: Error }
 
// 类型收窄自动生效
function renderNotification(n: Notification) {
  switch (n.type) {
    case 'progress': return <ProgressBar value={n.progress} />  // TS 知道 progress 存在
    case 'error': return <ErrorMessage error={n.error} />       // TS 知道 error 存在
  }
}

5.4 泛型重构

当多个函数有相似模式,泛型可以消除重复:

// ❌ Before: 三个几乎一样的函数
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch('/api/users')
  if (!res.ok) throw new ApiError(res.status)
  return res.json()
}
// fetchPosts, fetchComments 也是一样的模式...
 
// ✅ After: 一个泛型函数
async function fetchResource<T>(endpoint: string): Promise<ApiResponse<T>> {
  const res = await fetch(`/api/${endpoint}`)
  if (!res.ok) throw new ApiError(res.status)
  return res.json()
}
 
const users = await fetchResource<User[]>('users')  // 类型安全

六、API 与接口重构

API 重构是最需要谨慎的类型,因为它影响的不只是你的代码,还有所有调用方。

6.1 Breaking Change 管理策略

策略适用场景风险实施成本
直接修改内部 API,调用方可控
新旧并存 + 废弃标记公共 API,需要过渡期
Adapter 模式接口变化大,但逻辑不变
版本化 APIREST API,多版本共存
Feature Flag渐进式切换

6.2 Adapter 模式过渡

当内部实现大改,但需要保持旧接口兼容时:

> getPostsByLocale 的返回值结构要改:
> 旧:{ posts: Post[], total: number }
> 新:{ data: Post[], meta: { total, page, perPage } }
> 帮我:创建新实现 V2,写 adapter 让旧函数调用新实现,加 @deprecated 标记。
// 新实现
function getPostsByLocaleV2(locale: Locale, options: QueryOptions): PaginatedResult<Post> {
  return { data: posts, meta: { total, page: options.page, perPage: options.perPage } }
}
 
// Adapter:旧接口 → 新实现
/** @deprecated 请使用 getPostsByLocaleV2,此函数将在 v3.0 移除 */
function getPostsByLocale(locale: Locale, page?: number) {
  const result = getPostsByLocaleV2(locale, { page: page ?? 1, perPage: 10 })
  return { posts: result.data, total: result.meta.total }
}

6.3 Deprecation + 迁移指南

让 Claude Code 给旧函数加 @deprecated JSDoc(含迁移代码示例),开发环境输出 console.warn,并生成 MIGRATION.md:

/**
 * @deprecated 自 v2.5.0 起废弃,将在 v3.0.0 移除。
 * 请使用 `formatDateTime(new Date('2026-03-21'), { style: 'short' })` 替代。
 */
function formatDate(date: string): string {
  if (process.env.NODE_ENV === 'development') {
    console.warn('[DEPRECATED] formatDate() → 请使用 formatDateTime()')
  }
  return formatDateTime(new Date(date), { style: 'short' })
}

七、大规模重构策略

当重构涉及几十甚至上百个文件时,需要更系统的策略。

7.1 分步迁移

大规模重构最忌讳一步到位。拆成小步,每步可验证:

# Step 1: 分析(Plan Mode)
> /plan 我要把状态管理从 Redux 迁移到 Zustand。
> 分析有多少 slice、多少组件用了 useSelector/useDispatch、有没有 middleware。
> 给我一个分步迁移计划。
 
# Step 2: 按模块逐个迁移
> 先迁移 userSlice:创建 Zustand store → 更新组件 → 跑测试 → 提交
 
# Step 3: 重复直到完成
> 继续迁移 postSlice...

7.2 多 Agent 并行重构

对于互不依赖的模块,用多个 Claude Code 实例并行。详见多 Agent 指南

# 终端 1: 重构用户模块
claude "把 stores/userSlice.ts 从 Redux 迁移到 Zustand,更新所有相关组件和测试"
 
# 终端 2: 重构文章模块
claude "把 stores/postSlice.ts 从 Redux 迁移到 Zustand,更新所有相关组件和测试"
 
# 终端 3: 重构评论模块
claude "把 stores/commentSlice.ts 从 Redux 迁移到 Zustand,更新所有相关组件和测试"

注意:确保并行模块之间没有直接依赖,共享类型定义的修改要放在最前面单独完成。

7.3 Worktree 隔离

风险较高的重构,用 Git worktree 隔离最安全(详见 Git 指南)。主工作区不受影响,失败直接删除 worktree 零成本:

git worktree add ../blog-refactor-redux -b refactor/redux-to-zustand
cd ../blog-refactor-redux && claude  # 在隔离工作区中重构

7.4 进度追踪

# Redux → Zustand 迁移进度
 
| 模块 | 状态 | 组件数 | 测试 | 备注 |
|------|------|--------|------|------|
| userSlice | ✅ 已验证 | 8 | 通过 | - |
| postSlice | 🔄 进行中 | 12 | - | 有 middleware |
| commentSlice | ⬜ 待开始 | 5 | - | - |
| authSlice | ⬜ 待开始 | 6 | - | 依赖 userSlice |
| uiSlice | ⬜ 待开始 | 15 | - | 最多组件 |

八、重构安全网

重构最怕"改完了不知道有没有破坏什么"。安全网就是你的信心来源。

8.1 测试驱动重构

最可靠的安全网。详细 TDD 工作流参考测试指南

# 重构前:确保测试通过
> 跑一下 Navigation 组件的所有测试,确认当前是绿的。
 
# 重构中:每步验证
> 我刚把 MobileMenu 提取成独立组件了,跑一下测试。
 
# 重构后:补充新测试
> 重构完成,帮我补充 NavBar、MobileMenu、NavLink 的独立测试。

8.2 快照对比

对于 UI 组件重构,快照测试能快速发现渲染差异:

describe('Navigation refactoring', () => {
  it('should render identically after refactoring', () => {
    const { container } = render(<Navigation locale="zh" pathname="/zh/blog" />)
    expect(container).toMatchSnapshot()
  })
})

8.3 回滚策略

git stash push -m "refactor attempt 1"   # 小改动:stash
git checkout main && git branch -D refactor/xxx  # 大改动:删分支重来
git reset --soft HEAD~3                   # 逐 commit 回退,保留改动

8.4 重构后验证清单

验证项命令通过标准
TypeScript 编译tsc --noEmit零错误
单元测试npm test全部通过
Lint 检查npm run lint零错误
构建测试npm run build构建成功
快照对比npm test -- -u差异已确认
性能对比Lighthouse无明显退化
Bundle 大小npm run analyze无明显增长
> 重构完成了,帮我跑一遍验证清单:tsc --noEmit → npm test → npm run lint → npm run build
> 每步结果告诉我,有问题就停下来。

九、CLAUDE.md 重构规范

好的 CLAUDE.md 配置能让 Claude Code 在重构时自动遵循团队规范。完整用法参考 CLAUDE.md 指南

9.1 命名规范

## 命名规范
- 文件:组件 PascalCase / 工具函数 camelCase / 测试 同名.test
- 变量:组件 PascalCase / 函数 camelCase / 常量 UPPER_SNAKE_CASE
- 类型:PascalCase / 泛型 T, TData / Hook useCamelCase
- 布尔:is/has/should 前缀(isOpen, hasError)

9.2 文件组织规范

## 文件组织
- 组件拆分后放同名目录:components/navigation/NavBar.tsx
- 每个组件目录必须有 index.ts 统一导出
- 共享类型放 types.ts,私有类型就近定义
- 文件大小限制:组件 250 行 / 工具函数 200 行 / 类型 150 行
- Import 顺序:React 内置 → 第三方 → 内部模块 → 相对路径 → 类型

9.3 重构 Commit 规范

## 重构 Commit
- refactor: 不改变行为的结构调整
- refactor!: 有 breaking change(需要 BREAKING CHANGE footer)
- 每个独立重构步骤一个 commit,不混合"提取"和"重命名"
- Message 格式:refactor(scope): 简短描述 + 详细说明为什么重构

9.4 完整 CLAUDE.md 重构配置

# 重构规范
 
## 原则
- 小步重构,每步可验证
- 不改变公共 API 的外部行为
- 每步重构后必须通过所有测试
- 重构和功能修改不混在同一个 commit
 
## 流程
1. 确认测试覆盖率 ≥ 70%
2. 创建重构分支
3. 每步重构后跑 tsc + test
4. 用 /review 检查改动
5. 按步骤提交(每步一个 commit)
 
## 禁止事项
- 不要在重构 commit 中加新功能
- 不要删除测试(可以移动或重命名)
- 不要改变公共函数的签名(除非是 refactor!)
- 不要一次性重构超过 10 个文件(拆成多个 PR)

十、完整实战:重构 Navigation 组件

理论讲完了,来一个完整实战。把博客的 Navigation 组件从单文件拆分成多个子组件。

10.1 现状分析

当前 components/Navigation.tsx 有 350 行,问题很明显:

// components/Navigation.tsx — 350 行的单文件组件
'use client'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useRef } from 'react'
 
export default function Navigation({ locale }: { locale: Locale }) {
  const [isMenuOpen, setIsMenuOpen] = useState(false)
  // Escape 键关闭菜单 (20 行)
  // 点击外部关闭菜单 (15 行)
  // 自动聚焦第一个链接 (10 行)
  // 渲染桌面导航 (40 行)
  // 渲染移动端 hamburger (20 行)
  // 渲染移动端菜单面板 (60 行)
  // 渲染主题切换 + 语言切换 (30 行)
  return <nav>{/* 350 行的 JSX */}</nav>
}

桌面端和移动端逻辑混在一起,三个 useEffect 管理菜单状态,单个链接的 active 逻辑重复出现,难以单独测试。

10.2 Step 1:分析并制定计划

> 分析 components/Navigation.tsx,我想拆分成更小的组件。
> 当前有哪些职责混在一起?建议拆成哪几个?Props 接口?状态放哪层?
> 先不要改代码。

Claude Code 分析:拆成 NavBar(主容器 + 状态管理)、NavLink(active 判断 + 样式)、MobileMenu(开关 + 动画 + 焦点管理)。isMenuOpen 放在 NavBar 层,pathname 在 NavBar 获取后传给子组件。

10.3 Step 2:先写测试

> 在拆分前,帮我写测试覆盖当前行为:渲染所有链接、aria-current、hamburger 开关、Escape 关闭。
describe('Navigation', () => {
  it('renders all nav links', () => {
    render(<Navigation locale="zh" />)
    expect(screen.getByText('home')).toBeInTheDocument()
    expect(screen.getByText('blog')).toBeInTheDocument()
  })
 
  it('marks current page link as active', () => {
    render(<Navigation locale="zh" />)
    expect(screen.getByText('blog').closest('a')).toHaveAttribute('aria-current', 'page')
  })
 
  it('toggles mobile menu', () => {
    render(<Navigation locale="zh" />)
    fireEvent.click(screen.getByRole('button', { name: /openMenu/i }))
    expect(screen.getByRole('navigation')).toHaveClass('menu-open')
    fireEvent.keyDown(document, { key: 'Escape' })
    expect(screen.getByRole('navigation')).not.toHaveClass('menu-open')
  })
})

10.4 Step 3:提取 NavLink

从最简单的开始:

> 从 Navigation.tsx 中提取 NavLink 组件到 components/navigation/NavLink.tsx。
> Props: { href, label, isActive },包含 active 样式和 aria-current。
> 在 Navigation.tsx 中使用新组件,跑测试确认。
// components/navigation/NavLink.tsx
import { Link } from '@/i18n/navigation'
 
interface NavLinkProps { href: string; label: string; isActive: boolean }
 
export default function NavLink({ href, label, isActive }: NavLinkProps) {
  return (
    <Link
      href={href}
      className={`nav-link ${isActive ? 'nav-link-active' : ''}`}
      aria-current={isActive ? 'page' : undefined}
    >
      {label}
    </Link>
  )
}

测试通过,Navigation.tsx 从 350 行减少到 310 行。提交:

> 提交:refactor(navigation): extract NavLink into separate component

10.5 Step 4:提取 MobileMenu

最复杂的一步,涉及状态管理和焦点控制:

> 从 Navigation.tsx 中提取 MobileMenu 组件:
> Props: { items, isOpen, onToggle, onClose, pathname }
> 包含 Escape 关闭、自动聚焦的 useEffect,使用 NavLink 渲染链接。
> 保持所有 aria 属性和键盘导航。
// components/navigation/MobileMenu.tsx
'use client'
import { useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import NavLink from './NavLink'
import type { NavItem } from './types'
 
interface MobileMenuProps {
  items: NavItem[]; isOpen: boolean
  onToggle: () => void; onClose: () => void; pathname: string
}
 
export default function MobileMenu({ items, isOpen, onToggle, onClose, pathname }: MobileMenuProps) {
  const t = useTranslations('a11y')
  const firstLinkRef = useRef<HTMLAnchorElement>(null)
 
  // Escape 键关闭
  useEffect(() => {
    if (!isOpen) return
    const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
    document.addEventListener('keydown', handler)
    return () => document.removeEventListener('keydown', handler)
  }, [isOpen, onClose])
 
  // 自动聚焦
  useEffect(() => { if (isOpen) firstLinkRef.current?.focus() }, [isOpen])
 
  return (
    <div className="md:hidden">
      <button onClick={onToggle} aria-label={isOpen ? t('closeMenu') : t('openMenu')}
        aria-expanded={isOpen} type="button">{/* hamburger icon */}</button>
      {isOpen && (
        <div className="mobile-menu" role="menu">
          {items.map((item) => (
            <NavLink key={item.href} href={item.href} label={item.label}
              isActive={pathname.includes(item.href)} />
          ))}
        </div>
      )}
    </div>
  )
}

测试通过,Navigation.tsx 从 310 行减少到 180 行。提交。

10.6 Step 5:整理 NavBar 主组件

> 最后一步:整理成干净的 NavBar,创建 index.ts 和 types.ts,更新所有 import。
// components/navigation/types.ts
export interface NavItem { href: string; label: string }
 
// components/navigation/NavBar.tsx — 整理后的主组件(~90 行)
'use client'
import { usePathname } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import NavLink from './NavLink'
import MobileMenu from './MobileMenu'
import ThemeToggle from '../ThemeToggle'
 
export default function NavBar({ locale }: { locale: Locale }) {
  const t = useTranslations('nav')
  const pathname = usePathname()
  const [isMenuOpen, setIsMenuOpen] = useState(false)
  const navItems: NavItem[] = [
    { href: '/', label: t('home') }, { href: '/blog', label: t('blog') },
    { href: '/projects', label: t('projects') }, { href: '/about', label: t('about') },
  ]
  const isActive = (href: string) =>
    href === '/' ? pathname === `/${locale}` : pathname.startsWith(`/${locale}${href}`)
 
  return (
    <nav className={`navbar ${isMenuOpen ? 'menu-open' : ''}`}>
      <div className="hidden md:flex items-center gap-6">
        {navItems.map(item => (
          <NavLink key={item.href} href={item.href} label={item.label} isActive={isActive(item.href)} />
        ))}
      </div>
      <MobileMenu items={navItems} isOpen={isMenuOpen}
        onToggle={useCallback(() => setIsMenuOpen(p => !p), [])}
        onClose={useCallback(() => setIsMenuOpen(false), [])} pathname={pathname} />
      <div className="flex items-center gap-2"><ThemeToggle /></div>
    </nav>
  )
}
 
// components/navigation/index.ts
export { default as Navigation } from './NavBar'
export { default as NavLink } from './NavLink'
export { default as MobileMenu } from './MobileMenu'

10.7 Step 6:补充子组件测试

> 给拆分后的组件补充独立测试:NavLink、MobileMenu、NavBar 集成测试。
describe('NavLink', () => {
  it('applies active styles when isActive', () => {
    render(<NavLink href="/blog" label="Blog" isActive={true} />)
    expect(screen.getByText('Blog').closest('a')).toHaveAttribute('aria-current', 'page')
  })
})
 
describe('MobileMenu', () => {
  it('calls onClose when Escape is pressed', () => {
    const onClose = vi.fn()
    render(<MobileMenu items={[{ href: '/', label: 'Home' }]}
      isOpen={true} onToggle={vi.fn()} onClose={onClose} pathname="/zh" />)
    fireEvent.keyDown(document, { key: 'Escape' })
    expect(onClose).toHaveBeenCalled()
  })
})

10.8 最终验证

> 跑完整验证:tsc --noEmit → npm test → npm run lint → npm run build
 
# ✓ tsc --noEmit — 零错误
# ✓ npm test — 24 tests passed(原 17 + 新增 7)
# ✓ npm run lint — 零警告
# ✓ npm run build — 构建成功

最终 commit 历史:

git log --oneline -5
# a1b2c3d refactor(navigation): add unit tests for NavLink and MobileMenu
# d4e5f6g refactor(navigation): reorganize NavBar as orchestration component
# h7i8j9k refactor(navigation): extract MobileMenu into separate component
# l0m1n2o refactor(navigation): extract NavLink into separate component
# p3q4r5s test(navigation): add baseline tests before refactoring

重构前后对比

指标重构前重构后
文件数1 个(350 行)5 个(总 ~280 行)
最大文件350 行90 行(NavBar)
测试数011
测试覆盖率0%85%
可复用组件02(NavLink, MobileMenu)
职责清晰度混合单一职责

这就是完整的 Claude Code 重构工作流:分析 → 写测试 → 小步拆分 → 逐步验证 → 补充测试 → 最终确认。

总结

重构不是一次性的大工程,而是持续的小改进。Claude Code 让重构的成本大幅降低——以前需要半天的提取和重命名,现在几分钟就能完成,而且更安全。

关键心法:永远先有测试再动手重构;小步前进每步可验证;用 CLAUDE.md 固化规范;大规模重构拆成多个 PR;重构和功能开发分开提交。

配合调试指南一起使用——调试解决"坏了怎么修",重构解决"没坏但可以更好"。

推荐阅读