前言
相信现在大多数的前端同学都有听过和用过 Babel
,它的官网如是介绍自己: Babel 是一个 JavaScript 编译器
。在开发中,我们最常用它来做 ES6+
语法向 ES5
的转换,也会用它配合打包工具来做压缩、混淆等工作。它的工作过程可能简单用三个步骤来概括:
- 解析:将代码转换为抽象语法树(
ast
) - 转换:
Babel
或者相关插件对ast
进行操作转换 - 生成:用新的抽象语法树重新生成代码
由上面这三步可以看出,它是使用抽象语法树来贯穿全程的。而基于抽象语法树,我们很容易去做一些静态分析,本文举了三个我在实际工作中遇到的例子,并使用 Babel
去解决这三个实际问题。开始之前,还是有必要介绍一下 Babel
的相关概念以及 API
。
相关概念
先来介绍一下会用到的几个包:
@babel/cli
:Babel
的命令行客户端,可通过命令行编译文件。@babel/parser
:将代码解析成ast
@babel/traverse
:遍历ast
@babel/template
:在转换ast
的过程中,需要替换节点,这个库可以用静态声明的方式去替换,下文会提到@babel/generator
:将ast
生成为代码@babel/types
:工具包,用来生成节点或者检查节点的类型
介绍完会用到的包后,下面来介绍一些 Babel
插件的概念。插件返回的是一个函数,在这个函数里, Babel
帮我们去遍历 ast
,我们采用一个访问者模式去定义各个节点类型的处理函数。如下:
export default function() { return { visitor: { Identifier: { enter:(path) { }, exit:(path) { } }, }, }; }
Identifier
是一种节点的类型,它可以是一个对象,具有 enter
属性和 exit
属性。 ast
是一个树状结构, enter
就是从根节点往子节点遍历方向时触发的回调函数, exit
就是子节点往根节点方向回溯时触发的回调函数。在开发好插件之后,可以项目根目录下的 .babelrc
文件处引用插件。
{ "plugins": ["./src/plugins/autoBindThis"] }
以上就是本文会用到的一些包以及一些概念的简要介绍,更多详细的文档内容可以移步下面这两个链接:
babel中文网: Babel
的各种相关概念以及教程
babel-plugin-handbook:插件开发的相关教程与思路
那么话不多说,下面让我们进入实战环节。
autoBindThis
做过 React
开发的同学应该知道,我们有时候会写出来这样的代码:
class Comp { constructor() { this.func = this.func.bind(this) } func() { this.setState({ //xxx }) } render() { return <Child func={this.func}/> } }
因为 func
函数是在子组件被调用的,如果 func
函数里面有 this
相关的操作,那么 this
最终的指向会不准,之前常见的写法是在构造函数里用 bind
显性绑定一次。那么在了解完 Babel
之后,就可以用 Babel
去解决这个问题。
思路也很清晰,找到所有的函数定义,剔除掉一些肯定不需要绑定的函数——比如 constructor
、组件的生命周期钩子等;然后在 constructor
函数里对每个函数进行 this
绑定。但是有一点需要注意的是,如果原来的代码里不存在 constructor
函数,那么这个插件得先把 constructor
函数给补上。在开发 Babel
相关的东西的时候,astexplorer 这个网站是必不可少的。
在这里我们可以看到代码解析成抽象语法树的数据结构,也可以很方便的看到各个对应的节点以及它们的属性。那么先来实现第一点需求:判断一下原先的代码存不存在 constructor
函数,如果不存在,需要给它补上。
这里可以看到针对于 classBody
这个节点,它的 body
属性对应的就是这个类的所有方法,所以我们只要遍历这个数组,判断一下存不存在 constructor
函数,如果存在则忽略,如果不存在就给它补上。怎么补呢?刚开始接触的话可能有点无从下手,推荐在上面那个网站上写一个 constructor
函数,看看它的数据结构是怎样的。
由上图可以看到,它是一个 classMethod
类型,然后有 kind
、 key
等等属性,再配合我们的编辑器的提示:
就知道它的参数应该怎么传,这两个东西相互配合,然后去构造我们需要的 ast node
。具体的代码实现如下:
module.exports = function ({ types: t }) { return { visitor: { ClassBody: { enter(path) { const allMethods = path.node.body // 判断constructor是否存在 const constructorExist = allMethods.find(method => method.kind === 'constructor') if (!constructorExist) { // 构造一个constructor塞入数组中 path.node.body.unshift( t.classMethod( 'constructor', t.identifier('constructor'), [], t.blockStatement([]) ) ) } } } } }; };
那接下来剔除掉一些不需要绑定 this
的函数,将剩下的所有函数遍历,往 constructor
函数里面加上绑定的代码,也就是加上类似于 this.fun = this.fun.bind(this)
这样的语句。
每一条语句都属于 ExpressionStatement
类型,存放在 body
属性中。所以我们需要把构造出来的 ast node
塞到 body
属性里。
上面这是一条绑定语句的抽象语法树的节点信息,看起来这只是一行代码,但是构造起来还挺麻烦的:
const subAst = t.expressionStatement( t.assignmentExpression( '=', t.memberExpression( t.thisExpression(), t.identifier(path.node.key.name) ), t.callExpression( t.memberExpression( t.memberExpression(t.thisExpression(), t.identifier(path.node.key.name)), t.identifier('bind') ), [t.thisExpression()] ) ) )
这个时候就可以利用上面提过的 babel-template
这个包,它只需要下面几行简单的代码就可以实现上面那一坨的功能:
const template = require('@babel/template'); const templateString = template.default(` this.METHOD = this.METHOD.bind(this) `) const subAst = templateString({ METHOD: t.identifier(path.node.key.name) })
最终可以一起来看一下这个插件实现的完整代码:
const disabled = ['constructor', 'render'/*其他生命周期函数...*/] const template = require('@babel/template'); module.exports = function ({ types: t }) { return { visitor: { ClassBody: { enter(path) { const allMethods = path.node.body const constructorExist = [...allMethods].find(method => method.kind === 'constructor') if (!constructorExist) { path.node.body.unshift( t.classMethod('constructor', t.identifier('constructor'), [], t.blockStatement([]) ) ) } }, }, ClassMethod: { enter(path) { if (!disabled.includes(path.node.kind) && !disabled.includes(path.node.key.name)) { const constructor = path.container.find(method => method.kind === 'constructor'); if (constructor) { const templateString = template.default(` this.METHOD = this.METHOD.bind(this) `) const subAst = templateString({ METHOD: t.identifier(path.node.key.name) }) constructor.body.body.push( subAst ) } } } }, } }; };
然后可以通过 babel-cli
命令行工具去调用 babel
去编译对应的文件——类似于这样 npx babel test.js --out-file ./out/test.js
,别忘了在 .babelrc
文件中配置好这个插件。结果如下:
在实践这个的过程中,个人觉得刚开始如何去构造节点是比较没有头绪的,希望能通过上面的展示,给予大家一些思路。
生成文档
接下来我们来看一个生成文档的案例,这也是实际场景当中遇到的需求。无论是在开发前端组件、或者后端在写 API
,如果开发完成之后没有对应的文档的话,别人很难一下子看出来要如何使用。但是很多时候大家写完就算了,也没有去即使维护相对应的MD文档。所以这里就想做一个根据一些注释信息去生成文档的工具,让你在写注释的过程中就把文档给写了。那万一别人连这些注释也不想写怎么办? eslint+husky
可以帮到你,之后我会写一篇关于 eslint
、 stylelint
插件的文章,但时候再在那里展开。其实 husky
这种也不是能把所有人拦住,总有人在自己的环境上没装。所以要彻底解决这个问题,个人的想法应该是禁止直接提交 master
分支(发布分支),只能通过 feature
分支合并到 master
,强制卡 MR
。扯的有点远了,下面来正式看看这个工具的具体实现。
假如我现在有这么一个服务端文件:
const express = require('express') const app = express() const port = 3000 /** * @path /login * @param username 用户名 string Required * @param password 密码 string Required */ app.get('/login', (req, res) => { res.send('Hello World!') }) /** * @path /user * @param userId 用户id string Required * @param fields 需要查询的字段 array|undefined */ app.get('/user', (req, res) => { res.send('user info') }) app.listen(port, () => { console.log(`Example app listening on port ${port}`) })
我希望把 login
、 user
这两个接口前面的注释提取出来,生成接口文档,应该怎么办?老规矩,先在 astexplorer
看看它们长什么样。
注意到节点里有 leadingComments
和 trailingComments
这两个属性,分别表示节点前后的注释,我们规定相关的注释是写在节点的前面,所以去处理 leadingComments
就好了。整体的思路如下:
- 读取所有要处理的文件,生成
ast
- 对
ExpressionStatement
类型的节点进行处理,这个根据情况而定。 - 处理节点的
leadingComments
,根据约定的前缀还有字段顺序拼接字符串,输出到MD
文件中
整体代码我就直接贴出来了,行数也不多,感觉代码里都是一些“业务”的处理,我就只贴上一些注释,就不展开来讲
const { parse } = require('@babel/parser') const traverse = require('@babel/traverse') const path = require('path') const dir = path.resolve(__dirname, './server') const fs = require('fs') // 读取所有文件 const files = fs.readdirSync(dir) const doc = {} files.forEach(filename => { const filepath = `${dir}/${filename}` if (!doc[filename]) doc[filename] = `` // 读取文件内容 const content = fs.readFileSync(filepath, { encoding: 'utf8' }) // 生成ast const ast = parse(content) traverse.default(ast, { ExpressionStatement: { enter(path) { if (path.node.leadingComments && path.node.leadingComments.length > 0) { let comment = path.node.leadingComments[0].value let tableHead = false comment = comment.split('\n').map(item => item.replaceAll('*', '').trim()).filter(v => !!v) comment.forEach(line => { const result = line.split(' ').filter(v => !!v) const commentName = result[0] // 添加接口地址 if (commentName == '@path') { const apiPath = result[1] doc[filename] += ` ## ${apiPath} ` } if (commentName == '@param') { // 添加头部 if (!tableHead) { doc[filename] += `|参数名|参数描述|类型|是否必填| |---|---|---|---| ` tableHead = true } let [_, fieldName, desc = null, type = null, required = 'NoRequired'] = result doc[filename] += `|${fieldName}|${desc}|${type.replaceAll('|','\\|')}|${required === 'Required' ? true : false}| ` } }) } } } }) Object.keys(doc).forEach(filename => { // 输出文档 fs.writeFileSync( path.resolve(__dirname, `./doc/${filename.replace('.js', '.md')}`), doc[filename], { encoding: 'utf8' } ) }) })
最后生成的接口文档就是下面这个样子的:
其实代码的实现并不难,但是这个生成文档的代码实现很难抽象出一个公共的方法,现在也有一些现成的库,比如 jsdoc
。但如果这些库不能满足你的特性化需求,你可以考虑使用上面的方式去实现自己的一套工具。上面是以接口文档作为例子,当然你也可以用来做组件的使用文档等等。
国际化
国际化也是我们在业务中常常会遇见的需求,实现方式可能是用一些库或者自己实现一个 translate
函数,然后在代码里写各种文本的时候都需要用这个 translate
函数去套一下。其实这里也是可以利用 Babel
的静态分析特性,去自动的做这件事情,编码的时候还是照常写文本,经过 Babel
转换过后,生成的代码自动帮你套上 translate
函数。
我们这里主要处理两种节点:
- 纯文本:对应
StringLiteral
类型 - 模版字符串:对应
TemplateLiteral
类型
纯文本
纯文本的处理还是比较简单的,假设我们的翻译函数是 t
,还是老套路,在上面的网站上看看 文本
跟 t('文本')
长得有什么区别,然后可以很快写出下面的代码:
traverse.default(ast, { StringLiteral: { enter(path) { const { value } = path.node if (!( path.parent.type === 'CallExpression' && ['t', 'require'].includes(path.parent.callee.name) ) ) { const astNode = t.callExpression( t.identifier('t'), [t.stringLiteral(value)] ) path.replaceWith(astNode) } } } }
这里需要注意的是,有一些字符串是不需要再套用 t
函数的,比如本身就已经套用了 t
函数,或者是引用相关的路径。然后我们可以使用 replaceWith
这个方法来替换节点。
模版字符串
下面再来看模版字符串的处理,如果转换前是这样的:
这里需要注意的是,有一些字符串是不需要再套用 t 函数的,比如本身就已经套用了 t 函数,或者是引用相关的路径。然后我们可以使用 replaceWith 这个方法来替换节点。 模版字符串 下面再来看模版字符串的处理,如果转换前是这样的:
那我希望转换后是这样的:
const text3 = t(`#num#:参数1:#num1##num1#,参数二:#num2#`, { num: num, num1: num1, num2: num2 });
那么先来观察模版字符串的 ast node
:
里面有两个需要关注的属性:
quasis
:普通文本expressions
:表达式
这两个属性之间有一个规律:一个表达式的两边一定是分别是一个普通文本,如果没有的话,那这个 quasi
里面的 value
属性就是空字符串。利用这个规律,我们就可以开始进行拼接与替换。
TemplateLiteral: { enter(path) { const quasis = [...path.node.quasis] const expressions = [...path.node.expressions] let str = '' const variables = new Set() while (quasis.length) { const quasi = quasis.shift() str += quasi.value.raw if (expressions.length) { const expression = expressions.shift() str += `#${expression.name}#` // 把变量收集起来 variables.add(expression.name) } } const properties = [] if (variables.size > 0) { for (let variable of variables) { const property = t.objectProperty( t.identifier(variable), t.identifier(variable) ) properties.push(property) } } const args = [ t.templateLiteral( [ t.templateElement({ raw: str, cooked: str }) ], [] ), ] if (properties.length) { args.push( t.objectExpression( properties ) ) } // 构建t函数抽象节点 const astNode = t.callExpression( t.identifier('t'), args ) // 替换节点 path.replaceWith(astNode) } }
因为上面已经花了一些篇幅去讲如何构造节点,所以这里就不再详细的讲解了。替换前后的结果是这样的:
翻译函数
最后一步,就是要实现一个全局的翻译函数。其实为什么要把模版字符串里面有变量的替换成那个样子呢?我们来看看中英文的一些表达,在中文里,我们说 我手里有5个项目
,对应英文其实可能是 I have five projects
。那么我们写在代码里就是 我手里有${num}个项目
,对应的英文词条是 I have ${num} projects
,上述模版字符串的替换方式,可以看作一种中间状态,用一些占位符去标记,便于中英文转换时变量的插入。
module.exports = function (string, params = {}) { const en = { '文本': 'text', '#num#:参数1:#num1##num1#,参数二:#num2#': '#num#:param1:#num1##num1#,param2:#num2#', '字符串': 'string' } let result = en[string] if (!result) return string const reg = /(#([^#][0-9a-zA-Z]+)#)/ if (Object.keys(params).length === 0) { return result } else { let regResult while (regResult = reg.exec(result)) { const origin = result[1] const variable = result[2] result = result.replace(origin, params[variable]) } return result } }
所以上面转换过后的代码输出结果如下,整体是符合预期的:
最后
本文举了三个实际应用中遇到的例子,并以 Babel
的方式去解决,属于是抛砖引玉。如果你平时在实际场景中还使用到 Babel
来做一些其他的事情的话,欢迎在评论区一起交流~如果你觉得这篇文章有用或者有趣的话,点点关注点点赞吧~