HMR是什麼javascript
配置使用HMRhtml
debug服務端源碼java
debug客戶端源碼node
HMR
即Hot Module Replacement
是指當你對代碼修改並保存後,webpack
將會對代碼進行從新打包,並將改動的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,去實現局部更新頁面而非總體刷新頁面。接下來將從使用到實現一版簡易功能帶領你們深刻淺出HMR
。webpack
文章首發於@careteen/webpack-hmr,轉載請註明來源便可。git
如上圖所示,一個註冊頁面包含用戶名
、密碼
、郵箱
三個必填輸入框,以及一個提交
按鈕,當你在調試郵箱
模塊改動了代碼時,沒作任何處理狀況下是會刷新整個頁面,頻繁的改動代碼會浪費你大量時間去從新填寫內容。預期是保留用戶名
、密碼
的輸入內容,而只替換郵箱
這一模塊。這一訴求就須要藉助webpack-dev-server
的熱模塊更新功能。github
相對於live reload
總體刷新頁面的方案,HMR
的優勢在於能夠保存應用的狀態,提升開發效率。web
首先借助webpack
搭建項目ajax
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
目錄中的文件shell
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
執行文件的內容字符。
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 添加webpack的done事件回調,源代碼地址@webpack-dev-server/Server.js#L122
添加webpack-dev-middleware中間件,源代碼地址@webpack-dev-server/Server.js#L125
使用sockjs在瀏覽器端和服務端之間創建一個 websocket 長鏈接,源代碼地址@webpack-dev-server/Server.js#L745
上面是我經過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主要藉助了
tapable
這個庫所提供的一系列同步/異步鉤子函數貫穿整個生命週期。
基於此我實現了一版簡易的
webpack,源碼100+行,食用時伴着註釋很容易消化,感興趣的可前往看個思路。
上面也提到須要使用到發佈訂閱模式,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能須要先訂閱後發佈,此時能夠移步 @careteen/event-emitter。其實現原理也挺簡單,須要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時能夠取出全部事件執行。
由於經過socket通訊獲取的是一串字符串須要再作處理。而經過
JSONP
獲取的代碼能夠直接執行。