天天看點

數棧技術分享:史上最全babel-plugin-import源碼詳解

本文将帶領大家解析babel-plugin-import 實作按需加載的完整流程,解開業界所認可 babel 插件的面紗。

首先供上babel-plugin-import插件

一、初見萌芽

首先 babel-plugin-import 是為了解決在打包過程中把項目中引用到的外部元件或功能庫全量打包,進而導緻編譯結束後包容量過大的問題,如下圖所示:

數棧技術分享:史上最全babel-plugin-import源碼詳解
babel-plugin-import 插件源碼由兩個檔案構成

  • Index 檔案即是插件入口初始化的檔案,也是筆者在 Step1 中着重說明的檔案
  • Plugin 檔案包含了處理各種 AST 節點的方法集,以 Class 形式導出

先來到插件的入口檔案 Index :

import Plugin from './Plugin';
export default function({ types }) {
  let plugins = null;
  /**
   *  Program 入口初始化插件 options 的資料結構
   */
  const Program = {
    enter(path, { opts = {} }) {
      assert(opts.libraryName, 'libraryName should be provided');
      plugins = [
        new Plugin(
          opts.libraryName,
          opts.libraryDirectory,
          opts.style,
          opts.styleLibraryDirectory,
          opts.customStyleName,
          opts.camel2DashComponentName,
          opts.camel2UnderlineComponentName,
          opts.fileName,
          opts.customName,
          opts.transformToDefaultImport,
          types,
        ),
      ];
      applyInstance('ProgramEnter', arguments, this);
    },
    exit() {
      applyInstance('ProgramExit', arguments, this);
    },
  };
  const ret = {
    visitor: { Program }, // 對整棵AST樹的入口進行初始化操作
  };
  return ret;
}           

首先 Index 檔案導入了 Plugin ,并且有一個預設導出函數,函數的參數是被解構出的名叫 types 的參數,它是從 babel 對象中被解構出來的,types 的全稱是 @babel/types,用于處理 AST 節點的方法集。以這種方式引入後,我們不需要手動引入 @babel/types。 進入函數後可以看見觀察者( visitor ) 中初始化了一個 AST 節點 Program,這裡對 Program 節點的處理使用完整插件結構,有進入( enter )與離開( exit )事件,且需注意:

一般我們縮寫的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的簡寫形式。

這裡可能有同學會問 Program 節點是什麼?見下方 const a = 1 對應的 AST 樹 ( 簡略部分參數 )

{
  "type": "File",
  "loc": {
    "start":... ,
    "end": ...
  },
  "program": {
    "type": "Program", // Program 所在位置
    "sourceType": "module",
    "body": [
      {
        "type": "VariableDeclaration",
        "declarations": [
          {
            "type": "VariableDeclarator",
            "id": {
              "type": "Identifier",
              "name": "a"
            },
            "init": {
              "type": "NumericLiteral",
              "value": 1
            }
          }
        ],
        "kind": "const"
      }
    ],
    "directives": []
  },
  "comments": [],
  "tokens": [
       ...
  ]
}           

Program 相當于一個根節點,一個完整的源代碼樹。一般在進入該節點的時候進行初始化資料之類的操作,也可了解為該節點先于其他節點執行,同時也是最晚執行 exit 的節點,在 exit 時也可以做一些”善後“的工作。 既然 babel-plugin-import 的 Program 節點處寫了完整的結構,必然在 exit 時也有非常必要的事情需要處理,關于 exit 具體是做什麼的我們稍後進行讨論。 我們先看 enter ,這裡首先用 enter 形參 state 結構出使用者制定的插件參數,驗證必填的 libraryName [庫名稱] 是否存在。Index 檔案引入的 Plugin 是一個 class 結構,是以需要對 Plugin 進行執行個體化,并把插件的所有參數與 @babel/types 全部傳進去,關于 Plugin 類會在下文中進行闡述。 接着調用了 applyInstance 函數:

export default function({ types }) {
  let plugins = null;
  /**
   * 從類中繼承方法并利用 apply 改變 this 指向,并傳遞 path , state 參數
   */
  function applyInstance(method, args, context) {
    for (const plugin of plugins) {
      if (plugin[method]) {
        plugin[method].apply(plugin, [...args, context]);
      }
    }
  }
  const Program = {
    enter(path, { opts = {} }) {
      ...
      applyInstance('ProgramEnter', arguments, this);
    },
      ...
   }
}           

此函數的主要目的是繼承 Plugin 類中的方法,且需要三個參數

  1. method(String):你需要從 Plugin 類中繼承出來的方法名稱
  2. args:(Arrray):[ Path, State ]
  3. PluginPass( Object):内容和 State 一緻,確定傳遞内容為最新的 State

主要的目的是讓 Program 的 enter 繼承 Plugin 類的 ProgramEnter 方法,并且傳遞 path 與 state 形參至 ProgramEnter 。Program 的 exit 同理,繼承的是 ProgramExit 方法。

現在進入 Plugin 類:

export default class Plugin {
  constructor(
    libraryName,
    libraryDirectory,
    style,
    styleLibraryDirectory,
    customStyleName,
    camel2DashComponentName,
    camel2UnderlineComponentName,
    fileName,
    customName,
    transformToDefaultImport,
    types, // babel-types
    index = 0, // 标記符
  ) {
    this.libraryName = libraryName; // 庫名
    this.libraryDirectory = typeof libraryDirectory === 'undefined' ? 'lib' : libraryDirectory; // 包路徑
    this.style = style || false; // 是否加載 style
    this.styleLibraryDirectory = styleLibraryDirectory; // style 包路徑
    this.camel2DashComponentName = camel2DashComponentName || true; // 元件名是否轉換以“-”連結的形式
    this.transformToDefaultImport = transformToDefaultImport || true; // 處理預設導入
    this.customName = normalizeCustomName(customName); // 處理轉換結果的函數或路徑
    this.customStyleName = normalizeCustomName(customStyleName); // 處理轉換結果的函數或路徑
    this.camel2UnderlineComponentName = camel2UnderlineComponentName; // 處理成類似 time_picker 的形式
    this.fileName = fileName || ''; // 連結到具體的檔案,例如 antd/lib/button/[abc.js]
    this.types = types; // babel-types
    this.pluginStateKey = `importPluginState${index}`;
  }
  ...
}           

在入口檔案執行個體化 Plugin 已經把插件的參數通過 constructor 後被初始化完畢啦,除了 libraryName 以外其他所有的值均有相應預設值,值得注意的是參數清單中的 customeName 與 customStyleName 可以接收一個函數或者一個引入的路徑,是以需要通過 normalizeCustomName 函數進行統一化處理。

function normalizeCustomName(originCustomName) {
  if (typeof originCustomName === 'string') {
    const customeNameExports = require(originCustomName);
    return typeof customeNameExports === 'function'
      ? customeNameExports
      : customeNameExports.default;// 如果customeNameExports不是函數就導入{default:func()}
  }
  return originCustomName;
}           

此函數就是用來處理當參數是路徑時,進行轉換并取出相應的函數。如果處理後 customeNameExports 仍然不是函數就導入 customeNameExports.default ,這裡牽扯到 export default 是文法糖的一個小知識點。

export default something() {}
// 等效于
function something() {}
export ( something as default )           

回歸代碼,Step1 中入口檔案 Program 的 Enter 繼承了 Plugin 的 ProgramEnter 方法

export default class Plugin {
  constructor(...) {...}

  getPluginState(state) {
    if (!state[this.pluginStateKey]) {
      // eslint-disable-next-line no-param-reassign
      state[this.pluginStateKey] = {}; // 初始化标示
    }
    return state[this.pluginStateKey]; // 傳回标示
  }
  ProgramEnter(_, state) {
    const pluginState = this.getPluginState(state);
    pluginState.specified = Object.create(null); // 導入對象集合
    pluginState.libraryObjs = Object.create(null); // 庫對象集合 (非 module 導入的内容)
    pluginState.selectedMethods = Object.create(null); // 存放經過 importMethod 之後的節點
    pluginState.pathsToRemove = []; // 存儲需要删除的節點
    /**
     * 初始化之後的 state
     * state:{
     *    importPluginState「Number」: {
     *      specified:{},
     *      libraryObjs:{},
     *      select:{},
     *      pathToRemovw:[]
     *    },
     *    opts:{
     *      ...
     *    },
     *    ...
     * }
     */
  }
   ...
}           

ProgramEnter 中通過 getPluginState**初始化 state 結構中的 importPluginState 對象,getPluginState 函數在後續操作中出現非常頻繁,讀者在此需要留意此函數的作用,後文不再對此進行贅述。 但是為什麼需要初始化這麼一個結構呢?這就牽扯到插件的思路。正像開篇流程圖所述的那樣 ,babel-plugin-import 具體實作按需加載思路如下:經過 import 節點後收集節點資料,然後從所有可能引用到 import 綁定的節點處執行按需加載轉換方法。state 是一個引用類型,對其進行操作會影響到後續節點的 state 初始值,是以用 Program 節點,在 enter 的時候就初始化這個收集依賴的對象,友善後續操作。負責初始化 state 節點結構與取資料的方法正是 getPluginState。 這個思路很重要,并且貫穿後面所有的代碼與目的,請讀者務必了解再往下閱讀。

二、惟恍惟惚

借由 Step1,現在已經了解到插件以 Program 為出發點繼承了 ProgramEnter 并且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分,請回到 Step1 仔細消化下内容再繼續閱讀。 首先,我們再回到外圍的 Index 檔案,之前隻在觀察者模式中注冊了 Program 的節點,沒有其他 AST 節點入口,是以至少還需注入 import 語句的 AST 節點類型 ImportDeclaration

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) {
      ...
  }
  const Program = {
      ...
   }
  const methods = [ // 注冊 AST type 的數組
    'ImportDeclaration' 
  ]

  const ret = {
    visitor: { Program }, 
  };

  // 周遊數組,利用 applyInstance 繼承相應方法
  for (const method of methods) { 
    ret.visitor[method] = function() {
      applyInstance(method, arguments, ret.visitor);
    };
  }

}           

建立一個數組并将 ImportDeclaration 置入,經過周遊調用 applyInstance_ _和 Step1 介紹同理,執行完畢後 visitor 會變成如下結構

visitor: {
  Program: { enter: [Function: enter], exit: [Function: exit] },
  ImportDeclaration: [Function],
}           

現在回歸 Plugin,進入 ImportDeclaration

export default class Plugin {
  constructor(...) {...}
  ProgramEnter(_, state) { ... }

  /**
   * 主目标,收集依賴
   */
  ImportDeclaration(path, state) {
    const { node } = path;
    // path 有可能被前一個執行個體删除
    if (!node) return;
    const {
      source: { value }, // 擷取 AST 中引入的庫名
    } = node;
    const { libraryName, types } = this;
    const pluginState = this.getPluginState(state); // 擷取在 Program 處初始化的結構
    if (value === libraryName) { //  AST 庫名與插件參數名是否一緻,一緻就進行依賴收集
      node.specifiers.forEach(spec => {
        if (types.isImportSpecifier(spec)) { // 不滿足條件說明 import 是名稱空間引入或預設引入
          pluginState.specified[spec.local.name] = spec.imported.name; 
          // 儲存為:{ 别名 :  元件名 } 結構
        } else {
          pluginState.libraryObjs[spec.local.name] = true;// 名稱空間引入或預設引入的值設定為 true
        }
      });
      pluginState.pathsToRemove.push(path); // 取值完畢的節點添加進預删除數組
    }
  }
  ...
}           

ImportDeclaration 會對 import 中的依賴字段進行收集,如果是名稱空間引入或者是預設引入就設定為 { 别名 :true },解構導入就設定為 { 别名 :元件名 } 。getPluginState 方法在 Step1 中已經進行過說明。關于 import 的 AST 節點結構 用 babel-plugin 實作按需加載 中有詳細說明,本文不再贅述。執行完畢後 pluginState 結構如下

// 例: import { Input, Button as Btn } from 'antd'

{
  ...
  importPluginState0: {
     specified: {
      Btn : 'Button',
      Input : 'Input'
    },
    pathToRemove: {
      [NodePath]
    }
    ...
  }
  ...
}           

這下 state.importPluginState 結構已經收集到了後續幫助節點進行轉換的所有依賴資訊。 目前已經萬事俱備,隻欠東風。東風是啥?是能讓轉換 import 工作開始的 action。在 用 babel-plugin 實作按需加載 中收集到依賴的同時也進行了節點轉換與删除舊節點。一切工作都在 ImportDeclaration 節點中發生。而 babel-plugin-import 的思路是尋找一切可能引用到 Import 的 AST 節點,對他們全部進行處理。有部分讀者也許會直接想到去轉換引用了 import 綁定的 JSX 節點,但是轉換 JSX 節點的意義不大,因為可能引用到 import 綁定的 AST 節點類型 ( type ) 已經夠多了,所有應盡可能的縮小需要轉換的 AST 節點類型範圍。而且 babel 的其他插件會将我們的 JSX 節點進行轉換成其他 AST type,是以能不考慮 JSX 類型的 AST 樹,可以等其他 babel 插件轉換後再進行替換工作。其實下一步可以開始的入口有很多,但還是從咱最熟悉的 React.createElement 開始。

class Hello extends React.Component {
    render() {
        return <div>Hello</div>
    }
}

// 轉換後

class Hello extends React.Component {
    render(){
        return React.createElement("div",null,"Hello")
    }
}           

JSX 轉換後 AST 類型為 CallExpression(函數執行表達式),結構如下所示,熟悉結構後能友善各位同學對之後步驟有更深入的了解。

{
  "type": "File",
  "program": {
    "type": "Program",
    "body": [
      {
        "type": "ClassDeclaration",
        "body": {
          "type": "ClassBody",
          "body": [
            {
              "type": "ClassMethod",
              "body": {
                "type": "BlockStatement",
                "body": [
                  {
                    "type": "ReturnStatement",
                    "argument": {
                      "type": "CallExpression", // 這裡是處理的起點
                      "callee": {
                        "type": "MemberExpression",
                        "object": {
                          "type": "Identifier",
                          "identifierName": "React"
                        },
                        "name": "React"
                      },
                      "property": {
                        "type": "Identifier",
                        "loc": {
                          "identifierName": "createElement"
                        },
                        "name": "createElement"
                      }
                    },
                    "arguments": [
                      {
                        "type": "StringLiteral",
                        "extra": {
                          "rawValue": "div",
                          "raw": "\"div\""
                        },
                        "value": "div"
                      },
                      {
                        "type": "NullLiteral"
                      },
                      {
                        "type": "StringLiteral",
                        "extra": {
                          "rawValue": "Hello",
                          "raw": "\"Hello\""
                        },
                        "value": "Hello"
                      }
                    ]
                  }
                ],
                "directives": []
              }
            }
          ]
        }
      }
    ]
  }
}           

是以我們進入 CallExpression 節點處,繼續轉換流程。

export default class Plugin {
  constructor(...) {...}
  ProgramEnter(_, state) { ... }

  ImportDeclaration(path, state) { ... }

  CallExpression(path, state) {
    const { node } = path;
    const file = path?.hub?.file || state?.file;
    const { name } = node.callee;
    const { types } = this;
    const pluginState = this.getPluginState(state);
    // 處理一般的調用表達式
    if (types.isIdentifier(node.callee)) {
      if (pluginState.specified[name]) {
        node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
      }
    }
    // 處理React.createElement
    node.arguments = node.arguments.map(arg => {
      const { name: argName } = arg;
      // 判斷作用域的綁定是否為import
      if (
        pluginState.specified[argName] &&
        path.scope.hasBinding(argName) &&
        types.isImportSpecifier(path.scope.getBinding(argName).path)
      ) {
        return this.importMethod(pluginState.specified[argName], file, pluginState); // 替換了引用,help/import插件傳回節點類型與名稱
      }
      return arg;
    });
  } 
  ...
}           

可以看見源碼調用了importMethod 兩次,此函數的作用是觸發 import 轉換成按需加載模式的 action,并傳回一個全新的 AST 節點。因為 import 被轉換後,之前我們人工引入的元件名稱會和轉換後的名稱不一樣,是以 importMethod 需要把轉換後的新名字(一個 AST 結構)傳回到我們對應 AST 節點的對應位置上,替換掉老元件名。函數源碼稍後會進行詳細分析。 回到一開始的問題,為什麼 CallExpression 需要調用 importMethod 函數?因為這兩處表示的意義是不同的,CallExpression 節點的情況有兩種:

  1. 剛才已經分析過了,這第一種情況是 JSX 代碼經過轉換後的 React.createElement
  2. 我們使用函數調用一類的操作代碼的 AST 也同樣是 CallExpression 類型,例如:
import lodash from 'lodash'

lodash(some values)           

是以在 CallExpression 中首先會判斷 node.callee 值是否是 Identifier ,如果正确則是所述的第二種情況,直接進行轉換。若否,則是 React.createElement 形式,周遊 React.createElement 的三個參數取出 name,再判斷 name 是否是先前 state.pluginState 收集的 import 的 name,最後檢查 name 的作用域情況,以及追溯 name 的綁定是否是一個 import 語句。這些判斷條件都是為了避免錯誤的修改函數原本的語義,防止錯誤修改因閉包等特性的塊級作用域中有相同名稱的變量。如果上述條件均滿足那它肯定是需要處理的 import 引用了。讓其繼續進入importMethod 轉換函數,importMethod 需要傳遞三個參數:元件名,File(path.sub.file),pluginState

import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';

 export default class Plugin {
   constructor(...) {...}
   ProgramEnter(_, state) { ... }
   ImportDeclaration(path, state) { ... }
   CallExpression(path, state) { ... } 

  // 元件原始名稱 , sub.file , 導入依賴項
   importMethod(methodName, file, pluginState) {
    if (!pluginState.selectedMethods[methodName]) {
      const { style, libraryDirectory } = this;
      const transformedMethodName = this.camel2UnderlineComponentName // 根據參數轉換元件名稱
        ? transCamel(methodName, '_')
        : this.camel2DashComponentName
        ? transCamel(methodName, '-')
        : methodName;
       /**
       * 轉換路徑,優先按照使用者定義的customName進行轉換,如果沒有提供就按照正常拼接路徑
       */
      const path = winPath(
        this.customName
          ? this.customName(transformedMethodName, file)
          : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
      );
      /**
       * 根據是否是預設引入對最終路徑做處理,并沒有對namespace做處理
       */
      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));
        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) {  // 引入 scss/less 
        addSideEffect(file.path, `${path}/style`);
      } else if (style === 'css') { // 引入 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] };
  }
  ...
}           

進入函數後,先别着急看代碼,注意這裡引入了兩個包:path.join 和 @babel/helper-module-imports ,引入 join 是為了處理按需加載路徑快捷拼接的需求,至于 import 語句轉換,肯定需要産生全新的 import AST 節點實作按需加載,最後再把老的 import 語句删除。而新的 import 節點使用 babel 官方維護的 @babel/helper-module-imports 生成。現在繼續流程,首先無視一開始的 if 條件語句,稍後會做說明。再捋一捋 import 處理函數中需要處理的幾個環節:

  • 對引入的元件名稱進行修改,預設轉換以“-”拼接單詞的形式,例如:DatePicker 轉換為 date-picker,處理轉換的函數是 transCamel。
function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1); // 先轉換成小駝峰,以便正則擷取完整單詞
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`); 
  // 例 datePicker,正則抓取到P後,在它前面加上指定的symbol符号
}           

轉換到元件所在的具體路徑,如果插件使用者給定了自定義路徑就使用 customName 進行處理,babel-plugin-import 為什麼不提供對象的形式作為參數?因為 customName 修改是以 transformedMethodName 值作為基礎并将其傳遞給插件使用者,如此設計就可以更精确的比對到需要按需加載的路徑。處理這些動作的函數是 withPath,withPath 主要相容 Linux 作業系統,将 Windows 檔案系統支援的 '\' 統一轉換為 '/'。

function winPath(path) {
  return path.replace(/\\/g, '/'); 
  // 相容路徑: windows預設使用‘\’,也支援‘/’,但linux不支援‘\’,遂統一轉換成‘/’
}           

對 transformToDefaultImport 進行判斷,此選項預設為 true,轉換後的 AST 節點是預設導出的形式,如果不想要預設導出可以将 transformToDefaultImport 設定為 false,之後便利用 @babel/helper-module-imports 生成新的 import 節點,最後**函數的傳回值就是新 import 節點的 default Identifier,替換掉調用 importMethod 函數的節點,進而把所有引用舊 import 綁定的節點替換成最新生成的 import AST 的節點。

數棧技術分享:史上最全babel-plugin-import源碼詳解

最後,根據使用者是否開啟 style 按需引入與 customStyleName 是否有 style 路徑額外處理,以及 styleLibraryDirectory(style 包路徑)等參數處理或生成對應的 css 按需加載節點。

到目前為止一條最基本的轉換線路已經轉換完畢了,相信大家也已經了解了按需加載的基本轉換流程,回到 importMethod 函數一開始的if 判斷語句,這與我們将在 step3 中的任務息息相關。現在就讓我們一起進入 step3。

三、了如指掌

在 step3 中會進行按需加載轉換最後的兩個步驟:

  1. 引入 import 綁定的引用肯定不止 JSX 文法,還有其他諸如,三元表達式,類的繼承,運算,判斷語句,傳回文法等等類型,我們都得對他們進行處理,確定所有的引用都綁定到最新的 import,這也會導緻importMethod 函數被重新調用,但我們肯定不希望 import 函數被引用了 n 次,生成 n 個新的 import 語句,是以才會有先前的判斷語句。
  2. 一開始進入 ImportDeclaration 收集資訊的時候我們隻是對其進行了依賴收集工作,并沒有删除節點。并且我們尚未補充 Program 節點 exit 所做的 action

接下來将以此列舉需要處理的所有 AST 節點,并且會給每一個節點對應的接口(Interface)與例子(不關注語義):

MemberExpression

MemberExpression(path, state) {
    const { node } = path;
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const pluginState = this.getPluginState(state);
    if (!node.object || !node.object.name) return;
    if (pluginState.libraryObjs[node.object.name]) {
      // antd.Button -> _Button
      path.replaceWith(this.importMethod(node.property.name, file, pluginState));
    } else if (pluginState.specified[node.object.name] && path.scope.hasBinding(node.object.name)) {
      const { scope } = path.scope.getBinding(node.object.name);
      // 全局變量處理
      if (scope.path.parent.type === 'File') {
        node.object = this.importMethod(pluginState.specified[node.object.name], file, pluginState);
      }
    }
  }           

MemberExpression(屬性成員表達式),接口如下

interface MemberExpression {
    type: 'MemberExpression';
    computed: boolean;
    object: Expression;
    property: Expression;
}
/**
 * 處理類似:
 * console.log(lodash.fill())
 * antd.Button
 */           

如果插件的選項中沒有關閉 transformToDefaultImport ,這裡會調用 importMethod 方法并傳回@babel/helper-module-imports 給予的新節點值。否則會判斷目前值是否是收集到 import 資訊中的一部分以及是否是檔案作用域下的全局變量,通過擷取作用域檢視其父節點的類型是否是 File,即可避免錯誤的替換其他同名變量,比如閉包場景。

VariableDeclarator

VariableDeclarator(path, state) {
   const { node } = path;
   this.buildDeclaratorHandler(node, 'init', path, state);
}           

VariableDeclarator(變量聲明),非常友善了解處理場景,主要處理 const/let/var 聲明語句

interface VariableDeclaration : Declaration {
    type: "VariableDeclaration";
    declarations: [ VariableDeclarator ];
    kind: "var" | "let" | "const";
}
/**
 * 處理類似:
 * const foo = antd
 */           

本例中出現 buildDeclaratorHandler 方法,主要確定傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用後便進入 importMethod 進行轉換後傳回新節點覆寫原屬性。

buildDeclaratorHandler(node, prop, path, state) {
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const { types } = this;
    const pluginState = this.getPluginState(state);
    if (!types.isIdentifier(node[prop])) return;
    if (
      pluginState.specified[node[prop].name] &&
      path.scope.hasBinding(node[prop].name) &&
      path.scope.getBinding(node[prop].name).path.type === 'ImportSpecifier'
    ) {
      node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
    }
  }           

ArrayExpression

ArrayExpression(path, state) {
    const { node } = path;
    const props = node.elements.map((_, index) => index);
    this.buildExpressionHandler(node.elements, props, path, state);
  }           

ArrayExpression(數組表達式),接口如下所示

interface ArrayExpression {
    type: 'ArrayExpression';
    elements: ArrayExpressionElement[];
}
/**
 * 處理類似:
 * [Button, Select, Input]
 */           

本例的處理和剛才的其他節點不太一樣,因為數組的 Element 本身就是一個數組形式,并且我們需要轉換的引用都是數組元素,是以這裡傳遞的 props 就是類似 [0, 1, 2, 3] 的純數組,友善後續從 elements 中進行取資料。這裡進行具體轉換的方法是 buildExpressionHandler,在後續的 AST 節點進行中将會頻繁出現

buildExpressionHandler(node, props, path, state) {
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const { types } = this;
    const pluginState = this.getPluginState(state);
    props.forEach(prop => {
      if (!types.isIdentifier(node[prop])) return;
      if (
        pluginState.specified[node[prop].name] &&
        types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)
      ) {
        node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState); 
      }
    });
  }           

首先對 props 進行周遊,同樣確定傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用後便進入 importMethod 進行轉換,和之前的 buildDeclaratorHandler 方法差不多,隻是 props 是數組形式

LogicalExpression

LogicalExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['left', 'right'], path, state);
  }           

LogicalExpression(邏輯運算符表達式)

interface LogicalExpression {
    type: 'LogicalExpression';
    operator: '||' | '&&';
    left: Expression;
    right: Expression;
}
/**
 * 處理類似:
 * antd && 1
 */           

主要取出邏輯運算符表達式的左右兩邊的變量,并使用 buildExpressionHandler 方法進行轉換

ConditionalExpression

ConditionalExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
  }           

ConditionalExpression(條件運算符)

interface ConditionalExpression {
    type: 'ConditionalExpression';
    test: Expression;
    consequent: Expression;
    alternate: Expression;
}
/**
 * 處理類似:
 * antd ? antd.Button : antd.Select;
 */           

主要取出類似三元表達式的元素,同用 buildExpressionHandler 方法進行轉換。

IfStatement

IfStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test'], path, state);
    this.buildExpressionHandler(node.test, ['left', 'right'], path, state);
  }           

IfStatement(if 語句)

interface IfStatement {
    type: 'IfStatement';
    test: Expression;
    consequent: Statement;
    alternate?: Statement;
}
/**
 * 處理類似:
 * if(antd){ }
 */           

這個節點相對比較特殊,但筆者不明白為什麼要調用兩次 buildExpressionHandler ,因為筆者所想到的可能性,都有其他的 AST 入口可以處理。望知曉的讀者可進行科普。

ExpressionStatement

ExpressionStatement(path, state) {
    const { node } = path;
    const { types } = this;
    if (types.isAssignmentExpression(node.expression)) {
      this.buildExpressionHandler(node.expression, ['right'], path, state);
    }
 }           

ExpressionStatement(表達式語句)

interface ExpressionStatement {
    type: 'ExpressionStatement';
    expression: Expression;
    directive?: string;
}
/**
 * 處理類似:
 * module.export = antd
 */           

ReturnStatement

ReturnStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['argument'], path, state);
  }           

ReturnStatement(return 語句)

interface ReturnStatement {
    type: 'ReturnStatement';
    argument: Expression | null;
}
/**
 * 處理類似:
 * return lodash
 */           

ExportDefaultDeclaration

ExportDefaultDeclaration(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['declaration'], path, state);
  }           

ExportDefaultDeclaration(導出預設子產品)

interface ExportDefaultDeclaration {
    type: 'ExportDefaultDeclaration';
    declaration: Identifier | BindingPattern | ClassDeclaration | Expression | FunctionDeclaration;
}
/**
 * 處理類似:
 * return lodash
 */           

BinaryExpression

BinaryExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['left', 'right'], path, state);
  }           

BinaryExpression(二進制操作符表達式)

interface BinaryExpression {
    type: 'BinaryExpression';
    operator: BinaryOperator;
    left: Expression;
    right: Expression;
}
/**
 * 處理類似:
 * antd > 1
 */           

NewExpression

NewExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['callee', 'arguments'], path, state);
  }           

NewExpression(new 表達式)

interface NewExpression {
    type: 'NewExpression';
    callee: Expression;
    arguments: ArgumentListElement[];
}
/**
 * 處理類似:
 * new Antd()
 */           

ClassDeclaration

ClassDeclaration(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['superClass'], path, state);
  }           

ClassDeclaration(類聲明)

interface ClassDeclaration {
    type: 'ClassDeclaration';
    id: Identifier | null;
    superClass: Identifier | null;
    body: ClassBody;
}
/**
 * 處理類似:
 * class emaple extends Antd {...}
 */           

Property

Property(path, state) {
    const { node } = path;
    this.buildDeclaratorHandler(node, ['value'], path, state);
  }           

Property(對象的屬性值)

/**
 * 處理類似:
 * const a={
 *  button:antd.Button
 * }
 */           

處理完 AST 節點後,删除掉原本的 import 導入,由于我們已經把舊 import 的 path 儲存在 pluginState.pathsToRemove 中,最佳的删除的時機便是 ProgramExit ,使用 path.remove() 删除。

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

恭喜各位堅持看到現在的讀者,已經到最後一步啦,把我們所處理的所有 AST 節點類型注冊到觀察者中

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) { ... }
  const Program = { ... }

  // 補充注冊 AST type 的數組
  const methods = [ 
    'ImportDeclaration'
    'CallExpression',
    'MemberExpression',
    'Property',
    'VariableDeclarator',
    'ArrayExpression',
    'LogicalExpression',
    'ConditionalExpression',
    'IfStatement',
    'ExpressionStatement',
    'ReturnStatement',
    'ExportDefaultDeclaration',
    'BinaryExpression',
    'NewExpression',
    'ClassDeclaration',
  ]

  const ret = {
    visitor: { Program }, 
  };

  for (const method of methods) { ... }

}           

到此已經完整分析完 babel-plugin-import 的整個流程,讀者可以重新捋一捋處理按需加載的整個處理思路,其實抛去細節,主體邏輯還是比較簡單明了的。

四、一些思考

筆者在進行源碼與單元測試的閱讀後,發現插件并沒有對 Switch 節點進行轉換,遂向官方倉庫提了 PR,目前已經被合入 master 分支,讀者有任何想法,歡迎在評論區暢所欲言。 筆者主要補了 SwitchStatement ,SwitchCase 與兩個 AST 節點處理。

SwitchStatement

SwitchStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['discriminant'], path, state);
}           

SwitchCase

SwitchCase(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test'], path, state);
}           
數棧技術分享:史上最全babel-plugin-import源碼詳解
數棧技術分享:史上最全babel-plugin-import源碼詳解

五、小小總結

這是筆者第一次寫源碼解析的文章,也因筆者能力有限,如果有些邏輯闡述的不夠清晰,或者在解讀過程中有錯誤的,歡迎讀者在評論區給出建議或進行糾錯。

現在 babel 其實也出了一些 API 可以更加簡化 babel-plugin-import 的代碼或者邏輯,例如:path.replaceWithMultiple ,但源碼中一些看似多餘的邏輯一定是有對應的場景,是以才會被加以保留。

繼續閱讀