用Babel提效——从入门到业务实战

简介: 用Babel提效——从入门到业务实战

前言

相信现在大多数的前端同学都有听过和用过 Babel ,它的官网如是介绍自己: Babel 是一个 JavaScript 编译器 。在开发中,我们最常用它来做 ES6+ 语法向 ES5 的转换,也会用它配合打包工具来做压缩、混淆等工作。它的工作过程可能简单用三个步骤来概括:

  • 解析:将代码转换为抽象语法树( ast
  • 转换: Babel 或者相关插件对 ast 进行操作转换
  • 生成:用新的抽象语法树重新生成代码

由上面这三步可以看出,它是使用抽象语法树来贯穿全程的。而基于抽象语法树,我们很容易去做一些静态分析,本文举了三个我在实际工作中遇到的例子,并使用 Babel 去解决这三个实际问题。开始之前,还是有必要介绍一下 Babel 的相关概念以及 API

相关概念

先来介绍一下会用到的几个包:

  • @babel/cliBabel 的命令行客户端,可通过命令行编译文件。
  • @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 类型,然后有 kindkey 等等属性,再配合我们的编辑器的提示:


就知道它的参数应该怎么传,这两个东西相互配合,然后去构造我们需要的 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 可以帮到你,之后我会写一篇关于 eslintstylelint 插件的文章,但时候再在那里展开。其实 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}`)
})

我希望把 loginuser 这两个接口前面的注释提取出来,生成接口文档,应该怎么办?老规矩,先在 astexplorer 看看它们长什么样。


注意到节点里有 leadingCommentstrailingComments 这两个属性,分别表示节点前后的注释,我们规定相关的注释是写在节点的前面,所以去处理  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 来做一些其他的事情的话,欢迎在评论区一起交流~如果你觉得这篇文章有用或者有趣的话,点点关注点点赞吧~

相关文章
|
前端开发 JavaScript 搜索推荐
Vite多环境配置:让项目拥有更高定制化能力
业务背景 近些年来,随着前端工程架构发展,使得前端项目中也能拥有如后端工程的模块能力。今天我们就来聊下如何在`Vite`中实现一套拓展能力强的多环境适配方案。
Vite多环境配置:让项目拥有更高定制化能力
|
监控 Devops 中间件
阿里巴巴DevOps实践指南(十四)| 测试环境与路由
在阿里巴巴内部,随着业务规模和技术栈的拓展和更新,业务侧对测试环境的使用也逐步打破原固有模式,快速向多场景、多样化、多职能方向发展,如何能够跟上业务发展速度,及时满足业务侧对测试环境新场景的诉求,基于环境和路由模型的测试环境解决方案是解决问题的关键。
阿里巴巴DevOps实践指南(十四)| 测试环境与路由
|
13天前
|
JavaScript 前端开发
vue组件化开发流程梳理,拿来即用
vue组件化开发流程梳理,拿来即用
|
4月前
|
监控 前端开发 JavaScript
在线教育系统|线上教学系统|基于Springboot+Vue+Nodejs实现在线教学平台系统
在线教育系统|线上教学系统|基于Springboot+Vue+Nodejs实现在线教学平台系统
|
4月前
|
前端开发 JavaScript 小程序
亚马逊云科技 Build On -Serverless低代码平台初体验-快速完成vue前端订单小程序
亚马逊云科技 Build On -Serverless低代码平台初体验-快速完成vue前端订单小程序
55 0
|
11月前
|
存储 资源调度 JavaScript
基于 Yeoman 脚手架技术构建前端项目的实践
基于 Yeoman 脚手架技术构建前端项目的实践
140 0
|
12月前
|
运维 JavaScript 前端开发
上手华为软开云DevOps前后端分离实践之-前端Vue
上手华为软开云DevOps前后端分离实践之-前端Vue
170 0
|
12月前
|
前端开发
前端学习笔记202304学习笔记第九天-脚手架开发痛点1
前端学习笔记202304学习笔记第九天-脚手架开发痛点1
49 0
|
前端开发 Serverless API
人人都是Serverless架构师之传统内容管理系统改造实战二[踩坑实践]
容管理系统是很常见的一种web应用场景,可以用到个人独立站,企业官网展示等场景,具有很高的实用价值,一个标准的内容管理系统主要由三个部分组成 主站展示部分、后台管理系统、API接口服务,本篇文章会以一个已有内容管理系统的Serverless架构重构展开,介绍改造的基本思路,改造细节,以及性能优化业务可观测设计等。涉及大家关心的Serverless生产遇到的一些问题,比如数据库、日志、动静态分离、调试、维护、灰度方案等。最真实的展现Serverless架构的实施落地细节。
202 0
人人都是Serverless架构师之传统内容管理系统改造实战二[踩坑实践]
|
前端开发
006 使用 Umi 的微生成器快速助力业务交付
006 使用 Umi 的微生成器快速助力业务交付
346 0
006 使用 Umi 的微生成器快速助力业务交付