在笔者的上一篇文章前端工程化(7):你所需要知道的最新的babel兼容性实现方案中剖析了在实际项目中如何使用babel
提供的原生转译能力,得益于babel
强大的转译能力我们无需再担心项目的兼容性问题。但是babel
不只是一款帮助我们处理代码兼容性的工具,我们还可以借助它的插件化能力完成日常工作中一些重复、繁琐的工作。本文将笔者从在实际项目中碰到的问题而萌生用babel
来解决的想法,到一个完整的babel
插件的落地过程做了个总结,向大家展示在面对实际项目中的某些问题时用babel
插件来解决有多香!!
1. 实际项目中出现的问题
项目中会经常用到element-ui
中的$confirm来提示用户进行二次确认,比如在进行删除操作时应当都唤出是否确认删除的提示:
handleDelete (row) {
this.$confirm('是否删除该条数据?', '提示', {
type: 'warning'
}).then(async () => {
this.loading = true
const res = await this.$delete('/api/xx', { id: row.id })
if (!res) return
await this.loadList()
this.$message({
type: 'success',
message: '删除成功!'
})
})
}
上面是我们团队统一约定的删除逻辑编写方式。这么写没啥大问题,但是当我们取消二次确认弹框的时候,浏览器会提示错误:
这个错误想必大家也并不陌生,就是promise
错误没有捕捉。是的,因为我们没有写catch
,因为我们觉得没有什么必要的逻辑要在取消的时候触发(包括提示取消删除之类的)。
虽然这个错误对程序运行没有影响,但是对不熟悉的开发人员定位错误以及错误监控系统都会造成多余的困扰。我们也不好改组件源码,只好强制要求团队成员在每个$confirm
后面手动加上catch
逻辑:
handleDelete (row) {
this.$confirm('是否删除该条数据?', '提示', {
type: 'warning'
}).then(async () => {
this.loading = true
const res = await this.$delete('/api/xx', { id: row.id })
if (!res) return
await this.loadList()
this.$message({
type: 'success',
message: '删除成功!'
});
}).catch(err => err)
}
代码中充斥着大量的使用$confirm
的逻辑,如果靠人力去手动解决这种重复性的问题,一方面增加了工作量,另一方面不能避免会有团队成员疏忽。
有位大佬曾说过:当你在做着一些重复性的工作时,那一定有别的办法来帮助你快速的完成它。这时脑子里就萌生了用babel
插件来自动添加catch
的想法...
2. 编写插件前的准备工作
之所以萌生了用babel
插件的想法,那是因为babel
是从底层将我们代码解析成AST
树,然后对AST
树的节点进行递归遍历,在遍历的过程中,如果有插件则会执行插件中的逻辑对节点进行增删改,最后将修改过的AST
树再生成代码,从而实现代码的修改。所以,在编写插件前,我们首先要分析对应代码的AST
树结构,以及插件的运作方式。
2.1 AST 树结构分析
首先需要借助astexplorer来分析原代码的AST
树结构,以及目标代码的AST
树结构。对比两者结构的差异,从而才能找到转换的切入点。
原代码的主结构为:
this.$confirm().then()
解析的AST树结构(简化):
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "ThisExpression"
},
"property": {
"type": "Identifier",
"name": "$confirm"
}
},
"arguments": []
},
"property": {
"type": "Identifier"
"name": "then"
}
},
"arguments": []
}
}
目标代码的主结构为:
this.$confirm().then().catch()
解析的AST树结构(简化):
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "ThisExpression",
},
"property": {
"type": "Identifier",
"name": "$confirm"
}
},
"arguments": []
},
"property": {
"type": "Identifier",
"name": "then"
}
},
"arguments": []
},
"property": {
"type": "Identifier"
"name": "catch"
}
},
"arguments": []
}
}
关于babel
是怎么将代码转换成AST
树结构的在这里不再阐述,这里就大概分析下代码是如何跟AST
树对应上的:
-
首先,这行代码被称为表达式语句,所以这行代码的顶级节点的
type
就为ExpressionStatement
(在javascript
中,一行代码要么是表达式要么是声明,所以AST
树的顶级节点类型要么是Statement
要么是Declaration
)。 -
其次,这行代码是个调用表达式,所以次级节点的
type
为CallExpression
。这里的调用顺序的解析要注意,是从右往左依次解析的,可以理解为「「「this.$confirm()」.then()」.catch()」
。 -
接着,这个调用表达式的被调用者是成员表达式,所以接下来的节点的
type
为MemberExpression
,而成员表达式的访问的对象又是一个调用表达式,调用表达式的被调用者又是成员表达式,依次类推,一直解析到this.$confirm
为止。
我们可以看到,this.$confirm.then().catch()
的树结构在this.$confirm.then()
树结构的基础上多了一层调用表达式节点,被调用者是个成员表达式,而成员表达式的对象就是this.$confirm.then()
最外层的调用表达式节点。
知道这个特征后,我们接下来就可以利用插件来完成转换。
2.1 babel 插件结构分析
一个插件的基本结构如下所示:
module.exports = ({ types: t }) => {
return {
visitor: {
...
}
}
}
可以看出,babel
插件其实就是个函数,入参是babel
对象,其中包含了babel
所有的工具对象。最常用的是types
工具对象,我们在编写插件的时候基本都需要依赖它提供的创建AST
节点、验证AST
节点的方法。
创建一个节点可以通过types
调用该节点名称对应的方法:
t.identifier('a')
验证一个节点可以通过types
调用is
+ 该节点名称对应的方法:
t.isIdentifier(node)
插件函数最后会返回一个对象,对象里面定义一个visitor
(访问者)属性。在visitor
中可以定义你想要访问的节点类型,节点类型以函数的形式定义,这样当AST
遍历到你想要访问的节点类型时,则会执行你定义的节点类型方法。比如你想要访问CallExpression
类型节点,并对这节点做一些操作:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path) {
// do sth
}
}
}
}
节点类型方法接收一个path
(路径)参数,path
表示两个节点之间连接的对象,path
中存储着当前AST
节点信息以及一些节点操作方法,列举几个常用的:
-
path
中的属性:node
- 当前遍历到的节点信息parent
- 当前遍历到的节点信息的父节点信息parentPath
- 当前遍历到的节点的父节点路径scope
- 作用域
-
path
中的方法:findParent
- 找寻特定的父节点getSibling
- 获取同级路径getFunctionParent
- 获取包含该节点最近的父函数节点getStatementParent
- 获取包含该节点最近的表达式节点relaceWith
- 替换一个节点relaceWithMultiple
- 用多节点替换单节点insertBefore
- 在之前插入兄弟节点insertAfter
- 在之后插入兄弟节点remove
- 删除节点pushContainer
- 将节点插入到容器中stop
- 停止遍历skip
- 跳过此次遍历
当有一个节点类型方法的访问者时,实际上是在访问该节点的路径而非节点本身。我们对节点的操作也都是在操作路径,而不是节点本身。
所以,插件都是通过修改path
对象来修改AST
结构。我们只要合理运用path
提供的属性和方法,再辅以babel-types
提供的校验、创建节点能力,就可以简单的完成AST
树节点的增删改。
3. 开始编写插件
3.1 环境搭建
我们需要搭建一个环境来方便开发、调试以及发布babel
插件。首先安装几个babel
的核心包:
"devDependencies": {
"@babel/cli": "^7.14.5",
"@babel/core": "^7.14.6",
"@babel/preset-env": "^7.14.7"
}
新建文件夹src
和test
,src
中存放插件源码,test
中存放测试用例,然后配置打包和调试命令:
"scripts": {
"build": "rm -rf lib && babel src/index.js -d lib",
"test": "babel test/index.js -d test/compiled --watch"
},
最后新建babel.config.js
文件并配置plugins
,该配置项是一个数组,表示babel
需要加载的插件列表,我们将其指向自定义插件的路径就可以:
var config = {
presets: [
['@babel/preset-env']
]
}
// 执行 npm run test 时才启用插件
if (process.argv[2].indexOf('test') >= 0) {
config.plugins = [
["./src/index.js"]
]
}
module.exports = config
调试时只需要事先在test/index.js
文件中编写好几个测试用例,然后在src/index.js
中编写插件逻辑,重新执行npm run test
,最后在test/compiled/index.js
文件中查看编译的结果即可。
3.2 逻辑编写
插件的逻辑编写其实不难,关键是要找准我们应该访问哪种节点类型。对于this.$confirm().then()
代码不难看出我们要访问的节点类型是CallExpression
,然后通过CallExpression
节点找到this.$confirm
所在的节点,找到则继续往下执行,没有找到则提前退出:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path) {
const { node } = path
if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
return
}
}
}
}
}
如果找到this.$confirm
所在的节点,则沿着当前节点路径去搜寻父节点中是否有包含catch
的节点,没找到则继续往下执行,找到的话则不做任何操作提前退出:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path) {
const { node } = path
if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
return
}
const catchPath = path.findParent(({ node }) => {
return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
})
if (catchPath) {
return
}
}
}
}
}
如果没有在父节点路劲中找到catch
所在的节点,先获取最外层的then
所在的节点做好构建新节点的准备:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path) {
const { node } = path
if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
return
}
const catchPath = path.findParent(({ node }) => {
return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
})
if (catchPath) {
return
}
const mostOuterThenPath = path.findParent(pPath => {
const node = pPath.node
return t.isCallExpression(node) && isObjectProperty(node.callee.property, 'then') && !t.isMemberExpression(pPath.parentPath.node)
})
}
}
}
}
这里获取最外层then
所在节点的办法是判断当前节点的父节点是否是MemberExpression
,如果不是则是最外层then
所在的节点。
获取到最外层then
所在的节点以后,就用它构建一个callExpression
新节点,并替换掉它:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path) {
const { node } = path
if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
return
}
const catchPath = path.findParent(({ node }) => {
return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
})
if (catchPath) {
return
}
const mostOuterThenPath = path.findParent(pPath => {
const node = pPath.node
return t.isCallExpression(node) && isObjectProperty(node.callee.property, 'then') && !t.isMemberExpression(pPath.parentPath.node)
})
const arrowFunctionNode = t.arrowFunctionExpression(
[t.identifier('err')],
t.identifier('err')
)
const newNode = t.callExpression(
t.memberExpression(
mostOuterThenPath.node,
t.identifier('catch')
),
[arrowFunctionNode]
)
mostOuterThenPath.replaceWith(newNode)
}
}
}
}
完成上述babel
插件逻辑(忽略了一些边界情况),就可以实现this.$confim().then()
到this.$confim().then().catch(err => err)
的转换了。
4. 升级成通用方案
上面的babel
实现方案只针对this.$confirm
来做catch
的添加,插件要是只有这个功能未免也太鸡肋了。所以决定把这个方案升级成通用方案,这个通用方案支持的场景有:
-
不强制规定只给成员访问形式的
Promise
添加catch
,也就是说可以给this.$confirm
、$confirm
、this['$confirm']
、MessageBox.confirm
形式的Promise
添加catch
; -
用户可以选择为指定的
Promise
添加catch
,如果不选择则给所有Promise
都添加catch
;
借助babel
提供的插件选项,在babel.config.js
中修改配置:
config.plugins = [
["./src/index.js", {
promiseNames: ['$confirm', '$prompt', '$msgbox']
}]
]
promiseNames
中定义的选项会通过状态对象传递给插件访问者:
module.exports = ({ types: t }) => {
return {
visitor: {
CallExpression (path, { opts }) {
console.log(opts.promiseNames) // ['$confirm', '$prompt', '$msgbox']
}
}
}
}
- 支持自定义
catch
的回调逻辑,如果不定义,则默认是catch(err => err)
。
也是借助babel
提供的插件选项,在babel.config.js
中修改配置:
config.plugins = [
["./src/index.js", {
catchCallback: 'console.log(err)'
}]
]
同时还借助babel
提供的template
工具,将字符串转换成AST
节点:
module.exports = ({ types: t, template }) => {
return {
visitor: {
CallExpression (path, { opts }) {
console.log(opts.catchCallback) // console.log(err)
...
const arrowFunctionBody = !catchCallback
? t.identifier('err')
: t.BlockStatement([
template.ast(catchCallback)
])
const arrowFunctionNode = t.arrowFunctionExpression(
[t.identifier('err')],
arrowFunctionBody
)
...
}
}
}
}
完整的代码请戳这github.com/pandly/babe…
5. 在实际项目中调试
经过上述几个步骤的操作,一个完整的babel
插件就基本完成了。接下来,就是在实际项目中进行测试了,本地调试可以用npm link
指令来操作:
$ # 先去到模块目录,把它 link 到全局
$ cd path/to/babel-plugin-promise-add-catch
$ npm link
$
$ # 再去项目目录通过包名来 link
$ cd path/to/my-project
$ npm link babel-plugin-promise-add-catch
然后在实际项目的babel
配置文件中加上:
plugins: [
[
'promise-add-catch',
{
promiseNames: ['$confirm', '$prompt', '$msgbox'] // 如果有需要
catchCallback: 'console.log(err)' // 如果有需要
}
]
]
启动项目,删除代码中的catch
,如果控制台没有报错,则说明大功告成!
6. 总结
总体来说,babel
插件的编写入门还是比较简单的,但是要想写好却不是那么简单。入门简单是因为插件化结构清晰,api
封装的强大,文档比较健全;而要想写好一个babel
插件,首先要熟悉代码对应的AST
树结构,其次要去考虑很多边界情况来保证代码的健壮性,最后要熟读babel
文档,掌握babel
提供的api
和属性。
在编写插件的时候碰到两个问题,一直没有找到答案:
-
都说是
plugins
优先于presets
执行,可是在测试async
函数时很明显感受到是presets
优先于plugins
; -
本地
demo
使用@babel/preset-env
会把catch
和finally
方法编译成计算的形式['catch']()
,但是在真实项目中却不会。
以上两个问题有知道的大佬可以在评论区告诉我,非常感谢~~
个人觉得这个插件还是挺实用的,并且已经推广到团队中去了,哈哈哈哈~~
最后,这个插件已发布到npm
,插件地址babel-plugin-promise-add-catch,欢迎各位使用,也欢迎各位提出意见~~
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!