如今,已經有不少人分析過 webpack
熱更新的文章了。javascript
那麼,爲何還要寫本篇文章呢?html
主要是,我想從源碼分析的角度去梳理一下。前端
webpack
熱更新的總體流程比較複雜,第一次接觸的同窗很容易陷入到webpack-dev-server
/ webpack-client
這些名字的深淵中,但願這篇文章會對你有幫助。java
通常來講,咱們跑起來一個前端項目的命令是:node
npm run start
複製代碼
那麼,咱們找到這個項目的package.json
文件,能夠找到下面這段代碼:webpack
"scripts": {
"start": "webpack-dev-server --hot --open"
},
複製代碼
上面的代碼意思是,使用 webpack-dev-server
這個命令,傳入hot
、open
兩個參數。git
webpack-dev-server
命令是從哪裏來的呢?咱們在 ./node_module
目錄下面,找到 webpack-dev-server
包,找到這個包的 package.json
文件,這個文件描述了 這個 npm
包的行爲,有一個字段是 bin
,以下面:github
"bin": "bin/webpack-dev-server.js",
複製代碼
上面代碼的意思是,當咱們執行 webpack-dev-server
命令時,本質是執行了node_module/webpack-dev-server/bin/webpack-dev-server.js
這個文件。web
webpack-dev-server.js
webpack-dev-server.js
作了什麼呢?ajax
主要是作了兩件事情:
引用 webpack
,而後開始編譯,實例化出一個 compiler
,好比說var compiler = webpack(options)
啓動服務,而且剛纔實例化出來的 compiler
,傳入到服務中。好比說new Server(compiler)
。
下面的代碼,就是簡化版的 webpack-dev-server.js
的內容
// bin/webpack-dev-server.js 的內容
// 1. 調用 webpack 開始編譯
let compiler;
try {
compiler = webpack(webpackOptions);
} catch (e) {
throw e;
}
// 2. 啓動服務
const Server = require('../lib/Server');
try {
server = new Server(compiler, options);
} catch (e) {
process.exit(1);
}
複製代碼
細心的同窗會發現,new Server()
裏面其實傳入了 compiler
對象。compiler
對象表明着 webpack
編譯過程, 咱們就能夠在服務端的拿到編譯各個過程的鉤子。
調用 webpack()
方法,咱們親愛的webpack
會編譯打包咱們的代碼到內存中。具體 webpack
的編譯打包過程,這裏就不講解了,內容有點多,之後有機會再說。
接下來,咱們把注意力轉移到本文的重點,代碼熱更新上。也就是, new Server()
背後作了什麼。
new Server()
作了什麼事情呢?其實就是三件事情: 創建了http
的靜態資源服務 、創建了 websocket
服務、監聽了 webpack 從新編譯的 done
的生命週期。
http
的靜態資源服務的做用是什麼呢?
做用是:提供打包後的 js
資源。當開發時候,在瀏覽器裏面請求 http://localhost:8081/bundle.js
的靜態資源,就能夠拿到對應的js文件。這是由於中間件 webpack-dev-middleware
使用了 express
框架搭建了一個靜態資源的服務。咱們後面會講解到。
websocket
服務的做用是什麼呢?
做用是:用於通知瀏覽器。這裏的服務端,並非遠程的服務端,而是跑在咱們本機上的服務。服務端沒有辦法經過http
協議去通知瀏覽器,『嘿,你須要更新了』,只能經過 websocket
協議去通知瀏覽器。
接下來,咱們就來看一下,是如何創建http
的靜態資源服務的?
首先是,引用了 express
框架,起了一個後端服務;
const express = require('express');
const app = this.app = new express();
複製代碼
可是,光跑起來服務還不行,咱們還要匹配對應的路徑,返回不一樣的靜態資源;
其次,使用中間件 webpack-dev-middleware
,匹配對應的路徑。
下面這一段代碼,本質就是提供了普通的靜態資源服務器的基本功能。做用是,根據客戶端請求的路徑,返回不一樣的文件內容。
惟一不一樣的地方是,文件內容是從內存中讀出的,由於訪問內存中的代碼比訪問文件系統中的文件更快,並且也減小了代碼寫入文件的開銷,這一切都歸功於memory-fs。
function processRequest() {
try {
var stat = context.fs.statSync(filename);
if(!stat.isFile()) {
// 是目錄
if(stat.isDirectory()) {
// 若是是訪問 localhost:8080, index 就是 undefined
var index = context.options.index;
if(index === undefined || index === true) {
// 默認是 index.html
index = "index.html";
} else if(!index) {
throw "next";
}
// 找到了
filename = pathJoin(filename, index);
stat = context.fs.statSync(filename);
// 若是不是文件,則報錯退出
if(!stat.isFile()) throw "next";
} else {
throw "next";
}
}
} catch(e) {
return resolve(goNext());
}
// 從內存中找到 "/Users/dudu/webstorm/beibei/webpack-HMR-demo//bundle.js"
// 從內存中,讀出 /bundle.js 的二進制數據
var content = context.fs.readFileSync(filename);
content = shared.handleRangeHeaders(content, req, res);
// 肯定響應的contentType
var contentType = mime.lookup(filename);
if(!/\.wasm$/.test(filename)) {
contentType += "; charset=UTF-8";
}
// 設置 Content-Type Content-Length
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", content.length);
if(context.options.headers) {
for(var name in context.options.headers) {
res.setHeader(name, context.options.headers[name]);
}
}
// Express automatically sets the statusCode to 200, but not all servers do (Koa).
// 做者彷佛黑了一把 Koa
res.statusCode = res.statusCode || 200;
if(res.send) res.send(content);
else res.end(content);
resolve();
}
複製代碼
咱們用 webpack-dev-server 相對簡單,直接安裝依賴後執行命令便可,用 webpack-dev-middleware
能夠在既有的 Express 代碼基礎上快速添加 webpack-dev-server
的功能,同時利用 Express 來根據須要添加更多的功能,如 mock 服務、代理 API 請求等。
使用 sockjs
庫,創建一個 websocket
的服務。
// 創建一個 `websocket ` 的服務
const sockjs = require('sockjs');
const sockServer = sockjs.createServer();
複製代碼
可是,光有 websocket
的服務還不行啊,還得有客戶端的請求啊,咱們並無寫接收 websocket
消息的代碼。
當咱們使用了 webpackd-dev-server
, 就會修改了webpack 配置中的 entry 屬性,在裏面添加了創建 websocket
鏈接的代碼,這樣在最後的 bundle.js
文件中就會有接收 websocket
消息的代碼了。
done
鉤子其實,本質就是監聽 compiler.done
的生命週期
上文中說過,new Server(compiler)
傳入了 compiler
對象,compiler
對象表明着webpack編譯過程, 就能夠拿到編譯各個過程的鉤子。
下面的代碼,是監聽了 done
的生命週期函數,若是文件發生了變化,webpack 發生了從新編譯,在done
的鉤子中, 調用 _sendStats
方法,使用 websocket
協議去通知瀏覽器。
// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
// stats.hash 是最新打包文件的 hash 值
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});
Server.prototype._sendStats = function (sockets, stats, force) {
// 調用 sockWrite 方法將 hash 值經過 websocket 發送到瀏覽器端
this.sockWrite(sockets, 'hash', stats.hash);
};
複製代碼
接下來,咱們把視角轉到瀏覽器中。
瀏覽器會接收 websocket
消息, 這部分的代碼,是被打到 bundle.js
裏面的。
源碼是 node_modules/_webpack-dev-server@2.11.5@webpack-dev-server/client/index.js
咱們來看一下:
var socket = function initSocket(url, handlers) {
sock = new SockJS(url);
sock.onopen = function onopen() {
retries = 0;
};
sock.onclose = function onclose() {
};
// 在這裏,接收 來自 webpack-dev-server 的各類消息
sock.onmessage = function onmessage(e) {
var msg = JSON.parse(e.data);
// 根據 服務端傳過來的 type, 調用不一樣的處理函數
if (handlers[msg.type]) {
handlers[msg.type](msg.data);
}
};
};
複製代碼
上面處理函數的集合 handlers
長什麼樣子呢?
var onSocketMsg = {
hot: function hot() {},
invalid: function invalid() {},
hash: function hash(_hash) {
currentHash = _hash;
},
'still-ok': function stillOk() {},
'log-level': function logLevel(level) {},
overlay: function overlay(value) {},
progress: function progress(_progress) {},
'progress-update': function progressUpdate(data) {},
ok: function ok() {
sendMsg('Ok');
reloadApp();
},
'content-changed': function contentChanged() {
self.location.reload();
},
warnings: function warnings(_warnings) {},
errors: function errors(_errors) {},
error: function error(_error) {},
close: function close() {}
};
複製代碼
咱們能夠發現,服務器傳給瀏覽器的 websocket 消息有好多類型哦,
可是咱們只須要關注 hash
類型 和 ok
類型:
// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
currentHash = hash;
},
ok: function msgOk() {
reloadApp();
},
function reloadApp() {
if (hot) {
log.info('[WDS] App hot update...');
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
} else {
log.info('[WDS] App updated. Reloading...');
self.location.reload();
}
}
複製代碼
如上面代碼所示,首先將 hash 值暫存到 currentHash
變量,當接收到 ok
類型消息後,對 App 進行 reload
。
判斷是否配置了模塊熱更新hot
,若是沒有配置模塊熱更新,就直接調用 location.reload 方法刷新頁面。若是配置了,就調用hotEmitter.emit('webpackHotUpdate', currentHash)
接下來發生了什麼呢?
// 監聽第三步 webpack-dev-server/client 發送的 webpackHotUpdate 消息
// 調用了 check() 方法
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
// 居然去執行了檢查,真是嚴謹的小 webpack 啊
check();
});
複製代碼
執行了 check()
過程,那麼到底是怎麼檢查是否須要更新的呢?
在 check
過程當中會利用兩個方法: hotDownloadManifest
和 hotDownloadUpdateChunk
// 調用 AJAX 向服務端請求是否有更新的文件,若是有將發更新的文件列表返回瀏覽器端
hotDownloadManifest(hotRequestTimeout).then(function(update) {
if(!update) {
hotSetStatus("idle");
return null;
}
hotRequestedFilesMap = {};
hotWaitingFilesMap = {};
hotAvailableFilesMap = update.c;
hotUpdateNewHash = update.h;
hotSetStatus("prepare");
var promise = new Promise(function(resolve, reject) {
hotDeferred = {
resolve: resolve,
reject: reject
};
});
hotUpdate = {};
var chunkId = 0;
hotEnsureUpdateChunk()
if(hotStatus === "prepare" && hotChunksLoading === 0 && hotWaitingFiles === 0) {
// 真正開始下載了
hotUpdateDownloaded();
}
return promise;
});
複製代碼
上面代碼的核心就是:
先檢查是否須要更新,再下載更新的文件
return hotDownloadManifest().then(function() {
hotEnsureUpdateChunk()
hotUpdateDownloaded();
});
複製代碼
咱們先來看一下 hotDownloadManifest
方法作了什麼:
// webpack本身寫了一個原生ajax
function hotDownloadManifest(requestTimeout) {
requestTimeout = requestTimeout || 10000;
return new Promise(function(resolve, reject) {
if(typeof XMLHttpRequest === "undefined")
return reject(new Error("No browser support"));
try {
// 構建了一個原生的 XHR 對象
var request = new XMLHttpRequest();
// 拼接請求的路徑
// hotCurrentHash 是 89e94c7776606408e5a3
// requestPath 是 "89e94c7776606408e5a3.hot-update.json"
var requestPath = __webpack_require__.p + "" + hotCurrentHash + ".hot-update.json";
//
request.open("GET", requestPath, true);
request.timeout = requestTimeout;
request.send(null);
} catch(err) {
return reject(err);
}
request.onreadystatechange = function() {
// success
try {
// update 是 "{"h":"3f8a14b8d23b5bad41cc","c":{"0":true}}"
var update = JSON.parse(request.responseText);
} catch(e) {
reject(e);
return;
}
};
});
}
複製代碼
上面的代碼,核心流程是,構造一個原生的 XHR 對象, 向服務端請求是否有更新的文件,發送的GET
請求的路徑是相似於 "89e94c7776606408e5a3.hot-update.json" 這樣的。中間一串數字是 hash 值。
服務端若是有將發更新的文件列表返回瀏覽器端,拿到的文件列表大概是這樣的
"{"h":"3f8a14b8d23b5bad41cc","c":{"0":true}}"
複製代碼
接下來,執行 hotEnsureUpdateChunk
方法,以下面代碼所示。最終實際上是執行了 hotDownloadUpdateChunk
方法。
function hotEnsureUpdateChunk(chunkId) {
if(!hotAvailableFilesMap[chunkId]) {
hotWaitingFilesMap[chunkId] = true;
} else {
hotRequestedFilesMap[chunkId] = true;
hotWaitingFiles++;
hotDownloadUpdateChunk(chunkId);
}
}
複製代碼
hotDownloadUpdateChunk
方法的核心就是使用 jsonp
去下載更新後的代碼:
function hotDownloadUpdateChunk(chunkId) {
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.charset = "utf-8";
script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
;
head.appendChild(script);
}
複製代碼
上面的代碼中,所謂的jsonp
技術,其實沒什麼難度。
核心就是建立一個 <script>
標籤,設置好type
、src
屬性,瀏覽器就去下載 js 腳本。由於瀏覽器在下載腳本的時候,不會進行跨域處理,因此 jsonp
也經常用於處理跨域。
<script>
標籤的 src
屬性指定了 js 腳本文件的路徑,是由 chunkId
和 hotCurrentHash
拼接起來的:
// 大概長這個樣子
"http://localhost:8080/0.4d6b38763300df57f063.hot-update.js"
複製代碼
而後,瀏覽器就回去下載這個文件:
下載回來的文件大概是長這個樣子的:
webpackHotUpdate(0,{
/***/ 28:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
const hello = () => 'hell12o world nice12333'
/* harmony default export */ __webpack_exports__["a"] = (hello);
/***/ })
})
複製代碼
到目前爲止,咱們經歷了的流程是:
文件更新
-> webpack 從新編譯打包
-> 監聽到 編譯打包的 done 階段
-> 發送socket 通知瀏覽器
-> 瀏覽器收到通知
-> 發送 ajax 請求檢查
-> 若是確實須要更新,發送 jsonp 請求拉取新代碼
-> jsonp 請求回來的代碼能夠直接執行
咱們接着來看,接下來發生了什麼?
接下來,咱們要更新的代碼已經下載下來了。咱們應該怎麼樣,在保持頁面狀態的狀況下,把新的代碼插進去。
咱們注意到,下載的文件中,調用了 webpackHotUpdate
方法。這個方法的定義以下:
// `webpackHotUpdate` 方法 的定義
var parentHotUpdateCallback = window["webpackHotUpdate"];
window["webpackHotUpdate"] =
function webpackHotUpdateCallback(chunkId, moreModules) { // eslint-disable-line no-unused-vars
hotAddUpdateChunk(chunkId, moreModules);
if(parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;
複製代碼
上面代碼的核心,是調用了 hotAddUpdateChunk
方法, hotAddUpdateChunk
方法又調用了 hotUpdateDownloaded
方法,又調用了 hotApply
方法。
這一步是整個模塊熱更新(HMR)的關鍵步驟,這兒我不打算把 hotApply 方法整個源碼貼出來了,由於這個方法包含 300 多行代碼,我將只摘取關鍵代碼片斷:
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for(j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if(!child) continue;
idx = child.parents.indexOf(moduleId);
if(idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// ...
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
複製代碼
從上面 hotApply
方法能夠看出,模塊熱替換主要分三個階段
第一個階段是找出 outdatedModules
和 outdatedDependencie
第二個階段從緩存中刪除過時的模塊和依賴,以下:
delete installedModules[moduleId];
delete outdatedDependencies[moduleId];
複製代碼
可是,咱們還剩最後一件事情,當用新的模塊代碼替換老的模塊後,可是業務代碼並不能知道代碼已經發生變化,雖然新代碼已經被替換上去了,可是並無被真正執行一遍。
接下來,就是熱更新的階段。
不知道你有沒有聽過或看過這樣一段話:「在高速公路上將汽車引擎換成波音747飛機引擎」。
微信小程序的開發工具,沒有提供相似 Webpack 熱更新的機制,因此在本地開發時,每次修改了代碼,預覽頁面都會刷新,因而以前的路由跳轉狀態、表單中填入的數據,都沒了。
若是有相似 Webpack 熱更新的機制存在,則是修改了代碼,不會致使刷新,而是保留現有的數據狀態,只將模塊進行更新替換。也就是說,既保留了現有的數據狀態,又能看到代碼修改後的變化。
webpack 具體是如何實現呢?
須要 咱們開發者,本身去寫一些額外的代碼。
這些額外的代碼,告訴 webpack
要麼是接受變動(頁面不用刷新,模塊替換下就好),要麼不接受(必須得刷新)。咱們須要手動在業務代碼裏面這樣寫相似於下面
if (module.hot) {
// 選擇接受並處理 timer 的更新, 若是 timer.js 更新了,不刷新瀏覽器更新
module.hot.accept('timer', () => {
// ...
})
// 若是 foo.js 更新了,須要刷新瀏覽器
module.hot.decline('./foo')
}
複製代碼
這些額外的代碼放在哪裏呢?
假設 index.js
引用了 a.js
。那麼,這些額外的代碼要麼放在 index.js
,要麼放在 a.js
中。
Webpack 的實現機制有點相似 DOM 事件的冒泡機制,更新事件先由模塊自身處理,若是模塊自身沒有任何聲明,纔會向上冒泡,檢查使用方是否有對該模塊更新的聲明,以此類推。若是最終入口模塊也沒有任何聲明,那麼就刷新頁面了。
關於這塊內容,能夠看 這一篇文章的講解 juejin.im/post/5c14be…
這樣就是整個 HMR 的工做流程了。