Open jrainlau opened 3 years ago
在最近的工作中,接手了一个古老的项目,其中的 JS 代码是一整坨的面条代码,约 3000 行的代码全写在一个文件里,维护起来着实让人头疼。
想不通为啥之前维护项目的同学能够忍受这么难以维护的代码……既然现在这个锅被我拿下了,怎么着也不能容忍如此丑陋的代码继续存在着,必须把它优化一下。
横竖看了半天,由于逻辑都揉在了一个文件里,看都看得眼花缭乱,当务之急便是把它进行模块化拆分,把这一大坨面条状代码拆分成一个个模块并抽离成文件,这样才方便后续的持续优化。
说干就干,既然要拆分成模块,首先就要分析源码的结构。虽然源码内容很长很复杂,但万幸的是它还是有一个清晰的结构,简化一下,就是下面这种形式:
很容易看出,这是一种 ES5 时代的经典代码组织方式,在一个 IIFE 里面放一个构造函数,在构造函数的 protorype 上挂载不同的方法,以实现不同的功能。既然代码结构是清晰的,那么我们要做模块化的思路也很清晰,就是想办法把所有绑定在构造函数的 prototype 上的方法抽离出来,以模块文件的形式放置,而源码则使用 ES6 的 import 语句把模块引入进来,完成代码的模块化:
protorype
prototype
import
为了完成这个效果,我们可以借助 @babel 全家桶来构造我们的转化脚本。
@babel
关于 AST 的相关资料一搜一大堆,在这里就不赘述了。在本文中,我们会借助 AST 去分析源码,挑选源码中需要被抽离、改造的部分,因此 AST 可以说是本文的核心。在 https://astexplorer.net/ 这个网站,我们可以贴入示例代码,在线查看它的 AST 长什么样:
从右侧的 AST 树中可以很清晰地看到,Demo.prototype.func = function () {} 属于 AssignmentExpression 节点,即为“赋值语句”,拥有左右两个不同的节点(left,right)。
Demo.prototype.func = function () {}
AssignmentExpression
left
right
由于一段 JS 代码里可能存在多种赋值语句,而我们只想处理形如 Demo.prototype.func = function () {} 的情况,所以我们需要继续对其左右两侧的节点进行深入分析。
首先看左侧的节点,它属于一个“MemberExpression”,其特征如下图箭头所示:
对于左侧的节点,只要它的 object.property.name 的值为 prototype 即可,那么对应的函数名就是该节点的 property.name。
object.property.name
property.name
接着看右侧的节点,它属于一个“FunctionExpression”:
我们要做的,就是把它提取出来作为一个独立的文件。
分析完了 AST 以后,我们已经知道需要被处理的代码都有一些什么样的特征,接下来就是针对这些特征进行操作了,这时候就需要我们的 @babel 全家桶出场了!
首先我们需要安装四个工具,它们分别是:
@babel/parser
@babel/traverse
@babel/generator
@babel/types
接下来新建一个 index.js 文件,引入上面四个工具,并设法加载我们的源码(源码为 demo/es5code.js):
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 类型,不在我们的处理范围内)。
node
a = 1
由于 JS 中可能存在不同的 MemberExpression 节点,如 a.b.c = function () {},但我们现在只需要处理 a.prototype.func 的情况,意味着要盯着关键字 prototype。通过分析 AST 节点,我们知道这个关键字位于左侧节点的 object.property.name 属性中:
a.b.c = function () {}
a.prototype.func
同时对应的函数名则藏在左侧节点的 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 来处理。
Demo.prototype.func = func
还有一个事情别忘了,就是我们已经把右侧节点的代码抽离成了 JS 文件,那么我们也应该在最终改造完的源文件里把它们给引入进来,形如 import func1 from './func1'这种形式,因此可以继续使用 @babel/types 的 importDeclaration() 函数来生成对应的代码。这个函数参数比较复杂,可以封装成一个函数:
import func1 from './func1'
importDeclaration()
function createImportDeclaration (funcName) { return t.importDeclaration([t.importDefaultSpecifier(t.identifier(funcName))], t.stringLiteral(`./${funcName}`)) }
只需要传入一个 funcName,就可以生成一段 import funcName from './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 目录:
output
. ├── demo │ └── es5code.js ├── output │ ├── es6code.js │ ├── func1.js │ ├── func2.js │ └── func3.js ├── package.json └── src └── index.js
看看我们的代码:
大功告成!把脚本运用到我们的项目中,甚至可以发现原来的约 3000 行代码,已经被整理成了 300 多行:
放到真实环境去跑一遍这段代码,原有功能不受影响!
刚刚接手这个项目,我的内心是一万头神兽奔腾而过,是非常崩溃的。但是既然接手了,就值得好好对待它。借助 AST 和 @babel 全家桶,我们就有了充分改造源码的手段。花半个小时写个脚本,把丑陋的面条代码整理成清晰的模块化代码,内心的阴霾一扫而空,对这个古老的项目更是充满了期待——会不会有更多的地方可以被改造被优化呢?值得拭目以待!
在最近的工作中,接手了一个古老的项目,其中的 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
):接着使用
@babel/parser
获取源码的 AST:拿到 AST 以后,就可以使用
@babel/traverse
来遍历它的节点。从上一节的 AST 分析可以知道,我们只需要关注“AssignmentExpression”节点即可:当前节点即为参数
node
,我们需要分析它左右两侧的节点。只有当左侧节点的类型为“MemberExpression”且右侧节点的类型为“FunctionExpression”才需要进入下一步分析(因为形如a = 1
之类的节点也属于 AssignmentExpression 类型,不在我们的处理范围内)。由于 JS 中可能存在不同的 MemberExpression 节点,如
a.b.c = function () {}
,但我们现在只需要处理a.prototype.func
的情况,意味着要盯着关键字prototype
。通过分析 AST 节点,我们知道这个关键字位于左侧节点的object.property.name
属性中:同时对应的函数名则藏在左侧节点的
property.name
属性中:因此便可以很方便地提取出方法名:
可以很方便地把方法名打印出来检查:
现在我们已经分析完左侧节点的代码,提取出了方法名。接下来则是处理右侧节点。由于右侧代码直接就是一个 FunctionExpression 节点,因此我们要做的就是通过
@babel/generator
把该节点转化成 JS 代码,并写入文件。此外,我们也要把原来的代码从
Demo.prototype.func = function () {}
转化成Demo.prototype.func = func
的形式,因此右侧的节点需要从“FuncitionExpression”类型转化成“Identifier”类型,我们可以借助@babel/types
来处理。还有一个事情别忘了,就是我们已经把右侧节点的代码抽离成了 JS 文件,那么我们也应该在最终改造完的源文件里把它们给引入进来,形如
import func1 from './func1'
这种形式,因此可以继续使用@babel/types
的importDeclaration()
函数来生成对应的代码。这个函数参数比较复杂,可以封装成一个函数:只需要传入一个
funcName
,就可以生成一段import funcName from './funcName'
代码。最终整体代码如下:
四、运行脚本
在我们的项目目录中,其结构如下:
运行脚本,
demo/es5code.js
的代码将会被处理,然后输出到output
目录:看看我们的代码:
大功告成!把脚本运用到我们的项目中,甚至可以发现原来的约 3000 行代码,已经被整理成了 300 多行:
放到真实环境去跑一遍这段代码,原有功能不受影响!
小结
刚刚接手这个项目,我的内心是一万头神兽奔腾而过,是非常崩溃的。但是既然接手了,就值得好好对待它。借助 AST 和
@babel
全家桶,我们就有了充分改造源码的手段。花半个小时写个脚本,把丑陋的面条代码整理成清晰的模块化代码,内心的阴霾一扫而空,对这个古老的项目更是充满了期待——会不会有更多的地方可以被改造被优化呢?值得拭目以待!