重构技能
在重构功能以对齐既定模式、消除重复并提高代码复用时使用此技能。
对于UI属于pattern-library还是本地组合,请使用design-system技能。
重构前分析
1. 识别类似功能
- 在代码库中查找具有相似功能的可比功能
- 比较目录结构、组件模式和工具函数使用情况
- 查找具有相似功能但实现不同的功能
- 使用语义搜索查找相关功能:"具有类似表格/数据网格模式的功能"
2. 查找重复机会
搜索重复代码模式:
- 组件:查找跨功能的类似组件文件(同名或类似功能)
- Hooks:检查重复的hook逻辑(表格hook、过滤hook、聚合hook)
- 工具函数:查找重复的工具函数(聚合、格式化、验证、转换)
- 样式:识别重复的
sx属性模式或内联样式 - 常量:检查重复的常量或配置对象
- 类型:查找重复的类型定义或接口
3. 检查共享工具函数
在创建新工具函数之前,先验证现有工具:
- 搜索
@/util/目录获取领域特定的工具函数 - 检查
@/util/dates中的日期格式化函数 - 检查
@/util/numbers中的数字格式化函数 - 检查
@/components/中的可复用组件 - 检查
pattern-library中的设计系统组件 - 在创建新实现之前使用grep/搜索查找现有实现
重构过程
步骤 1:提取共享代码
当发现重复时:
-
在适当的
@/util/目录中创建共享工具函数:// 示例:util/[domain]/[category].ts export const sharedFunction = () => { ... }; export const sharedConstant = { ... }; -
更新所有功能以从共享位置导入:
// 功能特定文件 - 重新导出共享内容 export { sharedFunction, sharedConstant } from "@/util/[domain]/[category]"; -
从功能目录中删除重复实现
-
更新代码库中的所有导入
步骤 2:合并组件
当发现重复组件时:
- 识别最完整/功能最丰富的版本(检查属性、功能、测试)
- 提取到共享位置(
@/components/或与功能无关的位置) - 使属性灵活以支持所有用例(使用联合类型、可选属性或泛型)
- 更新所有使用该组件的功能中的导入
- 删除重复的组件文件
- 验证所有使用是否仍正常工作
步骤 3:对齐加载模式
对于加载状态:
- 检查
loading.tsx文件 - 应为简单的骨架屏,而非完整页面布局 - 移除组件级别的骨架加载器 - 依赖页面级别的
loading.tsx处理初始加载 - 在重新获取期间:显示现有数据,而非骨架屏(除非用户体验明确需要)
- 匹配代码库中类似功能的模式
- 使用Next.js App Router约定 -
loading.tsx处理初始加载,组件处理重新获取状态
步骤 4:合并样式
对于样式:
- 将可复用样式模式提取到共享组件或样式工具函数
- 当存在共享组件时移除内联
sx属性 - 使用
pattern-library中的设计系统组件而非自定义样式 - 检查可能成为共享工具函数或主题扩展的重复样式对象
- 遵循Material-UI模式 - 使用主题值,避免魔法数字
步骤 5:验证模式一致性
确保与既定功能的一致性:
- 比较目录结构 - 应遵循类似模式(
_components/、_hooks/、_utils/) - 检查hook模式 - 如果功能需求不同,更简单的hook也是可以的
- 验证数据流 - 应尽可能匹配类似功能
- 审查组件组合 - 应使用类似模式(包装器、组合等)
- 检查命名约定 - 遵循既定模式(组件用PascalCase,hook用camelCase)
步骤 6:工具函数合并
对于工具函数:
- 首先检查共享工具函数中是否存在该函数(搜索代码库)
- 比较实现 - 使用更完整/更健壮的版本
- 合并重复项 - 移动到共享位置,更新所有导入
- 记录差异 - 如果实现有意不同,记录原因
- 保留功能 - 确保合并后的版本处理所有用例
步骤 7:清理注释
重构后,删除不必要的注释:
- 删除迁移注释 - 解释代码从何处移动的注释(例如,“isCollecting现在从Table/utils.ts导出”)
- 删除显而易见的注释 - 仅重复代码功能的注释(例如,“// 重新导出共享聚合”而代码明显显示重新导出)
- 保留解释性注释 - 解释为何这样做的注释(例如,“// 使用setTimeout确保表单状态已更新”)
- 保留复杂逻辑注释 - 解释非明显的业务逻辑或边缘情况的注释
- 删除TODO/FIXME注释 - 除非是合法的未来工作项
不必要的注释示例:
// ❌ 没必要 - 代码中已明显体现
// 从util重新导出共享聚合
export { latest, totalRange } from "@/util/keywords/gridAggregations";
// ❌ 没必要 - 迁移注释,不再需要
// isCollecting现在从Table/utils.ts导出以避免重复
// 从那里导入:import { isCollecting } from "../KeywordListDetail/Table/utils";
好的注释示例:
// ✅ 好 - 解释为什么
// 使用setTimeout确保CSV处理后表单状态已更新
setTimeout(() => {
void handleSubmit(onSubmit)();
}, 100);
// ✅ 好 - 解释复杂逻辑
// 关键字在以下情况下更新:
// 1. 其lastUpdated值已更改(最可靠的指示器),或
// 2. 它不再处于收集状态并且我们已轮询足够次数(备用方案)
步骤 8:代码质量改进
在重构代码中应用一致的代码质量模式:
Null/Undefined检查
使用宽松相等简化null和undefined检查:
// ❌ 啰嗦
if (value !== undefined && value !== null) { ... }
if (value !== undefined && value !== null && value !== "") { ... }
// ✅ 简化
if (value != null) { ... }
if (value != null && value !== "") { ... }
注意:使用!= null(宽松相等)在一次检查中同时检查null和undefined。这是惯用的TypeScript/JavaScript,更简洁。
类型专用导入
对于类型专用导入使用import type以提高构建性能并使意图清晰:
// ❌ 为类型专用用法进行运行时导入
import { SomeType } from "app-api/types/domain";
// ✅ 类型专用导入
import type { SomeType } from "app-api/types/domain";
应用于:
- 来自
app-api的类型定义 - 接口导入
- 类型专用工具类型
- 仅用于类型注解的任何导入
移除不必要的类型断言
当TypeScript可以正确推断类型时,移除类型断言:
// ❌ 不必要的断言
const items = data.map((item) => item as SomeType);
// ✅ 让TypeScript推断
const items = data.map((item) => item);
何时保留断言:仅当TypeScript确实无法推断类型,或以TypeScript无法验证的方式缩小类型时。
移除不必要的Nullish合并
当值已可为null或TypeScript推断正确类型时,移除?? null:
// ❌ 没必要 - valueGetter已返回可为null的值
valueGetter: (value, row) => {
return row.someField ?? null;
};
// ✅ 简化
valueGetter: (value, row) => {
return row.someField;
};
何时保留:当为了API兼容性而显式将undefined转换为null,或类型系统要求时。
移除未使用的属性
当组件接口和实现中不使用某些属性时,移除它们:
// ❌ 未使用的属性
interface ComponentProps {
item: Item;
unusedField: string; // 组件中从未使用
}
// ✅ 干净的接口
interface ComponentProps {
item: Item;
}
为回调添加显式类型
当TypeScript无法从上下文中推断类型时,为回调参数添加显式类型注解:
// ❌ 隐式的any
const items = ids.map((id) => { ... });
// ✅ 显式类型
const items = ids.map((id: string | number) => { ... });
应用于:数组方法(map、filter、forEach)、事件处理器以及其他上下文不明确类型的回调。
提取重复的JSX模式
将重复的JSX模式提取到可复用组件中:
// ❌ 重复模式
{
isAggregation ? (
<Typography
variant="body2"
fontWeight="medium"
fontStyle="italic"
color="text.secondary"
>
{formattedValue}
</Typography>
) : (
<Typography variant="body2" fontWeight="medium">
{formattedValue}
</Typography>
);
}
// ✅ 提取的组件
<AggregationTypography
formattedValue={formattedValue}
isAggregation={isAggregation}
/>;
对变体实现使用工厂函数
当逻辑相似但需要处理不同数据结构时,使用工厂函数:
// ✅ 工厂函数模式
export const createIntentOperators = <T extends { intent?: Intent | Intent[] }>(
normalizeToArray: (value: T) => Intent[]
) => {
return {
// 使用规范化数组的操作符
};
};
// 在不同功能中使用
const operators1 = createIntentOperators<Feature1Type>((item) => [item.intent]);
const operators2 = createIntentOperators<Feature2Type>(
(item) => item.intents || []
);
好处:复用核心逻辑,同时允许不同数据结构得到适当处理。
代码审查强化(反复出现的反馈)
当重构涉及网格、仪表板、fetch调用或共享的“行+指标”形状时应用这些规则(与重复的Moz Pro UI PR反馈保持一致)。
DataGrid:详情面板切换
- 优先使用
pattern-library中的DetailPanelToggle来处理详情面板列的展开/折叠,而不是在自定义单元格中使用临时的IconButton+@mui/icons。 - 配置可访问性:当单元格应可通过Tab聚焦时,传递
buttonTabIndex={0}(参见app/(default)/competitive-research/top-competing-content/_module/components/Table/columns.tsx,SerpFeaturesCell)。 - 布局/居中:在
Box/Stack(或Stack的direction/spacing,当这是既定模式时)上使用**sx。不要用遗留的MUI系统样式属性替代Box上的sx(例如将display、alignItems作为顶级属性)——MUI正弃用那种方式**,转而推荐**sx**。
类型系统:避免object/匿名行形状
- 不要为
brand_definitions、行片段或API相邻列表使用readonly object[],当存在结构类型或可以一次性定义时。 - 在功能
utils模块中定义一个共享的“like”接口(例如BrandDefinitionLike);导出它和一个单一的显示行辅助函数(例如getBrandDefinitionDisplayLine),而不是在单元格中重复使用带类型转换的getBrandName。 - 在参数类型化后删除冗余的类型转换(
as BrandDefinitionLike[])。 - 当形状是真正的
BrandDefinition时,复用来自*_module/utils/dataHelpers的领域辅助函数;仅在提示行载荷不同时使用较宽松的辅助函数。
Typography与Box(pattern-library)
- 对于文本(空状态、内联“—”、强调的span),优先使用
pattern-library中的Typography,并在适当时使用component="span"——而不是用Box来模拟文本。 TypographyAPI:在表达语义的地方使用**variant、color、fontWeight、component**(这些未被弃用)。Box/ 布局包装器:对于间距、弹性布局、尺寸和主题值,优先使用**sx。避免被动地将样式从sx移动到Box**的“style”简写属性;这与当前MUI指导相冲突。
组件边界:属性与内部状态
- 从组件内部的
data派生布尔值,当父组件不需要它们进行布局或URL状态时(例如,从主要品牌的brand_terms派生“有品牌术语”)。 - 将仅用于UI的切换保持为内部状态(例如在一个卡片中切换两种图表模式),除非另一个兄弟元素或路由必须驱动相同状态——然后记录为何需要提升。
控制流与JSX
- 对于加载/空分支,优先使用早期
return,而不是在单个return之前立即赋值给变量的大型三元表达式,除非记忆化确实需要一个表达式。 - 排序比较器:永远不要硬编码与假设的域最大值绑定的魔法哨兵值(例如
5);对null/undefined使用**Number.POSITIVE_INFINITY/ 显式的“最后排序”**,以便在限制更改时行为保持正确。
Fetch调用与分页
- 避免使用固定的较小
limit(例如100)用于仪表板规模的列表,当后端支持分页且项目可能超过一页时。 - 循环使用稳定的页面大小,直到一页返回少于限制的行数或零行,或遵循action的文档约定(参见
moz-pro-serviceaction文档 / Bruno了解最大limit)。 - 如果调用方需要计数,一致地展示**
total**或派生的总计。
展示包装器:不要重复显而易见的内容
- 当父级
Card已经设置了可见标题时,移除冗余的备选状态字段(例如避免重复与卡片标题匹配的cardContentTitle)。 - Null检查:如果属性类型为
string,不要用与类型矛盾的检查(例如if (ownerName)已足够,不需要if (ownerName != null && ownerName !== undefined && ownerName !== ""))。
跨功能重构策略
当重构跨多个功能时:
- 按依赖关系排序 - 先重构独立功能,再重构依赖功能
- 使用功能标记 - 如果重构风险高,使用功能标记逐步推出
- 逐步提交 - 将大重构拆分为小的、可审查的提交
- 协调多个功能 - 更新所有相关功能的导入,以避免构建中断
- 添加回退 - 在为共享代码创建新接口时,添加与旧接口的兼容性层
跨功能重构清单
- 识别所有受影响的文件
- 更新共享工具函数和常量
- 更新所有导入
- 删除重复项
- 更新测试
- 添加新的共享测试
- 在本地完全构建/类型检查
- 在开发环境中验证所有功能
- 提交前代码审查
重构后的自动化步骤
考虑以下自动化工具以维持一致性:
- ESLint规则 - 添加自定义规则以强制执行模式(例如,优先使用
import type) - Prettier - 确保一致的代码格式化
- TypeScript严格模式 - 启用严格类型检查
- 单元测试覆盖率 - 为新的共享工具函数添加测试
- 集成测试 - 为重构的功能添加集成测试
常见重构模式
提取常量
将硬编码的值移动到命名常量中:
// ❌ 硬编码的值
const maxItems = 100;
// ✅ 命名常量
export const MAX_LIST_ITEMS = 100;
创建接口
为复杂对象创建接口:
// ❌ 内联类型
const processItem = (item: { name: string; value: number }) => { ... };
// ✅ 命名接口
interface Item {
name: string;
value: number;
}
const processItem = (item: Item) => { ... };
简化条件语句
将复杂的条件逻辑提取到命名函数或变量中:
// ❌ 复杂的条件
if (user.role === "admin" && user.permissions.includes("write") && !user.isSuspended) {
// 允许写入
}
// ✅ 命名的条件
const canWrite = user.role === "admin" && user.permissions.includes("write") && !user.isSuspended;
if (canWrite) {
// 允许写入
}
性能考虑
重构时考虑性能影响:
- 避免不必要的重新渲染 - 使用React.memo、useMemo、useCallback
- 延迟加载 - 使用Next.js动态导入大型组件
- 优化数据获取 - 使用SWR或React Query进行缓存和重新验证
- 减少包大小 - 使用树摇和动态导入
- 监控性能 - 使用React Profiler和运行时间测试
测试策略
重构后,确保测试覆盖率:
- 为提取的工具函数添加单元测试
- 为重构的组件添加集成测试
- 更新现有测试以反映更改
- 为共享组件添加快照测试(适当时)
- 验证重构功能的行为与原始行为相同
文档
为重构的代码添加文档:
- 为共享工具函数添加JSDoc注释
- 为共享组件添加PropsInterface文档
- 如果适用,更新Storybook
- 记录任何重要的架构决策
- 为未来重构维护模式指南
此技能旨在与design-system技能、code-review技能和testing技能一起使用,以获得最佳结果。