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

爲何要寫這個loader

咱們在平常開發中常常用到async await去請求接口,解決異步。可async await語法的缺點就是若await後的Promise拋出錯誤不能捕獲,整段代碼區就會卡住。從而使下面的邏輯不能順利執行。也許會有人說,卡住就是爲了避免進行後續的代碼,以避免形成更大的錯誤,可大多數狀況下須要catch住錯誤並給出一個邊界值使代碼正常執行。
我之前常常經常會這麼寫:node

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

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

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

這個loader解決的問題

本身寫的loader就是解決平常開發中忘記寫catch的狀況。
先說一下本身寫的loader的功能:webpack

  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){}
}
複製代碼

具體代碼+講解

接下來上代碼ios

//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)git

loader就是一個純函數,它能獲取當前文件的源代碼es6

//a.js
const num = 1;
console.log(num);
複製代碼

那麼source就是"const num = 1;console.log(num);"而咱們把它轉化爲ast又是什麼樣子呢? 我把它轉化爲了json結果,我只截取了部分(由於太長了),你們能夠去這個網站輸入一段js代碼看看轉化成了什麼樣~github

AST的大概結構

{
  "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的節點長什麼樣子。 源代碼 ast 上圖只是 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,那麼開始上代碼。web

具體代碼

source就是讀取的文件中的源碼內容。 parser.parse就是將源代碼轉爲AST,若是源代碼中使用export和import,那麼sourceType必須是module,plugin必須使用dynamicImport,jsx是爲了解析jsx語法,classProperties是爲了解析class語法。npm

//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。json

//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地址歡迎你們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-runtime'pre階段對async函數generator化,pre階段就是剛進入節點的階段,是本身寫的插件在後續的遍歷中沒有了AwaitExpression節點。這個問題搜了很久也不曾找到解決辦法,特地去了stackOverflow提問,也沒人回覆,可是發現一個相似的問題,也沒解決辦法,因此放棄了babel插件的寫法。 也曾想過使用webpack插件來完成此功能,可是也會偏離webpack插件的核心思想,因此就放棄了。 個人目的也是想更深次的學習一下webpack、babel在編譯過程當中作的事,掌握它們的原理,因此最後仍是選擇了loader的寫法。

相關文章
相關標籤/搜索