Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hujiulong opened this issue Mar 18, 2019 · 2 comments
Open

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

hujiulong opened this issue Mar 18, 2019 · 2 comments

Comments

@hujiulong
Copy link
Owner

hujiulong commented Mar 18, 2019

前言

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

相关代码:

@zuozuomuxi
Copy link

babel也是通过AST来转换代码的,function转class可以用lebab来做,就是反过来的babel

@hujiulong
Copy link
Owner Author

babel也是通过AST来转换代码的,function转class可以用lebab来做,就是反过来的babel

three.js的类里用了很多闭包,这块不太好直接转

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants