這篇文章適合在原理性知識不通的情況下,仍然對 ast 蠢蠢欲動的開發者們,文章不具備任何專業性以及嚴謹性,它除了實用,可能一無是處。
關於 AST 的介紹,網上已經一大堆了,不僅生澀難懂,還自帶一秒勸退屬性。其實我們可以很(hao)接(bu)地(yan)氣(jin)的去了解一個看上去高端大氣的東西,比如,AST 是一個將代碼解構成一棵可以千變萬化的樹的黑魔法。所以,只要我們知道咒語怎麼念,世界的大門就打開了。有趣的是,魔法咒語長得像 jQuery~
歡迎你,魔法師在成為一名魔法師之前,我們需要準備四樣東西:趁手的工具、又簡短又常用的使用技巧,即使看不懂也不影響使用的權威 api、 以及天馬行空的想像力。
🍭 魔法棒 之 趁手的工具🔗 AST exporer這是一個 ast 在線調試工具,有了它,我們可以非常直觀的看到 ast 生成前後以及代碼轉換,它分五個區域。我們接下來都依賴這個工具進行代碼操作。
🔗 jscodeshift它是一個 ast 轉換器,我們通過它來將原始代碼轉譯成 ast 語法樹,並借用其開放的 api 操作 ast,最終轉換成我們想要的代碼。
jscodeshift 的 api 基於 recast 封裝,語法十分接近 jquery。recast 是對 babel/travers & babel/types 的封裝,它提供簡易的 ast 操作,而 travers 是 babel 中用於操作 ast 的工具,types 我們可以粗淺的理解為字典,它用於描述結構樹類型。
同時,jscodeshift 還提供額外的功能,使得開發者們能夠在項目工程階段、亦或開發階段皆可投入使用,同時無需感知 babel 轉譯前後的過程,只專注於如何操作或改變樹,並得到結果。
儘管 jscodeshift 缺少中文文檔,但其源碼可讀性非常高,這也是為什麼推薦使用 jscodeshift 的重要原因之一。關於其 api 操作技巧,將在實踐中為大家揭曉。
📖 魔法書 之 權威 api🔗 babel-typesast 語法字典,方便我們快速查閱結構樹的類型,它是我們想要通過 ast 生成某行代碼時的重要工具之一。
認識 AST我以為的 AST假如我們有這樣一份代碼
var a = 1
複製代碼我們將其轉化為 AST,以 JSON 格式展示如下
{
"type": "Program",
"sourceType": "script",
"body": [
{
"type": "VariableDeclaration",
"kind": "var",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1
}
}
]
}
]
}
複製代碼當我操作對象 init 中 value 的值 1 改為 2 時,對應的 js 也會跟著改變為 var a = 2 當我操作對象 id 中的 name 的值 a 改為 b 時, 對應的 js 也會跟著改變為 var b = 2
看到這裡,突然發現,操作 AST 無非就是操作一組有規則的 JSON,發現新大陸有木有??那麼只要明白規則,是不是很快就可以掌握一個世界了有!木!有!
了解 AST 節點探索 AST 節點類型
常用節點含義對照表 看了規則後瞬間明白 ast 的 json 中那些看不懂的 type 是個什麼玩意了 (詳細可對照 babel-types),真的就是描述語法的詞彙罷了!原來掌握一個世界竟然可以這麼簡!單!
jscodeshift 簡易操作查找api類型接收參數描述findfntype:ast 類型
找到所有符合篩選條件的 ast 類型的 ast 節點,並返回一個 array。
filterfncallback:接受一個回調,默認傳遞被調用的 ast 節點篩選指定條件的 ast 節點,並返回一個 arrayforEachfncallback:接受一個回調,默認傳遞被調用的 ast 節點遍歷 ast 節點,同 js 的 forEach 函數除此之外, 還有 some、every、closest 等用法基本一致。
刪除api類型接收參數描述removefntype:ast 類型
filter:篩選條件找到所有符合篩選條件的 ast 類型的 ast 節點,並返回一個 array。
添加 & 修改api類型接收參數描述replaceWithfnnodes:ast 節點替換 ast 節點,如果為空則表示刪除insertBeforefnfnnodes:ast 節點insertAfterfnfnnodes:ast 節點toSourcefnoptions: 配置項ast 節點轉譯,返回 js除此之外, 還有 some、every、closest 等用法基本一致。
其它子節點相關操作如 getAST()、nodes() 等。指定 ast 節點的查找,如:findJSXElements()、hasAttributes()、hasChildren() 等。
更多可通過 ast explore 在操作區 console 查看、或直接查看 jscodeshift/collections
命令// -t 轉換文件的文件路徑 可以是本地或者url
// myTransforms ast執行文件
// fileA fileB 待操作的文件
// --params=options 用於執行文件接收的參數
jscodeshift -t myTransforms fileA fileB --params=options
複製代碼更多命令查看 🔗 jscodeshift
實踐接下來,我將在實踐中傳遞技巧。
簡單的例子我們先來看一個例子,假設有如下代碼
import * as React from 'react';
import styles from './index.module.scss';
import { Button } from "@alifd/next";
const Button = () => {
return (
<div>
<h2>轉譯前</h2>
<div>
<Button type="normal">Normal</Button>
<Button type="primary">Prirmary</Button>
<Button type="secondary">Secondary</Button>
<Button type="normal" text>Normal</Button>
<Button type="primary" text>Primary</Button>
<Button type="secondary" text>Secondary</Button>
<Button type="normal" warning>Normal</Button>
</div>
</div>
);
};
export default Button;
複製代碼執行文件(通過 jscodeshift 進行操作)
module.exports = (file, api) => {
const j = api.jscodeshift;
const root = j(file.source);
root
.find(j.ImportDeclaration, { source: { value: "@alifd/next" } })
.forEach((path) => {
path.node.source.value = "antd";
})
root
.find(j.JSXElement, {openingElement: { name: { name: 'h2' } }})
.forEach((path) => {
path.node.children = [j.jsxText('轉譯後')]
})
root
.find(j.JSXOpeningElement, { name: { name: 'Button' } })
.find(j.JSXAttribute)
.forEach((path) => {
const attr = path.node.name
const attrVal = ((path.node.value || {}).expression || {}).value ? path.node.value.expression : path.node.value
if (attr.name === "type") {
if (attrVal.value === 'normal') {
attrVal.value = 'default'
}
}
if (attr.name === "size") {
if (attrVal.value === 'medium') {
attrVal.value = 'middle'
}
}
if (attr.name === "warning") {
attr.name = 'danger'
}
if (attr.name === "text") {
const attrType = path.parentPath.value.filter(item => item.name.name === 'type')
attr.name = 'type'
if (attrType.length) {
attrType[0].value.value = 'link'
j(path).replaceWith('')
} else {
path.node.value = j.stringLiteral('link')
}
}
});
return root.toSource();
}
複製代碼該例代碼大致解讀如下
遍歷代碼中所有包含 @alifd/next 的引用模塊,並做如下操作找到代碼中標籤名為 h2 的代碼塊,並修改該標籤內的文案。改變標籤中 text 屬性變為 type = "link"最終輸出結果
import * as React from 'react';
import styles from './index.module.scss';
import { Button } from "antd";
const Button = () => {
return (
<div>
<h2>轉譯後</h2>
<div>
<Button type="default">Normal</Button>
<Button type="primary">Prirmary</Button>
<Button type="secondary">Secondary</Button>
<Button type="link" >Normal</Button>
<Button type="link" >Primary</Button>
<Button type="link" >Secondary</Button>
<Button type="default" danger>Normal</Button>
</div>
</div>
);
};
export default Button;
複製代碼
逐句解讀獲取必要的數據// 獲取操作ast用的api,獲取待編譯的文件主體內容,並轉換為AST結構。
const j = api.jscodeshift;
const root = j(file.source);
複製代碼執行 jscodeshift 命令後,執行文件接收 3 個參數
file屬性描述path文件路徑source待操作的文件主體,我們主要用到這個。api屬性描述jscodeshift對 jscodeshift 庫的引用,我們主要用到這個。stats --dry 運行期間收集統計信息的功能report將傳遞的字符串列印到 stdoutoptions執行 jscodeshift 命令時,接收額外傳入的參數,目前用不到,不做額外贅述。
代碼轉換// root: 被轉換後的ast跟節點
root
// ImportDeclaration 對應 import 句式
.find(j.ImportDeclaration, { source: { value: "@alifd/next" } })
.forEach((path) => {
// path.node 為import句式對應的ast節點
path.node.source.value = "antd";
})
複製代碼解讀:
遍歷代碼中所有包含 @alifd/next 的引用模塊,並做如下操作root
// JSXElement 對應 element 完整句式,如 <h2 ...> ... </h2>
// openingElement 對應 element 的 開放標籤句式, 如 <h2 ...>
.find(j.JSXElement, {openingElement: { name: { name: 'h2' } }})
.forEach((path) => {
// jsxText 對應 text
path.node.children = [j.jsxText('轉譯後')]
})
複製代碼解讀:
篩選標籤為 h2 的 html,更改該標籤的內容的 text 為 「轉譯後」root
// 篩選Button的 element開放句式
.find(j.JSXOpeningElement, { name: { name: 'Button' } })
// JSXAttribute 對應 element 的 attribute 句式, 如 type="normal" ...
.find(j.JSXAttribute)
.forEach((path) => {
const attr = path.node.name
const attrVal = ((path.node.value || {}).expression || {}).value ? path.node.value.expression : path.node.value
if (attr.name === "type") {
if (attrVal.value === 'normal') {
attrVal.value = 'default'
}
}
if (attr.name === "size") {
if (attrVal.value === 'medium') {
attrVal.value = 'middle'
}
}
if (attr.name === "warning") {
attr.name = 'danger'
}
if (attr.name === "text") {
// 判斷該ast節點的兄弟節點是否存在 type,
// 如果有,則修改type的值為link,如果沒有則改變當前節點為type=「link」
const attrType = path.parentPath.value.filter(item => item.name.name === 'type')
attr.name = 'type'
if (attrType.length) {
attrType[0].value.value = 'link'
j(path).replaceWith('')
} else {
// stringLiteral 對應 string類型欄位值
path.node.value = j.stringLiteral('link')
}
}
});
複製代碼解讀:
改變標籤中 text 屬性變為 type = "link"return root.toSource();
複製代碼解讀:
天馬行空的想像力來自於 「懶」假如我們想插入一大段代碼,按照 ast 的寫法就得使用大量的 type 生成大量的節點對象,如此繁瑣,大可不必,萬事總有暴力解決法 🌝。
const formRef = j('const formRef = React.createRef();').nodes()[0].program.body[0]
path.insertAfter(formRef)
複製代碼假如我們想句式轉換,比如 element 的 text 句式轉 attr 標籤。
const getStringEle = (source) => {
if (Array.isArray(source)) {
let arr = []
source.forEach((item, i, items) => {
if (!item.replace(/\s+|\n/g, '').length && i!==0 && i!== (items.length - 1 )){
arr.push('<></>')
}
arr.push(item)
})
return arr.join('')
} else {
return source
}
}
...
.find(j.JSXAttribute)
.forEach(path => {
const attrVal = ((path.node.value || {}).expression || {}).value ? path.node.value.expression : path.node.value
const childrenEleStr = getStringEle(j(path).toSource())
j(path).replaceWith(j.jsxIdentifier(
`attr={[${childrenEleStr.replace(/<><\/>/g, ',')}]}`
))
})
複製代碼掌握更多的鏈式寫法,就能玩出更多的花樣~ 這點和 jQuery 如出一轍。
讓文件結合工程 run 起來以上我們都基於 ast exporer,並不能實用於項目場景,或者滿足工程需要。真實的工程化場景,並不滿足於一份文件,如果想讓 ast 工程化,真正的落實在項目中,利用 ast 重構業務代碼,解放重複的勞動力,以下是一個很好的解決思路。
以下基於 node,我推薦兩個工具
npx & execa利用 npx 實現一個複雜命令,來創建一個簡易 cli。通過 execa 批量執行 jscodeshift。
關鍵代碼如下
package.json"bin": {
"ast-cli": "bin/index.js"
},
複製代碼
index.js#! /usr/bin/env node
require('./cli').main()
複製代碼
main()...
const path = require('path')
const execa = require('execa');
const jscodeshiftBin = require.resolve('.bin/jscodeshift');
module.exports.main = async () => {
...
const astFilesPath = ...
astFilesPath.forEach(async (transferPath, i) => {
const outdrr = await execa.sync(jscodeshiftBin, ['-t', transferPath, src])
if (outdrr.failed) {
console.log(`編譯出錯: ${outdrr}`)
}
})
...
}
...
複製代碼