寫一個為await自動加上catch的loader逐漸瞭解AST以及babel

溫潤如玉Ayu發表於2020-12-13

為什麼要寫這個loader

我們在日常開發中經常用到async await去請求介面,解決非同步。可async await語法的缺點就是若await後的Promise丟擲錯誤不能捕獲,整段程式碼區就會卡住。從而使下面的邏輯不能順利執行。也許會有人說,卡住就是為了不進行後續的程式碼,以免造成更大的錯誤,可大多數情況下需要catch住錯誤並給出一個邊界值使程式碼正常執行。 我以前經常常常會這麼寫:

const request = async (){
const { data = [] } = await getList() || {};
//...other
};

這樣寫看似有些**高階**,但其實風險係數很高,假設```getList()```請求發生了錯誤並且沒有捕獲到,那麼後邊的邏輯或表示式並不會生效,後續的程式碼並不能順序執行。
這種情況的最優解就是```getList()```能後捕獲到錯誤,雖然現在大多數axios都會catch,但是業務開發中應該不止請求才會用到Promise。那麼另一種解法是?

const request = async (){
const { data = [] } = await getList().catch(err=>{ //...do you want to do }) || {};
//...other
};

這個loader解決的問題

自己寫的loader就是解決日常開發中忘記寫catch的情況。
先說一下自己寫的loader的功能:
1. 可以自動為await後的promise加上catch
2. 可以決定是否需要在catch函式中列印error以及return出一個邊界值,可以選擇加上自己的程式碼
3. 若是await函式外層有被try catch包裹或者本身後邊就已經有catch,則不會做任何處理

//一個普通的async函式
const fn = async () => { 
const a = await pro() 
}
//會被轉化成
const fn = async () => {
const a = await pro().catch(err=>{})
}
//若是需要列印error以及return出一個邊界值
const fn = async () => {
const { a } = await pro().catch(err=>{ console.log(err); return { } });
}
//or
const fn = async () => {
const [ a ] = await pro().catch(err=>{ console.log(err); return [ ] });
}
//若是需要自己額外的程式碼處理,自己的程式碼賊會在console前面,假設自己程式碼為 message.error(error)
const fn = async () => {
const [ a ] = await pro().catch(err=>{ message.error(err);console.log(err); return [ ] });
}
// 如果被try catch包裹,則不會進行任何處理,因為catch可以捕獲到錯誤,擅自增加catch會擾亂原有的邏輯
const fn = async () => {
// 保持原樣
try{
const [ a ] = await pro()
}catch(err){}
}

具體程式碼+講解

接下來上程式碼

//add-catch-loader.js
const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");
const babel = require("@babel/core");

先來介紹一下各個babel包的作用

  1.  @babel/parser:解析js程式碼生成ast,因為loader讀取的js檔案中的原始碼,而我們又不能直接操作原始碼進行修改,只能先轉為ast進行操作。
  2. babel-traverse:遍歷ast,因為ast是一顆樹形結構,其中每個操作符、表示式等都是一個節點,是整顆樹上的一個枝幹,我們通過traverse去遍歷整棵樹來獲取其中一個節點的資訊來修改它。
  3. babel-types:我用來判斷一個節點的型別。
  4. @babel/template:我用來將程式碼段轉為ast節點。
  5.  @babel/core:程式碼生成,ast操作完後得到了一顆新的ast,那麼需要把ast在轉為js程式碼輸出到檔案中。

通過上邊的幾個包就看出了babel處理js的三個過程:解析(parase)、轉換(transform)、生成(generator)

loader就是一個純函式,它能獲取當前檔案的原始碼

//a.js
const num = 1;
console.log(num);
```
那麼source就是"const num = 1;console.log(num);"而我們把它轉化為ast又是什麼樣子呢?
我把它轉化為了json結果,我只擷取了部分(因為太長了),大家可以去[這個網站](https://astexplorer.net/)輸入一段js程式碼看看轉化成了什麼樣~
## AST的大概結構
```json
{
"type": "File",
"start": 0,
"end": 32,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 3,
"column": 16
}
},
"errors": [],
"program": {
"type": "Program",
"start": 0,
"end": 32,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 3,
"column": 16
... 
},
"comments": []
}

解決問題的思路

在ast結構中,每一個有type屬性的物件都是一個節點,裡面包含了這個節點的全部資訊,而我們既然要操做await後的promise,那麼就只需要看await操作符上下的節點就可以了,先看一下await的節點長什麼樣子。

上圖只是 const a = await po()這一段程式碼的ast,其中大部分還摺疊起來了。但是我們只需要關係await後的程式碼ast,即po()

 

 

 


AwaitExpression這個節點是await po()這段程式碼,CallExpression這個節點是po()這個節點。那麼await po().catch(err=>{ })程式碼的節點又長什麼樣子呢?

如下圖,AwaitExpressionawait pro().catch(err=>{});整段程式碼的節點,MemberExpressionpro().catch;的節點,arguments是函式體的引數,而ArrowFunctionExpression代表的就是err={},所以我們只需要把po()替換成po().catch(err=>{})
比較一下po()po().catch的不同(由於catch函式中的回撥函式是引數,屬於和po().catch一個級別,所以不把它算在內)
po()

po().catch()

從上圖中就可以看出來CallExpression節點換成了MemberExpression,那麼開始上程式碼。

具體程式碼

source就是讀取的檔案中的原始碼內容。
parser.parse就是將原始碼轉為AST,如果原始碼中使用export和import,那麼sourceType必須是module,plugin必須使用dynamicImport,jsx是為了解析jsx語法,classProperties是為了解析class語法。

//add-catch-loader.js
const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");
const babel = require("@babel/core");
const { createCatchIdentifier, createArguments } = require("./utils"); //自己寫的方法

function addCatchLoader(source){
let ast = parser.parse(source, {
sourceType: "module",
plugins: ["dynamicImport", "jsx","classProperties"],
});
}

獲得到AST語法樹我們就可以使用traverse進行遍歷了,traverse第一個引數是要遍歷的ast,第二個引數是暴露出來的節點API。

//add-catch-loader.js
const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");
const babel = require("@babel/core");

const createCatchIdentifier = () => {
const catchIdentifier = t.identifier("catch");
return catchIdentifier;
};


function addCatchLoader(source){
const self = this; //快取當前this
let ast = parser.parse(source, {
sourceType: "module",
plugins: ["dynamicImport", "jsx"],
});

const awaitMap = [];

traverse(ast,{
/*
我們既然是要替換await後的整顆節點,就要先獲取AwaitExpression這個節點的資訊。因為有些
人在用async await習慣用try catch進行包裹,而用了try catch就沒必要再加catch了,所以
我們這裡需要判斷await的父級節點有沒有try catch。若有就使用path.skip()停止接下來的循
環,沒有將當前節點的argument快取進一個陣列中,為了接下來進行比較。 
*/
AwaitExpression(path) {
const tryCatchPath = path.findParent((p) => {
return t.isTryStatement(p);
});
if (tryCatchPath) return path.skip();
/*
這裡leftId就是 = 左邊的值,因為可能需要在catch裡return,所以需要判斷它的型別
*/
const leftId = path.parent.id;
if (leftId) {
const type = leftId.type;
path.node.argument.returnType = type;
}
awaitMap.push(path.node.argument);
},
/*
CallExpression節點就是我們需要替換的節點,因為整顆ast中不止一個地方有
CallExpression型別的節點,所以我們需要比較快取的陣列中有沒有它,如有就代表是我們
要替換的```po()```。在這裡我們需要在進行一次判斷,因為原始碼中可能會有await後自動加
catch的情況,我們就不必處理了。
*/
CallExpression(path) {
if (!awaitMap.length) return null;
awaitMap.forEach((item, index) => {
if (item === path.node) {
const callee = path.node.callee;
const returnType = path.node.returnType; //這裡取出等號左邊的型別
if (t.isMemberExpression(callee)) return; //若是已經有了.catch則不需要處理
const MemberExpression = t.memberExpression(
item,
createCatchIdentifier()
);
const createArgumentsSelf = createArguments.bind(self); //繫結當前this
const ArrowFunctionExpression_1 = createArgumentsSelf(returnType);//建立catch的回撥函式裡的邏輯
const CallExpression = t.callExpression(MemberExpression, [
ArrowFunctionExpression_1,
]);
path.replaceWith(CallExpression);
awaitMap[index] = null;
}
});
},
})

我們看一下createArgumentsSelf的邏輯

const t = require("babel-types");
const template = require("@babel/template");
const loaderUtils = require("loader-utils");
const { typeMap } = require("./constant");

const createCatchIdentifier = () => {
const catchIdentifier = t.identifier("catch");
return catchIdentifier;
};

function createArguments(type) {
//上邊我們快取了this並把this傳入到當前函式中,就是為了取出loader的引數
const { needReturn, consoleError, customizeCatchCode } =
loaderUtils.getOptions(this) || {};

let returnResult = needReturn && type && typeMap[type];
let code = "";
let returnStatement = null;
if (returnResult) {
code = `return ${returnResult}`;
}
if (code) {
returnStatement = template.statement(code)();
}

/* 建立arguments:(err)=>{}
先建立ArrowFunctionExpression 引數(params,body為必須);params為err
param是引數列表,為一個陣列,每一項為Identifier;body為BlockStatement;
*/
// 建立body
const consoleStatement =
consoleError && template.statement(`console.log(error)`)();
const customizeCatchCodeStatement =
typeof customizeCatchCode === "string" &&
template.statement(customizeCatchCode)();
const blockStatementMap = [
customizeCatchCodeStatement,
consoleStatement,
returnStatement,
].filter(Boolean);
const blockStatement = t.blockStatement(blockStatementMap);
// 建立ArrowFunctionExpression
const ArrowFunctionExpression_1 = t.arrowFunctionExpression(
[t.identifier("error")],
blockStatement
);
return ArrowFunctionExpression_1;
}

module.exports = {
createCatchIdentifier,
createArguments,
};

確定了就是替換這個節點,那麼我們需要建立一個MemberExpression節點,檢視babel-type的問的文件

object和property是必須的,而在我們的ast中,object和property又分別代表什麼呢?

 

 

po()就是object,catch就是property,這樣我們的po().catch體就建立成功了。而po().catch是肯定不夠的,我們需要一個完整的```po().catch(err=>{})``` 結構,而err=>{}作為引數是和MemberExpression節點平級的,createArgumentsSelf函式就是建立了err=>{},其中需要根據引數判斷是否需要列印error,是否需要return邊界值,以及是否有別的邏輯程式碼,原理和建立catch一樣。最後建立好了使用path.replaceWith(要替換成的節點)就可以了。但是要注意將快取節點的陣列中將這個節點刪掉,因為ast遍歷中若是某個節點發生了改變,那麼就會一直遍歷,造成死迴圈!
因為我目前的處理的是await後跟的是一個函式的情況,即po()是一個函式,函式執行返回的是一個promise,那麼還有await後直接跟promise的情況,比如這種

const pro = new Promise((resolve,reject)=>{ reject('我錯了!') })

const fn = async () => {
const data = await pro;
}

這種情況也需要考慮進去,我程式碼上就不放了,pro是一個```Identifier```節點,思路和```CallExpression```完全一樣。
最後我們處理完ast節點,需要把新節點在轉回程式碼返回回去

//add-catch-loader.js
const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");
const babel = require("@babel/core");

const createCatchIdentifier = () => {
const catchIdentifier = t.identifier("catch");
return catchIdentifier;
};


function addCatchLoader(source){
const self = this; //快取當前this
let ast = parser.parse(source, {
sourceType: "module",
plugins: ["dynamicImport", "jsx"],
});

const awaitMap = [];

traverse(ast,{
/*
我們既然是要替換await後的整顆節點,就要先獲取AwaitExpression這個節點的資訊。因為有些
人在用async await習慣用try catch進行包裹,而用了try catch就沒必要再加catch了,所以
我們這裡需要判斷await的父級節點有沒有try catch。若有就使用path.skip()停止接下來的循
環,沒有將當前節點的argument快取進一個陣列中,為了接下來進行比較。 
*/
AwaitExpression(path) {
const tryCatchPath = path.findParent((p) => {
return t.isTryStatement(p);
});
if (tryCatchPath) return path.skip();
/*
這裡leftId就是 = 左邊的值,因為可能需要在catch裡return,所以需要判斷它的型別
*/
const leftId = path.parent.id;
if (leftId) {
const type = leftId.type;
path.node.argument.returnType = type;
}
awaitMap.push(path.node.argument);
},
/*
CallExpression節點就是我們需要替換的節點,因為整顆ast中不止一個地方有
CallExpression型別的節點,所以我們需要比較快取的陣列中有沒有它,如有就代表是我們
要替換的```po()```。在這裡我們需要在進行一次判斷,因為原始碼中可能會有await後自動加
catch的情況,我們就不必處理了。
*/
CallExpression(path) {
if (!awaitMap.length) return null;
awaitMap.forEach((item, index) => {
if (item === path.node) {
const callee = path.node.callee;
const returnType = path.node.returnType; //這裡取出等號左邊的型別
if (t.isMemberExpression(callee)) return; //若是已經有了.catch則不需要處理
const MemberExpression = t.memberExpression(
item,
createCatchIdentifier()
);
const createArgumentsSelf = createArguments.bind(self); //繫結當前this
const ArrowFunctionExpression_1 = createArgumentsSelf(returnType);//建立catch的回撥函式裡的邏輯
const CallExpression = t.callExpression(MemberExpression, [
ArrowFunctionExpression_1,
]);
path.replaceWith(CallExpression);
awaitMap[index] = null;
}
});
},
})
const { code } = babel.transformFromAstSync(ast, null, {
configFile: false, // 遮蔽 babel.config.js,否則會注入 polyfill 使得除錯變得困難
});
return code;

有些人可能在替換節點時用繼續深度遍歷當前節點的方法,因為要替換的節點必定是AwaitExpression的子節點嘛,我為了使整體程式碼結構看起來更結構化,所以這裡使用了快取節點。

在專案中使用 

 

[github地址](https://github.com/mayu888/await-add-catch-loader),```歡迎大家star or issues!```

npm i await-add-catch-loader --save-dev
// or
yarn add await-add-catch-loader --save-dev

//webpack.config.js
module.exports = {
//...
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/, //刨除哪個檔案裡的js檔案
include: path.resolve(__dirname, "./src"),
use: [
{loader: "babel-loader"},
{
loader: 'await-add-catch-loader',
options: {
needReturn: true,
consoleError: true,
customizeCatchCode: "//please input you want to do",
},
},
],
},
],
},
}

 

專案中的原始碼:

loader處理後的程式碼

寫loader中的一些困難及想法

從功能上來說單純為了給promise加上catch而寫一個loader是完全沒必要的,因為loader的核心作用是為了處理一個檔案級別的模組,單純實現一個小功能有些殺雞用宰牛刀的感覺,我一開始的目的其實是寫一個babel的外掛,想在babel處理js的過程中就完成這個功能,但是babel外掛有一個點就是在處理每一個ast節點時,會順序的執行每一個外掛,也就是每一個ast節點在babel外掛中只進行一次處理,並不是在執行完一個外掛後再去執行下一個外掛,其目的是優化效能,畢竟dom樹太複雜遍歷一次的成本就會越高。這樣帶來的問題就是我的外掛在處理到AwaitExpression節點前,別的外掛已經把async await替換成了generator,這樣我的外掛就失效了。

//webpack.config.js
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
include: path.resolve(__dirname, './src'),
use: [
{
loader: 'babel-loader?cacheDirectory',
options: {
presets: [
[
'@babel/preset-env', //呼叫es6-es5的模組],
'@babel/preset-react' //轉化react語法的模組
],
plugins: 
[
'@babel/plugin-transform-runtime',
[path.resolve(__dirname, 'babel-plugin', 'await-catch-babel-plugin')]//自己寫的babel外掛
]}

因為要使用'@babel/preset-env'將es6轉es5,而使用這個預設必須要使用@babel/plugin-transform-runtime來處理async await,通過分析原始碼,@babel/plugin-transform-runtimepre階段對async函式generator化,pre階段就是剛進入節點的階段,是自己寫的外掛在後續的遍歷中沒有了AwaitExpression節點。這個問題搜了好久也未曾找到解決辦法,特意去了stackOverflow提問,也沒人回覆,但是發現一個類似的問題,也沒解決辦法,所以放棄了babel外掛的寫法。
也曾想過使用webpack外掛來完成此功能,但是也會偏離webpack外掛的核心思想,所以就放棄了。
我的目的也是想更深次的學習一下webpack、babel在編譯過程中做的事,掌握它們的原理,所以最後還是選擇了loader的寫法。

相關文章