阿里面試官:如何給所有的async函式新增try/catch?

語言: CN / TW / HK

theme: juejin highlight: androidstudio


前言

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

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

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

// 示例 async function fn() { let value = await new Promise((resolve, reject) => { reject('failure'); }); console.log('do something...'); } fn()

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

在開發過程中,為了保證系統健壯性,或者是為了捕獲非同步的錯誤,需要頻繁的在 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();

列印的報錯資訊:

error.jpg

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

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是這樣的

ast-a.jpg

語法分析

語法分析階段會把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 | | SwitchStatement | Switch 語句 | 通常指 Switch Case 語句中的 Switch | | IfStatement | If 控制流語句 | 控制流語句,通常指 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 = ()=> {} | | AwaitExpression | await表示式 | 例如let val = await f() | | ObjectMethod | 物件中定義的方法 | 例如 let obj = { fn () {} } | | NewExpression | New 表示式 | 通常指使用 New 關鍵詞 | | AssignmentExpression | 賦值表示式 | 通常指將函式的返回值賦值給變數 | | UpdateExpression | 更新表示式 | 通常指更新成員值,例如 i++ | | Literal | 字面量 | 字面量 | | BooleanLiteral | 布林型字面量 | 布林值,例如 true false | | NumericLiteral | 數字型字面量 | 數字,例如 100 | | StringLiteral | 字元型字面量 | 字串,例如 vansenb | | SwitchCase | Case 語句 | 通常指 Switch 語句中的 Case

await節點對應的AST結構

1)原始程式碼 async function fn() { await f() } 對應的AST結構

async.jpg

2)增加try catch後的程式碼

async function fn() { try { await f() } catch (e) { console.log(e) } } 對應的AST結構

try.jpg

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

babel外掛開發

我曾在《歷時8個月!10w字前端知識體系+大廠面試筆記(工程化篇)🔥》中聊過如何開發一個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結構

asyncTrue.jpg

這裡要注意,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檔案 是否在 options 選項 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

npm.jpg

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

babel-plugin-await-add-trycatch

總結

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

nice.gif

參考資料
Babel 外掛手冊
嘿,不要給 async 函式寫那麼多 try/catch 了