一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时

一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第1张图片

作者:悟空来也

来源:https://juejin.im/post/5df36ffd518825124d6c1765

前言

本文以剖析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的意义,页面刷新期间保留状态

一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第2张图片 一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第3张图片

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)

    一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第4张图片
  • updated chunk (JavaScript) chunk名字.上一次编译生成的hash.hot-update.js(如main.b1f49e2fc76aae861d9f.hot-update.js)

    一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第5张图片

    这里调用了一个全局的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 建立起 浏览器端 和 服务器端 之间的通信

一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第6张图片

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向客户端发送一次

    一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第7张图片

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源码的流程图

一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第8张图片

写到最后

一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时_第9张图片

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

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

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

最后

欢迎关注「前端瓶子君」,回复「交流」加入前端交流群!

欢迎关注「前端瓶子君」,回复「算法」自动加入,从0到1构建完整的数据结构与算法体系!

在这里,瓶子君不仅介绍算法,还将算法与前端各个领域进行结合,包括浏览器、HTTP、V8、React、Vue源码等。

在这里(算法群),你可以每天学习一道大厂算法编程题(阿里、腾讯、百度、字节等等)或 leetcode,瓶子君都会在第二天解答哟!

另外,每周还有手写源码题,瓶子君也会解答哟!

》》面试官也在看的算法资料《《

“在看和转发”就是最大的支持

你可能感兴趣的:(一年前,我去面试,小姐姐问我webpack热更新原理,我跟她说了一小时)