作者介绍:梁自超,货拉拉资深前端工程师,主要负责货拉拉搬家业务的前端开发工作。
Taro 是一个开放式跨端跨框架解决方案,支持使用 React/Vue/Nerv 等框架来开发 微信 / 京东 / 百度 / 支付宝 / 字节跳动 / QQ / 飞书 小程序/ H5 / RN 等应用。
Taro 3.x 发布已有两年多的时间,比较稳定。作者负责的小程序项目还是使用 2.x 版本,因此决定升级至 3.x。虽然官方提供了一份 Taro 2.x 到 Taro 3.x 的升级指南[1]。但是在实践中,仅仅通过编辑器的查找替换去升级,却遇到更多的问题:
• 大小写怎么错了
• ESLint 怎么又报错了
• ...
我们试图寻找一个更为高效的改造方式:借助 babel
将源代码转换成 AST
,再对 AST
做相应的剪裁,最终生成新的代码。整个流程如下图所示:
AST(Abstract Syntax Tree)是源代码语法结构的一种抽象表示,以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
由于 AST
类型太多,结构繁杂,虽然 babel
提供了类型定义文件,但是查找起来比较费时费力。这里我们借助 AST Explorer[2] 来在线查看一段代码对应的 AST。
babel[3] 是一个工具链,主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。babel 通过语法转换器来支持新版本的 JavaScript 语法。
在本文中我们要用到的 babel 相关库:
• @babel/parser[4]:将TypeScript/Javascript
代码编译为 AST
• @babel/traverse[5]:遍历 AST
,通过它可以对 AST
做剪裁
• @babel/types[6]:用于生成节点类型
• @babel/generator[7]:将 AST
转换成源代码
通过一个官方的示例简单了解下如何使用上述的工具:
import * as parser from '@babel/parser';
const code = `function square(n) {
return n * n;
}`;
// 通过 @babel/parser 解析成 AST 树
const ast = parser.parse(code);
使用 @babel/traverse
可以便捷的访问 AST
,这里我们重点关注如何编写Visitor
, traverse
的定义如下
declare function traverse<T>(
node: Node,
handlers: TraversalHandler<T> | TraversalHandlers<T>,
state?: T,
): void;
在 Visitor
中可以对原有的 AST
节点进行编辑。下方的示例演示了如何更改方法名。
import traverse from '@babel/traverse';
import generate from '@babel/generator';
// ...
// @babel/traverse 操作剪裁
traverse(ast, {
FunctionDeclaration: function (path) {
if (path.node.id) {
path.node.id.name = 'x';
}
},
});
const resultCode = generate(ast).code;
// 方法名已经更改为 x 了
// function x(n) {
// // ...
// }
先观察一下 Taro2.x
和 Taro3.x
针对 import
有何不同。
// 2.x
import Taro, { Component, useEffect, useDidShow } from '@tarojs/taro';
// 3.x
import Taro, { useDidShow } from '@tarojs/taro';
// useEffect 是来自于 React 的 API
import React, { Component, useEffect } from 'react';
从上面的代码可以看到最主要的不同是 API
引入的来源不同。也就是说只需要找到 @tarojs/taro
引用节点,抽离出来原本属于 React
的 API
通过 react
包引入,再删除原有的引入即可。这里我们重点关注导入语句,即 ImportDeclaration
。
interface ImportDeclaration extends BaseNode {
type: 'ImportDeclaration';
specifiers: Array<
ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier
>;
source: StringLiteral;
//...
}
检测 source
字段是否为 '@tarojs/taro'
来定位要需要修改的节点。循环判断specifiers
,满足 React
引入条件的就放入 reactSpecifiers
,不满足的就放入 specifiers
,循环完毕之后将不满足 React
引入条件的 specifiers
数组塞回到节点中去,留下 reactSpecifiers
备用。
traverse(ast, {
// 处理导入语句,比如 import { useState } from '@tarojs/taro'
ImportDeclaration(path) {
if (path.node.source.value === TARO_TOKEN) {
// 处理 Taro Imports,比如 useMemo、useState 都需要从 react 引入
const specifiers = [];
path.node.specifiers.forEach((specifier) => {
if (!REACT_IMPORTS.includes(specifier.local.name)) {
// 从 taro 引入的 api 删除掉,变更为从 react 引入
specifiers.push(specifier);
} else {
reactSpecifiers.push(specifier); // 塞入 react 备用
}
});
path.node.specifiers = specifiers;
}
},
});
剩下的工作就是处理提取出来的 reactSpecifiers
,创建导入语句,这里不再赘述。
前面我们处理了导入问题,接下来我们看一个 API 用法的问题。
// 2.x
import Taro from '@tarojs/taro';
const MiniGuide: Taro.FC = () => {
const [test, setTest] = Taro.useState('');
// ...
};
// 3.x
import React from '@tarojs/taro';
const MiniGuide: React.FC = () => {
const [test, setTest] = React.useState('');
// ...
};
上面代码中的 Taro.FC
、Taro.useState
都需要进行替换。这里重点关注 TSQualifiedName
和 MemberExpression
,其对应的定义如下:
interface TSQualifiedName extends BaseNode {
type: 'TSQualifiedName';
left: TSEntityName;
right: Identifier;
}
interface MemberExpression extends BaseNode {
type: 'MemberExpression';
object: Expression | Super;
property: Expression | Identifier | PrivateName;
// ...
}
根据节点类型定义,不难写出替换代码。
traverse(ast, {
// typescript 类型声明,比如 const miniGuide: Taro.FC
TSQualifiedName(path) {
// ...
// 检测 Taro 引入的 节点,比如: const miniGuide: Taro.FC
if (REACT_IMPORTS.includes(path.node.right.name)) {
path.node.left.name = REACT_SPACE;
}
},
// 比如: Taro.useMemo(() => '', [])
MemberExpression(path) {
const node = path.node;
if (
t.isIdentifier(node.property) &&
REACT_IMPORTS.includes(node.property.name) &&
t.isIdentifier(node.object) &&
node.object.name === TARO_SPACE
) {
node.object.name = REACT_SPACE;
isUseReactSpace = true;
}
},
});
经由以上的步骤,我们基本上完成了 API 的用法迁移改造工作。
接下来我们来实现配置信息的提取,按照惯例先看下 Taro2.x
和 Taro3.x
的差异。
// 2.x
class Index extends Component {
config = {
navigationBarTitleText: '首页',
};
}
// index.js 页面配置
function Index() {}
Index.config = {
navigationBarTitleText: '首页',
};
// 3.x
class Index extends Component {}
// index.config.js
export default {
navigationBarTitleText: '首页',
};
对比得出:两者的差异是 2.x 的配置是挂载在类组件的类属性或函数式的属性上,3.x 会有一个新的文件:*.config.js
,*
代表你页面/项目文件的文件名,config
文件必须和页面/项目文件在同一文件夹。
挂载形式的不同处理方法也不同,接下来我们分别来处理。
类组件的 config
字段对应的节点类型为 ClassProperty
,其定义如下:
interface ClassProperty extends BaseNode {
key: Identifier | StringLiteral | NumericLiteral | BigIntLiteral | Expression;
value?: Expression | null;
// ...
}
由于类组件的 config
为类属性,所以这里可以直接通过 key
来获取节点,编写提取代码如下:
traverse(ast, {
// 处理类组件挂载的 config
ClassProperty(path) {
const node = path.node;
// ...
pageConfigSpecifiers = node.value.properties;
// 删除原有 config 节点
path.remove();
},
});
在 Taro
函数式组件里,页面的配置信息的书写格式是 组件名.config = {}
。我们可以提取到组件名,通过组件名去查找 config
节点的 object.name
是否匹配即可。
traverse(ast, {
// 提取函数式组件挂载的 config
AssignmentExpression(path) {
const node = path.node;
// ...
// 前置条件校验通过,存入临时集合,用于后续判断
// 这里并不能识别出来 config 是不是页面的配置信息,需要组合判断
allConfigSpecifiers[node.left.object.name] = {
path,
specifiers: node.right.properties,
};
},
// 提取函数式组件名
ExportDefaultDeclaration(path) {
if (!t.isIdentifier(path.node.declaration)) {
return;
}
// 前文已经将所有跟 config 相关的节点存储下来,这里通过提取默认导出的函数式组件名来去匹配真的 config
// 因为函数式组件的 config 挂载都在 export default 之前,而且单个文件只允许一个 export default,所以这里的顺序可以正常取到数据,反之就需要提取到外部处理
const findPath = allConfigSpecifiers[path.node.declaration.name];
if (findPath) {
// ...
pageConfigSpecifiers = findPath.specifiers;
findPath.path.remove();
}
},
});
这里查找配置支持了常规的场景,对于配置中存在引用外部变量的场景并未支持,可以通过分析
scope
去查找对应的引用,这里不再展开。
查找到真正的 config
之后,留存备用,然后把页面配置的节点信息从原有的树中删除掉,接下来我们要把提取出来的 config
信息转换成代码写入到同级目录的 *.config.ts
文件中去,这部分逻辑比较简单不再赘述。
路由的信息处理相对来说就简单很多,只需要将 this.$router
替换成 getCurrentInstance().router
即可。
import { getCurrentInstance } from '@tarojs/taro';
class C extends Component {
current = getCurrentInstance();
componentWillMount() {
// getCurrentInstance().router 和 this.$router 和属性一样
console.log(this.current.router);
}
}
// 函数式组件
import { getCurrentInstance } from '@tarojs/taro';
function C() {
const { router } = getCurrentInstance();
// getCurrentInstance().router 和 useRouter 返回的内容也一样
// const router = useRouter()
}
有一个注意的地方是我们需要给类组件挂载一个属性,需要反向查找到第一个类属性的位置,然后插入新的节点。
traverse(ast, {
MemberExpression(path) {
// ...
// 1. 先修改引入语句,增加 getCurrentInstance
createRouterImportSpecifier();
// 2. 使用新语法声明一个成员变量 current = getCurrentInstance()
// 先往上找到可以塞入类成员的地方,也就是 classBody
path.findParent((pathNode) => {
// ...
});
// 3. 替换 this.$router ==> this.current
node.property.name = MEMBER_KEY; // 将 this.$router ===> this.current
},
});
在 Taro 3.x 中,没有 组件的外部样式和全局样式[8] 的概念,组件的配置(config.js
)是无效的,页面和入口文件引入的 CSS 都会变成全局 CSS ,没有了 externalClasses
和 addGlobalClass
这两个概念。
所以我们只需要将这个两个属性移除即可,代码如下:
traverse(ast, {
ClassProperty(path) {
const node = path.node;
if (node.static && STYLE_CONFIG_KEYS.includes(node.key.name)) {
path.remove();
}
},
});
经过前面的几个步骤后,我们已经完成了对 AST
的剪裁,现在我们将修改后的 AST
保存成文件。
const code = generate(ast).code;
fs.writeFileSync(filePath, code);
至此,我们已经完成了 API
、Config
、Router
、Style
的迁移工作。然后执行 prettier/eslint 来检查并格式化代码。启动项目开发,编译/运行正常,大功告成。
完整 Demo 代码[9]
借助 babel 我们成功地完成了 Taro2.x
到 Taro3.x
的迁移。完成迁移的同时也加深了对 AST/babel 的应用与理解。
通过本文的介绍,我们认识到了 babel 的魅力,像是无所不能的黑魔法,其应用的场景也多种多样,希望大家在学习完本文介绍的相关知识后,能结合自己的业务场景,去探索更多关注 babel 的用法。
[1]
升级指南: https://taro-docs.jd.com/docs/migration[2]
AST Explorer: https://astexplorer.net/[3]
babel: https://www.babeljs.cn/docs[4]
@babel/parser: https://www.babeljs.cn/docs/babel-parser[5]
@babel/traverse: https://www.babeljs.cn/docs/babel-traverse[6]
@babel/types: https://www.babeljs.cn/docs/babel-types[7]
@babel/generator: https://www.babeljs.cn/docs/babel-generator[8]
组件的外部样式和全局样式: https://taro-docs.jd.com/docs/component-style[9]
完整 Demo 代码: https://github.com/mvpleung/taro2-taro3