Skip to content
微信公众号

JSX转换微信小程序模板的实现

代码的本质

不管是任意语言的代码,其实它们都有两个共同点:

  1. 它们都是由字符串构成的文本
  2. 它们都要遵循自己的语言规范

第一点很好理解,既然代码是字符串构成的,我们要修改/编译代码的最简单的方法就是使用字符串的各种正则表达式。例如我们要将 JSON 中一个键名 foo 改为 bar,只要写一个简单的正则表达式就能做到:

js
jsonStr.replace(/(?<=")foo(?="\s*:)/i, 'bar')

而这句代码就是我们的编译器——你看到这里可能觉得被骗了:“说好了讲一些编译原理高大上的东西呢?”但实际上这是理解编译器万里长征的第零步(也可能是最重要的一步):编译就是把一段字符串改成另外一段字符串。很多同学觉得做编译一定是高大上的,但当我们把它拉下神坛,就可以发现它其实就是(艰难地)操作字符串而已。

我们再来看这个正则表达式,由于 JSON 规定了它的键名必须由双引号包裹且包裹键名的第二个双引号的下一个非空字符串一定是冒号,所以我们的正则一定能匹配到对应的键值。这就是我们之前提到的凡是语言一定有一个规范, JavaScript 作为 JSON 的超集也和 JSON 别无二致,也就是说不管是 JSON 还是 JavaScript 它们的代码都是结构化的,我们可以通过任意一个结构化的数据结构(Schema)把它们的对应语法描述出来。

对于我们的目标而言,我们打算用 JavaScript 去编译 JavaScript。其实要做的事情就是把一段 JavaScript 代码解析成一个让 JavaScript 易于操作的对象,然后我们操作这个对象用它来生成另外一段目标字符串,而那个易于操作的对象我们把它称之为抽象语法树(Abstract Syntax Tree,以下简称为 AST)。生成 AST 的解析器(parser)在一个完整编译器当中属于前端部分,这部分代码可以说是比较无聊、复杂又繁琐的部分,由于 ECMAScript 本身也在不断进化,新的规范在不断添加,parser 也变得越来越复杂。最新的 ECMAScript 规范(ECMA-262)已经是八百页的 PDF 文件,如果我们先把这八百页看完再从头去实现一个 parser 将会消耗掉大量的时间。但好在社区已经有了非常好的 parser 可以供我们直接使用。

Babel

JavaScript 社区其实有非常多 parser 实现,比如 Acorn、Esprima、Recast、Traceur、Cherow 等等。但我们还是选择使用 Babel,主要有以下几个原因:

  1. Babel 可以解析还没有进入 ECMAScript 规范的语法。例如装饰器这样的提案,虽然现在没有进入标准但是已经广泛使用有一段时间了;
  2. Babel 提供插件机制解析 TypeScript、Flow、JSX 这样的 JavaScript 超集,不必单独处理这些语言;
  3. Babel 拥有庞大的生态,有非常多的文档和样例代码可供参考;
  4. 除去 parser 本身,Babel 还提供各种方便的工具库可以优化、生成、调试代码。

Babylon( @babel/parser)

Babylon 就是 Babel 的 parser。它可以把一段符合规范的 JavaScript 代码输出成一个符合 Esprima 规范的 AST。 大部分 parser 生成的 AST 数据结构都遵循 Esprima 规范,包括 ESLint 的 parser ESTree。这就意味着我们熟悉了 Esprima 规范的 AST 数据结构还能去写 ESLint 插件。

我们可以尝试解析 n * n 这句简单的表达式:

js
import * as babylon from "babylon";

const code = `n * n`;

babylon.parse(code);

最终 Babylon 会解析成这样的数据结构:

你也可以使用 ASTExploroer 快速地查看代码的 AST。

Babel-traverse (@babel/traverse)

babel-traverse 可以遍历由 Babylon 生成的抽象语法树,并把抽象语法树的各个节点从拓扑数据结构转化成一颗路径(Path)树,Path 表示两个节点之间连接的响应式(Reactive)对象,它拥有添加、删除、替换节点等方法。当你调用这些修改树的方法之后,路径信息也会被更新。除此之外,Path 还提供了一些操作作用域(Scope) 和标识符绑定(Identifier Binding) 的方法可以去做处理一些更精细复杂的需求。可以说 babel-traverse 是使用 Babel 作为编译器最核心的模块。

让我们尝试一下把一段代码中的 n * n 变为 x * x:

js
import * as babylon from "@babel/parser";
import traverse from "babel-traverse";

const code = `function square(n) {
  return n * n;
}`;

const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (
      path.node.type === "Identifier" &&
      path.node.name === "n"
    ) {
      path.node.name = "x";
    }
  }
});

Babel-types(@babel/types)

babel-types 是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。例如我们之前在 babel-traverse 中改变标识符 n 的代码可以简写为:

js
import traverse from "babel-traverse";
import * as t from "babel-types";

traverse(ast, {
  enter(path) {
    if (t.isIdentifier(path.node, { name: "n" })) {
      path.node.name = "x";
    }
  }
});

可以发现使用 babel-types 能提高我们转换代码的可读性,在配合 TypeScript 这样的静态类型语言后,babel-types 的方法还能提供类型校验的功能,能有效地提高我们转换代码的健壮性和可靠性。

设计思路

Taro 的结构主要分两个方面:运行时和编译时。运行时负责把编译后到代码运行在本不能运行的对应环境中,你可以把 Taro 运行时理解为前端开发当中 polyfill。举例来说,小程序新建一个页面是使用 Page 方法传入一个字面量对象,并不支持使用类。如果全部依赖编译时的话,那么我们要做到事情大概就是把类转化成对象,把 state 变为 data,把生命周期例如 componentDidMount 转化成 onReady,把事件由可能的类函数(Class method)和类属性函数(Class property function) 转化成字面量对象方法(Object property function)等等。

但这显然会让我们的编译时工作变得非常繁重,在一个类异常复杂时出错的概率也会变高。但我们有更好的办法:实现一个 createPage 方法,接受一个类作为参数,返回一个小程序 Page 方法所需要的字面量对象。这样不仅简化了编译时的工作,我们还可以在 createPage 对编译时产出的类做各种操作和优化。通过运行时把工作分离了之后,再编译时我们只需要在文件底部加上一行代码 Page(createPage(componentName)) 即可。

如果你是从 Taro CLI 的 dist 文件夹看编译后的代码会发现它相当复杂,那是因为代码会再经过 babel 编译为 ES5。

除了 Page 类型之外,小程序还有 Component 类型,所以 Taro 其实还有 createComponent 方法。由于 Component 在小程序里是全局变量,因此我们还得把 import { Component } from '@tarojs/taro' 的 Component 重命名。

回到一开始那段代码,我们定义了一个类属性 config,config 是一个对象表达式(Object Expression),这个对象表达式只接受键值为标识符(Identifier)或字符串,而键名只能是基本类型。这样简单的情况我们只需要把这个对象表达式转换为 JSON 即可。另外一个类属性 state 在 Page 当中有点像是小程序的 data,但它在多数情况不是完整的 data(下文会继续讨论data)。这里我们不用做过多的操作,babel的插件 transform-class-proerties 会把它编译到类的构造器中。函数 handleClick 我们交给运行时处理,有兴趣的同学可以跳到 Taro 运行时原理查看具体技术细节。

再来看我们的 render() 函数,它的第一行代码通过 filter 把数字数组的所有偶数项都过滤掉,真正用来循环的是 oddNumbers,而 oddNumbers 并没有在 this.state 中,所以我们必须手动把它加入到 this.state。和 React 一样,Taro 每次更新都会调用 render 函数,但和 React 不同的是,React 的 render 是一个创建虚拟 DOM 的方法,而 Taro 的 render 会被重命名为 _createData,它是一个创建数据的方法:在 JSX 使用过的数据都在这里被创建最后放到小程序 Page 或 Component 工厂方法中的 data 。最终我们的 render 方法会被编译为:

js
_createData() {
  this.__state = arguments[0] || this.state || {};
  this.__props = arguments[1] || this.props || {};

  const oddNumbers = this.__state.numbers.filter(number => number & 2);
  Object.assign(this.__state, {
    oddNumbers: oddNumbers
  });
  return this.__state;
}

WXML 和 JSX

在 Taro 里 render 的所有 JSX 元素都会在 JavaScript 文件中被移除,它们最终将会编译成小程序的 WXML。每个 WXML 元素和 HTML 元素一样,我们可以把它定义为三种类型:Element、Text、Comment。其中 Text 只有一个属性: 内容(content),它对应的 AST 类型是 JSXText,我们只需要将前文源码中对应字符串的奇数和偶数转换成 Text 即可。而对于 Comment 而言我们可以将它们全部清除,不参与 WXML 的编译。Element 类型有它的名字(tagName)、children、属性(attributes),其中 children 可能是任意 WXML 类型,属性是一个对象,键值和键名都是字符串。我们将把重点放在如何转换成为 WXML 的 Element 类型。

首先我们可以先看 <View className='home'>,它在 AST 中是一个 JSXElement,它的结构和我们定义 Element 类型差不多。我们先将 JSXElement 的 ScrollView 从驼峰式的 JSX 命名转化为短横线(kebab case)风格,className 和 scrollTop 的值分别代表了 JSXAttribute 值的两种类型:StringLiteral 和 JSXExpressionContainer,className 是简单的 StringLiteral 处理起来很方便,scrollTop 处理起来稍微麻烦点,我们需要用两个花括号 {} 把内容包起来。

JSXExpressionContainer 其实可以包含任何合法的 JavaScript 表达式,本例中我们只传入了一个字面量的布尔值,直接用双括号包裹在 WXML 是合法的。但 WXML 的模板支持的表达式是有限的,当表达式包含函数时 Taro 将生成一个匿名的 state 放在当前表达式作用域的前一行,并处理作用域命名的问题。

接下来我们再思考一下每一个 JSXElement 出现的位置,你可以发现其实它的父元素只有几种可能性:return、循环、条件(逻辑)表达式。而在上一篇文章中我们提到,babel-traverse 遍历的 AST 类型是响应式的——也就是说只要我们按照 JSXElement 父元素类型的顺序穷举处理这几种可能性,把各种可能性大结果应用到 JSX 元素之后删除掉原来的表达式,最后就可以把一个复杂的 JSX 表达式转换为一个简单的 WXML 数据结构。

JSXElement 的父元素其实可能有很多种情况,例如父元素可能是一个 JSXAttribute,这类情况 Taro 还不支持,我们用 ESLint 插件规避了这样的写法。还有一些情况,例如赋值表达式和 If 表达式处理起来较为复杂,本文不过多赘述。

我们先看第一个循环:

js
oddNumbers.map(number => <Text onClick={this.handleClick}>{number}</Text>)

Text 的父元素是一个 map 函数(CallExpression),我们可以把函数的 callee: oddNumbers 作为 wx:for 的值,并把它放到 state 中,匿名函数的第一个参数是 wx:for-item的值,函数的第二个参数应该是 wx:for-index 的值,但代码中没有传所以我们可以不管它。然后我们把这两个 wx: 开头的参数作为 attribute 传入 Text 元素就完成了循环的处理。而对于 onClick 而言,在 Taro 中 on 开头的元素参数都是事件,所以我们只要把 this. 去掉即可。Text 元素的 children 是一个 JSXExpressionContainer,我们按照之前的处理方式处理即可。最后这行我们生成出来的数据结构应该是这样:

js
{
  type: 'element',
  tagName: 'text',
  attributes: [
    { bindtap: 'handleClick' },
    { 'wx:for': '{{oddNumbers}}' },
    { 'wx:for-item': 'number' }
  ],
  children: [
    { type: 'text', content: '{{number}}' }
  ]
}

有了这个数据结构生成一段 WXML 就非常简单了,你可以参考 himalaya 的代码。

再来看第二个循环表达式:

js
numbers.map(number => number % 2 === 0 && <Text onClick={this.handleClick}>{number}</Text>)

它比第一个循环表达式多了一个逻辑表达式(Logical Operators),我们知道 expr1 && expr2 意味着如果 expr1 能转换成 true 则返回 expr2,也就是说我们只要把 number % 2 === 0 作为值生成一个键名 wx:if 的 JSXAttribute 即可。但由于 wx:if 和 wx:for 同时作用于一个元素可能会出现问题,所以我们应该生成一个 block 元素,把 wx:if 挂载到 block 元素,原元素则全部作为 children 传入 block 元素中。这时 babel-traverse 会检测到新的元素 block,它的父元素是一个 map 循环函数,因此我们可以按照第一个循环表达式的处理方法来处理这个表达式。

这里我们可以思考一下 this.props.text || this.props.children 的解决方案。当用户在 JSX 中使用 || 作为逻辑表达式时很可能是 this.props.text 和 this.props.children 都有可能作为结果返回。这里 Taro 将它编译成了 this.props.text ? this.props.text: this.props.children,按照条件表达式(三元表达式)的逻辑,也就是说会生成两个 block,一个 wx:if 和一个 wx:else:

js
<block wx:if="{{text}}">{{text}}</block>
<block wx:else>
    <slot></slot>
</block>

条件表达式(Conditional Expression)的处理比逻辑表达式稍微复杂一些,因为表达式返回的结果可以是任意类型。但万变不离其宗,我们只要一直处理 JSX 元素的父元素,如果支持不了就用 ESLint 警告,如果能够支持就把表达式转换成对应的属性挂载在到 JSX 元素中再把表达式删除,直到我们能将这个 JSX 元素移除为止。

本站总访问量次,本站总访客数人次
Released under the MIT License.