「前端基建」带你在Babel的世界中畅游

18,653 阅读29分钟

引言

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。

Babel在目前前端领域类似一座山一样的存在,任何项目或多或少都有它的身影在浮现。

也许对于Babel绝大多数前端开发者都是处于一知半解的状态,但是无论是在实际业务开发中还是对于我们个人提升来说熟练掌握Babel一定是晋升高级前端工程师的必备之路。

文章中我们只讲“干货”,从原理出发结合深层次实践带你领略Babel之美。

我们会从Babel基础内容从而渐进到Babel插件开发者的世界,从此让你对于Babel得心应手。

文中大致目录如下:

  • Babel日常用法指南

    • 从基础内容出发,带你掌握常见的PluginPreset

    • 前端基建项目中的Babel配置讲解。

    • Babel相关polyfill内容。

  • Babel插件开发指南

    • 带你走进Babel的编译世界,领略Babel背后的原理知识。

    • 手把手带你开发一款属于自己的Babel插件。

🚀 废话不多讲,让我们开始真正进入Babel的世界。

Babel日常用法

首先我们会从基础的配置Babel及相关内容开始讲解。

常见pluginPreset

首先我们来说说PluginPreset的区别和联系。

所谓Preset就是一些Plugin组成的合集,你可以将Preset理解称为就是一些的Plugin整合称为的一个包。

常见Preset

文章中列举了三个最常用的Preset,更多的Prest你可以在这里查阅

babel-preset-env

@babel/preset-env是一个智能预设,它可以将我们的高版本JavaScript代码进行转译根据内置的规则转译成为低版本的javascript代码。

preset-env内部集成了绝大多数pluginState > 3)的转译插件,它会根据对应的参数进行代码转译。

具体的参数配置你可以在这里看到

@babel/preset-env不会包含任何低于 Stage 3 的 JavaScript 语法提案。如果需要兼容低于Stage 3阶段的语法则需要额外引入对应的Plugin进行兼容。

需要额外注意的是babel-preset-env仅仅针对语法阶段的转译,比如转译箭头函数,const/let语法。针对一些Api或者Es 6内置模块的polyfillpreset-env是无法进行转译的。这块内容我们会在之后的polyfill中为大家进行详细讲解。

babel-preset-react

通常我们在使用React中的jsx时,相信大家都明白实质上jsx最终会被编译称为React.createElement()方法。

babel-preset-react这个预设起到的就是将jsx进行转译的作用。

babel-preset-typescript

对于TypeScript代码,我们有两种方式去编译TypeScript代码成为JavaScript代码。

  1. 使用tsc命令,结合cli命令行参数方式或者tsconfig配置文件进行编译ts代码。

  2. 使用babel,通过babel-preset-typescript代码进行编译ts代码。

常见Plugin

Babel官网列举出了一份非常详尽的Plugin List

关于常见的Plugin其实大多数都集成在了babel-preset-env中,当你发现你的项目中并不能支持最新的js语法时,此时我们可以查阅对应的Babel Plugin List找到对应的语法插件添加进入babel配置。

同时还有一些不常用的packages,比如@babel/register:它会改写require命令,为它加上一个钩子。此后,每当使用require加载.js.jsx.es.es6后缀名的文件,就会先用Babel进行转码。

这些包日常中不是特别常用,如果有同学有相关编译相关需求完全可以去babel官网查阅。如果官网不存在现成的plugin/package,别担心!我们同时也会在之后手把手教大家babel插件的开发。

其中最常见的@babel/plugin-transform-runtime我们会在下面的Polyfill进行详细的讲解。

前端基建中的Babel配置详解

接下里我们聊聊前端项目构建中相关的babel相关配置。

关于前端构建工具,无路你使用的是webapack还是rollup又或是任何构建打包工具,内部都离不开Babel相关配置。

这里我们使用业务中最常用的webpack举例,其他构建工具在使用方面只是引入的包不同,Babel配置原理是相通的。

关于WebPack中我们日常使用的babel相关配置主要涉及以下三个相关插件:

  • babel-loader

  • babel-core

  • babel-preset-env

也许你经常在项目搭建过程中见到他们,这里我们将逐步使用一段伪代码来讲解他们之间的区别和联系。

首先我们需要清楚在 webpackloader的本质就是一个函数,接受我们的源代码作为入参同时返回新的内容。

babel-loader

所以babel-loader的本质就是一个函数,我们匹配到对应的jsx?/tsx?的文件交给babel-loader:

/**
 * 
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader (sourceCode,options) {
  // ..
  return targetCode
}

关于optionsbabel-loader支持直接通过loader的参数形式注入,同时也在loader函数内部通过读取.babelrc/babel.config.js/babel.config.json``等文件注入配置。

关于babel在各种基建项目的初始化方式你在可以在这里查阅

babel-core

我们讲到了babel-loader仅仅是识别匹配文件和接受对应参数的函数,那么babel在编译代码过程中核心的库就是@babel/core这个库。

babel-corebabel最核心的一个编译库,他可以将我们的代码进行词法分析--语法分析--语义分析过程从而生成AST抽象语法树,从而对于“这棵树”的操作之后再通过编译称为新的代码。

babel-core其实相当于@babel/parse@babel/generator这两个包的合体,接触过js编译的同学可能有了解esprimaescodegen这两个库,你可以将babel-core的作用理解称为这两个库的合体。

babel-core通过transform方法将我们的代码进行编译。

关于babel-core中的编译方法其实有很多种,比如直接接受字符串形式的transform方法或者接受js文件路径的transformFile方法进行文件整体编译。

同时它还支持同步以及异步的方法,具体方法你可以在这里看到

关于babel-core内部的编译使用规则,我们会在之后的插件章节中详细讲到。

接下来让我们完善对应的babel-loader函数:

const core = require('@babel/core')

/**
 * 
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader (sourceCode,options) {
  // 通过transform方法编译传入的源代码
  core.transform(sourceCode)
  return targetCode
}

这里我们在babel-loader中调用了babel-core这个库进行了代码的编译作用。

babel-preset-env

上边我们说到babel-loader本质是一个函数,它在内部通过babel/core这个核心包进行JavaScript代码的转译。

但是针对代码的转译我们需要告诉babel以什么样的规则进行转化,比如我需要告诉babel:“嘿,babel。将我的这段代码转化称为EcmaScript 5版本的内容!”。

此时babel-preset-env在这里充当的就是这个作用:告诉babel我需要以为什么样的规则进行代码转移

const core = require('@babel/core');

/**
 *
 * @param sourceCode 源代码内容
 * @param options babel-loader相关参数
 * @returns 处理后的代码
 */
function babelLoader(sourceCode, options) {
  // 通过transform方法编译传入的源代码
  core.transform(sourceCode, {
    presets: ['babel-preset-env'],
    plugins: [...]
  });
  return targetCode;
}

这里pluginprest其实是同一个东西,所以我将plugin直接放在代码中了。同理一些其他的preset或者plugin也是发挥这样的作用。

关于babel的基础基建配置我相信讲到这里大家已经明白了他们对应的职责和基础原理,如果还有其他配置方面的问题可以查阅babel文档或者查看我的这篇文章React-Webpack5-TypeScript打造工程化多页面应用

Babel相关polyfill内容

何谓polyfill

关于polyfill,我们先来解释下何谓polyfill

首先我们来理清楚这三个概念:

  • 最新ES语法,比如:箭头函数,let/const
  • 最新ES Api,比如Promise
  • 最新ES实例/静态方法,比如String.prototype.include

babel-prest-env仅仅只会转化最新的es语法,并不会转化对应的Api和实例方法,比如说ES 6中的Array.from静态方法。babel是不会转译这个方法的,如果想在低版本浏览器中识别并且运行Array.from方法达到我们的预期就需要额外引入polyfill进行在Array上添加实现这个方法。

其实可以稍微简单总结一下,语法层面的转化preset-env完全可以胜任。但是一些内置方法模块,仅仅通过preset-env的语法转化是无法进行识别转化的,所以就需要一系列类似”垫片“的工具进行补充实现这部分内容的低版本代码实现。这就是所谓的polyfill的作用,

针对于polyfill方法的内容,babel中涉及两个方面来解决:

  • @babel/polyfill

  • @babel/runtime

  • @babel/plugin-transform-runtime

我们理清了何谓polyfill以及polyfill的作用和含义后,让我们来逐个击破这两个babel包对应的使用方式和区别吧。

@babel/polyfill

首先我们来看看第一种实现polyfill的方式:

@babel/polyfill介绍

通过babelPolyfill通过往全局对象上添加属性以及直接修改内置对象的Prototype上添加方法实现polyfill

比如说我们需要支持String.prototype.include,在引入babelPolyfill这个包之后,它会在全局String的原型对象上添加include方法从而支持我们的Js Api

我们说到这种方式本质上是往全局对象/内置对象上挂载属性,所以这种方式难免会造成全局污染。

应用@babel/polyfill

babel-preset-env中存在一个useBuiltIns参数,这个参数决定了如何在preset-env中使用@babel/polyfill

{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": false
        }]
    ]
}
  • useBuiltIns--"usage""entry"false
false

当我们使用preset-env传入useBuiltIns参数时候,默认为false。它表示仅仅会转化最新的ES语法,并不会转化任何Api和方法。

entry

当传入entry时,需要我们在项目入口文件中手动引入一次core-js,它会根据我们配置的浏览器兼容性列表(browserList)然后全量引入不兼容的polyfill

Tips: 在Babel7.4。0之后,@babel/polyfill被废弃它变成另外两个包的集成。"core-js/stable"; "regenerator-runtime/runtime";。你可以在这里看到变化,但是他们的使用方式是一致的,只是在入口文件中引入的包不同了。

浏览器兼容性列表配置方式简介你可以在这里看到

// 项目入口文件中需要额外引入polyfill
// core-js 2.0中是使用"@babel/polyfill" core-js3.0版本中变化成为了上边两个包
import "@babel/polyfill"

// babel
{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "entry"
        }]
    ]
}

同时需要注意的是,在我们使用useBuiltIns:entry/usage时,需要额外指定core-js这个参数。默认为使用core-js 2.0,所谓的core-js就是我们上文讲到的“垫片”的实现。它会实现一系列内置方法或者PromiseApi

core-js 2.0版本是跟随preset-env一起安装的,不需要单独安装哦~

usage

上边我们说到配置为entry时,perset-env会基于我们的浏览器兼容列表进行全量引入polyfill。所谓的全量引入比如说我们代码中仅仅使用了Array.from这个方法。但是polyfill并不仅仅会引入Array.from,同时也会引入PromiseArray.prototype.include等其他并未使用到的方法。这就会造成包中引入的体积太大了。

此时就引入出了我们的useBuintIns:usage配置。

当我们配置useBuintIns:usage时,会根据配置的浏览器兼容,以及代码中 使用到的Api 进行引入polyfill按需添加。

当使用usage时,我们不需要额外在项目入口中引入polyfill了,它会根据我们项目中使用到的进行按需引入。

{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "usage",
            "core-js": 3
        }]
    ]
}
关于usageentry存在一个需要注意的本质上的区别。

我们以项目中引入Promise为例。

当我们配置useBuintInts:entry时,仅仅会在入口文件全量引入一次polyfill。你可以这样理解:

// 当使用entry配置时
...
// 一系列实现polyfill的方法
global.Promise = promise

// 其他文件使用时
const a = new Promise()

而当我们使用useBuintIns:usage时,preset-env只能基于各个模块去分析它们使用到的polyfill从而进入引入。

preset-env会帮助我们智能化的在需要的地方引入,比如:

// a. js 中
import "core-js/modules/es.promise";

...
// b.js中

import "core-js/modules/es.promise";
...
  • usage情况下,如果我们存在很多个模块,那么无疑会多出很多冗余代码(import语法)。

  • 同样在使用usage时因为是模块内部局部引入polyfill所以按需在模块内进行引入,而entry则会在代码入口中一次性引入。

usageBuintIns不同参数分别有不同场景的适应度,具体参数使用场景还需要大家结合自己的项目实际情况找到最佳方式。

@babel/runtime

上边我们讲到@babel/polyfill是存在污染全局变量的副作用,在实现polyfillBabel还提供了另外一种方式去让我们实现这功能,那就是@babel/runtime

简单来讲,@babel/runtime更像是一种按需加载的解决方案,比如哪里需要使用到Promise@babel/runtime就会在他的文件顶部添加import promise from 'babel-runtime/core-js/promise'

同时上边我们讲到对于preset-envuseBuintIns配置项,我们的polyfillpreset-env帮我们智能引入。

babel-runtime则会将引入方式由智能完全交由我们自己,我们需要什么自己引入什么。

它的用法很简单,只要我们去安装npm install --save @babel/runtime后,在需要使用对应的polyfill的地方去单独引入就可以了。比如:

// a.js 中需要使用Promise 我们需要手动引入对应的运行时polyfill
import Promise from 'babel-runtime/core-js/promise'

const promsies = new Promise()

总而言之,babel/runtime你可以理解称为就是一个运行时“哪里需要引哪里”的工具库。

针对babel/runtime绝大多数情况下我们都会配合@babel/plugin-transfrom-runtime进行使用达到智能化runtimepolyfill引入。

@babel/plugin-transform-runtime

babel-runtime存在的问题

babel-runtime在我们手动引入一些polyfill的时候,它会给我们的代码中注入一些类似_extend(), classCallCheck()之类的工具函数,这些工具函数的代码会包含在编译后的每个文件中,比如:

class Circle {}
// babel-runtime 编译Class需要借助_classCallCheck这个工具函数
function _classCallCheck(instance, Constructor) { //... } 
var Circle = function Circle() { _classCallCheck(this, Circle); };

如果我们项目中存在多个文件使用了class,那么无疑在每个文件中注入这样一段冗余重复的工具函数将是一种灾难。

所以针对上述提到的两个问题:

  • babel-runtime无法做到智能化分析,需要我们手动引入。
  • babel-runtime编译过程中会重复生成冗余代码。

我们就要引入我们的主角@babel/plugin-transform-runtime

@babel/plugin-transform-runtime作用

@babel/plugin-transform-runtime插件的作用恰恰就是为了解决上述我们提到的run-time存在的问题而提出的插件。

  • babel-runtime无法做到智能化分析,需要我们手动引入。

@babel/plugin-transform-runtime插件会智能化的分析我们的项目中所使用到需要转译的js代码,从而实现模块化从babel-runtime中引入所需的polyfill实现。

  • babel-runtime编译过程中会重复生成冗余代码。

@babel/plugin-transform-runtime插件提供了一个helpers参数。具体你可以在这里查阅它的所有配置参数

这个helpers参数开启后可以将上边提到编译阶段重复的工具函数,比如classCallCheck, extends等代码转化称为require语句。此时,这些工具函数就不会重复的出现在使用中的模块中了。比如这样:

// @babel/plugin-transform-runtime会将工具函数转化为require语句进行引入
// 而非runtime那样直接将工具模块代码注入到模块中
var _classCallCheck = require("@babel/runtime/helpers/classCallCheck"); 
var Circle = function Circle() { _classCallCheck(this, Circle); };
配置@babel/plugin-transform-runtime

其实用法原理部分已经在上边分析的比较透彻了,配置这里还有疑问的同学可以评论区给我留言或者移步babel官网查看

这里为列一份目前它的默认配置:

{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": false,
        "helpers": true,
        "regenerator": true,
        "version": "7.0.0-beta.0"
      }
    ]
  ]
}

总结polyfill

我们可以看到针对polyfill其实我耗费了不少去将它们之间的区别和联系,让我们来稍微总结一下吧。

babel中实现polyfill主要有两种方式:

  • 一种是通过@babel/polyfill配合preset-env去使用,这种方式可能会存在污染全局作用域。

  • 一种是通过@babel/runtime配合@babel/plugin-transform-runtime去使用,这种方式并不会污染作用域。

  • 全局引入会污染全局作用域,但是相对于局部引入来说。它会增加很多额外的引入语句,增加包体积。

useBuintIns:usage情况下其实和@babel/plugin-transform-runtime情况下是类似的作用,

通常我个人选择是会在开发类库时遵守不污染全局为首先使用@babel/plugin-transform-runtime而在业务开发中使用@babel/polyfill

babel-runtime 是为了减少重复代码而生的。 babel生成的代码,可能会用到一些_extend(), classCallCheck() 之类的工具函数,默认情况下,这些工具函数的代码会包含在编译后的文件中。如果存在多个文件,那每个文件都有可能含有一份重复的代码。

babel-runtime插件能够将这些工具函数的代码转换成require语句,指向为对babel-runtime的引用,如 require('babel-runtime/helpers/classCallCheck'). 这样, classCallCheck的代码就不需要在每个文件中都存在了。

Babel插件开发

上边我们讲到了日常业务中babel的使用方式原理,接下来我们来讲讲babel插件相关开发的内容。

也许你会疑惑Babel插件能有什么样的作用?简单来说,通过babel插件可以带你在原理层面更加深入前端编译原理的知识内容。

当然如果不仅仅是对于个人能力的提升,假使你在开发一款属于自己的组件库,你想实现类似element-plus中的按需引入方式,又或许对于lint你存在自己的特殊的规则。再不然对于一些js中特殊的写法的支持。

总而言之,懂编译原理真的是可以无所欲为!

带你走进babel的编译世界

针对于编译方面的知识,文章中的重点并不是这个。但是大家请放心,我会用最通俗的方式带大家入门babel插件的开发。

webpacklintbabel等等很多工具和库的核心都是通过抽象语法树(Abstract Syntax Tree,AST)这个概念来实现对代码的处理。

AST

所谓抽象语法树就是通过JavaScript Parser将代码转化成为一颗抽象语法树,这棵树定义了代码的结构。然后通过操纵这棵树的增删改查实现对代码的分析,变更,优化。

针对将代码转化为不同的AST你可以在这里astexplorer目前主流任何解析器的AST转化。

这里我们首先列举一些涉及的参考网站给大家:

  • astexplorer:这是一个在线的代码转译器,他可以按照目前业界主流的方式将任何代码转为AST

  • babel-handbookbabel插件开发中文手册文档。

  • the-super-tiny-compiler-cn:一个github上的开源小型listp风格转化js编译器,强烈推荐对编译原理感兴趣的同学可以去看一看它的代码。

babel插件开发基础指南

当我们需要开发一款属于自己的babel插件时,通常我们会借助babel的一些库去进行代码的parser以及transform astgenerator code,并不需要我们去手动对代码进行词法/语法分析过程。

插件开发通常会涉及这些库:

  • @babel/core:上边我们说过babel/corebabel的核心库,核心的api都在这里。比如上边我们讲到的transformparse方法。

  • @babel/parser:babel解析器。

  • @babel/types: 这个模块包含手动构建 AST 和检查 AST 节点类型的方法(比如通过对应的api生成对应的节点)。

  • @babel/traverse: 这个模块用于对Ast的遍历,它维护了整棵树的状态(需要注意的是traverse对于ast是一种深度遍历)。

  • @babel/generator: 这个模块用于代码的生成,通过AST生成新的代码返回。

babel的工作流程

在日常前端项目中,绝大多数时候我们使用babel进行js代码的转化。

它的工作流程大概可以概括称为以下三个方面:

  • Parse(解析)阶段:这个阶段将我们的js代码(字符串)进行词法分析生成一系列tokens,之后再进行语法分析将tokens组合称为一颗AST抽象语法树。(比如babel-parser它的作用就是这一步)

  • Transform(转化)阶段:这个阶段babel通过对于这棵树的遍历,从而对于旧的AST进行增删改查,将新的js语法节点转化称为浏览器兼容的语法节点。(babel/traverse就是在这一步进行遍历这棵树)

  • Generator(生成)阶段:这个阶段babel会将新的AST转化同样进行深度遍历从而生成新的代码。(@babel/generator)

我们用一张图来描述一下这个过程:

image.png

babelAST的遍历过程

  • AST是所谓的深度优先遍历,关于何谓深度优先不了解的同学可以自行查阅相关资料~

  • babelAST节点的遍历是基于一种访问者模式(Visitor),不同的访问者会执行不同的操作从而得到不同的结果。

  • visitor上挂载了以每个节点命名的方法,当进行AST遍历时就会触发匹配的方法名从而执行对应方法进行操作。

手把手带你开发babel插件

这里我们以一个简单的ES6中的箭头函数转化为ES5方式入手,来带大家入门真正的babel插件开发。

我相信有的同学可能有疑惑,babel中已经存在对应的@babel/plugin-transform-arrow-functions进行箭头函数的转化,为什么我们还要去实现它呢。

没错,babel中的确已经存在这个插件而且已经非常完美了。这里我想强调的是,之所以选择这个例子是想带大家真正入门babel插件的开发流程,一个简单的例子其实可以更好的带给大家插件开发背后的思想体会,从而可以让大家举一反三。

让我们开始吧~

首先让我们来看看我们需要实现的结果:

目标

// input
const arrowFunc = () => {
	console.log(this)
}

// output
var _this = this
funciton arrowFunc() {
    console.log(_this)
}

babel原版转化方式

/**
 * babel插件
 * 主要还是@babel/core中的transform、parse 对于ast的处理
 * 以及babel/types 中各种转化规则
 *
 * Ast是一种深度优先遍历
 * 内部使用访问者(visitor)模式
 *
 * babel主要也是做的AST的转化
 *
 * 1. 词法分析 tokens : var a  = 1 ["var","a","=","1"]
 * 2. 语法分析 将tokens按照固定规则生成AST语法树
 * 3. 语法树转化 在旧的语法树基础上进行增删改查 生成新的语法书
 * 4. 生成代码 根据新的Tree生成新的代码
 */

// babel核心转化库 包含core -》 AST -》 code的转化实现
/* 
  babel/core 其实就可以相当于 esprima+Estraverse+Escodegen
  它会将原本的sourceCode转化为AST语法树
  遍历老的语法树
  遍历老的语法树时候 会检查传入的插件/或者第三个参数中传入的`visitor`
  修改对应匹配的节点 
  生成新的语法树
  之后生成新的代码地址
*/
const babel = require('@babel/core');

// babel/types 工具库 该模块包含手动构建TS的方法,并检查AST节点的类型。(根据不同节点类型进行转化实现)
const babelTypes = require('@babel/types');

// 转化箭头函数的插件
const arrowFunction = require('@babel/plugin-transform-arrow-functions');


const sourceCode = `const arrowFunc = () => {
	console.log(this)
}`;

const targetCode = babel.transform(sourceCode, {
  plugins: [arrowFunction],
});

console.log(targetCode.code)

这里我们使用了babel/core,它的transform方法会将我们的代码转化称为AST同时进入plugins的处理成为新的AST,最终生成对应的代码。

不太清楚插件工作原理的同学可以根据代码注释自己动手写一下,这里仅仅是短短十几行代码。

自己实现@babel/plugin-transform-arrow-functions插件

这里我们尝试一下自己来实现一个这样的功能。

首先,让我们先来写好基础的结构:

const babel = require('@babel/core');

// babel/types 工具库 该模块包含手动构建TS的方法,并检查AST节点的类型。(根据不同节点类型进行转化实现)
const babelTypes = require('@babel/types');

// 我们自己实现的转化插件
const { arrowFunctionPlugin } = require('./plugin-transform-arrow-functions');


const sourceCode = `const arrowFunc = () => {
	console.log(this)
}`;

const targetCode = babel.transform(sourceCode, {
  plugins: [arrowFunctionPlugin],
});
// 打印编译后代码
console.log(targetCode.code)
// plugin-transform-arrow-functions.js
const arrowFunctionPlugin = () => {
    // ...
}
module.exports = {
    arrowFunctionPlugin
}

这里,我们新建了一个plugin-transform-arrow-functions文件来实现我们自己的插件:

上边我们讲过babel插件实质上就是一个对象,里边会有一个属性visitor。这个visitor对象上会有很多方法,每个方法都是基于节点的名称去命名的。

babel/core中的transform方法进行AST的遍历时会进入visitor对象中匹配,如果对应节点的类型匹配到了visitor上的属性那么就会从而执行相应的方法。

比如这样一段代码:

const arrowFunctionPlugin = {
    visitor: {
      ArrowFunctionExpression(nodePath) {
          // do something
      }
    },
}

当进行AST遍历时,如果碰到节点类型为ArrowFunctionExpression时就会进入visitor对象中的ArrowFunctionExpression方法从而执行对应逻辑从而进行操作当前树。

这里有两个tip需要和大家稍微解释一下。

  • 我如何知道每个节点的类型呢?比如ArrowFunctionExpression就是箭头函数的类型。

首先,babel/types中涵盖了所有的节点类型。我们可以通过查阅babel/types查阅对应的节点类型。

当然还存在另一个更加方便的方式,上边我们提到的astexplorer,你可以在这里查阅对应代码生成的AST从而获得对应的节点。

  • 什么是nodePath参数,它有什么作用?

这里每一个方法都存在一个nodePath参数,所谓的nodePath参数你可以将它理解成为一个节点路径。它包含了这个树上这个节点分叉的所有信息和对应的api注意这里可以强调是路径,你可以在这里查阅它的含义以及对应的所有API

在我们写好基础的结构之后,让我们来开始动手实现插件的内部逻辑吧。

我们清楚想要讲代码进行编译,难免要进行AST节点的修改。本质上我们还是通过对于AST节点的操作修改AST从而生成我们想要的代码结果。

  • 首先,我们可以通过astexplorer分别输入我们的源代码和期望的编译后代码得到对应的AST结构。

  • 之后,我们在对比这两棵树的结构从而在原有的AST基础上进行修改得到我们最终的AST

  • 剩下,应该就没有什么剩下的步骤了。babel transform方法会根据我们修改后的AST生成对应的源代码。

强烈建议同学们自己进入astexplorer输入需要转译的代码和转译后的代码进行对比一下。

需要编译的箭头函数部分节点截图:

image.png

编译后代码的部分节点截图:

image.png

这里,我们发现对比inputoutput:

  • output中将箭头函数的节点ArrowFunctionExpression替换成为了FunctionDeclaration
  • output中针对箭头函数的body,调用表达式声明ExpressionStatement时,传入的argumentsThisExpression更换成了Identifier
  • 同时output在箭头函数同作用域内额外添加了一个变量声明,const _this = this

很简单吧,我们只需要在我们的arrowFunctionPlugin中实现这三个功能就可以满足要求了,让我们一起来动手试一试吧。

const babelTypes = require('@babel/types');

function ArrowFunctionExpression(path) {
  const node = path.node;
  hoistFunctionEnvironment(path);
  node.type = 'FunctionDeclaration';
}

/**
 *
 *
 * @param {*} nodePath 当前节点路径
 */
function hoistFunctionEnvironment(nodePath) {
  // 往上查找 直到找到最近顶部非箭头函数的this p.isFunction() && !p.isArrowFunctionExpression()
  // 或者找到跟节点 p.isProgram()
  const thisEnvFn = nodePath.findParent((p) => {
    return (p.isFunction() && !p.isArrowFunctionExpression()) || p.isProgram();
  });
  // 接下来查找当前作用域中那些地方用到了this的节点路径
  const thisPaths = getScopeInfoInformation(thisEnvFn);
  const thisBindingsName = generateBindName(thisEnvFn);
  // thisEnvFn中添加一个变量 变量名为 thisBindingsName 变量值为 this
  // 相当于 const _this = this
  thisEnvFn.scope.push({
    // 调用babelTypes中生成对应节点
    // 详细你可以在这里查阅到 https://babeljs.io/docs/en/babel-types
    id: babelTypes.Identifier(thisBindingsName),
    init: babelTypes.thisExpression(),
  });
  thisPaths.forEach((thisPath) => {
    // 将this替换称为_this
    const replaceNode = babelTypes.Identifier(thisBindingsName);
    thisPath.replaceWith(replaceNode);
  });
}

/**
 *
 * 查找当前作用域内this使用的地方
 * @param {*} nodePath 节点路径
 */
function getScopeInfoInformation(nodePath) {
  const thisPaths = [];
  // 调用nodePath中的traverse方法进行便利
  // 你可以在这里查阅到  https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md
  nodePath.traverse({
    // 深度遍历节点路径 找到内部this语句
    ThisExpression(thisPath) {
      thisPaths.push(thisPath);
    },
  });
  return thisPaths;
}

/**
 * 判断之前是否存在 _this 这里简单处理下
 * 直接返回固定的值
 * @param {*} path 节点路径
 * @returns
 */
function generateBindName(path, name = '_this', n = '') {
  if (path.scope.hasBinding(name)) {
    generateBindName(path, '_this' + n, parseInt(n) + 1);
  }
  return name;
}

module.exports = {
  hoistFunctionEnvironment,
  arrowFunctionPlugin: {
    visitor: {
      ArrowFunctionExpression,
    },
  },
};

接下来让我们在代码中使用我们写好的插件来run一下吧。

image.png

总结插件开发流程

上边虽然是一个简单的插件Demo例子,但是麻雀虽小五脏俱全。一个完整的babel插件流程大概就是如此,这里让我们稍微总结一下关于babel插件的开发过程。

  • 通过源代码和转译后代码进行AST节点对比,找出对应的区别节点,尽量复用之前的节点。

  • 存在修改/增加/删除的节点,通过nodePath中的Api调用对应的方法进行AST的处理。

宏观上来讲插件的开发流程主要就分为这两个步骤,剩下的就是各位对于ast中转化部分的“业务逻辑”啦。

babel插件开发部分可能会涉及一些大家之前并没有接触过的API,这里我选择直接用代码的方式去讲解插件的开发并没有去深入讲解这些API。如果对某些部分不太理解的话可以在评论区留言给我,对应的API我个人建议大家多动手去babel-handbook插件开发手册查询,这里理解起来会更加深刻。

文中插件仅仅是一个小Demo级别的,目的是为了将大家带入babel插件的开发的大门。文章中的代码你可以在这里查看。这个repo中不仅仅包含文章中的demo,还涉及了一些难度更高的插件学习模仿,以及文章开始提到的实现组件库的按需加载插件 (按需加载插件我还在写,原谅我的懒惰...)。

11.6更新

这里我们补充一个关于babel-register的简单知识点。

我们先来看看官网上对于babel-register的描述:

One of the ways you can use Babel is through the require hook. The require hook will bind itself to node's require and automatically compile files on the fly. This is equivalent to CoffeeScript's coffee-script/register.

简答来说也就是引入babel/registerbabel会对于特定后缀的文件进行处理。

All subsequent files required by node with the extensions .es6, .es, .jsx, .mjs, and .js will be transformed by Babel.

起初其实我不太理解它的作用究竟是什么,对于上述后缀的文件交给Babel进行转化,这个我们在各种前端编译工具中比如webpack中的babel-loaderrollup中的@rollup/plugin-babel帮我们做的事情吗?

这里的@babel/regiser的功能是不是有点多次一举了。

直到我在stackoverflow中看到这样一段回答

稍微简单总结一下,也就是说通常在我们前端领域中我们结合了各种构建工具和babel的配合使用可以更好的将需要处理的文件交给babel进行处编译。

而在nodejsbabel并不是其核心API的一部分,如果我们想在nodejs中使用babel进行转译我们的文件,就可以通过babel/regiseter针对于require语句引入的文件交给babeltransform。从而达到在nodejs进行转译js文件。

需要额外注意的是babel/register是即时编译。

// index.js
require('@babel/register')
const data = require('./register');

console.log(`${data}`, 'data');

// register.js
const arrowFunction = () => {
  console.log('Hello,My name is wang.haoyu');
};

module.exports = arrowFunction

// babelrc
{
  "presets": ["@babel/preset-env"]
}

当我们使用node运行index.js时:

image.png

你会发现我们的打印结果已经变成了普通函数了。

此时,让我们来注释掉对应的@babel/register

// require('@babel/register')
const data = require('./register');

console.log(`${data}`, 'data');

image.png

我们发现此时我们的箭头函数并没有被preset-env生效。

11.16更新

补充关于useBuiltIns:usageplugin-runtime的区别与最佳实践

业务Babel最佳场景

关于业务中最佳的Babel适用场景,我在另一篇文章中进行了详细阐述。

有兴趣的小伙伴可以移步前端基建」探索不同项目场景下Babel最佳实践方案

写在结尾

至此,感谢每一位看到这里的小伙伴。

文章中的babel讲解也不过是冰山一角,希望成为这篇文章会成为大家探索Babel的起点。