webpack系列之一總覽

滴滴WebApp架構組發表於1970-01-01

作者:崔靜

引言

webpack 對於每個前端兒來說都不陌生,它將每個靜態檔案當成一個模組,經過一系列的處理為我們整合出最後的需要的 js、css、圖片、字型等檔案。來自官網的圖很形象的闡述了 webpack 的功能 —— bundle js / css / ... (打包全世界ヾ(◍°∇°◍)ノ゙)

webpack

寫在前面的話

在閱讀一個東西的原始碼之前,首先需要了解這個東西是什麼,怎麼用。這樣在閱讀原始碼過程中才能在大腦中形成一副整體的認知。所以,先了解一下 webpack 打包前後程式碼發生了什麼?找一個簡單的例子

入口檔案為 main.js, 在其中引入了 a.js, b.js

// main.js
import { A } from './a'
import B from './b'
console.log(A)
B()
複製程式碼
// a.js
export const A = 'a'
複製程式碼
// b.js
export default function () {
    console.log('b')
}
複製程式碼

經過 webpack 的一番蹂躪,最後變成了一個檔案:bundle.js。先忽略細節,看最外面的程式碼結構

(function(modules){
  ...(webpack的函式)
  return __webpack_require__(__webpack_require__.s = "./demo01/main.js");
})(
 {
   "./demo01/a.js": (function(){...}),
   "./demo01/b.js": (function(){...}),
   "./demo01/main.js": (function(){...}),
 }
)
複製程式碼

最外層是一個立即執行函式,引數是 modules。 a.js、b.js 和 main.js 最後被編譯成三個函式(下文將這三個函式稱為 module 函式),key 是檔案的相對路徑。bundle.js 會執行到 __webpack_require__(__webpack_require__.s = "./demo01/main.js"); 即通過 __webpack_require__('./demo01/main.js') 開始主入口函式的執行。

通過 bundle.js 的主介面可以清晰的看出,對於 webpack 每個檔案就是一個 module。 我們寫的 import 'xxx',則最終為 __webpack_require__ 函式執行。更多的時候我們使用的是 import A from 'xxx' 或者 import { B } from 'xxx' ,可以猜想一下,這個 __webpack_require__ 函式中除了找到對應的 'xxx' 來執行,還需要一個返回 'xxx' 中 export 出來的內容。

function __webpack_require__(moduleId) {
	// Check if module is in cache
	if(installedModules[moduleId]) {
		return installedModules[moduleId].exports;
	}
	// Create a new module (and put it into the cache)
	var module = installedModules[moduleId] = {
		i: moduleId,
		l: false,
		exports: {}
	};
	// Execute the module function
	modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
	// Flag the module as loaded
	module.l = true;
	// Return the exports of the module
	return module.exports;
}
複製程式碼

呼叫每一個 module 函式時,引數為 modulemodule.exports__webpack_require__module.exports 用來收集 module 中所有的 export xxx 。看 ”./demo/a.js“ 的 module

(function(module, __webpack_exports__, __webpack_require__) {

// ...
__webpack_require__.d(__webpack_exports__, "A", function() { return A; });
const A = 'a'

/***/ })

// ...
__webpack_require__.d = function(exports, name, getter) {
	if(!__webpack_require__.o(exports, name)) {
		Object.defineProperty(exports, name, {
			configurable: false,
			enumerable: true,
			get: getter
		});
	}
};
// Object.prototype.hasOwnProperty.call
__webpack_require__.o = function(object, property) { 
	return Object.prototype.hasOwnProperty.call(object, property); 
};
// ...
複製程式碼

__webpack_require__.d(__webpack_exports__, "A", function() { return A; }); 簡單理解就是

__webpack_exports__.A = A;
複製程式碼

__webpack_exports__ 實際為上面的 __webpack_require__ 中傳入的 moule.exports, 如此,就將 A 變數收集到了 module.exports 中。如此我們的

  import { A } from './a.js' 
  console.log(A)
複製程式碼

就編譯為

  var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./demo/a.js"); 
  console.log(_a__WEBPACK_IMPORTED_MODULE_0__["A"])
複製程式碼

對於 b.js 我們使用的是 export default,webpack 處理後,會在 module.exports 中增加一個 default 屬性。

__webpack_exports__["default"] = (function () {
    console.log('b')
});
複製程式碼

最後 import B from './b.js 編譯為

var _b__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./demo/b.js")
Object(_b__WEBPACK_IMPORTED_MODULE_1__["default"])()
複製程式碼

非同步載入

在 webpack 中我們可以很方便的實現非同步載入,以簡單的 demo 入手

// c.js
export default {
  key: 'something'
}
複製程式碼
// main.js
  import('./c').then(test => {
    console.log(test)
})
複製程式碼

打包結果,非同步載入的 c.js,最後打包在一個單獨的檔案 0.js 中

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{
"./demo/c.js": (function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_require__.r(__webpack_exports__);
  __webpack_exports__["default"] = ({
  		key2: 'key2'
	});
  })
}]);
複製程式碼

簡化一下,執行的就是

var t = window["webpackJsonp"] = window["webpackJonsp"] || [])
t.push([[0], {function(){...}}])
複製程式碼

執行 import('./c.js') 時,實際上通過在 HTML 中插入一個 script 標籤載入 0.js。 0.js 載入後會執行 window["webpackJsonp"].push 方法。 在 main.js 在還有一段:

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
jsonpArray.push = webpackJsonpCallback;
複製程式碼

這裡篡改了一下, window["webpackJsonp"] 的 push 方法,將 push 方法外包裝了一層 webpackJonspCallback 的邏輯。當 0.js 載入後,會執行 window["webpackJsonp"].push ,這時便會進入 webpackJsonpCallback 的執行邏輯。

function webpackJsonpCallback(data) {
    var chunkIds = data[0];
    var moreModules = data[1];
    // add "moreModules" to the modules object,
    // then flag all "chunkIds" as loaded and fire callback
    var moduleId, chunkId, i = 0, resolves = [];
    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];
        }
    }
    if(parentJsonpFunction) parentJsonpFunction(data);
    while(resolves.length) {
        resolves.shift()();
    }
};
複製程式碼

在 webpackJsonpCallback 中會將 0.js 中的 chunks 和 modules 儲存到全域性的 modules 變數中,並設定 installedChunks 的標誌位。

有兩點需要詳細說明的:

  1. 我們知道 import('xxx.js') 會返一個 Promise 例項 promise,在 webpack 打包出來的最終檔案中是如何處理這個 promise 的?

    在載入 0.js 之前會在全域性 installedChunks 中先存入了一個 promise 物件

    installedChunks[chunkId] = [resolve, reject, promise]
    複製程式碼

    resolve 這個值在 webpackJsonpCallback 中會被用到,這時就會進入到我們寫的 import('./c.js').then() 的 then 語句中了。

  2. 在 main.js 中處理 webpackJsonp 過程中還有一段特殊的邏輯:

    jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
    var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
    ...
    jsonpArray = jsonpArray.slice();
    for(var i = 0; i < jsonpArray.length; i++)  webpackJsonpCallback(jsonpArray[i]);
    var parentJsonpFunction = oldJsonpFunction;
    複製程式碼

    也就是說如果之前已經存在全域性的 window["webpackJsonp"] 那麼在替換其 push 函式之前會將原有的 push 方法儲存為 oldJsonpFunction,同時將已存在於 window["webpackJsonp"] 中的內容,一一執行 webpackJsonpCallback。並且在 webpackJsonpCallback 中也將非同步載入的內容也會在 parentJsonpFunction 中同樣執行一次

    if(parentJsonpFunction) parentJsonpFunction(data);
    複製程式碼

    這樣的同步意義何在?試想下面的場景,webpack 中多入口情況下,例如如下配置

    {
     entry: {
       bundle1: 'bundle1.js',
       bundle2: 'bundle2.js'
     }
    }
    複製程式碼

    並且 bundle1 和 bundle2 中都用到了非同步載入了 0.js。而且在同一個頁面中同時載入了 bundle1 和 bundle2。那麼由於上面的邏輯,執行的流程如下圖:

    動態載入流程

    通過上圖可以看到,這樣設計對於多入口的地方,可以將 bundle1.js 和 bundle2.js 中非同步模組進行同步,這樣不僅保證了 0.js 可以同時在兩個檔案中被引用,而且不會重複載入。

非同步載入中,有兩個需要注意的地方:

  • Promise

    在 webpack 非同步載入使用了 Promise。要相容低版本的安卓,比如4.x 的程式碼來說,需要有全域性的 Promise polyfill。

  • window["webpackJsonp"]

    如果一個 HTML 頁面中,會載入多個 webpack 獨立打包出來的檔案。那麼這些檔案非同步載入的回撥函式,預設都叫 "webpackJonsp",會相互衝突。需要通過 output.jsonpFunction 配置修改這個預設的函式名稱。

webpack 編譯總流程

知道上面的產出,根據產出看 webpack 的總流程。這裡我們暫時不考慮 webpack 的快取、錯誤處理、watch 等邏輯,只看主流程。 首先會有一個入口檔案寫在配置檔案中,確定 webpack 從哪個檔案開始處理。

step1 webpack 配置檔案處理

我們在寫配置檔案中 entry 的時候,肯定寫過 ./main.js 這時一個相對目錄,所以會有一個將相對目錄變成絕對目錄的處理

step2 檔案位置解析

webpack 需要從入口檔案開始,順藤摸瓜找到所有的檔案。那麼會有一個

step3 載入檔案 step4 檔案解析 step5 從解析結果中找到檔案引入的其他檔案

在載入檔案的時候,我們會在 webpack 中配置很多的 loaders 來處理 js 檔案的 babel 轉化等等,還應該有檔案對應的 loader 解析,loader 執行。

step3.1 找到所有對應的 loader,然後逐個執行

處理完整入口檔案之後,得到依賴的其他檔案,遞迴進行處理。最後得到了所有檔案的 module 。最終輸出的是打包完成的 bundle 檔案。所以會有

step4 module 合併成 chunk 中 輸出最終檔案

根據 webpack 的使用和結果,我們猜測了一下 webpack 中大概的流程。然後看一下 webpack 的原始碼,並和我們腦中的流程對比一下。實際的 webpack 流程圖如下:

webpack流程

對整體框架和流程有了大致的概念之後,我們可以將原始碼拆分為一部分一部分來詳細閱讀。後續會通過一系列文章一一介紹:

  • 底層 Tapable 介紹

    webpack 的底層使用的 Tapable 用來處理各種型別的 hook,這部分主要介紹 Tapable 原理,已更新,點選檢視

  • reslove 過程

    webpack 中我們所寫的各種相對路徑/絕對路徑,alias 等是如何被處理,最終找到正確的執行檔案的。已更新,點選檢視

  • loaders 處理

    寫在 webpack 配置中,各種 loaders 如何被載入、解析;已更新 webpack loader詳解1 webpack loader詳解2 webpack loader詳解3

  • module 生成

    js檔案如何被解析,分析出依賴,同時遞迴處理所有的依賴;已更新 module 生成1 module 生成2

  • chunk 生成

    專案中各個檔案之間的依賴圖的生成,以及根據定義的規則,module 最終如何聚合為 chunk。

  • 最終檔案的生成

    經歷了上面的所有過程後,記憶體中儲存了生成檔案的各種資訊,這些資訊如何整合吐出最終真正執行的所有檔案。

相關文章