使用 babel 全家桶模块化古老的面条代码

image

在最近的工做中,接手了一个古老的项目,其中的 JS 代码是一整坨的面条代码,约 3000 行的代码全写在一个文件里,维护起来着实让人头疼。javascript

image

想不通为啥以前维护项目的同窗可以忍受这么难以维护的代码……既然如今这个锅被我拿下了,怎么着也不能容忍如此丑陋的代码继续存在着,必须把它优化一下。java

横竖看了半天,因为逻辑都揉在了一个文件里,看都看得眼花缭乱,当务之急即是把它进行模块化拆分,把这一大坨面条状代码拆分红一个个模块并抽离成文件,这样才方便后续的持续优化。node

1、结构分析

说干就干,既然要拆分红模块,首先就要分析源码的结构。虽然源码内容很长很复杂,但万幸的是它仍是有一个清晰的结构,简化一下,就是下面这种形式:es6

code

很容易看出,这是一种 ES5 时代的经典代码组织方式,在一个 IIFE 里面放一个构造函数,在构造函数的 protorype 上挂载不一样的方法,以实现不一样的功能。既然代码结构是清晰的,那么咱们要作模块化的思路也很清晰,就是想办法把全部绑定在构造函数的 prototype 上的方法抽离出来,以模块文件的形式放置,而源码则使用 ES6 的 import 语句把模块引入进来,完成代码的模块化:json

code

为了完成这个效果,咱们能够借助 @babel 全家桶来构造咱们的转化脚本。babel

2、借助 AST 分析代码

关于 AST 的相关资料一搜一大堆,在这里就不赘述了。在本文中,咱们会借助 AST 去分析源码,挑选源码中须要被抽离、改造的部分,所以 AST 能够说是本文的核心。在 https://astexplorer.net/ 这个网站,咱们能够贴入示例代码,在线查看它的 AST 长什么样:ide

image

从右侧的 AST 树中能够很清晰地看到,Demo.prototype.func = function () {} 属于 AssignmentExpression 节点,即为“赋值语句”,拥有左右两个不一样的节点(leftright)。模块化

因为一段 JS 代码里可能存在多种赋值语句,而咱们只想处理形如 Demo.prototype.func = function () {} 的状况,因此咱们须要继续对其左右两侧的节点进行深刻分析。函数

首先看左侧的节点,它属于一个“MemberExpression”,其特征以下图箭头所示:工具

image

对于左侧的节点,只要它的 object.property.name 的值为 prototype 便可,那么对应的函数名就是该节点的 property.name

接着看右侧的节点,它属于一个“FunctionExpression”:
image

咱们要作的,就是把它提取出来做为一个独立的文件。

分析完了 AST 之后,咱们已经知道须要被处理的代码都有一些什么样的特征,接下来就是针对这些特征进行操做了,这时候就须要咱们的 @babel 全家桶出场了!

3、处理代码

首先咱们须要安装四个工具,它们分别是:

  • @babel/parser:用于把 JS 源码转化成 AST;
  • @babel/traverse:用于遍历 AST 树,获取当中的节点内容;
  • @babel/generator:把 AST 节点转化成对应的 JS 代码;
  • @babel/types:新建 AST 节点。

接下来新建一个 index.js 文件,引入上面四个工具,并设法加载咱们的源码(源码为 demo/es5code.js):

const fs = require('fs')
const { resolve } = require('path')

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const INPUT_CODE = resolve(__dirname, '../demo/es5code.js')

const code = fs.readFileSync(`${INPUT_CODE}`, 'utf-8')

接着使用 @babel/parser 获取源码的 AST:

const ast = parser.parse(code)

拿到 AST 之后,就可使用 @babel/traverse 来遍历它的节点。从上一节的 AST 分析能够知道,咱们只须要关注“AssignmentExpression”节点便可:

traverse(ast, {
  AssignmentExpression ({ node }) {
    /* ... */
  }
})

当前节点即为参数 node,咱们须要分析它左右两侧的节点。只有当左侧节点的类型为“MemberExpression”且右侧节点的类型为“FunctionExpression”才须要进入下一步分析(由于形如 a = 1 之类的节点也属于 AssignmentExpression 类型,不在咱们的处理范围内)。

因为 JS 中可能存在不一样的 MemberExpression 节点,如 a.b.c = function () {},但咱们如今只须要处理 a.prototype.func 的状况,意味着要盯着关键字 prototype。经过分析 AST 节点,咱们知道这个关键字位于左侧节点的 object.property.name 属性中:

image

同时对应的函数名则藏在左侧节点的 property.name 属性中:

image

所以即可以很方便地提取出方法名

traverse(ast, {
  AssignmentExpression ({ node }) {
    const { left, right } = node
    if (left.type === 'MemberExpression' && right.type === 'FunctionExpression') {
      const { object, property } = left
      if (object.property.name === 'prototype') {
        const funcName = property.name // 提取出方法名
        console.log(funcName)
      }
    }
  }
})

能够很方便地把方法名打印出来检查:

image

如今咱们已经分析完左侧节点的代码,提取出了方法名。接下来则是处理右侧节点。因为右侧代码直接就是一个 FunctionExpression 节点,所以咱们要作的就是经过 @babel/generator 把该节点转化成 JS 代码,并写入文件。

此外,咱们也要把原来的代码从 Demo.prototype.func = function () {} 转化成 Demo.prototype.func = func 的形式,所以右侧的节点须要从“FuncitionExpression”类型转化成“Identifier”类型,咱们能够借助 @babel/types 来处理。

还有一个事情别忘了,就是咱们已经把右侧节点的代码抽离成了 JS 文件,那么咱们也应该在最终改造完的源文件里把它们给引入进来,形如 import func1 from './func1' 这种形式,所以能够继续使用 @babel/typesimportDeclaration() 函数来生成对应的代码。这个函数参数比较复杂,能够封装成一个函数:

function createImportDeclaration (funcName) {
  return t.importDeclaration([t.importDefaultSpecifier(t.identifier(funcName))], t.stringLiteral(`./${funcName}`))
}

只须要传入一个 funcName,就能够生成一段 import funcName from './funcName' 代码。

最终总体代码以下:

const fs = require('fs')
const { resolve } = require('path')

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const INPUT_CODE = resolve(__dirname, '../demo/es5code.js')
const OUTPUT_FOLDER = resolve(__dirname, '../output')

const code = fs.readFileSync(`${INPUT_CODE}`, 'utf-8')
const ast = parser.parse(code)

function createFile (filename, code) {
  fs.writeFileSync(`${OUTPUT_FOLDER}/${filename}.js`, code, 'utf-8')
}

function createImportDeclaration (funcName) {
  return t.importDeclaration([t.importDefaultSpecifier(t.identifier(funcName))], t.stringLiteral(`./${funcName}`))
}

traverse(ast, {
  AssignmentExpression ({ node }) {
    const { left, right } = node
    if (left.type === 'MemberExpression' && right.type === 'FunctionExpression') {
      const { object, property } = left
      if (object.property.name === 'prototype') {    
        // 获取左侧节点的方法名
        const funcName = property.name
        // 获取右侧节点对应的 JS 代码
        const { code: funcCode } = generator(right)
        // 右侧节点改成 Identifier
        const replacedNode = t.identifier(funcName)
        node.right = replacedNode
       
        // 借助 `fs.writeFileSync()` 把右侧节点的 JS 代码写入外部文件
        createFile(funcName, 'export default ' + funcCode)

        // 在文件头部引入抽离的文件
        ast.program.body.unshift(createImportDeclaration(funcName))
      }
    }
  }
})

// 输出新的文件
createFile('es6code', generate(ast).code)

4、运行脚本

在咱们的项目目录中,其结构以下:

.
├── demo
│   └── es5code.js
├── output
├── package.json
└── src
    └── index.js

运行脚本,demo/es5code.js 的代码将会被处理,而后输出到 output 目录:

.
├── demo
│   └── es5code.js
├── output
│   ├── es6code.js
│   ├── func1.js
│   ├── func2.js
│   └── func3.js
├── package.json
└── src
    └── index.js

看看咱们的代码:
image

image

大功告成!把脚本运用到咱们的项目中,甚至能够发现原来的约 3000 行代码,已经被整理成了 300 多行:

image

放到真实环境去跑一遍这段代码,原有功能不受影响!

小结

刚刚接手这个项目,个人心里是一万头神兽奔腾而过,心里是很是崩溃的。可是既然接手了,就值得好好对待它。借助 AST 和 @babel 全家桶,咱们就有了充分改造源码的手段。花半个小时个脚本,把丑陋的面条代码整理成清晰的模块化代码,心里的阴霾一扫而空,对这个古老的项目更是充满了期待——会不会有更多的地方能够被改造被优化呢?值得拭目以待!