一、前置背景
笔者日常使用 Rax 框架开发前端需求。但随着业务扩展,我面临一个头痛的需求:将现有的 Rax 组件适配为 Taro 组件,以实现一些特定商业场景的跨平台功能。
这一需求可以概括为:
- 新功能开发 - 在 Taro 框架中实现,确保多端兼容性。
- 旧功能复用 - 将现有 Rax 组件转换为 Taro,避免重写。
重写组件成本高昂,特别是对于缺乏文档和原开发者不在的旧组件。因此我们需要一种自动化工具,能够轻松地一键式将 Rax 组件转化为 Taro 组件,减少工作量,加快开发进程。
本篇博客将探讨构建一个 Rax 到 Taro 的编译器,从零开始实现组件级转换。
恐惧通常来自未知,你恐惧的不是写一个编译器,你恐惧的是你从来没有写过编译器。
作为前端同学,如果接到这种工作可能会汗流浃背。但不要担心,只要我们把目标拆解到足够清晰、足够细化,一切困难都是纸老虎。所以我们做事第一步,先了解一下什么是编译器:
二、编译器
编译器是个宽泛的概念,最初是指将程序员通过高级语言写的代码(Source Code)转换为计算机能识别的汇编/机器语言(Target Code)的工具。
个人理解:编译器本质是个从 A 转换的 B 的翻译工具 (暴论,如有不妥请评论区指正🤝)
但是编译器的翻译过程不是简单的翻译,通常涉及到多个步骤(词法分析、语法分析、语义分析、中间代码生成等)。具体细节不赘述,感兴趣的朋友请翻黑皮书《编译原理》。
01 | Babel:JavaScript 编译器
这里以日常工作中我们接触最多的 JavaScript 编译器 Babel 为例,它会将我们的源代码转换成另一种形式的代码。详细内容可以看以下Github官方文档:Babel 插件手册
抽象语法树(Abstract Syntax Tree,AST),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
Babel 工作流程
02 | 基本用法
这里以 const a = 1
转换成 var a = 1
为例,看下 Babel 是如何工作的
i. 解析(parse)成抽象语法树 AST
Babel 提供了 @babel/parser 将代码解析成 AST。 这一步主要做两件事:
- 词法分析:把代码转换为令牌流(tokens flow:解析的中间产物,不用管)
- 语法分析:再把每个 token 转换为 AST 结构
JS
const parse = require('@babel/parser').parse;
const ast = parse('const a = 1');
ii. 转换(transform)AST
Babel 提供了 @babel/traverse 对解析后的 AST 进行处理。
转换接收 AST 并对其遍历,在此过程中对节点进行增删改查,是 Babel 编译器最核心的过程。
traverse()
能够接收 ast
以及 visitor
两个参数:
- ast 是上一步解析得到的抽象语法树。
- visitor 提供访问不同节点的能力,当遍历到一个匹配的节点时,能够调用具体方法对节点进行处理。
JS
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
traverse(ast, {
VariableDeclaration: function(path) { //识别在变量声明的时候
if (path.node.kind === 'const') { //只有const的时候才处理
path.replaceWith(
t.variableDeclaration('var', path.node.declarations) //替换成var
);
}
path.skip();
}
});
Babel 提供了@babel/types 用于定义 AST 节点,在 visitor 里做节点处理的时候用于替换等操作。
在这个例子中,我们遍历上一步得到的 AST,在匹配到变量声明 VariableDeclaration
时,判断值是否为 const,并操作替换成 var。
t.variableDeclaration(kind, declarations)
接收两个参数 kind
和 declarations
iii. 生成(generate)代码
Babel 提供了 @babel/generator 将 AST 再还原成代码。
把转换后的最终 AST 还原为字符串形式的代码,同时创建源码映射(source maps)
JS
const generate = require('@babel/generator').default;
let code = generate(ast).code;
以上就是 Babel 在编译时的流程,这里涉及到了几个关键的包。
- @babel/parser:提供默认的 parse 方法用于解析
- @babel/traverse: 封装了对 AST 树的遍历和节点的增删改查操作
- @babel/types :用于构建、验证和修改 AST 节点
- @babel/generator: 提供给默认的 generate 方法用于代码生成。
三、Rax2Taro
至此我们对编译器,以及 Babel 转换流程有了初步的了解。
接下来需要了解 Rax 和 Taro 框架的文档用法,比较两者的差异和相似之处,注意转换过程中需要抹平的部分:
「Rax」 是阿里巴巴的的跨端解决方案,它的设计理念与 React 类似,提供了类似的组件化开发体验,能够运行在 Web、Node.js、阿里小程序、Weex 等多个平台。
「Taro」 是京东的跨端跨框架解决方案,支持使用 React 语法开发一次,然后将代码编译成不同平台的小程序(微信/百度/支付宝/字节跳动/京东小程序等)和 H5 应用,甚至可以编译成 React Native 应用。
通过阅读对比二者官方文档,寻找到接下来开发的关键破局点:Rax 和 Taro 均支持组件化开发。 由于Rax 和 Taro 都受到 React 的影响,并且都使用 JSX 语法,它们的许多基础组件在概念上是类似的,这意味着有一些组件和属性是可以在两者之间直接映射的。
01 | 本期目标
从组件化下手,本期编译器能力计划将左侧 Rax 文档中(除 Link 外)的 7 个组件一键转换 Taro 组件
- 在 Taro 中优先寻找平替组件,若能找到则增加转换逻辑抹平差异,实现转换器。
- 如果找不到对应组件就标红,等后续对特例地统一处理。
02 | 编译器结构设计
bash
/Rax2Taro
|-- /node_modules # 项目依赖库安装文件夹
|-- /src
| |-- index.js # 主入口文件,协调整个转换过程
| |-- parser.js # 用于解析源代码生成 AST
| |-- generator.js # 用于从修改后的 AST 生成新的源代码
|-- /Transformers # 存放转换逻辑的模块文件夹
| |-- index.js # 整合各种转换规则的主要转换器
| |-- FunctionTransformer.js # 函数转换逻辑
| |-- /JSXElementsTransformer # 存放 JSX 元素的特定转换器
| |-- index.js # 整合 JSX 元素转换规则
| |-- ... # 其他 JSX 元素转换模块
| |-- ... # 其他转换逻辑模块
|-- /Input # 存放待转化的 Rax.js 的文件夹
|-- /Output # 存放转化后的 Taro.js 的文件夹
|-- package.json # 定义项目依赖和脚本
|-- README.md # 项目说明文档
03 | 转换 View 组件
写一个编译器可能很难,但是编译转换一个小组件不难
我们的目标可以看作是转换 N 个基本组件。一旦我们知道了如何转换 View 组件,我们只需重复相同的步骤六次即可完成目标。
因此,我们将以 View 组件为案例,探讨如何制定单个组件的转换规则。
转化,其实就是找不同,让不同变相同:
- Rax 需要引入
createElement
不然会报错,Taro 除了组件外没其他引入行为; - Import 引入写法不同,Rax 用单文件引入单组件,Taro 是在
@tarojs/components
集中引入; - 同样都是
<View />
组件,两个框架间的组件 API 属性可能不同,或者属性名相同功能不同。需要抹平差异,或者特异处理;
从上图基本分析出需要做这三件事,接下来让我们一步步实现。读取文件和转换 SourceCode 得到 AST 部分不讲,具体细节可以在 Github 项目里看,就两行代码。下述一切操作均默认为转换器获取到 AST 之后。
i. 删除 createElement
JS
const traverse = require("@babel/traverse").default;
const importsTransformer = require("./ImportsTransformer");
function transform(ast) {
traverse(ast, {
ImportDeclaration(path) {
importsTransformer.transformImportDeclaration(path);
},
// ... 添加其他节点类型的转换规则
});
}
module.exports = {
transform,
};
开始之前,首先了解一下转换器主入口结构。引入了@babel/traverse
,引入我们之后用来删除 createElement 的方法。其中使用了traverse()
函数,它用于遍历抽象语法树(AST),允许访问树中的每个节点,并对这些节点进行修改、添加或删除。
JS
function transformImportDeclaration(path) {
const importSource = path.node.source.value;
// 删除 "rax" 模块里的 createElement
if (importSource === "rax") {
// 过滤 createElement 引入
path.node.specifiers = path.node.specifiers.filter(
(specifier) =>
!(
t.isImportSpecifier(specifier) &&
specifier.imported.name === "createElement"
)
);
// 删除空引用
if (path.node.specifiers.length === 0) {
path.remove();
}
}
}
接下来看功能函数,因为会遍历节点,所以
-
我们首先通过
path.node.source.value=== "rax"
定位,找到我们要增删改的目标节点;即类似import { createElement } from "rax";
这样的语句 -
然后过滤这个语句中的所有导入说明符
specifiers
,检查值是否为 createElementpath.node.specifiers
是 AST 中的一个部分,表示一个模块导入语句中的所有导入说明符。t.isImportSpecifier
是 Babel 类型检查器的一部分。
-
如果是,就被过滤掉。
-
最后加一步空引用清除,删除
import { } from 'rax'
ii. 改变组件引入写法
引入写法修改类似上面处理方法:
- 先定位找到
import View from "rax-view"
,删掉这句引入; - 声明一个对象,存 Taro 引入组件,把上面删掉的组件再以
import { View } from "@tarojs/components"
的形式声明;
但是考虑到可扩展性,之后还会遇到 Text、Image 等组件,所以这里我写了一张映射表,批量重复上面操作。
JS
const componentImportMap = {
"rax-view": {
source: "@tarojs/components",
importName: "View",
},
"rax-text": {
source: "@tarojs/components",
importName: "Text",
}
// ... 添加更多组件及其转换规则
};
const taroComponentsToImport = new Set(); // 声明去重 Taro 对象
// 基础组件映射转换
function transformImportDeclaration(path) {
const importSource = path.node.source.value;
// ... createElement 删除逻辑
const newImportInfo = componentImportMap[importSource];
if (newImportInfo) {
// 如果映射表里有,就存这个值到 Taro 对象中
taroComponentsToImport.add(newImportInfo.importName);
path.remove(); // 并移除原 rax-xxx 导入声明
}
}
由于功能类似,所以这两个功能我都写在 transformImportDeclaration
函数里了。
iii. 转换 View 组件
转换View组件听着挺复杂,其实拆解下,本质就是把两套属性差异抹平,用的也是上面对 AST 节点的增删改操作。
可以明显看到 Rax 比 Taro 的 View 少了很多属性,但由于我们实现的是 Rax -> Taro 的单向转换。
所以API 以 Rax 为转换基准,Taro 多出来的 API ,编译器不用管,如果后续开发需要用到Taro属性,则开发者根据 Taro 官方文档自行配置使用即可(反正 Rax 没有 🐒)
通过查看两种框架官网文档,对比 View API 差异,其中: 可以看到有 5 条 API 属性有不同,但有 3 个为使用方法不同,从编译器角度来看只需要转换两条属性:
- onClick -> onTap
- onLongpress -> onLongTap
View 转换器的逻辑如下,遍历找节点,找属性,重命名:
JS
const t = require("@babel/types");
function transformViewElement(path) {
// 确保我们只处理具有 name 属性的 JSXElement
if (path.node.openingElement && path.node.openingElement.name) {
const openingElementName = path.node.openingElement.name;
if (
t.isJSXIdentifier(openingElementName) &&
openingElementName.name === "View"
) {
path.node.openingElement.attributes.forEach((attribute) => {
if (t.isJSXAttribute(attribute) && attribute.name) {
const attributeName = attribute.name.name;
switch (attributeName) {
case "onClick":
attribute.name.name = "onTap";
break;
case "onLongpress":
attribute.name.name = "onLongTap";
break;
}
}
});
}
}
}
module.exports = {
transformViewElement,
};
至此,我们实现了 Rax -> Taro View 组件的编译转换。同理,对剩下的 6 个基础组件做同样操作即可完成本期目标
- 列表格
- 找不同
- 遍历找节点、找属性
- 执行重命名 or 删除动作
- 机械性动作 * n ...
当然,为了保证转换器的拓展性,这里新增了一个主入口 index.js
文件用来批量管理各个独立组建的小转换器。
其他组件的对应表请在语雀文档查看:
www.yuque.com/cascading/b... 《基础组件API》
四、自动化测试
由于在本地转换各个 API 需要反复调试,并且需要实时查看组件编译后的情况。为了开发提效,我本地还需要运行 Rax 和 Taro 两个用脚手架生成的项目,加一个小自动化测试脚本进行一键编译调试。
具体步骤如下,在e2e.test.js
中:
- 设定 Rax 项目路径:本项目从 Rax 应用的源文件夹
rax-test-demo/src/index.js
读取待转换组件代码。 - 设定 Taro 项目路径:将转换后的 Taro 组件代码写入 Taro 应用的目标文件夹
TaroTestDemo/src/app.js
- 转换代码:命令行执行:
npm run test:e2e
转换器将源代码解析为 AST,并进行转换。 - 监测转换结果:在 Taro 测试环境中检查转换后的代码,确保没有报错且符合预期。
JS
const fs = require("fs");
const path = require("path");
const { transform } = require("../src/Transformers");
const parser = require("@babel/parser");
const generator = require("@babel/generator").default;
// 基于你 Rax 源文件和 Taro 输出的路径
const raxSourcePath = path.join(__dirname, "../../rax-test-demo/src/index.js");
const taroOutputPath = path.join(
__dirname,
"../../TaroTestDemo/src/pages/index/index.jsx"
);
describe("End-to-End Transformation", () => {
it("从Rax组件中读取源码,转换为Taro组件", () => {
const raxSourceCode = fs.readFileSync(raxSourcePath, "utf8");
// 1.解析 Rax 源代码为 AST
const raxAst = parser.parse(raxSourceCode, {
sourceType: "module",
plugins: ["jsx"],
});
// 2.转换 AST
transform(raxAst);
// 3.生成转换后的 Taro 源代码
const taroOutput = generator(raxAst, {});
fs.writeFileSync(taroOutputPath, taroOutput.code);
});
});
01 | 准备 Rax 测试环境
bash
npm install -g rax-cli # 安装 Rax 脚手架(如果尚未安装)
cd Desktop # 进入桌面
rax init RaxTestDemo # 初始化 Rax 项目
cd rax-test-demo # 进入文件夹
npm install # 安装依赖
npm start # 运行
脚手架选项如下:
02 | 准备 Taro 测试环境
bash
npm install -g @tarojs/cli # 安装 Taro 脚手架(如果尚未安装)
cd Desktop # 进入桌面
taro init TaroTestDemo # 初始化 Taro 项目
cd TaroTestDemo # 进入文件夹
npm install # 安装依赖
npm run dev:h5 # 运行
脚手架选项如下:
确保遵循上述步骤来准备 Rax 和 Taro 的测试环境。在双方都构建完成后,您可以执行 Jest 测试来验证转换过程。
03 | 执行自动化测试
- 安装 Jest 命令行执行:
npm install --save-dev jest
- package.json 配置:
"test:e2e": "jest tests/e2e.test.js"
- 命令行执行:
npm run test:e2e
此时,你就可以在本地同时运行 Rax 与 Taro 项目,一边写 Rax 一边可实时通过此条命令进行编译转换 Taro。
五、总结
本篇文章从零开始构造了一个略具复杂度的 Rax 转 Taro 编译器。
初始目标挺吓人,但经过合理拆解发现大目标也不过只是走通 MVP(最小可行产品)后的重复累加。工作如此,生活亦如此。专注你的目标,不要被纷繁的信息流影响,脚踏实地一步步完成你的小Step,一切总能完成的。
后续对这个编译器,我计划如下内容,欢迎持续关注:
- 新增自定义脚手架功能
- 抹平转换过程中的 CSS 样式差异
- 新增 README_EN 完善中英文使用文档
写作不易,如果觉得本文对你有启发有帮助的话,请在 GitHub 帮我点个 Star ⭐
交个朋友,愿我们更高处相见️,比心感谢 ❤ ~~~