150行代码了解webpack中HMR的实现

1,360 阅读9分钟

HMR

前置知识

要想理解webpack中HMR的原理,你首先应该对以下知识点有一定的了解

  1. 了解webpack的基本知识以及webpack中require的实现 juejin.cn/post/690377…
  2. tapable(webpack的核心库)
  3. 有一定的node基础 (了解express 或者 koa)
  4. 了解websocket

刷新

  • 基于浏览器的刷新,不保留页面状态,就是简单粗暴,直接window.location.reload()
  • 另一种是基于WDS (Webpack-dev-server)的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。

HMR作为一个Webpack内置的功能,可以通过HotModuleReplacementPlugin--hot开启。那么,HMR到底是怎么实现热更新的呢?下面让我们来了解一下吧!

什么是HMR

Hot Module Replacement(以下简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。(注意不是浏览器刷新)。

webpack中使用HMR

只需要在webpack.config.js 加如下两句代码就可以实现热更新

  1. devServer: {hot:true}
  2. plugins:[new webpack.HotModuleReplacementPlugin()]
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  devtool: 'source-map',
  output: {
    filename: 'main.js',
    path: path.join(__dirname,'dist')
  },
  devServer: {
    hot:true
  },
  plugins:[
    new HtmlWebpackPlugin(),
    new webpack.HotModuleReplacementPlugin()
  ]
}

当发生热更新的时候我们在浏览器中看看发生了哪些变化 当第一次webpack打包完成之后,会生成一个hash发送到客户端。 当模块发生变化的时候。webpack回再次生成一个hash值。发送到客户端。客户端在发生热模块替换的时候,会拿到之前旧的hash值。去下载文件。从而实现热更新 企业微信截图_4454f1d0-9973-49eb-8adb-26f6bd6fe0ae.png 浏览器根据hash会拿到一个json文件。从而知道哪些模块发生了变化

企业微信截图_d2c03adb-cef3-4195-a0d8-1b3a9b28db91.png 根据hash去下载相应变化的模块。通知代码进行更新

webpack中HMR的实现

在webpack中,HRM的实现原理如下图,可能现在看觉得有些复杂,接下来我们会根据图中的步骤一步步的去实现 webpack-hrm (3).png

  1. 首先我们会创建一个webpack实例(得到compiler对象有一些列关于webpack的方法和属性)
  2. 创建一个server服务器。
  3. 服务器创建成功后,去修改config中entry的配置。动态为entry中增加两个文件(webpack-dev-server/client.index.js 和 webpack/hot/index.js. 这两个文件的作用后续会讲到)
  4. 监听webapck的打包完成事件。(当后续文件发生改变的时候。能去触发模块更新)
  5. 以watch的模块,去监听文件的变化,当文件发生变化的时候,会去触发webpack的打包。输出到文件系统。这个时候就会触发 第四步
  6. 通过静态文件服务器将文件托管起来,这样我们在浏览器中可以访问文件
  7. 创建一个websocket服务器。当第四步完成的时候,也就是webpack打包完成之后,会生成相应的hash。 websocket会将hash推送到客户端
  8. 客户端通过websocket接受到hash的时候。就会去检查文件是否更新
  9. 当文件更新的时候。就会去下载相应的json文件。json文件里面会记录哪些文件会去更新,以及最新的hash
  10. 下载发生变化的文件,然后浏览器删掉缓存的文件,将最新的文件内容替换进去。最后执行accept去调用文件。这样我们的热更新就完成了 接下来我们会根据以上步骤,一步步去实现HRM

创建webpack实例和创建server

在package.json文件中。添加一下命令

 "scripts": {
    "dev": "node webpack-dev-server"
  },

webpack-dev-server/index.js

const webpack = require('webpack')
const config = require('../webpack.config.js')
const complier = webpack(config)   // 创建webpack的实例
const Server = require('./lib/server/index')  
const server = new Server(complier)  // 创建server服务器
server.listen(9090,'localhost',()=>{
  console.log('服务已经启动')
})

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

const express = require('express')
const http = require('http')
const fs = require('fs-extra')
const path = require('path')
class Server {
  constructor(compiler) {
    this.compiler = compiler // 拿到webpack的实例
    this.setupApp()
    this.createServer() 
  }
  setupApp() {
    this.app =new express()   // 创建express服务(主要是利用express的中间件功能)
  }
  createServer() {
    this.server = http.createServer(this.app) // 创建http服务器。
  }

  listen(port,host,callback){
    this.server.listen(port,host,callback)
  }
}
module.exports = Server

这样我们就可以执行npm run dev ,访问9090端口,虽然我们现在什么文件也访问不了。

修改webpack入口文件的配置

在创建server的时候,去修改webpack的配置文件

class Server {
  constructor(compiler) {
    this.compiler = compiler // 拿到webpack的实例
     updateCompiler(compiler)
  }
 }

lib/utils/updateCompiler.js文件

const path = require('path')
function updateCompiler(compiler) {
  const config = compiler.options 
  config.entry = {
    main:[
      path.resolve(__dirname,'../../client/index.js'),
      path.resolve(__dirname,'../../../webpack/hot/devServer.js'),
      config.entry
    ]
  }
}
module.exports = updateCompiler

主要是为了往入口注入了两个文件。用于热更新。后续会详细讲解这两个文件

监听webpack打包完成

主要是为了监听webpack打包完成的事件。每次webpack打包完成之后,就会触发这个事件,从而拿到最新的hash值,发送到客户端

class Server {
  constructor(compiler) {
    this.compiler = compiler
    updateCompiler(compiler)
    this.setupHooks()
  }
  setupApp() {
    this.app =new express()
  }
  createServer() {
    this.server = http.createServer(this.app)
  }
  setupHooks() {
    let { compiler } = this
    // 用开监听webpack打包完成。拿到相应的hash。从而去向客户端emit对应的hash
    compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
      this.currentHash  = stats.hash
      this.clientSocketList.forEach(socket=>{
        socket.emit('hash',this.currentHash)
        socket.emit('ok')
      })
    })
  }

webpack-dev-middleWare

这部分的主要逻辑是以watch模式为了打包我们的文件。 输出到我们的内存文件系统。实现一个静态文件服务器,使我们通过浏览器能够访问打包后的文件。

class Server {
  constructor(compiler) {
    this.compiler = compiler
    updateCompiler(compiler)
    this.setupApp()
    this.currentHash;
    this.clientSocketList = []
    this.setupHooks()  
    this.setupDevMiddleWare()
    this.routes()
    this.createServer() 
  }
  setupApp() {
    this.app =new express()
  }
  createServer() {
    this.server = http.createServer(this.app)
  }
  setupHooks() {
    let { compiler } = this
    compiler.hooks.done.tap('webpack-dev-server',(stats)=>{
      this.currentHash  = stats.hash
      this.clientSocketList.forEach(socket=>{
        socket.emit('hash',this.currentHash)
        socket.emit('ok')
      })
    })
  }
  setupDevMiddleWare() {
    this.middleWare = this.webpackDevMiddleware()
  }
  routes() {
    let { compiler } = this
    let config = compiler.options 
    // express的中间件。
    this.app.use(this.middleWare(config.output.path))
  }
  webpackDevMiddleware() {
    let { compiler } = this
    // 当文件发生改变的时候,webpackjiu会重新编译
    compiler.watch({},()=>{
      console.log("监听模式")
    })
    
    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)
        console.log(filePath)
        try {
          let stateObj = this.fs.statSync(filePath)
          if(stateObj.isFile()) {
            // 读取文件
            let content = this.fs.readFileSync(filePath)
            // 拿到文件的后缀。从而返回相应的文件类型
            res.setHeader('Content-Type',mime.getType(filePath))
            // 将文件返回到客户端
            res.send(content)
          }
        } catch (error) {
          return res.sendStatus(404)
        }
      }
    }
  }
  listen(port,host,callback){
    this.server.listen(port,host,callback)
  }
}
module.exports = Server

创建websocket服务

这里创建websocket服务,是为了当webpack重新编译之后,将最新生成的hash值,发送到客户端。客户端通过hash去拉取相应的最新代码

class Server {
  constructor(compiler) {
    this.createServer() 
  }
    createSocketServer() {
        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',()=>{
           // 断开连接之后,就会删除相应的socket
            let index = this.clientSocketList.indexOf(socket)
            this.clientSocketList.splice(index,1)
          })
        })
      }
 }

走到这一步为们在服务端的代码就算基本完成。接下来我们去实现客户端的代码

客户端相关逻辑

在这里,我们首先创建两个文件 ./src/index.js

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

let render = () => {
  let title = require('./title.js')
  div.innerHTML = title
}
render()
if(module.hot){
  module.hot.accept(['./title.js'],render)
}

./src/title.js

module.exports = 'title'

在经过webpack打包之后,对webpack打包之后的代码进行相关简化。我们可以得到代码。 想知道详细怎么实现的。可以参考这篇文章juejin.cn/post/690377…

(function (modules) {
  let installedModules = {}
  function __webpack_require__(modulesId) {
    if (installedModules[modulesId]) {
      return installedModules[modulesId]
    }
    let module = installedModules[modulesId] = {
      i: modulesId,
      l: false,
      exports: {},
    }
   modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
   module.l = true
    return module.exports
  }
   return __webpack_require__('./src/index.js')
})(
  {
    "./src/index.js": function (module, exports, __webpack_require__) {
      __webpack_require__("webpack/hot/devServer.js")
      __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__(/*! ./title.js */ "./src/title.js")
        div.innerHTML = title
      }
      render()
      if (true) {
        module.hot.accept([/*! ./title.js */ "./src/title.js"], render)
      }
    },
    "./src/title.js": function (module, exports) {
      module.exports = 'title'
    },
    "webpack-dev-server/client/index.js": function (module, exports) {
    },
    "webpack/hot/devServer.js": function (module, exports) {

    }
  }
)

接下来我们实现webpack-dev-server/client/index.js

client/index.js

客户端主要是监听websocket事件,当有最新的hash生成的时候。触发相应的事件

let currentHash;
let lastHash;
class EventEmitter {
  constructor() {
    this.events = {}
  }
  on(eventName, fn) {
    this.events[eventName] = fn
  }
  emit(eventName, ...args) {
    this.events[eventName](...args)
  }
};
let hotEmitter = new EventEmitter();
// EventEmitter 是自己实现了一个发布订阅者模式,这里你可以用一些第三方库来代替

const socket = window.io('/')
  socket.on('hash', (hash) => {
    currentHash = hash
  })
  //监听ok事件
  socket.on('ok', () => {
    console.log('ok')
    reloadApp()
  })
  function reloadApp() {
  // 发送一个事件。告诉发生了热更新
    hotEmitter.emit('webpackHotUpdate')
  }

这样我们client/index.js的逻辑就完成了

webpack/hot/devServer.js

当hash发生了变化。我们就会去调用module.hot.check()功能

hotEmitter.on('webpackHotUpdate', () => {
    // console.log('check')
    // 判断hash是否发生了变化
    if(!lastHash) {
      lastHash = currentHash
      return
    }
    // hash发生了变化,就去调用热模块更新功能
    module.hot.check()
 })

check 和 accept

要想真正实现热模块更新,我们就必须维护模块之前的父子关系。当子模块发生变化的时候。我们就会通知父模块的accept方法,重新去加载子模块。所以接下来我们会对上面webpack打包的文件,进行相应的修改,让模块之间有相应的父子关系.并且实现模块的check和accept方法

function __webpack_require__(modulesId) {
    if (installedModules[modulesId]) {
      return installedModules[modulesId]
    }
    let module = installedModules[modulesId] = {
      i: modulesId,
      l: false,
      exports: {},
      hot:hotCreateModule(), // 实现模块的check方法
      parents: [], // 用户维护模块之前的父子关系
      children: []
    }
    modules[modulesId].call(module.exports, module, module.exports,  hotCreateRequire(modulesId))
    module.l = true
    return module.exports
  }
  __webpack_require__.c = installedModules
  return hotCreateRequire('./src/index.js')('./src/index.js')
  
  // 主要实现模块的父子关系
function hotCreateRequire(parentsModuleId) {
    let parentModule = installedModules[parentsModuleId]
    // 说明是顶级模块。没有服模块
    if (!parentModule) return __webpack_require__;
    // 有父模块
    let hotRequire = function (childModuleId) {
      __webpack_require__(childModuleId)
      let childModule = installedModules[childModuleId]
      childModule.parents.push(parentModule)
      parentModule.children.push(childModule)
      // console.log(childModule)
      return childModule.exports
    }
    return hotRequire
  }
  // 这里主要是为了实现模块的hot.accept方法以及check方法
   function hotCreateModule() {
    let hot = {
      _acceptDependencies: {},
      // 收集相关的依赖
      accept(deps,callback) {
        deps.forEach(dep => hot._acceptDependencies[dep]=callback);
      },
      check: hotcheck
    }
    return hot
  }

这样我们就成功维护了模块之前的父子关系,方便后面我们进行热更新

hotcheck

当上面hash发生变化的时候,就会调用module.hot.check()方法。这里就是执行到我们的hotcheck
我们根据hash的变化。通过hash去寻找对应的json文件。利用ajax去下载文件。下载成功之后。我们去检查哪些文件发生了变化。去下载这些变化模块的最新代码

// 通过ajax去下载对应的json 文件
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 hotcheck() {
    hotDownloadManifest().then(update=>{
      let chunkIds = Object.keys(update.c)
      chunkIds.forEach(chunkId=>{
        hotDownloadUpdateChunk(chunkId)
      })
      lastHash = currentHash 
    }).catch(()=>{
      window.location.reload()
    })
  }

hotDownloadUpdateChunk

拿到了变化的模块,去下载最新变化模块的代码

 function hotDownloadUpdateChunk(chunkId) {
   let script = document.createElement('script')
   script.src = `${chunkId}.${lastHash}.hot-update.js`
   document.head.appendChild(script)
 }

webpackHotUpdate

当浏览器拿到了最新的代码。就会去执行webpackHotUpdate 方法 这里主要拿到最新的模块。记录下最新的模块代码

 window.webpackHotUpdate = function(chunkId,moreModules) {
   HotUpdateChunk(chunkId,moreModules)
 }
 let hotUpdate = {}
function HotUpdateChunk(chunkId,moreModules) {
   for(moduleId in moreModules) {
     modules[moduleId] = hotUpdate[moduleId]  = moreModules[moduleId]
   }
   hotApply()
 }

hotApply

拿到之前的旧模块。删除之前的旧模块。从而去渲染最新生成的模块。去执行对应的回调函数。这样我们就实现了热模块替换

 function hotApply() {
   for(moduleId in hotUpdate) {
     let oldModule = installedModules[moduleId]
     delete installedModules[moduleId] 
     // 拿到旧模块的父模块。去执行对应的回调
     oldModule.parents.forEach(parentModule=>{
       let cb = parentModule.hot._acceptDependencies[moduleId]
       cb&&cb()
     })
   }
 }

全部代码可以访问 gitee.com/yujun96/web…