如何给所有的 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