這樣入門 js 抽象語法樹(AST),從此我來到了一個新世界
契機
最近在搭建一個開源的項目環(huán)境時,我需要打一個 ES 模塊的包,以便開發(fā)者可以直接通過 npm 就能安裝并使用,但是這個項目注定了會有樣式,而且我希望打出的包的文件目錄和我開發(fā)目錄是一致的,似乎 Rollup 是一個不錯的選擇,但是我(自虐般地)選擇了 Typescript 自帶的編譯器 tsc ,然后我就開始我的填坑之旅~
tsc 遇到的坑
在使用 tsc 編譯我的代碼時,對我目前來說,有三個基本的坑,下面我會對它們進行簡單的闡述,在此之前看下即將被編譯的目錄結構。
|-- src
|-- assets
|-- test.png
|-- util
|-- classnames.ts
|-- index.tsx
|-- index.scss
簡化引用路徑問題
首先我是在 tsconfig.json 中寫了簡化引用路徑配置的,比如針對以上目錄,我是這樣:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@Src/*": ["src/*"],
"@Utils/*": ["src/utils/*"],
"@Assets/*": ["src/assets/*"]
}
}
}
那么無論我層級多深時,我要是想引用 util 或 assets 里面的文件模塊、資源就會特別方便,比如我在 index.tsx 文件中這樣引入:編譯前:
import classNames from "@Utils/classnames";
import testPNG from "@Assets/test.png";
編譯后(預期 ??):
import classNames from "./util/classnames";
import testPNG from "./assets/test.png";
然而實際編譯后的結果令我大失所望, tsc 既然連這個都不支持轉譯??!它編譯之后的代碼還是老樣子,于是我就去找官網查,發(fā)現(xiàn)也沒有這個相關的配置項,于是跑到外網查了下發(fā)現(xiàn)有人是和我遇到了相同的問題的,它提供了一個解決方案就是,使用這個插件 tscpaths[1] 并在編譯后多加一段 npm 命令即可:
"scripts": {
"build": "tsc -p tsconfig.json && tscpaths -p tsconfig.json -s src -o dist,
},
當執(zhí)行到這個命令時:
tscpaths -p tsconfig.json -s src -o dist
這個插件會去遍歷每一個我們已經由 tsc 編譯之后的 .js 文件,將我們簡化的引用路徑轉為相對路徑,大功告成~
靜態(tài)資源未打包問題
如上所示,如果我在 index.tsx 文件中引入一個放在 assets 的圖片資源:
import testPNG from "@Assets/test.png";
在經過 tsc 編譯之后,而且在使用我們的命令行工具之后,我們的引用路徑是對了,但是一看打包出來的目錄中,是不會出現(xiàn) assets 這個資源文件夾的,其實這也正常,畢竟 tsc 也僅僅是個 Typescript 的編譯器,要實現(xiàn)其它的打包功能,要靠自己動手!解決問題的辦法就是使用 copyfiles[2] 命令行工具,它和上面我們介紹的插件一樣,都是在 tsc 編譯之后,做一些額外操作達到我們想要的目的。就像它的名字一樣,它就是拿來復制文件的~我們在 npm scripts 下的 build 命令后面再加上這個:
copyfiles -f src/assets/* dist/assets
這樣就能把資源文件夾復制到打包之后的文件目錄下了。
引入樣式文件后綴名問題
我們做一個項目時在所難免會用到 sass 或 less ,本項目就選擇了 sass ,我在 index.tsx 中引入樣式文件方式如下:
import "./index.scss";
但是在 tsc 編譯為 .js 文件之后,打開 index.js 發(fā)現(xiàn)引入的樣式后綴還是 .scss 。作為給別的開發(fā)者使用的包,一定是要引入 .css 文件的格式的,你不可能確定別人用的都是 sass ,所以我又去網上找解決方案,發(fā)現(xiàn)很少有人提這個問題,而且也沒有找到可以用的插件什么的。就在一籌莫展之時,我突然想到,臥槽,這不就是類似于上面提到的 tscpaths 這個工具嗎,也是在文件內做字符串替換,太像了!于是我趕緊下載了它的源碼,看了下大概是使用 node 讀取了 tsconfig.json 中 bathUrl 和 paths 配置,以及用戶自定義的入口、出口路徑來找到 .js 文件,分析成相對路徑之后再正則匹配到對應的引用路徑去替換掉!立馬有了思路準備實踐,突然想到全局正則匹配做替換的局限性,比如在開發(fā)者代碼中也寫了與引用一樣的代碼(這種情況基本不可能發(fā)生,但是仍要考慮),那不是把人家的邏輯代碼都改了嗎?比如以下代碼:
import React from "react";
import "./index.scss";
const Tool = () => {
return (
<div>
<p>You should import style file like this:</p>
<p>import './index.scss'</p>
</div>
);
};
怎么辦,你做全局替換,是會替換掉別人邏輯源代碼的。。當然,可以寫更好的查找算法(或正則)來精確替換,但是無形中考慮的情況就非常多了;我們有沒有更好的實現(xiàn)方式呢?這時候我想到了抽象語法樹(AST)。注意 ??:另外要說一下, tsc 也不會編譯 .scss 文件的,它需要 node-sass 來將每個 .scss 文件編譯到對應打包目錄,在 tsc 編譯之后,再執(zhí)行以下命令即可:
"build-css": "node-sass -r src -o dist",
AST 是什么?
如果你了解或者使用過 ESLint 、Babel 及 Webpack 這類工具,那么恭喜你,你已經對 AST 的強大之處有了最直觀的了解了,比如 ESLint 是怎么修復你的代碼的呢?看下面不太嚴謹?shù)膱D:
不嚴謹?shù)恼Z言描述就是,eslint 將當前的 js 代碼解析成了一個抽象語法樹,在這棵樹上做了一些修整,比如剪掉一條樹枝,就是去除代碼中多出的空格 space ;比如修整了一條樹枝,就是 var 轉換為 const 等。修整完之后再轉換為我們的 js 代碼!這個樹中的每條“枝”都代表了 js 代碼中的某個字段的描述對象,比如以下簡單的代碼:
const a = 1;
我們先自己定制一套簡單的轉換為 AST 語法規(guī)則,可以這樣表示上面這行代碼:
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
]
}
是的,這就是一顆簡易的抽象語法樹了,就這么簡單,它只是一種特殊的對象結構來表示我們的 js 代碼而已,如果我們有一個手段,能拿到表示 1 這個值的節(jié)點,并將 init.value 改為 2 ,再將該語法樹轉換為 js 源碼,那就能得到:
const a = 2;
那么上面說的“轉換”規(guī)則是不用我們自己去寫的,隨著 JavaScript 語言的發(fā)展,由一些大佬創(chuàng)建的項目 ESTree[3] 用于更新 AST 規(guī)則,目前已成為社區(qū)標準。然后社區(qū)中一些其它項目比如 ESlint 和 Babel 就會使用 ESTree 或在此基礎上做一些修改,然后衍生出自己的一套規(guī)則,并制作相應的轉換工具,暴露出一些 API 給開發(fā)者使用。
搭配工具
因為生成的 AST 結構上看起來是特別繁雜的,如果沒有好用工具或文檔,學習時或寫代碼時會很困擾,那么接下來就給大家介紹三個利器。
在線調試工具 AST Explorer
這是一個非常棒的網站,只需要將你現(xiàn)在的 js 代碼輸入進去,即可查看轉換后的 AST 結構。
有了這個網站你就能實時地去查看解析之后的 AST 是什么樣子的,以及它們的類型是什么,這在之后寫代碼去對 AST 做修改特別有用!因為你可以明確自己想要修改的地方是哪里。
比如上圖中,我們想要修改 1 為 2 ,我們通過某個工具去找到這個 AST 中的 type 為 Literal 這個節(jié)點,將其 value 設為 2 ,再轉換為 js 代碼就實現(xiàn)了這個需求。類似的工具是很多的,我們就選用 Facebook 官方的開源工具:jscodeshift[4]
AST 轉換工具 jscodeshift
jscodeshift 是基于 recast[5] 封裝的一個庫,相比于 recast 不友好的 api 設計,jscodeshift 將其封裝并暴露出對 js 開發(fā)者來說更為友好的 api,讓我們在操作修改 AST 的時候更加方便。我建議大家先知道這個工具就行,具體的 api 使用我下面會跟大家挑幾個典型的說一說,有個具體的印象就行,說實話,這個庫的文檔寫的并不好,也不適合初學者閱讀,特別是英語還不好的人。當你使用過它的一些 api 后有了直觀的感覺,再去閱讀也不遲~
AST 類型大全 @babel/types
這是一本 AST 類型詞典,如果我們想要生成一些新的代碼,也就是要生成一些新的節(jié)點,按照語法規(guī)則,你必須將你要添加的節(jié)點類型按照規(guī)范傳入,比如 const 的類型就為 type: VariableDeclaration ,當然了, type 只是一個節(jié)點的一個屬性而已,還有其他的,你都可以在這里面查閱到。下面是常用的節(jié)點類型含義對照表,更多的類型大家可以細看 @babel/types[6]:
| 類型名稱 | 中文譯名 | 描述 |
|---|---|---|
| Program | 程序主體 | 整段代碼的主體 |
| VariableDeclaration | 變量聲明 | 聲明變量,比如 let const var |
| FunctionDeclaration | 函數(shù)聲明 | 聲明函數(shù),比如 function |
| ExpressionStatement | 表達式語句 | 通常為調用一個函數(shù),比如 console.log(1) |
| BlockStatement | 塊語句 | 包裹在 {} 內的語句,比如 if (true) { console.log(1) } |
| BreakStatement | 中斷語句 | 通常指 break |
| ContinueStatement | 持續(xù)語句 | 通常指 continue |
| ReturnStatement | 返回語句 | 通常指 return |
| SwitchStatement | Switch 語句 | 通常指 switch |
| IfStatement | If 控制流語句 | 通常指 if (true) {} else {} |
| Identifier | 標識符 | 標識,比如聲明變量語句中 const a = 1 中的 a |
| ArrayExpression | 數(shù)組表達式 | 通常指一個數(shù)組,比如 [1, 2, 3] |
| StringLiteral | 字符型字面量 | 通常指字符串類型的字面量,比如 const a = '1' 中的 '1' |
| NumericLiteral | 數(shù)字型字面量 | 通常指數(shù)字類型的字面量,比如 const a = 1 中的 1 |
| ImportDeclaration | 引入聲明 | 聲明引入,比如 import |
AST 節(jié)點的增刪改查
上面說到了 jscodeshift 的 api 設計的是比較友好的,那么我們就以一個樹的增刪改查來簡單地帶大家了解一下,不過在這之前需要先搭建一個簡單的開發(fā)環(huán)境。
開發(fā)環(huán)境
第一步:創(chuàng)建一個項目文件夾
mkdir ast-demo
cd ast-demo
第二步:項目初始化
npm init -y
第三步:安裝 jscodeshift
npm install jscodeshift --save
第四步:新建 4 個 js 文件,分別對應增刪該查。
touch create.js delete.js update.js find.js
第五步:在做以下事例時,請大家打開 AST Explorer[7] ,把要轉換的 value 都復制進來看看它的樹結構,以便更好地理解。
查找節(jié)點
find.js :
const jf = require("jscodeshift");
const value = `
import React from 'react';
import { Button } from 'antd';
`;
const root = jf(value);
root
.find(jf.ImportDeclaration, { source: { value: "antd" } })
.forEach((path) => {
console.log(path.node.source.value);
});
在控制臺執(zhí)行以下命令:
node find.js
然后你就能看到控制臺打印了 antd 。在此說明一下,上面代碼中定義的 value 字符串就是我們要操作的文本內容,實際應用中我們一般都是讀取文件,然后做處理。在上面的 .find 函數(shù)中,第一個參數(shù)為要查找的類型,第二個參數(shù)為查詢條件,如果你將上面的 value 復制到 AST Explorer[8] 上看看,你就知道這個查詢條件為什么是這種結構了。
修改節(jié)點
update.js :
const jf = require("jscodeshift");
const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;
const root = jf(value);
root
.find(jf.ImportDeclaration, { source: { value: "antd" } })
.forEach((path) => {
const { specifiers } = path.node;
specifiers.forEach((spec) => {
if (spec.imported.name === "Button") {
spec.imported.name = "Select";
}
});
});
console.log(root.toSource());
上面的代碼目的是將從 antd 引入的 Button 改為 Input ,為了很精確地定位在這一行,我們先通過 ImportDeclaration 和條件參數(shù)去找到,在向內找到 Button 這個節(jié)點,簡單的判斷之后就可以做修改了。你能看到最后一行我們執(zhí)行了 toSource() ,該方法就是將 AST 轉回為我們的源碼,控制臺打印如下:
import React from "react";
import { Select, Input } from "antd"; // 可以看到 Button 已被精確地替換為了 Select
增加節(jié)點
create.js :
const jf = require("jscodeshift");
const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;
const root = jf(value);
root
.find(jf.ImportDeclaration, { source: { value: "antd" } })
.forEach((path) => {
const { specifiers } = path.node;
specifiers.push(jf.importSpecifier(jf.identifier("Select")));
});
console.log(root.toSource());
上面代碼首先仍然是找到 antd 那行,然后在 specifiers 這個數(shù)組的最后一位添加一個新的節(jié)點,表現(xiàn)在轉換后的 js 代碼上就是,新增了一個 Select 的引入:
import React from "react";
import { Button, Input, Select } from "antd"; // 可以看到引入了 Select
刪除節(jié)點
delete.js :
const jf = require("jscodeshift");
const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;
const root = jf(value);
root
.find(jf.ImportDeclaration, { source: { value: "antd" } })
.forEach((path) => {
jf(path).replaceWith("");
});
console.log(root.toSource());
刪除引入 antd 一整行,就是這么簡單。
更多 API
上面所實現(xiàn)的增刪改查其實都是多種實現(xiàn)方式中的一種而已,只要你對 API 很熟練,或者腦洞夠大,那可就誰也攔不住了~這里我只想說,去官方的 collection[9] 及 extensions[10] 看看你就知道有哪些 API 了,然后多嘗試、多動手,總會實現(xiàn)你想要的效果的。
實戰(zhàn)解析
技術為需求服務。
明確需求
在對 jscodeshift 有了初步了解之后,我們接下來做一個命令行工具來解決我在上面提出的“引入樣式文件后綴名問題”,接下來會簡單使用到 commander[11] ,它使 nodejs 命令行接口變得更簡單~我再次明確下我目前的需求:**由 tsc 編譯之后的目錄,比如 dist ,我要將里面生成的所有 js 文件中關于樣式文件的引入,比如 import './style.scss' ,全部轉換成以 .css 為后綴的方式。**該命令行工具我給它命名為:tsccss。
搭建環(huán)境
就像上面一樣,我們先初始化項目,因為演示為主,所以我們就不使用 Typescript 了,就寫原生 nodejs 原生模塊寫法,如果對項目要求較高的,也可以加上 ESLint 、 Prettier 等規(guī)范代碼的工具,如果大家有興趣,可以前往我在 github 上已經寫好了的這個命令行工具 tsccss[12] ,可以做個參考。好的,現(xiàn)在我們一氣呵成,按下面步驟來:
# 創(chuàng)建項目目錄
mkdir tsccss
cd tsccss
# 初始化
npm init -y
# 安裝依賴包
npm i commander globby jscodeshift --save
# 創(chuàng)建入口文件
mkdir src
cd src
touch index.js
現(xiàn)在目錄如下:
|-- node_modules
|-- src
|-- index.js
|-- package.json
接下來在 package.json 中找個位置加入以下代碼:
{
"main": "src/index.js",
"bin": {
"tsccss": "src/index.js"
},
"files": ["src"]
}
其中 bin 字段很重要,在其他開發(fā)者下載了你這個包之后,人家在 tsccss xxxxxx 時就會以 node 執(zhí)行后面配置的文件,即 src/index.js ,當然,我們的 index.js 還要在最頂部加上這行代碼:
#! /usr/bin/env node
這句代碼解決了不同的用戶 node 路徑不同的問題,可以讓系統(tǒng)動態(tài)的去查找 node 來執(zhí)行你的腳本文件。
使用 commander
直接在 index.js 中加入以下代碼:
const { program } = require("commander");
program.version("0.0.1").option("-o, --out <path>", "output root path");
program.on("--help", () => {
console.log(`
You can add the following commands to npm scripts:
------------------------------------------------------
"compile": "tsccss -o dist"
------------------------------------------------------
`);
});
program.parse(process.argv);
const { out } = program.opts();
console.log(out);
if (!out) {
throw new Error("--out must be specified");
}
接下來在項目根目錄下,執(zhí)行以下控制臺命令:
node src/index.js -o dist
你會發(fā)現(xiàn)控制臺打印了 dist ,是的,就是 -o dist 的作用,簡單介紹下 version 和 option 。
version
作用:定義命令程序的版本號;
用法示例:.version('0.0.1', '-v, --version') ;
參數(shù)解析:
第一個參數(shù),版本號 <必須>; 第二個參數(shù),自定義標志 <可省略>,默認為 -V 和 --version。
option
作用:用于定義命令選項;
用法示例:.option('-n, --name ', 'edit your name', 'vortesnail');
參數(shù)解析:
第一個參數(shù),自定義標志 <必須>,分為長短標識,中間用逗號、豎線或者空格分割;(標志后面可跟參數(shù),可以用 <> 或者 [] 修飾,前者意為必須參數(shù),后者意為可選參數(shù)) 第二個參數(shù),選項描述 <省略不報錯>,在使用 --help 命令時顯示標志描述; 第三個參數(shù),選項參數(shù)默認值,可選。
所以大家還可以試試這兩個命令:
node src/index.js --version
node src/index.js --help
讀取 dist 下 js 文件
dist 目錄是假定我們要去做樣式文件后綴名替換的文件根目錄,現(xiàn)在需要使用 globby 工具自動讀取該目錄下的所有 js 文件路徑,在頂部需要引入兩個函數(shù):
const { resolve } = require("path");
const { sync } = require("globby");
然后在下面繼續(xù)追加代碼:
const outRoot = resolve(process.cwd(), out);
console.log(`tsccss --out ${outRoot}`);
// Read output files
const files = sync(`${outRoot}/**/!(*.d).{ts,tsx,js,jsx}`, {
dot: true,
}).map((x) => resolve(x));
console.log(files);
files 即 dist 目錄下所有 js 文件路徑,我們故意在該目錄下新建幾個任意的 js 文件,再執(zhí)行下 node src/index.js -o dist ,看看控制臺是不是正確打印出了這些文件的絕對路徑。
編寫替換方法
因為有了前面的增刪改查的鋪墊,其實現(xiàn)在這一步已經很簡單了,思路就是:
找到所有類型為 ImportDeclaration的節(jié)點;運用正則判斷該節(jié)點的 source.value是否以.scss或.less結尾;若正則匹配到了,我們就運用正則的一些用法將其后綴替換為 .css。
就這么簡單,我們直接引入 jscodeshift :
const jscodeshift = require("jscodeshift");
然后追加以下代碼:
function transToCSS(str) {
const jf = jscodeshift;
const root = jf(str);
root.find(jf.ImportDeclaration).forEach((path) => {
let value = "";
if (path && path.node && path.node.source) {
value = path.node.source.value;
}
const regex = /(scss|less)('|"|`)?$/i;
if (value && regex.test(value.toString())) {
path.node.source.value = value
.toString()
.replace(regex, (_res, _$1, $2) => ($2 ? `css${$2}` : "css"));
}
});
return root.toSource();
}
可以看到,該方法直接返回了轉換后的 js 代碼,是可以直接寫入源文件的內容。
讀寫文件
拿到文件路徑 files 后,需要 node 原生模塊 fs 來幫助我們讀寫文件,這部分代碼很簡單,思路就是:讀 js 文件,將文件內容轉換為 AST 做節(jié)點值替換,再轉為 js 代碼,最后寫回該文件,就 OK 了。
const { readFileSync, writeFileSync } = require("fs");
// ...
const filesLen = files.length;
for (let i = 0; i < filesLen; i += 1) {
const file = files[i];
const content = readFileSync(file, "utf-8");
const resContent = transToCSS(content);
writeFileSync(file, resContent, "utf8");
}
現(xiàn)在你到 dist 目錄下的 index1.js 、 index2.js 文件中,隨便輸入以下內容,以便查看效果:
import "style.scss";
import "style.less";
import "style.css";
然后最后一次執(zhí)行我們的命令:
node src/index.js -o dist
再看剛才的 index1.js 或 index2.js ,是不是全部正確替換了:
import "style.css";
import "style.css";
import "style.css";
舒服了~ ??上面的代碼還是可以優(yōu)化很多地方的,比如大家還可以寫一些額外的代碼來統(tǒng)計替換的位置、數(shù)量、文件修改數(shù)量等,這些都可以在控制臺打印出來,在別人使用時也能得到較好的反饋~甚至替換的正則方法也可以再做改進,看大家的了!
最后想說的
雖然上面的實戰(zhàn)是非常簡單的一種 AST 用法,但是這篇文章的主要作用就是能帶大家入門,利用這種思維去解決工作或學習中遇到的一些問題,在我看來,有了對某方法的事物認知之后,你的解決問題的方式就會無形之中多了一種。其實技術在某種程度來說并不是最重要的,重要的是對技術的認知。畢竟,你不知道某個東西,利用它的想法都不會產生,但是你知道了,無論技術實現(xiàn)再難,也總是可以攻克的!最后感謝大家能認真讀到這里,文章中有錯誤的地方,歡迎探討。
參考資料
tscpaths: https://github.com/joonhocho/tscpaths
[2]copyfiles: https://github.com/calvinmetcalf/copyfiles
[3]ESTree: https://github.com/estree/estree
[4]jscodeshift: https://github.com/facebook/jscodeshift
[5]recast: https://github.com/benjamn/recast
[6]@babel/types: https://babeljs.io/docs/en/babel-types
[7]AST Explorer: https://astexplorer.net/
[8]AST Explorer: https://astexplorer.net/
[9]collection: https://github.com/facebook/jscodeshift/blob/master/src/Collection.js
[10]extensions: https://github.com/facebook/jscodeshift/tree/master/src/collections
[11]commander: https://github.com/tj/commander.js
[12]tsccss: https://github.com/vortesnail/tsccss
