本章介绍 Babel 生态体系相关的各种工具,这些工具在 Babel 内部和外部均可使用,了解这些工具和其背后的原理,对理解 Babel 非常有用。
和 Babel 相关的工具包括:
- 解析工具: babel-parser
- api 服务: babel-core
- 命令行: babel-cli
- node 增强: babel-register
- 命令行: babel-node
- 浏览器支持: babel-standalone
- 代码生成器: babel-generator
- 代码高亮: babel-highlight
- 模板引擎: babel-template
- webpack 支持: babel-loader(社区提供)
本章中涉及 Babel 转译部分,可以理解为下述代码:
import * as babel from "@babel/core";
const newCode = babel.transform(code, options)
2
# 5.1 commander.js
Babel 对外提供了几个命令行工具,babel-cli/babel-node 均使用了 commander.js 提供命令行服务。在如今的前端领域,commander.js 是命令行开发的重要基础工具了。这里先介绍 commander.js 的使用和原理,便于后续章节的理解。
commander.js 版本
本书中 babel-cli 使用的
commander.js
的版本是^4.0.1
,截至写稿时,commander.js
的最新版本是7.0.0。为了理解最新的设计理念,本书只介绍
commander.js
7.0.0版本的使用和实现,相信了解该版本实现后,^4.0.1
版本的理解也就顺理成章了。commander.js 的使用
commander.js 在 Github 的 Readme 介绍的比较详细,这里无意做文档翻译机,在此对 commander.js 的功能特点做一些归纳整理。
先准备 index.js 文件以便讲解,代码中涵盖了一部分使用特点。
后续的案例中我们使用
node index *
启动执行该文件。#!/usr/bin/env node // index.js const { Command } = require('commander'); const program = new Command(); // 链式调用 program /******************** 版本配置区域 **********************/ .version('0.0.1', '-v, --version') // 版本信息 /******************** 选项区域 **********************/ // boolean 类型的选项 .option('-d, --debug', 'output extra debugging') // 带参数类型的选项: <type> 必填 .option('-c, --cheese <type>' , 'cheese type', 'blue') // (选项定义, 描述, 默认值) // 带参数类型的选项: [type] 选填; // type 有值时,banana 选项为字符串类型 // type 无值时,banana 选项为 boolean 类型 .option('-b, --banana [type]' , 'banana type') // (选项定义, 描述) // 自定义参数类型的选项 .option('-i, --integer <number>', 'integer argument', function(number) { return parseInt(number); }) /******************** 解析命令 **********************/ .parse(); // 打印options console.log(program.opts());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33链式调用
commander.js 暴露的方法会返回
cmd
实例或子命令实例对象,以支持链式调用,该做法还是很常见的。版本配置
执行
node index.js -v
终端会显示0.0.1
。多样的选项配置
boolean 类型的选项
program.option('-d, --debug', 'output extra debugging')
1扩展:"取反 boolean"类型
program.option('--no-debug', 'not output extra debugging')
1非 boolean 类型的选项
program.option('-c, --cheese <type>' , 'cheese type', 'blue') // (选项定义, 描述, 默认值)
1参数可设置默认值。
既可是 boolean 也可非 boolean 类型的选项
program.option('-b, --banana [type]' , 'banana type') // (选项定义, 描述)
1type
无值时,banana
选项的值为 boolean 类型。type
有值时,banana
选项的值为非 boolean 类型。不定数量的选项
program .option('-n, --number <numbers...>', 'specify numbers') .option('-l, --letter [letters...]', 'specify letters');
1
2
3
commander.js 原理解析
概览
commander.js 7.0.0 版本的核心代码就一个文件
index.js
,2200 多行代码,代码的注释比较丰富,代码可读性也是不错的,感兴趣的同学可以通读一下。commander.js 包含以下类:
Option
Help
Command
CommandError
InvalidOptionArgumentError
如下图:
Option
类Option
类的实例存储选项的各类信息:- 选项是否必填(
requred
) - 描述信息(
description
) - 可变参数(
variadic
) - 是否反向 boolean,也就是
--no-xx
类型的选项 - 长名称/短名称,也就是
-c, --cheese
- 参数的值
- 其他
命令行实例执行
program.option("-c, --cheese", "add cheese")
,会创建一个新的Option
实例,存储该选项的各类信息。Option 类内部通过各种正则和字符串的计算各类信息。
比如,Option 类接受参数的长短名称有三种写法:
"-c, --cheese"
"-c|--cheese"
"-c --cheese"
其解析参数的时候以正则
/[ |,]+/
对字符串做分隔计算:flags.split(/[ |,]+/)
。- 选项是否必填(
Help
类Help
类主要负责帮助信息的展示、配置等工作。比如执行命令行
node index.js -h
会打印出:Usage: index [options] Options: -v, --version output the version number -d, --debug output extra debugging -c, --cheese <type> cheese type (default: "blue") -b, --banana [type] banana type -i, --integer <number> integer argument -h, --help display help for command
1
2
3
4
5
6
7
8
9Help
类中相对值得说的是formatHelp
方法了。formatHelp
接收当前命令行和 help 实例对象,返回格式化后的帮助信息。在生成帮助信息的过程中,有几个小的编程技巧可以借鉴:
日志信息字符串,通过数组形式组织,最终拼接为字符串
该方法对比直接拼接字符串,代码可维护性更强。
利用
String.prototype.padEnd
方法实现字符串补全日常工作中用到该方法的场景可能不多,容易遗漏。
比如:
'hello'.padEnd(7, '~')
的结果是hello~~
。在
formatHelp
里用空格补全,用于字符串显示的格式化。根据终端宽度动态计算显示宽度
TODO
Command
类Command
类是 commander.js 的核心类,提供了命令行的各类方法。下图是
Command
类使用时的主要流程:我们简要介绍下其中的一些点:
version(str, flags, description)
该方法注册了命令的版本信息,利用
createOption()
实现的一个快捷方法。command(nameAndArgs, actionOptsOrExecDesc, execOpts)
该方法注册子命令,有两种模式:
绑定函数实现命令
program .command('start') .action(function() { console.log('actor'); });
1
2
3
4
5执行
node index start
的时候,会执行action
注册的回调,打印actor
。启动独立文件执行命令
program.command('start', 'start runner');
1执行
node index start
的时候,会启动index-start.js
文件。
该方法内部通过是否含有描述信息判断是哪种模式。
重复注册命令时,会使用第一个注册的命令
比如:
program .command('start') .action(function() { console.log('start 1'); }); program .command('start') .action(function() { console.log('start 2'); });
1
2
3
4
5
6
7
8
9
10在执行
node index start
的时候,只会打印start 1
,因为内部找到匹配的命令的代码是:this.commands.find(cmd => cmd._name === name || cmd._aliases.includes(name));
1Array.prototype.find
方法会返回数组第一个匹配的元素。EventEmitter
在Command
类中的使用Node 内置模块
EventEmitter
提供了事件机制,最常见的 API 是on/emit
。Command
类中几处利用事件机制的地方举例:- 注册选项参数时,会注册
option:${optionName}
事件(on(option:${optionName})
),在命令行执行时触发回调(emit(option:${optionName})
)。 - 执行命令时,如果没有匹配的命令,会通过
this.listenerCount('command:*')
获取command:xx
事件(*
为通配符)的监听者数量,决定是否触发该事件
- 注册选项参数时,会注册
Error
类下图是 Commander.js 内部定义的几个
Error
类的继承关系。在内部实现上,分别定义了每个类自身的特殊字段。但值得注意的是,
Error.captureStackTrace(this, this.constructor)
被频繁使用。Error.captureStackTrace
使用Error.captureStackTrace(targetObject[, constructorOpt])
其作用是在
targetObject
中添加一个stack
属性。当访问targetObject.stack
时,将以字符串的形式返回Error.captureStackTrace
方法被调用时的代码位置信息。举例:index.js
> 1 const myObject = {}; > 2 Error.captureStackTrace(myObject); > 3 console.log(myObject.stack);
1
2
3执行
node index.js
后,终端输出:Error at Object.<anonymous> (xxx/index.js:2:7) at Module._compile (internal/modules/cjs/loader.js:689:30) at ... at ...
1
2
3
4
5当传入
constructorOpt
时,代码如:> 1 function MyError() { > 2 Error.captureStackTrace(this, MyError); > 3 } > 4 > 5 console.log(new MyError().stack)
1
2
3
4
5终端输出:
Error at Object.<anonymous> (xxx/index.js:5:13) at Module._compile (internal/modules/cjs/loader.js:689:30) at ... at ...
1
2
3
4
5可以看出,
MyError
函数内部的堆栈细节被隐藏了。Error.captureStackTrace
优点相对于
new Error().stack
,Error.captureStackTrace
有以下优点:更简洁
无需
new
一个新的Error
对象,节省内存空间,同时代码上也会更加优雅。一般而言,捕获错误信息通常的做法是:
try { new Error(); } catch(err) { // err.stack 包含了堆栈信息,可以对其处理 }
1
2
3
4
5而使用
Error.captureStackTrace
可以直接获取堆栈信息,实现方式更简洁。更安全
如果需要忽略部分堆栈信息,使用
Error.captureStackTrace
会更加方便,无需手工操作。更少资源
使用
Error.captureStackTrace
时,只有访问targetObject.stack
时,才会进行堆栈信息的格式化工作。如果
targetObj.stack
未被访问,则堆栈信息的格式化工作会被省略,从而节省计算资源。
Error.captureStackTrace
使用场景Error.captureStackTrace
并不是 Node.js 创造的,而是 V8 引擎的 Stack Trace API。语法上,Node.js 中的Error.captureStackTrace()
与 V8 引擎中所暴露的接口完全一致。事实上,Node.js 的
Error
类中,所有与 stack trace 有关的内容均依赖于 V8 的 Stack Trace API。因此,
Error.captureStackTrace(targetObject[, constructorOpt])
使用的场景有:基于 V8 引擎的运行环境,如 Node.js、Chrome 浏览器
Error.captureStackTrace(this, MyError)
作用也是隐藏构造函数内部的堆栈信息,但需要明确指定构造函数名,通用性不强。
Error.captureStackTrace(this, arguments.callee)
arguments.callee
表示当前函数,也有通用性。但 ES3 及之后的严格模式禁用了arguments.callee
,因此不建议使用。Error.captureStackTrace(this, this.constructor)
该做法可以隐藏构造函数内部的堆栈信息,无需指定构造函数名,通用型强。
# 5.2 api 服务: babel-core
TODO
# 5.3 命令行: babel-cli
babel-cli 本身的用法本书不再赘述,官方文档讲解的非常详细。
babel-cli 是基于 commander.js 的命令行,了解 commander.js 的使用之后,babel-cli 的原理也比较容易理解了。
babel-cli 的运行过程并不复杂,运行流程图如下:
以下是其源码中的部分细节:
参数定义
babel-cli 的参数定义基于 commander.js。
例如:
import commander from "commander"; commander.option( "-f, --filename [filename]", "The filename to use when reading from stdin. This will be used in source-maps, errors etc.", );
1
2
3
4
5
6globs 语法
babel-cli 支持通配符匹配源文件,例如:
babel *.js --output-file dist.js
1*.js
会匹配当前目录所有以.js
为后缀的文件。babel 使用 npm 包
glob
提供对通配符的支持。TODO: globs 语法
参数错误信息提示
解析参数的过程中,对于输入错误的情况,babel-cli 在解析过程中用数组统一收集错误信息,最后统一输出给使用者。
这个技巧值得借鉴。
// 错误收集 const errors = []; if (commander.outFile && commander.outDir) { errors.push("--out-file and --out-dir cannot be used together"); } // 错误展示 if (errors.length) { console.error("babel:"); errors.forEach(function (e) { console.error(" " + e); }); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14不向前兼容的提示
babel-cli 历史版本可能是支持用户这样使用:
const babel = require("babel-cli"); babel.transform(...);
1
2
3但后续的升级移除了对该功能的支持,为了提示使用者,babel-cli 保留了旧的
index.js
,内容比较直接:throw new Error("Use the `@babel/core` package instead of `@babel/cli`.");
1在做工具的破坏性升级时,这也是可以借鉴的提示技巧。
# 5.4 node 增强:babel-register
# 5.4.1 babel-register 用法
babel-register 模块会改写 node 的 require
方法,为其加上一个钩子,当 require
加载 .js
、.jsx
、.es6
、.es
、.mjs
后缀的时候,会先用 Babel 转码。
后缀可配置
.js
、.jsx
、.es6
、.es
、.mjs
为默认配置
举例:
index.js
require('babel-register');
require('./run');
2
run.js
import fs from 'fs';
console.log(fs);
2
执行 node index
时,run.js 就不需要被转码了。
有两点需要注意:
- babel-register 不会对所在的当前文件转码,也就是
index.js
不会被转码 - babel-register 是实时转码,会影响运行性能,只适合在开发环境使用
# 5.4.2 require 加载机制
(TODO:附录链接 https://github.com/nodejs/node-v0.x-archive/blob/master/lib/module.js)
理解 node 中 require
的加载机制,有助于理解 babel-register 是如何为 require
添加钩子的。
require
的源码定义在 node 源码 lib/module.js
的 Module.prototype.require
方法。
在此实现一个简单版本的 require
方法,它具有如下能力:
- 传入路径名,找到目标文件
- 执行目标文件代码,并注入
module / require
等方法 - 返回模块的
module.exports
以下是简要实现,辅以注释:
// require 位于 Module 类的原型
function Module(id = '') {
this.id = id; // id 就是 require 路径
this.path = path.dirname(id);
this.exports = {};
this.filename = null;
this.loaded = false;
}
Module.prototype.require = function(request) {
return Module._load(request);
};
// Module._load 有如下功能
// 1. 优先返回缓存
// 2. 如果没有缓存,就新建一个 Module 实例 module,用该实例加载目标模块,并返回目标模块的 module.exports
Module._load = function(request) {
// 根据 request 获取目标文件的绝对路径
// filename 是绝对路径
const filename = Module._resolveFilename(request); // 下文有该方法的定义
const cachedModule = Module._cache[filename];
// 优先返回缓存
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 没有缓存,为目标文件新建一个 Module 实例
const module = new Module(filename);
// 写入缓存
// 这样写有一个好处: 可以处理循环引用
// a -> b -> a 的情况下,a require b,b 会进入缓存,然后 b require a,a require b 时,拿到的是缓存里的 b
Module._cache[filename] = module;
module.load(filename);
return module.exports;
};
// 解析出绝对路径
// request: 是用户传入的名称,如 'path', '../xx' 等,不一定是绝对路径
Module._resolveFilename = function(request) {
// parent 是父模块描述对象,其 parent.paths 是该父级模块的 path 查找集合
// Module._resolveLookupPaths 会返回 request 的 path 查找集合
const [ id, paths ] = Module._resolveLookupPaths(request, parent);
// 最终遍历 paths 找到目标 filename,它是绝对路径
const filename = Module._findPath(request, paths);
return filename;
};
Module.prototype.load = function (filename) {
// 获取文件后缀名
const extname = path.extname(filename);
// 调用后缀名对应的处理函数来处理
Module._extensions[extname](this, filename);
this.loaded = true;
};
// 针对不同的文件后缀,执行不同的解析方法
Module._extensions = {
'.js': function(module, filename) {
// 先读文件
const content = fs.readFileSync(filename, 'utf8');
// 然后运行文件代码
module._compile(content, filename);
},
'.json': function(module, filename) {
// 先读文件
const content = fs.readFileSync(filename, 'utf8');
// 然后运行文件代码
module.exports = JSONParse(content);
},
};
// 该方法会将目标文件 filename 的代码在最外层包裹 exports, require, module, __dirname, __filename
Module.prototype._compile = function(content, filename) {
const wrapper = `(function (exports, require, module, __filename, __dirname) {
${script}
});`
// vm 是 nodejs 的虚拟机沙盒模块,runInThisContext 方法可以接受一个字符串并将它转化为一个函数
// 返回值就是转化后的函数,所以 compiledWrapper 是一个函数
const compiledWrapper = vm.runInThisContext(wrapper, {
filename,
lineOffset: 0,
displayErrors: true,
});
const dirname = path.dirname(filename);
compiledWrapper.call(this.exports, this.exports, this.require, this, filename, dirname);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
总结而言,require
的执行过程如下:
执行
require(request: 模块名称)
直接执行
Module._load(request: 模块名称)
。执行
Module._load(request: 模块名称)
- 解析出绝对路径
Module._resolveFilename(request, parent)
- 利用
const [id, paths] = Module._resolveLookupPaths(request, parent)
生成路径查找集合- 依赖
const paths = Module._nodeModulePaths(from: 当前模块的父路径)
- 依赖
- 利用
Module._findPath(request, paths)
遍历paths
找到目标模块的绝对路径
- 利用
- 优先返回缓存对象
- 如果没有缓存对象,执行
Module.load
- 解析出绝对路径
执行
Module.load(filename: 绝对路径)
- 针对不同文件后缀执行不同逻辑
- js 文件会执行
module._compile(content: 模块内容, filename: 绝对路径)
- js 文件会执行
- 针对不同文件后缀执行不同逻辑
执行
module._compile(content: 模块内容, filename: 绝对路径)
将目标模块源码用
exports, require...
包裹在 node 代码里常见的
exports
、require
、module
、__dirname
、__filename
均是被注入的局部变量执行目标模块代码
目标模块的代码是通过 node 虚拟机
require('vm').runInThisContext
、require('vm').runInNewContext
执行的
# 5.4.3 为 require
添加钩子
基本原理
我们已经了解了
require
的加载机制,那么,假如文件 run.js 的内容如下:console.log('Hi, this is run.js.');
1另有文件 index.js 的内容如下:
require('./run.js')
1当执行
node index.js
时,终端会打印:Hi, this is run.js.
1然后,我们尝试下拦截
require
,在执行node index.js
时,终端打印:Knock knock, I'm here! Hi, this is run.js.
1
2结合前面讲解的
require
加载机制,我们可以从以下点入手:重写
Module._extensions['.js']
,拦截所有.js
文件的require
行为const Module = require('module'); const oldLoader = Module._extensions['.js']; Module._extensions['.js'] = function(module, filename) { // 这里可以添加新逻辑 oldLoader(module, filename); }; require('./run.js');
1
2
3
4
5
6
7
8
9
10
11在新的处理函数中,重写
module
实例的_compile
方法const Module = require('module'); const oldLoader = Module._extensions['.js']; Module._extensions['.js'] = function (module, filename) { const oldCompile = module._compile; // 重写 module._compile module._compile = function (code) { const newCode = ` console.log("Knock knock, I'm here!"); ${code} `; return oldCompile.apply(this, [newCode, filename]); }; oldLoader(module, filename); }; require('./run.js');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这样,重新执行
node index.js
,终端打印出:Knock knock, I'm here! index.js
1
2pirates
很显然上面的代码并不优雅,真实的需求中我们可能无需拦截所有的
.js
文件,可能也需要拦截其他后缀的文件,各种操作还是比较繁琐的。babel-register 使用 npm 包
pirates
提供的addHook
方法拦截require
,拦截时利用 Babel 对源码转译。// 拦截 require 注册 compileHook 方法 // compileHook 会利用Babel转译模块源码,返回转译后的代码 addHook(compileHook, { exts, ignoreNodeModules: false })
1
2
3addHook
的核心原理同上,但处理了更多细节:增强安全性
import BuiltinModule from 'module'; const Module = module.constructor.length > 1 ? module.constructor : BuiltinModule;
1
2Module
对象的获取优先选取module.constructor
,其次选取内置的"module"
模块,以适配 mock 出的module
实例。能够恢复被拦截方法到原始状态
function revert() { if (reverted) return; reverted = true; exts.forEach((ext) => { // if the current loader for the extension is our loader then unregister it and set the oldLoader again // if not we can not do anything as we cannot remove a loader from within the loader-chain if (Module._extensions[ext] === loaders[ext]) { Module._extensions[ext] = oldLoaders[ext]; } }); }
1
2
3
4
5
6
7
8
9
10
11
12根据配置项确定是否处理目标模块源码
function shouldCompile(filename, exts, matcher, ignoreNodeModules) { if (typeof filename !== 'string') { return false; } if (exts.indexOf(path.extname(filename)) === -1) { return false; } const resolvedFilename = path.resolve(filename); if (ignoreNodeModules && nodeModulesRegex.test(resolvedFilename)) { return false; } if (matcher && typeof matcher === 'function') { return !!matcher(resolvedFilename); } return true; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5.4.4 babel-register 内部实现
主要流程
在理解了
require
的加载机制、如何为require
添加钩子后,继续阅读 babel-register 源码会得心应手很多。babel-register 依赖 pirates.js 提供的
addHook
方法,实现拦截require
,同时在拦截函数里执行 Babel 的转译逻辑。缓存处理
TODO
# 5.5 命令行:babel-node
babel-node 也是基于 commander.js 的命令行服务。下图是 babel-node 的运行过程:
由上图可以看出,babel-node 的运行过程比较直观。解析其源码,也有一些值得注意的细节:
v8flags
TODO
kexec 与 spawn
TODO
处理 exit 事件
TODO
repl
TODO
vm.runInThisContext
TODO
Module
TODO
node 参数处理
TODO
# 5.6 浏览器支持:babel-standalone
babel-standalone 提供浏览器环境实时转译 JavaScript 的能力。
# 5.7 babel-highlight
babel-highlight 可以将一段代码字符串在终端高亮展示。
代码中已经可以看到 Babel8 的处理方式了。无论是 Babel7 还是 Babel8 的处理方式,总体思路都是:将代码块解析为不同类型的 token 单元,并对不同类型显示不同的颜色。
token 化处理
babel-highlight 依赖 js-tokens 对代码块解析为不同类型的 token 单元。
TODO
高亮工具:chalk
babel-highlight 使用 chalk 实现代码块在终端的高亮。
TODO
高亮规则
babel-highlight 会用不同颜色对以下目标类型进行高亮处理:
keyword
: 关键词capitalized
: 大写jsxIdentifier
: JSX标识符punctuator
: 标点number
: 数字string
: 字符串regex
: 正则comment
: 注释invalid
: 非正常写法uncolored
# 5.8 babel-template
TODO
# 5.9 webpack 支持:babel-loader
babel-loader 非 Babel 官方出品,也是 webpack 生态中的重要基础工具,其承担了对 webpack 打包过程中相关文件的转译工作。
下图是 babel-loader 的大致运行过程:
转译模块
babel-loader 直接使用 babel-core 的
transform
方法执行源码的转译。而transform
的转译结果对象包含众多属性,babel-loader 选取了部分属性作为新的结果对象。根据 Github issue 中的描述,babel-loader 开发者注意到,transform 返回的原始结果对象中,含有难以序列化的属性值(如
options
),而 Babel 本身也会提供本地化的缓存结果,对于难以序列化的options
等属性,缓存时会丢失一些信息,因此,babel-loader 只会返回可以序列化的部分属性组成的新结果对象。缓存模块
babel-loader 将转移过的结果缓存到本地文件系统。
缓存路径优先使用开发者设置的
cacheDirectory
路径,否则使用babel-loader
提供的默认缓存路径。经过 babel-core 的
transform
API 处理的转译结果 result 对象,会直接被JSON.stringify(result)
转为字符串,并写入本地缓存。如果开发者指定需要对缓存文件压缩,则会压缩为.gz
文件,否则会明文写入缓存文件。下图描述了缓存处理过程:
错误处理模块
对于转译过程发生的错误,babel-loader 并没有直接使用 Node 的原生错误表现,而是基于原生
Error
类,封装了定制化的LoaderError
类。LoaderError
类对错误信息执行格式化,主要规范了以下属性:name
报错名称统一为"
BabelLoaderError
"。message
去掉 message 信息中的冗余信息。
hideStack
隐藏堆栈信息。
使用
Error.captureStackTrace
隐藏非必要信息TODO