原来的:
前言
在开发过程中,您是否经常在异步函数中编写 try/catch 逻辑以提高系统鲁棒性或捕获异步错误?
async function func() {
try {
let res = await asyncFunc()
} catch (e) {
//......
}
}
我曾经在《合格的高级后端工程师必须掌握的28项JavaScript技能》中提到过处理async/await的高尚方式
这样我们就可以用一个helper函数来包装这个async函数来实现错误捕获
async function func() {
let [err, res] = await errorCaptured(asyncFunc)
if (err) {
//... 错误捕获
}
//...
}
但这样做有一个缺陷。 每次使用时,必须引入辅助函数errorCaptured。 有没有“偷懒”的办法呢?
答案一定是肯定的。 在那篇博客之后我提出了一个新想法,可以通过 webpack 加载器手动注入 try/catch 代码。 最后的结果希望是这样的
// development
async function func() {
let res = await asyncFunc()
//...其他逻辑
}
// release
async function func() {
try {
let res = await asyncFunc()
} catch (e) {
//......
}
//...其他逻辑
}
不是很好吗? 开发环境中不需要任何多余的代码,让webpack自动将错误捕捉逻辑注入到生产环境的代码中,然后我们会逐步实现这个loader
装载机原理
在实现这个webpack加载器之前,我们先简单介绍一下加载器的原理。 我们在 webpack 中定义的加载器本质上只是函数。 在定义loader的时候,我们会同时定义一个test属性。 Webpack 会遍历所有模块名称。 当匹配到 test 属性定义的正则时,这个模块会作为源参数传递给加载器执行
{
test: /.vue$/,
use: "vue-loader",
}
当匹配到以 .vue 结尾的文件名时,该文件将作为源参数传递给 vue-loader。 use 属性前面可以是字符串或路径。 当为字符串时,默认会被视为一个nodejs模块,去node_modules中查找
虽然此类文件本质上都是字符串(图片、视频都是 Buffer 对象),但以 vue-loader 为例,loader 接收到文件时,会通过字符串匹配将其分为 3 部分,模板字符串将会是 vue-loader编译成render函数,脚本部分会交给babel-loader,样式部分交给css-loader。 同时,装载机遵循单一原则,即一台装载机只做一件事,这样多个装载机可以灵活组合,互不干扰。
实施思路
因为加载器可以读取匹配到的文件,并处理成想要的输出,所以我们可以自己实现一个加载器,接受js文件,遇到await关键字时,用一层try/catch包装代码
那么如何才能将try/catch包裹在await及其旁边的表达式上呢?这里就需要用到抽象语法树(AST)的知识了
谷草转氨酶
抽象语法树是源代码[1]语法[2]结构的具体表示。它以树[3]的形式表达编程语言[4]的句子结构,树上的每个节点代表源代码中的结构
很多特别有用的功能都可以通过AST来实现,比如将ES6之后的代码转换为ES5、eslint检测、代码美化,甚至js引擎也是依靠AST来实现的。 仅限于js之间的转换,scss、less等css预处理器也会通过AST转换浏览器识别的css代码。 我们举个反例
let a = 1
let b = a + 5
将其转换为抽象语法树后,它看起来像这样
将字符串转换为 AST 树需要两个步骤:词法分析和句子分析
词法分析将代码片段转换为标记(词法单元)并删除空格注释。 例如,第一行将 let、a、=、1 转换为标记。 token是一个对象,描述了代码片段在整个代码中的位置以及日志当前值的一些信息
语法分析会将token结合当前语言(JS)的句型转换为Node(节点),Node中包含一个type属性来记录当前的类型。 例如,let在JS中代表变量声明的关键字,所以它的类型是VariableDeclaration,而a=1会作为let的声明描述,它的类型是VariableDeclarator,声明描述依赖于变量声明,所以这是一个层级关系
另外可以发现,一个token并没有对应一个Node。 等号两边必须有值才能构成声明语句,否则会发出警告。 这就是eslint的基本原理。最后将所有Node组合起来生成AST语法树
推荐一款非常实用的AST查看工具,AST explorer,可以更直观地查看代码是如何转换成抽象语法树的
回到代码的实现,我们只需要通过AST树找到await表达式,并用try/catch Node节点包裹await即可。
async function func() {
await asyncFunc()
}
对应的AST树:
async function func() {
try {
await asyncFunc()
} catch (e) {
console.log(e)
}
}
对应的AST树:
装载机开发
有了具体的想法,接下来我们就开始编译loader。 当我们的加载器接收到源文件时,@babel/parser包可以将文件转换为AST抽象语法树,那么如何找到对应的await表达式呢?
这需要另一个 babel 包 @babel/traverse。 通过@babel/traverse,可以传入一棵AST树和一些钩子函数,然后深度遍历传入的AST树。 当遍历到的节点与钩子函数同名时,会执行对应的反弹
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
//...
}
})
//...
}
通过@babel/traverse,我们是不是可以很轻松的找到await表达式对应的Node节点,下一步就是创建一个TryStatement类型的Node节点,最后将await放入其中。这里我们还需要依赖另一个包@babel /types,可以理解为babel版本的loadsh库。 它提供了很多与AST的Node节点相关的辅助功能。 我们需要使用tryStatement方法,即创建一个TryStatement节点。 节点
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
let tryCatchAst = t.tryStatement(
//...
)
//...
}
})
}
try语句接受3个参数,第一个是try子句,第二个是catch子句,第三个是finally子句。 一个完整的try/catch语句对应的Node节点如下所示
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
let tryCatchAst = t.tryStatement(
// try 子句(必需项)
t.blockStatement([
t.expressionStatement(path.node)
]),
// catch 子句
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCatchAst
])
}
})
//...
}
使用 blockStatement 和 expressionStatement 方法创建块级作用域和携带等待表达式的 Node 节点。 @babel/traverse 会给每个钩子函数传递一个路径参数,其中包含了当前遍历的一些信息,比如当前节点、上次遍历的路径对象以及对应的节点,最重要的是有一些方法操作Node节点,我们需要使用replaceWithMultiple方法将当前Node节点替换为try/catch语句的Node节点
另外,我们还要考虑到await表达式可能会被用作声明语句
let res = await asyncFunc()
它也可能是一个赋值语句
res = await asyncFunc()
也可以是简单的表达
await asyncFunc()
这三种情况对应的AST也不同,所以我们需要分别处理。 @bable/types 提供了丰富的区分函数。 在AwaitExpression钩子函数中,我们只需要判断上级节点是哪种类型的Node节点即可,另外还可以使用AST explorer查看最终需要生成的AST树的结构
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
if (t.isVariableDeclarator(path.parent)) { // 变量声明
let variableDeclarationPath = path.parentPath.parentPath
let tryCatchAst = t.tryStatement(
t.blockStatement([
variableDeclarationPath.node // Ast
]),
t.catchClause(
//...
)
)
variableDeclarationPath.replaceWithMultiple([
tryCatchAst
])
} else if (t.isAssignmentExpression(path.parent)) { // 赋值表达式
let expressionStatementPath = path.parentPath.parentPath
let tryCatchAst = t.tryStatement(
t.blockStatement([
expressionStatementPath.node
]),
t.catchClause(
//...
)
)
expressionStatementPath.replaceWithMultiple([
tryCatchAst
])
} else { // await 表达式
let tryCatchAst = t.tryStatement(
t.blockStatement([
t.expressionStatement(path.node)
]),
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCatchAst
])
}
}
})
//...
}
收到替换后的AST树后,使用@babel/core包中的transformFromAstSync方法将AST树转换为对应的代码字符串并返回
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
const core = require("@babel/core")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
// 同上
}
})
return core.transformFromAstSync(ast).code
}
在此基础上还暴露了一些loader配置项,提高易用性。 例如,如果await语句已经被try/catch包裹,则不会再次注入。 原理也是基于AST。 使用path参数的findParent方法向下遍历所有父Node,判断是否被try/catch的Node包裹
traverse(ast, {
AwaitExpression(path) {
if (path.findParent((path) => t.isTryStatement(path.node))) return
// 处理逻辑
}
})
另外,catch子句中的代码片段也支持自定义,以便所有错误都使用统一的逻辑进行处理。 原理是将用户配置的代码片段转换为AST,并在创建TryStatement节点时作为参数传递给catch节点。
进一步改进
经过评论区交流css写箭头,我会默认在每个await语句中添加一个try/catch,并修改为对整个async函数进行包装try/catch。 原理是先找到await语句,然后递归向下遍历
当发现async函数时,创建一个try/catch Node节点,并将原async函数中的代码作为该Node节点的子节点,并替换async函数的函数体
当遇到try/catch时,说明已经被try/catch包裹了,取消注入,直接退出遍历,这样当用户有自定义的错误捕获代码时,加载器默认的捕获逻辑就不会了被处决
对应的AST树:
对应的AST树:
这只是最基本的async函数声明的node节点。 除此之外,还有函数表达式、箭头函数、作为对象的方式等其他表达形式。 当满足其中一个条件时,注入try/catch代码块
// 函数表达式
const func = async function () {
await asyncFunc()
}
// 箭头函数
const func2 = async () => {
await asyncFunc()
}
// 方法
const vueComponent = {
methods: {
async func() {
await asyncFunc()
}
}
}
总结
本文旨在做一个介绍。 在日常开发过程中,您可以结合自己的业务线css写箭头,开发出更适合您的加载器。 比如技术栈是jQuery的老项目,可以匹配$.ajax的Node节点,统一注入错误处理逻辑。 你甚至可以定制一些ECMA没有的新句型
抱歉,但是如果你明白了编译的原理,你真的可以为所欲为
通过开发这个loader,你不仅可以了解webpack loader是如何工作的,还可以学到很多关于AST和babel的原理。 更多方式可以查看babel官方文档或者babel手册
我已经在 npm 上发布了这个加载器。 有兴趣的同学可以直接调用 npm install async-catch-loader -D 来安装研究。 它的使用方式与通常的加载器相同。 记得将其放在 babel-loader 后面以优先执行。 注入后的结果继续被babel转义
{
test: /.js$/,
use: [
"babel-loader?cacheDirectory=true",
'async-catch-loader'
]
}
更多细节和源代码请查看github。 如果这篇文章对您有用,希望您能点个star。 非常感谢~
异步捕获加载器
参考
[1]
源代码:%E6%BA%90%E4%BB%A3%E7%A0%81
[2]
语法:%E8%AF%AD%E6%B3%95%E5%AD%A6
[3]
树(图论):%E6%A0%91_(%E5%9B%BE%E8%AE%BA)
[4]
编程语言:%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80
❤️爱心三连击
1.看到这里了就点个在看支持下吧,你的「在看」是我创作的动力。
2.关注公众号
程序员成长指北
,「带你一起学Node」!3.特殊阶段,带好口罩,做好个人防护。
4.可以添加我微信【ikoala520】,拉你进技术交流群一起学习。
“在看转发”是最大的支持