在最近的工作中,接手了一个古老的我的项目,其中的 JS 代码是一整坨的面条代码,约 3000 行的代码全写在一个文件里,保护起来着实让人头疼。
想不通为啥之前保护我的项目的同学可能忍耐这么难以保护的代码……既然当初这个锅被我拿下了,怎么着也不能容忍如此俊俏的代码持续存在着,必须把它优化一下。
横竖看了半天,因为逻辑都揉在了一个文件里,看都看得目迷五色,事不宜迟便是把它进行模块化拆分,把这一大坨面条状代码拆分成一个个模块并抽离成文件,这样才不便后续的继续优化。
一、构造剖析
说干就干,既然要拆分成模块,首先就要剖析源码的构造。尽管源码内容很长很简单,但万幸的是它还是有一个清晰的构造,简化一下,就是上面这种模式:
很容易看出,这是一种 ES5 时代的经典代码组织形式,在一个 IIFE 外面放一个构造函数,在构造函数的 protorype
上挂载不同的办法,以实现不同的性能。既然代码构造是清晰的,那么咱们要做模块化的思路也很清晰,就是想方法把所有绑定在构造函数的 prototype
上的办法抽离进去,以模块文件的模式搁置,而源码则应用 ES6 的 import
语句把模块引入进来,实现代码的模块化:
为了实现这个成果,咱们能够借助 @babel
全家桶来结构咱们的转化脚本。
二、借助 AST 剖析代码
对于 AST 的相干材料一搜一大堆,在这里就不赘述了。在本文中,咱们会借助 AST 去剖析源码,筛选源码中须要被抽离、革新的局部,因而 AST 能够说是本文的外围。在 https://astexplorer.net/ 这个网站,咱们能够贴入示例代码,在线查看它的 AST 长什么样:
从右侧的 AST 树中能够很清晰地看到,Demo.prototype.func = function () {}
属于 AssignmentExpression
节点,即为“赋值语句”,领有左右两个不同的节点(left
,right
)。
因为一段 JS 代码里可能存在多种赋值语句,而咱们只想解决形如 Demo.prototype.func = function () {}
的状况,所以咱们须要持续对其左右两侧的节点进行深入分析。
首先看左侧的节点,它属于一个“MemberExpression”,其特色如下图箭头所示:
对于左侧的节点,只有它的 object.property.name
的值为 prototype
即可,那么对应的函数名就是该节点的 property.name
。
接着看右侧的节点,它属于一个“FunctionExpression”:
咱们要做的,就是把它提取进去作为一个独立的文件。
剖析完了 AST 当前,咱们曾经晓得须要被解决的代码都有一些什么样的特色,接下来就是针对这些特色进行操作了,这时候就须要咱们的 @babel
全家桶出场了!
三、解决代码
首先咱们须要装置四个工具,它们别离是:
@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
属性中:
同时对应的函数名则藏在左侧节点的 property.name
属性中:
因而便能够很不便地提取出 办法名:
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)
}
}
}
})
能够很不便地把办法名打印进去查看:
当初咱们曾经剖析完左侧节点的代码,提取出了办法名。接下来则是解决右侧节点。因为右侧代码间接就是一个 FunctionExpression 节点,因而咱们要做的就是通过 @babel/generator
把该节点转化成 JS 代码,并写入文件。
此外,咱们也要把原来的代码从 Demo.prototype.func = function () {}
转化成 Demo.prototype.func = func
的模式,因而右侧的节点须要从“FuncitionExpression”类型转化成“Identifier”类型,咱们能够借助 @babel/types
来解决。
还有一个事件别忘了,就是咱们曾经把右侧节点的代码抽离成了 JS 文件,那么咱们也应该在最终革新完的源文件里把它们给引入进来,形如 import func1 from './func1'
这种模式,因而能够持续应用 @babel/types
的 importDeclaration()
函数来生成对应的代码。这个函数参数比较复杂,能够封装成一个函数:
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)
四、运行脚本
在咱们的我的项目目录中,其构造如下:
.
├── 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
看看咱们的代码:
功败垂成!把脚本使用到咱们的我的项目中,甚至能够发现原来的约 3000 行代码,曾经被整顿成了 300 多行:
放到实在环境去跑一遍这段代码,原有性能不受影响!
小结
刚刚接手这个我的项目,我的心田是一万头神兽奔流而过,心田是十分解体的。然而既然接手了,就值得好好看待它。借助 AST 和 @babel
全家桶,咱们就有了充沛革新源码的伎俩。花半个小时个脚本,把俊俏的面条代码整顿成清晰的模块化代码,心田的阴郁一扫而光,对这个古老的我的项目更是充斥了期待——会不会有更多的中央能够被革新被优化呢?值得刮目相待!