由淺至深了解webpack異步加載背後的原理
- 2020 年 1 月 14 日
- 筆記
源自最近對業務項目進行 webpack 異步分包加載一點點的學習總結
提綱如下:
- 相關概念
- webpack 分包配置
- webpack 異步加載分包如何實現
相關概念
- module、chunk、bundle 的概念
先來一波名詞解釋。先上網上一張圖解釋:

通過圖可以很直觀的分出這幾個名詞的概念:
1、module
:我們源碼目錄中的每一個文件,在 webpack 中當作module
來處理(webpack 原生不支持的文件類型,則通過 loader 來實現)。module
組成了chunk
。 2、chunk
。webpack
打包過程中的產物,在默認一般情況下(沒有考慮分包等情況),x 個webpack
的entry
會輸出 x 個bundle
。 3、bundle
。webpack
最終輸出的東西,可以直接在瀏覽器運行的。從圖中看可以看到,在抽離 css(當然也可以是圖片、字體文件之類的)的情況下,一個chunk
是會輸出多個bundle
的,但是默認情況下一般一個chunk
也只是會輸出一個bundle
hash
、chunkhash
、contenthash
這裡不進行 demo 演示了,網上相關演示已經很多。
hash。所有的 bundle 使用同一個 hash 值,跟每一次 webpack 打包的過程有關
chunkhash。根據每一個 chunk 的內容進行 hash,同一個 chunk 的所有 bundle 產物的 hash 值是一樣的。因此若其中一個 bundle 的修改,同一 chunk 的所有產物 hash 也會被修改。
contenthash。計算與文件內容本身相關。
tips:需要注意的是,在熱更新
模式下,會導致chunkhash
和contenthash
計算錯誤,發生錯誤(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead)
)。因此熱更新下只能使用hash
模式或者不使用hash
。在生產環境中我們一般使用contenthash
或者chunkhash
。
說了這麼多,那麼使用異步加載/分包加載有什麼好處呢。簡單來說有以下幾點
1、更好的利用瀏覽器緩存。如果我們一個很大的項目,不使用分包的話,每一次打包只會生成一個 js 文件,假設這個 js 打包出來有 2MB。而當日常代碼發佈的時候,我們可能只是修改了其中的一行代碼,但是由於內容變了,打包出來的 js 的哈希值也發生改變。瀏覽器這個時候就要重新去加載這個 2MB 的 js 文件。而如果使用了分包,分出了幾個 chunk,修改了一行代碼,影響的只是這個 chunk 的哈希(這裡嚴謹來說在不抽離 mainifest 的情況下,可能有多個哈希也會變化),其它哈希是不變的。這就能利用到 hash 不變化部分代碼的緩存
2、更快的加載速度。假設進入一個頁面需要加載一個 2MB 的 js,經過分包抽離後,可能進入這個頁面變成了加載 4 個 500Kb 的 js。我們知道,瀏覽器對於同一域名的最大並發請求數是 6 個(所以 webpack 的maxAsyncRequests
默認值是 6),這樣這個 4 個 500KB 的 js 將同時加載,相當於只是穿行加載一個 500kb 的資源,速度也會有相應的提高。
3、如果實現的是代碼異步懶加載。對於部分可能某些地方才用到的代碼,在用到的時候才去加載,也能很好起到節省流量的目的。
webpack 分包配置
在這之前,先強調一次概念,splitChunk
,針對的是chunk
,並不是module
。對於同一個 chunk 中,無論一個代碼文件被同 chunk 引用了多少次,它都還是算 1 次。只有一個代碼文件被多個 chunk 引用,才算是多次。
webpack 的默認分包配置如下
module.exports = { optimization: { splitChunks: { // **`splitChunks.chunks: 'async'`**。表示哪些類型的chunk會參與split。默認是異步加載的chunk。值還可以是`initial`(表示入口同步chunk)、`all`(相當於`initial`+`async`)。 chunks: "async", // minSize 表示符合代碼分割產生的新生成chunk的最小大小。默認是大於30kb的才會生成新的chunk minSize: 30000, // maxSize 表示webpack會嘗試將大於maxSize的chunk拆分成更小的chunk,拆解後的值需要大於minSize maxSize: 0, // 一個模塊被最少多少個chunk共享時參與split minChunks: 1, // 最大異步請求數。該值可以理解為一個異步chunk,被抽離出同時加載的chunk數不超過該值。若為1,該異步chunk將不會抽離出任意代碼塊 maxAsyncRequests: 5, // 入口chunk最大請求數。在多entry chunk的情況下會用到,表示多entry chunk公共代碼抽出的最大同時加載的chunk數 maxInitialRequests: 3, // 初始chunk最大請求數。 // 多個chunk拆分出小chunk時,這個chunk的名字由多個chunk與連接符組合成 automaticNameDelimiter: "~", // 表示chunk的名字自動生成(由cacheGroups的key、entry名字) name: true, // cacheGroups 表示分包分組規則,每一個分組會繼承於default // priority表示優先級,一個chunk可能被多個分組規則命中時,會使用優先級較高的 // test提供時 表示哪些模塊會被抽離 cacheGroups: { vendors: { test: /[\/]node_modules[\/]/, priority: -10 }, default: { minChunks: 2, priority: -20, // 復用已經生成的chunk reuseExistingChunk: true } } } } };
還有一個很重要的配置是output.jsonpFunction
(默認是webpackJsonp
)。這是用於異步加載 chunk 的時候一個全局變量。如果多 webpack 環境下,為了防止該函數命名衝撞產生問題,最好設置成一個比較唯一的值。
一般而言,沒有最完美的分包配置,只有最合適當前項目場景需求的配置。很多時候,默認配置已經足夠可用了。
通常來說,為了保證 hash 的穩定性,建議:
1、使用webpack.HashedModuleIdsPlugin
。這個插件會根據模塊的相對路徑生成一個四位數的 hash 作為模塊 id。默認情況下 webpack 是使用模塊數字自增 id 來命名,當插入一個模塊佔用了一個 id(或者一個刪去一個模塊)時,後續所有的模塊 id 都受到影響,導致模塊 id 變化引起打包文件的 hash 變化。使用這個插件就能解決這個問題。
2、chunk
id 也是自增的,同樣可能遇到模塊 id 的問題。可以通過設置optimization.namedChunks
為 true(默認 dev 模式下為 true,prod 模式為 false),將chunk
的名字使用命名chunk
。
1、2 後的效果如下。
3、抽離 css 使用mini-css-extract-plugin
。hash 模式使用contenthash
。
這裡以騰訊雲某控制台頁面以下為例,使用 webpack 路有異步加載效果後如下。可以看到,第一次訪問頁面。這裡是先請求到一個總的入口 js,然後根據我們訪問的路由(路由 1),再去加載這個路由相關的代碼。這裡可以看到我們異步加載的 js 數為 5,就相當於上面提到的默認配置項maxAsyncRequests
,通過waterfall
可以看到這裡是並發請求的。如果再進去其它路由(路由 2)的話,只會加載一個其它路由的 js(或者還有當前沒有加載過的 vendor js)。這裡如果只修改了路由 1 的自己單獨業務代碼,vendor 相關的 hash 和其它路由的 hash 也不是不會變,這些文件就能很好的利用了瀏覽器緩存了
webpack 異步加載分包如何實現
我們知道,默認情況下,瀏覽器環境的 js 是不支持import
和異步import('xxx').then(...)
的。那麼 webpack 是如何實現使得瀏覽器支持的呢,下面對 webpack 構建後的代碼進行分析,了解其背後原理。
實驗代碼結構如下
展開查看
// webpack.js const webpack = require("webpack"); const path = require("path"); const CleanWebpackPlugin = require("clean-webpack-plugin").CleanWebpackPlugin; const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = { entry: { a: 「./src/a.js」, b: 「./src/b.js」 }, output: { filename: 「[name].[chunkhash].js」, chunkFilename: 「[name].[chunkhash].js」, path: **dirname + 「/dist」, jsonpFunction: 「_**jsonp」 }, optimization: { splitChunks: { minSize: 0 } // namedChunks: true }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin() //new webpack.HashedModuleIdsPlugin() ], devServer: { contentBase: path.join(__dirname, 「dist」), compress: true, port: 8000 } };
// src/a.js import { common1 } from 「./common1」; import { common2 } from 「./common2」; common1(); common2(); import(/_ webpackChunkName: 「asyncCommon2」 _/ 「./asyncCommon2.js」).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(「done」); } );
// src/b.js import { common1 } from 「./common1」; common1(); import(/_ webpackChunkName: 「asyncCommon2」 _/ 「./asyncCommon2.js」).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(「done」); } );
// src/asyncCommon1.js export function asyncCommon1(){ console.log(『asyncCommon1』) } // src/asyncCommon2.js export function asyncCommon2(){ console.log(『asyncCommon2』) }
// ./src/common1.js export function common1() { console.log(「common11」); } import(/_ webpackChunkName: 「asyncCommon1」 _/ 「./asyncCommon1」).then( ({ asyncCommon1 }) => { asyncCommon1(); } );
// src/common2.js export function common2(){ console.log(『common2』) }
在分析異步加載機制之前,先看下 webpack 打包出來的代碼結構長啥樣(為了便於閱讀,這裡使用 dev 模式打包,沒有使用任何 babel 轉碼)。列出與加載相關的部分
// 入口文件 a.js (function() { //..... function webpackJsonpCallback(data){ //.... } // 緩存已經加載過的module。無論是同步還是異步加載的模塊都會進入該緩存 var installedModules = {}; // 記錄chunk的狀態位 // 值:0 表示已加載完成。 // undefined : chunk 還沒加載 // null :chunk preloaded/prefetched // Promise : chunk正在加載 var installedChunks = { a: 0 }; // 用於根據chunkId,拿異步加載的js地址 function jsonpScriptSrc(chunkId){ //... } // 同步import function __webpack_require__(moduleId){ //... } // 用於加載異步import的方法 __webpack_require__.e = function requireEnsure(chunkId) { //... } // 加載並執行入口js return __webpack_require__((__webpack_require__.s = "./src/a.js")); })({ "./src/a.js": function(module, __webpack_exports__, __webpack_require__) { eval( ...); // ./src/a.js的文件內容 }, "./src/common1.js": ...., "./src/common2.js": ... });
可以看到,經過 webpack 打包後的入口文件是一個立即執行函數,立即執行函數的參數就是為入口函數的同步import
的代碼模塊對象。key 值是路徑名,value 值是一個執行相應模塊代碼的eval
函數。這個入口函數內有幾個重要的變量/函數。
webpackJsonpCallback
函數。加載異步模塊完成的回調。installedModules
變量。 緩存已經加載過的 module。無論是同步還是異步加載的模塊都會進入該緩存。key
是模塊 id,value
是一個對象{ i: 模塊id, l: 布爾值,表示模塊是否已經加載過, exports: 該模塊的導出值 }
。installedChunks
變量。緩存已經加載過的 chunk 的狀態。有幾個狀態位。0
表示已加載完成、undefined
chunk 還沒加載、null
:chunkpreloaded/prefetched
加載的模塊、Promise
: chunk 正在加載jsonpScriptSrc
變量。用於返回異步 chunk 的 js 地址。如果設置了webpack.publicPath
(一般是 cdn 域名,這個會存到__webpack_require__.p
中),也會和該地址拼接成最終地址__webpack_require__
函數。同步import
的調用__webpack_require__.e
函數。異步import
的調用
而每個模塊構建出來後是一個類型如下形式的函數,函數入參module
對應於當前模塊的相關狀態(是否加載完成、導出值、id 等,下文提到)、__webpack_exports__
就是當前模塊的導出(就是 export)、__webpack_require__
就是入口 chunk 的__webpack_require__
函數,用於import
其它代碼
function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval(模塊代碼...);// (1) }
eval
內的代碼如下,以a.js
為例。
// (1) // 格式化為js後 __webpack_require__.r(__webpack_exports__); var _common1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( "./src/common1.js" ); var _common2__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( "./src/common2.js" ); // _common1__WEBPACK_IMPORTED_MODULE_0__是導出對象 // 執行導出的common1方法 // 源碼js: // import { common1 } from "./common1"; // common1(); Object(_common1__WEBPACK_IMPORTED_MODULE_0__["common1"])(); Object(_common2__WEBPACK_IMPORTED_MODULE_1__["common2"])(); __webpack_require__ .e("asyncCommon2") .then(__webpack_require__.bind(null, "./src/asyncCommon2.js")) .then(({ asyncCommon2 }) => { asyncCommon2(); console.log("done"); });
於是,就可知道
- 同步
import
最終轉化成__webpack_require__
函數 - 異步
import
最終轉化成__webpack_require__.e
方法
整個 流程執行就是。
入口文件最開始通過__webpack_require__((__webpack_require__.s = "./src/a.js"))
加載入口的 js,(上面可以觀察到installedChunked
變量的初始值是{a:0}
,),並通過eval
執行 a.js 中的代碼。
__webpack_require__
可以說是整個 webpack 構建後代碼出現最多的東西了,那麼__webpack_require__
做了啥。
function __webpack_require__(moduleId) { // 如果一個模塊已經import加載過了,再次import的話就直接返回 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 之前沒有加載的話將它掛到installedModules進行緩存 var module = (installedModules[moduleId] = { i: moduleId, l: false, exports: {} }); // 執行相應的加載的模塊 modules[moduleId].call( module.exports, module, module.exports, __webpack_require__ ); // 設置模塊的狀態為已加載 module.l = true; // 返回模塊的導出值 return module.exports; }
這裡就很直觀了,這個函數接收一個moduleId
,對應於立即執行函數傳入參數的key
值。若一個模塊之前已經加載過,直接返回這個模塊的導出值;若這個模塊還沒加載過,就執行這個模塊,將它緩存到installedModules
相應的moduleId
為 key 的位置上,然後返回模塊的導出值。所以在 webpack 打包代碼中,import
一個模塊多次,這個模塊只會被執行一次。還有一個地方就是,在 webpack 打包模塊中,默認import
和require
是一樣的,最終都是轉化成__webpack_require__
。
完成同步加載後,入口 chunk 執行a.js
。
回到一個經典的問題,webpack
環境中如果發生循環引用會怎樣?a.js
有一個import x from './b.js'
、b.js
有一個import x from 'a.js'
。經過上面對__webpack_require__
的分析就很容易知道了。一個模塊執行之前,webpack
就已經先將它掛到installedModules
中。例如此時執行a.js
它引入b.js
,b.js
中又引入a.js
。此時b.js
中拿到引入a
的內容只是在a.js
當前執行的時候已經export
出的東西(因為已經掛到了installedModules
,所以不會重新執行一遍a.js
)。
接下來回到eval
內執行的a.js
模塊代碼片段,異步加載 js 部分。
// a.js模塊 __webpack_require__ .e("asyncCommon2") .then(__webpack_require__.bind(null, "./src/asyncCommon1.js")) // (1) 異步的模塊文件已經被注入到立即執行函數的入參`modules`變量中了,這個時候和同步執行`import`調用`__webpack_require__`的效果就一樣了 .then(({ asyncCommon2 }) => { //(2) 就能拿到對應的模塊,並且執行相關邏輯了(2)。 asyncCommon2(); console.log("done"); });
__webpack_require__.e
做的事情就是,根據傳入的chunkId
,去加載這個chunkId
對應的異步 chunk 文件,它返回一個promise
。通過jsonp
的方式使用script
標籤去加載。這個函數調用多次,還是只會發起一次請求 js 的請求。若已加載完成,這時候異步的模塊文件已經被注入到立即執行函數的入參modules
變量中了,這個時候和同步執行import
調用__webpack_require__
的效果就一樣了(這個注入由webpackJsonpCallback
函數完成)。此時,在promise
的回調中再調用__webpack_require__.bind(null, "./src/asyncCommon1.js")
(1) 就能拿到對應的模塊,並且執行相關邏輯了(2)。
// __webpack_require__.e 異步import調用函數 // 再回顧下上文提到的 chunk 的狀態位 // 記錄chunk的狀態位 // 值:0 表示已加載完成。 // undefined : chunk 還沒加載 // null :chunk preloaded/prefetched // Promise : chunk正在加載 var installedChunks = { a: 0 }; __webpack_require__.e = function requireEnsure(chunkId) { //...只保留核心代碼 var promises = []; var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { // chunk還沒加載完成 if (installedChunkData) { // chunk正在加載 // 繼續等待,因此只會加載一遍 promises.push(installedChunkData[2]); } else { // chunk 還沒加載 // 使用script標籤去加載對應的js var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push((installedChunkData[2] = promise)); // start chunk loading // var script = document.createElement("script"); var onScriptComplete; script.src = jsonpScriptSrc(chunkId); document.head.appendChild(script); //..... } // promise的resolve調用是在jsonpFunctionCallback中調用 return Promise.all(promises); };
再看看異步加載 asyncCommon1 chunk(也就是異步加載的 js) 的代碼大體結構。它做的操作很簡單,就是往jsonpFunction
這個全局數組push
(需要注意的是這個不是數組的 push,是被重寫為入口 chunk 的webpackJsonpCallback
函數)一個數組,這個數組由 chunk
名和該chunk
的 module 對象 一起組成。
// asyncCommon1 chunk (window["jsonpFunction"] = window["jsonpFunction"] || []).push([["asyncCommon1"],{ "./src/asyncCommon1.js": (function(module, __webpack_exports__, __webpack_require__) { eval(module代碼....); }) }]);
而執行webpackJsonpCallback
的時機,就是我們通過script
把異步 chunk 拿回來了(肯定啊,因為請求代碼回來,執行異步 chunk 內的push
方法嘛!)。結合異步 chunk 的代碼和下面的webpackJsonpCallback
很容易知道,webpackJsonpCallback
主要做了幾件事:
1、將異步chunk
的狀態位置 0,表明該 chunk 已經加載完成。installedChunks[chunkId] = 0;
2、對__webpack_require__.e
中產生的相應的 chunk 加載 promise 進行 resolve
3、將異步chunk
的模塊 掛載到入口chunk
的立即執行函數參數modules
中。可供__webpack_require__
進行獲取。上文分析 a.js 模塊已經提到了這個過程
// function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var moduleId, chunkId, i = 0, resolves = []; for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; if ( Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId] ) { resolves.push(installedChunks[chunkId][0]); } // 將當前chunk設置為已加載 installedChunks[chunkId] = 0; } for (moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { // 將異步`chunk`的模塊 掛載到入口`chunk`的立即執行函數參數`modules`中 modules[moduleId] = moreModules[moduleId]; } } // 執行舊的jsonPFunction // 可以理解為原生的數組Array,但是這裡很精髓,可以防止撞包的情況部分模塊沒加載! if (parentJsonpFunction) parentJsonpFunction(data); while (resolves.length) { // 對__webpack_require__.e 中產生的相應的chunk 加載promise進行resolve resolves.shift()(); } }
簡單總結:
1、經過 webpack 打包,每一個 chunk 內的模塊文件,都是組合成形如
{ [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){ eval('模塊文件源碼') } }
2、同一頁面多個 webpack 環境,output.jsonpFunction
盡量不要撞名字。撞了一般也是不會掛掉的。只是會在立即執行函數的入參modules
上掛上別的 webpack 環境異步加載的部分模塊代碼。(可能會造成一些內存的增加?)
3、每一個 entry chunk 入口都是一個類似的立即執行函數
(function(modules){ //.... })({ [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){ eval('模塊文件源碼') } })
4、異步加載的背後是用script
標籤去加載代碼
5、異步加載沒那麼神秘,對於當項目大到一定程度時,能有較好的效果
(本文地址因水平有限,如有錯誤歡迎拍磚)