Description
Deep In Babel
本章介绍 Babel 生态体系相关的各种工具,这些工具在 Babel 内部和外部均可使用,了解这些工具和其背后的原理,对理解 Babel 非常有用。
和 Babel 相关的工具包括:
本章中涉及 Babel 转译部分,可以理解为下述代码:
import * as babel from "@babel/core";
const newCode = babel.transform(code, options)
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());
链式调用
commander.js 暴露的方法会返回 cmd
实例或子命令实例对象,以支持链式调用,该做法还是很常见的。
版本配置
执行 node index.js -v
终端会显示 0.0.1
。
多样的选项配置
boolean 类型的选项
program.option('-d, --debug', 'output extra debugging')
扩展:"取反 boolean"类型
program.option('--no-debug', 'not output extra debugging')
非 boolean 类型的选项
program.option('-c, --cheese <type>' , 'cheese type', 'blue') // (选项定义, 描述, 默认值)
参数可设置默认值。
既可是 boolean 也可非 boolean 类型的选项
program.option('-b, --banana [type]' , 'banana type') // (选项定义, 描述)
type
无值时,banana
选项的值为 boolean 类型。
type
有值时,banana
选项的值为非 boolean 类型。
不定数量的选项
program
.option('-n, --number <numbers...>', 'specify numbers')
.option('-l, --letter [letters...]', 'specify letters');
commander.js 原理解析
概览
commander.js 7.0.0 版本的核心代码就一个文件 index.js
,2200 多行代码,代码的注释比较丰富,代码可读性也是不错的,感兴趣的同学可以通读一下。
commander.js 包含以下类:
Option
Help
Command
CommandError
InvalidOptionArgumentError
如下图:
Option
类
Option
类的实例存储选项的各类信息:
requred
)description
)variadic
)--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
Help
类中相对值得说的是 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');
});
执行 node index start
的时候,会执行 action
注册的回调,打印 actor
。
启动独立文件执行命令
program.command('start', 'start runner');
执行 node index start
的时候,会启动 index-start.js
文件。
该方法内部通过是否含有描述信息判断是哪种模式。
重复注册命令时,会使用第一个注册的命令
比如:
program
.command('start')
.action(function() {
console.log('start 1');
});
program
.command('start')
.action(function() {
console.log('start 2');
});
在执行 node index start
的时候,只会打印 start 1
,因为内部找到匹配的命令的代码是:
this.commands.find(cmd => cmd._name === name || cmd._aliases.includes(name));
Array.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);
执行 node index.js
后,终端输出:
Error
at Object.<anonymous> (xxx/index.js:2:7)
at Module._compile (internal/modules/cjs/loader.js:689:30)
at ...
at ...
当传入 constructorOpt
时,代码如:
> 1 function MyError() {
> 2 Error.captureStackTrace(this, MyError);
> 3 }
> 4
> 5 console.log(new MyError().stack)
终端输出:
Error
at Object.<anonymous> (xxx/index.js:5:13)
at Module._compile (internal/modules/cjs/loader.js:689:30)
at ...
at ...
可以看出,MyError
函数内部的堆栈细节被隐藏了。
Error.captureStackTrace
优点
相对于 new Error().stack
,Error.captureStackTrace
有以下优点:
更简洁
无需 new
一个新的 Error
对象,节省内存空间,同时代码上也会更加优雅。
一般而言,捕获错误信息通常的做法是:
try {
new Error();
} catch(err) {
// err.stack 包含了堆栈信息,可以对其处理
}
而使用 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)
该做法可以隐藏构造函数内部的堆栈信息,无需指定构造函数名,通用型强。
TODO
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.",
);
globs 语法
babel-cli 支持通配符匹配源文件,例如:
babel *.js --output-file dist.js
*.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);
});
}
不向前兼容的提示
babel-cli 历史版本可能是支持用户这样使用:
const babel = require("babel-cli");
babel.transform(...);
但后续的升级移除了对该功能的支持,为了提示使用者,babel-cli 保留了旧的 index.js
,内容比较直接:
throw new Error("Use the `@babel/core` package instead of `@babel/cli`.");
在做工具的破坏性升级时,这也是可以借鉴的提示技巧。
babel-register 模块会改写 node 的 require
方法,为其加上一个钩子,当 require
加载 .js
、.jsx
、.es6
、.es
、.mjs
后缀的时候,会先用 Babel 转码。
后缀可配置
.js
、.jsx
、.es6
、.es
、.mjs
为默认配置
举例:
index.js
require('babel-register');
require('./run');
run.js
import fs from 'fs';
console.log(fs);
执行 node index
时,run.js 就不需要被转码了。
有两点需要注意:
index.js
不会被转码(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);
};
总结而言,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: 绝对路径)
module._compile(content: 模块内容, filename: 绝对路径)
执行 module._compile(content: 模块内容, filename: 绝对路径)
将目标模块源码用 exports, require...
包裹
在 node 代码里常见的 exports
、require
、module
、__dirname
、__filename
均是被注入的局部变量
执行目标模块代码
目标模块的代码是通过 node 虚拟机 require('vm').runInThisContext
、require('vm').runInNewContext
执行的
require
添加钩子基本原理
我们已经了解了 require
的加载机制,那么,假如文件 run.js 的内容如下:
console.log('Hi, this is run.js.');
另有文件 index.js 的内容如下:
require('./run.js')
当执行 node index.js
时,终端会打印:
Hi, this is run.js.
然后,我们尝试下拦截 require
,在执行 node index.js
时,终端打印:
Knock knock, I'm here!
Hi, this is run.js.
结合前面讲解的 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');
在新的处理函数中,重写 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');
这样,重新执行 node index.js
,终端打印出:
Knock knock, I'm here!
index.js
pirates
很显然上面的代码并不优雅,真实的需求中我们可能无需拦截所有的 .js
文件,可能也需要拦截其他后缀的文件,各种操作还是比较繁琐的。
babel-register 使用 npm 包 pirates
提供的 addHook
方法拦截 require
,拦截时利用 Babel 对源码转译。
// 拦截 require 注册 compileHook 方法
// compileHook 会利用Babel转译模块源码,返回转译后的代码
addHook(compileHook, { exts, ignoreNodeModules: false })
addHook
的核心原理同上,但处理了更多细节:
增强安全性
import BuiltinModule from 'module';
const Module = module.constructor.length > 1 ? module.constructor : BuiltinModule;
Module
对象的获取优先选取 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];
}
});
}
根据配置项确定是否处理目标模块源码
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;
}
主要流程
在理解了 require
的加载机制、如何为 require
添加钩子后,继续阅读 babel-register 源码会得心应手很多。
babel-register 依赖 pirates.js 提供的 addHook
方法,实现拦截 require
,同时在拦截函数里执行 Babel 的转译逻辑。
缓存处理
TODO
babel-node 也是基于 commander.js 的命令行服务。下图是 babel-node 的运行过程:
由上图可以看出,babel-node 的运行过程比较直观。解析其源码,也有一些值得注意的细节:
v8flags
TODO
kexec 与 spawn
TODO
处理 exit 事件
TODO
repl
TODO
vm.runInThisContext
TODO
Module
TODO
node 参数处理
TODO
babel-standalone 提供浏览器环境实时转译 JavaScript 的能力。
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
TODO
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
This app can be installed on your PC or mobile device. This will allow this web app to look and behave like any other installed app. You will find it in your app lists and be able to pin it to your home screen, start menus or task bars. This installed web app will also be able to safely interact with other apps and your operating system.
Deep In Babel