按需載入原理分析

李永寧發表於2022-02-09

簡介

瞭解 Babel 外掛基本知識,理解按需載入的內部原理,再也不怕面試官問我按需載入的實現原理了。


import { Button } from 'element-ui'

怎麼就變成了

var Button = require('element-ui/lib/button.js')
require('element-ui/lib/theme-chalk/button.css')

為了找到答案,分兩步來進行,這也是自己學習的過程:

  1. babel 外掛入門,編寫 babel-plugin-lyn 外掛

  2. 解讀 babel-plugin-component 原始碼,從原始碼中找到答案

babel 外掛入門

這一步我們去編寫一個babel-plugin-lyn外掛,這一步要達到的目的是:

  • 理解babel外掛做了什麼

  • 學會分析AST語法樹

  • 學會使用基本的API

  • 能編寫一個簡單的外掛,做基本的程式碼轉換

有了以上基礎我們就可以嘗試去閱讀babel-plugin-component原始碼,從原始碼中找到我們想要的答案

簡單介紹

Babel是一個JavaScript編譯器,是一個從原始碼到原始碼的轉換編譯器,你為Babel提供一些JavaScript程式碼,Babel按照要求更改這些程式碼,然後返回給你新生成的程式碼。

程式碼轉換(更改)的過程中是藉助AST (抽象語法樹)來完成的,通過改變AST節點資訊來達到轉換程式碼的目的,到這裡其實也就可以簡單回答出我們在目標中提到的程式碼轉化是怎麼完成的 ?,其實就是Babel讀取我們的原始碼,將其轉換為AST,分析AST,更改AST的某些節點資訊,然後生成新的程式碼,就完成了轉換過程,而具體是怎麼更改節點資訊,就需要去babel-plugin-component原始碼中找答案了

Babel的世界中,我們要更改某個節點的時候,就需要去訪問(攔截)該節點,這裡採用了訪問者模式訪問者是一個用於AST遍歷的跨語言的模式,加單的說就是定義了一個物件,用於在樹狀結構獲取具體節點的的方法,這些節點其實就是AST節點,可以在 AST Explorer 中檢視程式碼的AST資訊,這個我們在編寫程式碼的時候會多次用到

babel-plugin-lyn

接下來編寫一個自己的外掛

初始化專案目錄

mkdir babel-plugin && cd babel-plugin && npm init -y

新建外掛目錄

在專案的node_modules目錄新建一個資料夾,作為自己的外掛目錄

mkdir -p node_modules/babel-plugin-lyn

在外掛目錄新建 index.js

touch index.js

建立需要被處理的 JS 程式碼

在專案根目錄下建立 index.js,編寫如下程式碼

let a = 1
let b = 1

很簡單吧,我們需要將其轉換為:

const aa = 1
const bb = 1

接下來進行外掛編寫

babel-plugin-lyn/index.js

基本結構
// 函式會有一個 babelTypes 引數,我們結構出裡面的 types
// 程式碼中需要用到它的一些方法,方法具體什麼意思可以參考 
// https://babeljs.io/docs/en/next/babel-types.html
module.exports = function ({ types: bts }) {
  // 返回一個有 visitor 的物件,這是規定,然後在 visitor 中編寫獲取各個節點的方法
  return {
    visitor: {
        ...
    }
  }
}

分析原始碼

有了外掛的基本結構之後,接下來我們需要分析我們的程式碼,它在AST中長什麼樣

AST Explorer

如下圖所示:

用滑鼠點選需要更改的地方,比如我們要改變數名,則點選以後會看到右側的AST tree展開並高亮了一部分,高亮的這部分就是我們要改的變數aAST節點,我們知道它是一個Identifier型別的節點,所以我們就在visitor中編寫一個Identifier方法

module.exports = function ({ types: bts }) {
    return {
        visitor: {
            /**
             * 負責處理所有節點型別為 Identifier 的 AST 節點
             * @param {*} path AST 節點的路徑資訊,可以簡單理解為裡面放了 AST 節點的各種資訊
             * @param {*} state 有一個很重要的 state.opts,是 .babelrc 中的配置項
            */
            Identifier (path, state) {
                // 節點資訊
                const node = path.node
                // 從節點資訊中拿到 name 屬性,即 a 和 b
                const name = node.name
                // 如果配置項中存在 name 屬性,則將 path.node.name 的值替換為配置項中的值
                if (state.opts[name]) {
                    path.node.name = state.opts[name]
                }
            }
        }
    }
}

這裡我們用到了外掛的配置資訊,接下來我們在.babelrc中編寫外掛的配置資訊

.babelrc
{
  "plugins": [
    [
      "lyn",
      {
        "a": "aa",
        "b": "bb"
      }
    ]
  ]
}

這個配置項是不是很熟悉?和babel-plugin-component的及其相似,lyn表示 babel 外掛的名稱,後面的物件就是我們的配置項

輸出結果
首先安裝 babel-cli

這裡有一點需要注意,在安裝 babel-cli 之前,把我們編寫的外掛備份,不然執行下面的安裝時,我們的外掛目錄會被刪除,原因沒有深究,應該是我們的外掛不是一個有效的 npm 包,所以會被清除掉

npm i babel-cli -D
編譯
npx babel index.js

得到如下輸出:

let aa = 1;
let bb = 1;

說明我們的外掛已經生效,且剛才的思路是沒問題的,轉譯程式碼其實就是通過更改 AST 節點的資訊即可

let -> const

我們剛才已經完成了變數的轉譯,接下來再把let關鍵字變成const

按照剛才的方法,我們需要更改關鍵字let,將游標移動到let上,發現AST Tree高亮部分變了,可以看到letAST節點型別為VariableDeclaration,且我們要改的就是kind屬性,好了,開始寫程式碼

module.exports = function ({ types: bts }) {
    return {
        visitor: {
            Identifier (path, state) {
                ...
            },
            // 處理變數宣告關鍵字
            VariableDeclaration (path, state) {
                // 這次就沒從配置檔案讀了,來個簡單的,直接改
                path.node.kind = 'const'
            }
        }
    }
}
編譯
npx babel index.js

得到如下輸出:

const aa = 1;
const bb = 1;

到這裡我們第一階段的入門就結束了,是不是感覺很簡單??是的,這個入門示例真的很簡單,但是真的編寫一個可用於業務Babel外掛以及其中的涉及到的AST編譯原理是非常複雜的。但是這個入門示例已經可以支援我們去分析babel-plugin-component外掛的原始碼原理了。

完整程式碼
// 函式會有一個 babelTypes 引數,我們結構出裡面的 types
// 程式碼中需要用到它的一些方法,方法具體什麼意思可以參考 
// https://babeljs.io/docs/en/next/babel-types.html
module.exports = function ({ types: bts }) {
  // 返回一個有 visitor 的物件,這是規定,然後在 visitor 中編寫獲取各個節點的方法
  return {
    visitor: {
      /**
       * 負責處理所有節點型別為 Identifier 的 AST 節點
       * @param {*} path AST 節點的路徑資訊,可以簡單理解為裡面放了 AST 節點的各種資訊
       * @param {*} state 有一個很重要的 state.opts,是 .babelrc 中的配置項
       */
      Identifier (path, state) {
        // 節點資訊
        const node = path.node
        // 從節點資訊中拿到 name 屬性,即 a 和 b
        const name = node.name
        // 如果配置項中存在 name 屬性,則將 path.node.name 的值替換為配置項中的值
        if (state.opts[name]) {
          path.node.name = state.opts[name]
        }
      },
      // 處理變數宣告關鍵字
      VariableDeclaration (path, state) {
        // 這次就沒從配置檔案讀了,來個簡單的,直接改
        path.node.kind = 'const'
      }
    }
  }
}

babel-plugin-component 原始碼分析

目標分析

在進行原始碼閱讀之前我們先分析一下我們的目標,帶著目標去閱讀,效果會更好

原始碼

// 全域性引入
import ElementUI from 'element-ui'
Vue.use(ElementUI)
// 按需引入
import { Button, Checkbox } from 'element-ui'
Vue.use(Button)
Vue.component(Checkbox.name, Checkbox)

上面就是我們使用element-ui元件庫的兩種方式,全域性引入和按需引入

目的碼

// 全域性引入
var ElementUI = require('element-ui/lib')
require('element-ui/lib/theme-chalk/index.css')
Vue.use(ElementUI)
// 按需引入
var Button = require('element-ui/lib/button.js')
require('element-ui/lib/theme-chalk/button.css')
var Checkbox = require('element-ui/lib/checkbox.js')
require('element-ui/lib/theme-chalk/checkbox.css')
Vue.use(Button)
Vue.component(Checkbox.name, Checkbox)

以上就是原始碼和轉譯後的目的碼,我們可以將他們分別複製到 AST Explorer 中檢視 AST Tree的資訊,進行分析

全域性引入

從上圖中可以看出,這兩條語句總共是由兩種型別的節點組成,import對應的ImportDeclaration的節點,Vue.use(ElementUI)對應於ExpressionStatement型別的節點

可以看到import ElementUI from 'element-ui'對應到AST中,from後面的element-ui對應於source.value,且節點型別為StringLiteral

import ElementUI from 'element-ui'中的ElementUI對應於ImportDefaultSpecifier型別的節點,是個預設匯入,變數對應於Indentifier節點的name屬性

6

Vue.use(ElementUI)是個宣告式的語句,對應於ExpressionStatement的節點,可以看到引數ElementUI放到了arguments部分

按需引入

可以看到body有三個子節點,一個ImportDeclaration,兩個ExpressionStatement,和我們的程式碼一一對應

import語句中對於from後面的部分上面的全域性是一樣的,都是在source中,是個Literal型別的節點

可以看到import後面的內容變了,上面的全域性引入是一個ImportDefaultDeclaration型別的節點,這裡的按需載入是一個ImportDeclaration節點,且引入的內容放在specifiers物件中,每個元件(Button、Checkbox)是一個ImportSpecifier,裡面定義了importedlocalIdentifier,而我們的變數名稱(Button、Checkbox)放在name屬性上

剩下的Vue.use(Button)Vue.component(Checkbox.name, Checkbox)和上面全域性引入類似,有一點區別是Vue.component(Checkbox.name, Checkbox)arguments有兩個元素

經過剛開始的基礎入門以及上面對於AST的一通分析,我們其實已經大概可以猜出來從原始碼目的碼這個轉換過程中發生了些什麼,其實就是在visitor物件上設定響應的方法(節點型別),然後去處理符合要求的節點,將節點上對應的屬性更改為目的碼上響應的值,把原始碼目的碼都複製到 AST Explorer 中檢視,就會發現,相應節點之間的差異(改動)就是babel-plugin-component做的事情,接下來我們進入原始碼尋找答案。

原始碼分析

直接在剛才的專案中執行

npm i babel-plugin-component -D

安裝 babel-plugin-component,安裝完成,在node_modules目錄找babel-plugin-component目錄

image-20220207175041085

看程式碼是隨時對照AST Explorer和打log確認

.babelrc

{
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

入口,index.js

// 預設就是用於element-ui元件庫的按需載入外掛
module.exports = require('./core')('element-ui');

核心,core.js

原始碼閱讀提示

  • 清楚讀原始碼的目的是什麼,為了解決什麼樣的問題
  • 一定要有相關的基礎知識,比如上面的 babel 入門,知道入口位置在 visitor,以及在 visitor 中找那些方法去讀
  • 讀過程中一定要勤動手,寫註釋,打 log,這樣有助於提高思路
  • 閱讀這篇原始碼,一定要會用 AST Explorer 分析和對比我們的原始碼 和 目的碼
  • 下面的原始碼幾乎每行都加了註釋,大家按照步驟自己下一套原始碼,可以對比著看,一遍看不懂,看兩遍,書讀三遍其義自現,真的,當然,讀的過程中有不懂的地方需要查一查
/**
 * 判斷 obj 的型別
 * @param {*} obj 
 */
function _typeof(obj) { 
  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { 
    _typeof = function _typeof(obj) { 
      return typeof obj; 
    }; 
  } else { 
    _typeof = function _typeof(obj) { 
      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 
    }; 
  } 
  return _typeof(obj); 
}

// 提供了一些方法,負責生成 import 節點
var _require = require('@babel/helper-module-imports'),
  addSideEffect = _require.addSideEffect,
  addDefault = _require.addDefault;

// node.js 的內建模組,處理 路徑資訊
var resolve = require('path').resolve;

// node.js 內建模組,判斷檔案是否存在
var isExist = require('fs').existsSync;

// 快取變數, cache[libraryName] = 1 or 2
var cache = {};
// 快取樣式庫的樣式路徑,cachePath[libraryName] = ''
var cachePath = {};
// importAll['element-ui/lib'] = true,說明存在預設匯入
var importAll = {};

module.exports = function core(defaultLibraryName) {
  return function (_ref) {
    // babelTypes,提供了一系列方法供使用,官網地址:https://babeljs.io/docs/en/next/babel-types.html
    var types = _ref.types;
    // 儲存所有的 ImportSpecifier,即按需引入的元件,specified = { Button: 'Button', Checkbox: 'Checkbox' }
    var specified;
    // 儲存所有全域性引入的庫,libraryObjs = { ElementUI: 'element-ui' }
    var libraryObjs;
    // 儲存已經引入(處理)的方法(元件),
    // selectedMethods = {
    //   ElementUI: { type: 'Identifier', name: '_ElementUI' },
    //   Button: { type: 'Identifier', name: '_Button' },
    //   Checkbox: { type: 'Identifier', name: '_Checkbox' }
    // }
    var selectedMethods;
    // 引入的模組和庫之間的對應關係,moduleArr = { Button: 'element-ui', Checkbox: 'element-ui' }
    var moduleArr;

    // 將駝峰命名轉換為連字元命名
    function parseName(_str, camel2Dash) {
      if (!camel2Dash) {
        return _str;
      }

      var str = _str[0].toLowerCase() + _str.substr(1);

      return str.replace(/([A-Z])/g, function ($1) {
        return "-".concat($1.toLowerCase());
      });
    }

    /**
     * 該方法負責生成一些 AST 節點,這些節點的資訊是根據一堆配置項來的,這對配置項就是在告訴 AST 節點每個元件的路徑資訊,
     * 比如 'element-ui/lib/button.js' 和 'element-ui/lib/theme-chalk/button.css'
     * @param {*} methodName Button、element-ui
     * @param {*} file 一拖不想看的物件資訊
     * @param {*} opts .babelrc 配置項
     */
    function importMethod(methodName, file, opts) {
      // 如果 selectedMethods 中沒有 Butotn、element-ui 則進入 if ,否則直接 return selectedMethods[methodName],說明該方法(元件)已經被處理過了
      if (!selectedMethods[methodName]) {
        var options;
        var path;

        // 不用管
        if (Array.isArray(opts)) {
          options = opts.find(function (option) {
            return moduleArr[methodName] === option.libraryName || libraryObjs[methodName] === option.libraryName;
          }); // eslint-disable-line
        }

        /**
         * 以下是一堆配置項
         */
        // 傳遞進來的配置
        options = options || opts;
        var _options = options,
          // 配置的 libDir
          _options$libDir = _options.libDir,
          // 沒有配置,就預設為 lib, /element-ui/lib/button.js 中的 lib 就是這麼來的
          libDir = _options$libDir === void 0 ? 'lib' : _options$libDir,
          // 元件庫,element-ui
          _options$libraryName = _options.libraryName,
          // 元件庫名稱
          libraryName = _options$libraryName === void 0 ? defaultLibraryName : _options$libraryName,
          // 樣式,boolean 型別,這裡是 undefined
          _options$style = _options.style,
          // style 預設是 true,也可以由使用者提供,在使用者沒有提供 styleLibraryName 選項是起作用
          style = _options$style === void 0 ? true : _options$style,
          // undefiend
          styleLibrary = _options.styleLibrary,
          // undefined
          _options$root = _options.root,
          // ''
          root = _options$root === void 0 ? '' : _options$root,
          _options$camel2Dash = _options.camel2Dash,
          camel2Dash = _options$camel2Dash === void 0 ? true : _options$camel2Dash;
        // 配置項中的,'theme-chalk'
        var styleLibraryName = options.styleLibraryName;
        // ''
        var _root = root;
        var isBaseStyle = true;
        var modulePathTpl;
        var styleRoot;
        var mixin = false;
        // 字尾 xx.css
        var ext = options.ext || '.css';

        if (root) {
          _root = "/".concat(root);
        }

        if (libraryObjs[methodName]) {
          // 預設匯入 ElementUI, path = 'element-ui/lib'
          path = "".concat(libraryName, "/").concat(libDir).concat(_root);

          if (!_root) {
            // 預設匯入的情況下,記錄在 importAll 中標記 path 為 true
            importAll[path] = true;
          }
        } else {
          // 按需引入,path = 'element-ui/lib/button'
          path = "".concat(libraryName, "/").concat(libDir, "/").concat(parseName(methodName, camel2Dash));
        }

        // 'element-ui/lib/button'
        var _path = path;
        /**
         * selectedMethods['Button'] = { type: Identifier, name: '_Button' }
         * addDefault 就負責新增剛才在 visitor.CallExpreesion 那說的那堆東西,
         * 這裡主要負責 var Button = require('element-ui/lib/button.js'),
         * 這是猜的,主要是沒找到這方面的文件介紹
         */
        selectedMethods[methodName] = addDefault(file.path, path, {
          nameHint: methodName
        });

        /**
         * 接下來是處理樣式
         */
        if (styleLibrary && _typeof(styleLibrary) === 'object') {
          styleLibraryName = styleLibrary.name;
          isBaseStyle = styleLibrary.base;
          modulePathTpl = styleLibrary.path;
          mixin = styleLibrary.mixin;
          styleRoot = styleLibrary.root;
        }

        // styleLibraryName = 'theme-chalk',如果配置該選項,就採用預設的方式,進入 else 檢視
        if (styleLibraryName) {
          // 快取樣式庫路徑
          if (!cachePath[libraryName]) {
            var themeName = styleLibraryName.replace(/^~/, '');
            // cachePath['element-ui'] = 'element-ui/lib/theme-chalk'
            cachePath[libraryName] = styleLibraryName.indexOf('~') === 0 ? resolve(process.cwd(), themeName) : "".concat(libraryName, "/").concat(libDir, "/").concat(themeName);
          }

          if (libraryObjs[methodName]) {
            // 預設匯入
            /* istanbul ingore next */
            if (cache[libraryName] === 2) {
              // 提示資訊,意思是說如果你專案既存在預設匯入,又存在按需載入,則要保證預設匯入在按需載入的前面
              throw Error('[babel-plugin-component] If you are using both' + 'on-demand and importing all, make sure to invoke the' + ' importing all first.');
            }

            // 預設匯出的樣式庫路徑:path = 'element-ui/lib/theme-chalk/index.css'
            if (styleRoot) {
              path = "".concat(cachePath[libraryName]).concat(styleRoot).concat(ext);
            } else {
              path = "".concat(cachePath[libraryName]).concat(_root || '/index').concat(ext);
            }

            cache[libraryName] = 1;
          } else {
            // 按需引入,這裡不等於 1 就是存在預設匯入 + 按需引入的情況,基本上沒人會這麼用
            if (cache[libraryName] !== 1) {
              /* if set styleLibrary.path(format: [module]/module.css) */
              var parsedMethodName = parseName(methodName, camel2Dash);

              if (modulePathTpl) {
                var modulePath = modulePathTpl.replace(/\[module]/ig, parsedMethodName);
                path = "".concat(cachePath[libraryName], "/").concat(modulePath);
              } else {
                path = "".concat(cachePath[libraryName], "/").concat(parsedMethodName).concat(ext);
              }

              if (mixin && !isExist(path)) {
                path = style === true ? "".concat(_path, "/style").concat(ext) : "".concat(_path, "/").concat(style);
              }

              if (isBaseStyle) {
                addSideEffect(file.path, "".concat(cachePath[libraryName], "/base").concat(ext));
              }

              cache[libraryName] = 2;
            }
          }

          // 新增樣式匯入,require('elememt-ui/lib/theme-chalk/button.css'),這裡也是猜的,說實話,addDefault 方法看的有點懵,要是有文件就好了
          addDefault(file.path, path, {
            nameHint: methodName
          });
        } else {
          if (style === true) {
            // '/element-ui/style.css,這裡是預設的,ext 可以由使用者提供,也是用預設的
            addSideEffect(file.path, "".concat(path, "/style").concat(ext));
          } else if (style) {
            // 'element-ui/xxx,這裡的 style 是使用者提供的 
            addSideEffect(file.path, "".concat(path, "/").concat(style));
          }
        }
      }

      return selectedMethods[methodName];
    }

    function buildExpressionHandler(node, props, path, state) {
      var file = path && path.hub && path.hub.file || state && state.file;
      props.forEach(function (prop) {
        if (!types.isIdentifier(node[prop])) return;

        if (specified[node[prop].name]) {
          node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
        }
      });
    }

    function buildDeclaratorHandler(node, prop, path, state) {
      var file = path && path.hub && path.hub.file || state && state.file;
      if (!types.isIdentifier(node[prop])) return;

      if (specified[node[prop].name]) {
        node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
      }
    }

    return {
      // 程式的整個入口,熟悉的 visitor
      visitor: {
        // 負責處理 AST 中 Program 型別的節點
        Program: function Program() {
          // 將之前定義的幾個變數初始化為沒有原型鏈的物件
          specified = Object.create(null);
          libraryObjs = Object.create(null);
          selectedMethods = Object.create(null);
          moduleArr = Object.create(null);
        },
        // 處理 ImportDeclaration 節點
        ImportDeclaration: function ImportDeclaration(path, _ref2) {
          // .babelrc 中的外掛配置項
          var opts = _ref2.opts;
          // import xx from 'xx', ImportDeclaration 節點
          var node = path.node;
          // import xx from 'element-ui',這裡的 node.source.value 儲存的就是 庫名稱
          var value = node.source.value;
          var result = {};

          // 可以不用管,如果配置項是個陣列,從陣列中找到該庫的配置項
          if (Array.isArray(opts)) {
            result = opts.find(function (option) {
              return option.libraryName === value;
            }) || {};
          }

          // 庫名稱,比如 element-ui
          var libraryName = result.libraryName || opts.libraryName || defaultLibraryName;

          // 如果當前 import 的庫就是我們需要處理的庫,則進入
          if (value === libraryName) {
            // 遍歷node.specifiers,裡面放了多個ImportSpecifier,每個都是我們要引入的元件(方法)
            node.specifiers.forEach(function (spec) {
              // ImportSpecifer 是按需引入,還有另外的一個預設匯入,ImportDefaultSpecifier,比如:ElementUI
              if (types.isImportSpecifier(spec)) {
                // 設定按需引入的元件, 比如specfied['Button'] = 'Button'
                specified[spec.local.name] = spec.imported.name;
                // 記錄當前元件是從哪個庫引入的,比如 moduleArr['Button'] = 'element-ui'
                moduleArr[spec.imported.name] = value;
              } else {
                // 預設匯入,libraryObjs['ElementUI'] = 'element-ui'
                libraryObjs[spec.local.name] = value;
              }
            });

            // 不是全域性引入就刪掉該節點,意思是刪掉所有的按需引入,這個會在 importMethod 方法中設定
            if (!importAll[value]) {
              path.remove();
            }
          }
        },
        /**
         * 這裡很重要,我們會發現在使用按需載入時,如果你只是import引入,但是沒有使用,比如Vue.use(Button),則一樣不會打包,所以這裡就是來
         * 處理這種情況的,只有你引入的包實際使用了,才會真的import,要不然剛才刪了就沒有然後了,就不會在 node 上新增各種 arguments 了,比如:
         * {
         *   type: 'CallExpression',
         *   callee: { type: 'Identifier', name: 'require' },
         *   arguments: [ { type: 'StringLiteral', value: 'element-ui/lib' } ]
         * }
         * {
         *   type: 'CallExpression',
         *   callee: { type: 'Identifier', name: 'require' },
         *   arguments: [
         *    {
         *      type: 'StringLiteral',
         *      value: 'element-ui/lib/chalk-theme/index.css'
         *    }
         *   ]
         * }
         * {
         *    type: 'CallExpression',
         *    callee: { type: 'Identifier', name: 'require' },
         *    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib/button' } ]
         * }
         * 以上這些通過打log可以檢視,這個格式很重要,因為有了這部分資料,我們就知道:
         * import {Button} from 'element-ui' 為什麼能
         * 得到 var Button = require('element-ui/lib/button.js')
         * 以及 require('element-ui/lib/theme-chalk/button.css')
         *
         * @param {*} path 
         * @param {*} state 
         */
        CallExpression: function CallExpression(path, state) {
          // Vue.use(Button),CallExpression 節點
          var node = path.node;
          // 很大的一拖物件,不想看(不用看,費頭髮)
          var file = path && path.hub && path.hub.file || state && state.file;
          // callee 的 name 屬性,我們這裡不涉及該屬性,類似ElementUI(ok)這種語法會有該屬性,node.callee.name 就是 ElementUI
          var name = node.callee.name;

          console.log('import method 處理前的 node:', node)
          // 判斷 node.callee 是否屬於 Identifier,我們這裡不是,我們的是一個 MemberExpression
          if (types.isIdentifier(node.callee)) {
            if (specified[name]) {
              node.callee = importMethod(specified[name], file, state.opts);
            }
          } else {
            // 解析 node.arguments 陣列,每個元素都是一個 Identifier,Vue.use或者Vue.component的引數
            node.arguments = node.arguments.map(function (arg) {
              // 引數名稱
              var argName = arg.name;

              // 1、這裡會生成一個新的 Identifier,並更改 AST節點的屬性值
              // 2、按需引入還是預設匯入是在 ImportDeclaration 中決定的
              if (specified[argName]) {
                // 按需引入,比如:{ type: "Identifier", name: "_Button" },這是 AST 結構的 JSON 物件表示形式
                return importMethod(specified[argName], file, state.opts);
              } else if (libraryObjs[argName]) {
                // 預設匯入,{ type: "Identifier", name: "_ElementUI" }
                return importMethod(argName, file, state.opts);
              }

              return arg;
            });
          }
          console.log('import method 處理後的 node:', node)
        },
        /**
         * 後面幾個不用太關注,在這裡不涉及,看字面量就可以明白在做什麼 
         */
        // 處理 MemberExpression,更改 node.object 物件
        MemberExpression: function MemberExpression(path, state) {
          var node = path.node;
          var file = path && path.hub && path.hub.file || state && state.file;

          if (libraryObjs[node.object.name] || specified[node.object.name]) {
            node.object = importMethod(node.object.name, file, state.opts);
          }
        },
        // 處理賦值表示式
        AssignmentExpression: function AssignmentExpression(path, _ref3) {
          var opts = _ref3.opts;

          if (!path.hub) {
            return;
          }

          var node = path.node;
          var file = path.hub.file;
          if (node.operator !== '=') return;

          if (libraryObjs[node.right.name] || specified[node.right.name]) {
            node.right = importMethod(node.right.name, file, opts);
          }
        },
        // 陣列表示式
        ArrayExpression: function ArrayExpression(path, _ref4) {
          var opts = _ref4.opts;

          if (!path.hub) {
            return;
          }

          var elements = path.node.elements;
          var file = path.hub.file;
          elements.forEach(function (item, key) {
            if (item && (libraryObjs[item.name] || specified[item.name])) {
              elements[key] = importMethod(item.name, file, opts);
            }
          });
        },
        // 屬性
        Property: function Property(path, state) {
          var node = path.node;
          buildDeclaratorHandler(node, 'value', path, state);
        },
        // 變數宣告
        VariableDeclarator: function VariableDeclarator(path, state) {
          var node = path.node;
          buildDeclaratorHandler(node, 'init', path, state);
        },
        // 邏輯表示式
        LogicalExpression: function LogicalExpression(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['left', 'right'], path, state);
        },
        // 條件表示式
        ConditionalExpression: function ConditionalExpression(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
        },
        // if 語句
        IfStatement: function IfStatement(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['test'], path, state);
          buildExpressionHandler(node.test, ['left', 'right'], path, state);
        }
      }
    };
  };
};

總結

通過閱讀原始碼以及打log的方式,我們得到了如下資訊:

{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib' } ]
}
{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [
        {
          type: 'StringLiteral',
          value: 'element-ui/lib/chalk-theme/index.css'
        }
    ]
}
{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib/button' } ]
}

這其實就是經過變化後的AST的部分資訊,通過對比目的碼在AST Tree中的顯示會發現,結果是一致的,也就是說通過以上AST資訊就可以生成我們需要的目的碼

目的碼中的require關鍵字就是calleerequire函式中的引數就是arguments陣列

以上就是 按需載入原理分析 的所有內容。

連結

感謝各位的:點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識,掃碼關注微信公眾號,共同學習、進步。文章已收錄到 github,歡迎 Watch 和 Star。

微信公眾號

相關文章