手写 ES6 转换翻译器

AI 摘要如何把上千个老代码文件一键改写成现代的 ES6 class?从零讲清楚一个"偷懒但管用"的翻译器脚本是怎么想出来的、每一步在解决什么问题、用到了哪些编译原理的朴素直觉,以及为什么两个月后我又主动把它删掉了。

先说清楚:要解决什么

先看一个真实的组件文件,它长成这样:

BI.Button = BI.inherit(BI.BasicButton, {
    _defaultConfig: function (props) {
        var conf = BI.Button.superclass._defaultConfig.apply(this, arguments);
        // someCode
        return BI.extend(conf, {});
    },
});

这种写法在 FineUI(一个比较老的前端 UI 库)里到处都是。它用 BI.inherit 来模拟"继承",用 BI.Button.superclass._defaultConfig.apply(this, arguments) 来调用父类方法——这些都是 ES6 class 语法还没普及之前的"土办法"。

现在 ES6 已经是标配了,我们想把所有这样的代码改写成现代的写法:

import { shortcut, extend } from "@/core";
import { BasicButton } from "@/base";

@shortcut()
export class Button extends BasicButton {
    static xtype = "bi.button";

    _defaultConfig(props) {
        const conf = super._defaultConfig(...arguments);
        return extend(conf, {});
    }
}

两段代码其实是同一个东西的两种形态,只是语法更新了。一个文件人工改一下不算累,但整个 FineUI 仓库有上千个这样的文件——手改不仅慢,还容易出错。所以得写个程序自动做这件事。

这个程序就是本文主角:一个一次性的翻译器脚本

翻译器是什么?它和编译器是一家人

在讲具体怎么写之前,先花一分钟理解一个概念:翻译器

你平时听得更多的词可能是"编译器"——比如把 C++ 变成可执行文件的 GCC。其实编译器、翻译器、转译器(transpiler)、代码生成器,它们都是一家人,做的是同一类事:把一段代码变成另一段代码

所有这类工具,打开看骨架都是同一条流水线:

源码 → 拆分成小块(Lexer) → 理解结构(Parser) → 中间表示(AST) → 修改 → 生成新代码

逐项解释一下:

  • Lexer(词法分析器):把源码字符串拆成"单词",比如把 const x = 1 拆成 constx=1 四个 token
  • Parser(语法分析器):把这些单词按语法规则组装成一棵树,叫 AST(抽象语法树)。AST 是代码的"结构骨架"——它告诉你"这里是一个函数、它有两个参数、函数体里有一条 if 语句"
  • 变换:在 AST 这棵树上改动——比如把 var 改成 const、把 function 改成箭头函数
  • 代码生成:把改过的树再打印回字符串

听起来挺复杂?其实大部分实现的问题都是"怎么把每一步做对、做好"。业界已经有非常成熟的工具,比如 JavaScript 界的 Babel——你可以把它理解成"JS 的翻译器工厂",写插件就能接入上面说的每一步。

走正经路,还是偷懒路?

那问题就来了:我这次做的事,要不要用 Babel?

正经做法:用 @babel/parser 把旧代码解析成 AST,写一组"访问者函数"(visitor)来改写树上的节点,最后用 @babel/generator 打印回代码。这是 Babel、jscodeshift(Facebook 出的 codemod 工具)的标准玩法——鲁棒、可组合、工程上干净

但这里有个很重要的前提:我写的脚本是一次性的。迁移完这一千多个文件,它的使命就结束了。走正经路要付出什么?

  • 学 Babel 的 plugin API,熟悉上百种节点类型
  • 每条变换规则都得写一个 visitor
  • 最小可用版本排到两三天之后

对一个只用几天的工具投入两三天来学工具链?不划算。所以我选了一条偷懒路,把每一步都替换成"够用就行"的土办法:

阶段正经做法我的做法
Lexer / ParserBabel 生成 AST正则抓类名;eval借用 JS 运行时解析
变换visitor 改 AST 节点字符串 replace 一把梭
代码生成Babel generator模板字符串拼接 + prettier 和 eslint 擦一遍
依赖解析Babel module resolver自己写一个基于文件名的 import 路径搜索

这套方案里最关键的 trick 发生在 Lexer 这一层——我让 JS 运行时替我做了解析。下面按流水线顺序拆开讲,每一步都从"问题是什么"开始。

第一步:怎么从代码里找出类名?

先看最基础的问题。要把 BI.Button = BI.inherit(BI.BasicButton, {...}) 改写成 export class Button extends BasicButton,首先得从源码里挖出两个东西:

  • 类名:Button
  • 父类名:BasicButton

怎么挖?最顺手的工具就是正则表达式

正则是什么?简单说就是一种"字符串匹配规则"。你写一个规则,它在字符串里找所有符合这个规则的片段。比如 \d+ 代表"一段或多段数字",放在字符串 "abc123def456" 里就能匹配出 123456

我最初是这样写的:

/BI\.(.*?)\s\=\sBI\.inherit\(/.exec(sourceCode);
/inherit\(BI\.(.*?),/.exec(sourceCode);

看懂这两行有点门槛,我拆开说:

  • BI\.:要匹配"BI."这四个字符(点前面加反斜杠是因为"."在正则里有特殊含义,要转义)
  • (.*?):括号代表"捕获"——把匹配到的内容单独抠出来作为一个"组"。. 代表任意字符,* 代表 0 次或多次,? 代表"尽量少匹配"。合起来是"非贪婪地匹配一段字符"
  • \s\=\s:空白、等号、空白
  • BI\.inherit\(:匹配"BI.inherit("

意图是"找到 BI.X = BI.inherit("这种模式,然后把 X 抓出来。

这两行确实能跑,但拿到真实代码里就会栽两个跟头:

坑 1:\s 只匹配一个空白字符。 而实际文件里等号两边可能是两个空格、tab、甚至还带行内注释。遇到 BI.Button = BI.inherit(...)(多一个空格)就匹配不上。

坑 2:.*? 能匹配到点。 虽然加了 ? 已经在尽量少匹配,但它不会主动拒绝"."。遇到像 BI.A.B = BI.inherit(BI.C.D, ...) 这种嵌套写法时,会把点一起吞进去,抓出来的"类名"就成了 A.B——不是我们想要的。

修一下。把模糊的地方变具体:

// 一个合法的 JS 标识符(变量名)能由哪些字符组成?
// 第一个字符只能是字母、下划线或 $
// 后面可以是字母、数字、下划线或 $
const CLASS_RE = /BI\.([A-Za-z_$][\w$]*)\s*=\s*BI\.inherit\(/;
const SUPER_RE = /BI\.inherit\(\s*BI\.([A-Za-z_$][\w$]*)\s*,/;

const clzName = CLASS_RE.exec(sourceCode)?.[1];
const superName = SUPER_RE.exec(sourceCode)?.[1];

两处关键改动:

  • .*?[A-Za-z_$][\w$]*:明确告诉正则"这一段是一个 JS 合法的变量名",不能有点
  • \s\s*:允许 0 个或任意多个空白

这其实就是"词法分析"在手工做的事——把模糊的"任意字符"收紧到精确的"token 规则"。专业 Lexer 做的事其实也就是这个,只不过更完备。

第二步:怎么把一个对象拆成 class 的方法?

拿到类名之后,真正的难点来了。BI.inherit 的第二个参数是一个 JavaScript 对象,里面既有属性也有方法:

BI.inherit(BI.BasicButton, {
    props: { type: "default" },
    _defaultConfig: function (props) { ... },
    doClick: function () { ... },
});

我们要把里面每个方法原样搬到新的 class 里。听起来简单,但正则在这里会遇到一个硬骨头——方法的写法不止一种:

// 写法 A:经典 function 字面量
doClick: function () { ... }

// 写法 B:ES6 对象简写
doClick() { ... }

// 写法 C:以后万一有箭头函数?
doClick: () => { ... }

用正则要同时把这三种形态都抠出来、还要原样保留函数体内部的缩进和换行——写不对是必然。

这里我想了一个挺取巧的办法。你跟着我这样思考:

一个 JavaScript 对象字面量 { foo: 1, bar: function() {} }——它被写出来是"代码";但它被 JS 运行时执行一次之后,它就变成了一个真实的对象,可以直接访问 .foo.bar

那我如果想"解析"这个对象字面量,还用得着写 parser 吗?不用啊,直接让 JS 运行时跑一下不就好了

做法是这样:

// 定义一个容器,等会儿用来装各种收集到的东西
const collection = { static: {}, attr: {}, methods: [] };

// 造一个假的 BI 全局对象
const BI = {
    // 原来的 BI.inherit 会干很多事,这里我只需要它把 options 交给我
    inherit(_, options) {
        // 把所有"值是函数"的键,收集到 methods 数组里
        collection.methods = Object.keys(options)
            .filter(k => typeof options[k] === "function")
            .map(k => options[k]);
        // 把"值不是函数"的键,收集到 attr 里
        Object.keys(options)
            .filter(k => typeof options[k] !== "function")
            .forEach(k => { collection.attr[k] = options[k]; });
        return collection.static;
    },
    extend(target, o) { Object.assign(collection.static, o); },
    shortcut(xtype) { collection.xtype = xtype; },
};

// 执行整份源码!
// eslint-disable-next-line no-eval
eval(sourceCode);

你要是第一次见 eval,可能会很警觉——老师确实一直在说"别用 eval,有安全隐患"。但在这里它正好派上大用场:

eval 会把一段字符串当作 JavaScript 代码直接执行。这意味着它背后调用的就是 V8(Node.js 的 JS 引擎)自带的词法分析器和语法分析器。

换句话说,我不需要自己写 parser,V8 已经有一个全世界最强的 JS parser 了,直接借来用。一行 eval(sourceCode) 就让 V8 把整份源码解析并执行——它遇到 BI.inherit(...) 时会调用我刚才造好的假 BI.inherit,然后对象字面量作为 options 参数原封不动被传进来,此时它已经不是"字符串",而是一个真实的 JS 对象

我在 options 里能分辨出"哪些键是函数、哪些是数据"——因为对象已经解析完了,typeof 就能回答我这个问题。

拿到 collection.methods 这个函数数组之后,每个函数都能调用它的 .toString() 拿到函数本体的源码字符串(JS 的函数对象自带这能力)。最后一个小修饰,把 function 关键字替换成方法名,就拼出了 class 方法简写:

el.toString().replace(/^function/, el.name);
// function () { ... }  →  _defaultConfig () { ... }

Parser 一环就这么被绕过去了。用一行 eval 换掉了两三天的 AST 适配工作。

代价是什么?源码必须能执行。如果原文件里有什么"顶层就调接口"的副作用代码,这些副作用会真的跑起来。在 FineUI 这种"纯定义"的组件文件里没问题——它们只是定义类,不会干别的;但换个副作用密集的场景,这招就得小心。

第三步:改写函数体里的 BI.xxx 调用

上一步拿到每个方法的源码字符串后,还得对字符串做几轮替换:

  1. BI.Button.superclass._defaultConfig.apply(this, arguments)super._defaultConfig(...arguments)
  2. BI.SomeHelper(...) → 确认 SomeHelper 在哪个模块,替换 + 加 import
  3. "bi.some-xtype"SomeClass.xtype

这里正则又要顶上。每一条我都从原始写法开始,然后说它哪里有坑,怎么修。

3.1 把 superclass 换成 super

原始写法:

for (let i = 0; i < 100; i++) {
    f = f.replace(`BI.${clzName}.superclass`, "super");
}

你看到这段代码第一反应可能是"???"——为什么是 100 次循环?

原因是 JavaScript 的 String.prototype.replace 有个坑:如果第一个参数是字符串(不是正则)时,它默认只替换第一处。意思是 "aaaa".replace("a", "b") 只会得到 "baaa",不是 "bbbb"

要替换所有位置,要么用全局标志的正则 /a/g,要么就像上面那样硬来——循环替 100 次,假设一个文件里不会有超过 100 处 superclass 要替换。

这就是"偷懒"的代价。100 是个魔法数(凭感觉拍脑袋的数字),多了浪费性能,少了可能漏。

正经写法应该这样:

// 因为 clzName 是个变量(比如 "Button"),我们要把它拼进正则里
// 但是 clzName 里可能有 . 等正则特殊字符,所以要先转义
const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const superRe = new RegExp(`BI\\.${escape(clzName)}\\.superclass`, "g");
f = f.replace(superRe, "super");

这里涉及一个容易忽略的点:当你把字符串变量拼进正则时,如果变量里有 .( 这类特殊字符,它们会被当成正则语法而不是字面字符。所以要先用一个 escape 函数给它们加反斜杠。加了 g 标志之后,一次替换就能搞定所有位置。

循环没了,魔法数也没了。

3.2 把 xxx.apply(this, arguments) 换成 xxx(...arguments)

.apply(this, arguments) 是 ES5 风格的"把整个 arguments 传给另一个函数"的惯用写法;ES6 有了展开语法 ... 之后,可以直接写 (...arguments),更简洁。

原始写法有两条规则:

f = f.replace(
    /super\._defaultConfig\.apply\(this,\sarguments\)/g,
    "super._defaultConfig(...arguments)"
);
f = f.replace(/super\.(.*?)\.apply\(this,\sarguments\)/g, a => {
    const m = /super\.(.*?)\.apply\(this,\sarguments\)/.exec(a);
    return `super.${m[1]}(...arguments)`;
});

第一条专门处理 _defaultConfig 这个方法,第二条处理其他所有方法。但仔细看——第一条其实就是第二条的一个特例,完全可以合并。

另外 \s 只认一个空格,现实里一些人会写 apply ( this , arguments )(中间多加空格),这种就会漏掉。

合并并放宽空白:

f = f.replace(
    /super\.([A-Za-z_$][\w$]*)\.apply\s*\(\s*this\s*,\s*arguments\s*\)/g,
    (_, method) => `super.${method}(...arguments)`
);

关键变化是把方法名那个位置从 .*? 改成明确的 [A-Za-z_$][\w$]*(必须是合法标识符),把所有 \s 改成 \s*(允许任意空白)。

3.3 把 BI.xxx 换成 xxx(同时登记要 import 的东西)

这是最硬的一条。BI.xxx 可能是类名(BI.Button)、可能是工具函数(BI.extend)、可能是枚举值(BI.VerticalAlign)。每遇到一个,都要:

  1. 判断它该从哪里 import
  2. 登记到 import 表
  3. 把代码里的 BI.xxx 换成 xxx

原始写法:

f = f.replace(/BI\.(.*?)(\.|\(|\s|,)/g, matchedSentence => {
    const match = /BI\.(.*?)(\.|\(|\s|,)/.exec(matchedSentence);
    const target = match[1];
    const end = match[2];
    const loadSuccess = loader.load(srcName, target);
    if (loadSuccess) return target + end;
    return matchedSentence;
});

这段代码的逻辑是"匹配 BI.xxx 后面跟的某个边界字符(点、括号、空白、逗号),把 xxx 抠出来;确认能加载就把 BI. 去掉,否则保持原样"。

但这里藏着三个问题

问题一:边界字符在捕获组里,替换函数得二次 exec。 正则匹配到的完整字符串作为 matchedSentence 传给替换函数,但我要拿到"xxx"部分和"边界字符",还得再跑一次 exec。多此一举。

问题二:边界字符被吃掉了。 正则匹配规则是:匹配到的所有字符都算"被消费",下一次匹配从最后一个被消费的字符之后开始。遇到 BI.A.B.C 时,第一次匹配到的是 BI.A.(连带那个点),光标停在 B 上。但这时候 B 前面没有 BI. 了,于是 B.C 就再也对不上规则,漏掉了

问题三:边界字符集不完整。 );[{ 这些合法的分隔符都没写进去。

修法的关键是把边界字符从"消费"改成"偷看"——这在正则里有个专门的工具叫零宽断言(lookahead) (?=...)

f = f.replace(
    /BI\.([A-Za-z_$][\w$]*)(?=[.(\s,;)\[\]{}])/g,
    (whole, target) => {
        if (loader.load(srcName, target)) return target;
        console.log(`BI 变量替换失败 BI.${target}`);
        return whole;
    }
);

(?=[...]) 的意思是"下一个字符必须属于这个集合,但不吃掉它"。光标不前进,下一次匹配从同一位置继续。这样 BI.A.B.C 里的 BC 都不会被跳过。

替换函数的签名也干净了——target 直接就是第二个参数,不用再 exec

但这条规则还有一个更深的坑BI.A.B.C 里的 A.B 本身可能是一个合法的"成员访问链",正则看不出它该不该一起替换。真要处理到位,就该走 AST 上的 MemberExpression 节点(也就是 Babel 要做的事)。一次性脚本我选择不救这个边缘情况,失败就打日志让人手动处理。

3.4 把 xtype 字符串换成类引用

f = f.replace(/"bi\.([\w.]+)"/g, whole => {
    if (loader.load(srcName, whole)) {
        return `${depts[whole].clzName}.xtype`;
    }
    return whole;
});

原版写的是 "bi\.(.*?)",理论上 .*? 能跨行,实际没出过事,但表意不够准确。改成 [\w.]+ 的意思更明确:"xtype 里只能是标识符字符和点"。这和 FineUI 命名约定正好对齐。

第四步:import 从哪里来?

改写到这里,代码里的 BI.Button 都变成了 Button。但问题是 —— Button 这个符号从哪里来的?JS 需要一个 import 语句告诉它。

所以还得解决:

  1. 知道 Button 这个类的源文件路径在哪
  2. 根据当前文件和目标文件的位置,算出相对路径

这其实和你用过的 Node.js 的 require / import 解析逻辑是一回事——有个专门的词叫 module resolver。FineUI 里每个类都有唯一的文件路径,所以可以:

第一步,扫一遍整个仓库建一张依赖图

{
    "Button": "/src/base/button/button.js",
    "BasicButton": "/src/base/button/basic_button.js",
    "bi.button": { clzName: "Button", clzPath: "/src/base/button/button.js" },
    ...
}

第二步,写一个 search(src, module) 函数,给它两个绝对路径,算出相对路径。这是一道字符串处理题,思路是"找两个路径的最近公共祖先,从 src 往上爬几步、再往下找到 dst"。一道 medium 难度的力扣题,当时写得比较糙,能跑就没再打磨。

有个小细节值得提:目标目录里如果有 index.js 文件做聚合导出,按照 Node.js 惯例,路径应该指到目录而不是具体文件(这样 import ... from "./base" 会自动找到 ./base/index.js)。所以算到分叉点之后还得再多扫一层,找到最近的 index.js 才算收工。

第五步:加载器——把所有东西串起来

现在有了依赖图和路径计算,怎么用它们?我写了一个"加载器"loader,它承担的角色很简单:每次看到一个需要 import 的符号,就往 import 表里记一笔

const loader = {
    G: { "@/core": { shortcut: true } },
    load(srcName, module) {
        // 情况一:是核心工具(比如 extend, isNull)
        if (CORE_TOOLS.has(module)) {
            loader.G["@/core"][module] = true;
            return true;
        }
        // 情况二:是普通类或 xtype
        const key = search(srcName, module);
        if (!key) return false;
        loader.G[key] ??= {};
        const name = module.startsWith('"bi.')
            ? depts[module].clzName
            : module;
        loader.G[key][name] = true;
        return true;
    },
};

每处理完一个文件,loader.G 就是这个文件应该有的 import 表,按"从哪个模块 import 什么符号"的方式组织好了。编译原理里这种东西有个正儿八经的名字叫 symbol table(符号表)——我这个就是它的极简版。

演进:从 106 行到 538 行

讲到这里,核心逻辑差不多清楚了。脚本的首次提交是 2023-01-10,只有 106 行——刚好覆盖上面这些。但它一跑到真实代码上,边缘情况就开始冒头。接下来两个月,我陆陆续续在 KERNEL-14076 这个 Jira 单子下发了十几个 PR,脚本最终长到 538 行。

补上去的东西大致是这几样,按时间顺序:

最先补的是整目录递归。 最初版本只能处理单文件,加了一个 traverse 函数之后就能对着 src/basesrc/case 整包跑,并在每个文件改完后自动 eslint --fix 一下。

然后是兼容已 ES6 化的文件。 有些文件已经被同事手工改过了,但 xtype 字符串还没替换。脚本加了一条专门的分支——检测到文件已经是 class 形态就跳过主流程,只执行"替换 xtype"这一步。

再然后是 import 合并。 同一个包下引用了多个子模块时,原版会生成多条 import。加一层递归把它们合成一条 import { A, B, C } from ...,代码更干净。

接着上了循环依赖识别。 这一步有点意思。JS 模块允许循环依赖但容易出问题,工程上通常会定义"谁不能引谁"的规则。我写了一组 forbiddenCrossRules,明确比如 core 包不能反向引用 @/widget。脚本在替换 import 时,一旦检测到会产生"禁忌"的跨层引用,就降级成按相对路径直接引具体文件,绕开 @/ 别名。这是编译器/构建工具里很常见的"对 DAG(有向无环图)的回避策略"。

再之后是导入冲突检测。 同名 symbol 出现在不同 module 时(比如 Button 这个名字在两个地方都有定义),记到 ConflictImport 列表,跑完统一打印让人 review。

最后是单文件多 shortcut 的自动拆分。 有些老文件里一个文件定义了好几个类。divideFile 会按 shortcut 出现次数把一个大文件劈成多个小文件,让每个 class 独占一个文件。

整条演进线其实藏着一个挺典型的模式:每一版的新代码,都在补前一版没想到的边缘情况。核心逻辑只占 20%,剩下 80% 全是一点点打补丁。这是一次性脚本的典型生命轨迹——也说明了为什么这类工具很难一开始就写得"干净"。

为什么两个月后又删了

这一段是最有意思的。

脚本在仓库里活了两个月,从 2023-01-10 写到 2023-03-03。然后我提交了一个叫 KERNEL-14512 refact: 删除ES6脚本,防止被滥用 的改动,把 710 行代码自己删了

"自己写的脚本自己删"听起来挺怪,但理由其实非常清楚:

一,它的使命结束了。 脚本是一次性迁移工具,迁移完后 FineUI 已经全面 ES6 化,再跑这个脚本输入就不成立。

二,它是一个危险工具。 它会 eval 源文件、用正则改关键语法,所有这些都假设输入符合特定模式。谁之后手滑对新代码跑一次,就是一场灾难。

三,它不值得维护。 按正经工程标准,那些正则、硬编码的边界字符集、魔法数 100、硬编码的规则列表——都不及格。要维护就得重写,但重写一个"已经用不上"的工具,价值为零。不如彻底删掉

这里有一句我想专门写出来的话:

一次性脚本的正确归宿,就是被删除。

代码的价值从来不在"留存",而在"在该出现的时间点,完成它该完成的事"。留着一段已经完成使命的代码,迟早会有人对它感到疑惑、试着修它、或者错误地复用它——那都是在给未来添麻烦。

新手带走什么

这篇文章讲了一个具体的脚本,但里面藏着几条对任何写代码的人都有用的直觉,列在这里收个尾。

第一,"翻译代码"不是魔法,是一条可以拆开的流水线。 任何把一段代码变成另一段代码的工具,骨子里都是"读 → 拆分 → 理解结构 → 修改 → 生成"这五步。看到陌生的工具(Babel、TypeScript 编译器、ESLint),先在心里把它摆到这条流水线上,哪怕不看源码,你对它要解决的问题已经有了判断。

第二,工具的重度要和问题的寿命匹配。 用 AST 当然更稳,但我这次选了正则 + eval 的土办法——因为脚本只用几天。反过来也成立:如果这是一个要上线 10 年的产品工具,就该上 AST、写单测、做好向后兼容。不是"AST 比正则好",是"合适的工具比炫技重要"

第三,eval 在特定场景下是个好工具。 全世界的老师都说"别用 eval",但正如这篇文章演示的——让 JS 运行时替你做解析是非常省事的办法。关键是要想清楚"源码是可信的吗?执行它有副作用吗?"这两个问题。只要回答是"是、没有",就可以放心用。

第四,正则有坑,但坑有名字。 匹配任意字符的 .*?、只认单个空白的 \s、被消费掉的边界字符、特殊字符没转义——这些都是新手的经典坑。记住这几个名字,下次踩到就能自己爬出来。有条件的话,直接在 regex101 上调试,会省非常多时间。

第五,主动删掉自己写的代码,是一种美德。 这个翻译器脚本写完、用完、删掉——从头到尾的节奏是干净的。软件里最好的代码未必是最复杂的,而是那些"该出现时出现,完事了不留痕迹"的。

工具的寿命决定工程的强度——这是这个系列文章共同的收束。