跳到主要內容
黯羽輕揚每天積累一點點

Babel 快速指南

免費2018-10-07#Tool#babel plugin#babel插件#Babylon vs babel-parser#babel应用场景#babel与AST

Babel 做了些什麼?用 Babel 能做什麼?

一。作用

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 個核心包:

8 個工具包:

  • @babel/cli:以 CLI 方式使用 Babel,依賴 @babel/core

  • @babel/types:AST 操作工具庫,包括判斷、斷言、建立 3 類 API(isXXXassertXXXxxx,例如 t.isArrayExpression(node, opts)t.assertArrayExpression(node, opts)t.arrayExpression(elements)

  • @babel/polyfill:包含一些語言特性補丁(完整的 ES2015+ 環境支援),包括 core-jsregenerator 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.xxxdebugger,具體實現如下:

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.xxxconsoleCall.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 能夠根據程式碼輸出流程圖,讀原始碼可以參考,也可以用來分析祖傳邏輯

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論