前端架构演进:基于AST的常量模块自动化迁移实践
从“硬编码”到“全自动”:一次常量模块重构的工程化探索
一、背景与痛点
在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。
在我们的项目中,常量定义最初采用了一种集中式导出方式:
// src/constants/Constants_expert.ts
export default {
STATUS_PENDING: 0,
STATUS_APPROVED: 1,
// ... 数十个常量
}
而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:
// 旧代码片段
import { Constants_expert } from '@/locales';
if (status === Constants_expert.default.STATUS_APPROVED) { ... }
这种模式存在几个严重问题:
- Tree Shaking 失效:
export default对象导致整个常量对象被打包,无法按需剔除。 - 命名空间冗余:每次使用都要写
.default,代码冗长且容易出错。 - 模块职责混乱:
@/locales本应是国际化模块,却承担了常量聚合的职责。 - 可维护性差:新增常量文件需要手动修改
@/locales的导出,极易遗漏。
为了彻底解决这些问题,我们决定进行两项重构:
- 常量文件:将
export default { ... }拆解为多个export const,实现具名导出。 - 业务代码:将所有
Constants_xxx.default.PROP替换为直接使用PROP,并添加对应的具名导入。
项目涉及 30+ 个常量文件和 200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。
本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。
二、整体方案设计
整个迁移流程分为两个独立的阶段,必须严格按顺序执行:
graph LR
A[常量文件] -->|transform-const.js| B[具名导出常量]
C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
B -.->|提供导出变量列表| D
- 第一阶段:扫描
src/constants/*.ts,将每个文件中的export default对象转换为多个export const语句。 - 第二阶段:扫描
src/views下的所有.vue、.ts、.js文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。
两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。
三、第一阶段:常量文件格式转换(transform-const.js)
3.1 核心目标
将这样的代码:
// Constants_expert.ts
export default {
STATUS_PENDING: 0,
STATUS_APPROVED: 1,
}
转换为:
export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;
同时保留所有注释(文件头注释、属性上方注释等)。
3.2 AST 操作流程
我们使用 Babel 全家桶完成这次转换:
@babel/parser:将源码解析为 AST@babel/traverse:遍历和修改 AST 节点@babel/types:构建新的 AST 节点@babel/generator:将 AST 还原为代码
核心步骤:
- 解析源码,指定
sourceType: 'module'和plugins: ['typescript']以支持 TS 语法。 - 遍历 AST,找到
ExportDefaultDeclaration节点,并判断其声明是否为ObjectExpression。 - 移除该默认导出节点。
- 遍历对象的每个属性,对每个属性构建一个
ExportNamedDeclaration节点,内部包裹VariableDeclaration类型为const。 - 保留注释:将原属性的
leadingComments和trailingComments赋值给新节点。 - 重新生成代码,并写回原文件。
关键代码片段:
traverse(ast, {
ExportDefaultDeclaration(path) {
if (t.isObjectExpression(path.node.declaration)) {
defaultExportObject = path.node.declaration;
path.remove(); // 移除整个 export default
}
},
});
defaultExportObject.properties.forEach((prop) => {
const propName = prop.key.name;
const propValue = prop.value;
const exportDecl = t.exportNamedDeclaration(
t.variableDeclaration('const', [
t.variableDeclarator(t.identifier(propName), propValue),
])
);
// 保留注释
if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
exportConstNodes.push(exportDecl);
});
3.3 易错点与防御
- 非对象默认导出:某些常量文件可能已经是
export const格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。 - 属性名非标识符:如果对象的键是字符串字面量(如
"my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。 - 文件备份:转换前自动创建
.bak文件,防止误操作导致代码丢失。
四、第二阶段:业务代码引用迁移(transform-project.js)
这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScript 和 Vue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。
4.1 动态发现常量文件
第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。
function loadAllConstantFiles() {
const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }
for (const filePath of constantFiles) {
const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
const exportedNames = new Set();
traverse(ast, {
ExportNamedDeclaration(path) {
if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
path.node.declaration.declarations.forEach(d => {
if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
});
}
},
});
constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
}
return constantMap;
}
这样我们就获得了所有常量文件的“导出变量白名单”。
4.2 识别旧的导入模式
在业务代码中,旧的导入语句通常长这样:
import { Constants_expert, Constants_supplier_portrait } from '@/locales';
我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。
使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:
traverse(ast, {
ImportDeclaration(path) {
if (path.node.source.value === OLD_IMPORT_SOURCE) {
path.node.specifiers.forEach(spec => {
if (t.isImportSpecifier(spec)) {
const importedName = spec.imported.name;
const localName = spec.local.name;
if (constantMap.has(importedName)) {
oldLocalToConstantMap.set(localName, importedName);
shouldRemove = true;
}
}
});
if (shouldRemove) path.remove(); // 删除整条导入语句
}
},
});
4.3 替换成员访问表达式
旧的引用方式有两种常见形态:
Constants_expert.default.STATUS_PENDINGConstants_expert.STATUS_PENDING(某些早期代码省略了.default)
我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。
通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:
traverse(ast, {
MemberExpression(path) {
const root = findRootIdentifier(path.node);
if (!root) return;
const localName = root.name;
if (!oldLocalToConstantMap.has(localName)) return;
const constantSetName = oldLocalToConstantMap.get(localName);
const chain = getPropertyChain(path.node);
let propName = null;
if (chain.length >= 3 && chain[1] === 'default') {
propName = chain[2];
} else if (chain.length >= 2) {
propName = chain[1];
}
if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
// 记录需要导入的变量
neededImports.get(constantSetName).add(propName);
// 替换整个节点为一个简单的标识符
path.replaceWith(t.identifier(propName));
}
},
});
4.4 Vue SFC 的特殊处理
Vue 单文件组件包含 <template>、<script>、<script setup> 等多个块,需要分别处理。
Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script> 和 <script setup>,需要分别处理并合并导入变量。
Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。
但正则替换有几个坑:
- 常量名可能包含正则元字符(如
+、.),需要转义。 - 需要同时匹配
.default和没有.default的情况。 - 替换后要记录使用了哪些变量,以便生成导入。
我们构建动态正则:
const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。
4.5 生成新的导入语句
经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expert 和 Constants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。
因此,我们必须先检测冲突:
const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
for (const v of vars) {
if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
}
varToConstMap.set(v, constName);
}
}
如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:
const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');
最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。
五、技术难点与解决方案
5.1 路径别名动态转换
最初我们使用 path.relative 然后替换 ../ 为 @/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。
5.2 多个 <script> 块的替换位置
Vue SFC 可能同时存在 <script> 和 <script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offset 和 loc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。
5.3 模板正则的精确匹配
模板中可能包含字符串字面量,例如:
<div :title="'Constants_expert.default.STATUS'"></div>
我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。
5.4 保留代码格式与注释
AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate 的 { retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。
六、工程化落地与自动化流程
为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:
# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants
# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js
# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js
# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test
# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"
两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:
# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done
七、成果与思考
通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:
- Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
- 可读性提升:代码中直接使用
STATUS_PENDING而非冗长的Constants_expert.default.STATUS_PENDING。 - 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。
更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。
未来拓展方向
- 支持更复杂的引用模式:如
Constants_expert['default'].STATUS或Constants_expert[someVar].STATUS,这些可以通过增强MemberExpression的递归分析来支持。 - 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
- 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。
八、总结
本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。
如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。
欢迎交流讨论,共同提升前端工程化水平。更多文章