Webpack 將代碼打包成什麼樣子?

文章同步於 Github blog

可能你學會了如何使用 Webpack ,也大體知道其工做原理,但是你想過 Webpack 輸出的 bundle.js 是什麼樣子的嗎? 爲何原來一個個的模塊文件被合併成了一個單獨的文件?爲何 bundle.js 能直接運行在瀏覽器中?javascript

簡單工程打包

下面經過 Webpack 構建一個採用 CommonJS 模塊化編寫的項目,該項目有個網頁會經過 JavaScript 在網頁中顯示 Hello,Webpack。html

運行構建前,先把要完成該功能的最基礎的 JavaScript 文件和 HTML 創建好,須要以下文件:java

頁面入口文件 index.htmlwebpack

<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--導入 Webpack 輸出的 JavaScript 文件-->
<script src="./dist/bundle.js"></script>
</body>
</html>

JS 工具函數文件 show.jsgit

// 操做 DOM 元素,把 content 顯示到網頁上
function show(content) {
  window.document.getElementById('app').innerText = 'Hello,' + content;
}

// 經過 CommonJS 規範導出 show 函數
module.exports = show;

JS 執行入口文件 main.jsgithub

// 經過 CommonJS 規範導入 show 函數
const show = require('./show.js');
// 執行 show 函數
show('Webpack');

Webpack 在執行構建時默認會從項目根目錄下的 webpack.config.js 文件讀取配置,因此你還須要新建它,其內容以下:web

const path = require('path');

module.exports = {
  // JavaScript 執行入口文件
  entry: './main.js',
  output: {
    // 把全部依賴的模塊合併輸出到一個 bundle.js 文件
    filename: 'bundle.js',
    // 輸出文件都放到 dist 目錄下
    path: path.resolve(__dirname, './dist'),
  }
};

一切文件就緒,在項目根目錄下執行 webpack 命令運行 Webpack 構建,你會發現目錄下多出一個 dist 目錄,裏面有個 bundle.js 文件, bundle.js 文件是一個可執行的 JavaScript 文件,它包含頁面所依賴的兩個模塊 main.js 和 show.js 及內置的 webpackBootstrap 啓動函數。 這時你用瀏覽器打開 index.html 網頁將會看到 Hello,Webpack。segmentfault

Webpack 是一個打包模塊化 JavaScript 的工具,它會從 main.js 出發,識別出源碼中的模塊化導入語句, 遞歸的尋找出入口文件的全部依賴,把入口和其全部依賴打包到一個單獨的文件中。 從 Webpack2 開始,已經內置了對 ES六、CommonJS、AMD 模塊化語句的支持。數組

輸出代碼分析

先來看看由最簡單的項目構建出的 bundle.js 文件內容,代碼以下:promise

(
    // webpackBootstrap 啓動函數
    // modules 即爲存放全部模塊的數組,數組中的每個元素都是一個函數
    function (modules) {
        // 安裝過的模塊都存放在這裏面
        // 做用是把已經加載過的模塊緩存在內存中,提高性能
        var installedModules = {};

        // 去數組中加載一個模塊,moduleId 爲要加載模塊在數組中的 index
        // 做用和 Node.js 中 require 語句類似
        function __webpack_require__(moduleId) {
            // 若是須要加載的模塊已經被加載過,就直接從內存緩存中返回
            if (installedModules[moduleId]) {
                return installedModules[moduleId].exports;
            }

            // 若是緩存中不存在須要加載的模塊,就新建一個模塊,並把它存在緩存中
            var module = installedModules[moduleId] = {
                // 模塊在數組中的 index
                i: moduleId,
                // 該模塊是否已經加載完畢
                l: false,
                // 該模塊的導出值
                exports: {}
            };

            // 從 modules 中獲取 index 爲 moduleId 的模塊對應的函數
            // 再調用這個函數,同時把函數須要的參數傳入
            modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
            // 把這個模塊標記爲已加載
            module.l = true;
            // 返回這個模塊的導出值
            return module.exports;
        }

        // Webpack 配置中的 publicPath,用於加載被分割出去的異步代碼
        __webpack_require__.p = "";

        // 使用 __webpack_require__ 去加載 index 爲 0 的模塊,而且返回該模塊導出的內容
        // index 爲 0 的模塊就是 main.js 對應的文件,也就是執行入口模塊
        // __webpack_require__.s 的含義是啓動模塊對應的 index
        return __webpack_require__(__webpack_require__.s = 0);

    })(

    // 全部的模塊都存放在了一個數組裏,根據每一個模塊在數組的 index 來區分和定位模塊
    [
        /* 0 */
        (function (module, exports, __webpack_require__) {
            // 經過 __webpack_require__ 規範導入 show 函數,show.js 對應的模塊 index 爲 1
            const show = __webpack_require__(1);
            // 執行 show 函數
            show('Webpack');
        }),
        /* 1 */
        (function (module, exports) {
            function show(content) {
                window.document.getElementById('app').innerText = 'Hello,' + content;
            }
            // 經過 CommonJS 規範導出 show 函數
            module.exports = show;
        })
    ]
);

以上看上去複雜的代碼實際上是一個當即執行函數,能夠簡寫爲以下:

(function(modules) {

  // 模擬 require 語句
  function __webpack_require__() {
  }

  // 執行存放全部模塊數組中的第0個模塊
  __webpack_require__(0);

})([/*存放全部模塊的數組*/])

bundle.js 能直接運行在瀏覽器中的緣由在於輸出的文件中經過 __webpack_require__ 函數定義了一個能夠在瀏覽器中執行的加載函數來模擬 Node.js 中的 require 語句。

原來一個個獨立的模塊文件被合併到了一個單獨的 bundle.js 的緣由在於瀏覽器不能像 Node.js 那樣快速地去本地加載一個個模塊文件,而必須經過網絡請求去加載還未獲得的文件。 若是模塊數量不少,加載時間會很長,所以把全部模塊都存放在了數組中,執行一次網絡加載。

若是仔細分析 __webpack_require__ 函數的實現,你還有發現 Webpack 作了緩存優化: 執行加載過的模塊不會再執行第二次,執行結果會緩存在內存中,當某個模塊第二次被訪問時會直接去內存中讀取被緩存的返回值。

按需加載

在給單頁應用作按需加載優化時,通常採用如下原則:

  • 把整個網站劃分紅一個個小功能,再按照每一個功能的相關程度把它們分紅幾類。
  • 把每一類合併爲一個 Chunk,按需加載對應的 Chunk。
  • 對於用戶首次打開你的網站時須要看到的畫面所對應的功能,不要對它們作按需加載,而是放到執行入口所在的 Chunk 中,以下降用戶能感知的網頁加載時間。
  • 對於個別依賴大量代碼的功能點,例如依賴 Chart.js 去畫圖表、依賴 flv.js 去播放視頻的功能點,可再對其進行按需加載。

被分割出去的代碼的加載須要必定的時機去觸發,也就是當用戶操做到了或者即將操做到對應的功能時再去加載對應的代碼。 被分割出去的代碼的加載時機須要開發者本身去根據網頁的需求去衡量和肯定。

因爲被分割出去進行按需加載的代碼在加載的過程當中也須要耗時,你能夠預言用戶接下來可能會進行的操做,並提早加載好對應的代碼,從而讓用戶感知不到網絡加載時間。

用 Webpack 實現按需加載
Webpack 內置了強大的分割代碼的功能去實現按需加載,實現起來很是簡單。

舉個例子,如今須要作這樣一個進行了按需加載優化的網頁:

網頁首次加載時只加載 main.js 文件,網頁會展現一個按鈕,main.js 文件中只包含監聽按鈕事件和加載按需加載的代碼。
當按鈕被點擊時纔去加載被分割出去的 show.js 文件,加載成功後再執行 show.js 裏的函數。
其中 main.js 文件內容以下:

window.document.getElementById('btn').addEventListener('click', function () {
  // 當按鈕被點擊後纔去加載 show.js 文件,文件加載成功後執行文件導出的函數
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  })
});

show.js 文件內容以下:

module.exports = function (content) {
  window.alert('Hello ' + content);
};

代碼中最關鍵的一句是 import(/* webpackChunkName: "show" */ './show'),Webpack 內置了對 import(*) 語句的支持,當 Webpack 遇到了相似的語句時會這樣處理:

  • 以 ./show.js 爲入口新生成一個 Chunk;
  • 當代碼執行到 import 所在語句時纔會去加載由 Chunk 對應生成的文件。
  • import 返回一個 Promise,當文件加載成功時能夠在 Promise 的 then 方法中獲取到 show.js 導出的內容。
在使用 import() 分割代碼後,你的瀏覽器而且要支持 Promise API 才能讓代碼正常運行, 由於 import() 返回一個 Promise,它依賴 Promise。對於不原生支持 Promise 的瀏覽器,你能夠注入 Promise polyfill。

/* webpackChunkName: "show" */ 的含義是爲動態生成的 Chunk 賦予一個名稱,以方便咱們追蹤和調試代碼。 若是不指定動態生成的 Chunk 的名稱,默認名稱將會是 [id].js。 /* webpackChunkName: "show" */ 是在 Webpack3 中引入的新特性,在 Webpack3 以前是沒法爲動態生成的 Chunk 賦予名稱的。

按需加載輸出代碼分析

在採用了按需加載的優化方法時,Webpack 的輸出文件會發生變化。

例如把源碼中的 main.js 修改成以下:

// 異步加載 show.js
import('./show').then((show) => {
  // 執行 show 函數
  show('Webpack');
});

從新構建後會輸出兩個文件,分別是執行入口文件 bundle.js 和 異步加載文件 0.bundle.js。

其中 0.bundle.js 內容以下:

// 加載在本文件(0.bundle.js)中包含的模塊
webpackJsonp(
  // 在其它文件中存放着的模塊的 ID
  [0],
  // 本文件所包含的模塊
  [
    // show.js 所對應的模塊
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);

bundle.js 內容以下:

(function (modules) {
  /***
   * webpackJsonp 用於從異步加載的文件中安裝模塊。
   * 把 webpackJsonp 掛載到全局是爲了方便在其它文件中調用。
   *
   * @param chunkIds 異步加載的文件中存放的須要安裝的模塊對應的 Chunk ID
   * @param moreModules 異步加載的文件中存放的須要安裝的模塊列表
   * @param executeModules 在異步加載的文件中存放的須要安裝的模塊都安裝成功後,須要執行的模塊對應的 index
   */
  window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    // 把 moreModules 添加到 modules 對象中
    // 把全部 chunkIds 對應的模塊都標記成已經加載成功 
    var moduleId, chunkId, i = 0, resolves = [], result;
    for (; i < chunkIds.length; i++) {
      chunkId = chunkIds[i];
      if (installedChunks[chunkId]) {
        resolves.push(installedChunks[chunkId][0]);
      }
      installedChunks[chunkId] = 0;
    }
    for (moduleId in moreModules) {
      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        modules[moduleId] = moreModules[moduleId];
      }
    }
    while (resolves.length) {
      resolves.shift()();
    }
  };

  // 緩存已經安裝的模塊
  var installedModules = {};

  // 存儲每一個 Chunk 的加載狀態;
  // 鍵爲 Chunk 的 ID,值爲0表明已經加載成功
  var installedChunks = {
    1: 0
  };

  // 模擬 require 語句,和上面介紹的一致
  function __webpack_require__(moduleId) {
    // ... 省略和上面同樣的內容
  }

  /**
   * 用於加載被分割出去的,須要異步加載的 Chunk 對應的文件
   * @param chunkId 須要異步加載的 Chunk 對應的 ID
   * @returns {Promise}
   */
  __webpack_require__.e = function requireEnsure(chunkId) {
    // 從上面定義的 installedChunks 中獲取 chunkId 對應的 Chunk 的加載狀態
    var installedChunkData = installedChunks[chunkId];
    // 若是加載狀態爲0表示該 Chunk 已經加載成功了,直接返回 resolve Promise
    if (installedChunkData === 0) {
      return new Promise(function (resolve) {
        resolve();
      });
    }

    // installedChunkData 不爲空且不爲0表示該 Chunk 正在網絡加載中
    if (installedChunkData) {
      // 返回存放在 installedChunkData 數組中的 Promise 對象
      return installedChunkData[2];
    }

    // installedChunkData 爲空,表示該 Chunk 尚未加載過,去加載該 Chunk 對應的文件
    var promise = new Promise(function (resolve, reject) {
      installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    installedChunkData[2] = promise;

    // 經過 DOM 操做,往 HTML head 中插入一個 script 標籤去異步加載 Chunk 對應的 JavaScript 文件
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.charset = 'utf-8';
    script.async = true;
    script.timeout = 120000;

    // 文件的路徑爲配置的 publicPath、chunkId 拼接而成
    script.src = __webpack_require__.p + "" + chunkId + ".bundle.js";

    // 設置異步加載的最長超時時間
    var timeout = setTimeout(onScriptComplete, 120000);
    script.onerror = script.onload = onScriptComplete;

    // 在 script 加載和執行完成時回調
    function onScriptComplete() {
      // 防止內存泄露
      script.onerror = script.onload = null;
      clearTimeout(timeout);

      // 去檢查 chunkId 對應的 Chunk 是否安裝成功,安裝成功時纔會存在於 installedChunks 中
      var chunk = installedChunks[chunkId];
      if (chunk !== 0) {
        if (chunk) {
          chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
        }
        installedChunks[chunkId] = undefined;
      }
    };
    head.appendChild(script);

    return promise;
  };

  // 加載並執行入口模塊,和上面介紹的一致
  return __webpack_require__(__webpack_require__.s = 0);
})
(
  // 存放全部沒有通過異步加載的,隨着執行入口文件加載的模塊
  [
    // main.js 對應的模塊
    (function (module, exports, __webpack_require__) {
      // 經過 __webpack_require__.e 去異步加載 show.js 對應的 Chunk
      __webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
        // 執行 show 函數
        show('Webpack');
      });
    })
  ]
);

這裏的 bundle.js 和上面所講的 bundle.js 很是類似,區別在於:

多了一個 __webpack_require__.e 用於加載被分割出去的,須要異步加載的 Chunk 對應的文件;
多了一個 webpackJsonp 函數用於從異步加載的文件中安裝模塊。
在使用了 CommonsChunkPlugin 去提取公共代碼時輸出的文件和使用了異步加載時輸出的文件是同樣的,都會有 __webpack_require__.e 和 webpackJsonp。 緣由在於提取公共代碼和異步加載本質上都是代碼分割。

參考

相關文章
相關標籤/搜索