关于Babel的那些事儿
2021-12-20
| 2023-3-30
0  |  0 分钟
password
Created
Feb 19, 2023 04:27 PM
type
Post
status
Published
date
Dec 20, 2021
slug
summary
关于Babel的那些事儿
tags
Babel
category
工程化
icon
 
最近在给老的小程序工程切换原有的CI脚本, 迁移期间发现原有的CI脚本中竟是在上传前进行了一些babel的构建配置等流程才进行操作上传的.
例如: 配置了一些额外的插件
{ plugins: [ ['@babel/plugin-proposal-object-rest-spread', { useBuiltIns: true }], '@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-optional-chaining' ] }
那么这些插件目前到底还有存在的价值吗? 是否可以直接删掉. 带着这些疑问, 便开始了我的babel研究之旅.
本文会具体阐述babel的演进过程, babel生态. 同时会用一个tiny babel的小例子来展示babel是如何工作的. 最后, 我们以babel-plugin-import为例, 阐述一下babel的自定义插件实现.

起源

Babel 指的是 通天塔,是巴比伦文明里面的 通天塔 当时地上的人们都说同一种语言,当人们离开东方之后,他们来到了示拿之地。在那里,人们想方设法烧砖好让他们能够造出一座城和一座高耸入云的塔来传播自己的名声,以免他们分散到世界各地。上帝来到人间后看到了这座城和这座塔,说一群只说一种语言的人以后便没有他们做不成的事了;于是上帝将他们的语言打乱,这样他们就不能听懂对方说什么了,还把他们分散到了世界各地,这座城市也停止了修建。这座城市就被称为“巴别城”。 -- 《创世记》
通天塔的意义在于人类对于书同文 车同轨的美好向往的标志.
对于前端来说, babel则提供了一个"面向未来"的编码模式, 我们可以编写各种新语法和语法糖, 而由babel帮助我们将它转译为浏览器能够识别的代码

babel是啥?

官方给出的解释是: "a JavaScript compiler"
也就是一个Javascript的转译器
babel能够帮助我们将ES2015+的代码. 转译成为浏览器能够识别和执行的代码

前置知识

在此之前, 先同步几个前置知识:
为什么同步这些知识呢, 因为babel就是基于ES的标准去建立的, 在babel的配置过程中, 也时刻存在着这些标准的影子. 提前了解才不至于在配置时一头雾水.
  1. ES standard
应该大部分的前端同学都会知道, Javascript的官方称谓是ECMAScript, 是由ECMA国际(前身为欧洲计算机制造商协会)在标准ECMA-262中定义的脚本语言规范. 语言有几个版本, 且自2015之后, ES标准均以年份的方式命名, 且每年均会产生一个新的标准, 里面会囊括一些之前语法的修正及新特性, 语法糖等
ES3/ES5: 古老版本, 目前浏览器均已支持
ES6(ES2015): 推出了大量的语法更新, 并决定每年推出新标准, 按年份命名 ES2016: ... ES2017: ... ES2018: ... ...
  1. TC39
TC39是ECMA下的一个标准化组织, 负责推进ES标准化与处理ES新语法特性的提案, 提案分为四个步骤, 进入到第四步骤的, 才会被纳入下一年的ES标准中
  • Stage0(strawman): 任何讨论、想法、改变或者还没加到提案的特性都在这个阶段
  • Stage1(proposal): 产出一个正式的提案
  • Stage2(draft): 草案, 提案更加详细, 之后草案只允许增量更新
  • Stage3(candidate): 候选阶段, 已经很接近标准化了, 这个时候可能部分浏览器已经实现了此标准
  • Stage4(finished): 完成, 将会出现在下一个年度的ES标准中

版本

babel的前身是6to5, 致力于将ES6转换为ES5的转译器, 之后6to5不再维护, 转为babel
babel逐步的迭代演进下, 主要又以下几个大的版本:
Babel4: 与6to5的能力是类似的, 致力于将ES6转换为ES5
Babel5: 在babel中正式提出了stage的概念, babel编译指定stage即可转译特定stage下的语法
Babel6 支持了插件化的系统, babel至此成为了一个"平台"而不是一个单一的工具包, 并提供了stage的预设, 将配置的复杂性大大降低
Babel7 (目前版本)不再使用stage的方式来维护插件(因为标准不断在变, 导致babel相应要给出大量的breaking change), 而是基于browsisfy, 基于monorepo来对仓库进行管理
目前市面上主流是使用的babel7, 但有部分老项目可能会用到babel6, 二者最大的区别则是: babel7因为使用了monorepo, 所以是用@babel这个域开头的npm包.

babel的架构

babel仓库中存在着大量的包, 我们可以将这些包分成几类:
  1. 核心包
@babel/cli 负责babel的命令处理
@babel/core 负责babel整个体系的调度, 相当于是babel的"引擎"
  1. 转译与转换
@babel/parser 负责babel的转换逻辑, 主要是词法与语法转换成AST
@babel/traverse 遍历AST, 并调用visitor函数来修改AST
@babel/generate 把AST打印为目标代码字符串, 同时生成sourcemap
  1. 帮助类包
@babel/node 命令行, 可以直接替代node来使用
@babel/helpers 辅助函数集合
  1. 插件
babel中有大量的官方插件均以babel-plugin为前缀命名

原理初探

一次babel转换经历了如下三个步骤
  1. parse @babel/parser
将源码转成AST 其中又包含了词法分析(lexical parse)和句法分析(syntactic parse)两个阶段
  1. transform @babel/traverse
遍历AST, 并调用visitor函数来修改AST
如果修改AST涉及到AST的判断, 创建, 修改等, 这时候就需要@babel/types了
当需要批量创建AST的时候可以使用@babel/template来简化AST创建逻辑
  1. generate @babel/generate
Generate阶段会把AST打印为目标代码字符串, 同时生成sourcemap(用@babel/generate)
中途遇到错误要打印代码位置的时候, 用@babel/code-frame

实现一个tiny-babel

假如我们要实现一个转译器, 将某个语言的语法, 转换成另一个语言的语法, 按照babel的流程应该怎样做呢?
假设这样一个场景, 我们需要将LISP的语法, 转换成C的语法
LISP
C
(add 2 2)
add(2, 2)
(subtract 4 2)
subtract(4, 2)
(add 2 (subtract 4 2))
add(2, subtract(4, 2))

词法转换

首先, 我们通过词法转换, 将原有的LISP语法抽象成为一个个的"token"
对于最后一个来说, 我们期望的抽象结果是这样的
[ { type: 'paren', value: '(' }, { type: 'name', value: 'add' }, { type: 'number', value: '2' }, { type: 'paren', value: '(' }, { type: 'name', value: 'subtract' }, { type: 'number', value: '4' }, { type: 'number', value: '2' }, { type: 'paren', value: ')' }, { type: 'paren', value: ')' } ]
对于初步转换来说, 我们只需要识别其是数字, 还是字符, 还是符号就好了
逻辑则非常简单, 只是一个简单的单指针的遍历
function tokenizer(input) { let current = 0 let tokens = [] while (current < input.length) { let char = input[current] if (char === '(') { tokens.push({ type: 'paren', value: '(' }) current++ continue } if (char === ')') { tokens.push({ type: 'paren', value: ')' }) current++ continue } let WHITESPACE = /s/ if (WHITESPACE.test(char)) { current++ continue } let NUMBERS = /[0-9]/ if (NUMBERS.test(char)) { let value = '' while (NUMBERS.test(char)) { value += char char = input[++current] } tokens.push({ type: 'number', value }) continue } if (char === '"') { let value = '' char = input[++current] while (char !== '"') { value += char char = input[++current] } char = input[++current] tokens.push({ type: 'string', value }) continue } let LETTERS = /[a-z]/i if (LETTERS.test(char)) { let value = '' while (LETTERS.test(char)) { value += char char = input[++current] } tokens.push({ type: 'name', value }) continue } throw new TypeError('I dont know what this character is: ' + char) } return tokens }

语法转换

通过语法转换, 则可以将原来的词法分析的对象转换成为AST对象, 其主要的实现则是识别两个符号中间的内容, 并做转换即可, 我们的目标是将之前词法转换的内容, 转换成为以下结构
const test1 = { type: 'Program', body: [ { type: 'CallExpression', name: 'add', params: [ { type: 'NumberLiteral', value: '2' }, { type: 'CallExpression', name: 'subtract', params: [ { type: 'NumberLiteral', value: '4' }, { type: 'NumberLiteral', value: '2' } ] } ] } ] } function parser(tokens) { let current = 0 function walk() { let token = tokens[current] if (token.type === 'number') { current++ return { type: 'NumberLiteral', value: token.value } } if (token.type === 'string') { current++ return { type: 'StringLiteral', value: token.value } } if (token.type === 'paren' && token.value === '(') { token = tokens[++current] let node = { type: 'CallExpression', name: token.value, params: [] } token = tokens[++current] while ( token.type !== 'paren' || (token.type === 'paren' && token.value !== ')') ) { node.params.push(walk()) token = tokens[current] } current++ return node } throw new TypeError(token.type) } let ast = { type: 'Program', body: [] } while (current < tokens.length) { ast.body.push(walk()) } return ast }

访问者

转换AST之后, 我们就能够获得该语法的完整描述了. 直接通过DFS遍历即可获得这串"符号"到底做了些什么.
但是这样是有缺陷的, 主要有以下原因
  1. DFS无法在任意节点上进行中止, 或对其周围, 甚至顶层的对象进行处理, 也无法处理一些异步的情况
  1. 直接通过DFS来遍历, 我们无法很方便的获知到当前节点的父节点等等的信息.
所以因此, AST中一般会同步创建一个visitor对象来对AST进行访问
在这个tiny-babel中, 我们的visitor如下:
{ Program: { enter(node, parent) {}, exit(node, parent) {} }, CallExpression: { enter(node, parent) {}, exit(node, parent) {} }, NumberLiteral: { enter(node, parent) {}, exit(node, parent) {} } }

访问器

访问器会使用AST和visitor作为参数, 对AST节点进行遍历, 同时对相应的类型节点调用visitor对应的函数
function traverser(ast, visitor) { function traverseArray(array, parent) { array.forEach(child => { traverseNode(child, parent) }) } function traverseNode(node, parent) { let methods = visitor[node.type] if (methods && methods.enter) { methods.enter(node, parent) } switch (node.type) { case 'Program': traverseArray(node.body, node) break case 'CallExpression': traverseArray(node.params, node) break case 'NumberLiteral': case 'StringLiteral': break default: throw new TypeError(node.type) } if (methods && methods.exit) { methods.exit(node, parent) } } traverseNode(ast, null) }

转换器

最后, 我们就可以根据两种语言的特定语法来转换AST对象了
function transformer(ast) { let newAst = { type: 'Program', body: [] } ast._context = newAst.body traverser(ast, { NumberLiteral: { enter(node, parent) { parent._context.push({ type: 'NumberLiteral', value: node.value }) } }, StringLiteral: { enter(node, parent) { parent._context.push({ type: 'StringLiteral', value: node.value }) } }, CallExpression: { enter(node, parent) { let expression = { type: 'CallExpression', callee: { type: 'Identifier', name: node.name }, arguments: [] } node._context = expression.arguments if (parent.type !== 'CallExpression') { expression = { type: 'ExpressionStatement', expression: expression } } parent._context.push(expression) } } }) return newAst }

转换完成

至此, 我们就成功完成LISP语言到C语言的转换了.
其实总结下来, 整个的转换流程主要分为四个步骤
  1. 字符串通过词法转换, 生成token
  1. token转换生成AST
  1. AST通过访问器来逐个遍历和访问
  1. 最终通过特定的逻辑转换成为目标的AST, 从而实现语法的转换
在babel中, @babel/parser完成了步骤一和步骤二
@babel/traverse实现了对AST的访问和转换, 也就是步骤三四
@babel/generate在tiny-babel中没有体现, 是AST转换成为目标代码字符串的过程
关于tiny-babel的具体设计详情, 可以参照 这个github仓库>

实现一个babel插件

其实从上面的一节可以知道, 我们通过@babel/traverse的访问器对象, 就可以实现对AST的访问
假如我们需要实现一个极极简版的babel-plugin-import, 应该怎么实现呢?
众所周知, babel-plugin-import是一个给npm包实现按需引入的插件. 在antd中应用的非常广泛. 本质上其实是将引入全量包的编写方式变成了部分组件引入, 如下:
// 我们编写的原始引入方式, 会引入全量的antd组件 import { Button } from 'antd' // 在babel中配置按需引入"plugins": [["babel-plugin-import", { "libraryName": "antd", "libraryDirectory": "lib", "style": true }]] // 原有的引用方式会变成 var _button = require('antd/lib/button'); require('antd/lib/button/style');

STEP1: 创建插件

插件的新建非常简单, 我们新建一个js文件即可, 同时将该js文件引入到babel的plugin配置中
// 假设插件名为plugin-test "plugins": [["./plugin-test.js", { "libraryName": "antd", "libraryDirectory": "lib", "style": true }]]
接下来, 我们按照plugin的标准来进行插件初始化, 从前面一节我们了解到, 插件本质上是基于@babel/traversevisitor遍历过程.
module.exports = function({types: t}) { return { visitor: { // ... } } }

STEP2: 查看AST

这里我们就要用到https://astexplorer.net 这个网站了, 可以帮助我们实时看到我们需要转换的AST节点的情况. 我们可以将import的原始语法复制到这个站点中, 即可获得其AST的节点信息.
我们还是以button为例
import { Button } from 'antd';
通过查看AST, 我们便可以知道下一步需要做的事情
  1. 确认此import语法的源是否与传入的libraryName一致, 只有一致才需要进行转换
  1. 获取到通过解构语法引入的组件列表
  1. 遍历组件列表, 生成新的组件引入语法
  1. 删除当前import节点
notion image

STEP3: 编写插件

这里补充一个前置知识是, babel提供了一个帮助包, 可以让我们很方便的创建import节点 babel-helper-module-imports>
新建的引入关系, 我们也是用这个包来进行实现
const {addDefault, addSideEffect} = require('@babel/helper-module-imports') module.exports = function({types: t}) { return { visitor: { ImportDeclaration(path, state) { const {libraryName, libraryDirectory = "lib", style = true} = state.opts // 找到source为当前传入的libraryName的import if (path.node?.source?.value === libraryName) { const specifiers = path.node?.specifiers // 遍历引入的单个组件 specifiers.forEach(v => { // 获取组件的名称 const componentName = v.imported.name // 根据组件的名称来新曾组件和样式引入 addDefault(path, `${libraryName}/${libraryDirectory}/${componentName}`, { nameHint: componentName }) addSideEffect(path, `${libraryName}/${libraryDirectory}/${componentName}/style`) }) // 移除当前组件 path.remove() } } } } }

STEP4: 优化

上面已经实现了一个极简版本的babel-plugin-import, 但是还存在的问题是:
  1. 如果我引入了组件, 但是在之后并没有使用到, 是不是可以在这个时候shaking掉?
问题的解决方案可以去阅读一下babel-plugin-import的源码, 还是很有意思的, 这里不再赘述了.
实现babel-plugin-import部分参考了 这篇文章

babel的"坑"

  1. 傻傻分不清楚的命名规则(删掉)
如果需要导入@babel/plugin-transform-runtime, 下面的引入方式是正确的吗?
A:
{ "plugin": ["@babel/plugin-transform-runtime"] }
B:
{ "plugin": ["@babel/transform-runtime"] }
C:
{ "plugin": ["transform-runtime"] }
上面的情况中, 只有C是不支持的, AB其实写法都对, 因为babel提供了一些简写的方式. 除了以上之外其实还有很多简写的规则, 具体可以参照:
  1. polyfill的问题
在babel中, 默认是只转换新的语法, 而不转换新的API的. 例如: 箭头函数属于新的语法, 而Promise则是一个新的API
新的API则需要我们使用polyfill来进行兼容, polyfill的兼容方式有以下几种
  • 直接在html文件引入Babel官方的polyfill.js脚本文件;
  • 在前端工程的入口文件里引入polyfill.js;
  • 在前端工程的入口文件里引入@babel/polyfill;
  • 在前端工程的入口文件里引入core-js/stable与regenerator-runtime/runtime;
  • 在前端工程构建工具的配置文件入口项引入polyfill.js;
  • 在前端工程构建工具的配置文件入口项引入@babel/polyfill;
  • 在前端工程构建工具的配置文件入口项引入core-js/stable与regenerator-runtime/runtime;
上面的几种方法都能够实现polyfill的引入和语法兼容, 但是存在的问题是:
因为是入口处引入, 本质上这些polyfill都是直接在全局注入了这些API的, 也就是说, 引入之后, 可以通过window.XXX的方式来使用这些API, 但是这样会导致全局污染
其次是 所有的polyfill是全量引入的, 我们无法将当前不需要的polyfill给去掉
总结来说, 就是要解决"全局"和"全量"的问题, 在这里, babel提供了一个官方的插件@babel/plugin-transform-runtime来解决这个问题, 这也是目前babel7中配置的"最优配置解"
默认情况下, @babel/plugin-transform-runtime会使用@babel/runtime来进行选择性的polyfill, 也可以使用corejs2corejs3来进行polyfill, 目前笔者试了一下, 对于Promise这个API的polyfill目前似乎@babel/runtime并没有提供对应支持.
// 按此预设, 理论上说ie11没有支持Promise, 所以应该加上Promise的polyfill才对 // 但是并没有加上 { "presets": [ [ "@babel/preset-env", { "targets": { "chrome": 58, "ios": 8, "ie": 11 } } ] ], "plugins": [["@babel/plugin-transform-runtime", {}]] }
一般来讲, 我们会选择最新的corejs3来进行polyfill, 实现也很简单, 加上corejs配置即可
{ "presets": [ [ "@babel/preset-env", { "targets": { "chrome": 58, "ios": 8, "ie": 11 } } ] ], "plugins": [["@babel/transform-runtime", { "corejs": 3 }]] }
  1. babel插件的优先级策略
  • plugin优先级高于preset
  • plugin是左到右执行
  • preset是右到左执行
 
  1. babel-preset-env包含了哪些包?
可以通过配置这个包的debug为true, 能够打印出preset中你配置的plugin集合中的内容

好用的在线调试工具

  1. babel在线配置调试
可以直接调试babel各种插件装配之后的效果, 不需要自己本地npm一个个安装插件来尝试了, 非常方便.
脚手架如果需要新配置babel的话, 可以在这里先配置好, 然后再根据这里的选项生成babel的配置.
  1. ast explorer
可以支持在线查看ast的工具, 在编写babel插件的时候很有用
  1. @babel/types
当编写babel插件的时候, 可以通过这个包来进行一些辅助判断等.
plugin中的入口, type参数就是@babel/types的实例
module.exports = function({types: t}) { return { visitor: { // ... } } }
工程化
  • Babel
  • Vite:起源与实现机制原型链Getter/Setter的屏蔽效应
    目录