庖丁解牛:最全babel-plugin-import源碼詳解

庖丁解牛:最全 babel-plugin-import 源碼詳解

序言:在用 babel-plugin 實現(xiàn)按需加載一文中筆者用作用域鏈思路實現(xiàn)了按需加載組件烤镐。此思路是統(tǒng)一式處理幽勒,進入 ImportDeclaration 后,收集依賴镰吆,生成新節(jié)點,最后利用作用域( scope )鏈直接替換了被修改的 specifiers[] 綁定的所有引用名。同樣是利用作用域鏈向族,可以知曉某一節(jié)點是否在上下文中被引用搀继,如果沒有引用就刪除無效節(jié)點窘面。乃至最后的替換原節(jié)點完成按需加載。這次本文將帶領大家解析 babel-plugin-import 實現(xiàn)按需加載的完整流程叽躯,解開業(yè)界所認可 babel 插件的面紗财边。

首先供上 babel-plugin-import 插件地址:ant-design/babel-plugin-import

由于在筆者的上一篇文章中已經(jīng)對 babel 與 babel-plugin 有過介紹了,因此本文不再贅述点骑,直接進入正題酣难。
眾所周知,庖丁解牛分為三個階段:

  • 第一階段黑滴,庖丁剛開始宰牛的時候憨募,對于牛體的結(jié)構(gòu)還不了解,看見的只是整頭的牛袁辈。
  • 第二階段菜谣,三年之后,他見到的是牛的內(nèi)部肌理筋骨晚缩,再也看不見整頭的牛了尾膊。
  • 第三階段,現(xiàn)在宰牛的時候橡羞,只是用精神去接觸牛的身體就可以了眯停,而不必用眼睛去看。

現(xiàn)在就以這三個階段去逐步遞進 babel-plugin-import 插件源碼

Step1 : 始臣之解牛之時,所見無非牛者

首先 babel-plugin-import 是為了解決在打包過程中把項目中引用到的外部組件或功能庫全量打包卿泽,從而導致編譯結(jié)束后包容量過大的問題莺债,如下圖所示:

pag1.png

babel-plugin-import 插件源碼由兩個文件構(gòu)成

  • Index 文件即是插件入口初始化的文件,也是筆者在 Step1 中著重說明的文件
  • Plugin 文件包含了處理各種 AST 節(jié)點的方法集签夭,以 Class 形式導出

先來到插件的入口文件 Index :

import Plugin from './Plugin';
export default function({ types }) {
  let plugins = null;
  /**
   *  Program 入口初始化插件 options 的數(shù)據(jù)結(jié)構(gòu)
   */
  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 齐邦,并且有一個默認導出函數(shù),函數(shù)的參數(shù)是被解構(gòu)出的名叫 types 的參數(shù)第租,它是從 babel 對象中被解構(gòu)出來的措拇,types 的全稱是 @babel/types,用于處理 AST 節(jié)點的方法集慎宾。以這種方式引入后丐吓,我們不需要手動引入 @babel/types
進入函數(shù)后可以看見觀察者( visitor ) 中初始化了一個 AST 節(jié)點 Program趟据,這里對 Program 節(jié)點的處理使用完整插件結(jié)構(gòu)券犁,有進入( enter )與離開( exit )事件,且需注意:

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

這里可能有同學會問 Program 節(jié)點是什么粘衬?見下方 const a = 1 對應的 AST 樹 ( 簡略部分參數(shù) )

{
  "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 相當于一個根節(jié)點,一個完整的源代碼樹。一般在進入該節(jié)點的時候進行初始化數(shù)據(jù)之類的操作稚新,也可理解為該節(jié)點先于其他節(jié)點執(zhí)行勘伺,同時也是最晚執(zhí)行 exit 的節(jié)點,在 exit 時也可以做一些”善后“的工作褂删。 既然 babel-plugin-importProgram 節(jié)點處寫了完整的結(jié)構(gòu)飞醉,必然在 exit 時也有非常必要的事情需要處理,關(guān)于 exit 具體是做什么的我們稍后進行討論笤妙。
我們先看 enter 冒掌,這里首先用 enter 形參 state 結(jié)構(gòu)出用戶制定的插件參數(shù),驗證必填的 libraryName [庫名稱] 是否存在蹲盘。Index 文件引入的 Plugin 是一個 class 結(jié)構(gòu),因此需要對 Plugin 進行實例化膳音,并把插件的所有參數(shù)與 @babel/types 全部傳進去召衔,關(guān)于 Plugin 類會在下文中進行闡述。
接著調(diào)用了 applyInstance 函數(shù):

export default function({ types }) {
  let plugins = null;
  /**
   * 從類中繼承方法并利用 apply 改變 this 指向祭陷,并傳遞 path , state 參數(shù)
   */
  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);
    },
      ...
   }
}

此函數(shù)的主要目的是繼承 Plugin 類中的方法苍凛,且需要三個參數(shù)

  1. method(String):你需要從 Plugin 類中繼承出來的方法名稱
  2. args:(Arrray<T>):[ Path, State ]
  3. PluginPass( Object):內(nèi)容和 State 一致,確保傳遞內(nèi)容為最新的 State

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

現(xiàn)在進入 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; // 組件名是否轉(zhuǎn)換以“-”鏈接的形式
    this.transformToDefaultImport = transformToDefaultImport || true; // 處理默認導入
    this.customName = normalizeCustomName(customName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
    this.customStyleName = normalizeCustomName(customStyleName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
    this.camel2UnderlineComponentName = camel2UnderlineComponentName; // 處理成類似 time_picker 的形式
    this.fileName = fileName || ''; // 鏈接到具體的文件悠栓,例如 antd/lib/button/[abc.js]
    this.types = types; // babel-types
    this.pluginStateKey = `importPluginState${index}`;
  }
  ...
}

在入口文件實例化 Plugin 已經(jīng)把插件的參數(shù)通過 constructor 后被初始化完畢啦,除了 libraryName 以外其他所有的值均有相應默認值按价,值得注意的是參數(shù)列表中的 customeName 與 customStyleName 可以接收一個函數(shù)或者一個引入的路徑惭适,因此需要通過 normalizeCustomName 函數(shù)進行統(tǒng)一化處理。

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

此函數(shù)就是用來處理當參數(shù)是路徑時楼镐,進行轉(zhuǎn)換并取出相應的函數(shù)癞志。如果處理后 customeNameExports 仍然不是函數(shù)就導入 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 導入的內(nèi)容)
    pluginState.selectedMethods = Object.create(null); // 存放經(jīng)過 importMethod 之后的節(jié)點
    pluginState.pathsToRemove = []; // 存儲需要刪除的節(jié)點
    /**
     * 初始化之后的 state
     * state:{
     *    importPluginState「Number」: {
     *      specified:{},
     *      libraryObjs:{},
     *      select:{},
     *      pathToRemovw:[]
     *    },
     *    opts:{
     *      ...
     *    },
     *    ...
     * }
     */
  }
   ...
}

ProgramEnter 中通過 getPluginState**初始化 state 結(jié)構(gòu)中的 importPluginState 對象,getPluginState 函數(shù)在后續(xù)操作中出現(xiàn)非常頻繁秉宿,讀者在此需要留意此函數(shù)的作用戒突,后文不再對此進行贅述。
但是為什么需要初始化這么一個結(jié)構(gòu)呢蘸鲸?這就牽扯到插件的思路妖谴。正像開篇流程圖所述的那樣 ,babel-plugin-import 具體實現(xiàn)按需加載思路如下:經(jīng)過 import 節(jié)點后收集節(jié)點數(shù)據(jù),然后從所有可能引用到 import 綁定的節(jié)點處執(zhí)行按需加載轉(zhuǎn)換方法膝舅。state 是一個引用類型嗡载,對其進行操作會影響到后續(xù)節(jié)點的 state 初始值,因此用 Program 節(jié)點仍稀,在 enter 的時候就初始化這個收集依賴的對象洼滚,方便后續(xù)操作。負責初始化 state 節(jié)點結(jié)構(gòu)與取數(shù)據(jù)的方法正是 getPluginState技潘。
這個思路很重要遥巴,并且貫穿后面所有的代碼與目的,請讀者務必理解再往下閱讀享幽。

Step2: 三年之后,未嘗見全牛也

借由 Step1铲掐,現(xiàn)在已經(jīng)了解到插件以 Program 為出發(fā)點繼承了 ProgramEnter 并且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分值桩,請回到 Step1 仔細消化下內(nèi)容再繼續(xù)閱讀摆霉。
首先,我們再回到外圍的 Index 文件奔坟,之前只在觀察者模式中注冊了 Program 的節(jié)點携栋,沒有其他 AST 節(jié)點入口,因此至少還需注入 import 語句的 AST 節(jié)點類型 ImportDeclaration

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) {
      ...
  }
  const Program = {
      ...
   }
  const methods = [ // 注冊 AST type 的數(shù)組
    'ImportDeclaration' 
  ]
  
  const ret = {
    visitor: { Program }, 
  };
  
  // 遍歷數(shù)組咳秉,利用 applyInstance 繼承相應方法
  for (const method of methods) { 
    ret.visitor[method] = function() {
      applyInstance(method, arguments, ret.visitor);
    };
  }
   
}

創(chuàng)建一個數(shù)組并將 ImportDeclaration 置入婉支,經(jīng)過遍歷調(diào)用 applyInstance_ _和 Step1 介紹同理,執(zhí)行完畢后 visitor 會變成如下結(jié)構(gòu)

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

現(xiàn)在回歸 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 處初始化的結(jié)構(gòu)
    if (value === libraryName) { //  AST 庫名與插件參數(shù)名是否一致,一致就進行依賴收集
      node.specifiers.forEach(spec => {
        if (types.isImportSpecifier(spec)) { // 不滿足條件說明 import 是名稱空間引入或默認引入
          pluginState.specified[spec.local.name] = spec.imported.name; 
          // 保存為:{ 別名 :  組件名 } 結(jié)構(gòu)
        } else {
          pluginState.libraryObjs[spec.local.name] = true;// 名稱空間引入或默認引入的值設置為 true
        }
      });
      pluginState.pathsToRemove.push(path); // 取值完畢的節(jié)點添加進預刪除數(shù)組
    }
  }
  ...
}

ImportDeclaration 會對 import 中的依賴字段進行收集霎奢,如果是名稱空間引入或者是默認引入就設置為 { 別名 :true }户誓,解構(gòu)導入就設置為 { 別名 :組件名 } 。getPluginState 方法在 Step1 中已經(jīng)進行過說明幕侠。關(guān)于 import 的 AST 節(jié)點結(jié)構(gòu) 用 babel-plugin 實現(xiàn)按需加載 中有詳細說明帝美,本文不再贅述。執(zhí)行完畢后 pluginState 結(jié)構(gòu)如下

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

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

這下 state.importPluginState 結(jié)構(gòu)已經(jīng)收集到了后續(xù)幫助節(jié)點進行轉(zhuǎn)換的所有依賴信息晤硕。
目前已經(jīng)萬事俱備悼潭,只欠東風。東風是啥舞箍?是能讓轉(zhuǎn)換 import 工作開始的 action舰褪。在 用 babel-plugin 實現(xiàn)按需加載 中收集到依賴的同時也進行了節(jié)點轉(zhuǎn)換與刪除舊節(jié)點。一切工作都在 ImportDeclaration 節(jié)點中發(fā)生疏橄。而 babel-plugin-import 的思路是尋找一切可能引用到 Import 的 AST 節(jié)點占拍,對他們?nèi)窟M行處理略就。有部分讀者也許會直接想到去轉(zhuǎn)換引用了 import 綁定的 JSX 節(jié)點,但是轉(zhuǎn)換 JSX 節(jié)點的意義不大晃酒,因為可能引用到 import 綁定的 AST 節(jié)點類型 ( type ) 已經(jīng)夠多了表牢,所有應盡可能的縮小需要轉(zhuǎn)換的 AST 節(jié)點類型范圍。而且 babel 的其他插件會將我們的 JSX 節(jié)點進行轉(zhuǎn)換成其他 AST type贝次,因此能不考慮 JSX 類型的 AST 樹崔兴,可以等其他 babel 插件轉(zhuǎn)換后再進行替換工作。其實下一步可以開始的入口有很多蛔翅,但還是從咱最熟悉的 React.createElement 開始敲茄。

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

// 轉(zhuǎn)換后

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

JSX 轉(zhuǎn)換后 AST 類型為 CallExpression(函數(shù)執(zhí)行表達式),結(jié)構(gòu)如下所示山析,熟悉結(jié)構(gòu)后能方便各位同學對之后步驟有更深入的理解堰燎。

{
  "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 節(jié)點處,繼續(xù)轉(zhuǎn)換流程笋轨。

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);
    // 處理一般的調(diào)用表達式
    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插件返回節(jié)點類型與名稱
      }
      return arg;
    });
  } 
  ...
}

可以看見源碼調(diào)用了importMethod 兩次,此函數(shù)的作用是觸發(fā) import 轉(zhuǎn)換成按需加載模式的 action翩腐,并返回一個全新的 AST 節(jié)點。因為 import 被轉(zhuǎn)換后膏燃,之前我們?nèi)斯ひ氲慕M件名稱會和轉(zhuǎn)換后的名稱不一樣茂卦,因此 importMethod 需要把轉(zhuǎn)換后的新名字(一個 AST 結(jié)構(gòu))返回到我們對應 AST 節(jié)點的對應位置上,替換掉老組件名组哩。函數(shù)源碼稍后會進行詳細分析等龙。
回到一開始的問題,為什么 CallExpression 需要調(diào)用 importMethod 函數(shù)伶贰?因為這兩處表示的意義是不同的蛛砰,CallExpression 節(jié)點的情況有兩種:

  1. 剛才已經(jīng)分析過了,這第一種情況是 JSX 代碼經(jīng)過轉(zhuǎn)換后的 React.createElement
  2. 我們使用函數(shù)調(diào)用一類的操作代碼的 AST 也同樣是 CallExpression 類型黍衙,例如:
import lodash from 'lodash'

lodash(some values)

因此在 CallExpression 中首先會判斷 node.callee 值是否是 Identifier 泥畅,如果正確則是所述的第二種情況,直接進行轉(zhuǎn)換琅翻。若否位仁,則是 React.createElement 形式,遍歷 React.createElement 的三個參數(shù)取出 name方椎,再判斷 name 是否是先前 state.pluginState 收集的 import 的 name聂抢,最后檢查 name 的作用域情況,以及追溯 name 的綁定是否是一個 import 語句棠众。這些判斷條件都是為了避免錯誤的修改函數(shù)原本的語義琳疏,防止錯誤修改因閉包等特性的塊級作用域中有相同名稱的變量。如果上述條件均滿足那它肯定是需要處理的 import 引用了。讓其繼續(xù)進入importMethod 轉(zhuǎn)換函數(shù)空盼,importMethod 需要傳遞三個參數(shù):組件名书幕,F(xiàn)ile(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 // 根據(jù)參數(shù)轉(zhuǎn)換組件名稱
        ? transCamel(methodName, '_')
        : this.camel2DashComponentName
        ? transCamel(methodName, '-')
        : methodName;
       /**
       * 轉(zhuǎn)換路徑我注,優(yōu)先按照用戶定義的customName進行轉(zhuǎn)換按咒,如果沒有提供就按照常規(guī)拼接路徑
       */
      const path = winPath(
        this.customName
          ? this.customName(transformedMethodName, file)
          : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
      );
      /**
       * 根據(jù)是否是默認引入對最終路徑做處理,并沒有對namespace做處理
       */
      pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
        ? addDefault(file.path, path, { nameHint: methodName })
        : addNamed(file.path, methodName, path);
      if (this.customStyleName) { // 根據(jù)用戶指定的路徑引入樣式文件
        const stylePath = winPath(this.customStyleName(transformedMethodName));
        addSideEffect(file.path, `${stylePath}`);
      } else if (this.styleLibraryDirectory) { // 根據(jù)用戶指定的樣式目錄引入樣式文件
        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') { // 若是函數(shù),根據(jù)返回值生成引入
        const stylePath = style(path, file);
        if (stylePath) {
          addSideEffect(file.path, stylePath);
        }
      }
    }
    return { ...pluginState.selectedMethods[methodName] };
  }
  ...
}

進入函數(shù)后但骨,先別著急看代碼励七,注意這里引入了兩個包:path.join 和 @babel/helper-module-imports ,引入 join 是為了處理按需加載路徑快捷拼接的需求奔缠,至于 import 語句轉(zhuǎn)換掠抬,肯定需要產(chǎn)生全新的 import AST 節(jié)點實現(xiàn)按需加載,最后再把老的 import 語句刪除校哎。而新的 import 節(jié)點使用 babel 官方維護的 @babel/helper-module-imports 生成×讲ǎ現(xiàn)在繼續(xù)流程,首先無視一開始的 if 條件語句闷哆,稍后會做說明腰奋。再捋一捋 import 處理函數(shù)中需要處理的幾個環(huán)節(jié):

  • 對引入的組件名稱進行修改,默認轉(zhuǎn)換以“-”拼接單詞的形式抱怔,例如:DatePicker 轉(zhuǎn)換為 date-picker劣坊,處理轉(zhuǎn)換的函數(shù)是 transCamel。
function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1); // 先轉(zhuǎn)換成小駝峰,以便正則獲取完整單詞
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`); 
  // 例 datePicker屈留,正則抓取到P后局冰,在它前面加上指定的symbol符號
}
  • 轉(zhuǎn)換到組件所在的具體路徑,如果插件用戶給定了自定義路徑就使用 customName 進行處理灌危,babel-plugin-import 為什么不提供對象的形式作為參數(shù)康二?因為 customName 修改是以 transformedMethodName 值作為基礎并將其傳遞給插件使用者,如此設計就可以更精確的匹配到需要按需加載的路徑勇蝙。處理這些動作的函數(shù)是 withPath沫勿,withPath 主要兼容 Linux 操作系統(tǒng),將 Windows 文件系統(tǒng)支持的 '' 統(tǒng)一轉(zhuǎn)換為 '/'浅蚪。
function winPath(path) {
  return path.replace(/\\/g, '/'); 
  // 兼容路徑: windows默認使用‘\’,也支持‘/’藕帜,但linux不支持‘\’,遂統(tǒng)一轉(zhuǎn)換成‘/’
}
  • 對 transformToDefaultImport 進行判斷惜傲,此選項默認為 true洽故,轉(zhuǎn)換后的 AST 節(jié)點是默認導出的形式,如果不想要默認導出可以將 transformToDefaultImport 設置為 false盗誊,之后便利用 @babel/helper-module-imports 生成新的 import 節(jié)點时甚,最后**函數(shù)的返回值就是新 import 節(jié)點的 default Identifier隘弊,替換掉調(diào)用 importMethod 函數(shù)的節(jié)點,從而把所有引用舊 import 綁定的節(jié)點替換成最新生成的 import AST 的節(jié)點荒适。

    pag2.png
  • 最后梨熙,根據(jù)用戶是否開啟 style 按需引入與 customStyleName 是否有 style 路徑額外處理,以及 styleLibraryDirectory(style 包路徑)等參數(shù)處理或生成對應的 css 按需加載節(jié)點刀诬。

到目前為止一條最基本的轉(zhuǎn)換線路已經(jīng)轉(zhuǎn)換完畢了咽扇,相信大家也已經(jīng)了解了按需加載的基本轉(zhuǎn)換流程,回到 importMethod 函數(shù)一開始的if 判斷語句陕壹,這與我們將在 step3 中的任務息息相關(guān)≈视現(xiàn)在就讓我們一起進入 step3。

Step3: 方今之時,臣以神遇而不以目視,官知止而神欲行

在 step3 中會進行按需加載轉(zhuǎn)換最后的兩個步驟:

  1. 引入 import 綁定的引用肯定不止 JSX 語法糠馆,還有其他諸如嘶伟,三元表達式,類的繼承又碌,運算九昧,判斷語句,返回語法等等類型毕匀,我們都得對他們進行處理铸鹰,確保所有的引用都綁定到最新的 import,這也會導致importMethod 函數(shù)被重新調(diào)用皂岔,但我們肯定不希望 import 函數(shù)被引用了 n 次掉奄,生成 n 個新的 import 語句,因此才會有先前的判斷語句凤薛。
  2. 一開始進入 ImportDeclaration 收集信息的時候我們只是對其進行了依賴收集工作,并沒有刪除節(jié)點诞仓。并且我們尚未補充 Program 節(jié)點 exit 所做的 action

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

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
 */

如果插件的選項中沒有關(guān)閉 transformToDefaultImport 墅拭,這里會調(diào)用 importMethod 方法并返回@babel/helper-module-imports 給予的新節(jié)點值活玲。否則會判斷當前值是否是收集到 import 信息中的一部分以及是否是文件作用域下的全局變量,通過獲取作用域查看其父節(jié)點的類型是否是 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
 */

本例中出現(xiàn) buildDeclaratorHandler 方法,主要確保傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用后便進入 importMethod 進行轉(zhuǎn)換后返回新節(jié)點覆蓋原屬性唤蔗。

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(數(shù)組表達式),接口如下所示

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

本例的處理和剛才的其他節(jié)點不太一樣涯穷,因為數(shù)組的 Element 本身就是一個數(shù)組形式,并且我們需要轉(zhuǎn)換的引用都是數(shù)組元素藏雏,因此這里傳遞的 props 就是類似 [0, 1, 2, 3] 的純數(shù)組,方便后續(xù)從 elements 中進行取數(shù)據(jù)赚瘦。這里進行具體轉(zhuǎn)換的方法是 buildExpressionHandler,在后續(xù)的 AST 節(jié)點處理中將會頻繁出現(xiàn)

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 進行轉(zhuǎn)換蚤告,和之前的 buildDeclaratorHandler 方法差不多杜恰,只是 props 是數(shù)組形式

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 方法進行轉(zhuǎn)換

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 方法進行轉(zhuǎn)換。

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){ }
 */

這個節(jié)點相對比較特殊掘而,但筆者不明白為什么要調(diào)用兩次 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 節(jié)點后簇宽,刪除掉原本的 import 導入,由于我們已經(jīng)把舊 import 的 path 保存在 pluginState.pathsToRemove 中譬嚣,最佳的刪除的時機便是 ProgramExit 拜银,使用 path.remove() 刪除遭垛。


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

恭喜各位堅持看到現(xiàn)在的讀者锯仪,已經(jīng)到最后一步啦,把我們所處理的所有 AST 節(jié)點類型注冊到觀察者中

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) { ... }
  const Program = { ... }
                   
  // 補充注冊 AST type 的數(shù)組
  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) { ... }
   
}

到此已經(jīng)完整分析完 babel-plugin-import 的整個流程小腊,讀者可以重新捋一捋處理按需加載的整個處理思路秩冈,其實拋去細節(jié)入问,主體邏輯還是比較簡單明了的稀颁。

思考

筆者在進行源碼與單元測試的閱讀后匾灶,發(fā)現(xiàn)插件并沒有對 Switch 節(jié)點進行轉(zhuǎn)換,遂向官方倉庫提了 PR,目前已經(jīng)被合入 master 分支张肾,讀者有任何想法锚扎,歡迎在評論區(qū)暢所欲言驾孔。
筆者主要補了 SwitchStatement 惯疙,SwitchCase 與兩個 AST 節(jié)點處理霉颠。
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);
}

總結(jié)

這是筆者第一次寫源碼解析的文章蒿偎,也因筆者能力有限怀读,如果有些邏輯闡述的不夠清晰菜枷,或者在解讀過程中有錯誤的,歡迎讀者在評論區(qū)給出建議或進行糾錯≡啦t,F(xiàn)在 babel 其實也出了一些 API 可以更加簡化 babel-plugin-import 的代碼或者邏輯寝优,例如:path.replaceWithMultiple 枫耳,但源碼中一些看似多余的邏輯一定是有對應的場景迁杨,所以才會被加以保留。此插件經(jīng)受住了時間的考驗捷沸,同時對有需要開發(fā) babel-plugin 的讀者來說痒给,也是一個非常好的事例骏全。不僅如此姜贡,對于功能的邊緣化處理以及操作系統(tǒng)的兼容等細節(jié)都有做完善的處理楼咳。如果僅僅需要使用 babel-plugin-import 烛恤,此文展示了一些在 babel-plugin-import 文檔中未暴露的 API缚柏,也可以幫助插件使用者實現(xiàn)更多擴展功能宾添,因此筆者推出了此文,希望能幫助到各位同學壮莹。

作者信息

chenfeng.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市恶座,隨后出現(xiàn)的幾起案子跨琳,更是在濱河造成了極大的恐慌桐罕,老刑警劉巖功炮,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件薪伏,死亡現(xiàn)場離奇詭異嫁怀,居然都是意外死亡,警方通過查閱死者的電腦和手機萝招,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來召噩,“玉大人具滴,你說我怎么就攤上這事≈懿洌” “怎么了疲恢?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵棚愤,是天一觀的道長杂数。 經(jīng)常有香客問我揍移,道長,這世上最難降的妖魔是什么踏施? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任读规,我火速辦了婚禮束亏,結(jié)果婚禮上阵具,老公的妹妹穿的比我還像新娘阳液。我一直安慰自己帘皿,他們只是感情好,可當我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布虽填。 她就那樣靜靜地躺著斋日,像睡著了一般恶守。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上庸毫,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天岔绸,我揣著相機與錄音盒揉,去河邊找鬼兑徘。 笑死挂脑,一個胖子當著我的面吹牛崭闲,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播橄仍,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼侮繁,長吁一口氣:“原來是場噩夢啊……” “哼宪哩!你這毒婦竟也來了锁孟?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤涧至,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后哑了,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體弱左,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡拆火,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年们镜,在試婚紗的時候發(fā)現(xiàn)自己被綠了模狭。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片踩衩。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡驱富,死狀恐怖褐鸥,靈堂內(nèi)的尸體忽然破棺而出晶疼,到底是詐尸還是另有隱情,我是刑警寧澤锭吨,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布零如,位于F島的核電站考蕾,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏蚯窥。R本人自食惡果不足惜拦赠,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一荷鼠、第九天 我趴在偏房一處隱蔽的房頂上張望允乐。 院中可真熱鬧削咆,春花似錦拨齐、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蹂匹。三九已至限寞,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間计雌,已是汗流浹背凿滤。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工翁脆, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留反番,地道東北人。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓校读,卻偏偏與公主長得像,于是被迫代替她去往敵國和親蛾洛。 傳聞我的和親對象是個殘疾皇子轧膘,可洞房花燭夜當晚...
    茶點故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容