1. 什麼是 Babel
簡單地說,Babel 能夠轉譯 ECMAScript 2015+ 的程式碼,使它在舊的瀏覽器或者環境中也能夠執行。
// es2015 的 const 和 arrow function
const add = (a, b) => a + b;
// Babel 轉譯後
var add = function add(a, b) {
return a + b;
};
Babel 的功能很純粹。我們傳遞一段原始碼給 Babel,然後它返回一串新的程式碼給我們。就是這麼簡單,它不會執行我們的程式碼,也不會去打包我們的程式碼。
它只是一個編譯器。
大名鼎鼎的 Taro 也是利用 Babel 將 React 語法轉化成小程式模板。
2. Babel的包構成
核心包
- babel-core:babel轉譯器本身,提供了babel的轉譯API,如babel.transform等,用於對程式碼進行轉譯。像webpack的babel-loader就是呼叫這些API來完成轉譯過程的。
- babylon:js的詞法解析器,AST生成
- babel-traverse:用於對AST(抽象語法樹,想了解的請自行查詢編譯原理)的遍歷,主要給plugin用
- babel-generator:根據AST生成程式碼
功能包
- babel-types:用於檢驗、構建和改變AST樹的節點
- babel-template:輔助函式,用於從字串形式的程式碼來構建AST樹節點
- babel-helpers:一系列預製的babel-template函式,用於提供給一些plugins使用
- babel-code-frames:用於生成錯誤資訊,列印出錯誤點原始碼幀以及指出出錯位置
- babel-plugin-xxx:babel轉譯過程中使用到的外掛,其中babel-plugin-transform-xxx是transform步驟使用的
- babel-preset-xxx:transform階段使用到的一系列的plugin(官方寫好的外掛)
- babel-polyfill:JS標準新增的原生物件和API的shim,實現上僅僅是core-js和regenerator-runtime兩個包的封裝
- babel-runtime:功能類似babel-polyfill,一般用於library或plugin中,因為它不會汙染全域性作用域
工具包
babel-cli:babel的命令列工具,透過命令列對js程式碼進行轉譯
babel-register:透過繫結node.js的require來自動轉譯require引用的js程式碼檔案
babel8 將包名變為了@babel
3. 原理
Babel 轉換 JS 程式碼可以分成以下三個大步驟:
- Parser(解析):此過程接受轉換之前的原始碼,輸出 AST(抽象語法樹)。在 Babel 中負責此過程的包為 babel/parser;
- Transform(轉換):此過程接受 Parser 輸出的 AST(抽象語法樹),輸出轉換後的 AST(抽象語法樹)。在 Babel 中負責此過程的包為 @babel/traverse;
- Generator(生成):此過程接受 Transform 輸出的新 AST,輸出轉換後的原始碼。在 Babel 中負責此過程的包為 @babel/generator。
所以AST相關知識,你應該預先就瞭解了
babel是一個轉譯器,感覺相對於編譯器compiler,叫轉譯器transpiler更準確,因為它只是把同種語言的高版本規則翻譯成低版本規則,而不像編譯器那樣,輸出的是另一種更低階的語言程式碼。
但是和編譯器類似,babel的轉譯過程也分為三個階段:parsing、transforming、generating,以ES6程式碼轉譯為ES5程式碼為例,babel轉譯的具體過程如下:
(1)code --> AST
第一步就是把我們寫的 ES6 程式碼字串轉換成 ES6 AST
那轉換的工具為 babel 的 parser
怎麼轉換的你就理解為正常的轉 AST,簡單的例子會放到結尾
(2)Transform
這一步做的事情,就是操作 AST。 將 ES6 的 AST 操作 JS 轉換成 ES5 的 AST
Transform 會遍歷AST,在此過程中會對 AST 結構進行新增、移除、更新等操作,當然這些操作依賴開發者提供的外掛。Babel 對每一個 AST 節點都提供了「進入節點 enter」 與 「退出節點 exit」 兩個時機,第三方開發者可以利用這兩個時機對舊 AST 做操作。值得一提的是,Transform 步驟是 Babel 最複雜的部分,也是第三方外掛能大顯身手的地方。
這一步是最重要的地方,類似webpack,外掛plugins就是在這裡生效,也可以自己手寫外掛加入其中。
Transform 過程採用的是典型的 訪問者模式 不熟悉的同學可以瞭解一下。
我們可以看到 AST 中有很多相似的元素,它們都有一個 type 屬性,這樣的元素被稱作節點。一個節點通常含有若干屬性,可以用於描述 AST 的部分資訊。
比如這是一個最常見的 Identifier 節點:
{
type: 'Identifier',
name: 'add'
}
表示這是一個識別符號。
所以,操作 AST 也就是操作其中的節點,可以增刪改這些節點,從而轉換成實際需要的 AST。
Babel 對於 AST 的遍歷是深度優先遍歷,對於 AST 上的每一個分支 Babel 都會先向下遍歷走到盡頭,然後再向上遍歷退出剛遍歷過的節點,然後尋找下一個分支。
參考 前端進階面試題詳細解答
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration", // 變數宣告
"declarations": [ // 具體宣告
{
"type": "VariableDeclarator", // 變數宣告
"id": {
"type": "Identifier", // 識別符號(最基礎的)
"name": "add" // 函式名
},
"init": {
"type": "ArrowFunctionExpression", // 箭頭函式
"id": null,
"expression": true,
"generator": false,
"params": [ // 引數
{
"type": "Identifier",
"name": "a"
},
{
"type": "Identifier",
"name": "b"
}
],
"body": { // 函式體
"type": "BinaryExpression", // 二項式
"left": { // 二項式左邊
"type": "Identifier",
"name": "a"
},
"operator": "+", // 二項式運算子
"right": { // 二項式右邊
"type": "Identifier",
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
根節點我們就不說了,從 declarations 裡開始遍歷:
- 宣告瞭一個變數,並且知道了它的內部屬性(id、init),然後我們再以此訪問每一個屬性以及它們的子節點。
- id 是一個 Idenrifier,有一個 name 屬性表示變數名。
- 之後是 init,init 也有好幾個內部屬性:
- type 是ArrowFunctionExpression,表示這是一個箭頭函式表示式
- • params 是這個箭頭函式的入參,其中每一個引數都是一個 Identifier 型別的節點;
- • body 屬性是這個箭頭函式的主體,這是一個 BinaryExpression 二項式:left、operator、right,分別表示二項式的左邊變數、運算子以及右邊變數。
這是遍歷 AST 的白話形式,再看看 Babel 是怎麼做的:
Babel 會維護一個稱作 Visitor 的物件,這個物件定義了用於 AST 中獲取具體節點的方法。
Visitor
Babel 遍歷 AST 其實會經過兩次節點:遍歷的時候和退出的時候,所以實際上 Babel 中的 Visitor 應該是這樣的:
var visitor = {
Identifier: {
enter() {
console.log('Identifier enter');
},
exit() {
console.log('Identifier exit');
}
}
};
比如我們拿這個 visitor 來遍歷這樣一個 AST:
params: [ // 引數
{
"type": "Identifier",
"name": "a"
},
{
"type": "Identifier",
"name": "b"
}
]
過程可能是這樣的...
- 進入 Identifier(params[0])
- 走到盡頭
- 退出 Identifier(params[0])
- 進入 Identifier(params[1])
- 走到盡頭
- 退出 Identifier(params[1])
當然,Babel 中的 Visitor 模式遠遠比這複雜...
回到上面的,箭頭函式是 ES5 不支援的語法,所以 Babel 得把它轉換成普通函式,一層層遍歷下去,找到了 ArrowFunctionExpression 節點,這時候就需要把它替換成 FunctionDeclaration 節點。所以,箭頭函式可能是這樣處理的:
import * as t from "@babel/types";
var visitor = {
ArrowFunction(path) {
path.replaceWith(t.FunctionDeclaration(id, params, body));
}
};
(3) Generate(程式碼生成)
上一步是將 ES6 的 AST 操作 JS 轉換成 ES5 的 AST
這一步就是將 ES5 的AST 轉換成 ES5 程式碼字串
經過上面兩個階段,需要轉譯的程式碼已經經過轉換,生成新的 AST 了,最後一個階段理所應當就是根據這個 AST 來輸出程式碼。
Babel 是深度優先遍歷。
Generator 可以看成 Parser 的逆向操作,根據新的 AST 生成程式碼,其實就是生成字串,這些字串本身沒有意義,是編譯器賦予了字串意義才變成我們所說的「程式碼」。Babel 會深度優先遍歷整個 AST,然後構建可以表示轉換後程式碼的字串。
class Generator extends Printer {
constructor(ast, opts = {}, code) {
const format = normalizeOptions(code, opts);
const map = opts.sourceMaps ? new SourceMap(opts, code) : null;
super(format, map);
this.ast = ast;
}
ast: Object;
generate() {
return super.generate(this.ast);
}
}
經過這三個階段,程式碼就被 Babel 轉譯成功了。
4. 簡單實現
以 const add = (a, b) => a + b 為例,轉化完成後應該變成 function add(a,b) {return a + b}。
定義待轉化的程式碼字串:
/** * 待轉化的程式碼 */
const codeString = 'const add = (a, b) => a + b';
(1)ES6 code --> AST
生成AST是需要進行字串詞法分析和語法分析的
首先進行詞法分析
/**
* Parser 過程-詞法分析
* @param codeString 待轉化的字串
* @returns Tokens 令牌流
*/
function tokens(codeString) {
let tokens = []; //存放 token 的陣列
let current = 0; //當前的索引
while (current < codeString.length) {
let char = codeString[current];
//先處理括號
if (char === '(' || char === ')') {
tokens.push({
type: 'parens',
value: char
});
current++;
continue;
}
//處理空格,空格可能是多個連續的,所以需要將這些連續的空格一起放到token陣列中
const WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
let value = '';
while (current < codeString.length && WHITESPACE.test(char)) {
value = value + char;
current++;
char = codeString[current];
}
tokens.push({
type: 'whitespace',
value: value
});
continue;
}
//處理連續數字,數字也可能是連續的,原理同上
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
let value = '';
while (current < codeString.length && NUMBERS.test(char)) {
value = value + char;
current++;
char = codeString[current];
}
tokens.push({
type: 'number',
value: value
});
continue;
}
//處理識別符號,識別符號一般以字母、_、$開頭的連續字元
const LETTERS = /[a-zA-Z\$\_]/;
if (LETTERS.test(char)) {
let value = '';
//識別符號
while (current < codeString.length && /[a-zA-Z0-9\$\_]/.test(char)) {
value = value + char;
current++;
char = codeString[current];
}
tokens.push({
type: 'identifier',
value: value
});
continue;
}
//處理 , 分隔符
const COMMA = /,/;
if (COMMA.test(char)) {
tokens.push({
type: ',',
value: ','
});
current++;
continue;
}
//處理運算子
const OPERATOR = /=|\+|>/;
if (OPERATOR.test(char)) {
let value = '';
while (OPERATOR.test(char)) {
value += char;
current++;
char = codeString[current];
}
//如果存在 => 則說明遇到了箭頭函式
if (value === '=>') {
tokens.push({
type: 'ArrowFunctionExpression',
value,
});
continue;
}
tokens.push({
type: 'operator',
value
});
continue;
}
throw new TypeError(`還未加入此字元處理 ${char}`);
}
return tokens;
}
語法分析
/** * Parser 過程-語法分析 * @param tokens 令牌流 * @returns AST */
const parser = tokens => {
// 宣告一個全時指標,它會一直存在
let current = -1;
// 宣告一個暫存棧,用於存放臨時指標
const tem = [];
// 指標指向的當前token
let token = tokens[current];
const parseDeclarations = () => {
// 暫存當前指標
setTem();
// 指標後移
next();
// 如果字元為'const'可見是一個宣告
if (token.type === 'identifier' && token.value === 'const') {
const declarations = {
type: 'VariableDeclaration',
kind: token.value
};
next();
// const 後面要跟變數的,如果不是則報錯
if (token.type !== 'identifier') {
throw new Error('Expected Variable after const');
}
// 我們獲取到了變數名稱
declarations.identifierName = token.value;
next();
// 如果跟著 '=' 那麼後面應該是個表示式或者常量之類的,這裡我們們只支援解析函式
if (token.type === 'operator' && token.value === '=') {
declarations.init = parseFunctionExpression();
}
return declarations;
}
};
const parseFunctionExpression = () => {
next();
let init;
// 如果 '=' 後面跟著括號或者字元那基本判斷是一個表示式
if (
(token.type === 'parens' && token.value === '(') ||
token.type === 'identifier'
) {
setTem();
next();
while (token.type === 'identifier' || token.type === ',') {
next();
}
// 如果括號後跟著箭頭,那麼判斷是箭頭函式表示式
if (token.type === 'parens' && token.value === ')') {
next();
if (token.type === 'ArrowFunctionExpression') {
init = {
type: 'ArrowFunctionExpression',
params: [],
body: {}
};
backTem();
// 解析箭頭函式的引數
init.params = parseParams();
// 解析箭頭函式的函式主體
init.body = parseExpression();
} else {
backTem();
}
}
}
return init;
};
const parseParams = () => {
const params = [];
if (token.type === 'parens' && token.value === '(') {
next();
while (token.type !== 'parens' && token.value !== ')') {
if (token.type === 'identifier') {
params.push({
type: token.type,
identifierName: token.value
});
}
next();
}
}
return params;
};
const parseExpression = () => {
next();
let body;
while (token.type === 'ArrowFunctionExpression') {
next();
}
// 如果以(開頭或者變數開頭說明不是 BlockStatement,我們以二元表示式來解析
if (token.type === 'identifier') {
body = {
type: 'BinaryExpression',
left: {
type: 'identifier',
identifierName: token.value
},
operator: '',
right: {
type: '',
identifierName: ''
}
};
next();
if (token.type === 'operator') {
body.operator = token.value;
}
next();
if (token.type === 'identifier') {
body.right = {
type: 'identifier',
identifierName: token.value
};
}
}
return body;
};
// 指標後移的函式
const next = () => {
do {
++current;
token = tokens[current]
? tokens[current]
: {type: 'eof', value: ''};
} while (token.type === 'whitespace');
};
// 指標暫存的函式
const setTem = () => {
tem.push(current);
};
// 指標回退的函式
const backTem = () => {
current = tem.pop();
token = tokens[current];
};
const ast = {
type: 'Program',
body: []
};
while (current < tokens.length) {
const statement = parseDeclarations();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
};
可以大概認為,轉成AST的過程中就是不斷的迴圈、正則、識別符號比對等一系列的操作
(2) Transform
const traverser = (ast, visitor) => {
// 如果節點是陣列那麼遍歷陣列
const traverseArray = (array, parent) => {
array.forEach((child) => {
traverseNode(child, parent);
});
};
// 遍歷 ast 節點
const traverseNode = (node, parent) => {
const methods = visitor[node.type];
if (methods && methods.enter) {
methods.enter(node, parent);
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
case 'VariableDeclaration':
traverseArray(node.init.params, node.init);
break;
case 'identifier':
break;
default:
throw new TypeError(node.type);
}
if (methods && methods.exit) {
methods.exit(node, parent);
}
};
traverseNode(ast, null);
};
/**
* Transform 過程
* @param ast 待轉化的AST
* 此函式會呼叫traverser,傳入自定義的visitor完成AST轉化
*/
const transformer = (ast) => {
// 新 ast
const newAst = {
type: 'Program',
body: []
};
// 此處在ast上新增一個 _context 屬性,與 newAst.body 指向同一個記憶體地址,traverser函式操作的ast_context都會賦值給newAst.body
ast._context = newAst.body;
traverser(ast, {
VariableDeclaration: {
enter(node, parent) {
let functionDeclaration = {
params: []
};
if (node.init.type === 'ArrowFunctionExpression') {
functionDeclaration.type = 'FunctionDeclaration';
functionDeclaration.identifierName = node.identifierName;
functionDeclaration.params = node.init.params;
}
if (node.init.body.type === 'BinaryExpression') {
functionDeclaration.body = {
type: 'BlockStatement',
body: [{ type: 'ReturnStatement', argument: node.init.body }],
};
}
parent._context.push(functionDeclaration);
}
},
});
return newAst;
};
(3) generate
/** * Generator 過程 * @param node 新的ast * @returns 新的程式碼 */
const generator = (node) => {
switch (node.type) {
// 如果是 `Program` 結點,那麼我們會遍歷它的 `body` 屬性中的每一個結點,並且遞迴地
// 對這些結點再次呼叫 codeGenerator,再把結果列印進入新的一行中。
case 'Program':
return node.body.map(generator)
.join('\n');
// 如果是FunctionDeclaration我們分別遍歷呼叫其引數陣列以及呼叫其 body 的屬性
case 'FunctionDeclaration':
return 'function' + ' ' + node.identifierName + '(' + node.params.map(generator) + ')' + ' ' + generator(node.body);
// 對於 `Identifiers` 我們只是返回 `node` 的 identifierName
case 'identifier':
return node.identifierName;
// 如果是BlockStatement我們遍歷呼叫其body陣列
case 'BlockStatement':
return '{' + node.body.map(generator) + '}';
// 如果是ReturnStatement我們呼叫其 argument 的屬性
case 'ReturnStatement':
return 'return' + ' ' + generator(node.argument);
// 如果是ReturnStatement我們呼叫其左右節點並拼接
case 'BinaryExpression':
return generator(node.left) + ' ' + node.operator + ' ' + generator(node.right);
// 沒有符合的則報錯
default:
throw new TypeError(node.type);
}
};
(4) 整個流程串聯起來,完成呼叫鏈
let token = tokens(codeString);
let ast = parser(token);
let newAST = transformer(ast);
let newCode = generator(newAST);
console.log(newCode);
5. 其他擴充套件知識
此外,還要注意很重要的一點就是,babel只是轉譯新標準引入的語法,比如ES6的箭頭函式轉譯成ES5的函式;而新標準引入的新的原生物件,部分原生物件新增的原型方法,新增的API等(如Proxy、Set等),這些babel是不會轉譯的。需要使用者自行引入polyfill來解決
plugins
外掛應用於babel的轉譯過程,尤其是第二個階段transforming,如果這個階段不使用任何外掛,那麼babel會原樣輸出程式碼。
我們主要關注transforming階段使用的外掛,因為transform外掛會自動使用對應的詞法外掛,所以parsing階段的外掛不需要配置。
presets
如果要自行配置轉譯過程中使用的各類外掛,那太痛苦了,所以babel官方幫我們做了一些預設的外掛集,稱之為preset,這樣我們只需要使用對應的preset就可以了。以JS標準為例,babel提供瞭如下的一些preset:
• es2015
• es2016
• es2017
• env
es20xx的preset只轉譯該年份批准的標準,而env則代指最新的標準,包括了latest和es20xx各年份
另外,還有 stage-0到stage-4的標準成形之前的各個階段,這些都是實驗版的preset,建議不要使用。
polyfill
polyfill是一個針對ES2015+環境的shim,實現上來說babel-polyfill包只是簡單的把core-js和regenerator runtime包裝了下,這兩個包才是真正的實現程式碼所在(後文會詳細介紹core-js)。
使用babel-polyfill會把ES2015+環境整體引入到你的程式碼環境中,讓你的程式碼可以直接使用新標準所引入的新原生物件,新API等,一般來說單獨的應用和頁面都可以這樣使用。
runtime
polyfill和runtime的區別(必看)
直接使用babel-polyfill對於應用或頁面等環境在你控制之中的情況來說,並沒有什麼問題。但是對於在library中使用polyfill,就變得不可行了。因為library是供外部使用的,但外部的環境並不在library的可控範圍,而polyfill是會汙染原來的全域性環境的(因為新的原生物件、API這些都直接由polyfill引入到全域性環境)。這樣就很容易會發生衝突,所以這個時候,babel-runtime就可以派上用場了。