在最近的工做中,接手了一個古老的項目,其中的 JS 代碼是一整坨的麪條代碼,約 3000 行的代碼全寫在一個文件裏,維護起來着實讓人頭疼。javascript
想不通爲啥以前維護項目的同窗可以忍受這麼難以維護的代碼……既然如今這個鍋被我拿下了,怎麼着也不能容忍如此醜陋的代碼繼續存在着,必須把它優化一下。java
橫豎看了半天,因爲邏輯都揉在了一個文件裏,看都看得眼花繚亂,當務之急即是把它進行模塊化拆分,把這一大坨麪條狀代碼拆分紅一個個模塊並抽離成文件,這樣才方便後續的持續優化。node
說幹就幹,既然要拆分紅模塊,首先就要分析源碼的結構。雖然源碼內容很長很複雜,但萬幸的是它仍是有一個清晰的結構,簡化一下,就是下面這種形式:es6
很容易看出,這是一種 ES5 時代的經典代碼組織方式,在一個 IIFE 裏面放一個構造函數,在構造函數的 protorype
上掛載不一樣的方法,以實現不一樣的功能。既然代碼結構是清晰的,那麼咱們要作模塊化的思路也很清晰,就是想辦法把全部綁定在構造函數的 prototype
上的方法抽離出來,以模塊文件的形式放置,而源碼則使用 ES6 的 import
語句把模塊引入進來,完成代碼的模塊化:json
爲了完成這個效果,咱們能夠藉助 @babel
全家桶來構造咱們的轉化腳本。babel
關於 AST 的相關資料一搜一大堆,在這裏就不贅述了。在本文中,咱們會藉助 AST 去分析源碼,挑選源碼中須要被抽離、改造的部分,所以 AST 能夠說是本文的核心。在 https://astexplorer.net/ 這個網站,咱們能夠貼入示例代碼,在線查看它的 AST 長什麼樣:ide
從右側的 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
全家桶,咱們就有了充分改造源碼的手段。花半個小時個腳本,把醜陋的麪條代碼整理成清晰的模塊化代碼,心裏的陰霾一掃而空,對這個古老的項目更是充滿了期待——會不會有更多的地方能夠被改造被優化呢?值得拭目以待!