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

一、前置背景

笔者日常使用 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 ⭐

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

相关推荐
庸俗今天不摸鱼25 分钟前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
QTX1873025 分钟前
JavaScript 中的原型链与继承
开发语言·javascript·原型模式
黄毛火烧雪下32 分钟前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox42 分钟前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞1 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行1 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758101 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周1 小时前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队1 小时前
Vue自定义指令最佳实践教程
前端·vue.js
Jasmin Tin Wei2 小时前
蓝桥杯 web 学海无涯(axios、ecahrts)版本二
前端·蓝桥杯