【面試準備·1】Babel

我不吃餅乾呀發表於2019-03-11

  /** 考慮到窩真的是一個很菜的選手,加上英語不太好文件看的很吃力,部分概念可能理解不對,所以如果您發現錯誤,請一定要告訴窩,拯救一個辣雞(但很帥)的少年就靠您了!*/

Babel 是一個 JavaScript 的編譯器。你可能知道 Babel 可以將最新版的 ES 語法轉為 ES5,不過不只如此,它還可用於語法檢查,編譯,程式碼高亮,程式碼轉換,優化,壓縮等場景。

Babel7 為了區分之前的版本,所有的包名都改成了 @babel/... 格式。本文參考最新版文件。

Babel 的使用方式

  • 單檔案

<div id="output"></div>
<!-- 載入 Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- 你的指令碼程式碼 -->
<script type="text/babel">
// code...
</script>複製程式碼

  • 命令列
安裝相關包

npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install --save @babel/polyfill
複製程式碼

建立配置檔案 babel.config.js  

const presets = [
  [
    '@babel/env',
    {
      useBuiltIns: 'usage'
    }
  ]
]
module.exports = { presets }複製程式碼

也可以使用 .babelrc 檔案配置,兩者好像沒什麼區別,不過 js 檔案比 json 檔案靈活,一些複雜的配置就只能使用 babel.config.js 了。

{
  "presets": [
    [
      "@babel/env",
      {
        "useBuiltIns": "usage"
      }
    ]
  ]
}
複製程式碼

其中 "useBuiltIns": "usage" 是預設外掛組合 @babel/env 的選項,表示按需引入用到的 API,使用該選項要下載 @babel/polyfill 包。

建立原始檔 src/index.js 

let f = x => x;
let p = Promise.resolve(1);複製程式碼

然後在命令列執行命令 npx babel src/index.js

可以看到控制檯列印出的編譯後的程式碼:

"use strict";
require("core-js/modules/es6.promise");
var f = function f(x) {  
  return x;
};
var p = Promise.resolve(1);複製程式碼

也可以將編譯結果儲存到檔案,執行命令 npx babel src/index.js --out-dir lib 可以將編譯後的檔案儲存到 lib/index.js

  • 構建工具的外掛(webpack、Glup 等)

在 Webpack 中配置 babel-loader 

module: {
  rules: [
    { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }
  ]
}
複製程式碼

更多使用方法可見 使用 Babel

Babel 配置 presets 和 plugins

使用 Babel 時一般會設定 presetsplugins ,也可以同時設定。而 Presets 就是預設的一組 Babel 外掛集合。

Babel 會先執行 plugins 再執行 presets,其中 plugins 按指定順序執行,presets 逆序執行。

babel-preset-es2015/es2016/es2017/latest & babel-preset-stage-x

設定預設的外掛集合,來配置 babel 能轉換的 ES 語法的級別,stage 表示語法提案的不同階段。現在全部不推薦使用了,請一律使用 @babel/preset-env

@babel/preset-env

預設配置相當於 babel-preset-latest,詳細配置見 Env preset 。

舉一個同時配置 pluginspresets 的例子:

配置檔案 .babelrc ,可以寫 react 語法和使用裝飾器。裝飾器還沒有通過提案,瀏覽器一般也都不支援,需要使用 babel 進行轉換。

{
    "presets":[
        "@babel/preset-react"
    ],
    "plugins":[
        [
            "@babel/plugin-proposal-decorators",
            {
                "legacy":true
            }
        ]
    ]
}
複製程式碼

然後寫 index.js 檔案

function createComponentWithHeader(WrappedComponent) {
    class Component extends React.Component {
        render() {
            return (
                <div>
                    <div>header</div>
                    <WrappedComponent />
                </div>
            );
        }
    }
    return Component;
}

@createComponentWithHeader
class App extends React.Component {
    render() {
        return (
            <div>hello react!</div>
        );
    }
}

ReactDOM.render(
    <App />,
    document.getElementById('app')
);
複製程式碼

然後同上面一樣進行編譯,npx babel src/index.js --out-dir lib 就可以得到編譯後檔案了。

可以建立 index.html 開啟頁面檢視效果。

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
		<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
	</head>
	<body>
		<div id="app"></div>
		<script src="./lib/index.js"></script>
	</body>
</html>
複製程式碼

基於環境配置 Babel

{
    "presets": ["es2015"],
    "plugins": [],
    "env": {
        "development": {
            "plugins": [...]
        },
        "production": {
    	    "plugins": [...]
        }
    }
}
複製程式碼

當前環境可以使用 process.env.BABEL_ENV 來獲得。 如果 BABEL_ENV 不可用,將會替換成 NODE_ENV,並且如果後者也沒有設定,那麼預設值是"development"

Babel 相關工具

@babel/polyfill

Babel 在配置了上面的 babel-preset-env 之後,只能轉換語法,而對於一些新的 API,如 PromiseMap 等,並沒有實現,仍然需要引入。

引入 @babel/polyfill (可以通過 require("@babel/polyfill"); 或 import "@babel/polyfill"; )會把這些 API 全部掛載到全域性物件。缺點是會汙染全域性變數,同時如果只用到其中部分的話,會造成多餘的引用。也可以在 @babel/preset-env 裡通過設定 useBuiltIns 選項引入。

@babel/runtime & @babel/plugin-transform-runtime

@babel/runtime@babel/polyfill 解決相同的問題,不過 @babel/runtime手動按需引用的。 不同於 @babel/polyfill 的掛載全域性物件, @babel/runtime 是以模組化方式包含函式實現的包。

引入 babel-plugin-transform-runtime 包實現多次引用相同 API 只載入一次。

注意:對於類似 "foobar".includes("foo") 的例項方法是不生效的,如需使用則仍要引用 @babel/polyfill

@babel/cli

babel 的命令列工具,可以在命令列使用 Babel 編譯檔案,像前文演示的那樣。

@babel/register

@babel/register 模組改寫 require 命令,為它加上一個鉤子。此後,每當使用 require 載入 .js.jsx.es.es6 字尾名的檔案,就會先用 Babel 進行轉碼。預設會忽略 node_modules 。具體配置可見 @babel/register

@babel/node

@babel/node 提供一個同 node 一樣的命令列工具,不過它在執行程式碼之前會根據 Babel 配置進行編譯。在 Babel7 中 @babel/node 不包含在 @babel/cli 中了。

@babel/core

babel 編譯器的核心。可以通過直接呼叫 API 來對程式碼、檔案或 AST 進行轉換。

Babel 的處理階段

解析(parse)

通過詞法分析轉為 token 流(可以理解為詞法單元的陣列),然後通過語法分析轉為抽象語法樹(Abstract Syntax Tree,AST)。

例如,下面的程式碼

n * n
複製程式碼

被轉為轉為 token 流:

[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } }
]複製程式碼

然後轉為 AST。

{    
    "type":"BinaryExpression",
    "start":0,
    "end":5,
    "left":{
        "type":"Identifier",
        "start":0,
        "end":1,
        "name":"n"
    },
    "operator":"*",
    "right":{
        "type":"Identifier",
        "start":4,
        "end":5,
        "name":"n"
    }
}複製程式碼

轉換(transform)

Babel 將遍歷 AST,外掛就是作用於這個階段,我們可以獲取遍歷 AST 過程中的一些資訊並進行處理。

程式碼生成(generate)

通過處理後的 AST 生成可執行程式碼。

Babel 的核心模組

@babel/core

@babel/core 的編譯器的核心模組,開啟 package.json 可以看到其依賴包

"dependencies": {
    "@babel/code-frame": "^7.0.0",  // 生成指向源位置包含程式碼幀的錯誤
    "@babel/generator": "^7.3.4", // Babel 的程式碼生成器 讀取AST並將其轉換為程式碼和原始碼對映
    "@babel/helpers": "^7.2.0",	// Babel 轉換的幫助函式集合
    "@babel/parser": "^7.3.4",	// Babel 的解析器
    "@babel/template": "^7.2.2", // 從一個字串模板中生成 AST
    "@babel/traverse": "^7.3.4", // 遍歷AST 並且負責替換、移除和新增節點
    "@babel/types": "^7.3.4",	// 為 AST 節點提供的 lodash 類的實用程式庫
    ...
}
複製程式碼

依次研究一下這些包.....

@babel/parser

以前版本叫 Babylon ,是 Babel 的解析器。@babel/parser 支援 JSXFlowTypeScript 語法。API 為:

babelParser.parse(code, [options])
babelParser.parseExpression(code, [options])複製程式碼

@babel/traverse

@babel/traverse 用於維護 AST 的狀態,並且負責替換、移除和新增節點。

遍歷並修改 AST (將識別符號 n 改為 x)

import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
const code = `function square(n) { return n * n; }`;
const ast = parser.parse(code);
traverse(ast, {
  enter(path) {
    if (path.isIdentifier({ name: "n" })) {
      path.node.name = "x";
    }
  }
});
複製程式碼

@babel/types

@babel/types 模組是一個用於 AST 節點的 Lodash 式工具庫,它包含了構造、驗證以及變換 AST 節點的方法。

引入 import * as t from "babel-types";

判斷是否為識別符號 t.isIdentifier(node)

構造表示式(a*b) t.binaryExpression("*", t.identifier("a"), t.identifier("b"));

超多 API 見 babel-types ,編寫外掛需要參考這裡。

@babel/generator

@babel/generator 通過 AST 生成程式碼,同時可以生成轉換程式碼和原始碼的對映。

對於上面 @babel/traverse 生成的 AST 轉換為程式碼:

import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from '@babel/generator';
const code = `function square(n) {  return n * n;}`;
const ast = parser.parse(code);
traverse(ast, {
  enter(path) {
    if (path.isIdentifier({
        name: "n"
      })) {
      path.node.name = "x";
    }
  }
});
const output = generate(ast, { /* options */ }, code); 
/*
{ code: 'function square(x) {\n  return x * x;\n}',  map: null,  rawMappings: null } 
*/
複製程式碼

@babel/template

@babel/template 能讓你編寫字串形式且帶有佔位符的程式碼來代替手動編碼。在電腦科學中,這種能力被稱為準引用(quasiquotes)。

import template from "@babel/template";
import generate from "@babel/generator";
import * as t from "@babel/types";

const buildRequire = template(`
  var IMPORT_NAME = require(SOURCE);
`);

const ast = buildRequire({
  IMPORT_NAME: t.identifier("myModule"),
  SOURCE: t.stringLiteral("my-module"),
});

console.log(generate(ast).code);
// const myModule = require("my-module");複製程式碼

Babel 的外掛編寫

訪問者模式

關於訪問者模式,可以參考文章:《23種設計模式(9):訪問者模式

總結下就是有元素類和訪問者兩種型別,元素類有 accept 方法接受一個訪問者物件並呼叫其訪問方法,訪問者提供訪問方法,接受元素類提供的引數並進行操作。

好處是符合單一職責原則擴充套件性良好

使用於物件中存在著一些與本物件不相干(或者關係較弱)的操作,或一組物件中,存在著相似的操作,為了避免出現大量重複的程式碼,也可以將這些重複的操作封裝到訪問者中去。

缺點是元素類擴充套件困難。

訪問者

寫 Babel 外掛就是定義一個訪問者,每次進入一個節點的時候,我們是在訪問一個節點。對於 AST,@babel/traverse 對其進行先序遍歷,每個節點都會被訪問兩次,可以通過 enterexit 方法對兩次訪問節點進行操作。

const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
};

// 你也可以先建立一個訪問者物件,並在稍後給它新增方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}複製程式碼

Identifier() { ... } 相當於 Identifier { enter() { ... } } 

通過屬性名來指定該屬性中的函式會訪問哪些節點。也可以通過 | 分割訪問多種型別的節點。如: "Idenfifier |MemberExpression"

路徑

enter()exit() 的引數是 path ,如果想獲得當前節點,需要通過 path.node 獲取。path 表示兩個節點的連線物件,所以除了 node 表示當前節點外還有許多其他的屬性,如 parent 獲取父節點。

我們也可以遍歷一個 traverse(ast, visitor); 也可以直接對路徑進行遍歷 path.traverse(visitor);  

如果忽略當前節點的所有子孫節點,可以使用 path.skip() 如果想要結束遍歷,可以使用 path.stop()

寫一個簡單的外掛

我們接受 babel 作為引數,可以取 babel.types 作為引數 t ,並返回一個含有 visitor 屬性的物件。

export default function({ types: t }) {
  return {
    visitor: {
      // visitor contents
    }
  };
};複製程式碼

編寫外掛,src/visitor.js,對於二元表示式,如果操作符為 === ,則將操作符左邊的識別符號改為 sebmck 將右邊的識別符號改為 dork 。

export default function({ types: t }) {
  return {
    visitor: {
      BinaryExpression(path) {
        if (path.node.operator !== "===") {
          return;
        }
        path.node.left = t.identifier("sebmck");
        path.node.right = t.identifier("dork");
      }
    }
  };
}
複製程式碼

然後在 src/index.js 使用外掛

import { transform } from '@babel/core';
const result = transform("foo === bar;", {
	plugins: [require("./visitor.js")]
});
console.log(result.code); // sebmck === dork;
複製程式碼

可以在 package.json 中設定指令碼 然後通過 npm run build 執行。(babel 配置不用說了吧

"scripts": {
    "build": "babel src/index.js src/visitor.js --out-dir lib && node lib/index.js"
}
複製程式碼

這樣可以在控制檯看到輸出編譯後的結果,sebmck === dork; 

antd 的按需載入

看到有面試題是關於 antd 的按需載入的問題。

正常通過 import { Button } from 'antd'; 引入元件時會載入整個元件庫。如果通過 Babel 轉成 import Button from 'antd/lib/button'; 則可以只引入所需元件。

通過 AST Explorer 可以看到 import { Button, Table } from 'antd'; 生成的 AST 為:

{
    "type":"ImportDeclaration",
    "start":0,
    "end":37,
    "specifiers":[
        {
            "type":"ImportSpecifier",
            "start":9,
            "end":15,
            "imported":{
                "type":"Identifier",
                "start":9,
                "end":15,
                "name":"Button"
            },
            "local":{
                "type":"Identifier",
                "start":9,
                "end":15,
                "name":"Button"
            }
        },
        {
            "type":"ImportSpecifier",
            "start":17,
            "end":22,
            "imported":{
                "type":"Identifier",
                "start":17,
                "end":22,
                "name":"Table"
            },
            "local":{
                "type":"Identifier",
                "start":17,
                "end":22,
                "name":"Table"
            }
        }
    ],
    "source":{
        "type":"Literal",
        "start":30,
        "end":36,
        "value":"antd",
        "raw":"'antd'"
    }
}
複製程式碼

同時也要看下生成的 import Table from 'antd/lib/table'; 的 AST 

{
    "type":"ImportDeclaration",
    "start":36,
    "end":71,
    "specifiers":[
        {
            "type":"ImportDefaultSpecifier",
            "start":43,
            "end":48,
            "local":{
                "type":"Identifier",
                "start":43,
                "end":48,
                "name":"Table"
            }
        }
    ],
    "source":{
        "type":"Literal",
        "start":54,
        "end":70,
        "value":"antd/lib/table",
        "raw":"'antd/lib/table'"
    }
}
複製程式碼

對比兩個 AST ,可以寫出轉換外掛。

module.exports = function({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path) {
        let { specifiers, source } = path.node;
        if (source.value === 'antd') {
          // 如果庫引入的是 'antd' 
          if (!t.isImportDefaultSpecifier(specifiers[0]) // 判斷不是預設匯入 import Default from 'antd';           
            && !t.isImportNamespaceSpecifier(specifiers[0])) { // 也不是全部匯入 import * as antd from 'antd';      
            let declarations = specifiers.map(specifier => {
              let componentName = specifier.imported.name; // 引入的元件名              
              // 新生成的引入是預設引入             
              return t.ImportDeclaration([t.ImportDefaultSpecifier(specifier.local)], // 轉換後的引入要與之前保持相同的名字 
                t.StringLiteral('antd/lib/' + componentName.toLowerCase()) // 修改引入庫的名字      
              );
            }); // 用轉換後的語句替換之前的宣告語句     
            path.replaceWithMultiple(declarations);
          }
        }
      }
    }
  };
}
複製程式碼

當然 antd 的外掛 babel-plugin-import 是有引數的,所以這裡也簡單的配置引數。

重寫外掛

module.exports = function({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path, { opts }) { // opts 使用者配置外掛選項        
        let { specifiers, source } = path.node;
        if (source.value === opts.libraryName) { // 如果庫引入的是 opts.libraryName 就進行轉換   
          if (!t.isImportDefaultSpecifier(specifiers[0]) // 判斷不是預設匯入 import Default from 'antd';     
            && !t.isImportNamespaceSpecifier(specifiers[0])) { // 也不是全部匯入 import * as antd from 'antd';     
            let declarations = [];
            for (let specifier of specifiers) {
              let componentName = specifier.imported.name; // 引入的元件名            
              declarations.push(t.ImportDeclaration( // 新生成的引入是預設引入     
                [t.ImportDefaultSpecifier(specifier.local)], // 轉換後的引入要與之前保持相同的名字       
                t.StringLiteral(opts.customName(componentName)) // 修改引入庫的名字     
              ));
              if (opts.styleName) {
                declarations.push(t.ExpressionStatement( // 新增引入樣式的節點          
                  t.CallExpression(t.Identifier('require'), 
                  [t.StringLiteral(opts.styleName(componentName))])
                ));
              }
            } // 用轉換後的語句替換之前的宣告語句          
            path.replaceWithMultiple(declarations);
          }
        }
      }
    }
  };
}
複製程式碼

配置 babel.config.js 檔案

const plugins = [
  [
    './plugin.js',
    { 
      "libraryName": "antd", // 轉換的庫名
      "customName": name => `antd/lib/${name.toLowerCase()}`, // 引入元件宣告的轉換規則
      "styleName": name => `antd/lib/${name.toLowerCase()}/style` // 引入元件的樣式
    }
  ]
]
module.exports = { plugins }
複製程式碼

原始檔

import { Button as Btn, Table } from 'antd';複製程式碼

編譯後的檔案

import Btn from "antd/lib/button";
require("antd/lib/button/style");
import Table from "antd/lib/table";
require("antd/lib/table/style");複製程式碼

參考資料


相關文章