我在真實項目中使用了AST大法!

NO IMAGE

終於將 AST 用在需求裡辣!

AST 和 我

之前有在 小組裡分享過 AST 的相關概念,以及 recast 庫來操作 AST 樹,
你可以在這看這篇文章

抽象語法樹(AST)

當時分享完覺得很空曠,雖然瞭解了其部分基礎概念,也做了一個小 demo,但還是太過於表面,沒有實際應用,紙上得來終覺淺。恰好最近有兩次機會用上了 AST。
我的體驗是:

真 TMD 爽!

枚舉庫 和 JSDOC 的碰撞

當 組員 整理完 項目的枚舉,並將它封裝為一個庫後,MR 發了過來。

之前項目裡零零散散的枚舉統一由私有庫來維護,再也不用每個項目都維護一份了。

但文檔似乎有點多,好幾十個 js 腳本。於是

我:應該再出一份文檔,告知開發者這些枚舉都是幹啥的,這樣不需要開發者找代碼,而直接調用它。

組員 說,可以,然後調研了下 JSDOC 庫,發現代碼格式不符合 JSDOC 的要求。比如:
備註要像前者,而不是後者:
/** 這是JSDOC可識別的備註 */
/* 這是JSDOC不可識別的備註 */

比如:
導出的類型要像前者,而不是後者:

const applyTypeObj = {
/** 普通投遞 */
NORMAL_APPLY: 0,
/** 一鍵投遞 */
ONE_CLICK_APPLY: 1,
/** 邀請投遞 */
INVITE_APPLY: 2
}
export const applyTypeEnum = Object.freeze(applyTypeObj)
// 普通投遞
export const NORMAL_APPLY = 0
// 一鍵投遞
export const ONE_CLICK_APPLY = 1
// 邀請投遞
export const INVITE_APPLY = 2

有 21 個文件、以及文件裡的大量枚舉 需要這樣處理,你可以算算需要多少人工成本,並且處理的過程是枯燥,乏味,容易出錯的。

你為什麼不問問神奇的 AST 呢

有了上次分享的經驗,這次應該很容易寫出這樣的轉換代碼。
先構思下基本的流程

遞歸讀取項目文件 -> 讀文件 -> AST 操作 -> 寫文件

這個流程核心就是 AST 操作。依然用我們可愛的 recast 庫。

function recastFileName(path, fileName) {
fs.readFile(path, function(err, data) {
// 讀取文件失敗/錯誤
if (err) {
throw err
}
const code = data.toString()
console.log(code)
const ast = recast.parse(code)
let i = 0
// 要做的事情很簡單,把所有 var 定義的 並且值是 Literal 整合起來
const maps = {}
// 各個字段的備註存在這
const markMap = {}
let markDown = ''
recast.visit(ast, {
visitExportNamedDeclaration: function(path) {
const init = path.node.declaration.declarations[0].init
const key = path.node.declaration.declarations[0].id.name
let value = init.value
const type = init.type
if (type === 'UnaryExpression') {
value = eval(`${init.operator}${init.argument.value}`)
}
if (type === 'Literal' || type === 'UnaryExpression') {
maps[key] = value
path.node.comments &&
path.node.comments.map(item => {
markDown = `/**
* Enum for ${fileName}
* ${item.value}
* @enum {number}
*/\n`
})
return null
}
return false
},
visitVariableDeclaration: function(path) {
if (
!path.value.declarations ||
!path.value.declarations[0] ||
!path.value.declarations[0].init.elements
) {
return false
}
console.log('定義')
console.log(path.value.declarations[0].init.elements)
path.value.declarations[0].init.elements.map(element => {
const key = element.properties[0].value.name
const value = element.properties[1].value.value
element.properties[0].value = memberExpression(id(`${fileName}Obj`), id(key))
markMap[key] = value
})
return false
}
})
if (!Object.keys(maps).length) {
console.log('無需轉換')
return
}
let mapString = '{\n'
Object.keys(maps).map((key, index) => {
if (markMap[key]) mapString += `  /** ${markMap[key]} */\n`
if (index === Object.keys(maps).length - 1) {
mapString += `  "${key}": ${maps[key]}\n`
} else {
mapString += `  "${key}": ${maps[key]},\n`
}
})
mapString += '}'
const res = `const ${fileName}Obj = ${mapString}\nexport const ${fileName}Enum = Object.freeze(${fileName}Obj)\n`
const output = res + recast.print(ast).code
const finel = recast.print(recast.parse(output)).code
console.log(finel)
console.log(output)
fs.writeFile(path, `${markDown}\n${finel}`, {}, function() {
console.log(`wirte ${fileName} OK!`)
})
})
}

代碼看起來很懵逼,這裡只講講核心代碼。

const map = []({
// 很容易看出來,這個方法是用來捕捉 export 語句的
visitExportNamedDeclaration: function(path) {
const init = path.node.declaration.declarations[0].init
const key = path.node.declaration.declarations[0].id.name
let value = init.value
const type = init.type
/* 將
export const NORMAL_APPLY = 0
有用的信息拿出來,存進對象裡
maps: { NORMAL_APPLY: 0 }
*/
if (type === 'Literal' || type === 'UnaryExpression') {
maps[key] = value
}
return false
}
})

當我們枚舉都存到 map 對象裡的時候,就可以拼接 map 對象,然後 塞進 代碼文件裡了。

let mapString = '{\n'
Object.keys(maps).map((key, index) => {
if (markMap[key]) mapString += `  /** ${markMap[key]} */\n`
if (index === Object.keys(maps).length - 1) {
mapString += `  "${key}": ${maps[key]}\n`
} else {
mapString += `  "${key}": ${maps[key]},\n`
}
})
mapString += '}'
writeFile(mapString)

當然,還有很多地方需要注意,比如文件頭統一的備註,枚舉的單獨備註。這裡就不贅述了。你可以翻到最上面細讀。

這個需求做的還算順利。

又一個需求

小程序路由參數修改

當我花了半天做完 枚舉庫 的轉化後,內心有點小激動,恰好當前版本有一個需求和上面的需求很像。

小程序項目,路由跳轉是這樣的:

wx.navigateTo({
url: `/pages/resumeOptimize?jobId=${this.jobId}&resume_enhance_source=apply_work_success&workid=${this.jobId}&service_type=resume_optimization`
})

長、醜陋、後期加參數容易出錯。

所以需要寫成這樣。

wx.navigateTo({
url: `/pages/resumeOptimize?${qs.stringify({
jobId: this.jobId,
resume_enhance_source: 'apply_work_success',
workid: this.jobId,
service_type: 'resume_optimization'
})}`
})

優雅,縮進漂亮,容易維護。

再問問神奇的 AST ?

這次的顯然比上次難。首先代碼文件不是 js,而是 .wpy

因為小程序用了 wepy 框架,類 vue 結構。

<template></template>
<script></script>
<style></style>

首先要從這個結構裡單獨將 script 抽出來。當然是用強大的正則了。

function getScript(code) {
let jsReg = /<script>[\s|\S]*?<\/script>/ig;
const scriptColletion = code.match(jsReg)[0].replace(/<script>/, '').replace(/<\/script>/, '');
return scriptColletion
}

很容易拿到了 script 的內容。
假設我們將 ast 操作完成了,要將代碼文件存回去,同樣的。


// 再把script設置回去
function setScript(code, script) {
let jsReg = /<script>[\s|\S]*?<\/script>/ig;
return code.replace(jsReg, `<script>\n${script}</script>`)
}

需求分析

上節只是簡單的實現了代碼的存取,重頭戲終於來了。首先分析下我們要做什麼。

  • 攔截代碼裡的 wx.navigateTowepy.navigateTo,這兩個api相同,開發者都可能調用
  • 將這個 api 的參數,由模版字符串,替換為 qs.stringify 方法調用
  • 如果該文件有第二步操作,並且,文件頭部沒有 import qs from 'qs',需要手動加上

攔截api

api 方法調用顯然是一個 ExpressionStatement,因此我們很簡單的攔截到了它,並且之後的操作都是在 visitExpressionStatement 回調裡做的。

recast.visit(ast, {
visitExpressionStatement: function(path) {
const callee = path.node.expression.callee
if(!callee || !callee.object) {
return false
}
const objName = callee.object.name
const fnName = callee.property.name
// 調用者是wx 或者 wepy
if(objName === 'wx' || objName === 'wepy') {
// 跳轉
if(fnName === 'navigateTo') {
// 攔截到了
}
}
return false
},
}

模版字符串替換

悪夢の起源

這裡是核心功能,它消耗了我半天多時間。
我們位於以上代碼 ‘攔截到了’ 位置。利用 devTool 找到了 wx.navigateTo 的語法樹構成。
舉個🌰:

wepy.navigateTo({
url: `/pages/detail/jobDetail?id=${e.id}&from=job_detail&num=${e.index}&uniqueKey=${uniqueKey}`
})
<pre>
if(fnName === 'navigateTo') {
const argument = path.node.expression.arguments[0]
let {expressions, quasis} = argument.properties[0].value
if(!expressions || !quasis || !expressions.length) {
return false
}
if(expressions.length < 2) {return false}
expressions = expressions.map((val) => {
const res = recast.print(val)
return res.code
})
let url = ''
quasis = quasis.map((val) => {
const path = val.original.value.cooked
if(/\?/.test(path)) {
// 把 url 存下來
url = path.split('?')[0]
return path.split('?')[1]
}
return path
})
}
</pre>

事實上,ast將這行代碼分割成了兩組,一組是 expressions,一組是 quasis,我將這二者打印出來。前者是表達式,後者是字符串。正好 表達式長度 = 字符串長度 – 1。
這符合模版字符串的格式。

["e.id", "e.index", "uniqueKey"]
["id=", "&from=job_detail&num=", "&uniqueKey=", ""]

我需要將這兩個數組拼接起來,並給字符串加上引號。

<pre>
const express = assignArray(quasis, expressions).join('').split('&')
function assignArray(arr2, arr1) {
// 把 arr2裡面的字符串加上引號
arr2 = arr2.map((val) => val.split('&').map((equel) => {
if(!equel || !equel.split('=')[1]) {
return equel
}
const value = '\'' + equel.split('=')[1] + '\''
return [equel.split('=')[0], value].join('=')
}).join('&'))
arr1.forEach((item, index) => {
arr2.splice(2 * (index + 1) - 1, 0, item)
})
return arr2
}
</pre>

最終是這個樣子:
["id=e.id", "from='job_detail'", "num=e.index", "uniqueKey=uniqueKey"]
接下來,將上面的數組轉化成函數的參數即可


const results = giveQsString(express, url)
function giveQsString(expressArr, url) {
let str = `url: \`${url}?\${qs.stringify({\n`
expressArr.map((val, index) => {
const [key, value] = val.split('=')
if(index === expressArr.length - 1)
str += `  ${key}: ${value}\n`
else
str += `  ${key}: ${value},\n`
})
str += `})}\``
console.log(str)
return str
}

最終是這個樣子的。

/*
url: `/pages/detail/jobDetail?${qs.stringify({
id: e.id,
from: 'job_detail',
num: e.index,
uniqueKey: uniqueKey
})}`
*/

最重要的一步,將該參數,填到 navigate 的方法中


path.node.expression.arguments[0].properties[0] = templateElement({ 
"cooked": results, "raw": results 
}, false)

就這樣,核心的ast就完成了。

加上 qs

如果該文件進行了以上步驟,那它就需要進行 import qs,這裡只需要對 ImportDeclaration 進行判斷,如果沒有導入 qs 模塊,則告訴下游,在文件頭部追加 import 語句。

visitImportDeclaration: function(path) {
// 如果模塊引入了qs,則不需要導入
if(path.node.source.value === 'qs') {
needQs = false
}
return false
}

總結

用我週報上的話來總結吧

這周的兩個需求都用上了AST,也是第一次將AST用在了實際項目中,兩個需求產生的效果也不同。
代碼結構和註釋變更:這個庫的代碼文件格式比較統一,文件數較多,不太適合手動一個個改寫,用AST做減少了不少時間,且難度不高

路由參數走qs: 這個庫的代碼格式是wepy類型,做起來難度比較高,花了一整天的時間寫AST代碼,最終也實現了全局路由的替換,替換後發現需要替換的文件並不多,所以這是一個反例。
因此,在決定用AST前一定要先調研是否適合用,我認為需要滿足以下兩個條件:
1,ast 代碼容易寫。
2,重複的工作量大

AST 牛逼!

相關文章

雲原生基礎及調研

一文搞懂V8引擎的垃圾回收

精讀《正交的React組件》

如何封裝一個flutter的多語言plugin