一。作用
Babel is a JavaScript compiler.
結構上屬於編譯器,由於輸入 JS 原始碼,輸出也是 JS 原始碼���所謂 source to source),所以也稱為transpiler(轉譯器)
二。原理
You give Babel some JavaScript code, it modifies the code and generates the new code back out.
具體的,原始碼轉換工作分為 3 個步驟:
parsing -> transforming -> generation
首先「理解」原始碼所具有的語義,接著進行語義層面的轉換,最後從語義表示形式對映回原始碼形式
而語義表示形式,在 Babel 裡指的就是 AST(抽象語法樹):
How it modifies the code? Exactly! It builds AST, traverses it, modifies it based on plugins applied and then generate new code from modified AST.
所以,就程式碼的表示形式而言,是通過引入中間表示形式(AST)來進行語義轉換的:
parsing transforming generation
String -------> AST ------------> modified AST ----------> String
整個過程中,parsing 和 generation 是固定不變的,最關鍵的是 transforming 步驟,通過 babel 外掛來支援,這是其擴充套件性的關鍵
P.S. 編譯原理相關的概念,見 再看編譯原理
parsing
輸入 JS 原始碼,輸出 AST
parsing(解析),對應於編譯器的詞法分析,及語法分析階段。輸入的原始碼字元序列經過詞法分析,生成具有詞法意義的 token 序列(能夠區分出關鍵字、數值、標點符號等),接著經過語法分析,生成具有語法意義的 AST(能夠區分出語句塊、註釋、變數宣告、函式引數等)
實際上就是對程式碼字串進行語義識別的過程,輸入一段程式碼串,如何識別出其語法含義,例如:
var a = 'A variable.';
經過 parsing 後,生成的 AST 如下:
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": "A variable.",
"raw": "'A variable.'"
}
}
],
"kind": "var"
}
它說:這是個 var 型別的變數宣告,變數名叫 a,初始值是個字面量,值為 "A variable."
沒錯,AST 能夠完整地描述程式碼所具有的語法含義,有了這份資訊,編譯器就能像人一樣理解程式碼了,這是進行語義層面轉換的基礎
P.S. JS 程式碼對應的 AST 結構可以通過 AST Explorer 工具檢視
transforming
輸入 AST,輸出修改過的 AST
transforming(轉換),對應於編譯器的機器無關程式碼優化階段(稍微有點牽強,但二者工作內容都是修改 AST),對 AST 做一些修改,比如把變數名 a 改為 input:
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "input"
},
"init": {
"type": "Literal",
"value": "A variable.",
"raw": "'A variable.'"
}
}
],
"kind": "var"
}
修改 AST 節點屬性即可,但如果要把宣告與賦值拆開的話,就需要新增 AST 節點:
[{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "input"
},
"init": null
}
],
"kind": "var"
},
{
"type": "ExpressionStatement",
"expression": {
"type": "AssignmentExpression",
"operator": "=",
"left": {
"type": "Identifier",
"name": "input"
},
"right": {
"type": "Literal",
"value": "A variable.",
"raw": "'A variable.'"
}
}
}]
它說:第一個語句是個 var 型別的變數宣告,變數名叫 input,沒有初始值。第二個語句是個表示式語句,具體的是賦值表示式,操作符是 =,左操作數是標識符 input,右操作數是字面量,值為 "A variable."
語義層面的轉換具體而言就是對 AST 進行增、刪、改操作,修改後的 AST 可能具有不同的語義,對映回程式碼字串也不同
generation
輸入 AST,輸出 JS 原始碼
generation(生成),對應於編譯器的程式碼生成階段,把 AST 對映回程式碼字串,例如:
var input;
input = 'A variable.';
比起 parsing,generation 的過程相對容易些,拼接字串而已
三。用法
相關 npm 包
4 個核心包:
-
@babel/core:以程式設計方式來使用 Babel(不以 CLI 方式)
-
@babel/parser:解析輸入原始碼,建立 AST
-
@babel/traverse:遍歷操作 AST
-
@babel/generator:把 AST 轉回 JS 程式碼
8 個工具包:
-
@babel/cli:以 CLI 方式使用 Babel,依賴 @babel/core
-
@babel/types:AST 操作工具庫,包括判斷、斷言、建立 3 類 API(
isXXX、assertXXX與xxx,例如t.isArrayExpression(node, opts),t.assertArrayExpression(node, opts)與t.arrayExpression(elements)) -
@babel/polyfill:包含一些語言特性補丁(完整的 ES2015+ 環境支援),包括 core-js 和 regenerator runtime
-
@babel/runtime:包含 Babel 轉換產生的工具方法(
_classCallCheck之類的),以及一份 regenerator-runtime,配合 @babel/plugin-transform-runtime 外掛使用 -
@babel/register:Node 環境下 hack require 來達到自動編譯
require到的所有檔案的目的,配合 @babel/node 執行 -
@babel/template:用來快速建立 AST 的模板語法,支援佔位符
-
@babel/helpers:一系列預定義的 @babel/template 模板方法,供 Babel 外掛使用
-
@babel/code-frame:用來輸出原始碼行列相關的錯誤資訊
P.S. 關於 Babel packages 的更多資訊,見 babel/packages/README.md
P.S. 至於為什麼包名都是 @babel/xxx 形式的,一方面出於避免命名衝突考慮,另一方面是為了方便區分官方 package 與社群 package,避免誤解,具體見 Renames: Scoped Packages (@babel/x)
babylon 與 @babel/parser
@babel/parser 是 Babel 7 推出的,之前叫 Babylon
The Babel parser (previously Babylon) is a JavaScript parser used in Babel.
是 Babel 的 JS 解析器,幾個特點:
-
預設開啟最新版 ES(ES2017)特性支援
-
保留註釋(comment attachment)
-
支援 JSX、Flow、Typescript
-
支援實驗性的語言特性(stage-0 及其它階段的候選特性)
@babel/polyfill 與@babel/runtime
這 2 個東西都是用來提供 ES 特性補丁的,比如 Promise、Set、Map 等:
The babel-polyfill and babel-runtime modules are used to serve the same function in two different ways. Both modules ultimately serve to emulate an ES6 environment.
區別在於:
-
@babel/polyfill:會汙染全域性作用域,適合 App 和命令列工具
-
@babel/runtime:會作為執行時依賴打包進去,不汙染全域性作用域,更適合類庫
簡單示例
把常量名轉換成大寫,即:
// 輸入
const numberFive = 5;
// 要求輸出
const NUMBER_FIVE = 5;
清晰起見,分別引用 @babel/parser、@babel/traverse 與 @babel/generator(不直接使用 @babel/core 提供的上層 API):
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
let input = `
const number = 'number';
const numberFive = 5;
const numberSix = 6, numberSeven = numberSix + 1;
const XMLHttpRequest = window.XMLHttpRequest;
let aString = 'string';
var numberEight = numberSeven + 1;
function f() {
const numberEleven = numberSeven + 4;
return numberFive + numberEleven + numberEight;
}
`;
// 1. 解析
let ast = parser.parse(input);
// 2. 轉換
function renameConst(name) {
return name.replace(/([a-z])([A-Z])/, '$1_$2').toUpperCase();
}
function renameConstBindings(path) {
let ownBindings = path.scope.bindings;
for (let name in ownBindings) {
if (ownBindings[name].kind === 'const') {
path.scope.rename(name, renameConst(name));
}
}
}
traverse(ast, {
Program: {
exit: renameConstBindings
},
Function: {
exit: renameConstBindings
}
});
// 3. 生成
let output = generate(ast);
// test
console.log(output.code);
輸出:
const NUMBER = 'number';
const NUMBER_FIVE = 5;
const NUMBER_SIX = 6,
NUMBER_SEVEN = NUMBER_SIX + 1;
const XMLHTTP_REQUEST = window.XMLHttpRequest;
let aString = 'string';
var numberEight = NUMBER_SEVEN + 1;
function f() {
const NUMBER_ELEVEN = NUMBER_SEVEN + 4;
return NUMBER_FIVE + NUMBER_ELEVEN + numberEight;
}
純作用域操作(找出常量,再重命名即可),scope 相關的更多 API 見 babel/packages/babel-traverse/src/scope/index.js
四。外掛
定義
Babel 外掛的一般格式為:
export default function(babel) {
return {
// 必需,配合 traverse 使用的 visitor 物件
visitor: {},
// 可選,繼承其它外掛,比如識別 JSX、async function 等語法
inherits: OtherPlugin,
// 可選,外掛執行前,初始化狀態,如 cache
pre(state) {},
// 可選,外掛執行後,收尾清理工作
post(state) {}
}
}
所以很容易把上面的常量名轉換功能包裝成 Babel 外掛,把轉換部分的 visitor 拿進來即可:
// babel-plugin-transform-const-name.js
export default function(babel) {
return {
visitor: {
Program: {
exit: renameConstBindings
},
Function: {
exit: renameConstBindings
}
}
}
}
P.S. 通過 Babel 配置選項設定的外掛引數,可以通過 state.opts 讀取,具體見 Plugin Options
編譯
Babel 及外掛執行的 Node 環境不支援 ES Module(export default),所以外掛自身需要編譯,這裡通過 @babel/cli 來完成:
npx babel plugins --no-babelrc --presets=@babel/preset-env --out-dir lib
也可以通過 npm scripts 來做:
"scripts": {
"compile-plugins": "babel plugins --no-babelrc --presets=@babel/preset-env --out-dir lib"
}
把 ./plugins/ 目錄下的外掛原始碼全都轉一遍放到 ./lib/ 下,檔名保持不變
配置
一般通過 .babelrc 配置檔案(放在專案根目錄下)來應用指定外掛:
{
"plugins": ['./lib/babel-plugin-transform-const-name.js']
}
注意,這裡用的是編譯後的(lib 目錄下)外掛,否則會報錯不支援 export 關鍵字:
SyntaxError: Unexpected token export
應用
然後通過 @babel/core 讓外掛跑起來:
const babel = require('@babel/core');
const input = require('fs').readFileSync('./const-rename-input.js', 'utf-8');
let output = babel.transform(input, {
filename: 'const-rename-input.js'
});
console.log(output.code);
注意,要走 .babelrc 配置的話,必須指定 filename,具體見 babel.transform API is not using .babelrc
.babelrc files are loaded relative to the file being compiled. If this option is omitted, Babel will behave as if babelrc: false has been set.
或者不走 .babelrc 直接通過 CLI 來跑:
npx babel const-rename-input.js --no-babelrc --presets=@babel/preset-env --plugins=./lib/babel-plugin-transform-const-name.js
P.S. Babel ClI 的更多用法,見 Usage
輸出:
"use strict";
const NUMBER = 'number';
const NUMBER_FIVE = 5;
const NUMBER_SIX = 6,
NUMBER_SEVEN = NUMBER_SIX + 1;
const XMLHTTP_REQUEST = window.XMLHttpRequest;
let aString = 'string';
var numberEight = NUMBER_SEVEN + 1;
function f() {
const NUMBER_ELEVEN = NUMBER_SEVEN + 4;
return NUMBER_FIVE + NUMBER_ELEVEN + numberEight;
}
五。應用場景
刪除除錯程式碼
去掉 console.xxx,debugger,具體實現如下:
function removeConsoleCall(path, {types: t}) {
if (path.node.name === 'console') {
let consoleCall = path.findParent(p => p.isCallExpression());
if (consoleCall) {
try {
consoleCall.remove();
} catch(ex) {
consoleCall.replaceWith(t.identifier('undefined'));
}
}
}
}
export default function(babel) {
return {
visitor: {
Identifier: {
enter(path) {
removeConsoleCall(path, babel);
}
},
DebuggerStatement: {
enter(path) {
path.remove();
}
}
}
}
}
注意一個細節,預設刪掉 console.xxx(consoleCall.remove();),但有些情況不能直接刪除,比如作為操作數參與運算時,刪掉就會引發語法錯誤,這裡利用 path 操作自帶的校驗,捕獲此類錯誤並以替換成 undefined 來兜底
輸入:
console.log(1);
window.console.log(2);
console.error('err');
let result = 2 > 1 ? console.log(3) : window.console.log(4);
if (true) debugger;
if (true) {
debugger;console.log(2);alert(3);
let three = 2 + (console.info('info'), 1);
}
輸出:
"use strict";
var result = 2 > 1 ? undefined : undefined;
if (true) {}
if (true) {
var three = 2 + (1);
}
看起來不錯,但對於別名之類難以追蹤的東西無能為力,例如:
let log = console.log.bind(console);
log(4);
var c = window.console;
c.log(5);
// 存在誤傷
void function(c) {
c.log(6);
alert(7);
}(window.console);
輸出:
var log;
log(4);
var c = window.console;
c.log(5); // 存在誤傷
void undefined;
常量編譯替換
編譯時,把 _GET_CONFIG('c3') 為對應的配置資訊,如:
{
"c1": "#FFFFFF",
"c2": "#00FFFF",
"c3": "#FF00FF",
"c4": "#FFFF00"
}
外掛內容如下:
const CONFIG_MAP = {
"c1": "#FFFFFF",
"c2": "#00FFFF",
"c3": "#FF00FF",
"c4": "#FFFF00"
};
export default function({types: t}) {
return {
inherits: require("@babel/plugin-syntax-jsx").default,
visitor: {
CallExpression: {
enter(path) {
if (path.node.callee.name === '_GET_CONFIG') {
let args = path.node.arguments.map(v => v.value);
let configValue = CONFIG_MAP[args[0]] || '';
path.replaceWith(t.stringLiteral(configValue));
}
}
}
}
}
}
輸入:
function render() {
return <div style={{color: _GET_CONFIG('c3')}}></div>
}
輸出:
"use strict";
function render() {
return <div style={{
color: "#FF00FF"
}}></div>;
}
同樣,只能應對靜態替換的場景,不支援別名,也不支援變數:
let x = 'c3';
_GET_CONFIG(x);
let get = _GET_CONFIG;
get('c4');
輸出:
var x = 'c3';
"";
var get = _GET_CONFIG;
get('c4');
其它場景
-
實現強約束:比如 使用 babel 外掛來打造真正的「私有」屬性,用
Symbol作為私有屬性的key,把道德規範變成強約束 -
原始碼轉換:有專門的工具 facebook/jscodeshift,提供了更方便的 API(如
findVariableDeclarators('foo').renameTo('bar')),尤其適合 API 升級之類的需要大規模重構的場景,例如 reactjs/react-codemod -
格式化:如 Prettier,進行語義等價的程式碼風格轉換,比如箭頭函式引數帶不帶括號、語句末尾要不要分號之類的
-
可視化:js2flowchart 能夠根據程式碼輸出流程圖,讀原始碼可以參考,也可以用來分析祖傳邏輯
暫無評論,快來發表你的看法吧