webpack中非同步載入(懶載入)原理

語言: CN / TW / HK
ead>

highlight: zenburn theme: devui-blue


一、前言

本文是 從零到億系統性的建立前端構建知識體系✨ 中的第二篇,整體難度 ⭐️⭐️。

承接上文(從構建產物洞悉模組化原理),本文將繼續從分析構建產物出發,探索 Webpack 中非同步載入(懶載入)的原理,最後將徹底弄清楚懶載入是如何做到能夠加快應用初始載入速度的,整體深度閱讀時間約15分鐘。

在正式開始之前我們先看看幾個常見的相關面試題:

  • 在Webpack搭建的專案中,如何達到懶載入的效果?
  • 在Webpack中常用的程式碼分割方式有哪些?
  • Webpack中懶載入的原理是什麼?
  • ......

相信讀完本文,你對上面的一系列問題都能夠輕鬆解答。

二、前置知識

在正式內容開始之前,先來學一些預備小知識點,以免影響後面的學習。

懶載入或者按需載入,是一種很好的優化網頁或應用的方式。這種方式實際上是先把你的程式碼在一些邏輯斷點處分離開,然後在一些程式碼塊中完成某些操作後,立即引用或即將引用另外一些新的程式碼塊。這樣加快了應用的初始載入速度,減輕了它的總體體積,因為某些程式碼塊可能永遠不會被載入。

懶載入的本質實際上就是程式碼分離。把程式碼分離到不同的 bundle 中,然後按需載入或並行載入這些檔案

在Webpack中常用的程式碼分離方法有三種:

  • 入口起點:使用 entry 配置手動地分離程式碼。
  • 防止重複:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分離 chunk。
  • 動態匯入:通過模組的行內函數呼叫來分離程式碼。

今天我們的核心主要是第三種方式:動態匯入

當涉及到動態程式碼拆分時,Webpack 提供了兩個類似的技術:

  • 第一種,也是推薦選擇的方式是,使用符合 ECMAScript 提案 的 import()語法 來實現動態匯入
  • 第二種,則是 Webpack 的遺留功能,使用 Webpack 特定的 require.ensure (不推薦使用) ,本文不做探討

我們主要看看 import()語法 的方式。

import() 的語法十分簡單。該函式只接受一個引數,就是引用模組的地址,並且使用 promise 式的回撥獲取載入的模組。在程式碼中所有被 import() 的模組,都將打成一個單獨的模組,放在 chunk 儲存的目錄下。在瀏覽器執行到這一行程式碼時,就會自動請求這個資源,實現非同步載入。

常見使用場景:路由懶載入。

三、統一配置

為了防止出現我可以你不可以的情況,我們先統一配置:

js "webpack": "^5.73.0", "webpack-cli": "^4.10.0",

webpack.config.js 配置:

js module.exports = { mode: "development", devtool: false, entry: { main: "./src/main.js", }, output: { filename: "main.js", //定義打包後的檔名稱 path: path.resolve(__dirname, "./dist"), //必須是絕對路徑 }, };

四、import()基本使用

我們先來看看使用 import() 非同步載入的效果。

在 main.js 中同步匯入並使用:

```js const buttonEle = document.getElementById("button");

buttonEle.onclick = function () { import("./test").then((module) => { const print = module.default; print(); }); }; ```

test.js:

js export default () => { console.log("按鈕點選了"); };

先看打包結果:將 main.js 和 test.js 打包成了兩個檔案(說明有做程式碼分割)。

image.png

將打包後的檔案在 index.html 中引入(注意這裡只引用了 main.js ,並沒有引用 src_test_js.main.js ):

```js

Document

```

將 index.html 在瀏覽器中開啟,檢視網路請求:

jvd1c-z85av.gif

發現首次並沒有載入 src_test_js.main.js 檔案(也就是 test.js 模組),在點選按鈕後才會載入。符合懶載入的預期,確實有幫助我們做非同步載入。

五、原理分析

結合現象看本質。在上面我們主要了解了非同步載入的現象,接下來我們主要來分析和實現一下其中的原理。

老規矩,我們先說整體思路:

  • 第一步:當點選按鈕時,先通過 jsonp 的方式去載入 test.js 模組所對應的檔案
  • 第二步:載入回來後在瀏覽器中執行此JS指令碼,將請求過來的模組定義合併到 main.js 中的 modules 中去
  • 第三步:合併完後,去載入這個模組
  • 第四步:拿到該模組匯出的內容

整體程式碼思路(這裡函式命名跟原始碼有出入,有優化過):

image.png

第一步:當點選按鈕時,先通過 jsonp 的方式去載入 test.js 模組所對應的檔案

js const buttonEle = document.getElementById("button"); buttonEle.onclick = function () { require.e("src_test_js") //src_test_js是test.js打包後的chunkName };

接下來就去實現require.e函式:

js //接收chunkId,這裡其實就是 "src_test_js" require.e = function (chunkId) { let promises = []; //定義promises,這裡面放的是一個個promise require.j(chunkId, promises); //給promises賦值 return Promise.all(promises); //只有當promises中的所有promise都執行完成後,才能走到下一步 }; require.j函式:這一步其實就是給promises陣列賦值,並通過jsonp去載入檔案

```js //已經安裝好的程式碼塊,main.js就是對應的main程式碼塊,0表示已經載入成功,已經就緒 var installedChunks = { main: 0, };

//這裡傳入的是 "src_test_js" , [] require.j = function (chunkId, promises) { var promise = new Promise((resolve, reject) => { installedChunks[chunkId] = [resolve, reject]; //此時installedChunks={ main: 0, "src_test_js":[ resolve, reject ]} }); promises.push(promise); //此時promises=[ promise ]

var url = require.publicPath + chunkId + ".main.js"; //拿到的結果就是test.js打包後輸出的檔名稱:src_test_js.main.js,publicPath就是我們在output中配置的publicPath,預設是空字串 let script = document.createElement("script"); script.src = url; document.head.appendChild(script); //將該指令碼新增進來 }; ```

第二步:載入回來後在瀏覽器中執行此JS指令碼,將請求過來的模組定義合併到 main.js 中的 modules 中去

在第一步中我們通過jsonp的方式載入了src_test_js.main.js檔案,載入後需要立即執行該檔案的內容,我們先來看看該檔案長什麼樣子:

js self["webpackChunkstudy"].push([ ["src_test_js"], { "./src/test.js": (modules, exports, require) => { require.defineProperty(exports, { default: () => WEBPACK_DEFAULT_EXPORT, }); const WEBPACK_DEFAULT_EXPORT = () => { console.log("按鈕點選了"); }; }, }, ]); 這裡的self其實就是windowwebpackChunkstudy就是一個名字,它是webpackChunk + 我們package.json 中的 name 欄位拼接來的,我這裡是study。

翻譯過來就是要執行 window.webpackChunkstudy.push([xxx])這個函式,那接下來我們就實現一下它:接受一個二維陣列作為引數,二維陣列中,第一項是moduleId,第二項是模組定義:

```js //初始化:預設情況下這裡放的是同步程式碼塊,這裡的demo因為沒有同步程式碼,所以是一個空的模組物件 var modules = {};

//這裡chunkIds=["src_test_js"] moreModules={xxx} test.js檔案的模組定義 function webpackJsonpCallback([chunkIds, moreModules]) { const resolves = []; for (let i = 0; i < chunkIds.length; i++) { const chunkId = chunkIds[i];//src_test_js resolves.push(installedChunks[chunkId][0]); //此時installedChunks={ main: 0, "src_test_js":[ resolve, reject ]} ,將 src_test_js 的resolve放到resolves中去 installedChunks[chunkId] = 0; //標識一下程式碼已經載入完成了 }

for (const moduleId in moreModules) { modules[moduleId] = moreModules[moduleId]; //合併modules,此時modules中有了test.js的程式碼 }

while (resolves.length) { resolves.shift()(); //執行promise中的resolve,當所有promises都resolve後,接下來執行第三步 } }

window.webpackChunkstudy.push = webpackJsonpCallback; ```

此時 modules 已經變為:

js var modules = { "./src/test.js": (modules, exports, require) => { require.defineProperty(exports, { default: () => WEBPACK_DEFAULT_EXPORT, }); const WEBPACK_DEFAULT_EXPORT = () => { console.log("按鈕點選了"); }; }, };

第三步:合併完後,去載入這個模組

走到這裡require.e函式中的 Promise.all 已經走完,接下來走到第一個.then處:require.bind(require, "./src/test.js")

js require.e("src_test_js") //完成第一步和第二步的工作 .then(require.bind(require, "./src/test.js")) //完成第三步

require函式與之前相同,不做過多的贅述,大家可以看前一篇文章:從構建產物洞悉模組化原理。這裡直接拷貝過來:

```js //已經載入過的模組 var cache = {};

//相當於在瀏覽器中用於載入模組的polyfill function require(moduleId) { var cachedModule = cache[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } var module = (cache[moduleId] = { exports: {}, }); modulesmoduleId; return module.exports; }

require.defineProperty = (exports, definition) => { for (var key in definition) { Object.defineProperty(exports, key, { enumerable: true, get: definition[key], }); } }; `` 這裡執行完require.bind(require, "./src/test.js")後,返回的是一個export`物件:

js { default: () => { console.log("按鈕點選了"); } //因為這裡是預設匯出,所以是default }

第四步:拿到該模組匯出的內容

js require.e("src_test_js") //完成第一步和第二步的工作 .then(require.bind(require, "./src/test.js")) //完成第三步:前面程式碼載入併合並完後,去執行該模組程式碼 .then((module) => { //完成第四步 const print = module.default; print(); }); 在第三步中匯出的是一個export物件,又因為是預設匯出,所以這裡取值是module.default,走到這裡就完全走完啦。

六、整體程式碼

打包後的main.js(經優化):

```js //初始化:預設情況下這裡放的是同步程式碼塊,這裡的demo因為沒有同步程式碼,所以是一個空的模組物件 var modules = {};

//已經載入過的模組 var cache = {};

//相當於在瀏覽器中用於載入模組的polyfill function require(moduleId) { var cachedModule = cache[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } var module = (cache[moduleId] = { exports: {}, }); modulesmoduleId; return module.exports; }

require.defineProperty = (exports, definition) => { for (var key in definition) { Object.defineProperty(exports, key, { enumerable: true, get: definition[key], }); } };

//已經安裝好的程式碼塊,main.js就是對應的main程式碼塊,0表示已經載入成功,已經就緒 var installedChunks = { main: 0, };

require.publicPath = ""; //output中的publicPath屬性

require.j = function (chunkId, promises) { var promise = new Promise((resolve, reject) => { installedChunks[chunkId] = [resolve, reject]; }); promises.push(promise); var url = require.publicPath + chunkId + ".main.js"; let script = document.createElement("script"); script.src = url; document.head.appendChild(script); };

function webpackJsonpCallback([chunkIds, moreModules]) { const resolves = []; for (let i = 0; i < chunkIds.length; i++) { const chunkId = chunkIds[i]; resolves.push(installedChunks[chunkId][0]); installedChunks[chunkId] = 0; //標識一下程式碼已經載入完成了 }

for (const moduleId in moreModules) { modules[moduleId] = moreModules[moduleId]; //合併modules }

while (resolves.length) { resolves.shift()(); } } self.webpackChunkstudy = {}; self.webpackChunkstudy.push = webpackJsonpCallback;

require.e = function (chunkId) { let promises = []; require.j(chunkId, promises); return Promise.all(promises); };

const buttonEle = document.getElementById("button"); buttonEle.onclick = function () { require .e("src_test_js") .then(require.bind(require, "./src/test.js")) .then((module) => { const print = module.default; print(); }); }; ```

打包後的test.js:

js self["webpackChunkstudy"].push([ ["src_test_js"], { "./src/test.js": (modules, exports, require) => { require.defineProperty(exports, { default: () => WEBPACK_DEFAULT_EXPORT, }); const WEBPACK_DEFAULT_EXPORT = () => { console.log("按鈕點選了"); }; }, }, ]);

七、總結

上面我們差不多用50行程式碼寫了一個簡易demo實現了懶載入原理,在該demo中當然還有一些場景沒有考慮進去:比如當點選按鈕時,只需第一次載入時去請求檔案,後面載入時應該要去使用快取。但這並不是重點,希望通過本章大家能夠更加深入理解Webpack中的懶載入,早日擺脫API工程師。

八、推薦閱讀

  1. 從零到億系統性的建立前端構建知識體系✨
  2. 我是如何帶領團隊從零到一建立前端規範的?🎉🎉🎉
  3. 手寫hooks系列
  4. 淺析前端異常及降級處理
  5. 前端重新部署後,領導跟我說頁面崩潰了...
  6. 前端場景下的搜尋框,你真的理解了嗎?
  7. 前端React最佳實踐
  8. 手把手教你實現React資料持久化機制
  9. 面試官:你確定多視窗之間sessionStorage不能共享狀態嗎???🤔
  10. 如何封裝一個不被同事噴的元件?