万物皆可转:前端框架编译原理内参
点击关注公众号,“技术干货”及时达!
「Rax2Taro」:点击查看Github地址(https://github.com/Trade-Offf/Rax2Taro?tab=readme-ov-file)
一、前置背景笔者日常使用 Rax 框架开发前端需求。但随着业务扩展,我面临一个头痛的需求:将现有的 Rax 组件适配为 Taro 组件,以实现一些特定商业场景的跨平台功能。
这一需求可以概括为:
「新功能开发」 - 在 Taro 框架中实现,确保多端兼容性。「旧功能复用」 - 将现有 Rax 组件转换为 Taro,避免重写。重写组件成本高昂,特别是对于缺乏文档和原开发者不在的旧组件。因此我需要一种自动化工具,能够轻松地「一键式」将 Rax 组件转化为 Taro 组件,减少工作量,加快开发进程。
本篇博客内容,将探讨构建一个从 Rax 转换到 Taro 的编译器,从零开始实现组件级转换。
?「 恐惧通常源自未知,你恐惧的不是造轮子,你恐惧的是你从来没造过轮子 」
?作为前端同学,如果遇到这种工作可能会汗流浃背。
但不要担心,只要我们把目标拆解到足够清晰、足够细化,一切困难都是纸老虎。
二、编译器编译器是个宽泛的概念,最初是指将「高级语言」转换为计算机能识别的「汇编/机器语言」的工具。
个人理解:编译器本质是个从 A 转换的 B 的翻译工具 (如有不妥,还原评论区指正 ??)
但是编译器的翻译过程不是简单的翻译,通常涉及到多个步骤(词法、语法、语义分析、中间代码生成等)。详细知识点不赘述了,感兴趣的朋友请翻阅《编译原理》(https://book.douban.com/subject/3296317/)。
01 | Babel:JavaScript 编译器我们以日常开发中,接触最多的 JavaScript 编译器 Babel 为例,了解编译器工作逻辑,这里只介绍 Babel 基本流程。更多详细内容可以看以下Github官方文档:Babel 插件手册(https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md)
Babel 工作流程
02 | 基本用法这里以将 const a = 1 转换成 var a = 1 为例,看下 Babel 是如何工作的
i. 解析(parse)成抽象语法树 AST?「抽象语法树(Abstract Syntax Tree)」:本质是一种数据结构,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
?Babel 提供了 @babel/parser 将代码解析成 AST。这一步主要做两件事:
「词法分析」:把代码转换为令牌流(tokens flow:解析的中间产物,不用管)「语法分析」:再把每个 token 转换为 AST 结构constparse=require('@babel/parser').parse;
constast=parse('consta=1');
ii. 转换(transform)ASTBabel 提供了 @babel/traverse 对解析后的 AST 进行处理。
转换接收 AST 并对其遍历,在此过程中对节点进行增删改查,是 Babel 编译器最核心的过程。
traverse()能够接收 ast 以及 visitor 两个参数:
「ast」 是上一步解析得到的抽象语法树。「visitor」 提供访问不同节点的能力,当遍历到一个匹配的节点时,能够调用具体方法对节点进行处理。constt=require('@babel/types');
consttraverse=require('@babel/traverse').default;
traverse(ast,{
VariableDeclaration:function(path){
if(path.node.kind==='const'){
path.replaceWith(
t.variableDeclaration('var',path.node.declarations)//替换成var
}
path.skip();
}
});
Babel 提供了@babel/types 用于定义 AST 节点,在 visitor 处理节点的时候用于新增/替换等操作。
这个例子中,我们遍历上一步得到的 AST,在匹配到变量声明 VariableDeclaration 时,判断值是否为 const,并操作替换成 var。
iii. 生成(generate)代码Babel 提供了 @babel/generator 把转换后的最终 AST 还原为字符串形式的代码,同时创建源码映射。
constgenerate=require('@babel/generator').default;
letcode=generate(ast).code;
以上就是 Babel 在编译时的流程,这里涉及到了几个关键的包:
「@babel/types」 :用于构建、验证和修改 AST 节点「@babel/parser」:提供默认的 parse 方法用于解析「@babel/traverse」: 封装了对 AST 树的遍历和节点的增删改查操作「@babel/generator」: 提供给默认的 generate 方法用于代码生成我们接下来写的编译器,就是基于上述介绍的 Babel 包来实现。
三、Rax2Taro除了转换工具,我们还需要了解被转换和生产的对象。
因此需要了解 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 | 编译器设计
编译器结构设计
/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//主入口文件
consttraverse=require("@babel/traverse").default;
constimportsTransformer=require("./ImportsTransformer");
functiontransform(ast){
traverse(ast,{
ImportDeclaration(path){
importsTransformer.transformImportDeclaration(path);
},
//...添加其他节点类型的转换规则
}
module.exports={
transform,
};
开始之前,首先了解一下转换器主入口结构。
主入口文件引入了@babel/traverse,还引入我们新增用来删除 createElement 的方法。
其中使用了traverse()函数,它用于遍历抽象语法树(AST),访问树中的每个节点,并对这些节点进行修改、添加或删除。
//功能函数
functiontransformImportDeclaration(path){
constimportSource=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" 定位,找到我们要增删改的目标节点;
然后过滤这个语句中的所有导入说明符 specifiers ,检查值是否为 createElement
path.node.specifiers 是 AST 中的一个部分,表示一个模块导入语句中的所有导入说明符。t.isImportSpecifier 是 Babel 类型检查器的一部分。如果是,就被过滤掉。
最后加一步空引用清除,删除 import { } from 'rax'
ii. 改变组件引入写法引入写法的修改方式,类似上面的处理方法:
先定位找到 import View from "rax-view",删除这句引入;声明一个对象,存 Taro 引入组件,把上面删掉的组件再以 import { View } from "@tarojs/components"的形式声明;但是考虑到可扩展性,之后还会遇到 Text、Image 等组件,所以这里我写了一张映射表,批量重复上面操作。
constcomponentImportMap={
"rax-view":{
source:"@tarojs/components",
importName:"View",
},
"rax-text":{
source:"@tarojs/components",
importName:"Text",
}
//...添加更多组件及其转换规则
};
consttaroComponentsToImport=newSet();//声明去重Taro对象
//基础组件映射转换
functiontransformImportDeclaration(path){
constimportSource=path.node.source.value;
//...createElement删除逻辑
constnewImportInfo=componentImportMap[importSource];
if(newImportInfo){
//如果映射表里有,就存这个值到Taro对象中
taroComponentsToImport.add(newImportInfo.importName);
path.remove();//并移除原rax-xxx导入声明
}
}
由于功能类似,所以这两个功能(删除 createElement、改变组件引入写法)我都写在 transformImportDeclaration 函数里。
iii. 转换 View 组件转换View组件听上去很复杂,其实拆解下,本质就是把两套属性差异抹平,用的也是上面对 AST 节点的增删改操作。
从二者官方文档里展示的组件 API 可以明显看到 Rax 比 Taro 的 View 少了很多属性,但由于我们实现的是 「Rax - Taro」 的单向转换。
所以「一切编译行为以 Rax API 为转换基准」。
至于 Taro 多出来的 API ,编译器不用管,如果后续开发需要用到Taro属性,则开发者根据 Taro 官方文档自行配置使用即可(反正 Rax 没有 ??)
对比 View API 差异,我整理出下面的表格内容:
可以看到有 5 条 API 属性有不同,其中 3 条是使用方法不同,2 条是需要编译器处理的属性。
onClick - onTaponLongpress - onLongTapView 转换器的逻辑如下:
constt=require("@babel/types");
functiontransformViewElement(path){
//确保我们只处理具有name属性的JSXElement
if(path.node.openingElementpath.node.openingElement.name){
constopeningElementName=path.node.openingElement.name;
if(
t.isJSXIdentifier(openingElementName)&&
openingElementName.name==="View"
){
path.node.openingElement.attributes.forEach((attribute)={
if(t.isJSXAttribute(attribute)attribute.name){
constattributeName=attribute.name.name;
switch(attributeName){
case"onClick":
attribute.name.name="onTap";
break;
case"onLongpress":
attribute.name.name="onLongTap";
break;
}
}
}
}
}
module.exports={
transformViewElement,
};
跟之前操作差不多,还是遍历找节点,找到要处理的 API 属性,将属性重命名。
至此,我们实现了 Rax - Taro View 组件的编译转换。接下来需要对剩下的 6 个基础组件做同样操作,即可完成本期目标:
重复流程如下:
列表格,整理 API 差异,标明处理方式遍历 AST 找对应节点、找到需要处理的 API 属性执行重命名 or 删除动作机械性动作 * n ...
为了保证转换器的拓展性,这里新增了一个主入口 index.js 文件用来批量管理各个独立组建的小转换器。
其他组件的对应表请在语雀文档查看:
https://www.yuque.com/cascading/bwnowo/uwp3s510g0im9ue9?singleDoc# 《基础组件 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 测试环境中检查转换后的代码,确保没有报错且符合预期。constfs=require("fs");
constpath=require("path");
const{transform}=require("../src/Transformers");
constparser=require("@babel/parser");
constgenerator=require("@babel/generator").default;
//基于你Rax源文件和Taro输出的路径
constraxSourcePath=path.join(__dirname,"../../rax-test-demo/src/index.js");
consttaroOutputPath=path.join(
__dirname,
"../../TaroTestDemo/src/pages/index/index.jsx"
);
describe("End-to-EndTransformation",()={
it("从Rax组件中读取源码,转换为Taro组件",()={
constraxSourceCode=fs.readFileSync(raxSourcePath,"utf8");
//1.解析Rax源代码为AST
constraxAst=parser.parse(raxSourceCode,{
sourceType:"module",
plugins:["jsx"],
//2.转换AST
transform(raxAst);
//3.生成转换后的Taro源代码
consttaroOutput=generator(raxAst,
fs.writeFileSync(taroOutputPath,taroOutput.code);
});
01 | 准备 Rax 测试环境npminstall-grax-cli#安装Rax脚手架(如果尚未安装)
cdDesktop#进入桌面
raxinitRaxTestDemo#初始化Rax项目
cdrax-test-demo#进入文件夹
npminstall#安装依赖
npmstart#运行
脚手架选项如下:
02 | 准备 Taro 测试环境npminstall-g@tarojs/cli#安装Taro脚手架(如果尚未安装)
cdDesktop#进入桌面
taroinitTaroTestDemo#初始化Taro项目
cdTaroTestDemo#进入文件夹
npminstall#安装依赖
npmrundev:h5#运行
脚手架选项如下:
确保遵循上述步骤来准备 Rax 和 Taro 的测试环境。在双方都构建完成后,您可以执行 Jest 测试来验证转换过程。
03 | 执行自动化测试安装 Jest 命令行执行:npm install --save-dev jestpackage.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 ?
交个朋友,愿我们更高处相见?,比心感谢 ? ~~~
点击关注公众号,“技术干货”及时达!
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线