目錄
HMR是什麼
HMR
即Hot Module Replacement
是指當你對代碼修改並保存後,webpack
將會對代碼進行重新打包,並將改動的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,去實現局部更新頁面而非整體刷新頁面。接下來將從使用到實現一版簡易功能帶領大家深入淺出HMR
。
文章首發於@careteen/webpack-hmr,轉載請註明來源即可。
使用場景
如上圖所示,一個註冊頁面包含用戶名
、密碼
、郵箱
三個必填輸入框,以及一個提交
按鈕,當你在調試郵箱
模塊改動了代碼時,沒做任何處理情況下是會刷新整個頁面,頻繁的改動代碼會浪費你大量時間去重新填寫內容。預期是保留用戶名
、密碼
的輸入內容,而只替換郵箱
這一模塊。這一訴求就需要藉助webpack-dev-server
的熱模塊更新功能。
相對於live reload
整體刷新頁面的方案,HMR
的優點在於可以保存應用的狀態,提高開發效率。
配置使用HMR
配置webpack
首先借助webpack
搭建項目
- 初識化項目並導入依賴
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
- 配置文件
webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development', // 開發模式不壓縮代碼,方便調試
entry: './src/index.js', // 入口文件
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js'
},
devServer: {
contentBase: path.join(__dirname, 'dist')
},
plugins: [
new htmlWebpackPlugin({
template: './src/index.html',
filename: 'index.html'
})
]
}
- 新建
src/index.html
模板文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Webpack Hot Module Replacement</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
- 新建
src/index.js
入口文件編寫簡單邏輯
var root = document.getElementById('root')
function render () {
root.innerHTML = require('./content.js')
}
render()
- 新建依賴文件
src/content.js
導出字符供index渲染頁面
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
- 配置
package.json
"scripts": {
"dev": "webpack-dev-server",
"build": "webpack"
}
- 然後
npm run dev
即可啓動項目 - 通過
npm run build
打包生成靜態資源到dist
目錄
接下來先分析下dist
目錄中的文件
解析webpack打包後的文件內容
- webpack自己實現的一套commonjs規範講解
- 區分commonjs和esmodule
dist目錄結構
.
├── index.html
└── main.js
其中index.html
內容如下
<!-- ... -->
<div id="root"></div>
<script type="text/javascript" src="main.js"></script></body>
<!-- ... -->
使用html-webpack-plugin
插件將入口文件及其依賴通過script
標籤引入
先對main.js
內容去掉註釋和無關內容進行分析
(function (modules) { // webpackBootstrap
// ...
})
({
"./src/content.js":
(function (module, exports) {
eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
}),
"./src/index.js": (function (module, exports, __webpack_require__) {
eval("var root = document.getElementById('root')\nfunction render () {\n root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
})
});
可見webpack打包後會產出一個自執行函數,其參數爲一個對象
"./src/content.js": (function (module, exports) {
eval("...")
}
鍵爲入口文件或依賴文件相對於根目錄的相對路徑,值則是一個函數,其中使用eval
執行文件的內容字符。
- 再進入自執行函數體內,可見webpack自己實現了一套
commonjs
規範
(function (modules) {
// 模塊緩存
var installedModules = {};
function __webpack_require__(moduleId) {
// 判斷是否有緩存
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 沒有緩存則創建一個模塊對象並將其放入緩存
var module = installedModules[moduleId] = {
i: moduleId,
l: false, // 是否已加載
exports: {}
};
// 執行模塊函數
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 將狀態置爲已加載
module.l = true;
// 返回模塊對象
return module.exports;
}
// ...
// 加載入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果對上面commonjs
規範感興趣可以前往我的另一篇文章手摸手帶你實現commonjs規範
給出上面代碼主要是先對webpack的產出文件混個眼熟,不要懼怕。其實任何一個不管多複雜的事物都是由更小更簡單的東西組成,剖開它認識它愛上它。
配置HMR
接下來配置並感受一下熱更新帶來的便捷開發
webpack.config.js
配置
// ...
devServer: {
hot: true
}
// ...
./src/index.js
配置
// ...
if (module.hot) {
module.hot.accept(['./content.js'], () => {
render()
})
}
當更改./content.js
的內容並保存時,可以看到頁面沒有刷新,但是內容已經被替換了。
這對提高開發效率意義重大。接下來將一層層剖開它,認識它的實現原理。
HMR原理
如上圖所示,右側Server
端使用webpack-dev-server
去啓動本地服務,內部實現主要使用了webpack
、express
、websocket
。
- 使用
express
啓動本地服務,當瀏覽器訪問資源時對此做響應。 - 服務端和客戶端使用
websocket
實現長連接 -
webpack
監聽源文件的變化,即當開發者保存文件時觸發webpack
的重新編譯。- 每次編譯都會生成
hash值
、已改動模塊的json文件
、已改動模塊代碼的js文件
- 編譯完成後通過
socket
向客戶端推送當前編譯的hash戳
- 每次編譯都會生成
-
客戶端的
websocket
監聽到有文件改動推送過來的hash戳
,會和上一次對比- 一致則走緩存
- 不一致則通過
ajax
和jsonp
向服務端獲取最新資源
- 使用
內存文件系統
去替換有修改的內容實現局部刷新
上圖先只看個大概,下面將從服務端和客戶端兩個方面進行詳細分析
debug服務端源碼
現在也只需要關注上圖的右側服務端部分,左側可以暫時忽略。下面步驟主要是debug服務端源碼分析其詳細思路,也給出了代碼所處的具體位置,感興趣的可以先行定位到下面的代碼處設置斷點,然後觀察數據的變化情況。也可以先跳過閱讀此步驟。
- 啓動
webpack-dev-server
服務器,源代碼地址@webpack-dev-server/webpack-dev-server.js#L173 - 創建webpack實例,源代碼地址@webpack-dev-server/webpack-dev-server.js#L89
- 創建Server服務器,源代碼地址@webpack-dev-server/webpack-dev-server.js#L107
-
添加webpack的done事件回調,源代碼地址@webpack-dev-server/Server.js#L122
- 編譯完成向客戶端發送消息,源代碼地址@webpack-dev-server/Server.js#L184
- 創建express應用app,源代碼地址@webpack-dev-server/Server.js#L123
- 設置文件系統爲內存文件系統,源代碼地址@webpack-dev-middleware/fs.js#L115
-
添加webpack-dev-middleware中間件,源代碼地址@webpack-dev-server/Server.js#L125
- 中間件負責返回生成的文件,源代碼地址@webpack-dev-middleware/middleware.js#L20
- 啓動webpack編譯,源代碼地址@webpack-dev-middleware/index.js#L51
- 創建http服務器並啓動服務,源代碼地址@webpack-dev-server/Server.js#L135
-
使用sockjs在瀏覽器端和服務端之間建立一個 websocket 長連接,源代碼地址@webpack-dev-server/Server.js#L745
- 創建socket服務器,源代碼地址@webpack-dev-server/SockJSServer.js#L34
服務端簡易實現
上面是我通過debug得出dev-server運行流程比較核心的幾個點,下面將其抽象整合到一個文件中。
啓動webpack-dev-server服務器
先導入所有依賴
const path = require('path') // 解析文件路徑
const express = require('express') // 啓動本地服務
const mime = require('mime') // 獲取文件類型 實現一個靜態服務器
const webpack = require('webpack') // 讀取配置文件進行打包
const MemoryFileSystem = require('memory-fs') // 使用內存文件系統更快,文件生成在內存中而非真實文件
const config = require('./webpack.config') // 獲取webpack配置文件
創建webpack實例
const compiler = webpack(config)
compiler代表整個webpack編譯任務,全局只有一個
創建Server服務器
class Server {
constructor(compiler) {
this.compiler = compiler
}
listen(port) {
this.server.listen(port, () => {
console.log(`服務器已經在${port}端口上啓動了`)
})
}
}
let server = new Server(compiler)
server.listen(8000)
在後面是通過express來當啓動服務的
添加webpack的done事件回調
constructor(compiler) {
let sockets = []
let lasthash
compiler.hooks.done.tap('webpack-dev-server', (stats) => {
lasthash = stats.hash
// 每當新一個編譯完成後都會向客戶端發送消息
sockets.forEach(socket => {
socket.emit('hash', stats.hash) // 先向客戶端發送最新的hash值
socket.emit('ok') // 再向客戶端發送一個ok
})
})
}
webpack
編譯後提供提供了一系列鉤子函數,以供插件能訪問到它的各個生命週期節點,並對其打包內容做修改。compiler.hooks.done
則是插件能修改其內容的最後一個節點。
編譯完成通過socket
向客戶端發送消息,推送每次編譯產生的hash
。另外如果是熱更新的話,還會產出二個補丁文件,裏面描述了從上一次結果到這一次結果都有哪些chunk和模塊發生了變化。
使用let sockets = []
數組去存放當打開了多個Tab時每個Tab的socket實例
。
創建express應用app
let app = new express()
設置文件系統爲內存文件系統
let fs = new MemoryFileSystem()
使用MemoryFileSystem
將compiler
的產出文件打包到內存中。
添加webpack-dev-middleware中間件
function middleware(req, res, next) {
if (req.url === '/favicon.ico') {
return res.sendStatus(404)
}
// /index.html dist/index.html
let filename = path.join(config.output.path, req.url.slice(1))
let stat = fs.statSync(filename)
if (stat.isFile()) { // 判斷是否存在這個文件,如果在的話直接把這個讀出來發給瀏覽器
let content = fs.readFileSync(filename)
let contentType = mime.getType(filename)
res.setHeader('Content-Type', contentType)
res.statusCode = res.statusCode || 200
res.send(content)
} else {
return res.sendStatus(404)
}
}
app.use(middleware)
使用expres啓動了本地開發服務後,使用中間件去爲其構造一個靜態服務器,並使用了內存文件系統,使讀取文件後存放到內存中,提高讀寫效率,最終返回生成的文件。
啓動webpack編譯
compiler.watch({}, err => {
console.log('又一次編譯任務成功完成了')
})
以監控的模式啓動一次webpack編譯,當編譯成功之後執行回調
創建http服務器並啓動服務
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
// ...
}
listen(port) {
this.server.listen(port, () => {
console.log(`服務器已經在${port}端口上啓動了`)
})
}
使用sockjs在瀏覽器端和服務端之間建立一個 websocket 長連接
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
let io = require('socket.io')(this.server)
io.on('connection', (socket) => {
sockets.push(socket)
socket.emit('hash', lastHash)
socket.emit('ok')
})
}
啓動一個 websocket服務器,然後等待連接來到,連接到來之後存進sockets池
當有文件改動,webpack重新編譯時,向客戶端推送hash
和ok
兩個事件
服務端調試階段
感興趣的可以根據上面debug服務端源碼所帶的源碼位置,並在瀏覽器的調試模式下設置斷點查看每個階段的值。
node dev-server.js
使用我們自己編譯的dev-server.js
啓動服務,可看到頁面可以正常展示,但還沒有實現熱更新。
下面將調式客戶端的源代碼分析其實現流程。
debug客戶端源碼
現在也只需要關注上圖的左側客戶端部分,右側可以暫時忽略。下面步驟主要是debug客戶端源碼分析其詳細思路,也給出了代碼所處的具體位置,感興趣的可以先行定位到下面的代碼處設置斷點,然後觀察數據的變化情況。也可以先跳過閱讀此步驟。
debug客戶端源碼分析其詳細思路
- webpack-dev-server/client端會監聽到此hash消息,源代碼地址@webpack-dev-server/index.js#L54
- 客戶端收到ok的消息後會執行reloadApp方法進行更新,源代碼地址index.js#L101
- 在reloadApp中會進行判斷,是否支持熱更新,如果支持的話發射webpackHotUpdate事件,如果不支持則直接刷新瀏覽器,源代碼地址reloadApp.js#L7
- 在webpack/hot/dev-server.js會監聽webpackHotUpdate事件,源代碼地址dev-server.js#L55
- 在check方法裏會調用module.hot.check方法,源代碼地址dev-server.js#L13
- HotModuleReplacement.runtime請求Manifest,源代碼地址HotModuleReplacement.runtime.js#L180
- 它通過調用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代碼地址JsonpMainTemplate.runtime.js#L23
- 調用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通過JSONP請求獲取到最新的模塊代碼,源代碼地址JsonpMainTemplate.runtime.js#L14
- 補丁JS取回來後會調用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代碼地址JsonpMainTemplate.runtime.js#L8
- 然後會調用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法動態更新模塊代碼,源代碼地址HotModuleReplacement.runtime.js#L222
- 然後調用hotApply方法進行熱更新,源代碼地址HotModuleReplacement.runtime.js#L257、HotModuleReplacement.runtime.js#L278
客戶端簡易實現
上面是我通過debug得出dev-server運行流程比較核心的幾個點,下面將其抽象整合成一個文件。
webpack-dev-server/client端會監聽到此hash消息
在開發客戶端功能之前,需要在src/index.html
中引入socket.io
<script src="/socket.io/socket.io.js"></script>
下面連接socket並接受消息
let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
console.log('客戶端連接成功')
}
let hotCurrentHash // lastHash 上一次 hash值
let currentHash // 這一次的hash值
socket.on('hash', (hash) => {
currentHash = hash
})
將服務端webpack每次編譯所產生hash
進行緩存
客戶端收到ok的消息後會執行reloadApp方法進行更新
socket.on('ok', () => {
reloadApp(true)
})
reloadApp中判斷是否支持熱更新
// 當收到ok事件後,會重新刷新app
function reloadApp(hot) {
if (hot) { // 如果hot爲true 走熱更新的邏輯
hotEmitter.emit('webpackHotUpdate')
} else { // 如果不支持熱更新,則直接重新加載
window.location.reload()
}
}
在reloadApp中會進行判斷,是否支持熱更新,如果支持的話發射webpackHotUpdate事件,如果不支持則直接刷新瀏覽器。
在webpack/hot/dev-server.js會監聽webpackHotUpdate事件
首先需要一個發佈訂閱去綁定事件並在合適的時機觸發。
class Emitter {
constructor() {
this.listeners = {}
}
on(type, listener) {
this.listeners[type] = listener
}
emit(type) {
this.listeners[type] && this.listeners[type]()
}
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
if (!hotCurrentHash || hotCurrentHash == currentHash) {
return hotCurrentHash = currentHash
}
hotCheck()
})
會判斷是否爲第一次進入頁面和代碼是否有更新。
上面的發佈訂閱較爲簡單,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能需要先訂閱後發佈,此時可以移步@careteen/event-emitter。其實現原理也挺簡單,需要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時可以取出所有事件執行。
在check方法裏會調用module.hot.check方法
function hotCheck() {
hotDownloadManifest().then(update => {
let chunkIds = Object.keys(update.c)
chunkIds.forEach(chunkId => {
hotDownloadUpdateChunk(chunkId)
})
})
}
上面也提到過webpack每次編譯都會產生hash值
、已改動模塊的json文件
、已改動模塊代碼的js文件
,
此時先使用ajax
請求Manifest
即服務器這一次編譯相對於上一次編譯改變了哪些module和chunk。
然後再通過jsonp
獲取這些已改動的module和chunk的代碼。
調用hotDownloadManifest方法
function hotDownloadManifest() {
return new Promise(function (resolve) {
let request = new XMLHttpRequest()
//hot-update.json文件裏存放着從上一次編譯到這一次編譯 取到差異
let requestPath = '/' + hotCurrentHash + ".hot-update.json"
request.open('GET', requestPath, true)
request.onreadystatechange = function () {
if (request.readyState === 4) {
let update = JSON.parse(request.responseText)
resolve(update)
}
}
request.send()
})
}
調用hotDownloadUpdateChunk方法通過JSONP請求獲取到最新的模塊代碼
function hotDownloadUpdateChunk(chunkId) {
let script = document.createElement('script')
script.charset = 'utf-8'
// /main.xxxx.hot-update.js
script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
document.head.appendChild(script)
}
這裏解釋下爲什麼使用JSONP
獲取而不直接利用socket
獲取最新代碼?主要是因爲JSONP
獲取的代碼可以直接執行。
調用webpackHotUpdate方法
當客戶端把最新的代碼拉到瀏覽之後
window.webpackHotUpdate = function (chunkId, moreModules) {
// 循環新拉來的模塊
for (let moduleId in moreModules) {
// 從模塊緩存中取到老的模塊定義
let oldModule = __webpack_require__.c[moduleId]
// parents哪些模塊引用這個模塊 children這個模塊引用了哪些模塊
// parents=['./src/index.js']
let {
parents,
children
} = oldModule
// 更新緩存爲最新代碼 緩存進行更新
let module = __webpack_require__.c[moduleId] = {
i: moduleId,
l: false,
exports: {},
parents,
children,
hot: window.hotCreateModule(moduleId)
}
moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
module.l = true // 狀態變爲加載就是給module.exports 賦值了
parents.forEach(parent => {
// parents=['./src/index.js']
let parentModule = __webpack_require__.c[parent]
// _acceptedDependencies={'./src/title.js',render}
parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
})
hotCurrentHash = currentHash
}
}
hotCreateModule的實現
實現我們可以在業務代碼中定義需要熱更新的模塊以及回調函數,將其存放在hot._acceptedDependencies
中。
window.hotCreateModule = function () {
let hot = {
_acceptedDependencies: {},
dispose() {
// 銷燬老的元素
},
accept: function (deps, callback) {
for (let i = 0; i < deps.length; i++) {
// hot._acceptedDependencies={'./title': render}
hot._acceptedDependencies[deps[i]] = callback
}
}
}
return hot
}
然後在webpackHotUpdate
中進行調用
parents.forEach(parent => {
// parents=['./src/index.js']
let parentModule = __webpack_require__.c[parent]
// _acceptedDependencies={'./src/title.js',render}
parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
})
最後調用hotApply方法進行熱更新
客戶端調試階段
經過上述實現了一個基本版的HMR,可更改代碼保存的同時查看瀏覽器並非整體刷新,而是局部更新代碼進而更新視圖。在涉及到大量表單的需求時大大提高了開發效率。
問題
- 如何實現commonjs規範?
感興趣的可前往debug CommonJs規範瞭解其實現原理。
- webpack實現流程以及各個生命週期的作用是什麼?
webpack主要藉助了tapable
這個庫所提供的一系列同步/異步鉤子函數貫穿整個生命週期。基於此我實現了一版簡易的webpack,源碼100+行,食用時伴着註釋很容易消化,感興趣的可前往看個思路。
- 發佈訂閱的使用和實現,並且如何實現一個可先訂閱後發佈的機制?
上面也提到需要使用到發佈訂閱模式,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能需要先訂閱後發佈,此時可以移步@careteen/event-emitter。其實現原理也挺簡單,需要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時可以取出所有事件執行。
- 爲什麼使用JSONP而不用socke通信獲取更新過的代碼?
因爲通過socket通信獲取的是一串字符串需要再做處理。而通過JSONP
獲取的代碼可以直接執行。
引用
- 珠峯架構課
- 模塊熱替換 - webpack官網