通过一个需求揭秘多端编译

https://ss2.bdstatic.com/70cFvnSh_Q1YnxGkpoWK1HF6hhy/it/u=3710369427,300738485&fm=26&gp=0.jpg

过年在家办公期间,接到了一个需求,需要将目前的 微信小程序自定义组件 扩展到 支付宝小程序 平台。关于需求的背景和历史这边就暂不多说了,就从上面已说明的内容来看待这个需求吧。
接到需求的第一时间,笔者就思考,这不就是多端编译吗?话不多说,那就开搞吧。

背景介绍

由于笔者的项目是一个单纯的微信小程序自定义组件,打包工具是rollup,所以,笔者的技术方案是编写一个rollup插件,来支持多端编译。关于rollup和rollup插件的写法本次不作过多介绍,有兴趣的可以看它的官方文档,这边只是介绍一下核心的多端编译流程。

流程介绍

微信小程序组件包含 *.json*.js*.wxml*.wxss 这4个文件,要转换成支付宝小程序,其中json文件和wxss文件比较简单,前者原封不动,后者改一下后缀名就好了,主要要修改js和wxml两个文件。

大致流程基本就是如下

  1. 差异整理
  2. 将代码转成AST树
  3. 替换树上的节点
  4. 根据新的AST树生成代码

acorn

对于js文件,要实现这些功能的话,业界已经有一些出色的工具了。笔者选择了babel,babel内置acorn作为javascript解释器,生成符合estree标准的AST树(可以在https://astexplorer.net/中查看效果)。其次babel的封装很漂亮,除了搭配webpack完成日常的构建工作外,它还提供了 @babel/parser@babel/generator@babel/traverse@babel/types 等优秀的工具包,每个工具包都是单一职责,职责很明确,帮助实现以上的流程(其实rollup内置了acorn实例,不过babel会更好用一些)。
其中 @babel/parser 可以将js代码解释为AST树,@babel/generator 将根据AST树生成js代码,@babel/traverse 支持高效地操作AST树的节点,@babel/types 则提供一些判断函数,帮助开发者快速定位节点。

看一个简单的示例

1
2
3
4
5
function sayHello() {
console.log('hello')
}

sayHello();

对于以上这段代码,通过acorn转换后,得出的AST树如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
{
"type": "Program",
"start": 0,
"end": 58,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 45,
"id": {
"type": "Identifier",
"start": 9,
"end": 17,
"name": "sayHello"
},
"expression": false,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"start": 20,
"end": 45,
"body": [
{
"type": "ExpressionStatement",
"start": 23,
"end": 43,
"expression": {
"type": "CallExpression",
"start": 23,
"end": 43,
"callee": {
"type": "MemberExpression",
"start": 23,
"end": 34,
"object": {
"type": "Identifier",
"start": 23,
"end": 30,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 31,
"end": 34,
"name": "log"
},
"computed": false
},
"arguments": [
{
"type": "Literal",
"start": 35,
"end": 42,
"value": "hello",
"raw": "'hello'"
}
]
}
}
]
}
},
{
"type": "ExpressionStatement",
"start": 47,
"end": 58,
"expression": {
"type": "CallExpression",
"start": 47,
"end": 57,
"callee": {
"type": "Identifier",
"start": 47,
"end": 55,
"name": "sayHello"
},
"arguments": []
}
}
],
"sourceType": "module"
}

对于这段js代码,如果要替换它的方法名为 sayHi、打印出的 hello 替换为 Hi,通过babel,只需要这样做就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import * as t from "@babel/types";

const code = `
function sayHello() {
console.log('hello')
}

sayHello();
`;

const transform = code => {
const ast = parse(code);
traverse(ast, {
enter(path) {
if (t.isIdentifier(path.node, { name: "sayHello" })) {
path.node.name = "sayHi";
}
if (t.isLiteral(path.node, { value: "hello" })) {
path.node.value = "Hi";
}
}
});
const output = generate(ast, {}, code);
return output;
};

console.log(transform(code).code);

也可以在codeSandbox中查看效果。

关于包的其它使用,可以查看官方手册

himalaya

对于wxml文件,笔者选择了himalaya-wxml,它提供了 parsestringify 两个方法,前者将wxml解释成AST树,后者反之(可以在https://jew.ski/himalaya/中查看效果)。通过 parse 将wxml代码转换成AST树之后,接下去只需要手动递归遍历AST树去替换节点,再将其转换回wxml代码就可以完成工作了。

同样,看一个简单的示例

1
2
3
<div id='main'>
<span>hello world</span>
</div>

对于以上html代码,通过 himalaya 转换后,生成的AST树如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[
{
"type": "element",
"tagName": "div",
"attributes": [],
"children": [
{
"type": "text",
"content": "\n "
},
{
"type": "element",
"tagName": "span",
"attributes": [],
"children": [
{
"type": "text",
"content": "hello world"
}
]
},
{
"type": "text",
"content": "\n"
}
]
}
]

对于这段代码html代码,如果要替换它外层 dividcontainer,只需要这样做就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { parse, stringify } from "himalaya";

const code = `
<div id='main'>
<span>hello world</span>
</div>
`;

const traverse = ast => {
return ast.map(item => {
if (item.type === "element" && item.attributes) {
return {
...item,
attributes: item.attributes.map(attr => {
if (attr.key !== "id") {
return attr;
}
return {
...attr,
value: "container"
};
})
};
}
return item;
});
};

const transform = code => {
const ast = parse(code);
const json = traverse(ast);
return stringify(json);
};

console.log(transform(code));

也可以在codeSandbox中查看效果。

核心介绍

流程和工具介绍的差不多了,接下来就开始正题吧。
首先是整理差异,根据笔者的调研,微信小程序组件要转换成支付宝小程序组件,大致有以下几个改动(只是符合笔者的需求,如果不完全,欢迎补充):

  1. wxml后缀名要改成axml
  2. wxss后缀名要改成acss
  3. wxml中的属性wx-xxx要改成a-xxx
  4. wxml中的事件属性bindxxx要改成onXxx
  5. 生命周期attached要替换成onInit
  6. 生命周期detached要替换成didUnmount
  7. 生命周期pageLifetimes.show要替换成didMount
  8. 生命周期pageLifetimes要删除

改后缀名的工作相对简单,交给构建工具,output配置里面指定一下就好了,重点是替换属性。

转换js部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
import * as t from '@babel/types';

function transformJs(code: string) {
const ast = parse(code);
let pp;

traverse(ast, {
enter(path) {
if (t.isIdentifier(path.node, {name: 'attached'})) {
path.node.name = 'onInit';
}
if (t.isIdentifier(path.node, {name: 'detached'})) {
path.node.name = 'didUnmount';
pp = path.parentPath;
}
if(t.isIdentifier(path.node.key, {name: 'show'})){
path.node.key.name = 'didMount';
pp.insertAfter(path.node);
}
},
exit(path) {
if(t.isIdentifier(path.node.key, {name: 'pageLifetimes'})){
path.remove();
}
}
});
const output = generate(ast, {}, code);
return output
}

export default transformJs

转换wxml部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { parse, stringify } from 'himalaya-wxml';

const traverseKey = (key: string) => {
if(key.startsWith('wx:')){
const postfix = key.slice(3);
return `a:${postfix}`;
}
if(key === 'catchtouchmove'){
return 'catchTouchMove';
}
if(key === 'bindtap'){
return 'onTap';
}
if(key === 'bindload'){
return 'onLoad';
}
if(key === 'binderror'){
return 'onError';
}
if(key === 'bindchange'){
return 'onChange';
}
return key
}

const traverseAst = (ast: any) => {
return ast.map(item => {
if(item.type !== 'element'){
return item;
}
let res = item;
if(item.attributes){
res = {
...item,
attributes: item.attributes.map(attr => ({
...attr,
key: traverseKey(attr.key)
}))
}
}
if(item.children){
res.children = traverseAst(item.children);
}
return res
});
}

const transformWxml = (code: string) => {
const ast = parse(code);
const json = traverseAst(ast);
return stringify(json)
}

export default transformWxml

以上,就拥有了两个转换函数,再之后的工作,就是将这两个函数运行在rollup里,就完成了将微信小程序组件转换成支付宝小程序组件的功能。

总结

javascript作为前端最常用的语言,我们不仅要熟悉它,更要能操控它,通过javascript解释器,我们就拥有了操控它的能力。溯本求源,巩固基础,才能在寒冬之中保持内心的平静。

坚持原创技术分享,您的支持将鼓励我继续创作!