使用 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 全家桶,咱們就有了充分改造源碼的手段。花半個小時個腳本,把醜陋的麪條代碼整理成清晰的模塊化代碼,心裏的陰霾一掃而空,對這個古老的項目更是充滿了期待——會不會有更多的地方能夠被改造被優化呢?值得拭目以待!

相關文章
相關標籤/搜索