從零實現webpack熱更新HMR

前言

本文以剖析webpack-dev-server源碼,從零開始實現一個webpack熱更新HMR,深刻了解webpack-dev-server、webpack-dev-middleware、webpack-hot-middleware的實現機制,完全搞懂他們的原理,在面試過程當中這個知識點能答的很是出彩,在搭建腳手架過程當中這塊能駕輕就熟。知其然並知其因此然,更上一層樓。javascript

舒適提示❤️~篇幅較長,建議收藏到電腦端食用更佳。html

源碼連接
原理圖連接vue

零、什麼是HMR

1. 概念

Hot Module Replacement是指當咱們對代碼修改並保存後,webpack將會對代碼進行得新打包,並將新的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,以實如今不刷新瀏覽器的前提下更新頁面。java

2. 優勢

相對於live reload刷新頁面的方案,HMR的優勢在於能夠保存應用的狀態,提升了開發效率node

3. 那就來用用吧

./src/index.jswebpack

// 建立一個input,能夠在裏面輸入一些東西,方便咱們觀察熱更新的效果
let inputEl = document.createElement("input");
document.body.appendChild(inputEl);

let divEl = document.createElement("div")
document.body.appendChild(divEl);

let render = () => {
    let content = require("./content").default;
    divEl.innerText = content;
}
render();

// 要實現熱更新,這段代碼並不可少,描述當模塊被更新後作什麼
// 爲何vue-cli中.vue不用寫額外的邏輯,也能夠實現熱更新呢?那是由於有vue-loader幫咱們作了,不少loader都實現了熱更新
if (module.hot) {
    module.hot.accept(["./content.js"], render);
}
複製代碼

./src/content.jsgit

let content = "hello world"
console.log("welcome");
export default content;
複製代碼

cd 項目根目錄github

npm run devweb

4. 效果看圖

當咱們在輸入框中輸入了123,這個時候更新content.js中的代碼,會發現hello world!!!!變成了hello world,可是 輸入框的值 還保留着,這正是HMR的意義,頁面刷新期間保留狀態 面試

5. 理解chunk和module的概念

chunk 就是若干 module 打成的包,一個 chunk 應該包括多個 module,通常來講最終會造成一個 file。而 js 之外的資源,webpack 會經過各類 loader 轉化成一個 module,這個模塊會被打包到某個 chunk 中,並不會造成一個單獨的 chunk。

1、webpack編譯

Webpack watch:使用監控模式開始啓動webpack編譯,在 webpack 的 watch 模式下,文件系統中某一個文件發生修改,webpack 監聽到文件變化,根據配置文件對模塊從新編譯打包,每次編譯都會產生一個惟一的hash值

1. HotModuleReplacementPlugin作了哪些事

  1. 生成兩個補丁文件
  • manifest(JSON)上一次編譯生成的hash.hot-update.json(如:b1f49e2fc76aae861d9f.hot-update.json)
  • updated chunk (JavaScript) chunk名字.上一次編譯生成的hash.hot-update.js(如main.b1f49e2fc76aae861d9f.hot-update.js)
    這裏調用了一個全局的webpackHotUpdate函數,留心一下這個js的結構
  • 是的這兩個文件不是webpack生成的,而是這個插件生成的,你可在配置文件把HotModuleReplacementPlugin去掉試一試
  1. 在chunk文件中注入HMR runtime運行時代碼:咱們的熱更新客戶端主要邏輯(拉取新模塊代碼執行新模塊代碼執行accept的回調實現局部更新)都是這個插件 把函數 注入到咱們的chunk文件中,而非webpack-dev-server,webpack-dev-server只是調用了這些函數

2. 看懂打包文件

下面這段代碼就是使用的HotModuleReplacementPlugin編譯生成的chunk,注入了HMR runtime的代碼,啓動服務npm run dev,輸入http://localhost:8000/main.js,截取主要的邏輯,細節處理省了(先細看,有個大概印象)

(function (modules) {
  	//(HMR runtime代碼) module.hot屬性就是hotCreateModule函數的執行結果,全部hot屬性有accept、check等屬性
  	function hotCreateModule() {
        var hot = {
            accept: function (dep, callback) {
                for (var i = 0; i < dep.length; i++)
                    hot._acceptedDependencies[dep[i]] = callback;
            },
            check: hotCheck,//【在webpack/hot/dev-server.js中調用module.hot.accept就是hotCheck函數】
        };
        return hot;
    }
  	
    //(HMR runtime代碼) 如下幾個方法是 拉取更新模塊的代碼
    function hotCheck(apply) {}
    function hotDownloadUpdateChunk(chunkId) {}
    function hotDownloadManifest(requestTimeout) {}

    //(HMR runtime代碼) 如下幾個方法是 執行新代碼 並 執行accept回調
    window["webpackHotUpdate"] = function webpackHotUpdateCallback(chunkId, moreModules) {
        hotAddUpdateChunk(chunkId, moreModules);
    };
    function hotAddUpdateChunk(chunkId, moreModules) {hotUpdateDownloaded();}
    function hotUpdateDownloaded() {hotApply()}
    function hotApply(options) {}

    //(HMR runtime代碼) hotCreateRequire給模塊parents、children賦值了
    function hotCreateRequire(moduleId) {
      	var fn = function(request) {
            return __webpack_require__(request);
        };
        return fn;
    }
  
    // 模塊緩存對象
    var installedModules = {};

    // 實現了一個 require 方法
    function __webpack_require__(moduleId) {
        // 判斷這個模塊是否在 installedModules緩存 中
        if (installedModules[moduleId]) {
            // 在緩存中,直接返回 installedModules緩存 中該 模塊的導出對象
            return installedModules[moduleId].exports;
        }
      
        // Create a new module (and put it into the cache)
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,  // 模塊是否加載
            exports: {},  // 模塊的導出對象
            hot: hotCreateModule(moduleId), // module.hot === hotCreateModule導出的對象
            parents: [], // 這個模塊 被 哪些模塊引用了
            children: [] // 這個模塊 引用了 哪些模塊
        };

        // (HMR runtime代碼) 執行模塊的代碼,傳入參數
        modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));

        // 設置模塊已加載
        module.l = true;

        // 返回模塊的導出對象
        return module.exports;
    }
  
    // 暴露 模塊的緩存
    __webpack_require__.c = installedModules;

    // 加載入口模塊 而且 返回導出對象
    return hotCreateRequire(0)(__webpack_require__.s = 0);
})(
    {
        "./src/content.js":
            (function (module, __webpack_exports__, __webpack_require__) {}),
        "./src/index.js":
            (function (module, exports, __webpack_require__) {}),// 在模塊中使用的require都編譯成了__webpack_require__

        "./src/lib/client/emitter.js":
            (function (module, exports, __webpack_require__) {}),
        "./src/lib/client/hot/dev-server.js":
            (function (module, exports, __webpack_require__) {}),
        "./src/lib/client/index.js":
            (function (module, exports, __webpack_require__) {}),

        0:// 主入口
            (function (module, exports, __webpack_require__) {
                eval(` __webpack_require__("./src/lib/client/index.js"); __webpack_require__("./src/lib/client/hot/dev-server.js"); module.exports = __webpack_require__("./src/index.js"); `);
            })
    }
);
複製代碼

梳理下大概的流程:

  • hotCreateRequire(0)(__webpack_require__.s = 0)主入口

  • 當瀏覽器執行這個chunk時,在執行每一個模塊的時候,會給每一個模塊傳入一個module對象,結構以下,並把這個module對象放到緩存installedModules中;咱們能夠經過__webpack_require__.c拿到這個模塊緩存對象

    var module = installedModules[moduleId] = {
          i: moduleId,
          l: false,
          exports: {},
          hot: hotCreateModule(moduleId),
          parents: [],
          children: []
      };
    複製代碼
  • hotCreateRequire會幫咱們給模塊 module的parents、children賦值

  • 接下來看看hot屬性,hotCreateModule(moduleId)返回了啥?沒錯hot是一個對象有accept、check兩個主要屬性,接下來咱們就詳細的解剖下module.hot和module.hot.accept

    function hotCreateModule() {
          var hot = {
              accept: function (dep, callback) {
              for (var i = 0; i < dep.length; i++)
                  hot._acceptedDependencies[dep[i]] = callback;
              },
              check: hotCheck,
          };
          return hot;
      }
    複製代碼

3. 聊聊module.hot和module.hot.accept

1. accept使用

若是要實現熱更新,下面這段代碼是必不可少的,accept傳入的回調函數就是局部刷新邏輯,當./content.js模塊改變時執行

if (module.hot) {
    module.hot.accept(["./content.js"], render);
}
複製代碼

2. accept原理

爲何咱們只有寫了module.hot.accept(["./content.js"], render);才能實現熱更新,這得從accept這個函數的原理開始提及,咱們再來看看 module.hot 和 module.hot.accept

function hotCreateModule() {
    var hot = {
        accept: function (dep, callback) {
            for (var i = 0; i < dep.length; i++)
                hot._acceptedDependencies[dep[i]] = callback;
        },
    };
    return hot;
} 
var module = installedModules[moduleId] = {
    // ...
    hot: hotCreateModule(moduleId),
};
複製代碼

沒錯accept就是往hot._acceptedDependencies對象存入 局部更新回調函數,_acceptedDependencies何時會用到呢?(當模塊文件改變的時候,咱們會調用acceptedDependencies蒐集的回調

3. 再看accept

// 再看下面這段代碼是否是有點明白了
if (module.hot) {
    module.hot.accept(["./content.js"], render);
    // 等價於module.hot._acceptedDependencies["./content.js"] = render
    // 沒錯,他就是將模塊改變時,要作的事進行了蒐集,蒐集到_acceptedDependencies中
    // 以便當content.js模塊改變時,他的父模塊index.js經過_acceptedDependencies知道要幹什麼
}
複製代碼

2、整體流程

1. 整個流程分爲客戶端和服務端

2. 經過 websocket 創建起 瀏覽器端 和 服務器端 之間的通訊

3. 服務端主要分爲四個關鍵點

  • 經過webpack建立compiler實例,webpack在watch模式下編譯
    • compiler實例:監聽本地文件的變化、文件改變自動編譯、編譯輸出
    • 更改config中的entry屬性:將lib/client/index.js、lib/client/hot/dev-server.js注入到打包輸出的chunk文件中
    • 往compiler.hooks.done鉤子(webpack編譯完成後觸發)註冊事件:裏面會向客戶端發射hashok事件
  • 調用webpack-dev-middleware:啓動編譯、設置文件爲內存文件系統、裏面有一箇中間件負責返回編譯的文件
  • 建立webserver靜態服務器:讓瀏覽器能夠請求編譯生成的靜態資源
  • 建立websocket服務:創建本地服務和瀏覽器的雙向通訊;每當有新的編譯,立馬告知瀏覽器執行熱更新邏輯

4. 客戶端主要分爲兩個關鍵點

  • 建立一個 websocket客戶端 鏈接 websocket服務端,websocket客戶端監聽 hashok 事件
  • 主要的熱更新客戶端實現邏輯,瀏覽器會接收服務器端推送的消息,若是須要熱更新,瀏覽器發起http請求去服務器端獲取新的模塊資源解析並局部刷新頁面(這本是HotModuleReplacementPlugin幫咱們作了,他將HMR 運行時代碼注入到chunk中了,可是我會帶你們實現這個 HMR runtime

5. 原理圖

3、源碼實現

1、結構

.
├── package-lock.json
├── package.json
├── src
│   ├── content.js   測試代碼
│   ├── index.js     測試代碼入口
│   ├── lib
│   │   ├── client   熱更新客戶端實現邏輯
│   │   │   ├── index.js   等價於源碼中的webpack-dev-server/client/index.js
│   │   │   ├── emitter.js
│   │   │   └── hot
│   │   │       └── dev-server.js   等價於源碼中的webpack/hot/dev-server.js 和 HMR runtime
│   │   └── server   熱更新服務端實現邏輯
│   │       ├── Server.js
│   │       └── updateCompiler.js
│   └── myHMR-webpack-dev-server.js   熱更新服務端主入口
└── webpack.config.js   webpack配置文件
複製代碼

2、看看webpack.config.js

// /webpack.config.js

let webpack = require("webpack");
let HtmlWebpackPlugin = require("html-webpack-plugin")
let path = require("path");
module.exports = {
    mode: "development",
    entry:"./src/index.js",// 這裏咱們尚未將客戶端代碼配置,而是經過updateCompiler方法更改entry屬性
    output: {
        filename: "[name].js",
        path: path.resolve(__dirname, "dist")
    },
    plugins: [
        new HtmlWebpackPlugin(),// 輸出一個html,並將打包的chunk引入
        new webpack.HotModuleReplacementPlugin()// 注入HMR runtime代碼
    ]
}
複製代碼

3、依賴的模塊

"dependencies": {
    "express": "^4.17.1",
  	"mime": "^2.4.4",
    
    "socket.io": "^2.3.0",
    
  	"webpack": "^4.41.2",
    "webpack-cli": "^3.3.10",
   	"memory-fs": "^0.5.0",
		"html-webpack-plugin": "^3.2.0",
}
複製代碼

4、服務端實現

  • /src/myHMR-webpack-dev-server.js 熱更新服務端入口
  • /src/lib/server/Server.js Server類是熱更新服務端的主要邏輯
  • /src/lib/server/updateCompiler.js 更改entry,增長/src/lib/client/index.js和/src/lib/client/hot/dev-server.js

1. myHMR-webpack-dev-server.js總體一覽

// /src/myHMR-webpack-dev-server.js
 
 const webpack = require("webpack");
 const Server = require("./lib/server/Server");
 const config = require("../../webpack.config");
 
 // 【1】建立webpack實例 
 const compiler = webpack(config);
 // 【2】建立Server類,這個類裏面包含了webpack-dev-server服務端的主要邏輯(在 2.Server總體 中會梳理他的邏輯)
 const server = new Server(compiler);
 
 // 最後一步【10】啓動webserver服務器
 server.listen(8000, "localhost", () => {
     console.log(`Project is running at http://localhost:8000/`);
 })
複製代碼

2. Server總體一覽

// /src/lib/server/Server.js

const express = require("express");
const http = require("http");
const mime = require("mime");// 能夠根據文件後綴,生成相應的Content-Type類型
const path = require("path");
const socket = require("socket.io");// 經過它和http實現websocket服務端
const MemoryFileSystem = require("memory-fs");// 內存文件系統,主要目的就是將編譯後的文件打包到內存
const updateCompiler = require("./updateCompiler");

class Server {
    constructor(compiler) {
        this.compiler = compiler;// 將webpack實例掛載到this上
        updateCompiler(compiler);// 【3】entry增長 websocket客戶端的兩個文件,讓其一同打包到chunk中
        this.currentHash;// 每次編譯的hash
        this.clientSocketList = [];// 全部的websocket客戶端
        this.fs;// 會指向內存文件系統
        this.server;// webserver服務器
	      this.app;// express實例
        this.middleware;// webpack-dev-middleware返回的express中間件,用於返回編譯的文件

        this.setupHooks();// 【4】添加webpack的done事件回調,編譯完成時會觸發;編譯完成時向客戶端發送消息,經過websocket向全部的websocket客戶端發送兩個事件,告知瀏覽器來拉取新的代碼了
      	this.setupApp();//【5】建立express實例app
        this.setupDevMiddleware();// 【6】裏面就是webpack-dev-middlerware完成的工做,主要是本地文件的監聽、啓動webpack編譯、設置文件系統爲內存文件系統(讓編譯輸出到內存中)、裏面有一箇中間件負責返回編譯的文件
      	this.routes();// 【7】app中使用webpack-dev-middlerware返回的中間件
        this.createServer();// 【8】建立webserver服務器,讓瀏覽器能夠訪問編譯的文件
        this.createSocketServer();// 【9】建立websocket服務器,監聽connection事件,將全部的websocket客戶端存起來,同時經過發送hash事件,將最新一次的編譯hash傳給客戶端
    }
    setupHooks() {}
    setupApp() {} 
    setupDevMiddleware() {}
    routes() {}
    createServer() {}
    createSocketServer() {}
    listen() {}// 啓動服務器
}

module.exports = Server;
複製代碼

3. 更改webpack的entry屬性,增長 websocket客戶端文件,讓其編譯到chunk中

在進行webpack編譯前,調用了updateCompiler(compiler)方法,這個方法很關鍵,他會往咱們的chunk中偷偷塞入兩個文件,lib/client/client.jslib/client/hot-dev-server.js

這兩個文件是幹什麼的呢?咱們說利用websocket實現雙向通訊的,咱們服務端會建立一個websocket服務器(第9步會講),每當代碼改動時會從新進行編譯,生成新的編譯文件,這時咱們websocket服務端將通知瀏覽器,你快來拉取新的代碼啦

那麼一個websocket客戶端,實現和服務端通訊的邏輯,是否是也的有呢?因而webpack-dev-server給咱們提供了客戶端的代碼,也就是上面的兩個文件,爲咱們安插了一個間諜,悄悄地去拉新的代碼、實現熱更新

爲啥要分紅兩個文件呢?固然是模塊劃分啦,balabala寫在一坨總很差吧,在客戶端實現部分我會細說這兩個文件幹了什麼

// /src/lib/server/updateCompiler.js

const path = require("path");
let updateCompiler = (compiler) => {
    const config = compiler.options;
    config.entry = {
        main: [
            path.resolve(__dirname, "../client/index.js"),
            path.resolve(__dirname, "../client/hot-dev-server.js"),
            config.entry
        ]
    }
    compiler.hooks.entryOption.call(config.context, config.entry);
}
module.exports = updateCompiler;
複製代碼

​ 修改後的webpack入口配置以下:

{ 
    entry:{ 
        main: [
            'xxx/src/lib/client/index.js',
            'xxx/src/lib/client/hot/dev-server.js',
            './src/index.js'
        ],
    },
}      
複製代碼

4. 添加webpack的done事件回調

咱們要在compiler編譯完成的鉤子上註冊一個事件,這個事件主要乾了一件事情,每當新一次編譯完成後都會向全部的websocket客戶端發送消息,發射兩個事件,通知瀏覽器來拉代碼啦

瀏覽器會監聽這兩個事件,瀏覽器會去拉取上次編譯生成的hash.hot-update.json,具體的邏輯咱們會在下面的客戶端章節詳細講解

// /src/lib/server/Server.js
setupHooks() {
    let { compiler } = this;
    compiler.hooks.done.tap("webpack-dev-server", (stats) => {
        //每次編譯都會產生一個惟一的hash值
        this.currentHash = stats.hash;
        //每當新一個編譯完成後都會向全部的websocket客戶端發送消息
        this.clientSocketList.forEach(socket => {
            //先向客戶端發送最新的hash值
            socket.emit("hash", this.currentHash);
            //再向客戶端發送一個ok
            socket.emit("ok");
        });
    });
}
複製代碼

5.建立express實例app

setupApp() {
    this.app = new express();
}
複製代碼

6. 添加webpack-dev-middleware中間件

1. 關於webpack-dev-server和webpack-dev-middleware
  • webpack-dev-server核心是作準備工做(更改entry、監聽webpack done事件等)、建立webserver服務器和websocket服務器讓瀏覽器和服務端創建通訊
  • 編譯和編譯文件相關的操做都抽離到webpack-dev-middleware
2. Webpack-dev-middleware主要乾了三件事(這裏咱們本身實現他的邏輯)
  • 本地文件的監聽、啓動webpack編譯;使用監控模式開始啓動webpack編譯在 webpack 的 watch 模式下,文件系統中某一個文件發生修改,webpack 監聽到文件變化,根據配置文件對模塊從新編譯打包;
  • 設置文件系統爲內存文件系統(讓編譯輸出到內存中)
  • 實現了一個express中間件,將編譯的文件返回
// /src/lib/server/Server.js
setupDevMiddleware() {
    let { compiler } = this;
    // 會監控文件的變化,每當有文件改變(ctrl+s)的時候都會從新編譯打包
    // 在編譯輸出的過程當中,會生成兩個補丁文件 hash.hot-update.json 和 chunk名.hash.hot-update.js
    compiler.watch({}, () => {
        console.log("Compiled successfully!");
    });

    //設置文件系統爲內存文件系統,同時掛載到this上,以方便webserver中使用
    let fs = new MemoryFileSystem();
    this.fs = compiler.outputFileSystem = fs;
  
    // express中間件,將編譯的文件返回
    // 爲何不直接使用express的static中間件,由於咱們要讀取的文件在內存中,因此本身實現一款簡易版的static中間件
    let staticMiddleWare = (fileDir) => {
        return (req, res, next) => {
            let { url } = req;
            if (url === "/favicon.ico") {
              	return res.sendStatus(404);
            }
            url === "/" ? url = "/index.html" : null;
            let filePath = path.join(fileDir, url);
            try {
              	let statObj = this.fs.statSync(filePath);
                if (statObj.isFile()) {// 判斷是不是文件,不是文件直接返回404(簡單粗暴)
                    // 路徑和原來寫到磁盤的同樣,只是這是寫到內存中了
                    let content = this.fs.readFileSync(filePath);
                    res.setHeader("Content-Type", mime.getType(filePath));
                    res.send(content);
              	} else {
                    res.sendStatus(404);
              	}
            } catch (error) {
                res.sendStatus(404);
            }
        }
    }
    this.middleware = staticMiddleWare;// 將中間件掛載在this實例上,以便app使用
}
複製代碼

7. app中使用webpack-dev-middlerware返回的中間件

routes() {
    let { compiler } = this;
    let config = compiler.options;// 通過webpack(config),會將 webpack.config.js導出的對象 掛在compiler.options上
    this.app.use(this.middleware(config.output.path));// 使用webpack-dev-middleware導出的中間件
}
複製代碼

8. 建立webserver服務器

讓瀏覽器能夠請求webpack編譯後的靜態資源

這裏使用了express和原生的http,你可能會有個疑問?爲何不直接使用express和http中的任意一個?

  • 不直接使用express,是由於咱們拿不到server,能夠看下express的源碼,爲何要這個server,由於咱們要在socket中使用;
  • 不直接使用http,想必你們也知道,原生http寫邏輯簡直傷不起;咱們這裏只是寫了一個簡單的static處理邏輯,因此看不出什麼,可是源碼中還有不少的邏輯,這裏只是將核心邏輯挑了出來實現
  • 那既然二者都有缺陷,就結合一下唄,咱們用原生http建立一個服務,不就拿到了server嘛,這個server的請求邏輯,仍是交給express處理就行了唄,this.server = http.createServer(app);一行代碼完美搞定
// /src/lib/server/Server.js
createServer() {
    this.server = http.createServer(this.app);
}
複製代碼

9. 建立websocket服務器

使用socket.js在瀏覽器端和服務端之間創建一個 websocket 長鏈接

// /src/lib/server/Server.js
createSocketServer() {
    // socket.io+http服務 實現一個websocket
    const io = socket(this.server);
    io.on("connection", (socket) => {
        console.log("a new client connect server");
        // 把全部的websocket客戶端存起來,以便編譯完成後向這個websocket客戶端發送消息(實現雙向通訊的關鍵)
        this.clientSocketList.push(socket);
        // 每當有客戶端斷開時,移除這個websocket客戶端
        socket.on("disconnect", () => {
            let num = this.clientSocketList.indexOf(socket);
            this.clientSocketList = this.clientSocketList.splice(num, 1);
        });
        // 向客戶端發送最新的一個編譯hash
        socket.emit('hash', this.currentHash);
        // 再向客戶端發送一個ok
        socket.emit('ok');
    });
}
複製代碼

10. 啓動webserver服務,開始監聽

// /src/lib/server/Server.js
listen(port, host = "localhost", cb = new Function()) {
  	this.server.listen(port, host, cb);
}
複製代碼

5、客戶端實現

  • /src/lib/client/index.js負責websocket客戶端hash和ok事件的監聽,ok事件的回調只幹了一件事發射webpackHotUpdate事件

  • /src/lib/client/hot/dev-server.js負責監聽webpackHotUpdate,調用hotCheck開始拉取代碼,實現局部更新

  • 他們經過/src/lib/client/emitter.js的共用了一個EventEmitter實例

0. emitter紐帶

// /src/lib/client/emiitter.js
const { EventEmitter } = require("events");
module.exports = new EventEmitter();
// 使用events 發佈訂閱的模式,主要仍是爲了解耦
複製代碼

1. index.js實現

// /src/lib/client/index.js
const io = require("socket.io-client/dist/socket.io");// websocket客戶端
const hotEmitter = require("./emitter");// 和hot/dev-server.js共用一個EventEmitter實例,這裏用於發射事件
let currentHash;// 最新的編譯hash

//【1】鏈接websocket服務器
const URL = "/";
const socket = io(URL);

//【2】websocket客戶端監聽事件
const onSocketMessage = {
    //【2.1】註冊hash事件回調,這個回調主要乾了一件事,獲取最新的編譯hash值
    hash(hash) {
        console.log("hash",hash);
        currentHash = hash;
    },
    //【2.2】註冊ok事件回調,調用reloadApp進行熱更新
    ok() {
        console.log("ok");
        reloadApp();
    },
    connect() {
        console.log("client connect successfully!");
    }
};
// 將onSocketMessage進行循環,給websocket註冊事件
Object.keys(onSocketMessage).forEach(eventName => {
    let handler = onSocketMessage[eventName];
    socket.on(eventName, handler);
});

//【3】reloadApp中 發射webpackHotUpdate事件
let reloadApp = () => {
    let hot = true;
  	// 會進行判斷,是否支持熱更新;咱們自己就是爲了實現熱更新,因此簡單粗暴設置爲true
  	if (hot) {
        // 事件通知:若是支持的話發射webpackHotUpdate事件
        hotEmitter.emit("webpackHotUpdate", currentHash);
    } else {
        // 直接刷新:若是不支持則直接刷新瀏覽器 
        window.location.reload();
    }
}
複製代碼

2. 聊聊源碼中的webpack/hot/dev-server.js

咱們說了webpack-dev-server.js會在updateCompiler(compiler)更改entry配置,將webpack-dev-server/client/index.js?http://localhost:8080webpack/hot/dev-server.js一塊兒打包到chunk中,那咱們就來揭開源碼中的hot/devserver.js的真面目吧,沒錯下面就是主要代碼

// 源碼中webpack/hot/dev-server.js
if (module.hot) {// 是否支持熱更新
    var check = function check() {
    	module.hot
            .check(true)// 沒錯module.hot.check就是hotCheck函數,看是否是繞到了HRMPlugin在打包的chunk中注入的HMR runtime代碼啦
            .then( /*日誌輸出*/)
            .catch( /*日誌輸出*/)
    };

    // 和client/index.js共用一個EventEmitter實例,這裏用於監聽事件
    var hotEmitter = require("./emitter");

    // 監聽webpackHotUpdate事件
    hotEmitter.on("webpackHotUpdate", function(currentHash) {
       	check();
    });
} else {
    throw new Error("[HMR] Hot Module Replacement is disabled.");
}
複製代碼

明白了吧,真正的客戶端熱更新的邏輯都是HotModuleReplacementPlugin.runtime運行時代碼乾的,經過module.hot.check=hotCheck把 webpack/hot/dev-server.jsHotModuleReplacementPlugin在chunk文件中注入的hotCheck等代碼 架起一座橋樑

3. hot/dev-server.js總體概覽

和源碼的出入:源碼中hot/dev-server.js很簡單,就是調用了module.hot.check(即HMR runtime運行時的hotCheck)。HotModuleReplacementPlugin插入的代碼是熱更新客戶端的核心

接下來看看咱們本身要實現的hot/dev-server.js的總體,咱們不使用HotModuleReplacementPlugin插入的運行時代碼,而是在hot/dev-server.js咱們本身實現一遍

let hotEmitter = require("../emitter");// 和client.js公用一個EventEmitter實例
let currentHash;// 最新編譯生成的hash
let lastHash;// 表示上一次編譯生成的hash,源碼中是hotCurrentHash,爲了直接表達他的字面意思換了個名字

//【4】監聽webpackHotUpdate事件,而後執行hotCheck()方法進行檢查
hotEmitter.on("webpackHotUpdate", (hash) => {
    hotCheck();
})

//【5】調用hotCheck拉取兩個補丁文件
let hotCheck = () => {
    hotDownloadManifest().then(hotUpdate => {
        hotDownloadUpdateChunk(chunkID);
    })
}

// 【6】拉取lashhash.hot-update.json,向 server 端發送 Ajax 請求,服務端返回一個 Manifest文件(lasthash.hot-update.json),該 Manifest 包含了本次編譯hash值 和 更新模塊的chunk名
let hotDownloadManifest = () => {}
// 【7】拉取更新的模塊chunkName.lashhash.hot-update.json,經過JSONP請求獲取到更新的模塊代碼
let hotDownloadUpdateChunk = (chunkID) => {}

// 【8.0】這個hotCreateModule很重要,module.hot的值 就是這個函數執行的結果
let hotCreateModule = (moduleID) => {
    let hot = {
        accept() {},
        check: hotCheck
    }
    return hot;
}

//【8】補丁JS取回來後會調用webpackHotUpdate方法(請看update chunk的格式),裏面會實現模塊的熱更新
window.webpackHotUpdate = (chunkID, moreModules) => {
    //【9】熱更新的重點代碼實現
}
複製代碼

4. 監聽webpackHotUpdate事件

和源碼的出入:源碼中調用的是check方法,在check方法裏調用module.hot.check方法——也就是hotCheck方法,check裏面還會進行一些日誌輸出。這裏直接寫check裏面的核心hotCheck方法

hotEmitter.on("webpackHotUpdate", (hash) => {
    currentHash = hash;
    if (!lastHash) {// 說明是第一次請求
        return lastHash = currentHash
    }
    hotCheck();
})
複製代碼

5. hotCheck

let hotCheck = () => {
    //【6】hotDownloadManifest用來拉取lasthash.hot-update.json
    hotDownloadManifest().then(hotUpdate => {// {"h":"58ddd9a7794ab6f4e750","c":{"main":true}}
        let chunkIdList = Object.keys(hotUpdate.c);
        //【7】調用hotDownloadUpdateChunk方法經過JSONP請求獲取到最新的模塊代碼
        chunkIdList.forEach(chunkID => {
            hotDownloadUpdateChunk(chunkID);
        });
        lastHash = currentHash;
    }).catch(err => {
        window.location.reload();
    });
}
複製代碼

6. 拉補丁代碼——lasthash.hot-update.json

// 六、向 server 端發送 Ajax 請求,服務端返回一個 Manifest文件(xxxlasthash.hot-update.json),該 Manifest 包含了全部要更新的模塊的 hash 值和chunk名
let hotDownloadManifest = () => {
    return new Promise((resolve, reject) => {
        let xhr = new XMLHttpRequest();
        let hotUpdatePath = `${lastHash}.hot-update.json`
        xhr.open("get", hotUpdatePath);
        xhr.onload = () => {
            let hotUpdate = JSON.parse(xhr.responseText);
            resolve(hotUpdate);// {"h":"58ddd9a7794ab6f4e750","c":{"main":true}}
        };
        xhr.onerror = (error) => {
            reject(error);
        }
        xhr.send();
    })
}
複製代碼

7. 拉補丁代碼——更新的模塊代碼lasthash.hot-update.json

hotDownloadUpdateChunk方法經過JSONP請求獲取到最新的模塊代碼

爲何是JSONP?由於chunkName.lasthash.hot-update.js是一個js文件,咱們爲了讓他從服務端獲取後能夠立馬執行js腳本

let hotDownloadUpdateChunk = (chunkID) => {
    let script = document.createElement("script")
    script.charset = "utf-8";
    script.src = `${chunkID}.${lastHash}.hot-update.js`//chunkID.xxxlasthash.hot-update.js
    document.head.appendChild(script);
}
複製代碼

8.0 hotCreateModule

module.hot module.hot.accept module.hot.check

let hotCreateModule = (moduleID) => {
    let hot = {// module.hot屬性值
        accept(deps = [], callback) {
            deps.forEach(dep => {
                // 調用accept將回調函數 保存在module.hot._acceptedDependencies中
                hot._acceptedDependencies[dep] = callback || function () { };
            })
        },
        check: hotCheck// module.hot.check === hotCheck
    }
    return hot;
}
複製代碼

8. webpackHotUpdate實現熱更新

回顧下hotDownloadUpdateChunk來取的代碼長什麼樣

webpackHotUpdate("index", {
  "./src/lib/content.js":
    (function (module, __webpack_exports__, __webpack_require__) {
      eval("");
    })
})
複製代碼

調用了一個webpackHotUpdate方法,說明咱們得在全局上有一個webpackHotUpdate方法

和源碼的出入:源碼webpackHotUpdate裏面會調用hotAddUpdateChunk方法動態更新模塊代碼(用新的模塊替換掉舊的模塊),而後調用hotApply方法進行熱更新,這裏將這幾個方法核心直接寫在webpackHotUpdate中

window.webpackHotUpdate = (chunkID, moreModules) => {
    // 【9】熱更新
    // 循環新拉來的模塊
    Object.keys(moreModules).forEach(moduleID => {
        // 一、經過__webpack_require__.c 模塊緩存能夠找到舊模塊
        let oldModule = __webpack_require__.c[moduleID];

        // 二、更新__webpack_require__.c,利用moduleID將新的拉來的模塊覆蓋原來的模塊
        let newModule = __webpack_require__.c[moduleID] = {
            i: moduleID,
            l: false,
            exports: {},
            hot: hotCreateModule(moduleID),
            parents: oldModule.parents,
            children: oldModule.children
        };

        // 三、執行最新編譯生成的模塊代碼
        moreModules[moduleID].call(newModule.exports, newModule, newModule.exports, __webpack_require__);
        newModule.l = true;

        // 這塊請回顧下accept的原理
        // 四、讓父模塊中存儲的_acceptedDependencies執行
        newModule.parents && newModule.parents.forEach(parentID => {
            let parentModule = __webpack_require__.c[parentID];
            parentModule.hot._acceptedDependencies[moduleID] && parentModule.hot._acceptedDependencies[moduleID]()
        });
    })
}
複製代碼

6、webpack-dev-server,webpack-hot-middleware,webpack-dev-middleware

利用webpack-dev-middleware、webpack-hot-middleware、express實現HMR Demo

1.Webpack-dev-middleware

  • 讓webpack以watch模式編譯;

  • 並將文件系統改成內存文件系統,不會把打包後的資源寫入磁盤而是在內存中處理;

  • 中間件負責將編譯的文件返回;

2. Webpack-hot-middleware:

提供瀏覽器和 Webpack 服務器之間的通訊機制、且在瀏覽器端訂閱並接收 Webpack 服務器端的更新變化,而後使用webpack的HMR API執行這些更改

1. 服務端

  • 服務端監聽compiler.hooks.done事件;

  • 經過SSE,服務端編譯完成向客戶端發送building、built、sync事件;

    webpack-dev-middleware是經過EventSource也叫做server-sent-event(SSE)來實現服務器發客戶端單向推送消息。經過心跳檢測,來檢測客戶端是否還活着,這個💓就是SSE心跳檢測,在服務端設置了一個 setInterval 每一個10s向客戶端發送一次

2. 客戶端

  • 一樣客戶端代碼須要添加到config的entry屬性中,

    // /dev-hot-middleware demo/webpack.config.js
    entry: {
      	index: [
        		// 主動引入client.js
        		"./node_modules/webpack-hot-middleware/client.js",
        		// 無需引入webpack/hot/dev-server,webpack/hot/dev-server 經過 require('./process-update') 已經集成到 client.js模塊
        		"./src/index.js",
      	]
    },
    複製代碼
  • 客戶端 建立EventSource 實例 請求 /__webpack_hmr,監聽building、built、sync事件,回調函數會利用HotModuleReplacementPlugin運行時代碼進行更新;

3. 總結

  • 其實咱們在實現webpack-dev-server熱更新的時候,已經把webpack-hot-middleware的功能都實現了。
  • 他們的最大區別就是瀏覽器和服務器之間的通訊方式,webpack-dev-server使用的是websocketwebpack-hot-middleware使用的是eventSource;以及通訊過程的事件名不同了,webpack-dev-server是利用hash和ok,webpack-dev-middleware是build(構建中,不會觸發熱更新)和sync(判斷是否開始熱更新流程)

3. webpack-dev-server

Webpack-Dev-Server 就是內置了 Webpack-dev-middleware 和 Express 服務器,以及利用websocket替代eventSource實現webpack-hot-middleware的邏輯

4. 區別

Q: 爲何有了webpack-dev-server,還有有webpack-dev-middleware搭配webpack-hot-middleware的方式呢?

A: webpack-dev-server是封裝好的,除了webpack.config和命令行參數以外,很難定製型開發。在搭建腳手架時,利用 webpack-dev-middlewarewebpack-hot-middleware,以及後端服務,讓開發更靈活。

7、源碼位置

1. 服務端

步驟 功能 源碼連接
1 建立webpack實例 webpack-dev-server
2 建立Server實例 webpack-dev-server
3 更改config的entry屬性 Server
updateCompiler
entry添加dev-server/client/index.js addEntries
entry添加webpack/hot/dev-server.js addEntries
4 監聽webpack的done事件 Server
編譯完成向websocket客戶端推送消息,最主要信息仍是新模塊的hash值,後面的步驟根據這一hash值來進行模塊熱替換 Server
5 建立express實例app Server
6 使用webpack-dev-middlerware Server
以watch模式啓動webpack編譯,文件系統中某一個文件發生修改,webpack 監聽到文件變化,根據配置文件對模塊從新編譯打包 webpack-dev-middleware
設置文件系統爲內存文件系統 webpack-dev-middleware
返回一箇中間件,負責返回生成的文件 webpack-dev-middleware
7 app中使用webpack-dev-middlerware返回的中間件 Server
8 建立webserver服務器並啓動服務 Server
9 使用sockjs在瀏覽器端和服務端之間創建一個 websocket 長鏈接 Server
建立socket服務器並監聽connection事件 SockJSServer

2. 客戶端

步驟 功能 源碼連接
1 鏈接websocket服務器 client/index.js
2 websocket客戶端監聽事件 client/index.js
監聽hash事件,保存此hash值 client/index.js
監聽ok事件,執行reloadApp方法進行更新 client/index.js
3 調用reloadApp,在reloadApp中會進行判斷,是否支持熱更新,若是支持的話發射webpackHotUpdate事件,若是不支持則直接刷新瀏覽器 client/index.js
reloadApp中發射webpackHotUpdate事件 reloadApp
4 webpack/hot/dev-server.js會監聽webpackHotUpdate事件, webpack/hot/dev-server.js
而後執行check()方法進行檢查 webpack/hot/dev-server.js
在check方法裏會調用module.hot.check方法 webpack/hot/dev-server.js
5 module.hot.check也就是hotCheck HotModuleReplacement.runtime
6 調用hotDownloadManifest`,向 server 端發送 Ajax 請求,服務端返回一個 Manifest文件(lasthash.hot-update.json),該 Manifest 包含了本次編譯hash值 和 更新模塊的chunk名 HotModuleReplacement.runtime
JsonpMainTemplate.runtime
7 調用hotDownloadUpdateChunk``方法經過JSONP請求獲取到最新的模塊代碼 HotModuleReplacement.runtime
HotModuleReplacement.runtime
JsonpMainTemplate.runtime
8 補丁JS取回來後會調用的webpackHotUpdate方法,裏面會調用hotAddUpdateChunk方法,用新的模塊替換掉舊的模塊 JsonpMainTemplate.runtime
9 調用hotAddUpdateChunk方法動態更新模塊代碼 JsonpMainTemplate.runtime
JsonpMainTemplate.runtime
10 調用hotApply方法進行熱更新 HotModuleReplacement.runtime
HotModuleReplacement.runtime
從緩存中刪除舊模塊 HotModuleReplacement.runtime
執行accept的回調 HotModuleReplacement.runtime
執行新模塊 HotModuleReplacement.runtime

8、流程圖

這是剖析webpack-dev-server源碼的流程圖

寫到最後

終於講完啦,堅持到這,你是最棒的,爲你點贊👍(可能的多啃幾下哦~)

不知道是否全面,若有不足,歡迎指正。

第一篇文章,若是對你有幫助和啓發,還望給個小小的贊喲❤️~給我充充電🔋

相關文章
相關標籤/搜索