为了账号安全,请及时绑定邮箱和手机立即绑定

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

标签:
Node.js Html5

本文同步于我的github博客,欢迎订阅

前言

先简单介绍一些背景:
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。

相关代码:

本文同步于我的github博客,欢迎订阅

点击查看更多内容
2人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
59
获赞与收藏
210

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消