HMR
即Hot Module Replacement
是指當你對代碼修改並保存後,webpack
將會對代碼進行從新打包,並將改動的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,去實現局部更新頁面而非總體刷新頁面。接下來將從使用到實現一版簡易功能帶領你們深刻淺出HMR
。javascript
文章首發於@careteen/webpack-hmr,轉載請註明來源便可。html
如上圖所示,一個註冊頁面包含用戶名
、密碼
、郵箱
三個必填輸入框,以及一個提交
按鈕,當你在調試郵箱
模塊改動了代碼時,沒作任何處理狀況下是會刷新整個頁面,頻繁的改動代碼會浪費你大量時間去從新填寫內容。預期是保留用戶名
、密碼
的輸入內容,而只替換郵箱
這一模塊。這一訴求就須要藉助webpack-dev-server
的熱模塊更新功能。java
相對於live reload
總體刷新頁面的方案,HMR
的優勢在於能夠保存應用的狀態,提升開發效率。node
首先借助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
便可啓動項目git
經過npm run build
打包生成靜態資源到dist
目錄github
接下來先分析下dist
目錄中的文件web
dist目錄結構ajax
.
├── index.html
└── main.js
複製代碼
index.html
內容以下<!-- ... -->
<div id="root"></div>
<script type="text/javascript" src="main.js"></script></body>
<!-- ... -->
複製代碼
使用html-webpack-plugin
插件將入口文件及其依賴經過script
標籤引入shell
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
執行文件的內容字符。
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的產出文件混個眼熟,不要害怕。其實任何一個無論多複雜的事物都是由更小更簡單的東西組成,剖開它認識它愛上它。
接下來配置並感覺一下熱更新帶來的便捷開發
webpack.config.js
配置
// ...
devServer: {
hot: true
}
// ...
複製代碼
./src/index.js
配置
// ...
if (module.hot) {
module.hot.accept(['./content.js'], () => {
render()
})
}
複製代碼
當更改./content.js
的內容並保存時,能夠看到頁面沒有刷新,可是內容已經被替換了。
這對提升開發效率意義重大。接下來將一層層剖開它,認識它的實現原理。
如上圖所示,右側Server
端使用webpack-dev-server
去啓動本地服務,內部實現主要使用了webpack
、express
、websocket
。
express
啓動本地服務,當瀏覽器訪問資源時對此作響應。websocket
實現長鏈接webpack
監聽源文件的變化,即當開發者保存文件時觸發webpack
的從新編譯。
hash值
、已改動模塊的json文件
、已改動模塊代碼的js文件
socket
向客戶端推送當前編譯的hash戳
websocket
監聽到有文件改動推送過來的hash戳
,會和上一次對比
ajax
和jsonp
向服務端獲取最新資源內存文件系統
去替換有修改的內容實現局部刷新上圖先只看個大概,下面將從服務端和客戶端兩個方面進行詳細分析
如今也只須要關注上圖的右側服務端部分,左側能夠暫時忽略。下面步驟主要是debug服務端源碼分析其詳細思路,也給出了代碼所處的具體位置,感興趣的能夠先行定位到下面的代碼處設置斷點,而後觀察數據的變化狀況。也能夠先跳過閱讀此步驟。
webpack-dev-server
服務器,源代碼地址@webpack-dev-server/webpack-dev-server.js#L173上面是我經過debug得出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配置文件
複製代碼
const compiler = webpack(config)
複製代碼
compiler表明整個webpack編譯任務,全局只有一個
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來當啓動服務的
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實例
。
let app = new express()
複製代碼
let fs = new MemoryFileSystem()
複製代碼
使用MemoryFileSystem
將compiler
的產出文件打包到內存中。
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啓動了本地開發服務後,使用中間件去爲其構造一個靜態服務器,並使用了內存文件系統,使讀取文件後存放到內存中,提升讀寫效率,最終返回生成的文件。
compiler.watch({}, err => {
console.log('又一次編譯任務成功完成了')
})
複製代碼
以監控的模式啓動一次webpack編譯,當編譯成功以後執行回調
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
// ...
}
listen(port) {
this.server.listen(port, () => {
console.log(`服務器已經在${port}端口上啓動了`)
})
}
複製代碼
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得出dev-server運行流程比較核心的幾個點,下面將其抽象整合成一個文件。
在開發客戶端功能以前,須要在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
進行緩存
socket.on('ok', () => {
reloadApp(true)
})
複製代碼
// 當收到ok事件後,會從新刷新app
function reloadApp(hot) {
if (hot) { // 若是hot爲true 走熱更新的邏輯
hotEmitter.emit('webpackHotUpdate')
} else { // 若是不支持熱更新,則直接從新加載
window.location.reload()
}
}
複製代碼
在reloadApp中會進行判斷,是否支持熱更新,若是支持的話發射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。其實現原理也挺簡單,須要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時能夠取出全部事件執行。
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的代碼。
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()
})
}
複製代碼
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
獲取的代碼能夠直接執行。
當客戶端把最新的代碼拉到瀏覽以後
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
}
}
複製代碼
實現咱們能夠在業務代碼中定義須要熱更新的模塊以及回調函數,將其存放在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,可更改代碼保存的同時查看瀏覽器並不是總體刷新,而是局部更新代碼進而更新視圖。在涉及到大量表單的需求時大大提升了開發效率。
感興趣的可前往debug CommonJs規範瞭解其實現原理。
webpack主要藉助了
基於此我實現了一版簡易的 webpack,源碼100+行,食用時伴着註釋很容易消化,感興趣的可前往看個思路。tapable
這個庫所提供的一系列同步/異步鉤子函數貫穿整個生命週期。
上面也提到須要使用到發佈訂閱模式,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能須要先訂閱後發佈,此時能夠移步@careteen/event-emitter。其實現原理也挺簡單,須要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時能夠取出全部事件執行。
由於經過socket通訊獲取的是一串字符串須要再作處理。而經過
JSONP
獲取的代碼能夠直接執行。