webpack構建流程
webpack是時下最流行的前端打包構建工具,本質上是一個模組打包器,通過從入口檔案開始遞迴的分析尋找模組之間的依賴,最終輸出一個或多個bundle檔案。
webpack的構建是一個序列的流程,從啟動到結束,會依次執行以下流程:
-
初始化配置
從配置檔案和命令列中讀取引數併合並引數,生成最終的配置項,並且執行配置檔案中的外掛例項化語句,生成Compiler傳入plugin的apply方法,為webpack事件流掛上自定義鉤子;
-
開始編譯
生成compiler示例,執行compiler.run開始編譯;
-
確定入口檔案
從配置項中讀取所有的入口檔案;
-
編譯模組
從入口檔案開始編譯,使用對應的loader編譯模組,並且遞迴的編譯當前模組所依賴的模組,在所有的模組都編譯完成後,得到所有模組的最終內容和模組之間的依賴關係,最後將所有模組的
require
語句替換為__webpack_require__
來模擬模組化操作; -
資源輸出
根據入口和模組的依賴關係,組裝成一個個包含多個模組的chunk,然後將chunk轉換成一個單獨的檔案加入輸出列表;
-
生成檔案
將生成的內容根據配置生成檔案,輸出到指定的位置。
webpack的核心物件是Compile,負責檔案的監聽和啟動編譯,繼承自Tapable[github.com/webpack/tap…],使得Compile例項具備了註冊和呼叫外掛的功能。
在webpack執行構建流程時,webpack會在特定的時機廣播對應的事件,外掛在監聽到事件後,會執行特定的邏輯來修改模組的內容。
通過下面這個流程圖我們能夠對webpack的構建流程有個更直觀的印象:
webpack輸出檔案分析
下面,我們將通過分析webpack輸出的bundle檔案,瞭解bundle檔案是如何在瀏覽器中執行的。
單檔案分析
首先建立 src/index.js
,執行一個最簡單的js語句:
console.log('hello world')
複製程式碼
建立 webpack.config.js
, 配置如下:
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist')
}
}
複製程式碼
本例中使用的webpack版本為4.35.3,此處為了更好的分析輸出的bundle檔案,將mode設定為'none',此時webpack不會預設啟用任何外掛。
mode有三個可選值,分別是'none'、'production'、'development',預設值為'production',預設開啟以下外掛:
-
FlagDependencyUsagePlugin:編譯時標記依賴;
-
FlagIncludedChunksPlugin:標記子chunks,防止多次載入依賴;
-
ModuleConcatenationPlugin:作用域提升(scope hosting),預編譯功能,提升或者預編譯所有模組到一個閉包中,提升程式碼在瀏覽器中的執行速度;
-
NoEmitOnErrorsPlugin:在輸出階段時,遇到編譯錯誤跳過;
-
OccurrenceOrderPlugin:給經常使用的ids更短的值;
-
SideEffectsFlagPlugin:識別 package.json 或者 module.rules 的 sideEffects 標誌(純的 ES2015 模組),安全地刪除未用到的 export 匯出;
-
TerserPlugin:壓縮程式碼
mode值為'development'時,預設開啟以下外掛:
-
NamedChunksPlugin:以名稱固化chunkId;
-
NamedModulesPlugin:以名稱固化moduleId
執行webpack構建命令:
$ webpack
複製程式碼
輸出到dist資料夾中的 main.js
檔案內容如下:
(function(modules) { // webpackBootstrap
// 模組快取
var installedModules = {};
// 模組載入函式
function __webpack_require__(moduleId) {
// 如果載入過該模組,則直接從快取中讀取
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 建立新模組並將其快取起來
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 執行模組函式,設定module.exports
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 將module標記為已載入
module.l = true;
// 返回設定好的module.exports
return module.exports;
}
// 指向modules
__webpack_require__.m = modules;
// 指向快取
__webpack_require__.c = installedModules;
// 定義exports的get方式
__webpack_require__.d = function(exports, name, getter) {
if(!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter });
}
};
// 設定es6模組標記
__webpack_require__.r = function(exports) {
if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};
// create a fake namespace object
// mode & 1: value is a module id, require it
// mode & 2: merge all properties of value into the ns
// mode & 4: return value when already ns object
// mode & 8|1: behave like require
__webpack_require__.t = function(value, mode) {
if(mode & 1) value = __webpack_require__(value);
if(mode & 8) return value;
if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
var ns = Object.create(null);
__webpack_require__.r(ns);
Object.defineProperty(ns, 'default', { enumerable: true, value: value });
if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
return ns;
};
// 相容commonjs和es6模組
__webpack_require__.n = function(module) {
var getter = module && module.__esModule ?
function getDefault() { return module['default']; } :
function getModuleExports() { return module; };
__webpack_require__.d(getter, 'a', getter);
return getter;
};
// Object.prototype.hasOwnProperty的封裝
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
// webpack配置的publicpath
__webpack_require__.p = "";
// 載入模組並返回
return __webpack_require__(__webpack_require__.s = 0);
})
/************************************************************************/
([
/* 0 */
/***/ (function(module, exports) {
console.log('hello world')
/***/ })
]);
複製程式碼
可以看到輸出的程式碼是個IIFE(立即執行函式),可以簡化如下:
(function(modules) {
var installedModules = {};
// webpack require語句
// 載入模組
function __webpack_require__(moduleId) {}
return __webpack_require__(0)
})([
function(module, exports) {
console.log('hello world')
}
])
複製程式碼
簡化後程式碼中的 __webpack_require__
函式起到的就是載入模組的功能,IIFE函式接收的引數是個陣列,第0項內容便是 src/index.js
中的程式碼語句,通過 __webpack_require__
函式載入並執行模組,最終在瀏覽器控制檯輸出 hello world
。
接下來我們通過程式碼分析下 __webpack_reuqire__
函式內部是如何工作的
function __weboack_require__(moduleId) {
// 如果已經載入過該模組,則從快取中直接讀取
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 如果沒有載入過該模組,則建立一個新的module存入快取中
var module = installedModules[moduleId] = {
i: moduleId, // module id
l: false, // 是否已載入 false
exports: {} // 模組匯出
};
// 執行該module
// call方法第一個引數為modules.exports,是為了module內部的this指向該模組
// 然後傳入三個引數,分別為module, module.exports, __webpack_require__模組載入函式
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 設定module為已載入
module.l = true;
// 最終返回module.exports
return module.exports;
}
}
複製程式碼
可以看到 __webpack_require__
函式接收一個模組id,通過執行該模組,最終返回該模組的exports,並將模組快取在記憶體中。如果再次載入該模組, 則直接從快取中讀取。 modules[modulesId]
的內容是IIFE引數的第0項,即:
function(module, exports) {
console.log('hello world')
}
複製程式碼
在匯出的IIFE中,除了 __webpack_require__
函式,還在 __webpack_require__
下掛載了很多屬性.
__webpack_require__.m
: 掛載所有的modules;__webpack_require__.c
: 掛載已快取的modules;__webpack_require__.d
: 定義exports的getter;__webpack_require__.r
: 將module設定為es6模組;__webpack_require__.t
: 根據不同的場景返回對應處理後的模組或值;__webpack_require__.n
: 返回getter,內部區分是否為es6模組;__webpack_require__.o
: Object.prototype.hasOwnProperty功能封裝;__webpack_require__.p
: output配置項中的publicPath屬性;
多檔案引用分析
在前面的例子中,webpack打包的bundle中只包含一個非常簡單的入口檔案,並不存在模組之間的引用。
下面我們修改下 src/index.js
中的程式碼,引用一個ES6模組 src/math.js
進來:
// math.js
const add = function (a, b) {
return a + b
}
export default add
複製程式碼
// index.js
import add from './math'
console.log(add(1, 2))
複製程式碼
重新執行webpack打包命令,可以看到輸出的IIFE中的引數已經變成了兩項:
([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
console.log(Object(_math__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2))
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
const add = function (a, b) {
return a + b
}
/* harmony default export */ __webpack_exports__["default"] = (add);
/***/ })
]);
複製程式碼
陣列第1項中定義了 math.js
模組,並且通過執行 __webpack_require__.r(__webpack_exports__)
使得webpack能夠識別出該模組是個ES6模組,最後將 __webpack_exports__
的 default
屬性值設定為函式 add
。
陣列第0項是 index.js
打包後輸出的模組,語句 var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1)
的功能即是將模組 math.js
匯出的 add
函式引進來, __webpack_require__(1)
返回 module.exports
,其中 1
是由webpack在打包時生成的chunkId,最後通過 console.log(Object(_math__WEBPACK_IMPORTED_MODULE_0__["default"])(1, 2))
執行 index.js
中的語句。
webpack通過將原本獨立的一個個模組存放到IIFE的引數中來載入,從而達到只進行一次網路請求便可執行所有模組,避免了通過多次網路載入各個模組造成的載入時間過長的問題。並且在IIFE函式內部,webpack也對模組的載入做了進一步優化,通過將已經載入過的模組快取起來存在記憶體中,第二次載入相同模組時便直接從記憶體中取出。
非同步載入分析
上面兩個例子都是同步載入模組並執行,但是在實際專案中為了提高頁面的載入速度,往往對首屏初始化時暫時用不到的模組進行非同步載入,比如從首頁跳轉後的路由模組等。接下來我們將通過非同步載入的方式來載入 math.js
模組並執行其匯出的 add
函式。
import('./math').then((add) => {
console.log(add(1, 2))
})
複製程式碼
重新打包後,輸出 main.js
和 1.js
,1.js
是需要非同步載入的檔案。
先分析入口檔案 main.js
,可以看到相對於同步載入方式的程式碼輸出,檔案中多了 __webpack_require__.e
和 webpackJsonpCallback
函式,IIFE中的引數也只有一個:
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, 1)).then((add) => {
console.log(add(1, 2))
})
/***/ })
複製程式碼
該模組通過 __webpack_require__.e(1)
的方式載入模組1的檔案,載入成功後再通過執行 __webpack_require__.bind(null, 1)
返回模組1,然後執行該模組匯出的 add
函式。
__webpack_require__.e
的作用便是載入需要非同步載入的模組,函式的內容如下:
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 如果為0則代表已經載入過該模組
// installedChunkData 不為空且不為0表示該 Chunk 正在網路載入中
// 直接返回promise物件
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// 該chunk從未被載入過,返回陣列包含三項,分別是resolve,reject和建立的promise物件
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 建立script標籤,載入模組
var script = document.createElement('script');
var onScriptComplete;
script.charset = 'utf-8';
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
// jsonpScriptSrc的作用是返回根據配置的publicPath和chunkId生成的檔案路徑
script.src = jsonpScriptSrc(chunkId);
// 建立一個Error例項,用於在載入錯誤時catch
var error = new Error();
onScriptComplete = function (event) {
// 防止記憶體洩漏
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
// chunk載入失敗,丟擲錯誤
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
error.name = 'ChunkLoadError';
error.type = errorType;
error.request = realSrc;
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
// 非同步載入最長等待時間120s
var timeout = setTimeout(function () {
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
// 將建立的script標籤插入dom中
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
複製程式碼
函式內部先判斷是否載入過該模組,如果沒有載入過,則建立一個script
標籤,script
的路徑是通過內部的 jsonpScriptSrc
函式根據webpack的配置生成最終的src路徑返回得到。函式最終返回一個 Promise
物件,js檔案載入失敗時則會執行 reject
將錯誤丟擲。
math.js
輸出的bundle 1.js
的內容很簡單,程式碼如下:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],[
/* 0 */,
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
const add = function (a, b) {
return a + b
}
/* harmony default export */ __webpack_exports__["default"] = (add);
/***/ })
]]);
複製程式碼
可以看到該bundle的作用就是向 window['webpackJsonp']
陣列中push了一個新的陣列,其中第一項 [1]
是webpack生成的chunkId,第二項是 math.js
轉換後的模組具體內容。
與此同時,在 main.js
中IIFE的後部分,對掛載在全域性的 window['webpackJsonp']
陣列的push方法進行了重寫,指向了在前面定義過的 webpackJsonpCallback
函式:
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
// 將data第1項模組新增到modules中,
// 然後將對應的chunkId標記為已載入
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;
}
// 將傳進來的moreModules陣列中的每一個模組依次新增到IIFE中快取的modules中
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
// parentJsonpFunction為window['webpackJsonp']中原聲的陣列push方法
// 執行parentJsonpFunction將data真正的新增到window['webpackJsonp']陣列中去
if(parentJsonpFunction) parentJsonpFunction(data);
// 將前面建立的promise執行resolve
while(resolves.length) {
resolves.shift()();
}
};
複製程式碼
通過分析 webpackJsonpCallback
函式的內容,可以看到該函式的主要作用是將傳入的chunkid標記為已載入,並將傳入的模組掛在到快取模組的 modules
物件上,最終執行 __webpack_require__.e
函式返回的promise物件的resolve方法代表該非同步載入的模組已經載入完成,此時,在 __webpack_require__.e(1).then()
中便可以通過同步載入模組的方式載入該模組啦。
重新梳理一下入口主檔案載入非同步模組的大概流程:
- 執行
__webpack_require__.e
載入非同步模組; - 建立chunkid對應的script標籤載入指令碼,並返回promise;
- 如果載入失敗,reject掉promise;如果載入成功,非同步chunk立即執行
window[webpackJsonp]
的push方法,將模組標記為已載入,並resolve掉相應的promise; - 成功後可在
__webpack_require__.e().then
中以同步的方式載入模組。
輸出檔案總結
在webpack輸出的檔案中,通過IIFE的形式將所有模組作為引數都傳遞進來,用 __webpack_require__
模擬import或者require語句,然後從入口模組開始依次遞迴的執行載入模組,需要非同步載入的模組,通過在dom上插入一個新的script標籤載入。並且內部對模組載入做了快取處理優化。
在實際的專案中,輸出的bundle內容會遠比本文中的demo複雜的多,並且會有chunkId設定,公共chunk抽取,程式碼壓縮混淆等優化,但是可以通過這個最基本的demo,熟悉webpack輸出的檔案在執行時的工作流程,便於我們在除錯時更好的分析。
編寫一個簡單的loader
在編寫一個loader之前,先簡單介紹下webpack loader的作用。在webpack中,可以將loader理解為一個轉換器,通過處理檔案的輸入,返回一個新的結果,最終交給webpack進行下一步的處理。
一個loader就是一個nodejs模組,它的基本結構如下:
// 可以通過loader-utils這個包獲取該loader的配置項options
const loaderUtils = require('loader-utils')
// 匯出一個函式,source為webpack傳遞給loader的檔案源內容
module.exports = function(source) {
// 獲取該loader的配置項
const options = loaderUtils.getOptions(this)
// 一些轉換處理,最終返回處理後的結果。
return source
}
複製程式碼
在平時配置webpack loader的時候,都是使用通過npm安裝的loader,為了載入本地的loader,一般有兩種方式,第一種是通過npm link的方式將loader關聯到專案的node_modules下,還有一種方式是通過配置wepack的resolveLoader.modules配置項,告訴webpack通過何種形式尋找loader。第一種方式需要配置相關的 package.json
,在本例中使用第二種方式配置。
module.exports = {
resolveLoader: {
// 假設本地編寫的loader在loaders資料夾下
modules: ['node_modules', './loaders/']
}
}
複製程式碼
下面我們編寫一個loader,用於刪除程式碼中的註釋。命名為remove-comment-loader:
module.exports = function(source) {
// 匹配js中的註釋內容
const reg = new RegExp(/(\/\/.*)|(\/\*[\s\S]*?\*\/)/g)
// 刪除註釋
return source.replace(reg, '')
}
複製程式碼
然後修改webpack.config.js:
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/index.js',
module: {
rules: [
{
test: /\.js$/,
loader: 'remove-comment-loader' // 當匹配到js檔案時,使用我們編寫的remove-comment-loader
}
]
},
output: {
path: path.resolve(__dirname, 'dist')
},
resolveLoader: {
modules: ['node_modules', './loaders/'] // 配置載入本地loader
}
}
複製程式碼
然後在入口檔案程式碼中加上一些註釋,重新打包檢視輸出檔案,就能看到程式碼中的註釋已經被刪除了。
本文中的demo程式碼參見;github.com/duwenbin031…
在此處順便向大家推薦下民生科技公司Firefly移動金融開發平臺中的前端打包構建工具apollo-build。apollo-build包含開發除錯、打包、測試、
和打包dll的功能,並且提供了非常好用的前端介面Mock功能,命令列體驗和create-react-app一致。我們封裝了webpack中的大部分常用功能並在內部做了很多優化,從中提取出了最常用的配置項,即使不熟悉webpack的配置也能快速上手,並且也支援通過 webpack.config.js
的方式做高階的修改,歡迎訪問民生科技官網瞭解。
參考
《深入淺出webpack》 - 吳浩麟
作者介紹
杜文斌 民生科技有限公司 使用者體驗技術部 Firefly移動金融開發平臺前端開發工程師