通过分析 AST 自动重构 three.js 的老旧代码

前言

先简单介绍一些背景:

three.js 是一个非常流行的JS三维渲染库,通常是做web端三维效果的第一选择。但是同时three.js已经有了将近9年的历史,所有它很多代码仍然是使用非常老旧的模式。

three.js曾经所有的文件都是使用全局变量 THREE 的方式来组织,比如欧拉角 Euler.js

// three.js/src/math/Euler.js
THREE.Euler = function ( x, y, z, order ) {
  this._x = x || 0;
  this._y = y || 0;
  this._z = z || 0;
  this._order = order || THREE.Euler.DefaultOrder;
};

在经历几次重构以后,three.js的核心代码已经完全迁移成用ES6 Module来组织了,直接通过 export { Euler } 来输出变量。

但是在核心代码以外,仍然有大量非常常用的代码使用这种老旧方式来组织,比如所有的模型加载器 loaders ,以及控制器 controls 。如果想直接 import 它们,需要自己手动去改成ES6 Module的形式,在我以前的一个项目 vue-3d-model 中,所有的loaders就是我手动修改的。

为什么要用AST来做

粗略看来这些老旧代码大多遵循一些特定的模式,例如很多都是以 THREE.XX = xx 的形式来输出变量,很容易想到用正则去处理它。

但是用正则匹配会遇到非常多的问题:

1.正则要求很严格,每一个字符都要写规则来匹配它

如果代码风格不统一,例如想匹配 THREE.XX = xx 这种代码,你写的正则必须要同时兼容 THREE.XX=xx 这种等号两边没有空格的情况。实践中还要处理各种特殊情况,非常麻烦。

2.很难避开注释中的代码

注释中也可能会出现你要匹配的字符串,会导致很多错误。

但是绕过代码本身,直接分析代码的抽象语法树(AST),这些问题就都迎刃而解了。

AST是源代码语法结构的一种抽象表示,代码对应的AST和代码风格无关,多写一个空格少写一个分号都没关系,通过AST来查找代码节点也更加可靠,不必担心错误匹配到别的代码,像eslint,webpack之类的工具都是通过分析AST来处理代码的。

JS的AST已经形成了一套规范,具体可以看这个 文档

生成AST的工具也有很多,我选择的是 acorn

找出输出语句

输出语句大多是直接给全局变量THREE赋值的,例如这样前言中说的Euler.js,我们期望将这样的代码:

THREE.Euler = function() { /* ... */ };

转换成:

const Euler = function() { /* ... */ };
export { Euler };

可以看到输出语句大都是 THREE.XX = xx 的形式,后面的 xx 可能是一个类、变量、函数或别的什么东西,总的来说它是一个赋值语句。

先抛开要处理的代码,我们来看一个简单的给属性赋值语句代码对应的AST是什么样的。

THREE.A = 1;

通过 acorn.parse(code) 可以得到AST:

{
  "type": "AssignmentExpression",
  "start": 1,
  "end": 12,
  "operator": "=",
  "left": {
    "type": "MemberExpression",
    "start": 1,
    "end": 8,
    "object": {
      "type": "Identifier",
      "start": 1,
      "end": 6,
      "name": "THREE"
    },
    "property": {
      "type": "Identifier",
      "start": 7,
      "end": 8,
      "name": "A"
    },
    "computed": false
  },
  "right": {
    "type": "Literal",
    "start": 11,
    "end": 12,
    "value": 1,
    "raw": "1"
  }
}

简单分析一下:

首先整个节点的 type"AssignmentExpression" ,表示它是一个赋值表达式,里面的 startend 是源代码中对应的位置, leftright 即表达式左边和右边的值,也就是被赋值的变量和赋值的值。

lefttype"MemberExpression" ,即成员表达式,也就是 A.B 的形式的代码,也可以看到它所属的 object 的名称为 THREE

righttype"Literal" ,即字面量,其实我们并不关心 right ,它可能是字面量,也可能是函数、对象或别的东西。

到这里我们的目标就变得明确了,我们只需要找到所有的 "AssignmentExpression" ,并且它的 left"MemberExpression" ,且 nameTHREE

接下来就可以处理所有代码了,遍历每个文件并得到它们的AST,然后使用 acorn/walk 遍历AST所有的节点,就可以知道每个文件都输出了什么。

walk.simple( ast, {
  AssignmentExpression: ( node ) => {
    if (node.left.type === 'MemberExpression' &&
      node.left.object.name === 'THREE') {
      const { start, end, property } = node.left;
      code.overwrite( start, end, `const ${property.name}` );  // 将THREE.XX = xx替换为const XX = xx
      exportVars.push(property.name);  // 将输出的变量保存,最后export它们
    }
  }
})

这样最后我们得到了所有的输出变量,就可以在文件末尾export它们。

处理依赖

除了找到输出的变量,我们还需要处理文件的依赖。值得高兴的是THREE所有文件都没有任何外部依赖,所有的依赖情况只有两种:

1.依赖three.js的核心库

2.依赖别的需要转化的文件

比如文件中有这样一段代码

const v = new THREE.Vector3();
const loader = new THREE.OBJLoader();

我们期望的转化后的文件应该是这样:

import { Vector3 } from 'three';
import { OBJLoader } from '../loader/OBJLoader.js';
const v = new Vector3();
const loader = new OBJLoader();

我们先找出代码中所有有依赖的地方,这两种依赖情况都是获取THREE中的一个值,所以只要像处理输出语句那样找到所有 nameTHREEMemberExpression 节点就可以了。

walk.simple( ast, {
  MemberExpression: node => {
    const { object, property } = node;
    if ( object.name === 'THREE' && property.type === 'Identifier' ) {
      code.overwrite(object.start, object.end + 1, ''); // 将代码中的THREE.XX 替换为 XX
      dependences.push( property.name );  // 得到依赖
    }
  }
})

得到所有依赖的名称后,通过判断three的核心库中是否包含这个值,就可以知道它是位于three中还是别的文件中,然后通过计算文件之间的相对位置,可以得到依赖文件的地址。

后话

转换实际情况要更加复杂一点,但是基本都可以通过AST来做正确的替换,通过这种方式我处理了将近300个文件,只有很少的一部分需要再手动修改一下。

另外three.js目前实现类的方式都还是ES5时代的function的方式,后面会通过各种方式来将它们批量转换成ES6的class,这中间肯定也需要用到AST。

相关代码:

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章