webpack-dev-server 以及热更新原理

1,495 阅读12分钟

webpack 将我们的项目源代码进行编译打包成可分发上线的静态资源,在开发阶段我们想要预览页面效果的话就需要启动一个服务器伺服 webpack 编译出来的静态资源。webpack-dev-server 就是用来启动 webpack 编译、伺服这些静态资源。

除此之外,它还默认提供了liveReload的功能,就是在一次 webpack 编译完成后浏览器端就能自动刷新页面读取最新的编译后资源。为了提升开发体验和效率,它还提供了 hot 选项开启 hotReload,相对于 liveReload, hotReload 不刷新整个页面,只更新被更改过的模块。


服务端

webpack-dev-server服务实际上开启了一个express的server服务,在这个server服务中传入了webpack的compile对象。

入口

webpack-dev-server/index.js:

const webapck = require('webpack');
// 配置对象
const config = require('../webpack.config.js');
const Server = require('./lib/server/Server.js');
// 编译器对象
const compiler = webapck(config);
// 创建Server服务器
const server = new Server(compiler);

server.listen(9099, 'localhost', () => {
  console.log('服务已经在9099端口启动 http://localhost:9099');
});

核心代码

webpack-dev-server/lib/server/Server.js

const express = require('express');
const http = require('http');
const path = require('path');
// const MemoryFS = require('memory-fs');
const fs = require('fs-extra');
fs.join = path.join;
const mime = require('mime');
const socketIO = require('socket.io');
const updateCompilrer = require('../utils/updateCompilrer.js');

class Server {
  constructor (compiler) {
    this.compiler = compiler; // 保存编译器对象
    updateCompilrer(compiler); // 注入client和server通信的文件
    this.currentHash; // 当前的hash值,每次编译都会产生一个新的hash值
    this.clientSocketList = []; // 存放所有的通过websocket连接到服务器的客户端
    this.setupHooks(); // webpack生命周期监听钩子 done事件监听
    this.setupApp(); // 创建App
    this.setupDevMiddleware();
    this.routes(); // 配置路由
    this.createServer(); // 创建HTTP服务器,以app作为路由
    this.createSocketServer(); // 创建socket服务器
  }
  createSocketServer () {
    // websocket协议握手是需要依赖http服务器的
    const io = socketIO(this.server);
    // 服务器要监听客户端的连接,当客户端连接上后,socket:代表和这个客户端连接的对象
    io.on('connection', (socket) => {
      console.log('[新的客户端连接完成]');
      this.clientSocketList.push(socket); // 把新的socket放入数组中
      socket.emit('hash', this.currentHash); // 新的hash发送给客户端
      socket.emit('ok'); // 给客户端发送一个确认
      socket.on('disconnect', () => {
        let index = this.clientSocketList.indexOf(socket);
        this.clientSocketList.splice(index, 1);
      })
    })
  }
  routes () {
    let { compiler } = this;
    let config = compiler.options;
    this.app.use(this.middleware(config.output.path));
  }
  createServer () {
    this.server = http.createServer(this.app);
  }
  listen (port, host, callback) {
    this.server.listen(port, host, callback);
  }
  setupDevMiddleware () {
    this.middleware = this.webapckDevMiddleware(); // 返回一个express中间件
  }
  webapckDevMiddleware () {
    let { compiler } = this;
    // 以监听模式启动编译,如果以后文件发生变更,会重新编译
    compiler.watch({}, () => {
      console.log('监听模式编译成功');
    })
    // let fs = new MemoryFS(); // 内存文件系统实例
    // 打包后的文件写入内存文件系统,读取的时候从内存文件系统读
    this.fs = compiler.outputFileSystem = fs;
    // 返回一个中间件,相应客户端对于产出文件的请求
    return (staticDir) => {
      return (req, res, next) => {
        let { url } = req;
        if (url === '/favicon.ico') return res.sendStatus(404);
        url === '/' ? url = '/index.html' : null;
        let filePath = path.join(staticDir, url);
        try {
          // 返回此路径上的文件的描述对象,如果不存在,抛出异常
          let statObj = this.fs.statSync(filePath);
          // console.log('[statObj]', statObj);
          if (statObj.isFile()){
            let content = this.fs.readFileSync(filePath); // 读取文件内容
            res.setHeader('Content-Type', mime.getType(filePath)); // 设置相应头,告诉浏览器此文件的内容
            res.send(content); // 内容发送给浏览器
          }
        }catch(error){
          return res.sendStatus(404);
        } 
      }
    }
  }
  setupHooks () {
    let { compiler } = this;
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      // stats是一个描述对象,包含打包后的hash、chunkHash、contentHash 代码块 模块等
      console.log('[hash]', stats.hash);
      this.currentHash = stats.hash;
      // 编译成功,向所有的客户端广播
      this.clientSocketList.forEach(socket => {
        socket.emit('hash', this.currentHash); // 新的hash发送给客户端
        socket.emit('ok'); // 给客户端发送一个确认
      })
    })
  }
  setupApp () {
    // 执行express函数得到this.app  http应用对象
    this.app = express();
  }
}

module.exports = Server;

webpack-dev-server/utils/updateCompilrer.js

/**
 * 实现客户端Client和服务器Server之间通信,需要在入口注入两个文件
 * (webpack)-dev-server/client/index.js
 * (webapck)/hot/dev-server.js
 * ./src/index.js
*/
const path = require('path');
function updateCompilrer (compiler) {
  const config = compiler.options;
  config.entry = {
    main: [
      path.resolve(__dirname, '../../client/index.js'),
      path.resolve(__dirname, '../../../webpack/hot/dev-server.js'),
      config.entry, // ./src/index.js
    ]
  }
}

module.exports = updateCompilrer;


结合起来看,server服务主要做了一下几件事

  1. 绑定 webpack compiler 钩子,这里主要关注是 done 钩子,在 webpack compiler 实例每次触发编译完成后就会进行 webscoket 广播 webpack 的编译信息,这里可具体看setupHooks函数
  2. 实例化 express 服务器,setupApp,这个函数其实是讲express赋值给了this.app,真正创建服务是createServer函数
  createServer () {
    this.server = http.createServer(this.app);
  }
  1. 构造 webpack-dev-middleware 中间件用于处理静态资源的请求,主要看webapckDevMiddleware函数,这个函数是创建了一个闭包函数,return了一个express中间件,重点看一下它的实现, staticDir 即传入的output路径,中间件处理了请求信息,url里拿到请求的路径,根据请求路径拿到请求的文件,响应里把文件内容返回。

webpackMiddleware 函数的返回结果是一个 Expressjs 的中间件,该中间件有以下功能:

  • 接收来自 Webpack Compiler 实例输出的文件,但不会把文件输出到硬盘,而是保存在内存中;
  • 往 Expressjs app 上注册路由,拦截 HTTP 收到的请求,根据请求路径响应对应的文件内容;​
	return (staticDir) => {
      return (req, res, next) => {
        let { url } = req;
        if (url === '/favicon.ico') return res.sendStatus(404);
        url === '/' ? url = '/index.html' : null;
        let filePath = path.join(staticDir, url);
        try {
          // 返回此路径上的文件的描述对象,如果不存在,抛出异常
          let statObj = this.fs.statSync(filePath);
          // console.log('[statObj]', statObj);
          if (statObj.isFile()){
            let content = this.fs.readFileSync(filePath); // 读取文件内容
            res.setHeader('Content-Type', mime.getType(filePath)); // 设置相应头,告诉浏览器此文件的内容
            res.send(content); // 内容发送给浏览器
          }
        }catch(error){
          return res.sendStatus(404);
        } 
      }
    }
  1. 创建socket通信,重点看createSocketServer函数,依赖刚刚创建的http服务器创建websocket通信。
createSocketServer () {
    // websocket协议握手是需要依赖http服务器的
    const io = socketIO(this.server);
    // 服务器要监听客户端的连接,当客户端连接上后,socket:代表和这个客户端连接的对象
    io.on('connection', (socket) => {
      console.log('[新的客户端连接完成]');
      this.clientSocketList.push(socket); // 把新的socket放入数组中
      socket.emit('hash', this.currentHash); // 新的hash发送给客户端
      socket.emit('ok'); // 给客户端发送一个确认
      socket.on('disconnect', () => {
        let index = this.clientSocketList.indexOf(socket);
        this.clientSocketList.splice(index, 1);
      })
    })
  }

至此,服务端的逻辑创建完成,在入口文件下通过运行index.js启动服务。


客户端


当我们编辑了源代码,触发 webpack 重新编译,编译完成后执行 done 钩子上的回调。具体可参考上面 Server.js 中 setupHooks 方法。stats是一个描述对象,包含打包后的hash、chunkHash、contentHash 代码块 模块等,先把hash保存下来。 向所有链接到的客户端广播一个类型为 hash 的消息,然后再根据编译信息广播 warnings/errors/ok 消息。这里我们只关注正常流程 ok 消息。

webpack-dev-server/client/index.js

let io = require('socket.io');
// 客户端记录当前的hash值
let currentHash;

class EventEmitter {
  constructor(){
    this.events = {};
  }
  on(eventName, fn){
    this.events[eventName] = fn;
  }
  emit(eventName, ...args) {
    this.events[eventName](...args);
  }
}
let hotEmitter = new EventEmitter()

// 1、连接websocket服务器
const socket = io('/');
socket.on('hash', (hash) => {
  currentHash = hash;
})

socket.on('ok', () => {
  console.log('[ok]');
  reloadApp();
})

function reloadApp () {
  hotEmitter.emit('webpackHotUpdate');
}

client/index.js 主要就是初始化了 webscoket 客户端,然后为不同的消息类型设置了相应的回调函数。

服务端在每次编译后都会广播 hash 消息,客户端接收到后就会将这个webpack 编译产生的 hash 值暂存起来。编译成功如果没有 warning 也没有 error 就会广播 ok 消息,客户端接收到 ok 消息就会执行 ok 回调函数中的 reloadApp 刷新应用。刷新应用是通过发布订阅模式发射了一个webpackHotUpdate事件。

webpack/hot/dev-server.js

let lastHash;

hotEmitter.on('webpackHotUpdate', () => {
  if (!lastHash || lastHash == currentHash) {
    return lastHash = currentHash
  }
  hotCheck()
});

从这里我们看到dev-server.js主要做的就是订阅webpackHotUpdate这个时间,并且设置hotCheck回调,整体理解起来就是,浏览器客户端访问路由的时候链接sockets客户端,webpack在编译完成时会向socktes广播hash事件和编译完成的ok事件,客户端接收到ok事件的时候,又通过发布订阅模式发射了webpackHotUpdate,这个函数触发了hotCheck()函数

以上代码就是我们在上面就讲到的在 webpack 编译的时候注入到 bundle.js 进去的。

最终打包后的代码应该如下:


// 客户端记录当前的hash值
let currentHash;
let lastHash; // 上一次的hashclass EventEmitter {
  constructor(){
    this.events = {};
  }
  on(eventName, fn){
    this.events[eventName] = fn;
  }
  emit(eventName, ...args) {
    this.events[eventName](...args);
  }
}
let hotEmitter = new EventEmitter();

(function(modules){
  // 模块缓存
  var installedModules = {};
  function hotCheck () {
    // {"h":"e4113d05239b9e227b26","c":{"main":true}}
    hotDownloadManifest().then(update => {
      let chunkIds = Object.keys(update.c);
      chunkIds.forEach(chunkId => {
        hotDownloadUpdateChunk(chunkId);
      })
      lastHash = currentHash;
    }).catch(() => {
      window.location.reload();
    })
  }
  function hotDownloadUpdateChunk (chunkId) {
    let script = document.createElement('script');
    script.src = `${chunkId}.${lastHash}.hot-update.js`;
    document.head.appendChild(script);
  }
  window.webpackHotUpdate = (chunkId, moreModules) => {
    hotAddUpdateChunk(chunkId, moreModules);
  }
  let hotUpdate = {};
  function hotAddUpdateChunk (chunkId, moreModules) {
    for(let moduleId in moreModules){
      modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];
    }
    hotApply();
  }
  function hotApply () {
    for(let moduleId in hotUpdate){
      let oldModule = installedModules[moduleId]; // 旧的模块
      delete installedModules[moduleId]; // 删掉缓存中的旧的模块
      // 循环所有的父模块,取出父模块的回调callback,有则执行
      oldModule.parents.forEach(parentModule => {
        let cb = parentModule.hot._acceptDependencies[moduleId];
        cb&&cb();
      })
    }
  }
  function hotDownloadManifest () {
    return new Promise((resolve, reject) => {
      let xhr = new XMLHttpRequest();
      let url = `${lastHash}.hot-update.json`;
      xhr.open('get', url);
      xhr.responseType = 'json';
      xhr.onload = function () {
        resolve(xhr.response)
      }
      xhr.send();
    })
  }
  function hotCreateModule () {
    let hot = {
      _acceptDependencies: {},
      accept (deps, callback) {
        deps.forEach((dep) => {
          hot._acceptDependencies[dep] = callback;
        })
      },
      check: hotCheck
    }
    return hot;
  }
  // 维护父子关系
  function hotCreateRequire (parentModuleId) {
    // 加载子模块的时候,父模块肯定以及加载过,可以从缓存中加载父模块
    let parentModule = installedModules[parentModuleId];
    // 如果缓存中没有parentModule,说明子模块是顶级模块
    if (!parentModule) return __webpack_require__;
    let hotRequire = function (childModuleId) {
      __webpack_require__(childModuleId); // require过的模块,会放在缓存中
      let childModule = installedModules[childModuleId]; // 取出缓存中的子模块
      childModule.parents.push(parentModule);
      // console.log('[childModule]', childModule);
      parentModule.children.push(childModule);
      return childModule.exports;
    }
    return hotRequire;
  }
  function __webpack_require__(moduleId){
    // 缓存命中,直接返回
    if(installedModules[moduleId]){
      return installedModules[moduleId]
    }
    // 创建一个新的模块对象并缓存入缓存区
    let module = installedModules[moduleId] = {
      i: moduleId, // 模块ID
      l: false, // 是否已经加载
      exports: {}, // 导出对象
      parents: [], // 当前模块的父模块
      children: [], // 当前模块的子模块
      hot: hotCreateModule(),
    }
    modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
    module.l = true; // 模块已完成加载
    return module.exports;
  }
  return hotCreateRequire("./src/index.js")("./src/index.js")
  // return __webpack_require__("./src/index.js")
})({
  "./src/index.js": function (module, exports, __webpack_require__) {
    // 监听webpackHotUpdate消息
    __webpack_require__("webpack/hot/dev-server.js");
    // 连接websocket服务器,服务器发送给hash,就保存到currentHash,如果服务器发送ok,就发送webpackHotUpdate事件
    __webpack_require__("webpack-dev-server/client/index.js");
    let input = document.createElement('input');
    document.body.appendChild(input);

    let div = document.createElement('div');
    document.body.appendChild(div);

    let render = () => {
      let title = __webpack_require__('./src/title.js');
      div.innerHTML = title;
    }
    render();
    if(module.hot){
      module.hot.accept(['./src/title.js'],render);
    }
  },
  "./src/title.js": function(module, exports){
    module.exports = "title"
  },
  "webpack-dev-server/client/index.js": function(module, exports){
    // 1、连接websocket服务器
    const socket = window.io('/');
    socket.on('hash', (hash) => {
      currentHash = hash;
    })

    socket.on('ok', () => {
      console.log('[ok]');
      reloadApp();
    })

    function reloadApp () {
      hotEmitter.emit('webpackHotUpdate');
    }
  },
  "webpack/hot/dev-server.js": function(module, exports){
    hotEmitter.on('webpackHotUpdate', () => {
      // 第一次渲染
      if (!lastHash) {
        lastHash = currentHash
        return;
      }
      // 调用hot.check方法向服务器检查更新,并拉取最新的代码
      module.hot.check();
    });
  }
})

所以这个hotCheck 方法主要做了什么呢,这里提前总结一下。在 webpack 使用HotModuleReplacementPlugin 编译时,每次增量编译就会多产出两个文件,形如c390bbe0037a0dd079a6.hot-update.json,main.c390bbe0037a0dd079a6.hot-update.js,分别是描述 chunk 更新的 manifest文件和更新过后的 chunk 文件。那么浏览器端调用 hotDownloadManifest 方法去下载模块更新的 manifest.json 文件,然后调用 hotDownloadUpdateChunk 方法使用 jsonp 的方式下载需要更新的 chunk。

hotCheck 方法就是和服务器进行通信拿到更新过后的 chunk,下载好 chunk 后就开始执行 HMR runtime 里的 webpackHotUpdate 回调。

  window.webpackHotUpdate = (chunkId, moreModules) => {
    hotAddUpdateChunk(chunkId, moreModules);
  }
  let hotUpdate = {};
  function hotAddUpdateChunk (chunkId, moreModules) {
    for(let moduleId in moreModules){
      modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];
    }
    hotApply();
  }
  function hotApply () {
    for(let moduleId in hotUpdate){
      let oldModule = installedModules[moduleId]; // 旧的模块
      delete installedModules[moduleId]; // 删掉缓存中的旧的模块
      // 循环所有的父模块,取出父模块的回调callback,有则执行
      oldModule.parents.forEach(parentModule => {
        let cb = parentModule.hot._acceptDependencies[moduleId];
        cb&&cb();
      })
    }
  }

最终来到hotApply方法,这个方法把更新过后的模块 apply 到业务中, 看过 webpack runtime 代码之后我们知道 runtime 里声明了 installedModules 这个变量,里面缓存了所有被 webpack_require 调用后加载过的模块,还有 modules 这个变量存储了所有模块。(如果不了解 webpack runtime 可以先了解 webpack runtime 的执行机制)。如果模块有被 accept 的话,那么就会从 installedModules 里删掉旧的模块,把模块从父子依赖中删除,然后把 modules 里面的模块替换成新的模块。

项目源码

地址:webpack-hotReload

如何启动

  1. 安装依赖
    npm i

  2. 启动服务
    npm run dev 开启一个webpack-dev-server的服务,此服务会打包出dist文件夹,以dist为路径资源文件夹启动一个http服务

  3. 将dist1包中的hmr.html以及hrm.js移动到dist
    mv dist1/* dist/ 这一步是因为webpack代码注入客户端的热更新十分复杂,因此将主要流程事件写入到hrm.js,到时候直接访问访问这个文件,即可实现热更新

  4. 打开浏览器,访问localhost9099/hmr.html

  5. 改变src/title.js内容的title,观察到浏览器的输入内容自动发生了改变

此时服务端和客户端发生了如下的通信过程:​


总结

最后总结一下,webpack-dev-server 可以作为命令行工具使用,核心模块依赖是 webpack 和 webpack-dev-middleware。webapck-dev-server 负责启动一个 express 服务器监听客户端请求;实例化 webpack compiler;启动负责推送 webpack 编译信息的 webscoket 服务器;负责向 bundle.js 注入和服务端通信用的 webscoket 客户端代码和处理逻辑。webapck-dev-middleware 把 webpack compiler 的 outputFileSystem 改为 in-memory fileSystem;启动 webpack watch 编译;处理浏览器发出的静态资源的请求,把 webpack 输出到内存的文件响应给浏览器。

每次 webpack 编译完成后向客户端广播 ok 消息,客户端收到信息后根据是否开启 hot 模式使用 liveReload 页面级刷新模式或者 hotReload 模块热替换。hotReload 存在失败的情况,失败的情况下会降级使用页面级刷新。
开启 hot 模式,即启用 HMR 插件。hot 模式会向服务器请求更新过后的模块,然后对模块的父模块进行回溯,对依赖路径进行判断,如果每条依赖路径都配置了模块更新后所需的业务处理回调函数则是 accepted 状态,否则就降级刷新页面。判断 accepted 状态后对旧的缓存模块和父子依赖模块进行替换和删除,然后执行 accept 方法的回调函数,执行新模块代码,引入新模块,执行业务处理代码。

为了更加熟悉完整的编译流程可以初始化一个 webpack-dev-server 项目,使用 vscode 的 debug 功能进行断点调试的方式去阅读源码。

参考资料: