Stories

Detail Return Return

編寫babel的插件 - Stories Detail

前言

Babel 是一個通用的多功能的 JavaScript 編譯器,讓一些新版本的語法或者語法糖可以在低版本的瀏覽器上跑起來。
它有三個主要處理步驟 Parse -> Transform -> Generate。
在 Transform 轉換過程中通過將插件(或預設)應用到配置文件來啓用代碼轉換。

AST

編寫 Babel 插件非常複雜,需要有相當的基礎知識,在講插件之前必須得提起 AST 的概念。
AST 全稱 Abstract Syntax Tree 抽象語法樹,這棵樹定義了代碼的結構,通過操作這棵樹的增刪改查實現對代碼的變動和優化,並最終在Generate步驟構建出轉換後的代碼字符串。
astexplorer是一款非常好用的在線轉換工具,可以幫助我們更直觀的認識到 AST 節點。

function square(n) {
  return n * n;
}

經過網站解析後,得到

{
  "type": "Program",
  "start": 0,
  "end": 38,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 38,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 15,
        "name": "square"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "n"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 38,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 23,
            "end": 36,
            "argument": {
              "type": "BinaryExpression",
              "start": 30,
              "end": 35,
              "left": {
                "type": "Identifier",
                "start": 30,
                "end": 31,
                "name": "n"
              },
              "operator": "*",
              "right": {
                "type": "Identifier",
                "start": 34,
                "end": 35,
                "name": "n"
              }
            }
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}

這裏不是本文的重點,大概熟悉下數據結構就行,後面實例中用到了會再詳細講解。

簡介

visitor

轉換階段 @babel/traverse 會遍歷訪問每一個 AST 節點傳遞給插件,插件根據需要選擇感興趣的節點進行轉換操作,這種設計模式稱為訪問者模式(visitor)。
這樣做的好處是:

  • 統一執行遍歷操作
  • 統一執行節點的轉換方法
想象一下,Babel 有那麼多插件,如果每個插件自己去遍歷AST,對不同的節點進行不同的操作,維護自己的狀態。這樣子不僅低效,它們的邏輯分散在各處,會讓整個系統變得難以理解和調試。

來看一個最簡單的插件結構:

export default function({ types: t }) {
  return {
    visitor: {
      Identifier(path, state) {
        console.log(path, state);
      },
    }
  };
};

它在每次進入一個標識符 Identifier 的時候會打印當前的 pathstate
注意:

Identifier() {}
  ↑ 簡寫
Identifier: {
  enter() {}
}

如果需要訪問到完整的生命週期(包含離開事件),使用如下寫法:

Identifier: {
  enter() {
    console.log('Entered!');
  },
  exit() {
    console.log('Exited!');
  }
}

@babel/traverse

遍歷並負責替換、移除和添加 AST 節點。

path

表示節點之間的關聯關係,詳見path源碼:

// 數據
{
  "parent": {...}, // 父節點數據結構
  "parentPath": null, // 父節點路徑
  "node": {...}, // 節點數據結構
  "scope": null, // 作用域
  ...等等
}
// 方法
{
  "remove", // 移除當前節點
  "replaceWith", // 替換當前節點
  ...等等
}

state

用來訪問插件信息、配置參數,也可以用來存儲插件處理過程中的自定義狀態。

@babel/types

包含了構造、驗證、變換 AST 節點的方法的工具庫。
我們以上述 square 方法為例,寫一個把 n 重命名為 x 的訪問者的快速實現:

enter(path) {
  if (path.node.type === 'Identifier' && path.node.name === 'n') {
    path.node.name = 'x';
  }
}

結合 @babel/types 可以更簡潔且語義化:

import * as t from '@babel/types';
enter(path) {
  if (t.isIdentifier(path.node, { name: 'n' })) {
    path.node.name = 'x';
  }
}

只要確定節點的類型(type 屬性)後,根據類型到官方文檔查找。

實例

babel-plugin-import

使用過 react 組件庫 ant-design 或者 vue 組件庫 vant 的小夥伴一定都不會對按需引入(import-on-demand)這個概念陌生,具體概念文檔可見 antd 按需加載、vant快速上手,都推薦使用 babel-plugin-import 這款插件支持自動按需。
這裏需要注意的是,大部分構建工具支持對 ESM 產物基於 Tree Shaking 的按需加載,那麼這個插件是不是已經無用武之地了?
答案是否定的:

  • Tree Shaking 受到複雜環境影響(如副作用 sideEffects)導致失敗
  • 基於 less 等預處理的 css 無法支持 Tree Shaking 而被全局引入
  • 構建工具無 Tree Shaking 或組件庫無 ESM 產物

講完了它的不可替代性,接下來我們看看這個插件做了什麼

// 在.babelrc 中添加配置
{
  "plugins": [
    ["import", {
      "libraryName": "vant",
      "libraryDirectory": "es",
      "style": true
    }]
  ]
}
import { Button } from 'vant';

      ↓ ↓ ↓ ↓ ↓ ↓

import "vant/es/button/style";
import _Button from "vant/es/button";

如果去掉插件效果會怎麼樣呢?

import { Button } from 'vant';

      ↓ ↓ ↓ ↓ ↓ ↓

var _vant = require("vant");
var Button = _vant.Button;

可以明顯看到會將整個組件庫全部引入,嚴重影響了包大小。
鋪墊了這麼多,進入主題分析源碼吧。先知道需要做什麼,從樹上收集到關鍵一些關鍵字 ImportDeclarationspecifiers.local.namesource.value

image.png

針對這些關鍵節點,開始做狀態收集,源碼如下:

ImportDeclaration(path, state) {
  const { node } = path;

  // path maybe removed by prev instances.
  if (!node) return;

  const { value } = node.source;
  const { libraryName } = this;
  // @babel/types 工具庫
  const { types } = this;
  // 內部維護的狀態
  const pluginState = this.getPluginState(state);
  if (value === libraryName) {
    node.specifiers.forEach(spec => {
      if (types.isImportSpecifier(spec)) {
        pluginState.specified[spec.local.name] = spec.imported.name;
      } else {
        pluginState.libraryObjs[spec.local.name] = true;
      }
    });
    pluginState.pathsToRemove.push(path);
  }
}

分析得出,做了以下幾件事:

  1. 判斷引入的包名是否與參數libraryName相同
  2. 遍歷 specifiers 關鍵字,判斷是否 ImportSpecifier 類型(大括號方式),分別存入不同的內部狀態
  3. 將當前節點存入內部狀態,最後統一刪除

收集完狀態後,尋找所有可能引用到 Import 的節點,對他們所有進行處理。由於需要判斷的節點太多,這裏不多做贅述,涉及到的可以查看源碼如下:

const methods = [
  'ImportDeclaration',
  'CallExpression', // 函數調用表達式 React.createElement(Button)
  'MemberExpression', // 屬性成員表達式 vant.Button
  'Property', // 對象屬性值 const obj = { btn: Button }
  'VariableDeclarator', // 變量聲明 const btn = Button
  'ArrayExpression', // 數組表達式 [Button, Input]
  'LogicalExpression', // 邏輯運算符表達式 Button || 1
  'ConditionalExpression', // 條件運算符 true ? Button : Input
  'IfStatement',
  'ExpressionStatement', // 表達式語句 module.export = Button
  'ReturnStatement',
  'ExportDefaultDeclaration',
  'BinaryExpression', // 二元表達式 Button | 1
  'NewExpression',
  'ClassDeclaration', // 類聲明 class btn extends Button {}
  'SwitchStatement',
  'SwitchCase',
];

一些明顯能看懂的方法名就不一一註釋了,需要特別説明的是非大括號方式的狀態會在 MemberExpression 方法中將 vant.Button 轉為 _Button,

import vant from 'vant'; // 對應pluginState.libraryObjs
const Button = vant.Button;

      ↓ ↓ ↓ ↓ ↓ ↓

import "vant/es/button/style";
import _Button from "vant/es/button";
const Button = _Button;

這些方法最終都會調用 importMethod 函數,它接受3個參數:

  • methodName 原組件名
  • file 當前文件path.hub.file
  • pluginState 內部狀態
importMethod(methodName, file, pluginState) {
  if (!pluginState.selectedMethods[methodName]) {
    const { style, libraryDirectory } = this;
    const transformedMethodName = this.camel2UnderlineComponentName // eslint-disable-line
      ? transCamel(methodName, '_')
      : this.camel2DashComponentName
      ? transCamel(methodName, '-')
      : methodName;
    const path = winPath(
      this.customName
        ? this.customName(transformedMethodName, file)
        : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
    );
    pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
      ? addDefault(file.path, path, { nameHint: methodName })
      : addNamed(file.path, methodName, path);
    if (this.customStyleName) {
      const stylePath = winPath(this.customStyleName(transformedMethodName, file));
      addSideEffect(file.path, `${stylePath}`);
    } else if (this.styleLibraryDirectory) {
      const stylePath = winPath(
        join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
      );
      addSideEffect(file.path, `${stylePath}`);
    } else if (style === true) {
      addSideEffect(file.path, `${path}/style`);
    } else if (style === 'css') {
      addSideEffect(file.path, `${path}/style/css`);
    } else if (typeof style === 'function') {
      const stylePath = style(path, file);
      if (stylePath) {
        addSideEffect(file.path, stylePath);
      }
    }
  }
  return { ...pluginState.selectedMethods[methodName] };
}

分析得出,做了以下幾件事:

  1. 通過methodName進行去重,確保importMethod函數不會被多次調用
  2. 對組件名methodName進行轉換
  3. 根據不同配置生成 import 語句和 import 樣式

這裏還用到了babel官方的輔助函數包 @babel/helper-module-imports 方法 addDefaultaddNamedaddSideEffect,具體作用如下:

import { addDefault } from '@babel/helper-module-imports';
// If 'hintedName' exists in scope, the name will be '_hintedName2', '_hintedName3', ...
addDefault(path, 'source', { nameHint: 'hintedName' })

      ↓ ↓ ↓ ↓ ↓ ↓

import _hintedName from 'source'
import { addNamed } from '@babel/helper-module-imports';
// if the hintedName isn't set, the function will gennerate a uuid as hintedName itself such as '_named'
addNamed(path, 'named', 'source');

      ↓ ↓ ↓ ↓ ↓ ↓

import { named as _named } from 'source'
import { addSideEffect } from '@babel/helper-module-imports';
addSideEffect(path, 'source');

      ↓ ↓ ↓ ↓ ↓ ↓

import 'source'

最後,在exit離開事件中做好善後工作,刪除掉舊的 import 導入。

ProgramExit(path, state) {
  this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
}

總結一下整個 babel-import-plugin 的流程:

  1. 解析 import 引入的所有標識符並內部緩存
  2. 枚舉所有可能使用到這些標識符的節點,去匹配緩存中的標識符
  3. 匹配成功則用輔助函數生成新的節點
  4. 統一刪除舊節點

附言

寫這篇文章的初衷是因為本系列的兄弟篇:
編寫webpack的loader和plugin(附實例)
編寫markdown-it的插件和規則
都已經寫完了(順便安利一波😁),怎麼能沒有強大的 babel 篇呢。通過上述例子也可以看出,每個plugin都要考慮到各種複雜情況生成的不同 AST 樹,需要大量的知識儲備,不同於之前的文章,本人沒有在項目中實踐過自己的 babel plugin實例,希望之後能有機會補上。

參考文檔

babel插件手冊

user avatar cyzf Avatar haoqidewukong Avatar zaotalk Avatar linlinma Avatar nihaojob Avatar freeman_tian Avatar front_yue Avatar jingdongkeji Avatar aqiongbei Avatar chongdianqishi Avatar razyliang Avatar longlong688 Avatar
Favorites 211 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.