教你手写一个Babel 插件

493 阅读5分钟

1. 前言

关于 Babel,不知道你是不是跟我一样,对它的了解仅限于在脚手架的基础上加些个性化配置,比如支持 ES2015+ 等特殊语法,以便应用可在目标浏览器中正常运行,比如可选链。

直到有一天,我的微信小程序本地图片占用太多了,超过微信的代码包上传限制,需要把所有的本地图片都上传 OSS 云对象存储上,涉及到大量的本地图片路径转换为线上地址,人工替换工作量大,最终我通过手写一个 babel 自定义插件解决了这个问题。

2. 知识准备

2.1 知道 babel 是什么,有什么用

babel 是一个 JavaScript 编译器,负责源码到源码的编译,这种高级语言到高级语言的编译也叫转译。它最开始是用来解决 es6 转 es5的问题。后来随着发展,babel 越来越强大,还提供众多的模块用于不同形式的静态分析编译。

静态分析是在不需要执行代码的前提下对代码进行分析的处理过程 (]执行代码的同时进行代码分析即是动态分析)。

静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。

  • esnext 新的语法、typescriptflow 的语法转成目标环境可支持的实现,还可以针对浏览器不支持的 api 进行 polyfill 模拟实现。
  • 一些特定用途的代码转换,比如转译工具 taro 可转换 react 语法为微信小程序的语法。
  • 网站支持自动国际化。
  • linter 工具,对代码规范进行检查。
  • 压缩混淆工具,去掉死代码、变量名混淆等各种编译优化。
  • api 文档自动生成文档,通过提取源码中的注释,生成文档。
  • type checker 类型检查。
  • javascript 解释器。

2.2 理解 babel 抽象语法树 ast

跟直接使用正则表达式直接操作字符串不同,babel 整个处理过程中都在操作 astast 到底是什么呢?

因为源码是一串按照指定的语法格式组织的字符串,为了让计算机认识源代码的语法结构,方便我们按照语法结构进行操作。我们需要把源代码转换成有依赖关系的抽象语法树数据结构 ast ,每个节点对象保存不同的数据。 之所以叫做抽象语法树,会把源码中一些无具体意义的分隔符如;{ 等剔除。

babel 对源代码字符串进行词法分析、语法分析,最终生成一颗可操作的 ast 抽象语法树,这样对源代码的修改转换到对 ast 的增删改,然后再对 ast 处理输出目标字符串。

image.png

比如下面的代码

// before
var bar = [1, 2, 3];

// after
var bar = mori.vector(1, 2, 3);

生成的 ast 节点如下:

image.png (图片来源:www.sitepoint.com/understandi…

ast 是对源码的抽象,由标识符 Identifer、各种字面量 xxLiteral、各种语句 xxStatement,各种声明语句 xxDeclaration,各种表达式 xxExpression,以及 ClassModulesFileProgramDirectiveComment 这些 ast 节点构成。

从关注字符串的不同,到我只需要知道前后代码对应 ast 树的区别,直接对源代码对应 ast 进行转换处理,最终生成目标代码。

对于代码对应的 ast,我们也不需要强制记下来,在开发过程中,我们可以随时在工具网站 astexpoler.net 去查。

2.3 babel 的编译流程和主要 API 包

babel 是源码到源码的转换,整体编译流程分为三步,先解析源码成 ast,然后插件更改 ast,最后由 babel输出代码:

parse 解析:通过 parser 解析器进行词法分析和语法分析把源码转成抽象语法树 ast

transform 转换:接收 ast 并对其遍历,调用各种 transform 插件对 ast 进行增删改

generate 生成:把转换后的 ast 转换成目标代码字符串,并生成源码映射 sourcemap

image.png

从 github 中查看 babel 的整体功能,有以下模块:

• parse 阶段有 @babel/parser,功能是把源码转成 ast

• transform 阶段有 @babel/traverse,可以遍历 ast,并调用 visitor 函数修改 ast。对于 ast节点的判断、创建、修改等,可以用 @babel/types 包,当需要批量创建 AST 的时候可以用 @babel/template 简化逻辑。

• generate 阶段有 @babel/generate,会把 ast 打印为目标代码字符串,同时生成 sourcemap

• 使用 @babel/code-frame 包,用于中途遇到错误想打印代码位置的时候。

• 当需要应用 babel 预设和插件时,就需要使用 @babel/core,它会使用上面的包实现整体的功能。

举个例子,我想要给 console.log 打印函数额外添加行和列信息,应用上面的包如下:

// before
console.log(1);
// after
console.log("filename: (2, 4)", 1);

整体的代码结构是这样的

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');
const targetCalleeNameList = ['log'].map(item => `console.${item}`);

// 把目标代码解析为 ast
const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous' // 根据内容是否有 import 和 export 来自动设置 module 还是 script   
});
// 遍历 ast,这里可以对 ast 进行增删改
traverse(ast, {
    CallExpression(path, state) { // 遍历函数调用 ast 节点时会回调
        const calleeName = generate(path.node.callee).code;
        if (targetCalleeNameList.includes(calleeName)) {
            const { line, column } = path.node.loc.start;
            path.node.arguments.unshift(
                types.stringLiteral(`filename: (${line}, ${column})`)
            );
        }
    }
});
// ast 代码转目标代码
const { code, map } = generate(ast);
console.log(code);

通过上面的代码,可以知道 parsertraversegenerator 各个包的作用。

3. 实现一个简单插件,理解编写规范

前面我们已经知道 babel 的编译流程,了解到 babel 插件是在 transform 阶段执行的。

接下来我们通过把上述的 console 函数插入参数转成插件,支持下面这样转换:

Before

console.log(1);
function func() {
    console.info(2);
}

export default class Clazz {
    say() {
        console.debug(3);
    }
    render() {
        return <div>{console.error(4)}</div>
    }
}

After

console.log("filename: (1, 0)")
console.log(1);
function func() {
    console.log("filename: (4, 4)")
    console.info(2);
}

export default class Clazz {
    say() {
        console.log("filename: (9, 8)")
        console.debug(3);
    }
    render() {
        return <div>{[console.log("filename: (12, 21)"), console.error(4)]}</div>;
}

搭建主体流程代码如下,使用 @babel/coretransformFileSync 调用插件。

// index.js
const { transformFileSync } = require('@babel/core');
const path = require('path');
const insertParameterPlugin = require('./plugins/babel-plugin-parameters-insertinsert');
const { code } = transformFileSync(
    path.join(__dirname, './sourceCode.js'),
    {   
        plugins: [insertParameterPlugin], // 插件配置   
        parserOpts: {       
        sourceType: 'unambiguous',       
        plugins: ['jsx'// 支持解析 jsx  

    }
});

3.1 插件编写规范 visitor

源码 parseast 之后,需要进行 ast 的遍历和增删改。babel 会递归遍历 ast,遍历过程中

会调用 ast 节点对应的 visitor 函数来实现 transform

编写规范:导出一个函数,函数里返回一个对象,在对象里添加属性 vistiorvisitor 里面声明对应的 ast 节点处理访问函数。由于 babel 在遍历 ast 是深度遍历,我们有两次机会访问节点,向下遍历时进入每个节点,向上遍历时我们退出每个节点。

// 返回一个函数,第一个参数为 api 参数,有 types、template 这些对应着对应的 npm 包 api。  
module.exports = function(api) {  
    return {       
        visitor: {
            Program: {               
                enter(path, state) {} // 程序进入时调用
            }       
            // 每当遇到一个函数调用 ast 节点时调用
            CallExpression(path, state) {}, // 默认进入  
            Identifier: {
                enter() {
                    console.log("Entered!");
                },
                exit() {
                    console.log("Exited!");
                }
            }
        }   
    }
}

visitor 即访问者,babel 这里运用到了 访问者设计模式。它的思想是当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,以便独立扩展。

image.png

对应到 babel 中,就是 astvisitor 分离,在遍历 ast 的时候,调用注册的 visitor 来对其进行处理。

image.png

3.2 插件常用操作 API

上面已经知道 visitor 的编写规范,接下来了解每个 vistor 的参数 pathstate

3.2.1 path(路径)

抽象语法树ast 通常有很多节点,babel 提供 path 来进行节点操作以及访问关联的节点。

path 是一个对象,它表示两个节点之间的链接。它记录遍历路径的 api,记录了父子节点的引用以及对 ast 的增删改 api。

获取节点信息

  • 获取当前节点:path.node
  • 获取父节点:path.parent
  • 获取父节点的 path:path.parentPath
  • 获取 javaScript 中的作用域链信息:path.scope
  • path.hub 可以通过 path.hub.file 拿到最外层 File 对象,path.hub.getScope 拿到最外层作用域,path.hub.getCode 拿到源码字符串

判断 AST 类型

  • path.isFunctionDeclaration()
  • path.isTemplateLiteral()

增删改 AST 类型

  • 插入节点:path.insertBeforepath.insertAfter
  • 替换接:path.replaceWithpath.replaceWithMultiplereplaceWithSourceString
  • 删除节点:path.remove

在开发时我们可以通过 debugger 或者直接查看 babel 源码测试用例了解更多的操作 api。

image.png

3.2.2 state(状态)

可以从 state 中获取插件的配置项 opts 以及 file 对象:

state {   
    file    
    opts
}

比如自动化文档需求,下面摘取的一段代码,可以把数据存在 file 对象里:

const autoDocumentPlugin = declare((api, options, dirname) => {
    return {
        pre(file) {
            file.set('docs', []);
        },

        visitor: {
            FunctionDeclaration(path, state) {
                const docs = state.file.get('docs');
                docs.push({});
                state.file.set('docs', docs);
            }
        },

        post(file) {
            // 读取存取的数据
            const docs = file.get('docs');
            const res = generate(docs, options.format); // 生成字符串
            // 写到磁盘中
            fse.writeFileSync(path.join(options.outputDir, 'docs' + res.ext), res.content);
        }
    }
});

我们也可以添加自定义的属性,以便在遍历过程中 ast 节点之间传递数据。

比如我想要在每个文件中引用某个模块,首先要找到模块标识符 jecyuId,把它存在 state 里,然后在每个 visitor 里都可以通过 state.jecyuId 找到这个,后面自定义插件会用到这种用法。

最终实现的 console 标记行列插件代码如下:


module.exports = function({types, template}) {
    return {
        visitor: {
            CallExpression(path, state) { // state 可以读取插件 options 的配置
            if (path.node.isNew) {
                return;
            }

            const calleeName = generate(path.node.callee).code;
            if (targeCalleeNameList.includes(calleeName)) {
                const { line, column } = path.node.loc.start;
                const newNode = template.expression(`console.log("filename: (${line}, ${column})")`)(); // 使用 template 模版 api 批量创建 AST 节点
                newNode.isNew = true;
                
                // 处理 JSX 里的 AST 节点
                if (path.findParent(path => path.isJSXElement())) {
                    path.replaceWith(types.arrayExpression([newNode, path.node])); // // 替换 ast 节点
                    path.skip(); // 跳过新节点的遍历
                } else {
                    path.insertBefore(newNode); // 直接在前面插入
                }
                }
            }
        }
    }
}

4. 编写插件解决路径替换问题

让我们来解决文章开头提出的静态图片替换问题,需要把微信小程序代码包的本地图片上传到 OSS 上获得线上地址,然后把代码中引用本地图片,地址更改为线上地址,如下所示:

Before:

import SHIPPING_ICON from '../../../images/index/shipping-icon@2x.png';
const icon = <div src={require('../../images/modal-head@2x.png')} /div>;

After:

const SHIPPING_ICON = `${host}/freight-retail/ossimg/freight-weapp/images/index/shipping-icon@2x.png`;
const icon = <div src={`${host}/freight-retail/ossimg/freight-weapp/images/modal-head@2x.png`} /div>;

其中 host 变量是根据运行环境动态引入的测试环境或生产环境的域名地址,从外部模块 global-data 引入

// global-data.js
export const host = process.env.NODE_ENV === 'development' ? 'https://jecyu.sit.com' : 'https://jecyu.com'

4.1 思路分析

因为本地所有的图片都放在 images 下,通过子文件夹进行作用域分离,避免命名冲突,为方便替换,本地路径与上传到 OSS 桶里的文件夹是一致的。

另外,因为 OSS 有测试环境与正式环境的域名区别,所以替换的路径是带有 host 动态变量的。host 从哪里来,host 是声明在外部,每个更改路径的文件都需要显式引入,像这样:

import globalData from '@/store/global-data';
const { host } = globalData;

这里要处理的细节是,如果已引入 globalData模块的话,就判断是否引入 host 变量,没有的话要引入。

怎么解决呢?分为两个步骤:

  1. 收集要处理的文件
  2. 对每个文件执行处理规则

第一步,可以通过 node 脚本扫描获取所有文件 tsxts 文件 第二步,最粗暴的方式是采用正则表达式匹配,最原始的处理方式,需要对正则表达式较熟悉且维护麻烦。

另一种方式,借用 babel 处理 ast 解决,毕竟代码分析与编译是 babel 的强项。

  1. 判断文件是否有引入静态图片路径的,有则进行替换,并记录在 state 中,用于后续判断是否需要引入 global-data 模块。

  2. 如果有 state.hasStaticImg,则判断是否已经引入 global-data 模块和 host 变量。

image.png

4.2 代码实现

在编写插件时,我们可以通过 astexplorer.net/ 输入要处理的源代码,分析代码处理前后对应的 ast 节点,再编码实现。

image.png

声明 ImportDeclaration 模块引入的 ast 节点回调,处理通过 import 引入图片的方式

// 针对 import 引入的图片替换处理
ImportDeclaration(curPath) {
    const requireModulePath = curPath.get('source').node.value;
    const specifierPath = curPath.get('specifiers.0');
    if (specifierPath && specifierPath.isImportDefaultSpecifier()) {
        const importModulePathList = requireModulePath.toString().split(path.sep); // path.sep 兼容 window 和 mac 的反斜杠

        const ext = importModulePathList[importModulePathList.length - 1].split('.');
        if (['png', 'jpg', 'jpeg', 'svg'].includes(ext[ext.length - 1])) {
            state.hasStaticImg = true;
            // 做替换
            const imgPath = requireModulePath.replace(/(\.\.\/|\.\/)/g, '').replace(/\'/g, '');
            const value = '`' + `\$\{host\}/freight-retail/ossimg/freight-weapp/${imgPath}` + '`';
            const declarationAst = api.template.ast(`const ${specifierPath.toString()} = ${value};`);
            curPath.replaceWith(declarationAst);
        }
    }
}

声明 CallExpression 调用函数节点的回调,处理通过 require 动态引入图片的方式:

CallExpression(curPath) {
    const { callee, arguments: args } = curPath.node;
    if (callee.name === 'require') {
        if (args.length) {
            const requireModulePath = args[0].value.toString();
            const importModulePathList = requireModulePath.split(path.sep);
            const ext = importModulePathList[importModulePathList.length - 1].split('.');
            if (['png', 'jpg', 'jpeg', 'svg'].includes(ext[ext.length - 1])) {
                state.hasStaticImg = true;
                // 做替换
                const imgPath = requireModulePath.replace(/(\.\.\/|\.\/)/g, '').replace(/\'/g, '');
                const value = '`' + `\$\{host\}/freight-retail/ossimg/freight-weapp/${imgPath}` + '`';
                const declarationAst = api.template.ast(`${value}`);
                curPath.replaceWith(declarationAst);
            }
        }
    }
}

通过前面的处理后,可以找到有静态图片的路径需要替换。如果有的话,就引入 global-data 模块以及 host 变量。

声明 ImportDeclaration 调用函数节点的回调,引入 global-data 模块。

引入模块需要判断 ImportDeclaration 是否包含了 global-data 模块,没有的话就用 @babel/helper-module-import 来引入。

path.traverse({
    ImportDeclaration(curPath) {
        const requireModulePath = curPath.get('source').node.value;
        const importModulePathList = requireModulePath.toString().split('/');
        const targetModule = importModulePathList[importModulePathList.length - 1];
        if (targetModule === options.hostModule) { // options.hostModule 即插件配置的 'global-data'
            state.globalModuePath = curPath;
            const specifierPath = curPath.get('specifiers.0');
          	
            if (specifierPath.isImportDefaultSpecifier()) {// import globalData from 'global-data'
                state.globalModueId = specifierPath.toString();
            } else if (specifierPath.isImportSpecifier()){ // import { globalData } from 'global-data'
                state.globalModueId = specifierPath.toString();
            } else if (specifierPath.isImportNamespaceSecifier()) { // import * as globalData from 'global-data'
                state.globalModueId = specifierPath.get('local').toString();
            }
            curPath.stop();
        }
    }
});

if (!state.globalModueId) {
    state.globalModuePath = importModule.addDefault(path, '@/store/global-data', {
    nameHint: path.scope.generateUid('globalData') // 生成唯一值
    });
    state.globalModueId = state.globalModuePath.name;
}

声明 VariableDeclarator 调用函数节点的回调,判断是否从globalData 中引入 host

如果有引入 globalData,但没有引入 host,则追加到变量声明后面。 如果没有引入 globalData,则直接引入整个 host 声明语句。

path.traverse({
    VariableDeclarator(curPath) {
        if (curPath.node.init.name === state.globalModueId) {
            state.hasDestructureGlobalData = true;
            // 是否有 host
            const propertiesPath = curPath.node.id.properties;
            const hasHost = propertiesPath.find(property => property.key.name === 'host');
            if (!hasHost) {
                curPath.node.id.properties.push( // 追加 host 属性
                api.types.objectProperty(
                    api.types.identifier('host'),
                    api.types.identifier('host'),
                    false,
                    true
                ));
            }
            curPath.stop();
        }
    }
});

if (!state.hasDestructureGlobalData) {
    const ast = api.template.ast(`const { host } = ${state.globalModueId}`);
    path.node.body.unshift(ast)
}

5. 总结

如何手写一个 babel 插件呢,在编写之前我们需要了解 babel 解决了什么问题,知道 ast 是什么、babel 的编译流程、插件在哪一步产生作用、以及插件的编写规范,在实现过程中我们可以查阅 ast 编译网站和使用 vscode 的 debugger 解决每一步可能遇到的问题。

下次你遇到一个 js 代码转换的问题,不仅仅可以用正则表达式处理,还可以通过手写一个 babel 插件解决。作为工程师,需要不断培养提效的思维,多了解业界的最佳做法。

本文涉及代码:github.com/jecyu/babel…

参考资料