本文同步于我的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"
,表示它是一个赋值表达式,里面的start
和end
是源代码中对应的位置,left
和right
即表达式左边和右边的值,也就是被赋值的变量和赋值的值。left
的type
为"MemberExpression"
,即成员表达式,也就是A.B
的形式的代码,也可以看到它所属的object
的名称为THREE
。
而right
的type
为"Literal"
,即字面量,其实我们并不关心right
,它可能是字面量,也可能是函数、对象或别的东西。
到这里我们的目标就变得明确了,我们只需要找到所有的"AssignmentExpression"
,并且它的left
为"MemberExpression"
,且name
为THREE
。
接下来就可以处理所有代码了,遍历每个文件并得到它们的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中的一个值,所以只要像处理输出语句那样找到所有name
为THREE
的MemberExpression
节点就可以了。
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博客,欢迎订阅