从零实现webpack热更新HMR

17,690 阅读23分钟

前言

本文以剖析webpack-dev-server源码,从零开始实现一个webpack热更新HMR,深入了解webpack-dev-server、webpack-dev-middleware、webpack-hot-middleware的实现机制,彻底搞懂他们的原理,在面试过程中这个知识点能答的非常出彩,在搭建脚手架过程中这块能得心应手。知其然并知其所以然,更上一层楼。

温馨提示❤️~篇幅较长,建议收藏到电脑端食用更佳。

源码链接
原理图链接

零、什么是HMR

1. 概念

Hot Module Replacement是指当我们对代码修改并保存后,webpack将会对代码进行得新打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,以实现在不刷新浏览器的前提下更新页面。

2. 优点

相对于live reload刷新页面的方案,HMR的优点在于可以保存应用的状态,提高了开发效率

3. 那就来用用吧

./src/index.js

// 创建一个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.js

let content = "hello world"
console.log("welcome");
export default content;

cd 项目根目录

npm run dev

4. 效果看图

当我们在输入框中输入了123,这个时候更新content.js中的代码,会发现hello world!!!!变成了hello world,但是 输入框的值 还保留着,这正是HMR的意义,页面刷新期间保留状态

5. 理解chunk和module的概念

chunk 就是若干 module 打成的包,一个 chunk 应该包括多个 module,一般来说最终会形成一个 file。而 js 以外的资源,webpack 会通过各种 loader 转化成一个 module,这个模块会被打包到某个 chunk 中,并不会形成一个单独的 chunk。

一、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知道要干什么
}

二、总体流程

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. 原理图

三、源码实现

一、结构

.
├── 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配置文件

二、看看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代码
    ]
}

三、依赖的模块

"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",
}

四、服务端实现

  • /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);
}

五、客户端实现

  • /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

// 6、向 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 => {
        // 1、通过__webpack_require__.c 模块缓存可以找到旧模块
        let oldModule = __webpack_require__.c[moduleID];

        // 2、更新__webpack_require__.c,利用moduleID将新的拉来的模块覆盖原来的模块
        let newModule = __webpack_require__.c[moduleID] = {
            i: moduleID,
            l: false,
            exports: {},
            hot: hotCreateModule(moduleID),
            parents: oldModule.parents,
            children: oldModule.children
        };

        // 3、执行最新编译生成的模块代码
        moreModules[moduleID].call(newModule.exports, newModule, newModule.exports, __webpack_require__);
        newModule.l = true;

        // 这块请回顾下accept的原理
        // 4、让父模块中存储的_acceptedDependencies执行
        newModule.parents && newModule.parents.forEach(parentID => {
            let parentModule = __webpack_require__.c[parentID];
            parentModule.hot._acceptedDependencies[moduleID] && parentModule.hot._acceptedDependencies[moduleID]()
        });
    })
}

六、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,以及后端服务,让开发更灵活。

七、源码位置

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

八、流程图

这是剖析webpack-dev-server源码的流程图

写到最后

终于讲完啦,坚持到这,你是最棒的,为你点赞👍(可能的多啃几下哦~)

不知道是否全面,如有不足,欢迎指正。

第一篇文章,如果对你有帮助和启发,还望给个小小的赞哟❤️~给我充充电🔋