用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 来做一些其他的事情的话,欢迎在评论区一起交流~如果你觉得这篇文章有用或者有趣的话,点点关注点点赞吧~

相关文章
|
7月前
|
资源调度 前端开发 测试技术
前端工程化实践:从零搭建现代化项目构建流程
【4月更文挑战第6天】本文介绍了前端工程化的概念和重要性,包括模块化、自动化、规范化和CI/CD。接着,讨论了选择合适的工具链,如包管理器、构建工具和测试框架。然后,详细阐述了如何从零开始搭建一个基于React的现代化项目构建流程,涉及初始化、代码规范、测试、CSS处理、代码分割和CI/CD配置。最后,提到了持续优化与迭代的方向,如性能优化、类型检查和微前端。通过这样的实践,开发者可以提升开发效率和代码质量,为项目长远发展奠定基础。
335 0
|
7月前
|
缓存 前端开发 JavaScript
Vite 构建流程大揭秘:快速构建前端项目的秘密武器
Vite 构建流程大揭秘:快速构建前端项目的秘密武器
|
前端开发 JavaScript 搜索推荐
Vite多环境配置:让项目拥有更高定制化能力
业务背景 近些年来,随着前端工程架构发展,使得前端项目中也能拥有如后端工程的模块能力。今天我们就来聊下如何在`Vite`中实现一套拓展能力强的多环境适配方案。
Vite多环境配置:让项目拥有更高定制化能力
|
3天前
|
前端开发 中间件 程序员
如何尽可能快地上手一个业务or项目
本文简单讲述作者对于“怎么尽可能快地上手一个新业务/项目?”这个问题的个人理解。
|
4月前
|
前端开发 JavaScript 测试技术
构建与部署全栈JavaScript应用:从构思到上线的完整指南
【8月更文挑战第9天】构建和部署一个全栈JavaScript应用是一个复杂但充满挑战的过程。从需求分析到项目上线,每一步都需要精心策划和严格执行。通过本文的指南,希望能帮助你更好地理解和掌握全栈JavaScript应用的开发流程,从而打造出高性能、高可用、易维护的应用。
|
监控 前端开发 JavaScript
带你入门前端工程(一):技术选型
带你入门前端工程(一):技术选型
610 0
|
7月前
|
JSON 移动开发 数据可视化
Dooring无代码搭建平台技术演进之路
Dooring无代码搭建平台技术演进之路
147 0
|
Web App开发 前端开发 JavaScript
ESM Bundleless 在低代码场景的实践
ESM Bundleless 在低代码场景的实践
327 0
|
Kubernetes 前端开发 应用服务中间件
前端部署脚手架专网项目实践
前端脚手架是前端工程化中一项重要的提升团队效率的工具,因而构建脚手架对于前端工程师而言是一项不可获取的技能,而业界对于部署方面的脚手架相对较少,一般来说都是针对于业务的相关模板进行相关的工程化脚手架构建,本文旨在提供一些对前端部署相关的脚手架实践方案,希望对构建工程链路相关的同学能有所帮助。
82 0
|
缓存 前端开发 API
从 0 实现一个前端项目提效脚手架
从 0 实现一个前端项目提效脚手架
从 0 实现一个前端项目提效脚手架