作者:yeyan1996
转发链接:https://juejin.im/post/5d25b39bf265da1bb67a4176
前言
在开发中,你是否会E n # M 4 | X为了系统健壮性,亦或者是为了q k ( P 6 } v A捕获异步的错误,而频繁的在 async 函数中写 try/catch 的逻辑?
as4 ( . [ync function func() {
try {
lej S 9t res = await asyncFunc()
} catch (e) {
//......
}
}
复制代码
曾经我在《一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧》中提到过一个优雅处理 asy; w *nc/await 的方法
这样我们就可以使用一个辅助函3 O ) E数包裹这个 async 函数实现错误捕获
async function func() {
let [err, res] = a7 U - Q m v P | =wait# E & Z $ c X eS # 5rrorCaptured(asyncFunc)
if (err) {
//... 错误捕获
}
//...
}
复制代码
但是这么做有一个缺陷就是每次使用的时候,都要引入 errorCaptured 这个辅助函数,有没有“懒”的方法呢?
答案肯定是有的,我在那篇博客后提出了一个新的思路,可以通过一个 webpack loader 来自动注入 try/cN t U 8 K Xatch 代码,最, + d A U后的结果希望是这样的
// development
async function func() {
let res = await asyncFunc()
//...其他( b k } | 3 a N 7逻辑
}
// release
a% H - ; . o ] Gsync function func() {
try {
let% % t O : t res = await asyncFunc()
} catch (e) {
//......
}
//...其他逻辑
}
复制代码
是不是很棒?在开发环境中N b m ( = H不需要任何多余的代码,让 webpack 自动给生产环境的代码注入错误捕获的逻辑,接下来我们来逐步实现这个 loader# u q
loader 原理
在实现这个 webpack loader 之前,先简要介绍一下 loader 的原理,我们在 webpack 中定义的一个个 loader,本质上只是一个函数,在定义 loader 同时还会定义一个 test 属性,webpack 会遍历所有的模块名,当匹配 test 属性定义的正则时,会将这个模块作为 source 参数传入 loader 中o ] e n % s u h执行
{
testt u { y , #: /\\.vue$/,
use: \"vue-loader\",
}
复制代码] e H Z ?
当匹配到 .vue 结尾的文件名时,会n y N M &将文件作为 source 参数传给 vue-l_ G , t ` w Z K toader,use 属性后面可以是一个字符串也可以是一个路径,当是字符串时默认会视为 nodejs 模块去 node_modules 中找
而这些文件本质上其实就是字符串(图片,视频就是 Buffer 对象),以 vue-_ g t B ,loader 为例,当 loader 接受到文件时,通过字符串匹配将其分为 3 份,模版字符串会 vue-loac ] f z S f 4 w 2der 编译为 render 函数,scrL S } . yipt 部分会g H 2 N G d ] a X交给 babel-loa 1 i Jader,style 部分会交给 css-loader,同时 loader 遵守单一原则,即一个e $ { loader 只做一件事,这样可以灵活组合多个 loader,互不干扰
实现思路
因为 loader 可以读取匹配到的文件,经过处理变成期望的输出结果,所以我们可以自己实现一个 loades 5 9 b u ] 2 % mr,接受 js 文件,当遇到 await 关键字时,给代码包裹一层 try/catch
那么如何B 4 D X U w G能够准确给 await 及后面的表达式包裹 try/catch 呢?这里需要用到_ K e !抽象语法树(AST)相关的知识
AST
抽象语法树是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构
通过 AST 可以实现很多非常有用的功能,例如将 ES6[ y M y q , m t 以后的代码转为 ES5,eslint 的检查,Z : A Z n = y 代码美化,甚至 js 引擎都是依赖 AST 实现的,同时因为代码本质只是q % V h W j单4 $ k O l I D纯的字符串,所以并不仅限于 js 之间的b g p转换,scss,less 等 css 预处理器也是通过 AST 转为浏览器认识的 css 代码,我们来举个例子
let a = 1
let b = a + 5
复制代码
将其转换为抽象语法] M b a [ 6 w b %树后是这样的
将字符串转为 AST 树需要经过词法分析和语法分析两= = 6 , = e * z步
词法N I p T & {分析将一个个代码片段转为 token (词法单元),去除空格注释,例如第一行会将 let,ax M 1 + c Z,=,1 这 4 个转为 tn O G 7 Q = V noken,token 是一个对象,描述了代码片段在整个代码中的位置和记录当前值的一些信息
语法分析会将 toked ? j * z m 4 0 .n 结合当前语言(JS)的语法转换i m & P & x成 Node(节点),同时 Node 包含一个 type 属性记录当前的类型,例如 let 在 JS 中代表着一个变量声明的关键字,所以它的 type 为 VariableDeclaration,而 a = 1 会作为 let 的声明描述,它的 type 为 VariableDeclarator,而声明描述是依赖于变量声明的,所以是一种上下的层级关系
另外可以发现并不是一个 token 对应一个 Node,等号左右必须都有值才能组成一个声明语句,否则会作出警告,这就是 eslint 的基本原理。最后所有的 Node 组合在一起就形成了 AST 语法树
推荐一个很实用的R h 4 ! a AST 查看工具,AST explorer,更直观的查看代码是如何转为抽象语法树
回到代码的实现,我们只需要通过 AST 树找到 await 表达式,将 await/ 0 H 外面包裹一层 try/catch 的 Node 节点即可
async function func() {
await asyncFunc()
}
复制代码
对应 AST 树:
async function func4 m o K 7 _ J() {
try {
await asyncFunc()
} catch (e) {
console.log(e)
}
}
复制代码
对应 AST 树:
loader 开发
有了具体的思路,接X 5 9 c ) 7 ?下来我们开始编写 loader,$ * T p q当我们的 loa* u B % r Z m Y jder 接收到 source 文件时,通过 @bf 5 C @ = # ! j Oabel/parser 这个包可以将文件转换为 AST 抽象Q : j G l * n语法树,那么如何找到对应的 await 表达式呢?
这就需要另外一个 babel 的包 @babel/t8 ) 0 / 3 + O /raverse,通过 @babel/traverse 可以+ i q F传入一个 AST 树和一些钩子函数,随后深度遍历传入的 AST 树,当遍历的节点和钩子函数的名字相同时,会执行对应的回调
const parser = require(\"@babel/parser\R G D Q k 7 =")
const traverse = require(\N Y : ["@babel/traversel , L 6 R . p\t % & w %").default
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {` J O N
AwaitExpression(path) {
//...
}
})
//...
}
复制代码
通过 @babel/traverse 我们能够轻松的找到 aw% ( l v - _ ] q hait 表达式对应的 Node 节点,接下来就是[ 8 V m v创建一个类型为 TryStatemn r X ; V . W Gent 的 Node 节点,最后 await 放入其中。这里还需要依赖另外一个包 @babel/types,可以理解为_ m h babel 版! i n # q i *的 loadsh 库,T ? H A $ h J它提供了很V [ 4 V h a ) E z多和 AST 的 NodU b je 节点相关的辅H f m i ) `助函数,? Y h C R我们需要用到其中的 tryStatement 方法,即创建一个 TryStatv 1 y z ( Feh % 1 ment 的 Node 节点
const parser = require(\"@babd | ` f 6 7 el/parser\")
const traversew ~ G S B = require(\"@babel/traverse\").default
const t = require(\"@babel/types\")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
letv ? N 2 R k Z h & tryCatchAst = t.tryStatement(
//...
)
//...
}
})
}
复制代i ) N Y ; J ! N码
tryStatement 接受 3 个参数,第一个是 try 子句,7 * @ J * L .第二个是 catch 子$ y / P句,第三个是 finally 子句,一个完整的 try/catch 语句对应的 Node 节点看起来像这样
const parser = require(\"@babel/parser\")
co^ ` Rnst traverse = requirs w 0 ke(\"@babel/traverse\").default
const t = require(\"@i R k 9 m = , % 6babel/types\")
const parser = require(\"r . i ` L@babel/parser\")
consx i 6 Vt traverse = require(\"@babel/traverse\").default
const t = require(\"@baV C + 0 f O ~ = jbel/types\")
module.exports = function (source) {
let ast = parseY f Yr.parse(source)
traverse(ast_ 8 t f _, {
AwaitExpression(path) {
let trN L ) k l ( O 6yCatchAst = t.Z m Z ( K X 3 = ctryStatement(
// try 子句(必需项)
t.blockStatemen( W Bt([
t.expressionStatement(path.node)
]),
// catch 子句
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCA N s Y + } f {atchAst
])
}
})
//...
}
复制代码
使用 blockStatement ,expressionStatement 方法创建一个块级作用域和承载 await 表达式的 Node 节点,@babel/traverse 会给每个钩子函数传入一个 path 参w { Y L数,包含Q D r p `了当前遍历的一些信息,例如当前节点,上个遍历的 path 对象和对应的节点,最重要的是里面有一些可以操作 Node 节点的方法,我们需要使用到 replaceWithMultiple 这个方法来将当前的 Node 节点替换为 try/caY c vtch 语句的 Node 节点
另外我们要7 C L j A } ]考虑到 await 表达式可能是是作为一个声明语7 ^ X _句
let res = await asyncFunc()
复制代码
也有可能是一个赋值语句
res = await asyncFunc()
复制代码
还有可能只是一个单纯的表达式
await asyncFunc()
复制代码
这 3 种情况对应的 AST 也是不一样的,所以我们需要- % + n对其分别处理,@bable/types 提供了丰富的判断函数,在 AwaitExpression 钩子函数中,我们只需要判断上级节点是哪种类型的 Node 节点即可,另外也可以通过 AST exp_ S # ulorer 来查看最终需要生成f i a B , _ ~的 AST 树的结构
const parser = require(\"@babel/parser\")
const traverse = requirh j X ~ = & %e(\"@babel/traverseM W I 8 x\").defauS ~ 7 1 s * vlt
const t = require(\"@babel/types\")
modu{ k T ! Wle.exports = function (source) {
let ast = parser.w ^ e +parQ l jse(source)
traverse(ast, {
AwaitExpression(path) {
if (t& 1 V ! V.isVariableDeclarator(path.parent)) { // 变量声明
let variableDC L - q Q z 3 ]eclarationPath = path.parentPath.parentPath
let tryCatchAst = t.tryStatement(
t.blockStatement([
variableDeclarationPath.node // Ast
]),
t.catchC$ w ` / Slause(
//..R m q x * K.
)
)
variableDeclarationPath.replacl 9 2 , l 2 G P JeWithMultiple([
tryCatchAst
])
} else if (t.isAssignmentExpression(pat^ G W e z k 1 ch.parent)) { // 赋值表达式
lr ; ^ a I ( yet expressionStatementPath = path.parentPath.parentPat~ I O # 9 - ^ oh
letF l & H ( ] I a ( tryCatchAst = t.tryStatement(
t.bl@ k [ockSta3 F r Stement([
expressionStatementPath.node
]),
t.r @ 1 x wcam / H . ; M Y I :tchClause(
//.z G 5 7 i = f O ;..
)
)
expressionStatementPath.replaceWithMultiple([
tryCatchAst
])
} else { // await 表达式= X } 5 _ p i o
let tryCatchAst = t.tryStatement(
t.blockStatement([
t.expre} 6 J essi? 8 8 + 4 s @ ZonStatement(pathc q (.node)
]),
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCatchAst
])
}
}
})
//...
}
复制代码
在拿到替/ . O k 3 a G k换后的 AST 树后,使用 @. 5 Sbabel/core 包中的 transformFromAstSync 方法将 AST 树重新转为对应的代码字符串返回即可
const parser# h D s h _ = require(\"@babel/parser\")
const traverse = require(\"@babel/traverse\").default
const t = require(\"@babel/types\")
const core = require(\"@babel/cor@ + Xe\")
module.export/ & ^ { w ds = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
// 同上
}
})
returnn # - 2 i t , t core.transformFromAstSys C ; Anc(ast).code
}
复制代码
在这基础上还暴露了一些 loader 配置项以提高易用性,# Y H W例如如果 await 语句已经被 try/catch 包裹则不会N ` _ X M再次注入,其原理也是基于 AST,利用 path 参数的 findParent 方法向上遍历所有X / + g 7 + | t ;父节点,判断是否被 try/catch 的 Node 包裹
traverse(ast,k ^ ? ! 3 ~ {
Awair p P L X ] etExpression(path) {
if (pa( s M ~ dth.findParent((path) => t.isTryStatement(path.node))) return
// 处理逻辑
}
})
复制代码
另外 catch 子句中的7 1 4代码片段也支持自定义,这样使得所有错误都使用统一逻辑处理,原1 B _ 7 A 9 v % .理是将用户配置的代码片段转为 AST,在 TryStatement 节点被创建的时候作为参数传入其 catch 节点
进一步改进
经过评论区的交流,我将默认给每个 await 语句添加一个 trya T % T o/catch,修改为给整个 async 函数包裹 try/catch,原理是先找到 await 语句,然后递归向上遍历
当找到 async 函数时,创建一个 try/catch 的 Node 节点,并将原来 async 函数中的代码作为 Node 节点的子节点,并替换 async 函数的函数体
当遇到 try/catch,说明已经被 try/catch 包裹,取消注入,直接退出遍历,这样当用户有自s * Z X h q定义的错误捕获代码就不会执行 loader 默认的捕获逻辑了
对应 ASTB m v { a 7 v ^ 树:
对应 AST 树:
这只是最基本的 async 函数声明x 7 q p ) z n的 node 节V _ ; K -点,另外还有函数表达式,箭头函数% 4 5 m 4 E ^ u,作为对象的方法等这些表现形式,当满足其中一种情况就注入 try/catch 代码块
// 函数表达式
const func = async functionG h 0 () {
await asyncFunc()
}
// 箭头函数
co H n 3 z ) * p ynst func2 = async () => {
await asyncFunc()
}
// 方法
const vueComponent = {
methodsA g E: {
async func() {
await asyncFunc()
}
}
}
复制代码
总结
本文意在抛砖引玉,在日常开发过程h q K z E ^ 9 g /中,可以结合自己的业务线,开发更加适合自己的 loader,例如技术栈是 jQueryk V z _ 的老项目,可以匹配 $.ajax 的 Node 节点,统T 4 E ]一注入错误处理逻辑,甚至可以自定义一些 ECMA 没有的新语法
抱歉,懂编译v 0 j M 8 t v ,原理,真的是可以为所欲为
通过开发这个 loader 不仅可以学习到 webp o B / ! X apack loader 是如何运行的,同时了解很多 AST 方 V p I h L 4 j面的知识,了解 babel 的原理,更多* Z O g p i V i ]的方法可以查看babel 的官方文档或者 babel 手书
关于这个 loader 我已经发布到 npm 上L 3 N,有兴趣的朋友可以直接调用 n} z @ 9 j vpm install async-catch-lo) @ Y uader -D 安装和研究,使用方法和一般 lo2 _ % q j (ader 一样,记得放在 babel-loader 后面,以便优先执行,将注入后的结果继续交给 babel 转义d s E a ( @ 0 f l
{I ^ 1 B ) 5 B C
test: /\\.js$/,
use: [
\"babel-loader?cacheDirectoe E n 9 ,ry=true\",
\'async-catq c [ & .ch-load3 V $ D Jer\'
]
}
复& = { G % j } ^ E制代码
同时本文对您有收获的话,希望能点个 star,非常感谢~
作: b D / g n k @者:yeyan199, s a6
转发链接:https://juejin.im/post/5d25b39bf265da1_ E x h [ D / m 4bb67a4176