深刻源碼分析 webpack 熱更新的原理

如今,已經有不少人分析過 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 這個命令,傳入hotopen 兩個參數。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

主要是作了兩件事情:

  1. 引用 webpack,而後開始編譯,實例化出一個 compiler ,好比說var compiler = webpack(options)

  2. 啓動服務,而且剛纔實例化出來的 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的靜態資源服務的?

創建 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 請求等。

創建 websocket 服務

使用 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 過程當中會利用兩個方法: hotDownloadManifesthotDownloadUpdateChunk

// 調用 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> 標籤,設置好typesrc 屬性,瀏覽器就去下載 js 腳本。由於瀏覽器在下載腳本的時候,不會進行跨域處理,因此 jsonp 也經常用於處理跨域。

<script> 標籤的 src 屬性指定了 js 腳本文件的路徑,是由 chunkIdhotCurrentHash 拼接起來的:

// 大概長這個樣子
"http://localhost:8080/0.4d6b38763300df57f063.hot-update.js"
複製代碼

而後,瀏覽器就回去下載這個文件:

image-20191025215807578

下載回來的文件大概是長這個樣子的:

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 方法能夠看出,模塊熱替換主要分三個階段

  • 第一個階段是找出 outdatedModulesoutdatedDependencie

  • 第二個階段從緩存中刪除過時的模塊和依賴,以下:

delete installedModules[moduleId];
delete outdatedDependencies[moduleId];
複製代碼
  • 第三個階段是將新的模塊添加到 modules 中,當下次調用 webpack_require (webpack 重寫的 require 方法)方法的時候,就是獲取到了新的模塊代碼了

可是,咱們還剩最後一件事情,當用新的模塊代碼替換老的模塊後,可是業務代碼並不能知道代碼已經發生變化,雖然新代碼已經被替換上去了,可是並無被真正執行一遍。

接下來,就是熱更新的階段。

不知道你有沒有聽過或看過這樣一段話:「在高速公路上將汽車引擎換成波音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 的工做流程了。

相關文章
相關標籤/搜索