面試題:如何給所有的async函數新增try/catch?

2022-10-31 22:00:34

前端(vue)入門到精通課程:進入學習
Apipost = Postman + Swagger + Mock + Jmeter 超好用的API偵錯工具:

去阿里面試,三面的時候被問到了這個問題,當時思路雖然正確,可惜表述的不夠清晰

後來花了一些時間整理了下思路,那麼如何實現給所有的async函數新增try/catch呢?

async如果不加 try/catch 會發生什麼事?

// 範例
async function fn() {
  let value = await new Promise((resolve, reject) => {
    reject('failure');
  });
  console.log('do something...');
}
fn()
登入後複製

導致瀏覽器報錯:一個未捕獲的錯誤

1.png

在開發過程中,為了保證系統健壯性,或者是為了捕獲非同步的錯誤,需要頻繁的在 async 函數中新增 try/catch,避免出現上述範例的情況

可是我很懶,不想一個個加,懶惰使我們進步?

下面,通過手寫一個babel 外掛,來給所有的async函數新增try/catch

babel外掛的最終效果

原始程式碼:

async function fn() {
  await new Promise((resolve, reject) => reject('報錯'));
  await new Promise((resolve) => resolve(1));
  console.log('do something...');
}
fn();
登入後複製

使用外掛轉化後的程式碼:

async function fn() {
  try {
    await new Promise((resolve, reject) => reject('報錯'));
    await new Promise(resolve => resolve(1));
    console.log('do something...');
  } catch (e) {
    console.log("\nfilePath: E:\\myapp\\src\\main.js\nfuncName: fn\nError:", e);
  }
}
fn();
登入後複製

列印的報錯資訊:

2.png

通過詳細的報錯資訊,幫助我們快速找到目標檔案和具體的報錯方法,方便去定位問題

babel外掛的實現思路

1)藉助AST抽象語法樹,遍歷查詢程式碼中的await關鍵字

2)找到await節點後,從父路徑中查詢宣告的async函數,獲取該函數的body(函數中包含的程式碼)

3)建立try/catch語句,將原來async的body放入其中

4)最後將async的body替換成建立的try/catch語句

babel的核心:AST

先聊聊 AST 這個帥小夥?,不然後面的開發流程走不下去

AST是程式碼的樹形結構,生成 AST 分為兩個階段:詞法分析語法分析

詞法分析

詞法分析階段把字串形式的程式碼轉換為令牌(tokens) ,可以把tokens看作是一個扁平的語法片段陣列,描述了程式碼片段在整個程式碼中的位置和記錄當前值的一些資訊

比如let a = 1,對應的AST是這樣的

3.png

語法分析

語法分析階段會把token轉換成 AST 的形式,這個階段會使用token中的資訊把它們轉換成一個 AST 的表述結構,使用type屬性記錄當前的型別

例如 let 代表著一個變數宣告的關鍵字,所以它的 type 為 VariableDeclaration,而 a = 1 會作為 let 的宣告描述,它的 type 為 VariableDeclarator

AST線上檢視工具:AST explorer

再舉個?,加深對AST的理解

function demo(n) {
  return n * n;
}
登入後複製

轉化成AST的結構

{
  "type": "Program", // 整段程式碼的主體
  "body": [
    {
      "type": "FunctionDeclaration", // function 的型別叫函數宣告;
      "id": { // id 為函數宣告的 id
        "type": "Identifier", // 識別符號 型別
        "name": "demo" // 識別符號 具有名字 
      },
      "expression": false,
      "generator": false,
      "async": false, // 代表是否 是 async function
      "params": [ // 同級 函數的引數 
        {
          "type": "Identifier",// 引數型別也是 Identifier
          "name": "n"
        }
      ],
      "body": { // 函數體內容 整個格式呈現一種樹的格式
        "type": "BlockStatement", // 整個函數體內容 為一個塊狀程式碼塊型別
        "body": [
          {
            "type": "ReturnStatement", // return 型別
            "argument": {
              "type": "BinaryExpression",// BinaryExpression 二進位制表示式型別
              "start": 30,
              "end": 35,
              "left": { // 分左 右 中 結構
                "type": "Identifier", 
                "name": "n"
              },
              "operator": "*", // 屬於操作符
              "right": {
                "type": "Identifier",
                "name": "n"
              }
            }
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}
登入後複製

常用的 AST 節點型別對照表

型別原名稱中文名稱描述
Program程式主體整段程式碼的主體
VariableDeclaration變數宣告宣告一個變數,例如 var let const
FunctionDeclaration函數宣告宣告一個函數,例如 function
ExpressionStatement表示式語句通常是呼叫一個函數,例如 console.log()
BlockStatement塊語句包裹在 {} 塊內的程式碼,例如 if (condition){var a = 1;}
BreakStatement中斷語句通常指 break
ContinueStatement持續語句通常指 continue
ReturnStatement返回語句通常指 return
SwitchStatementSwitch 語句通常指 Switch Case 語句中的 Switch
IfStatementIf 控制流語句控制流語句,通常指 if(condition){}else{}
Identifier識別符號標識,例如宣告變數時 var identi = 5 中的 identi
CallExpression呼叫表示式通常指呼叫一個函數,例如 console.log()
BinaryExpression二進位制表示式通常指運算,例如 1+2
MemberExpression成員表示式通常指呼叫物件的成員,例如 console 物件的 log 成員
ArrayExpression陣列表示式通常指一個陣列,例如 [1, 3, 5]
FunctionExpression函數表示式例如const func = function () {}
ArrowFunctionExpression箭頭函數表示式例如const func = ()=> {}
AwaitExpressionawait表示式例如let val = await f()
ObjectMethod物件中定義的方法例如 let obj = { fn () {} }
NewExpressionNew 表示式通常指使用 New 關鍵詞
AssignmentExpression賦值表示式通常指將函數的返回值賦值給變數
UpdateExpression更新表示式通常指更新成員值,例如 i++
Literal字面量字面量
BooleanLiteral布林型字面量布林值,例如 true false
NumericLiteral數位型字面量數位,例如 100
StringLiteral字元型字面量字串,例如 vansenb
SwitchCaseCase 語句通常指 Switch 語句中的 Case

await節點對應的AST結構

1)原始程式碼

async function fn() {
   await f()
}
登入後複製

對應的AST結構

4.png

2)增加try catch後的程式碼

async function fn() {
    try {
        await f()
    } catch (e) {
        console.log(e)
    }
}
登入後複製

對應的AST結構

5.png

通過AST結構對比,外掛的核心就是將原始函數的body放到try語句中

babel外掛開發

我曾在之前的文章中聊過如何開發一個babel外掛

這裡簡單回顧一下

外掛的基本格式範例

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 設定需要範圍的節點型別
       CallExression: (path, state) => { 
         do soming ……
       }
     }
   }
 }
登入後複製

1)通過 babel 拿到 types 物件,操作 AST 節點,比如建立、校驗、轉變等

2)visitor:定義了一個存取者,可以設定需要存取的節點型別,當存取到目標節點後,做相應的處理來實現外掛的功能

尋找await節點

回到業務需求,現在需要找到await節點,可以通過AwaitExpression表示式獲取

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 設定AwaitExpression
       AwaitExpression(path) {
         // 獲取當前的await節點
         let node = path.node;
       }
     }
   }
 }
登入後複製

向上查詢 async 函數

通過findParent方法,在父節點中搜尋 async 節點

// async節點的屬性為true
const asyncPath = path.findParent(p => p.node.async)
登入後複製

async 節點的AST結構

6.png

這裡要注意,async 函數分為4種情況:函數宣告 、箭頭函數 、函數表示式 、函數為物件的方法

// 1️⃣:函數宣告
async function fn() {
  await f()
}

// 2️⃣:函數表示式
const fn = async function () {
  await f()
};

// 3️⃣:箭頭函數
const fn = async () => {
  await f()
};

// 4️⃣:async函數定義在物件中
const obj = {
  async fn() {
      await f()
  }
}
登入後複製

需要對這幾種情況進行分別判斷

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       // 設定AwaitExpression
       AwaitExpression(path) {
         // 獲取當前的await節點
         let node = path.node;
         // 查詢async函數的節點
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
       }
     }
   }
 }
登入後複製

利用babel-template生成try/catch節點

babel-template可以用以字串形式的程式碼來構建AST樹節點,快速優雅開發外掛

// 引入babel-template
const template = require('babel-template');

// 定義try/catch語句模板
let tryTemplate = `
try {
} catch (e) {
console.log(CatchError:e)
}`;

// 建立模板
const temp = template(tryTemplate);

// 給模版增加key,新增console.log列印資訊
let tempArgumentObj = {
   // 通過types.stringLiteral建立字串字面量
   CatchError: types.stringLiteral('Error')
};

// 通過temp建立try語句的AST節點
let tryNode = temp(tempArgumentObj);
登入後複製

async函數體替換成try語句

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       AwaitExpression(path) {
         let node = path.node;
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
         
         let tryNode = temp(tempArgumentObj);
         
         // 獲取父節點的函數體body
         let info = asyncPath.node.body;

         // 將函數體放到try語句的body中
         tryNode.block.body.push(...info.body);

         // 將父節點的body替換成新建立的try語句
         info.body = [tryNode];
       }
     }
   }
 }
登入後複製

到這裡,外掛的基本結構已經成型,但還有點問題,如果函數已存在try/catch,該怎麼處理判斷呢?

若函數已存在try/catch,則不處理

// 範例程式碼,不再新增try/catch
async function fn() {
    try {
        await f()
    } catch (e) {
        console.log(e)
    }
}
登入後複製

通過isTryStatement判斷是否已存在try語句

module.exports = function (babel) {
   let t = babel.type
   return { 
     visitor: {
       AwaitExpression(path) {
       
        // 判斷父路徑中是否已存在try語句,若存在直接返回
        if (path.findParent((p) => p.isTryStatement())) {
          return false;
        }
       
         let node = path.node;
         const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
         let tryNode = temp(tempArgumentObj);
         let info = asyncPath.node.body;
         tryNode.block.body.push(...info.body);
         info.body = [tryNode];
       }
     }
   }
 }
登入後複製

新增報錯資訊

獲取報錯時的檔案路徑 filePath 和方法名稱 funcName,方便快速定位問題

獲取檔案路徑

// 獲取編譯目標檔案的路徑,如:E:\myapp\src\App.vue
const filePath = this.filename || this.file.opts.filename || 'unknown';
登入後複製

獲取報錯的方法名稱

// 定義方法名
let asyncName = '';

// 獲取async節點的type型別
let type = asyncPath.node.type;

switch (type) {
// 1️⃣函數表示式
// 情況1:普通函數,如const func = async function () {}
// 情況2:箭頭函數,如const func = async () => {}
case 'FunctionExpression':
case 'ArrowFunctionExpression':
  // 使用path.getSibling(index)來獲得同級的id路徑
  let identifier = asyncPath.getSibling('id');
  // 獲取func方法名
  asyncName = identifier && identifier.node ? identifier.node.name : '';
  break;

// 2️⃣函數宣告,如async function fn2() {}
case 'FunctionDeclaration':
  asyncName = (asyncPath.node.id && asyncPath.node.id.name) || '';
  break;

// 3️⃣async函數作為物件的方法,如vue專案中,在methods中定義的方法: methods: { async func() {} }
case 'ObjectMethod':
  asyncName = asyncPath.node.key.name || '';
  break;
}

// 若asyncName不存在,通過argument.callee獲取當前執行函數的name
let funcName = asyncName || (node.argument.callee && node.argument.callee.name) || '';
登入後複製

新增使用者選項

使用者引入外掛時,可以設定excludeincludecustomLog選項

exclude: 設定需要排除的檔案,不對該檔案進行處理

include: 設定需要處理的檔案,只對該檔案進行處理

customLog: 使用者自定義的列印資訊

最終程式碼

入口檔案index.js

// babel-template 用於將字串形式的程式碼來構建AST樹節點
const template = require('babel-template');

const { tryTemplate, catchConsole, mergeOptions, matchesFile } = require('./util');

module.exports = function (babel) {
  // 通過babel 拿到 types 物件,操作 AST 節點,比如建立、校驗、轉變等
  let types = babel.types;

  // visitor:外掛核心物件,定義了外掛的工作流程,屬於存取者模式
  const visitor = {
    AwaitExpression(path) {
      // 通過this.opts 獲取使用者的設定
      if (this.opts && !typeof this.opts === 'object') {
        return console.error('[babel-plugin-await-add-trycatch]: options need to be an object.');
      }

      // 判斷父路徑中是否已存在try語句,若存在直接返回
      if (path.findParent((p) => p.isTryStatement())) {
        return false;
      }

      // 合併外掛的選項
      const options = mergeOptions(this.opts);

      // 獲取編譯目標檔案的路徑,如:E:\myapp\src\App.vue
      const filePath = this.filename || this.file.opts.filename || 'unknown';

      // 在排除列表的檔案不編譯
      if (matchesFile(options.exclude, filePath)) {
        return;
      }

      // 如果設定了include,只編譯include中的檔案
      if (options.include.length && !matchesFile(options.include, filePath)) {
        return;
      }

      // 獲取當前的await節點
      let node = path.node;

      // 在父路徑節點中查詢宣告 async 函數的節點
      // async 函數分為4種情況:函數宣告 || 箭頭函數 || 函數表示式 || 物件的方法
      const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));

      // 獲取async的方法名
      let asyncName = '';

      let type = asyncPath.node.type;

      switch (type) {
        // 1️⃣函數表示式
        // 情況1:普通函數,如const func = async function () {}
        // 情況2:箭頭函數,如const func = async () => {}
        case 'FunctionExpression':
        case 'ArrowFunctionExpression':
          // 使用path.getSibling(index)來獲得同級的id路徑
          let identifier = asyncPath.getSibling('id');
          // 獲取func方法名
          asyncName = identifier && identifier.node ? identifier.node.name : '';
          break;

        // 2️⃣函數宣告,如async function fn2() {}
        case 'FunctionDeclaration':
          asyncName = (asyncPath.node.id && asyncPath.node.id.name) || '';
          break;

        // 3️⃣async函數作為物件的方法,如vue專案中,在methods中定義的方法: methods: { async func() {} }
        case 'ObjectMethod':
          asyncName = asyncPath.node.key.name || '';
          break;
      }

      // 若asyncName不存在,通過argument.callee獲取當前執行函數的name
      let funcName = asyncName || (node.argument.callee && node.argument.callee.name) || '';

      const temp = template(tryTemplate);

      // 給模版增加key,新增console.log列印資訊
      let tempArgumentObj = {
        // 通過types.stringLiteral建立字串字面量
        CatchError: types.stringLiteral(catchConsole(filePath, funcName, options.customLog))
      };

      // 通過temp建立try語句
      let tryNode = temp(tempArgumentObj);

      // 獲取async節點(父節點)的函數體
      let info = asyncPath.node.body;

      // 將父節點原來的函數體放到try語句中
      tryNode.block.body.push(...info.body);

      // 將父節點的內容替換成新建立的try語句
      info.body = [tryNode];
    }
  };
  return {
    name: 'babel-plugin-await-add-trycatch',
    visitor
  };
};
登入後複製

util.js

const merge = require('deepmerge');

// 定義try語句模板
let tryTemplate = `
try {
} catch (e) {
console.log(CatchError,e)
}`;

/*
 * catch要列印的資訊
 * @param {string} filePath - 當前執行檔案的路徑
 * @param {string} funcName - 當前執行方法的名稱
 * @param {string} customLog - 使用者自定義的列印資訊
 */
let catchConsole = (filePath, funcName, customLog) => `
filePath: ${filePath}
funcName: ${funcName}
${customLog}:`;

// 預設設定
const defaultOptions = {
  customLog: 'Error',
  exclude: ['node_modules'],
  include: []
};

// 判斷執行的file檔案 是否在 exclude/include 選項內
function matchesFile(list, filename) {
  return list.find((name) => name && filename.includes(name));
}

// 合併選項
function mergeOptions(options) {
  let { exclude, include } = options;
  if (exclude) options.exclude = toArray(exclude);
  if (include) options.include = toArray(include);
  // 使用merge進行合併
  return merge.all([defaultOptions, options]);
}

function toArray(value) {
  return Array.isArray(value) ? value : [value];
}

module.exports = {
  tryTemplate,
  catchConsole,
  defaultOptions,
  mergeOptions,
  matchesFile,
  toArray
};
登入後複製

github倉庫

babel外掛的安裝使用

npm網站搜尋babel-plugin-await-add-trycatch

7.png

有興趣的朋友可以下載玩一玩

babel-plugin-await-add-trycatch

總結

通過開發這個babel外掛,瞭解很多 AST 方面的知識,瞭解 babel 的原理。實際開發中,大家可以結合具體的業務需求開發自己的外掛

【相關推薦:、】

以上就是面試題:如何給所有的async函數新增try/catch?的詳細內容,更多請關注TW511.COM其它相關文章!