者:薛丁科
巴比伦塔 (希伯来语:???????? ??????,**Migdal Bā?ēl)也译作巴贝尔塔、巴别塔,或意译为通天塔**),本是犹太教《塔纳赫·创世纪篇》中的一个故事,说的是人类产生不同语言的起源。在这个故事中,一群只说一种语言的人在“大洪水”之后从东方来到了示拿地区,并决定在这修建一座城市和一座“能够通天的”高塔;上帝见此情形就把他们的语言打乱,让他们再也不能明白对方的意思,并把他们分散到了世界各地。摘自 Wikipedia - Tower of Babel[1]
Babel 官网是这样定义的:Babel is a JavaScript compiler。Babel 是一套解决方案,主要用来把 ECMAScript 2015+ 的代码转化为浏览器或者其它环境支持的代码。它主要可以做以下事情:
2014 年,高中生 Sebastian McKenzie 首次提交了 babel 的代码,当时的名字叫 6to5。从名字就能看出来,它主要的作用就是将 ES6 转化为 ES5。于是很多人评价,6to5 只是 ES6 得到支持前的一个过渡方案,但作者非常不同意这个观点,他认为 6to5 不光会按照标准逐步完善,依然具备非常大的潜力反过来影响并推进标准的制定。正因为如此,后来的 6to5 团队觉得 '6to5' 这个名字并没有准确的传达这个项目的目标。加上 ES6 正式发布后,被命名为 ES2015,对于 6to5 来说更偏离了它的初衷。于是 2015 年 2 月 15 号,6to5 正式更名为 Babel。(把 ES6 送上天的通天塔)
了解完 babel 是什么后,我们接下来看如何使用它。根据官网中提供的用法,我们初始化一个基础项目并安装依赖。
npm install --save-dev @babel/core @babel/cli @babel/preset-env
1、配置中的 debug 用于打印 babel 命令执行的日志;
2、presets 主要是配置用来编译的预置,plugins 主要是配置完成编译的插件,具体的含义后面会讲。
接下来,在命令行执行 npm run babel 命令,看看转换效果。
从上图中可以看到,const 被转换成了 var,箭头函数转换成了普通 function,同时打印出来如下日志:
了解完成 babel 的基础使用后,我们来分析 babel 的工作原理。babel 作为一个编译器,主要做的工作内容如下:
整体流程图下:
根据上图中的流程,我们依次进行分析。
Parse(解析)阶段
一般来说,Parse 阶段可以细分为两个阶段:词法分析(Lexical Analysis, LA)和语法分析(Syntactic Analysis, SA)。
词法分析是对代码进行分词,把代码分割成被称为 Tokens 的东西。Tokens 是一个数组,由一些代码的碎片组成,比如数字、标点符号、运算符号等等,例如这样:
// 代码
const a=1;
// Tokens https://esprima.org/demo/parse.html#
[
{
"type": "Keyword",
"value": "const"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "1"
},
{
"type": "Punctuator",
"value": ";"
}
]
词法分析之后,代码就已经变成了一个 Tokens 数组,现在需要通过语法分析把 Tokens 转化为 AST。例如上面的代码转成的 AST 结构如下(在线查看[3]):
在 babel 中,以上工作是通过 @babel/parser 来完成的,它基于ESTree 规范[4],但也存在一些差异。从上图中,我们可以看到最终生成的 AST 结构中有很多相似的元素,它们都有一个 type 属性(可以通过官网提供的说明文档来查看所有类型),这样的元素被称作节点。一个节点通常含有若干属性,可以用于描述 AST 的节点信息。
Transform(转换)阶段
转换阶段,Babel 对 AST 进行深度优先遍历,对于 AST 上的每一个分支 Babel 都会先向下遍历走到尽头,然后再向上遍历退出刚遍历过的节点,然后寻找下一个分支。在遍历的过程中,可以增删改这些节点,从而转换成实际需要的 AST。
以上是 babel 转换阶段操作节点的思路,具体实现是:babel 维护一个称作 Visitor 的对象,这个对象定义了用于 AST 中获取具体节点的方法,如果匹配上一个 type,就会调用 visitor 里的方法,实现如下:
一个简单的 Visitor 对象如下:
const visitor={
FunctionDeclaration(path, state) {
console.log('我是函数声明');
}
};
在遍历 AST 的过程中,如果当前节点的类型匹配 visitor 中的类型,就会执行对应的方法。上面提到,遍历 AST 节点的时候会遍历两次(进入和退出),因此,上面的 Vistor 也可以这样写:
const visitor={
FunctionDeclaration: {
enter(path, state) {
console.log('enter');
},
exit(path, state) {
console.log('exit');
}
}
};
Visitor 中的每个函数接收 2 个参数:path 和 state
path:表示两个节点之间连接的对象,对象包含:当前节点、父级点、作用域等元信息,以及增删改查 AST 的 api。
state:遍历过程中 AST 节点之间传递数据的方式,插件可以从 state 中拿到 opts,也就是插件的配置项
例如使用上面 visitor 遍历如下代码时:
// 源码
function test() {
console.log(1)
}
输出如下:
Generator(生成)阶段
经过上面两个阶段,需要转译的代码已经经过转换,生成新的 AST ,最后一个阶段理所应当就是根据这个 AST 来输出代码。在生成阶段,会遍历新的 AST,递归将节点数据打印成字符串,会对不同的 AST 节点做不同的处理,在这个过程中把抽象语法树中省略掉的一些分隔符重新加回来。比如 while 语句 WhileStatement 就是先打印 while,然后打印一个空格和 '(',然后打印 node.test 属性的节点,然后打印 ')',之后打印 block 部分。
export function WhileStatement(this: Printer, node: t.WhileStatement) {
this.word("while");
this.space();
this.token("(");
this.print(node.test, node);
this.token(")");
this.printBlock(node);
}
@babel/generator 的 src/generators[5] 下定义了每一种 AST 节点的打印方式,通过上述处理,就可以生成最终的目标代码了。
上面介绍了 Babel 的原理,知道了 babel 是如何进行代码解析和转换,以及生成最终的代码。那么转换阶段,babel 是怎么知道要进行哪些转换操作呢?答案是通过 plugin,babel 为每一个新的语法提供了一个插件,在 babel 的配置中配置了哪些插件,就会把插件对应的语法给转化掉。插件被命名为 @babel/plugin-xxx 的格式。
插件的使用:
// babel配置文件
"plugins": [
"pluginA",
['pluginB'],
["babel-plugin-b", { options }] // 如果需要传参就用数组格式,第二个元素为参数。
]
常用插件介绍
// 源码
const profile=(
<div>
<img src="avatar.png" className="profile" />
<h3>{[user.firstName, user.lastName].join(" ")}</h3>
</div>
);
// 出码
const profile=React.createElement(
"div",
null,
React.createElement("img", { src: "avatar.png", className: "profile" }),
React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
);
// 源码
var a=()=> {};
// 出码
var a=function() {};
// 源码
let { x, y }=obj;
let [a, b, ...rest]=arr;
// 出码
function _toArray(arr) { ... }
let _obj=obj,
x=_obj.x,
y=_obj.y;
let _arr=arr,
_arr2=_toArray(_arr),
a=_arr2[0],
b=_arr2[1],
rest=_arr2.slice(2);
更多 babel 插件[6]请参考官网。
插件的形式:
babel 插件支持两种形式,一是函数,二是对象。
export default funciton(babel, options, dirname) {
return {
// 继承某个插件
inherits: parentPlugin,
// 修改参数
manipulateOptions(options, parserOptions) {
options.xxx='';
},
// 遍历前调用
pre(file) {
this.cache=new Map();
},
// 指定 traverse 时调用的函数
visitor: {
FunctionDeclaration(path, state) {
this.cache.set(path.node.value, 1);
}
},
// 遍历后调用
post(file) {
console.log(this.cache);
}
}
}
export default plugin={
pre(state) {
this.cache=new Map();
},
visitor: {
FunctionDeclaration(path, state) {
this.cache.set(path.node.value, 1);
}
},
post(state) {
console.log(this.cache);
}
};
执行顺序:从前往后
上面介绍了插件的使用和具体实现,在实际的项目中,转换时会涉及到非常多的插件,如果我们依次去添加对应的插件,效率会非常低,而且记住插件的名字和其对应功能本身就是一件很难的事。我们能不能把通用的插件封装成一个集合,用的时候只需要安装一个插件即可,这就是 preset。一句话总结:preset 就是对 babel 配置的一层封装。
预设的使用
Preset 预设使用详情[7]可参考官网。
// babel配置文件
{
"presets": [
"presetA", // 字符串
["presetA"], // 数组
[
"presetA", // 如果有参数,数组第二项为对象
{
target: {
chrome: '58' // 目标环境是chrome版本 >=58
}
}
]
]
}
执行顺序:从后往前;
插件 & 预设执行顺序:先执行插件,后执行预设。
让我们再次回到开始的源码转换
从转换结果来看,const 和 var 都进行了转换,但 startsWith 方法却保留原样,这是怎么回事呢?原因是在 babel 中,把 ES6 的标准分为 syntax 和 built-in 两种类型。syntax 就是语法,像 const、=> 这些默认被 Babel 转译的就是 syntax 类型。而对于那些可以通过改写覆盖的语法就认为是 built-in,像 startsWith 和 includes 这些都属于 built-in。而 Babel 默认只转译 syntax 类型的,对于 built-in 类型的就需要通过 @babel/polyfill 来完成转译。 @babel/polyfill 实现的原理也非常简单,就是覆盖那些 ES6 新增的 built-in。示意如下:
Object.defineProperty(Array.prototype, 'startsWith',function(){...})
由于 Babel 在 7.4.0 版本中宣布废弃 @babel/polyfill ,而是通过 core-js 替代,所以本文直接使用 core-js 来讲解 polyfill 的用法。
core-js 使用
// babel.config.js
const presets=[
[
'@babel/env',
{
debug: true,
+ useBuiltIns: 'usage', // usage | entry | false
+ corejs: 3, // 2 | 3
}
]
]
可以看到,代码顶部多了 require("core-js/modules/es.string.starts-with.js"),通过阅读 require 近来的源码[8],它内部实现了字符串的 startsWith 方法,这样就完成了 built-in 类型的转换。
通过上面的介绍,我们对插件的形式和实现有了基本的了解,接下来我们将通过手写一个简单的插件来切身感受下 babel 的魅力。
在我们的日常开发中,经常会在 async 函数中使用 tryCatch 来封装代码,例如:
async function getName() {
try {
// code
const name=await api.getName();
} catch(error) {
// do somethine
}
}
上述每个这样的函数我们都需要封装一次,我们能否把封装的工作交给 babel 来处理呢?答案是肯定的,让我们一起看看怎么实现?
const template=require('@babel/template'); // 使用它来将代码批量生成节点
function babelPlugintryCatch({ types: t }) {
return {
visitor: {
FunctionDeclaration: {
enter(path) {
/**
* 1. 获取当前函数体
* 2. 如果是async函数,则创建tryCatch并将原函数内容放到try体内
* 3. 替换原函数
*/
// 1. 获取当前函数节点信息
const { params, generator, async, id, body }=path.node;
// 如果是async,则执行替换
if (async) {
// 生成 console.log(error) 的节点数据
const catchHandler=template.statement('console.log(error)')();
// 创建trycatch节点,并把原函数体内的代码放到try{}中,把刚刚生成的catchHandler放到catch体内
const tryStatement=t.tryStatement(body, t.catchClause(t.identifier('error'), t.BlockStatement([catchHandler])));
// 创建一个新的函数节点并替换原节点
path.replaceWith(t.functionDeclaration(id, params, t.BlockStatement([tryStatement]), generator, async))
// 跳过当前节点,否则会重新进入当前节点
path.skip();
}
}
}
}
}
}
module.exports=babelPlugintryCatch
从结果来看,我们已经实现了基本的转换需求,但还不是一个完善的插件,例如如果已经有 trycatch 了就不需要再转换了,又例如可以在 catch 体内做一些错误上报等。其它功能留给大家去探索~
babel 是一款 javascript 编译器,它的作用是将 js 编译成目标环境可运行的代码,编译原理是先解析源代码生成 AST,对 AST 进行操作并生成新的 AST,最后根据新的 AST 生成最终的代码。在转换过程中,遍历到不同的节点类型时,会调用在插件中定义的访问者函数来处理,而单个插件的管理成本太大,因此,babel 在插件的基础上通过抽象一层 preset 来批量引入 plugin 并进行配置。(没有什么问题是不能通过增加一个抽象层解决的,如果有,再增加一层)最后我们一起手动实现了一个简单的 babel 插件,对 babel 的转换原理有了更加深入的理解。
[1] 摘自 Wikipedia - Tower of Babel: https://en.wikipedia.org/wiki/Tower_of_Babel
[2] Videos about Babel: https://babeljs.io/videos.html
[3] 在线转换 AST: https://astexplorer.net/
[4] ESTree 规范: https://github.com/estree/estree
[5] @babel/generator 的 src/generators: https://github.com/babel/babel/tree/main/packages/babel-generator/src/generators
[6] 更多 babel 插件: https://babeljs.io/docs/en/babel-plugin-transform-react-jsx
[7] Preset 预设使用详情: https://babeljs.io/docs/en/presets
[8] require 近来的源码: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js
[9] What is Babel?: https://babeljs.io/docs/en/
[10] blockstatement: https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#blockstatement
[11] Babel 插件手册 - 路径: https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md#toc-paths
[12] es.string.starts-with: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js
[13] Babel 设计,组成: https://zhuanlan.zhihu.com/p/57883838
[14] Babel:把 ES6 送上天的通天塔: https://zhuanlan.zhihu.com/p/129089156
[15] 2015 in review: https://medium.com/@sebmck/2015-in-review-51ac7035e272
[16] 在线转换 Tokens: https://esprima.org/demo/parse.html#
关注「字节前端 ByteFE」公众号,追更不迷路!
文将带领大家解析babel-plugin-import 实现按需加载的完整流程,解开业界所认可 babel 插件的面纱。
首先供上babel-plugin-import插件
一、初见萌芽
首先 babel-plugin-import 是为了解决在打包过程中把项目中引用到的外部组件或功能库全量打包,从而导致编译结束后包容量过大的问题,如下图所示:
babel-plugin-import 插件源码由两个文件构成
先来到插件的入口文件 Index :
import Plugin from './Plugin';
export default function({ types }) {
let plugins=null;
/**
* Program 入口初始化插件 options 的数据结构
*/
const Program={
enter(path, { opts={} }) {
assert(opts.libraryName, 'libraryName should be provided');
plugins=[
new Plugin(
opts.libraryName,
opts.libraryDirectory,
opts.style,
opts.styleLibraryDirectory,
opts.customStyleName,
opts.camel2DashComponentName,
opts.camel2UnderlineComponentName,
opts.fileName,
opts.customName,
opts.transformToDefaultImport,
types,
),
];
applyInstance('ProgramEnter', arguments, this);
},
exit() {
applyInstance('ProgramExit', arguments, this);
},
};
const ret={
visitor: { Program }, // 对整棵AST树的入口进行初始化操作
};
return ret;
}
首先 Index 文件导入了 Plugin ,并且有一个默认导出函数,函数的参数是被解构出的名叫 types 的参数,它是从 babel 对象中被解构出来的,types 的全称是 @babel/types,用于处理 AST 节点的方法集。以这种方式引入后,我们不需要手动引入 @babel/types。 进入函数后可以看见观察者( visitor ) 中初始化了一个 AST 节点 Program,这里对 Program 节点的处理使用完整插件结构,有进入( enter )与离开( exit )事件,且需注意:
一般我们所写的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。
这里可能有同学会问 Program 节点是什么?见下方 const a=1 对应的 AST 树 ( 简略部分参数 )
{
"type": "File",
"loc": {
"start":... ,
"end": ...
},
"program": {
"type": "Program", // Program 所在位置
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 1
}
}
],
"kind": "const"
}
],
"directives": []
},
"comments": [],
"tokens": [
...
]
}
Program 相当于一个根节点,一个完整的源代码树。一般在进入该节点的时候进行初始化数据之类的操作,也可理解为该节点先于其他节点执行,同时也是最晚执行 exit 的节点,在 exit 时也可以做一些”善后“的工作。 既然 babel-plugin-import 的 Program 节点处写了完整的结构,必然在 exit 时也有非常必要的事情需要处理,关于 exit 具体是做什么的我们稍后进行讨论。 我们先看 enter ,这里首先用 enter 形参 state 结构出用户指定的插件参数,验证必填的 libraryName [库名称] 是否存在。Index 文件引入的 Plugin 是一个 class 结构,因此需要对 Plugin 进行实例化,并把插件的所有参数与 @babel/types 全部传进去,关于 Plugin 类会在下文中进行阐述。 接着调用了 applyInstance 函数:
export default function({ types }) {
let plugins=null;
/**
* 从类中继承方法并利用 apply 改变 this 指向,并传递 path , state 参数
*/
function applyInstance(method, args, context) {
for (const plugin of plugins) {
if (plugin[method]) {
plugin[method].apply(plugin, [...args, context]);
}
}
}
const Program={
enter(path, { opts={} }) {
...
applyInstance('ProgramEnter', arguments, this);
},
...
}
}
此函数的主要目的是继承 Plugin 类中的方法,且需要三个参数
主要的目的是让 Program 的 enter 继承 Plugin 类的 ProgramEnter 方法,并且传递 path 与 state 形参至 ProgramEnter 。Program 的 exit 同理,继承的是 ProgramExit 方法。
现在进入 Plugin 类:
export default class Plugin {
constructor(
libraryName,
libraryDirectory,
style,
styleLibraryDirectory,
customStyleName,
camel2DashComponentName,
camel2UnderlineComponentName,
fileName,
customName,
transformToDefaultImport,
types, // babel-types
index=0, // 标记符
) {
this.libraryName=libraryName; // 库名
this.libraryDirectory=typeof libraryDirectory==='undefined' ? 'lib' : libraryDirectory; // 包路径
this.style=style || false; // 是否加载 style
this.styleLibraryDirectory=styleLibraryDirectory; // style 包路径
this.camel2DashComponentName=camel2DashComponentName || true; // 组件名是否转换以“-”链接的形式
this.transformToDefaultImport=transformToDefaultImport || true; // 处理默认导入
this.customName=normalizeCustomName(customName); // 处理转换结果的函数或路径
this.customStyleName=normalizeCustomName(customStyleName); // 处理转换结果的函数或路径
this.camel2UnderlineComponentName=camel2UnderlineComponentName; // 处理成类似 time_picker 的形式
this.fileName=fileName || ''; // 链接到具体的文件,例如 antd/lib/button/[abc.js]
this.types=types; // babel-types
this.pluginStateKey=`importPluginState${index}`;
}
...
}
在入口文件实例化 Plugin 已经把插件的参数通过 constructor 后被初始化完毕啦,除了 libraryName 以外其他所有的值均有相应默认值,值得注意的是参数列表中的 customeName 与 customStyleName 可以接收一个函数或者一个引入的路径,因此需要通过 normalizeCustomName 函数进行统一化处理。
function normalizeCustomName(originCustomName) {
if (typeof originCustomName==='string') {
const customeNameExports=require(originCustomName);
return typeof customeNameExports==='function'
? customeNameExports
: customeNameExports.default;// 如果customeNameExports不是函数就导入{default:func()}
}
return originCustomName;
}
此函数就是用来处理当参数是路径时,进行转换并取出相应的函数。如果处理后 customeNameExports 仍然不是函数就导入 customeNameExports.default ,这里牵扯到 export default 是语法糖的一个小知识点。
export default something() {}
// 等效于
function something() {}
export ( something as default )
回归代码,Step1 中入口文件 Program 的 Enter 继承了 Plugin 的 ProgramEnter 方法
export default class Plugin {
constructor(...) {...}
getPluginState(state) {
if (!state[this.pluginStateKey]) {
// eslint-disable-next-line no-param-reassign
state[this.pluginStateKey]={}; // 初始化标示
}
return state[this.pluginStateKey]; // 返回标示
}
ProgramEnter(_, state) {
const pluginState=this.getPluginState(state);
pluginState.specified=Object.create(null); // 导入对象集合
pluginState.libraryObjs=Object.create(null); // 库对象集合 (非 module 导入的内容)
pluginState.selectedMethods=Object.create(null); // 存放经过 importMethod 之后的节点
pluginState.pathsToRemove=[]; // 存储需要删除的节点
/**
* 初始化之后的 state
* state:{
* importPluginState「Number」: {
* specified:{},
* libraryObjs:{},
* select:{},
* pathToRemovw:[]
* },
* opts:{
* ...
* },
* ...
* }
*/
}
...
}
ProgramEnter 中通过 getPluginState**初始化 state 结构中的 importPluginState 对象,getPluginState 函数在后续操作中出现非常频繁,读者在此需要留意此函数的作用,后文不再对此进行赘述。 但是为什么需要初始化这么一个结构呢?这就牵扯到插件的思路。正像开篇流程图所述的那样 ,babel-plugin-import 具体实现按需加载思路如下:经过 import 节点后收集节点数据,然后从所有可能引用到 import 绑定的节点处执行按需加载转换方法。state 是一个引用类型,对其进行操作会影响到后续节点的 state 初始值,因此用 Program 节点,在 enter 的时候就初始化这个收集依赖的对象,方便后续操作。负责初始化 state 节点结构与取数据的方法正是 getPluginState。 这个思路很重要,并且贯穿后面所有的代码与目的,请读者务必理解再往下阅读。
二、惟恍惟惚
借由 Step1,现在已经了解到插件与 Program 为出发点继承了 ProgramEnter 并且初始化了 Plugin 依赖,如果读者还有尚未梳理清楚的部分,请回到 Step1 仔细消化下内容再继续阅读。 首先,我们再回到外围的 Index 文件,之前只在观察者模式中注册了 Program 的节点,没有其他 AST 节点入口,因此至少还需注入 import 语句的 AST 节点类型 ImportDeclaration
export default function({ types }) {
let plugins=null;
function applyInstance(method, args, context) {
...
}
const Program={
...
}
const methods=[ // 注册 AST type 的数组
'ImportDeclaration'
]
const ret={
visitor: { Program },
};
// 遍历数组,利用 applyInstance 继承相应方法
for (const method of methods) {
ret.visitor[method]=function() {
applyInstance(method, arguments, ret.visitor);
};
}
}
创建一个数组并将 ImportDeclaration 置入,经过遍历调用 applyInstance_ _和 Step1 介绍同理,执行完毕后 visitor 会变成如下结构
visitor: {
Program: { enter: [Function: enter], exit: [Function: exit] },
ImportDeclaration: [Function],
}
现在回归 Plugin,进入 ImportDeclaration
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
/**
* 主目标,收集依赖
*/
ImportDeclaration(path, state) {
const { node }=path;
// path 有可能被前一个实例删除
if (!node) return;
const {
source: { value }, // 获取 AST 中引入的库名
}=node;
const { libraryName, types }=this;
const pluginState=this.getPluginState(state); // 获取在 Program 处初始化的结构
if (value===libraryName) { // AST 库名与插件参数名是否一致,一致就进行依赖收集
node.specifiers.forEach(spec=> {
if (types.isImportSpecifier(spec)) { // 不满足条件说明 import 是名称空间引入或默认引入
pluginState.specified[spec.local.name]=spec.imported.name;
// 保存为:{ 别名 : 组件名 } 结构
} else {
pluginState.libraryObjs[spec.local.name]=true;// 名称空间引入或默认引入的值设置为 true
}
});
pluginState.pathsToRemove.push(path); // 取值完毕的节点添加进预删除数组
}
}
...
}
ImportDeclaration 会对 import 中的依赖字段进行收集,如果是名称空间引入或者是默认引入就设置为 { 别名 :true },解构导入就设置为 { 别名 :组件名 } 。getPluginState 方法在 Step1 中已经进行过说明。关于 import 的 AST 节点结构 用 babel-plugin 实现按需加载 中有详细说明,本文不再赘述。执行完毕后 pluginState 结构如下
// 例: import { Input, Button as Btn } from 'antd'
{
...
importPluginState0: {
specified: {
Btn : 'Button',
Input : 'Input'
},
pathToRemove: {
[NodePath]
}
...
}
...
}
这下 state.importPluginState 结构已经收集到了后续帮助节点进行转换的所有依赖信息。 目前已经万事俱备,只欠东风。东风是啥?是能让转换 import 工作开始的 action。在 用 babel-plugin 实现按需加载 中收集到依赖的同时也进行了节点转换与删除旧节点。一切工作都在 ImportDeclaration 节点中发生。而 babel-plugin-import 的思路是寻找一切可能引用到 Import 的 AST 节点,对他们全部进行处理。有部分读者也许会直接想到去转换引用了 import 绑定的 JSX 节点,但是转换 JSX 节点的意义不大,因为可能引用到 import 绑定的 AST 节点类型 ( type ) 已经够多了,所有应尽可能地缩小需要转换的 AST 节点类型范围。而且 babel 的其他插件会将我们的 JSX 节点进行转换成其他 AST type,因此能不考虑 JSX 类型的 AST 树,可以等其他 babel 插件转换后再进行替换工作。其实下一步可以开始的入口有很多,但还是从咱最熟悉的 React.createElement 开始。
class Hello extends React.Component {
render() {
return <div>Hello</div>
}
}
// 转换后
class Hello extends React.Component {
render(){
return React.createElement("div",null,"Hello")
}
}
JSX 转换后 AST 类型为 CallExpression(函数执行表达式),结构如下所示,熟悉结构后能方便各位同学对之后步骤有更深入的理解。
{
"type": "File",
"program": {
"type": "Program",
"body": [
{
"type": "ClassDeclaration",
"body": {
"type": "ClassBody",
"body": [
{
"type": "ClassMethod",
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "CallExpression", // 这里是处理的起点
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"identifierName": "React"
},
"name": "React"
},
"property": {
"type": "Identifier",
"loc": {
"identifierName": "createElement"
},
"name": "createElement"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "div",
"raw": "\"div\""
},
"value": "div"
},
{
"type": "NullLiteral"
},
{
"type": "StringLiteral",
"extra": {
"rawValue": "Hello",
"raw": "\"Hello\""
},
"value": "Hello"
}
]
}
],
"directives": []
}
}
]
}
}
]
}
}
因此我们进入 CallExpression 节点处,继续转换流程。
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
ImportDeclaration(path, state) { ... }
CallExpression(path, state) {
const { node }=path;
const file=path?.hub?.file || state?.file;
const { name }=node.callee;
const { types }=this;
const pluginState=this.getPluginState(state);
// 处理一般的调用表达式
if (types.isIdentifier(node.callee)) {
if (pluginState.specified[name]) {
node.callee=this.importMethod(pluginState.specified[name], file, pluginState);
}
}
// 处理React.createElement
node.arguments=node.arguments.map(arg=> {
const { name: argName }=arg;
// 判断作用域的绑定是否为import
if (
pluginState.specified[argName] &&
path.scope.hasBinding(argName) &&
types.isImportSpecifier(path.scope.getBinding(argName).path)
) {
return this.importMethod(pluginState.specified[argName], file, pluginState); // 替换了引用,help/import插件返回节点类型与名称
}
return arg;
});
}
...
}
可以看见源码调用了importMethod 两次,此函数的作用是触发 import 转换成按需加载模式的 action,并返回一个全新的 AST 节点。因为 import 被转换后,之前我们人工引入的组件名称会和转换后的名称不一样,因此 importMethod 需要把转换后的新名字(一个 AST 结构)返回到我们对应 AST 节点的对应位置上,替换掉老组件名。函数源码稍后会进行详细分析。 回到一开始的问题,为什么 CallExpression 需要调用 importMethod 函数?因为这两处表示的意义是不同的,CallExpression 节点的情况有两种:
import lodash from 'lodash'
lodash(some values)
因此在 CallExpression 中首先会判断 node.callee 值是否是 Identifier ,如果正确则是所述的第二种情况,直接进行转换。若否,则是 React.createElement 形式,遍历 React.createElement 的三个参数取出 name,再判断 name 是否是先前 state.pluginState 收集的 import 的 name,最后检查 name 的作用域情况,以及追溯 name 的绑定是否是一个 import 语句。这些判断条件都是为了避免错误的修改函数原本的语义,防止错误修改因闭包等特性的块级作用域中有相同名称的变量。如果上述条件均满足那它肯定是需要处理的 import 引用了。让其继续进入importMethod 转换函数,importMethod 需要传递三个参数:组件名,File(path.sub.file),pluginState
import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
ImportDeclaration(path, state) { ... }
CallExpression(path, state) { ... }
// 组件原始名称 , sub.file , 导入依赖项
importMethod(methodName, file, pluginState) {
if (!pluginState.selectedMethods[methodName]) {
const { style, libraryDirectory }=this;
const transformedMethodName=this.camel2UnderlineComponentName // 根据参数转换组件名称
? transCamel(methodName, '_')
: this.camel2DashComponentName
? transCamel(methodName, '-')
: methodName;
/**
* 转换路径,优先按照用户定义的customName进行转换,如果没有提供就按照常规拼接路径
*/
const path=winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
);
/**
* 根据是否是默认引入对最终路径做处理,并没有对namespace做处理
*/
pluginState.selectedMethods[methodName]=this.transformToDefaultImport // eslint-disable-line
? addDefault(file.path, path, { nameHint: methodName })
: addNamed(file.path, methodName, path);
if (this.customStyleName) { // 根据用户指定的路径引入样式文件
const stylePath=winPath(this.customStyleName(transformedMethodName));
addSideEffect(file.path, `${stylePath}`);
} else if (this.styleLibraryDirectory) { // 根据用户指定的样式目录引入样式文件
const stylePath=winPath(
join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
);
addSideEffect(file.path, `${stylePath}`);
} else if (style===true) { // 引入 scss/less
addSideEffect(file.path, `${path}/style`);
} else if (style==='css') { // 引入 css
addSideEffect(file.path, `${path}/style/css`);
} else if (typeof style==='function') { // 若是函数,根据返回值生成引入
const stylePath=style(path, file);
if (stylePath) {
addSideEffect(file.path, stylePath);
}
}
}
return { ...pluginState.selectedMethods[methodName] };
}
...
}
进入函数后,先别着急看代码,注意这里引入了两个包:path.join 和 @babel/helper-module-imports ,引入 join 是为了处理按需加载路径快捷拼接的需求,至于 import 语句转换,肯定需要产生全新的 import AST 节点实现按需加载,最后再把老的 import 语句删除。而新的 import 节点使用 babel 官方维护的 @babel/helper-module-imports 生成。现在继续流程,首先无视一开始的 if 条件语句,稍后会做说明。再捋一捋 import 处理函数中需要处理的几个环节:
function transCamel(_str, symbol) {
const str=_str[0].toLowerCase() + _str.substr(1); // 先转换成小驼峰,以便正则获取完整单词
return str.replace(/([A-Z])/g, $1=> `${symbol}${$1.toLowerCase()}`);
// 例 datePicker,正则抓取到P后,在它前面加上指定的symbol符号
}
function winPath(path) {
return path.replace(/\\/g, '/');
// 兼容路径: windows默认使用‘\’,也支持‘/’,但linux不支持‘\’,遂统一转换成‘/’
}
到目前为止一条最基本的转换线路已经转换完毕了,相信大家也已经了解了按需加载的基本转换流程,回到 importMethod 函数一开始的if 判断语句,这与我们将在 step3 中的任务息息相关。现在就让我们一起进入 step3。
三、了如指掌
在 step3 中会进行按需加载转换最后的两个步骤:
接下来将以此列举需要处理的所有 AST 节点,并且会给每一个节点对应的接口(Interface)与例子(不关注语义):
MemberExpression(path, state) {
const { node }=path;
const file=(path && path.hub && path.hub.file) || (state && state.file);
const pluginState=this.getPluginState(state);
if (!node.object || !node.object.name) return;
if (pluginState.libraryObjs[node.object.name]) {
// antd.Button -> _Button
path.replaceWith(this.importMethod(node.property.name, file, pluginState));
} else if (pluginState.specified[node.object.name] && path.scope.hasBinding(node.object.name)) {
const { scope }=path.scope.getBinding(node.object.name);
// 全局变量处理
if (scope.path.parent.type==='File') {
node.object=this.importMethod(pluginState.specified[node.object.name], file, pluginState);
}
}
}
MemberExpression(属性成员表达式),接口如下
interface MemberExpression {
type: 'MemberExpression';
computed: boolean;
object: Expression;
property: Expression;
}
/**
* 处理类似:
* console.log(lodash.fill())
* antd.Button
*/
如果插件的选项中没有关闭 transformToDefaultImport ,这里会调用 importMethod 方法并返回@babel/helper-module-imports 给予的新节点值。否则会判断当前值是否是收集到 import 信息中的一部分以及是否是文件作用域下的全局变量,通过获取作用域查看其父节点的类型是否是 File,即可避免错误的替换其他同名变量,比如闭包场景。
VariableDeclarator(path, state) {
const { node }=path;
this.buildDeclaratorHandler(node, 'init', path, state);
}
VariableDeclarator(变量声明),非常方便理解处理场景,主要处理 const/let/var 声明语句
interface VariableDeclaration : Declaration {
type: "VariableDeclaration";
declarations: [ VariableDeclarator ];
kind: "var" | "let" | "const";
}
/**
* 处理类似:
* const foo=antd
*/
本例中出现 buildDeclaratorHandler 方法,主要确保传递的属性是基础的 Identifier 类型且是 import 绑定的引用后便进入 importMethod 进行转换后返回新节点覆盖原属性。
buildDeclaratorHandler(node, prop, path, state) {
const file=(path && path.hub && path.hub.file) || (state && state.file);
const { types }=this;
const pluginState=this.getPluginState(state);
if (!types.isIdentifier(node[prop])) return;
if (
pluginState.specified[node[prop].name] &&
path.scope.hasBinding(node[prop].name) &&
path.scope.getBinding(node[prop].name).path.type==='ImportSpecifier'
) {
node[prop]=this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
}
}
ArrayExpression(path, state) {
const { node }=path;
const props=node.elements.map((_, index)=> index);
this.buildExpressionHandler(node.elements, props, path, state);
}
ArrayExpression(数组表达式),接口如下所示
interface ArrayExpression {
type: 'ArrayExpression';
elements: ArrayExpressionElement[];
}
/**
* 处理类似:
* [Button, Select, Input]
*/
本例的处理和刚才的其他节点不太一样,因为数组的 Element 本身就是一个数组形式,并且我们需要转换的引用都是数组元素,因此这里传递的 props 就是类似 [0, 1, 2, 3] 的纯数组,方便后续从 elements 中进行取数据。这里进行具体转换的方法是 buildExpressionHandler,在后续的 AST 节点处理中将会频繁出现
buildExpressionHandler(node, props, path, state) {
const file=(path && path.hub && path.hub.file) || (state && state.file);
const { types }=this;
const pluginState=this.getPluginState(state);
props.forEach(prop=> {
if (!types.isIdentifier(node[prop])) return;
if (
pluginState.specified[node[prop].name] &&
types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)
) {
node[prop]=this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
}
});
}
首先对 props 进行遍历,同样确保传递的属性是基础的 Identifier 类型且是 import 绑定的引用后便进入 importMethod 进行转换,和之前的 buildDeclaratorHandler 方法差不多,只是 props 是数组形式
LogicalExpression(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['left', 'right'], path, state);
}
LogicalExpression(逻辑运算符表达式)
interface LogicalExpression {
type: 'LogicalExpression';
operator: '||' | '&&';
left: Expression;
right: Expression;
}
/**
* 处理类似:
* antd && 1
*/
主要取出逻辑运算符表达式的左右两边的变量,并使用 buildExpressionHandler 方法进行转换
ConditionalExpression(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
}
ConditionalExpression(条件运算符)
interface ConditionalExpression {
type: 'ConditionalExpression';
test: Expression;
consequent: Expression;
alternate: Expression;
}
/**
* 处理类似:
* antd ? antd.Button : antd.Select;
*/
主要取出类似三元表达式的元素,通用 buildExpressionHandler 方法进行转换。
IfStatement(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['test'], path, state);
this.buildExpressionHandler(node.test, ['left', 'right'], path, state);
}
IfStatement(if 语句)
interface IfStatement {
type: 'IfStatement';
test: Expression;
consequent: Statement;
alternate?: Statement;
}
/**
* 处理类似:
* if(antd){ }
*/
这个节点相对比较特殊,但笔者不明白为什么要调用两次 buildExpressionHandler ,因为笔者所想到的可能性,都有其他的 AST 入口可以处理。望知晓的读者可进行科普。
ExpressionStatement(path, state) {
const { node }=path;
const { types }=this;
if (types.isAssignmentExpression(node.expression)) {
this.buildExpressionHandler(node.expression, ['right'], path, state);
}
}
ExpressionStatement(表达式语句)
interface ExpressionStatement {
type: 'ExpressionStatement';
expression: Expression;
directive?: string;
}
/**
* 处理类似:
* module.export=antd
*/
ReturnStatement(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['argument'], path, state);
}
ReturnStatement(return 语句)
interface ReturnStatement {
type: 'ReturnStatement';
argument: Expression | null;
}
/**
* 处理类似:
* return lodash
*/
ExportDefaultDeclaration(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['declaration'], path, state);
}
ExportDefaultDeclaration(导出默认模块)
interface ExportDefaultDeclaration {
type: 'ExportDefaultDeclaration';
declaration: Identifier | BindingPattern | ClassDeclaration | Expression | FunctionDeclaration;
}
/**
* 处理类似:
* return lodash
*/
BinaryExpression(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['left', 'right'], path, state);
}
BinaryExpression(二元操作符表达式)
interface BinaryExpression {
type: 'BinaryExpression';
operator: BinaryOperator;
left: Expression;
right: Expression;
}
/**
* 处理类似:
* antd > 1
*/
NewExpression(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['callee', 'arguments'], path, state);
}
NewExpression(new 表达式)
interface NewExpression {
type: 'NewExpression';
callee: Expression;
arguments: ArgumentListElement[];
}
/**
* 处理类似:
* new Antd()
*/
ClassDeclaration(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['superClass'], path, state);
}
ClassDeclaration(类声明)
interface ClassDeclaration {
type: 'ClassDeclaration';
id: Identifier | null;
superClass: Identifier | null;
body: ClassBody;
}
/**
* 处理类似:
* class emaple extends Antd {...}
*/
Property(path, state) {
const { node }=path;
this.buildDeclaratorHandler(node, ['value'], path, state);
}
Property(对象的属性值)
/**
* 处理类似:
* const a={
* button:antd.Button
* }
*/
处理完 AST 节点后,删除掉原本的 import 导入,由于我们已经把旧 import 的 path 保存在 pluginState.pathsToRemove 中,最佳的删除的时机便是 ProgramExit ,使用 path.remove() 删除。
ProgramExit(path, state) {
this.getPluginState(state).pathsToRemove.forEach(p=> !p.removed && p.remove());
}
恭喜各位坚持看到现在的读者,已经到最后一步啦,把我们所处理的所有 AST 节点类型注册到观察者中
export default function({ types }) {
let plugins=null;
function applyInstance(method, args, context) { ... }
const Program={ ... }
// 补充注册 AST type 的数组
const methods=[
'ImportDeclaration'
'CallExpression',
'MemberExpression',
'Property',
'VariableDeclarator',
'ArrayExpression',
'LogicalExpression',
'ConditionalExpression',
'IfStatement',
'ExpressionStatement',
'ReturnStatement',
'ExportDefaultDeclaration',
'BinaryExpression',
'NewExpression',
'ClassDeclaration',
]
const ret={
visitor: { Program },
};
for (const method of methods) { ... }
}
到此已经完整分析完 babel-plugin-import 的整个流程,读者可以重新捋一捋处理按需加载的整个处理思路,其实抛去细节,主体逻辑还是比较简单明了的。
四、一些思考
笔者在进行源码与单元测试的阅读后,发现插件并没有对 Switch 节点进行转换,遂向官方仓库提了 PR,目前已经被合入 master 分支,读者有任何想法,欢迎在评论区畅所欲言。 笔者主要补了 SwitchStatement ,SwitchCase 与两个 AST 节点处理。
SwitchStatement
SwitchStatement(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['discriminant'], path, state);
}
SwitchCase
SwitchCase(path, state) {
const { node }=path;
this.buildExpressionHandler(node, ['test'], path, state);
}
五、小小总结
这是笔者第一次写源码解析的文章,也因笔者能力有限,如果有些逻辑阐述得不够清晰,或者在解读过程中有错误的,欢迎读者在评论区给出建议或进行纠错。
现在 babel 其实也出了一些 API 可以更加简化 babel-plugin-import 的代码或者逻辑,例如:path.replaceWithMultiple ,但源码中一些看似多余的逻辑一定是有对应的场景,所以才会被加以保留。
此插件经受住了时间的考验,同时对有需要开发 babel-plugin 的读者来说,也是一个非常好的事例。不仅如此,对于功能的边缘化处理以及操作系统的兼容等细节都有做完善的处理。
如果仅仅需要使用babel-plugin-import ,此文展示了一些在 babel-plugin-import 文档中未暴露的API,也可以帮助插件使用者实现更多扩展功能,因此笔者推出了此文,希望能帮助到各位同学。
tsy 的 Web 平台团队在过去几年中花费了大量时间来更新我们的前端代码。仅在一年半以前,我们才将 JavaScript 构建系统现代化 ,以实现更高级的特性,比如 箭头函数 和 类 ,从 2015 年起,它们被添加到了这个语言中。尽管这个升级意味着我们对代码库的未来验证已经完成,并且可以编写出更加习惯化、更可扩展的 JavaScript,但是我们知道还有改进的空间。
Etsy 已经有十六年的历史了。自然地,我们的代码库变得相当庞大; Monorepo (单体仓库)拥有超过 17000 个 JavaScript 文件,并且跨越了网站的很多迭代。如果开发者使用我们的代码库,很难知道哪些部分仍被视为最佳实践,哪些部分遵循传统模式或者被视为技术债务。JavaScript 语言本身使得这个问题更加复杂:虽然在过去的几年里,为该语言增加了新的语法特性,但是 JavaScript 非常灵活,对如何使用也没有多少可强制性的限制。这样,在编写 JavaScript 时,如果没有事先研究依赖关系的实现细节,就很有挑战性。尽管文档在某种程度上有助于减轻这个问题,但是它只能在很大程度上防止 JavaScript 库的不当使用,从而最终导致不可靠的代码。
所有这些问题(还有更多!)都是我们认为 TypeScript 可能为我们解决的问题。TypeScript 自称是“JavaScript 的超集”。换言之,TypeScript 就是 JavaScript 中的一切,可以选择增加类型。类型从根本上来说,在编程中,类型是通过代码移动的数据的期望的方式:函数可以使用哪些类型的输入,变量可以保存哪些类型的值。(如果你不熟悉类型的概念,TypeScript 的手册有一个 很好的介绍 )。
TypeScript 被设计成可以很容易地在已有的 JavaScript 项目中逐步采用,尤其是在大型代码库中,而转换成一种新的语言是不可能的。它非常擅长从你已经编写好的代码中推断出类型,并且其类型语法细微到足以正确地描述 JavaScript 中普遍存在的“怪癖”。此外,它由微软开发,已被 Slack 和 Airbnb 等公司使用,根据 去年的“State of JavaScript”调查 ,它是迄今为止使用最多、最流行的 JavaScript。若要使用类型来为我们的代码库带来某种秩序,TypeScript 看起来是一个非常可靠的赌注。
因此,在迁移到 ES6 之后,我们开始研究采用 TypeScript 的路径。本文将讲述我们如何设计我们的方法,一些有趣的技术挑战,以及如何使一家 Etsy 级别的公司学习一种新的编程语言。
我并不想花太多时间向你安利 TypeScript,因为在这方面还有很多其他的 文章 和 讲座 ,都做得非常好。相反,我想谈谈 Etsy 在推出 TypeScript 支持方面所做的努力,这不仅仅是从 JavaScript 到 TypeScript 的技术实现。这也包括许多规划、教育和协调工作。但是如果把细节弄清楚,你会发现很多值得分享的学习经验。让我们先来讨论一下我们想要的采用是什么样的做法。
TypeScript 在检查代码库中的类型时,可能多少有点“严格”。据 TypeScript 手册 所述,一个更严格的 TypeScript 配置 “能更好地保证程序的正确性”,你可以根据自己的设计,根据自己的需要逐步采用 TypeScript 的语法及其严格性。这个特性诗 TypeScript 添加到各种代码库中成为可能,但是它也使“将文件迁移到 TypeScript”成为一个定义松散的目标。很多文件需要用类型进行注释,这样 TypeScript 就可以完全理解它们。还有许多 JavaScript 文件可以转换成有效的 TypeScript,只需将其扩展名从 .js 改为 .ts 即可。但是,即使 TypeScript 对文件有很好的理解,该文件也可能会从更多的特定类型中获益,从而提高其实用性。
各种规模的公司都有无数的文章讨论如何迁移到 TypeScript,所有这些文章都对不同的迁移策略提出了令人信服的论点。例如,Airbnb 尽可能地 自动化 了他们的迁移。还有一些公司在他们的项目中启用了较不严格的 TypeScript,随着时间的推移在代码中添加类型。
确定 Etsy 的正确方法意味着要回答一些关于迁移的问题:
我们决定将严格性放在第一位;采用一种新的语言需要付出大量的努力,如果我们使用 TypeScript,我们可以充分利用其类型系统(此外,TypeScript 的检查器在更严格的类型上 执行得更好 )。我们还知道 Etsy 的代码库相当庞大;迁移每个文件可能并不能充分利用我们的时间,但是确保我们拥有类型用于我们网站的新的和经常更新的部分是很重要的。当然,我们也希望我们的类型尽可能有用,容易使用。
以下是我们的采用策略:
让我们再仔细看看这几点吧。
严格的 TypeScript 能够避免许多常见的错误,所以我们认为最合理的做法是尽量严格的。这一决定的缺点是我们现有的大多数 JavaScript 都需要类型注释。它还需要以逐个文件的方式迁移我们的代码库。使用严格的 TypeScript,如果我们尝试一次转换所有的代码,我们最终将会有一个长期的积压问题需要解决。如前所述,我们在单体仓库中有超过 17000 个 JavaScript 文件,其中很多都不经常修改。我们选择把重点放在那些在网站上积极开发的区域,明确地区分哪些文件具有可靠的类型,以及哪些文件不使用 .js 和 .ts 文件扩展名。
一次完全迁移可能在逻辑上使改进已有的类型很难,尤其是在单体仓库模式中。当导入 TypeScript 文件时,出现被禁止的类型错误,你是否应该修复此错误?那是否意味着文件的类型必须有所不同才能适应这种依赖关系的潜在问题?哪些具有这种依赖关系,编辑它是否安全?就像我们的团队所知道的,每个可以被消除的模糊性,都可以让工程师自己作出改进。在增量迁移中,任何以 .ts 或 .tsx 结尾的文件都可以认为存在可靠的类型。
当我们的工程师开始编写 TypeScript 之前,我们希望我们所有的工具都能支持 TypeScript,并且所有的核心库都有可用的、定义明确的类型。使用 TypeScript 文件中的非类型化依赖项会使代码难以使用,并可能会引入类型错误;尽管 TypeScript 会尽力推断非 TypeScript 文件中的类型,但是如果无法推断,则默认为“any”。换句话说,如果工程师花时间编写 TypeScript,他们应该能够相信,当他们编写代码的时候,语言能够捕捉到他们所犯的类型错误。另外,强制工程师在学习新语言和跟上团队路线图的同时为通用实用程序编写类型,这是一种让人们反感 TypeScript 的好方法。这项工作并非微不足道,但却带来了丰厚的回报。在下面的“技术细节”一节中,我将对此进行详细阐述。
我们已经花了很多时间在 TypeScript 的教育上,这是我们在迁移过程中所做的最好的决定。Etsy 有数百名工程师,在这次迁移之前,他们几乎没有 TypeScript 的经验(包括我)。我们知道,要想成功地迁移,人们首先必须学习如何使用 TypeScript。打开这个开关,告诉所有人都要这么做,这可能会使人们迷惑,使我们的团队被问题压垮,也会影响我们产品工程师的工作速度。通过逐步引入团队,我们能够努力完善工具和教学材料。它还意味着,没有任何工程师能在没有队友能够审查其代码的情况下编写 TypeScript。逐步适职使我们的工程师有时间学习 TypeScript,并把它融入到路线图中。
在迁移过程中,有很多有趣的技术挑战。令人惊讶的是,采用 TypeScript 的最简单之处就是在构建过程中添加对它的支持。在这个问题上,我不会详细讨论,因为构建系统有许多不同的风格,但简单地说:
上面所做的工作花费了一到两个星期,其中大部分时间是用于验证我们发送到生产中的 TypeScript 是否会发生异常行为。在其他 TypeScript 工具上,我们花费了更多的时间,结果也更有趣。
我们在 Etsy 中大量使用了自定义的 ESLint Lint 规则。它们为我们捕捉各种不良模式,帮助我们废除旧代码,并保持我们的 pull request(拉取请求)评论不跑题,没有吹毛求疵。如果它很重要,我们将尝试为其编写一个 Lint 规则。我们发现,有一个地方可以利用 Lint 规则的机会,那就是强化类型特异性,我一般用这个词来表示“类型与所描述的事物之间的精确匹配程度”。
举例来说,假设有一个函数接受 HTML 标签的名称并返回 HTML 元素。该函数可以将任何旧的字符串作为参数接受,但是如果它使用这个字符串来创建元素,那么最好能够确保该字符串实际上是一个真正的 HTML 元素的名称。
// This function type-checks, but I could pass in literally any string in as an argument.
function makeElement(tagName: string): HTMLElement {
return document.createElement(tagName);
}
// This throws a DOMException at runtime
makeElement("literally anything at all");
假如我们努力使类型更加具体,那么其他开发者将更容易正确地使用我们的函数。
// This function makes sure that I pass in a valid HTML tag name as an argument.
// It makes sure that ‘tagName’ is one of the keys in
// HTMLElementTagNameMap, a built-in type where the keys are tag names
// and the values are the types of elements.
function makeElement(tagName: keyof HTMLElementTagNameMap): HTMLElement {
return document.createElement(tagName);
}
// This is now a type error.
makeElement("literally anything at all");
// But this isn't. Excellent!
makeElement("canvas");
迁移到 TypeScript 意味着我们需要考虑和解决许多新实践。 typescript-eslint 项目给了我们一些 TypeScript 特有的规则,可供我们利用。例如, ban-types 规则允许我们警告不要使用泛型 Element 类型,而使用更具体的 HTMLElement 类型。
此外,我们也作出了一个(有一点争议)决定,在我们的代码库中 不 允许使用 非空断言 和 类型断言 。前者允许开发者告诉 TypeScript,当 TypeScript 认为某物可能是空的时候,它不是空的,而后者允许开发者将某物视为他们选择的任何类型。
// This is a constant that might be ‘null’.
const maybeHello=Math.random() > 0.5 ? "hello" : null;
// The `!` below is a non-null assertion.
// This code type-checks, but fails at runtime.
const yellingHello=maybeHello!.toUpperCase()
// This is a type assertion.
const x={} as { foo: number };
// This code type-checks, but fails at runtime.
x.foo;
这两种语法特性都允许开发者覆盖 TypeScript 对某物类型的理解。很多情况下,它们都意味着某种类型更深层次问题,需要加以修复。消除这些类型,我们强迫这些类型对于它们所描述得更具体。举例来说,你可以使用“ as ”将 Element 转换为 HTMLElement ,但是你可能首先要使用 HTMLElement。TypeScript 本身无法禁用这些语言特性,但是 Lint 使我们能够识别它们并防止它们被部署。
作为防止人们使用不良模式的工具,Lint 确实非常有用,但是这并不意味着这些模式是普遍不好的:每个规则都有例外。Lint 的好处在于,它提供了合理的逃生通道。在任何时候,如果确实需要使用“as”,我们可以随时添加一次性的 Lint 例外。
// NOTE: I promise there is a very good reason for us to use `as` here.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const x={} as { foo: number };
我们希望我们的开发者能够编写出有效的 TypeScript 代码,所以我们需要确保为尽可能多的开发环境提供类型。乍一看,这意味着将类型添加到可重用设计组件、辅助实用程序和其他共享代码中。但是理想情况下,开发者需要访问的任何数据都应该有自己的类型。几乎我们网站上所有的数据都是通过 Etsy API 实现的,所以如果我们能在那里提供类型,我们很快就可以涵盖大部分的代码库。
Etsy 的 API 使用 PHP 实现的,并且我们为每个端点生成 PHP 和 JavaScript 配置,从而帮助简化请求的过程。在 JavaScript 中,我们使用一个轻量级封装 EtsyFetch 来帮助处理这些请求。这一切看起来就是这样的:
// This function is generated automatically.
function getListingsForShop(shopId, optionalParams={}) {
return {
url: `apiv3/Shop/${shopId}/getLitings`,
optionalParams,
};
}
// This is our fetch() wrapper, albeit very simplified.
function EtsyFetch(config) {
const init=configToFetchInit(config);
return fetch(config.url, init);
}
// Here's what a request might look like (ignoring any API error handling).
const shopId=8675309;
EtsyFetch(getListingsForShop(shopId))
.then((response)=> response.json())
.then((data)=> {
alert(data.listings.map(({ id })=> id));
});
在我们的代码库中,这种模式是非常普遍的。如果我们没有为 API 响应生成类型,开发者就得手工写出它们,并且想让它们与实际的 API 同步。我们需要严格的类型,但是我们也不希望我们的开发者为了得到这些类型而折腾。
最后,我们在 开发者 API 上做了一些工作,将端点转换成 OpenAPI 规范 。OpenAPI 规范是以类似 JSON 等格式描述 API 端点的标准化方式。虽然我们的开发者 API 使用了这些规范来生成面向公共的文档,但是我们也可以利用它们生成用于 API 的响应的 TypeScript 类型。在编写和改进 OpenAPI 规范生成器之前,我们已经花费了大量的时间来编写和改进,它可以适用于我们所有的内部 API 端点,然后使用一个名为 openapi-typescript 的库,将这些规范转换成 TypeScript 类型。
在为所有端点生成 TypeScript 类型之后,仍然需要以一种可利用的方式将它们整合到代码库中。我们决定将生成的响应类型编入我们所生成的配置中,然后更新 EtsyFetch,以便在它返回的 Promise 中使用这些类型。把所有这些放在一起,看起来大致是这样的:
// These types are globally available:
interface EtsyConfig<JSONType> {
url: string;
}
interface TypedResponse<JSONType> extends Response {
json(): Promise<JSONType>;
}
// This is roughly what a generated API config file looks like:
import OASGeneratedTypes from "api/oasGeneratedTypes";
type JSONResponseType=OASGeneratedTypes["getListingsForShop"];
function getListingsForShop(shopId): EtsyConfig<JSONResponseType> {
return {
url: `apiv3/Shop/${shopId}/getListings`,
};
}
// This is (looooosely) what EtsyFetch looks like:
function EtsyFetch<JSONType>(config: EtsyConfig<JSONType>) {
const init=configToFetchInit(config);
const response: Promise<TypedResponse<JSONType>>=fetch(config.url, init);
return response;
}
// And this is what our product code looks like:
EtsyFetch(getListingsForShop(shopId))
.then((response)=> response.json())
.then((data)=> {
data.listings; // "data" is fully typed using the types from our API
});
这一模式的结果非常有用。目前,对 EtsyFetch 的现有调用具有开箱即用的强类型,不需要进行更改。而且,如果我们更新 API 的方式会引起客户端代码的破坏性改变,那么类型检查器就会失败,而这些代码将永远不会出现在生产中。
键入我们的 API 还为我们提供了机会,使我们可以在后端和浏览器之间使用 API 作为唯一的真相。举例来说,如果我们希望确保支持某个 API 的所有区域都有一个标志的表情符号,我们可以使用以下类型来强制执行:
type Locales OASGeneratedTypes["updateCurrentLocale"]["locales"];
const localesToIcons : Record<Locales, string>={
"en-us": ":us:",
"de": ":de:",
"fr": ":fr:",
"lbn": ":lb:",
//... If a locale is missing here, it would cause a type error.
}
最重要的是,这些特性都不需要改变我们产品工程师的工作流程。他们可以免费使用这些类型,只要他们使用他们已经熟悉的模式。
推出 TypeScript 的一个重要部分是密切关注来自我们工程师的投诉。在我们进行迁移的早期阶段,有人提到过在提供类型提示和代码完成时,他们的编辑器很迟钝。例如,一些人告诉我们,当鼠标悬停在一个变量上时,他们要等半分钟才能显示出类型信息。考虑到我们可以在一分钟内对所有的 TS 文件运行类型检查器,这个问题就更加复杂了;当然,单个变量的类型信息也不应该这么昂贵。
幸运的是,我们和一些 TypeScript 项目的维护者举行了一次会议。他们希望看到 TypeScript 能够在诸如 Etsy 这样独特的代码库上获得成功。对于我们在编辑器上的挑战,他们也很惊讶,而且更让他们吃惊的是,TypeScript 花了整整 10 分钟来检查我们的整个代码库,包括未迁移的文件和所有文件。
在反复讨论后,确定我们没有包含超出需求的文件后,他们向我们展示了当时他们刚刚推出的性能跟踪功能。跟踪结果表明,当对未迁移的 JavaScript 文件进行类型检查时,TypeScript 就会对我们的一个类型出现问题。以下是该文件的跟踪(这里的宽度代表时间)。
结果是,类型中存在一个循环依赖关系,它帮助我们创建不可变的对象的内部实用程序。到目前为止,这些类型对于我们处理的所有代码来说都是完美无缺的,但在代码库中尚未迁移的部分,它的一些使用却出现了问题,产生了一个无限的类型循环。如果有人打开了代码库的这些部分文件,或者在我们对所有代码运行类型检查器时,就会花很多时间来尝试理解该类型,然后放弃并记录类型错误。修复了这个类型之后,检查一个文件的时间从将近 46 秒减少到了不到 1 秒。
这种类型在其他地方也会产生问题。当进行修正之后,检查整个代码库的时间大约为此前的三分之一,并且减少了整整 1GB 的内存使用。
如果我们没有发现这个问题,那么它最终将导致我们的测试(以及生产部署)速度更慢。它还会使每个人在编写 TypeScript 的时候非常非常不愉快。
采用 TypeScript 的最大障碍,无疑是让大家学习 TypeScript。类型越多的 TypeScript 就越好。假如工程师对编写 TypeScript 代码感到不适应,那么完全采用这种语言将是一场艰难的斗争。就像我在上面提到的,我们决定逐个团队推广是建立某种制度化的 TypeScript 的最佳方式。
我们通过直接与少数团队合作来开始我们的推广工作。我们寻找那些即将开始新项目并且时间相对灵活的团队,并询问他们是否对用 TypeScript 编写项目感兴趣。在他们工作的时候,我们唯一的工作就是审查他们的拉取请求,为他们需要的模块实现类型,并在他们学习时与他们配对。
在此期间,我们可以完善自己的类型,并且为 Etsy 代码库中难以处理的部分开发专门的文档。由于只有少数工程师正在编写 TypeScript,所以很容易从他们那里得到直接的反馈,并迅速解决他们遇到的问题。这些早期的团队为我们提供了很多 Lint 规则,这可以确保我们的文档清晰、有用。它还为我们提供了足够的时间来完成迁移的技术部分,比如向 API 添加类型。
当我们感觉大多数问题已经解决后,我们决定让任何有兴趣和准备好的团队加入。为使团队能够编写 TypeScript,我们要求他们先完成一些培训。我们从 ExecuteProgram 找到了一门课程,我们认为这门课程以互动和有效的方式,很好地教授了 TypeScript 的基础知识。当我们认为团队的所有成员都需要完成这门课程(或具有一定的同等经验),我们才能认为他们准备好了。
我们努力使人们对 TypeScript 非常感兴趣,以吸引更多的人参加互联网上的课程。我们与 Dan Vanderkam 取得了联系,他是《 Effective TypeScript 》(暂无中译本)的作者,我们想知道他是否对做一次内部讲座感兴趣(他答应了,他的讲座和书都非常棒)。此外,我还设计了一些非常高质量的虚拟徽章,我们会在课程作业的期中和期末发给大家,以保持他们的积极性(并关注大家学习 TypeScript 的速度)。
然后我们鼓励新加入的团队腾出一些时间迁移他们团队负责的 JS 文件。我们发现,迁移你所熟悉的文件是学习如何使用 TypeScript 的一个好方法。这是一种直接的、亲手操作类型的方式,然后你可以马上在别处使用。实际上,我们决定不使用更复杂的自动迁移工具( 比如 Airbnb 写的那个 ),部分原因是它剥夺了一些学习机会。另外,一个有一点背景的工程师迁移文件的效率比脚本要高。
一次一个团队的适职意味着我们必须防止个别工程师在其团队其他成员准备就绪之前编写 TypeScript。这种情况比你想象的要多;TypeScript 是一种非常酷的语言,人们都渴望去尝试它,尤其是在看到代码库中使用它后。为了避免这种过早地采用,我们编写了一个简单的 git 提交钩子,禁止不属于安全列表的用户修改 TypeScript。当一个团队准备好时,我们只需将他们加入到安全列表即可。
此外,我们努力与每一个团队的工程师经理发展直接交流。将电子邮件发送到整个工程部门很容易,但是和每一个经理密切合作可以确保没有人对我们的推出感到惊讶。它还给了我们一个机会来解决团队所关心的问题,比如学习一门新语言。尤其在大公司中,强制要求变更可能是一种负担,虽然直接的沟通层很小,但会有很大的帮助(即使它需要一个相当大的电子表格来跟踪所有的团队)。
事实证明,审查 PR 是早期发现问题的一种很好的方法,并为以后 Lint 规则的制定提供了许多参考。为有助于迁移,我们决定对包含 TypeScript 的每个 PR 进行明确的审查,直到推广顺利。我们将审查的范围扩大到语法本身,并随着我们的发展,向那些已经成功适职的工程师寻求帮助。我们将这个小组称为 TypeScript 顾问,他们是新晋 TypeScript 工程师的重要支持来源。
在推广过程中最酷的一个方面就是很多学习过程是如何有机进行的。有些小组举行了大型的结对会议,他们共同解决问题,或者尝试迁移文件,我们并不知道。一些小组甚至建立了读书会来阅读 TypeScript 书籍。这类迁移确实需要付出大量的努力,但是我们很容易忘记,其中有多少工作是由热情的同事和队友完成的。
在今秋早些时候,我们已经开始要求使用 TypeScript 编写所有新文件。大概有 25% 的文件是类型,这个数字还不包括被丢弃的特性、内部工具和死代码。到撰写本文时,每一个团队都已成功地使用 TypeScript。
“完成向 TypeScript 的迁移”并不是一个明确的定义,特别是对于大型代码库而言。尽管我们可能还会有一段时间在我们的仓库中没有类型的 JavaScript 文件,但从现在开始,我们的每一个新特性都将进行类型化。撇开这些不谈,我们的工程师已经在有效地编写和使用 TypeScript,开发自己的工具,就类型展开深思熟虑的讨论,分享他们认为有用的文章和模式。虽然很难说,但是人们似乎很喜欢一种去年这个时候几乎没人用过的语言。对于我们来说,这是一次成功的迁移。
*请认真填写需求信息,我们会在24小时内与您取得联系。