如何给所有的 async 函数添加 try/catch?

前言

阿里三面的时候被问到了这个问题,当时思路虽然正确,可惜表述的不够清晰

后来花了一些时间整理了下思路,那么如何实现给所有的 async 函数添加 try/catch 呢?

async 如果不加 try/catch 会发生什么事?

1// 示例
2async function fn() {
3  let value = await new Promise((resolve, reject) => {
4    reject("failure");
5  });
6  console.log("do something...");
7}
8fn();

导致浏览器报错:一个未捕获的错误

在开发过程中,为了保证系统健壮性,或者是为了捕获异步的错误,需要频繁的在 async 函数中添加 try/catch,避免出现上述示例的情况

可是我很懒,不想一个个加,懒惰使我们进步😂

下面,通过手写一个 babel 插件,来给所有的 async 函数添加 try/catch

babel 插件的最终效果

原始代码:

1async function fn() {
2  await new Promise((resolve, reject) => reject("报错"));
3  await new Promise((resolve) => resolve(1));
4  console.log("do something...");
5}
6fn();

使用插件转化后的代码:

1async function fn() {
2  try {
3    await new Promise((resolve, reject) => reject("报错"));
4    await new Promise((resolve) => resolve(1));
5    console.log("do something...");
6  } catch (e) {
7    console.log("\nfilePath: E:\\myapp\\src\\main.js\nfuncName: fn\nError:", e);
8  }
9}
10fn();

通过详细的报错信息,帮助我们快速找到目标文件和具体的报错方法,方便去定位问题

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 看作是一个扁平的语法片段数组,描述了代码片段在整个代码中的位置和记录当前值的一些信息

语法分析

语法分析阶段会把 token 转换成 AST 的形式,这个阶段会使用 token 中的信息把它们转换成一个 AST 的表述结构,使用 type 属性记录当前的类型

例如 let 代表着一个变量声明的关键字,所以它的 type 为 VariableDeclaration,而 a = 1 会作为 let 的声明描述,它的 type 为 VariableDeclarator

AST 在线查看工具:AST explorer

再举个 🌰,加深对 AST 的理解

1function demo(n) {
2  return n * n;
3}

转化成 AST 的结构

1{
2  "type": "Program", // 整段代码的主体
3  "body": [
4    {
5      "type": "FunctionDeclaration", // function 的类型叫函数声明;
6      "id": { // id 为函数声明的 id
7        "type": "Identifier", // 标识符 类型
8        "name": "demo" // 标识符 具有名字
9      },
10      "expression": false,
11      "generator": false,
12      "async": false, // 代表是否 是 async function
13      "params": [ // 同级 函数的参数
14        {
15          "type": "Identifier",// 参数类型也是 Identifier
16          "name": "n"
17        }
18      ],
19      "body": { // 函数体内容 整个格式呈现一种树的格式
20        "type": "BlockStatement", // 整个函数体内容 为一个块状代码块类型
21        "body": [
22          {
23            "type": "ReturnStatement", // return 类型
24            "argument": {
25              "type": "BinaryExpression",// BinaryExpression 二进制表达式类型
26              "start": 30,
27              "end": 35,
28              "left": { // 分左 右 中 结构
29                "type": "Identifier",
30                "name": "n"
31              },
32              "operator": "*", // 属于操作符
33              "right": {
34                "type": "Identifier",
35                "name": "n"
36              }
37            }
38          }
39        ]
40      }
41    }
42  ],
43  "sourceType": "module"
44}

常用的 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)原始代码

1async function fn() {
2  await f();
3}

2)增加 try catch 后的代码

1async function fn() {
2  try {
3    await f();
4  } catch (e) {
5    console.log(e);
6  }
7}

通过 AST 结构对比,插件的核心就是将原始函数的 body 放到 try 语句中

babel 插件开发

参考资料:《「历时 8 个月」10 万字前端知识体系总结(工程化篇)🔥》

这里简单回顾一下

插件的基本格式示例

1module.exports = function (babel) {
2   let t = babel.type
3   return {
4     visitor: {
5       // 设置需要范围的节点类型
6       CallExression: (path, state) => {
7         do soming ……
8       }
9     }
10   }
11 }

1)通过 babel 拿到 types 对象,操作 AST 节点,比如创建、校验、转变等

2)visitor:定义了一个访问者,可以设置需要访问的节点类型,当访问到目标节点后,做相应的处理来实现插件的功能

寻找 await 节点

回到业务需求,现在需要找到 await 节点,可以通过AwaitExpression表达式获取

1module.exports = function (babel) {
2  let t = babel.type;
3  return {
4    visitor: {
5      // 设置AwaitExpression
6      AwaitExpression(path) {
7        // 获取当前的await节点
8        let node = path.node;
9      },
10    },
11  };
12};

向上查找 async 函数

通过findParent方法,在父节点中搜寻 async 节点

1// async节点的属性为true
2const asyncPath = path.findParent((p) => p.node.async);

这里要注意,async 函数分为 4 种情况:函数声明 、箭头函数 、函数表达式 、函数为对象的方法

1// 1️⃣:函数声明
2async function fn() {
3  await f();
4}
5
6// 2️⃣:函数表达式
7const fn = async function () {
8  await f();
9};
10
11// 3️⃣:箭头函数
12const fn = async () => {
13  await f();
14};
15
16// 4️⃣:async函数定义在对象中
17const obj = {
18  async fn() {
19    await f();
20  },
21};

需要对这几种情况进行分别判断

1module.exports = function (babel) {
2  let t = babel.type;
3  return {
4    visitor: {
5      // 设置AwaitExpression
6      AwaitExpression(path) {
7        // 获取当前的await节点
8        let node = path.node;
9        // 查找async函数的节点
10        const asyncPath = path.findParent(
11          (p) =>
12            p.node.async &&
13            (p.isFunctionDeclaration() ||
14              p.isArrowFunctionExpression() ||
15              p.isFunctionExpression() ||
16              p.isObjectMethod())
17        );
18      },
19    },
20  };
21};

利用 babel-template 生成 try/catch 节点

babel-template可以用以字符串形式的代码来构建 AST 树节点,快速优雅开发插件

1// 引入babel-template
2const template = require("babel-template");
3
4// 定义try/catch语句模板
5let tryTemplate = `
6try {
7} catch (e) {
8console.log(CatchError:e)
9}`;
10
11// 创建模板
12const temp = template(tryTemplate);
13
14// 给模版增加key,添加console.log打印信息
15let tempArgumentObj = {
16  // 通过types.stringLiteral创建字符串字面量
17  CatchError: types.stringLiteral("Error"),
18};
19
20// 通过temp创建try语句的AST节点
21let tryNode = temp(tempArgumentObj);

async 函数体替换成 try 语句

1module.exports = function (babel) {
2  let t = babel.type;
3  return {
4    visitor: {
5      AwaitExpression(path) {
6        let node = path.node;
7        const asyncPath = path.findParent(
8          (p) =>
9            p.node.async &&
10            (p.isFunctionDeclaration() ||
11              p.isArrowFunctionExpression() ||
12              p.isFunctionExpression() ||
13              p.isObjectMethod())
14        );
15
16        let tryNode = temp(tempArgumentObj);
17
18        // 获取父节点的函数体body
19        let info = asyncPath.node.body;
20
21        // 将函数体放到try语句的body中
22        tryNode.block.body.push(...info.body);
23
24        // 将父节点的body替换成新创建的try语句
25        info.body = [tryNode];
26      },
27    },
28  };
29};

到这里,插件的基本结构已经成型,但还有点问题,如果函数已存在 try/catch,该怎么处理判断呢?

若函数已存在 try/catch,则不处理

1// 示例代码,不再添加try/catch
2async function fn() {
3  try {
4    await f();
5  } catch (e) {
6    console.log(e);
7  }
8}

通过isTryStatement判断是否已存在 try 语句

1module.exports = function (babel) {
2  let t = babel.type;
3  return {
4    visitor: {
5      AwaitExpression(path) {
6        // 判断父路径中是否已存在try语句,若存在直接返回
7        if (path.findParent((p) => p.isTryStatement())) {
8          return false;
9        }
10
11        let node = path.node;
12        const asyncPath = path.findParent(
13          (p) =>
14            p.node.async &&
15            (p.isFunctionDeclaration() ||
16              p.isArrowFunctionExpression() ||
17              p.isFunctionExpression() ||
18              p.isObjectMethod())
19        );
20        let tryNode = temp(tempArgumentObj);
21        let info = asyncPath.node.body;
22        tryNode.block.body.push(...info.body);
23        info.body = [tryNode];
24      },
25    },
26  };
27};

添加报错信息

获取报错时的文件路径 filePath 和方法名称 funcName,方便快速定位问题

获取文件路径

1// 获取编译目标文件的路径,如:E:\myapp\src\App.vue
2const filePath = this.filename || this.file.opts.filename || "unknown";

获取报错的方法名称

1// 定义方法名
2let asyncName = "";
3
4// 获取async节点的type类型
5let type = asyncPath.node.type;
6
7switch (type) {
8  // 1️⃣函数表达式
9  // 情况1:普通函数,如const func = async function () {}
10  // 情况2:箭头函数,如const func = async () => {}
11  case "FunctionExpression":
12  case "ArrowFunctionExpression":
13    // 使用path.getSibling(index)来获得同级的id路径
14    let identifier = asyncPath.getSibling("id");
15    // 获取func方法名
16    asyncName = identifier && identifier.node ? identifier.node.name : "";
17    break;
18
19  // 2️⃣函数声明,如async function fn2() {}
20  case "FunctionDeclaration":
21    asyncName = (asyncPath.node.id && asyncPath.node.id.name) || "";
22    break;
23
24  // 3️⃣async函数作为对象的方法,如vue项目中,在methods中定义的方法: methods: { async func() {} }
25  case "ObjectMethod":
26    asyncName = asyncPath.node.key.name || "";
27    break;
28}
29
30// 若asyncName不存在,通过argument.callee获取当前执行函数的name
31let funcName =
32  asyncName || (node.argument.callee && node.argument.callee.name) || "";

添加用户选项

用户引入插件时,可以设置exclude、include、 customLog选项

exclude: 设置需要排除的文件,不对该文件进行处理

include: 设置需要处理的文件,只对该文件进行处理

customLog: 用户自定义的打印信息

最终代码

入口文件 index.js

1// babel-template 用于将字符串形式的代码来构建AST树节点
2const template = require("babel-template");
3
4const {
5  tryTemplate,
6  catchConsole,
7  mergeOptions,
8  matchesFile,
9} = require("./util");
10
11module.exports = function (babel) {
12  // 通过babel 拿到 types 对象,操作 AST 节点,比如创建、校验、转变等
13  let types = babel.types;
14
15  // visitor:插件核心对象,定义了插件的工作流程,属于访问者模式
16  const visitor = {
17    AwaitExpression(path) {
18      // 通过this.opts 获取用户的配置
19      if (this.opts && !typeof this.opts === "object") {
20        return console.error(
21          "[babel-plugin-await-add-trycatch]: options need to be an object."
22        );
23      }
24
25      // 判断父路径中是否已存在try语句,若存在直接返回
26      if (path.findParent((p) => p.isTryStatement())) {
27        return false;
28      }
29
30      // 合并插件的选项
31      const options = mergeOptions(this.opts);
32
33      // 获取编译目标文件的路径,如:E:\myapp\src\App.vue
34      const filePath = this.filename || this.file.opts.filename || "unknown";
35
36      // 在排除列表的文件不编译
37      if (matchesFile(options.exclude, filePath)) {
38        return;
39      }
40
41      // 如果设置了include,只编译include中的文件
42      if (options.include.length && !matchesFile(options.include, filePath)) {
43        return;
44      }
45
46      // 获取当前的await节点
47      let node = path.node;
48
49      // 在父路径节点中查找声明 async 函数的节点
50      // async 函数分为4种情况:函数声明 || 箭头函数 || 函数表达式 || 对象的方法
51      const asyncPath = path.findParent(
52        (p) =>
53          p.node.async &&
54          (p.isFunctionDeclaration() ||
55            p.isArrowFunctionExpression() ||
56            p.isFunctionExpression() ||
57            p.isObjectMethod())
58      );
59
60      // 获取async的方法名
61      let asyncName = "";
62
63      let type = asyncPath.node.type;
64
65      switch (type) {
66        // 1️⃣函数表达式
67        // 情况1:普通函数,如const func = async function () {}
68        // 情况2:箭头函数,如const func = async () => {}
69        case "FunctionExpression":
70        case "ArrowFunctionExpression":
71          // 使用path.getSibling(index)来获得同级的id路径
72          let identifier = asyncPath.getSibling("id");
73          // 获取func方法名
74          asyncName = identifier && identifier.node ? identifier.node.name : "";
75          break;
76
77        // 2️⃣函数声明,如async function fn2() {}
78        case "FunctionDeclaration":
79          asyncName = (asyncPath.node.id && asyncPath.node.id.name) || "";
80          break;
81
82        // 3️⃣async函数作为对象的方法,如vue项目中,在methods中定义的方法: methods: { async func() {} }
83        case "ObjectMethod":
84          asyncName = asyncPath.node.key.name || "";
85          break;
86      }
87
88      // 若asyncName不存在,通过argument.callee获取当前执行函数的name
89      let funcName =
90        asyncName || (node.argument.callee && node.argument.callee.name) || "";
91
92      const temp = template(tryTemplate);
93
94      // 给模版增加key,添加console.log打印信息
95      let tempArgumentObj = {
96        // 通过types.stringLiteral创建字符串字面量
97        CatchError: types.stringLiteral(
98          catchConsole(filePath, funcName, options.customLog)
99        ),
100      };
101
102      // 通过temp创建try语句
103      let tryNode = temp(tempArgumentObj);
104
105      // 获取async节点(父节点)的函数体
106      let info = asyncPath.node.body;
107
108      // 将父节点原来的函数体放到try语句中
109      tryNode.block.body.push(...info.body);
110
111      // 将父节点的内容替换成新创建的try语句
112      info.body = [tryNode];
113    },
114  };
115  return {
116    name: "babel-plugin-await-add-trycatch",
117    visitor,
118  };
119};

util.js

1const merge = require("deepmerge");
2
3// 定义try语句模板
4let tryTemplate = `
5try {
6} catch (e) {
7console.log(CatchError,e)
8}`;
9
10/*
11 * catch要打印的信息
12 * @param {string} filePath - 当前执行文件的路径
13 * @param {string} funcName - 当前执行方法的名称
14 * @param {string} customLog - 用户自定义的打印信息
15 */
16let catchConsole = (filePath, funcName, customLog) => `
17filePath: ${filePath}
18funcName: ${funcName}
19${customLog}:`;
20
21// 默认配置
22const defaultOptions = {
23  customLog: "Error",
24  exclude: ["node_modules"],
25  include: [],
26};
27
28// 判断执行的file文件 是否在 exclude/include 选项内
29function matchesFile(list, filename) {
30  return list.find((name) => name && filename.includes(name));
31}
32
33// 合并选项
34function mergeOptions(options) {
35  let { exclude, include } = options;
36  if (exclude) options.exclude = toArray(exclude);
37  if (include) options.include = toArray(include);
38  // 使用merge进行合并
39  return merge.all([defaultOptions, options]);
40}
41
42function toArray(value) {
43  return Array.isArray(value) ? value : [value];
44}
45
46module.exports = {
47  tryTemplate,
48  catchConsole,
49  defaultOptions,
50  mergeOptions,
51  matchesFile,
52  toArray,
53};

参考资料:babel-plugin-await-add-trycatch