webpack Hmr 原始碼實現

燦兒哈擦蘇發表於2019-08-12

一、熱更新原理 

服務端:
  •  1.啟動webpack-dev-server伺服器 
  • 2.建立webpack例項 
  • 3.建立Server伺服器 
  • 4.新增webpackdone事件回撥 編譯完成向客戶端傳送訊息(hash和描述檔案oldhash.js和oldhash.json) 
  • 5.建立express應用app 
  • 6.設定檔案系統為記憶體檔案系統 
  • 7.新增webpack-dev-middleware中介軟體 負責返回生成的檔案 
  • 8.建立http服務 啟動 
  • 9.使用socket 實現瀏覽器和伺服器的通訊(這裡先傳送一次hash,將socket存入到第四步,初次編譯完第四步中的socket是空,不會觸發hash下發) 


客戶端: 

  • 1.webpack-dev-server/client-src下檔案監聽hash,儲存此hash值 
  • 2.客戶端收到ok訊息執行reload更新 
  • 3.在reload中進行判斷,如果支援熱更新執行webpackHotUpdate,不支援的話直接重新整理頁面 
  • 4.在webpack/hot/dev-server.js監聽webpackHotUpdate 然後執行 check() 方法進行檢測 
  • 5.在check方法裡面呼叫module.hot.check 
  • 6.通過呼叫 JsonpMainTemplate.runtimehotDownloadmainfest方法,向server端傳送ajax請求,服務端返回一個Mainfest檔案,該檔案包含所有要更新模組的hash值和chunk名 
  • 7.呼叫 JsonpMainTemplate.runtimehotDownloadUpdateChunk方法通過jsonp請求獲取到最新的模組程式碼 
  • 8.補丁js取回後呼叫 JsonpMainTemplate.runtimewebpackHotUpdate方法,裡面會呼叫hotAddUpdateChunk方法,用心的模組替換掉舊的模組 
  • 9.呼叫HotMoudleReplacement.runtime.jshotAddUpdateChunk方法動態更新模組程式碼 
  • 10.呼叫 hotApply 方法熱更新

客戶端程式碼輔助流程理解

  • 客戶端這裡初次載入 先走 socket.on("hash")和socket.on("ok") 拿到服務端首次生成的hash
  • 然後執行 reloadApp 這個函式 這裡派發 hotEmitter.emit('webpackHotUpdate') 事件
  • 然後執行 hotEmitter.on('webpackHotUpdate') 這個函式,
  • 因為是初次編譯 所以 hotCurrentHash 為 undefined 然後將首次拿到的 currentHash 賦值給 hotCurrentHash
  • 到這裡 初次載入的邏輯執行完畢
  • ------------------next--------------------
  • 假如使用者修改了某個模組的程式碼,將會再次執行 socket.on("hash")和socket.on("ok") 拿到最新的程式碼編譯後的 hash
  • 如上述步驟進入 hotEmitter.on('webpackHotUpdate') 中的事件判斷, if(!hotCurrentHash || hotCurrentHash == currentHash) hotCurrentHash為上次的hash值 currentHash為最新收到的 並且判斷兩次是否一致,一致則不需要更新,不一致就執行熱更新邏輯
  • hotCheck 會通過ajax請求服務端拉取最新的 hot-update.json 描述檔案 說明哪些模組哪些chunk(大集合)發生了更新改變
  • 然後根據描述檔案 hotDownloadUpdateChunk 去建立jsonp拉取到最新的更新後的程式碼,返回形式為: webpackHotUpdate(id, {...})
  • 為了拉取到的程式碼直接執行,客戶端需要定義一個 window.webpackHotUpdate 函式來處理
  • 這裡面將快取的舊程式碼更新為最新的程式碼,接著將父模組中的render函式執行一下
  • 最後將 hotCurrentHash = currentHash 置舊hash方便下次比較

二、根據流程實現程式碼:

客戶端:

//釋出訂閱
class Emitter{
	constructor(){
		this.listeners = {}
	}
	on(type, listener){
		this.listeners[type] = listener
	}
	emit(){
		this.listeners[type] && this.listeners[type]()
	}
}

let socket = io('/');
let hotEmitter = new Emitter();
const onConnected = () => {
	console.log('客戶端連線成功')
}
//存放服務端傳給的hash 本次的hash 和 上一次的hash
let currentHash, hotCurrentHash;
socket.on("hash",(hash)=>{
	currentHash = hash
});

//收到ok事件之後
socket.on('ok',()=>{
	//true代表熱更新
	reloadApp(true);
})
hotEmitter.on('webpackHotUpdate',()=>{
	if(!hotCurrentHash || hotCurrentHash == currentHash){
		return hotCurrentHash = currentHash
	} 
	hotCheck()
})

function hotCheck(){
	hotDownloadMainfest().then((update)=>{
		let chunkIds = Object.keys(update.c)
		chunkIds.forEach(chunkId=>{
			hotDownloadUpdateChunk(chunkId);
		})
	})
}
function hotDownloadUpdateChunk(chunkId){
	let script = document.createElement('script');
	script.charset = 'utd-8'
	script.src = '/'+chunkId+'.'+hotCurrentHash+'.hot-update.js'
	document.head.appendChild(script);
}
//此方法用來詢問伺服器到底這一次編譯相對於上一次編譯改變了哪些chunk、哪些模組
function hotDownloadMainfest(){
	return new Promise(function(resolve){
		let request = new XMLHttpRequest()
		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 reloadApp(hot){
	if(hot){
		//釋出
		hotEmitter.emit('webpackHotUpdate')
	}else{
		//不支援熱更新直接重新整理
		window.location.reload()
	}
}

window.hotCreateModule = function(){
	let hot = {
		_acceptedDependencies:{},
		accept: function(deps, callback){
			//callback 對應render回撥
			for(let i = 0; i < deps.length; i++){
				hot._acceptedDependencies[deps[i]] = callback
			}
			
		}
	}
	return hot
}

//通過jsonp獲取的最新程式碼   jsonp中有webpackHotUpdate這個函式
window.webpackHotUpdate = function(chunkId, moreModules){
	for(let moduleId in moreModules){
		//從模組快取中取到老的模組定義
		let oldModule - __webpack_requrie__.c[moduleId]
		let {parents, children} = oldModule
		//parents哪些模組引用和這個模組  children這個模組用了哪些模組
		//更新快取為最新程式碼
		let module = __webpack_requrie__.c[moduleId] = {
			i: moduleId,
			l: false,
			exports: {},
			parents,
			children,
			hot: window.hotCreateModule(moduleId)
		}
		moreModules[moduleId].call(module.exports, module, module.exports, __webpack_requrie__)
		module.l = true
		//index.js ---import a.js import b.js  a.js和b.js的父模組(index.js)   
		parents.forEach(par=>{
			//父中的老模組的物件
			let parModule = __webpack_requrie__.c[par]
			parModule && parModule.hot && parModule.hot._acceptedDependencies[moduleId] && parModule.hot._acceptedDependencies[moduleId]()
		})
		//熱更新之後 本次的hash變為上一次的hash  置舊操作
		hotCurrentHash = currentHash
	}
}


socket.on("connect", onConnected);

複製程式碼


服務端實現:

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');
//compiler代表整個webpack編譯任務,全域性只有一個
const compiler = webpack(config);
class Server{
  constructor(compiler){
      this.compiler = compiler;
      let sockets = [];
      let lasthash;//每次編譯完成後都會產生一個stats物件,其中有一個hash值代表這一次編譯結果hash就是一個32的字串
      compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
          lasthash = stats.hash;
          //每當新一個編譯完成後都會向客戶端傳送訊息
          sockets.forEach(socket=>{
              //先向客戶端傳送最新的hash值
              //每次編譯都會產生一個hash值,另外如果是熱更新的話,還會產出二個補丁檔案。
              //裡面描述了從上一次結果到這一次結果都有哪些chunk和模組發生了變化 
              socket.emit('hash',stats.hash);
              //再向客戶端傳送一個ok
              socket.emit('ok');
          });
      });
      let app = new express();
      //以監控的模組啟動一次webpack編譯,當編譯成功之後執行回撥
      compiler.watch({},err=>{
          console.log('又一次編譯任務成功完成了')
      });
      let fs = new MemoryFileSystem();
      //如果你把compiler的輸出檔案系統改成了 MemoryFileSystem的話,則以後再產出檔案都打包記憶體裡去了
      compiler.outputFileSystem = fs;
      function middleware(req, res, next) {
          // /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{
             // next();
            return  res.senStatus(404);
          }
      }
      //express app  其實是一個請求監聽函式
      app.use(middleware);
      this.server = require('http').createServer(app);
      let io = require('socket.io')(this.server);
      //啟動一個 websocket伺服器,然後等待連線來到,連線到來之後socket
      io.on('connection',(socket)=>{
        sockets.push(socket);
        socket.emit('hash',lasthash);
        //再向客戶端傳送一個ok
        socket.emit('ok');
      });
  }
  listen(port){
    this.server.listen(port,()=>{
        console.log(`伺服器已經在${port}埠上啟動了`)
    });
  }
}
let server = new Server(compiler);
server.listen(8000);複製程式碼


相關文章