前端架构演进:基于AST的常量模块自动化迁移实践

2 阅读10分钟

前端架构演进:基于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) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/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 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

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/TypeScriptVue 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_PENDING
  • Constants_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_expertConstants_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.offsetloc.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'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章