現代 web 開發者們對於 webpack 想必已經很熟悉了,webpack-dev-server 幾乎都是標配。可是 webpack-dev-server 背後的運行原理是怎樣的呢?想了解 how 咱們先看看 what。vue
webpack 將咱們的項目源代碼進行編譯打包成可分發上線的靜態資源,在開發階段咱們想要預覽頁面效果的話就須要啓動一個服務器伺服 webpack 編譯出來的靜態資源。webpack-dev-server 就是用來啓動 webpack 編譯、伺服這些靜態資源,node
除此以外,它還默認提供了liveReload的功能,就是在一次 webpack 編譯完成後瀏覽器端就能自動刷新頁面讀取最新的編譯後資源。爲了提高開發體驗和效率,它還提供了 hot 選項開啓 hotReload,相對於 liveReload, hotReload 不刷新整個頁面,只更新被更改過的模塊。react
altwebpack
上圖是我對 webpack-dev-server 的一個簡單的整理。具體的實現原理是怎樣的咱們接着往下看。web
本文基於如下版本進行分析:express
若是做爲命令行啓動,webpack-dev-server/bin/webpack-dev-server.js
就是整個命令行的入口。貼出來的代碼進行了一些精簡,忽略了一些非核心的分支處理,只關心 webpack-dev-server 的核心邏輯。能夠按照註釋標註的順序簡單閱讀下代碼。json
// webpack-dev-server/bin/webpack-dev-server.js
function startDevServer(config, options) {
let compiler;
try {
// 2. 調用webpack函數返回的是 webpack compiler 實例
compiler = webpack(config);
} catch (err) {
}
try {
// 3. 實例化 webpack-dev-server
server = new Server(compiler, options, log);
} catch (err) {
}
if (options.socket) {
} else {
// 4. 調用 server 實例的 listen 方法
server.listen(options.port, options.host, (err) => {
if (err) {
throw err;
}
});
}
}
// 1. 對參數進行處理後啓動
processOptions(config, argv, (config, options) => {
startDevServer(config, options);
});
webpack-dev-server 做爲命令行啓動,首先是調用了 webpack-cli 模塊下的兩個文件,分別配置了命令行提示選項、和從命令行和配置文件收集了 webpack 的 config,這樣複用了webpack-cli 的代碼,保持行爲一致,上面貼出來的代碼省略了這部分代碼,有興趣的能夠本身翻閱源碼。bootstrap
以後調用 processOptions 對收集的參數進行一些默認處理後獲得須要傳給 webpack 的 config 和須要傳給 wepack-dev-server 的 options。傳入這兩個配置參數調用 startDevServer,startDevServer 這個函數主要是先調用 webpack 函數實例化了 compiler,注意這裏沒有給 webpack 函數傳入回調函數,根據 webpack 源碼實現,不傳入回調函數就不會直接運行 webpack 而是返回 webpack compiler 的實例,供調用方自行啓動 webpack 運行。拿到 webpack compiler 實例和先前的 webpack-dev-server 的 options 就去實例化 Server,這個 Server 類就是實現 webpack-dev-server 的核心邏輯。api
最後調用 Server 類的 listen 方法,就正式開啓監聽請求,listen 方法後面會再解析具體邏輯。這就是 webapck-dev-server 大體的啓動過程,後面來看下 Server 類具體作了什麼。promise
// webpack-dev-server/lib/Server.js
class Server {
constructor(compiler, options = {}, _log) {
// 0. 校驗參數是否符合 schema, 不符合會拋出錯誤
validateOptions(schema, options, 'webpack Dev Server');
this.compiler = compiler;
this.options = options;
// 1. 爲一些選項提供默認參數
normalizeOptions(this.compiler, this.options);
// 2. 對 webpack compiler 進行一些修改 webpack-dev-server/lib/utils/updateCompiler.js
// - 若是設置了 hot 選項,自動給 webpack 配置 HotModuleReplacementPlugin
// - 注入一些客戶端代碼:webpack 的 websocket 客戶端依賴 sockJS/websocket + websocket 客戶端業務代碼 + hot 模式下的 webpack/hot/dev-server
updateCompiler(this.compiler, this.options);
// 3. 添加一些 hooks 插件,這裏主要關注 webpack compiler 的 done 鉤子,即每次編譯完成後的鉤子 (編譯完成觸發 _sendStats 方法給客戶端廣播消息 )
this.setupHooks();
// 4. 實例化 express 服務器
this.setupApp();
// 5. 設置 webpack-dev-middleware,用於處理對靜態資源的處理,後面解析
this.setupDevMiddleware();
// 6. 建立 HTTP 服務器
this.createServer();
}
setupApp() {
// Init express server
// eslint-disable-next-line new-cap
this.app = new express();
}
setupHooks() {
const addHooks = (compiler) => {
const { compile } = compiler.hooks;
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
};
addHooks(this.compiler);
}
setupDevMiddleware() {
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(
this.compiler,
Object.assign({}, this.options, { logLevel: this.log.options.level })
);
this.app.use(this.middleware);
}
createServer() {
this.listeningApp = http.createServer(this.app);
this.listeningApp.on('error', (err) => {
this.log.error(err);
});
}
listen(port, hostname, fn) {
this.hostname = hostname;
return this.listeningApp.listen(port, hostname, (err) => {
this.createSocketServer();
});
}
createSocketServer() {
const SocketServerImplementation = this.socketServerImplementation;
this.socketServer = new SocketServerImplementation(this);
this.socketServer.onConnection((connection, headers) => {
// 鏈接後保存客戶端鏈接
this.sockets.push(connection);
if (this.hot) {
// hot 選項先廣播一個 hot 類型的消息
this.sockWrite([connection], 'hot');
}
this._sendStats([connection], this.getStats(this._stats), true);
});
}
// eslint-disable-next-line
sockWrite(sockets, type, data) {
sockets.forEach((socket) => {
this.socketServer.send(socket, JSON.stringify({ type, data }));
});
}
// send stats to a socket or multiple sockets
_sendStats(sockets, stats, force) {
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) {
this.sockWrite(sockets, 'errors', stats.errors);
} else if (stats.warnings.length > 0) {
this.sockWrite(sockets, 'warnings', stats.warnings);
} else {
this.sockWrite(sockets, 'ok');
}
}
}
這部分代碼稍長,主邏輯都在構造函數裏。
在構造函數中進行參數校驗,參數缺省值處理,注入客戶端代碼,綁定 webpack compiler 鉤子,這裏主要關注是 done 鉤子,(在 webpack compiler 實例每次觸發編譯完成後就會進行 webscoket 廣播 webpack 的編譯信息)。實例化 express 服務器,添加 webpack-dev-middleware 中間件用於處理靜態資源的請求,而後初始化 HTTP 服務器。
咱們在上面的 webpack-dev-server.js 中調用的 listen 方法就是開始監聽配置的端口,監聽回調裏再初始化 websocket 的服務端。代碼執行到這已經完成了服務器端全部的邏輯,可是 webpack 尚未啓動編譯,用戶打開瀏覽器後請求設置的IP和端口服務端又是怎麼處理的呢?這部分暫時被咱們略過了,這部分就是 webpack-dev-middleware 處理的內容了。
webapck-dev-middleware 做爲一個獨立的模塊,如下是它的目錄結構:
.
├── README.md
├── index.js
├── lib
│ ├── DevMiddlewareError.js
│ ├── context.js
│ ├── fs.js
│ ├── middleware.js
│ ├── reporter.js
│ └── util.js
└── package.json
webapck-dev-middleware 初始化執行:
// webpack-dev-middleware/index.js
module.exports = function wdm(compiler, opts) {
const options = Object.assign({}, defaults, opts);
// 1. 初始化 context
const context = createContext(compiler, options);
// start watching
if (!options.lazy) {
// 2. 啓動 webpack 編譯
context.watching = compiler.watch(options.watchOptions, (err) => {
if (err) {
context.log.error(err.stack || err);
if (err.details) {
context.log.error(err.details);
}
}
});
} else {
// lazy 模式是請求過來一次才webpack編譯一次, 這裏不關注
}
// 3. 替換 webpack 默認的 outputFileSystem 爲 memory-fs, 存取都在內存上操做
// fileSystem = new MemoryFileSystem();
// compiler.outputFileSystem = fileSystem;
setFs(context, compiler);
// 3. 執行 middleware 函數返回真正的 middleware
return middleware(context);
};
wdm 函數返回結果是 express 標準的 middleware 用於處理瀏覽器靜態資源的請求。執行過程當中顯示初始化了一個 context 對象,默認非 lazy 模式,開啓了 webpack 的 watch 模式開始啓動編譯。
而後將 compiler 的原來基於 fs 模塊的 outputFileSystem 替換成 memory-fs模塊的實例。memory-fs 是實現了 node fs api 的基於內存的 fileSystem,這意味着 webpack 編譯後的資源不會被輸出到硬盤而是內存。最後將真正處理請求的 middleware 返回裝載在 express 上。
當用戶在瀏覽器打開配置的IP和端口,如 https://localhost:8080 ,請求就會被 middleware 處理。middleware 使用 memory-fs 從內存中讀到請求的資源返回給客戶端。
// webpack-dev-middleware/lib/middleware.js
module.exports = function wrapper(context) {
return function middleware(req, res, next) {
// 1. 根據請求的 URL 地址,獲得絕對路徑的 webpack 輸出的資源路徑地址
let filename = getFilenameFromUrl(
context.options.publicPath,
context.compiler,
req.url
);
return new Promise((resolve) => {
handleRequest(context, filename, proce***equest, req);
// eslint-disable-next-line consistent-return
function proce***equest() {
// 2.從內存讀取到資源內容
let content = context.fs.readFileSync(filename);
// 3. 返回給客戶端
if (res.send) {
res.send(content);
} else {
res.end(content);
}
resolve();
}
});
};
};
當咱們編輯了源代碼,觸發 webpack 從新編譯,編譯完成後執行 done 鉤子上的回調。具體可參考上面 Server.js 中 setupHooks 方法。_sendStats 方法會先廣播一個類型爲 hash 的消息,而後再根據編譯信息廣播 warnings/errors/ok 消息。這裏咱們只關注正常流程 ok 消息。
咱們已經很熟悉客戶端接收到更新後都會對應用進行 Reload 來獲取更好的開發體驗。具體是 liveReload(刷新整個頁面)仍是 hotReload(更新改動過的模塊)就取決於咱們傳入的 hot 選項。
如下代碼就是咱們在上面就講到的在 webpack 編譯的時候注入到 bundle.js 進去的。當用戶打開頁面預覽時,這些代碼就會自動執行。
// webpack-dev-server/client/index.js
var onSocketMessage = {
hot: function hot() {
options.hot = true;
log.info('[WDS] Hot Module Replacement enabled.');
},
liveReload: function liveReload() {
options.liveReload = true;
log.info('[WDS] Live Reloading enabled.');
},
hash: function hash(_hash) {
status.currentHash = _hash;
},
ok: function ok() {
if (options.initial) {
return options.initial = false;
} // eslint-disable-line no-return-assign
reloadApp(options, status);
}
};
socket(socketUrl, onSocketMessage);
client/index.js 主要就是初始化了 webscoket 客戶端,而後爲不一樣的消息類型設置了相應的回調函數。
在前面 Server.js 中咱們看到若是 hot 選項爲 true 時,當 websocket 客戶端鏈接到服務端,服務端會先廣播一個 hot 類型的消息,客戶端接收到後會把 options 對象的 hot 設置爲 true。
服務端在每次編譯後都會廣播 hash 消息,客戶端接收到後就會將這個webpack 編譯產生的 hash 值暫存起來。編譯成功若是沒有 warning 也沒有 error 就會廣播 ok 消息,客戶端接收到 ok 消息就會執行 ok 回調函數中的 reloadApp 刷新應用。
// webpack-dev-server/client/utils/reloadApp.js
function reloadApp(_ref, _ref2) {
var hotReload = _ref.hotReload,
hot = _ref.hot,
liveReload = _ref.liveReload,
currentHash = _ref2.currentHash;
if (hot) {
log.info('[WDS] App hot update...');
var hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
}
else if (liveReload) {
var rootWindow = self; // use parent window for reload (in case we're in an iframe with no valid src)
var intervalId = self.setInterval(function () {
if (rootWindow.location.protocol !== 'about:') {
// reload immediately if protocol is valid
applyReload(rootWindow, intervalId);
} else {
rootWindow = rootWindow.parent;
if (rootWindow.parent === rootWindow) {
// if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
applyReload(rootWindow, intervalId);
}
}
});
}
function applyReload(rootWindow, intervalId) {
clearInterval(intervalId);
log.info('[WDS] App updated. Reloading...');
rootWindow.location.reload();
}
}
若是設置了 hot: true 客戶端就會引入 webpack/hot/emitter,觸發一個 webpackHotUpdate 事件,將 hash 值傳遞過去。這個webpack/hot/emitter
咱們查閱 webpack 源碼看到其實就是 node 的 events 模塊。咱們暫時不關注這個事件會觸發什麼回調後面再具體再看。若是沒有設置 hot: true。那麼就是使用 liveReload 模式,liveReload 就比較無腦,直接刷新整個頁面。
再回到上一個問題,究竟是在哪裏接收 webpackHotUpdate 事件並處理的呢?就是 webpack/hot/dev-server.js 中處理的。在這裏會去檢查是否能夠更新,若是更新失敗就會刷新整個頁面來降級實現代碼更新的功能。其實咱們回過頭來看看這樣降級也是必須的,若是更新失敗,源碼更新了,而客戶端的代碼卻沒更新,這樣顯然是不合理的。
var lastHash;
var upToDate = function upToDate() {
return lastHash.indexOf(__webpack_hash__) >= 0;
};
var log = require("./log");
// 2. 檢查更新
var check = function check() {
// 3. 具體的檢查邏輯
module.hot
.check(true)
.then(function(updatedModules) {
// 3.1 更新成功
})
.catch(function(err) {
var status = module.hot.status();
// 3.2 更新失敗,降級爲從新刷新整個應用
if (["abort", "fail"].indexOf(status) >= 0) {
log(
"warning",
"[HMR] Cannot apply update. Need to do a full reload!"
);
window.location.reload();
} else {
log("warning", "[HMR] Update failed: " + log.formatError(err));
}
});
};
var hotEmitter = require("./emitter");
// 1. 註冊事件回調
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
module.hot.check 方法位於 webpack/lib/HotModuleReplacement.runtime.js 中,是 webpack 內置的 HotModuleReplacementPlugin 注入在 webpack bootstrap runtime 中的。
因此 check 方法主要作了什麼呢,這裏提早總結一下。在 webpack 使用了 HotModuleReplacementPlugin 編譯時,每次增量編譯就會多產出兩個文件,形如c390bbe0037a0dd079a6.hot-update.json
,main.c390bbe0037a0dd079a6.hot-update.js
,分別是描述 chunk 更新的 manifest文件和更新事後的 chunk 文件。那麼瀏覽器端調用 hotDownloadManifest 方法去下載模塊更新的 manifest.json 文件,而後調用 hotDownloadUpdateChunk 方法使用 jsonp 的方式下載須要更新的 chunk。
hotDownloadUpdateChunk 下載完成後調用 webpackHotUpdate 回調。回調內拿到更新的模塊,而後從模塊自身開始進行冒泡,若是發現只要有一條祖先路徑沒有 accept 此次改動就直接刷新頁面實行降級強制更新, 若是有被 accept, 就會替換掉原來 webpack runtime 裏 module 裏舊的模塊,而後再執行 accept 的 callback 進行更新。爲何要執行這樣的判斷呢?
假設給定這樣的依賴路徑:
componentA.js -> componentB.js -> app.js -> index.js
componentA.js -> componentC.js -> app.js -> index.js
參考如下的代碼示例,accept 指該 module 的祖先模塊調用了 module.hot.accept, 處理了該 module 更新事後的業務邏輯,通常都是 rerender。
// index.js
if(module.hot) {
module.hot.accept('./app', function() {
rerender()
})
}
若是咱們對 componentA.js 進行了更改,可是若是僅僅 componentB accept 了更改,componentC 卻沒 accept,那麼這樣是沒有到達更新的目的的。因此在祖先路徑回溯的時候,要保證每一條路徑都被 accept。
function hotCheck(apply) {
// 1. 拿此次編譯後的 hash 請求服務器,拿到結構爲 {c: {main: true} h: "ac69ee760bb48d5db5f5"} 的數據
return hotDownloadManifest(hotRequestTimeout).then(function(update) {
hotAvailableFilesMap = update.c;
hotUpdateNewHash = update.h;
// 2. 生成一個 defered promise,供上面提到的 promise 鏈消費
var promise = new Promise(function(resolve, reject) {
hotDeferred = {
resolve: resolve,
reject: reject
};
});
hotUpdate = {};
// 3. 這個方法裏面調用的就是 hotDownloadUpdateChunk,就是發起一個 jsonp 請求更新事後的 chunk,jsonp的回調是 HMR runtime 裏的 webpackHotUpdate
{
hotEnsureUpdateChunk(chunkId);
}
return promise;
});
}
hotCheck 方法就是和服務器進行通訊拿到更新事後的 chunk,下載好 chunk 後就開始執行 HMR runtime 裏的 webpackHotUpdate 回調。
window["webpackHotUpdate"] = function webpackHotUpdateCallback(chunkId, moreModules) {
hotAddUpdateChunk(chunkId, moreModules);
if (parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;
通過一系列方法調用而後來到 hotApplyInternal 方法,這個方法把更新事後的模塊 apply 到業務中,整個方法比較長,就不完整貼出來了。這裏拿出核心的部分,
for (var id in hotUpdate) {
if (Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
var result;
if (hotUpdate[id]) {
result = getAffectedStuff(moduleId);
} else {
result = {
type: "disposed",
moduleId: id
};
}
switch (result.type) {
case "self-declined":
case "declined":
case "unaccepted":
if (options.onUnaccepted) options.onUnaccepted(result);
if (!options.ignoreUnaccepted)
abortError = new Error(
"Aborted because " + moduleId + " is not accepted" + chainInfo
);
break;
case "accepted":
if (options.onAccepted) options.onAccepted(result);
doApply = true;
break;
case "disposed":
break;
default:
throw new Error("Unexception type " + result.type);
}
}
}
把更新過的模塊進行遍歷,找到被該模塊影響到的祖先模塊,返回一個結果,若是結果標識爲 unaccepted 就會被拋出錯誤,而後走到 webpack/hot/dev-server.js 裏的 catch 進行頁面級刷新。若是被 accept 的話就會執行後面的 apply 的邏輯。
function getAffectedStuff(updateModuleId) {
var outdatedModules = [updateModuleId];
var outdatedDependencies = {};
var queue = outdatedModules.map(function(id) {
return {
chain: [id],
id: id
};
});
// 1. 遍歷 queue
while (queue.length > 0) {
var queueItem = queue.pop();
var moduleId = queueItem.id;
var chain = queueItem.chain;
// 2. 找到改模塊的舊版本
module = installedModules[moduleId];
// 3. 若是到根模塊了,返回 unaccepted
if (module.hot._main) {
return {
type: "unaccepted",
chain: chain,
moduleId: moduleId
};
}
// 4. 遍歷父模塊
for (var i = 0; i < module.parents.length; i++) {
var parentId = module.parents[i];
var parent = installedModules[parentId];
// 5. 若是父模塊處理了模塊變動的話就跳過,繼續檢查
if (parent.hot._acceptedDependencies[moduleId]) {
continue;
}
outdatedModules.push(parentId);
// 6. 沒跳過的話推入隊列,繼續檢查
queue.push({
chain: chain.concat([parentId]),
id: parentId
});
}
}
// 7.若是全部依賴路徑都有被 accept 就返回 accepted
return {
type: "accepted",
moduleId: updateModuleId,
outdatedModules: outdatedModules,
outdatedDependencies: outdatedDependencies
};
}
看過 webpack runtime 代碼以後咱們知道 runtime 裏聲明瞭 installedModules 這個變量,裏面緩存了全部被__webpack_require__
調用後加載過的模塊,還有 modules 這個變量存儲了全部模塊。(若是不瞭解 webpack runtime 能夠先了解 webpack runtime 的執行機制)。若是模塊有被 accept 的話,那麼就會從 installedModules 裏刪掉舊的模塊,把模塊從父子依賴中刪除,而後把 modules 裏面的模塊替換成新的模塊。
// remove module from cache
delete installedModules[moduleId];
// insert new code
for (moduleId in appliedUpdate) {
if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
這樣僅僅完成了模塊的替換,尚未執行過新模塊代碼,也就是沒被__webpack_require__
調用過。對於 ES Module,新模塊代碼的執行是在 accept 函數的 callback 裏被 webpack 自動插入代碼執行的。使用 require()
引入的模塊不會被自動執行。
if(module.hot) {
module.hot.accept('./App', function() {
console.log('accepted')
})
}
會被 webpack 改造爲:
if(true) {
module.hot.accept("./src/App.js", function(__WEBPACK_OUTDATED_DEPENDENCIES__) {
_App__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/App.js");
(function() {
console.log('accepted')
})(__WEBPACK_OUTDATED_DEPENDENCIES__);
}.bind(this))
}
因此新模塊的代碼是在 accept 方法回調執行以前被執行的。引入了新代碼後就能夠執行咱們的業務代碼,這些業務代碼通常都和框架相關,框架去處理模塊的熱更新邏輯。好比 react-hot-loader, vue-loader,想要了解更多能夠參考 官方文檔。
最後總結一下,webpack-dev-server 能夠做爲命令行工具使用,核心模塊依賴是 webpack 和 webpack-dev-middleware。webapck-dev-server 負責啓動一個 express 服務器監聽客戶端請求;實例化 webpack compiler;啓動負責推送 webpack 編譯信息的 webscoket 服務器;負責向 bundle.js 注入和服務端通訊用的 webscoket 客戶端代碼和處理邏輯。webapck-dev-middleware 把 webpack compiler 的 outputFileSystem 改成 in-memory fileSystem;啓動 webpack watch 編譯;處理瀏覽器發出的靜態資源的請求,把 webpack 輸出到內存的文件響應給瀏覽器。
每次 webpack 編譯完成後向客戶端廣播 ok 消息,客戶端收到信息後根據是否開啓 hot 模式使用 liveReload 頁面級刷新模式或者 hotReload 模塊熱替換。hotReload 存在失敗的狀況,失敗的狀況下會降級使用頁面級刷新。
開啓 hot 模式,即啓用 HMR 插件。hot 模式會向服務器請求更新事後的模塊,而後對模塊的父模塊進行回溯,對依賴路徑進行判斷,若是每條依賴路徑都配置了模塊更新後所需的業務處理回調函數則是 accepted 狀態,不然就降級刷新頁面。判斷 accepted 狀態後對舊的緩存模塊和父子依賴模塊進行替換和刪除,而後執行 accept 方法的回調函數,執行新模塊代碼,引入新模塊,執行業務處理代碼。
爲了更加熟悉完整的編譯流程能夠初始化一個 webpack-dev-server 項目,使用 vscode 的 debug 功能進行斷點調試的方式去閱讀源碼。