如何从零实现一个框架编译器?

一、前置背景

笔者日常使用 Rax 框架开发前端需求。但随着业务扩展,我面临一个头痛的需求:将现有的 Rax 组件适配为 Taro 组件,以实现一些特定商业场景的跨平台功能。

这一需求可以概括为:

  1. 新功能开发 - 在 Taro 框架中实现,确保多端兼容性。
  2. 旧功能复用 - 将现有 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) 接收两个参数 kinddeclarations

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 组件为案例,探讨如何制定单个组件的转换规则。

转化,其实就是找不同,让不同变相同

  1. Rax 需要引入 createElement 不然会报错,Taro 除了组件外没其他引入行为;
  2. Import 引入写法不同,Rax 用单文件引入单组件,Taro 是在 @tarojs/components 集中引入;
  3. 同样都是<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();
    }
  }
}

接下来看功能函数,因为会遍历节点,所以

  1. 我们首先通过path.node.source.value=== "rax"定位,找到我们要增删改的目标节点;即类似 import { createElement } from "rax"; 这样的语句

  2. 然后过滤这个语句中的所有导入说明符specifiers ,检查值是否为 createElement

    • path.node.specifiers 是 AST 中的一个部分,表示一个模块导入语句中的所有导入说明符。
    • t.isImportSpecifier 是 Babel 类型检查器的一部分。
  3. 如果是,就被过滤掉。

  4. 最后加一步空引用清除,删除import { } from 'rax'

ii. 改变组件引入写法

引入写法修改类似上面处理方法:

  1. 先定位找到 import View from "rax-view",删掉这句引入;
  2. 声明一个对象,存 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 个基础组件做同样操作即可完成本期目标

  1. 列表格
  2. 找不同
  3. 遍历找节点、找属性
  4. 执行重命名 or 删除动作
  5. 机械性动作 * 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 | 执行自动化测试

  1. 安装 Jest 命令行执行:npm install --save-dev jest
  2. package.json 配置:"test:e2e": "jest tests/e2e.test.js"
  3. 命令行执行:npm run test:e2e

此时,你就可以在本地同时运行 Rax 与 Taro 项目,一边写 Rax 一边可实时通过此条命令进行编译转换 Taro。

五、总结

本篇文章从零开始构造了一个略具复杂度的 Rax 转 Taro 编译器。

初始目标挺吓人,但经过合理拆解发现大目标也不过只是走通 MVP(最小可行产品)后的重复累加。工作如此,生活亦如此。专注你的目标,不要被纷繁的信息流影响,脚踏实地一步步完成你的小Step,一切总能完成的。

后续对这个编译器,我计划如下内容,欢迎持续关注:

  • 新增自定义脚手架功能
  • 抹平转换过程中的 CSS 样式差异
  • 新增 README_EN 完善中英文使用文档

写作不易,如果觉得本文对你有启发有帮助的话,请在 GitHub 帮我点个 Star ⭐

交个朋友,愿我们更高处相见️,比心感谢 ❤ ~~~

相关推荐
也无晴也无风雨41 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui