cover_image

babel 在 Taro 3 迁移中的实践

梁自超 货拉拉技术
2022年11月10日 09:00

作者介绍:梁自超,货拉拉资深前端工程师,主要负责货拉拉搬家业务的前端开发工作。

背景

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 与 babel 快速入门

AST(Abstract Syntax Tree)是源代码语法结构的一种抽象表示,以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

由于 AST 类型太多,结构繁杂,虽然 babel 提供了类型定义文件,但是查找起来比较费时费力。这里我们借助 AST Explorer[2] 来在线查看一段代码对应的 AST。

图片
astexplorer.net_.png

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>(
  nodeNode,
  handlersTraversalHandler<T> | TraversalHandlers<T>,
  state?: T,
): void;

在 Visitor 中可以对原有的 AST 节点进行编辑。下方的示例演示了如何更改方法名。

import traverse from '@babel/traverse';
import generate from '@babel/generator';
// ...

// @babel/traverse 操作剪裁
traverse(ast, {
  FunctionDeclarationfunction (path) {
    if (path.node.id) {
      path.node.id.name = 'x';
    }
  },
});

const resultCode = generate(ast).code;
// 方法名已经更改为 x 了
// function x(n) {
//  // ...
// }

迁移实践

迁移 API

import 语句

先观察一下 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';
  specifiersArray<
    ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier
  >;
  sourceStringLiteral;
  //...
}

检测 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,创建导入语句,这里不再赘述。

Usage

前面我们处理了导入问题,接下来我们看一个 API 用法的问题。

// 2.x
import Taro from '@tarojs/taro';

const MiniGuideTaro.FC = () => {
  const [test, setTest] = Taro.useState('');
  // ...
};

// 3.x
import React from '@tarojs/taro';

const MiniGuideReact.FC = () => {
  const [test, setTest] = React.useState('');
  // ...
};

上面代码中的 Taro.FCTaro.useState 都需要进行替换。这里重点关注 TSQualifiedName 和 MemberExpression,其对应的定义如下:

interface TSQualifiedName extends BaseNode {
  type'TSQualifiedName';
  leftTSEntityName;
  rightIdentifier;
}

interface MemberExpression extends BaseNode {
  type'MemberExpression';
  objectExpression | Super;
  propertyExpression | 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 的用法迁移改造工作。

迁移 Config

接下来我们来实现配置信息的提取,按照惯例先看下 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 {
  keyIdentifier | 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 文件中去,这部分逻辑比较简单不再赘述。

迁移 Router

路由的信息处理相对来说就简单很多,只需要将 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
  },
});

迁移 Style

在 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);
图片

至此,我们已经完成了 APIConfigRouterStyle 的迁移工作。然后执行 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


继续滑动看下一个
货拉拉技术
向上滑动看下一个