V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
hujiulong
V2EX  ›  前端开发

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

  •  
  •   hujiulong · 2019-03-19 10:37:03 +08:00 · 2409 次点击
    这是一个创建于 2113 天前的主题,其中的信息可能已经有所发展或是发生改变。

    本文同步于我的 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 条回复    2019-03-19 10:43:03 +08:00
    dandycheung
        1
    dandycheung  
       2019-03-19 10:41:15 +08:00 via Android
    赞。
    fy
        2
    fy  
       2019-03-19 10:43:03 +08:00
    哇 大佬
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   977 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 21:05 · PVG 05:05 · LAX 13:05 · JFK 16:05
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.