从零实现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源码的流程图

写到最后

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

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

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

相关文章
相关标签/搜索