用过 webpack 的同学应该都晓得,有一个特地好用的『热更新』,在不刷新页面的状况下,就能将代码推到浏览器。

明天的文章将会探寻一下 webpack 热更新的机密。

如何配置热更新

咱们先装置一些咱们须要的包:

npm i webpack webpack-cli -Dnpm i webpack-dev-server -Dnpm i html-webpack-plugin -D

而后,咱们须要弄明确,webpack 从版本 webpack@4 之后,须要通过 webpack CLI 来启动服务,提供了打包的命令和启动开发服务的命令。

# 打包到指定目录webpack build --mode production --config webpack.config.js# 启动开发服务器webpack serve --mode development --config webpack.config.js
// pkg.json{  "scripts": {    "dev": "webpack serve --mode development --config webpack.config.js",    "build": "webpack build --mode production --config webpack.config.js"  },  "devDependencies": {    "webpack": "^5.45.1",    "webpack-cli": "^4.7.2",    "webpack-dev-server": "^3.11.2",    "html-webpack-plugin": "^5.3.2",  }}

在启动开发服务的时候,在 webpack 的配置文件中配置 devServe 属性,即可开启热更新模式。

// webpack.config.jsconst path = require('path')const HtmlWebpackPlugin = require('html-webpack-plugin')module.exports = {  entry: './src/index.js',  output: {    filename: 'main.js',    path: path.resolve(__dirname, 'dist'),  },  devServer: {    hot: true, // 开启热更新    port: 8080, // 指定服务器端口号  },  plugins: [    new HtmlWebpackPlugin({      template: './index.html'    })  ]}

配置结束后,咱们能够开始按上面的目录构造新建文件。

├── src│   ├── index.js│   └── num.js├── index.html├── package.json└── webpack.config.js

这里因为须要对 DOM 进行操作,为了不便咱们间接应用 jQuery (yyds),在 HTML 文件中引入 jQuery 的 CDN。

<!DOCTYPE html><html lang="en"><head>  <title>Webpack Demo</title>  <script src="https://unpkg.com/jquery@3.6.0/dist/jquery.js"></script></head><body>  <div id="app"></div> </body></html>

而后在 index.js 中对 div#app 进行操作。

// src/index.jsimport { setNum } from './num'$(function() {  let num = 0  const $app = $('#app')  $app.text(`同步批改后果: ${num}`)  setInterval(() => {    num = setNum(num) // 调用 setNum 更新 num 的值    $app.text(`同步批改后果: ${num}`)  }, 1e3)})

这里每秒调用一次 setNum 办法,更新变量 num 的值,而后批改 div#app 的文本。setNum 办法在 num.js 文件中,这里就是咱们须要批改的中央,通过批改该办法,让页面间接进行热更新。

// src/num.jsexport const setNum = (num) => {  return ++num // 让 num 自增}

批改 setNum 办法的过程中,发现页面间接刷新了,并没有达到料想中的热更新操作。

官网文档中如同也没说还有什么其余的配置要做,真是让人蛊惑。

最初把文档翻烂了之后,发现,热更新除了要批改 devServer 配置之外,还须要在代码中通知 webpack 哪些模块是须要进行热更新的。

模块热替换:https://webpack.docschina.org...

同理,咱们须要批改 src/index.js,通知 webpack src/num.js 模块是须要进行热更新的。

import { setNum } from './num'if (module.hot) {  //num 模块须要进行热更新  module.hot.accept('./num')}$(function() {  ……})

对于模块热替换更多 API 介绍能够看这里:

模块热替换(hot module replacement) -https://www.webpackjs.com/api/hot-module-replacement

如果不是像我这样手动配置 webpack,并且应用 jQuery 基本不会留神到这个配置。在一些 Loader (style-loader、vue-loader、react-hot-loader)中,都在其外部调用了 module hot api,也是替开发者省了很多心。

style-loader 热更新代码

https://github.com/webpack-co...

vue-loader 热更新代码

https://github.com/vuejs/vue-...

热更新的原理

在讲热更新之前,咱们须要先看看 webpack 是如何打包文件的。

webpack 打包逻辑

先回顾一下后面的代码,并且把之前的 ESM 语法改成 require ,因为 webpack 外部也会把 ESM 批改成 require

// src/index.js$(function() {  let num = 0  const $app = $('#app')  $app.text(`同步批改后果: ${num}`)  setInterval(() => {    num = require('./num').setNum(num)    $app.text(`同步批改后果: ${num}`)  }, 1e3)})// src/num.jsexports.setNum = (num) => {  return --num}

咱们都晓得,webpack 实质是一个打包工具,会把多个 js 文件打包成一个 js 文件。上面的代码是 webpack 打包后的代码:

// webpackBootstrap(() => {  // 所有模块打包都一个对象中  // key 为文件名,value 为一个匿名函数,函数内就是文件内代码  var __webpack_modules__ = ({    "./src/index.js": ((module, __webpack_exports__, __webpack_require__) => {      "use strict";      $(function() {        let num = 0        const $app = $('#app')        $app.text(`同步批改后果: ${num}`)        setInterval(() => {          num = (0,__webpack_require__("./src/num.js").setNum)(num)          $app.text(`同步批改后果: ${num}`)        }, 1e3)      })    }),    "./src/num.js": ((module, __webpack_exports__, __webpack_require__) => {      "use strict";      Object.assign(__webpack_exports__, {        "setNum": (num) => {          return ++num        }      })    })  });  // 外部实现一个 require 办法  function __webpack_require__(moduleId) {    // Execute the module function    try {      var module = {        id: moduleId,        exports: {}      };      // 取出模块执行      var factory = __webpack_modules__[moduleId]      factory.call(module.exports, module, module.exports, __webpack_require__);    } catch(e) {      module.error = e;      throw e;    }    // 返回执行后的 exports    return module.exports;  }  /*******************************************/  // 启动  // Load entry module and return exports  __webpack_require__("./src/index.js");})

当然,下面的代码是简化后的代码,webpack 理论打包进去的代码还会有一些缓存、容错以及 ESM 模块兼容之类的代码。

咱们能够简略的模仿一下 webpack 的打包逻辑。

// build.jsconst path = require('path')const minimist = require('minimist')const chokidar = require('chokidar')const wrapperFn = (content) => {  return  `function (require, module, exports) {\n  ${content.split('\n').join('\n  ')}\n}`}const modulesFn = (files, contents) => {  let modules = 'const modules = {\n'  files.forEach(file => {    modules += `"${file}": ${wrapperFn(contents[file])},\n\n`  })  modules += '}'  return modules}const requireFn = () => `const require = function(url) {  const module = { exports: {} }  const factory = modules[url] || function() {}  factory.call(module, require, module, module.exports)  return module.exports}`const template = {  wrapperFn,  modulesFn,  requireFn,}module.exports = class Build {  files = new Set()  contents = new Object()  constructor() {    // 解析参数    // index: 入口 html 的模板    // entry: 打包的入口 js 文件名    // output: 打包后输入的 js 文件名    const args = minimist(process.argv.slice(2))    const { index, entry, output } = args    this.index = index || 'index.html'    this.entry = path.join('./', entry)    this.output = path.join('./', output)    this.getScript()  }  getScript() {    // 从入口的 js 文件开始,获取所有的依赖    this.files.add(this.entry)    this.files.forEach(file => {      const dir = path.dirname(file)      const content = fs.readFileSync(file, 'utf-8')      const newContent = this.processJS(dir, content)      this.contents[file] = newContent    })  }  processJS(dir, content) {    let match = []    let result = content    const depReg = /require\s*\(['"](.+)['"]\)/g    while ((match = depReg.exec(content)) !== null) {      const [statements, url] = match      let newUrl = url      // 不存在文件后缀时,手动补充后缀      if (!newUrl.endsWith('.js')) {        newUrl += '.js'      }      newUrl = path.join(dir, newUrl)      // 将 require 中的绝对地址替换为相对地址      let newRequire = statements.replace(url, newUrl)      newRequire = newRequire.replace('(', `(/* ${url} */`)      result = result.replace(statements, newRequire)      this.files.add(newUrl)    }    return result  }  genCode() {    let outputJS = ''    outputJS += `/* all modules */${template.modulesFn(this.files, this.contents)}\n`    outputJS += `/* require */${template.requireFn()}\n`    outputJS += `/* start */require('${this.entry}')\n`    return outputJS  }}
// index.jscosnt fs = require('fs')const Build = require('./build')const build = new Build()// 生成打包后的代码const code = build.genCode()fs.writeFileSync(build.output, code)

启动代码:

node index.js --entry ./src/index.js --output main.js

生成后的代码如下所示:

/*    所有的模块都会放到一个对象中。    对象的 key 为模块的文件门路;    对象的 value 为一个匿名函数;*/const modules = {  "src/index.js": function (require, module, exports) {    $(function() {      let num = 0      const $app = $('#app')      $app.text(`同步批改后果: ${num}`)      setInterval(() => {        num = require('./num').setNum(num)        $app.text(`同步批改后果: ${num}`)      }, 1e3)    })  },  "src/num.js": function (require, module, exports) {    exports.setNum = (num) => {      return ++num    }  },}/*     外部实现一个 require 办法,从 modules 中获取对应模块,    而后注入 require、module、exports 等参数*/const require = function(url) {  const module = { exports: {} }  const factory = modules[url] || function() {}  factory.call(module, require, module, module.exports)  return module.exports}/* 启动入口的 index.js */require('src/index.js')

webpack 打包除了将所有 js 模块打包到一个文件外,引入 html-webpack-plugin 插件,还会将生成的 output 主动插入到 html 中。

new HtmlWebpackPlugin({  template: './index.html'})

这里咱们也在 build.js 中新增一个办法,模仿下这个行为。

module.exports = class Build {  constructor() {    ……  }  genIndex() {    const { index, output } = this    const htmlStr = fs.readFileSync(index, 'utf-8')    const insertIdx = htmlStr.indexOf('</head>')    const insertScript = `<script src="${output}"></script>`    // 在 head 标签内插入 srcript 标签    return htmlStr.slice(0, insertIdx) + insertScript + htmlStr.slice(insertIdx)  }}

要实现热更新,webpack 还须要本人启动一个服务,实现动态文件的传输。咱们利用 koa 启动一个简略的服务。

// index.jsconst koa = require('koa')const nodePath = require('path')const Build = require('./build')const build = new Build()// 启动服务const app = new koa()app.use(async ctx => {  const { method, path } = ctx  const file = nodePath.join('./', path)   if (method === 'GET') {    if (path === '/') {      // 返回 html      ctx.set(        'Content-Type',        'text/html;charset=utf-8'      )      ctx.body = build.genIndex()      return    } else if (file === build.output) {      ctx.set(        'Content-Type',        'application/x-javascript;charset=utf-8'      )      ctx.body = build.genCode()      return    }  }  ctx.throw(404, 'Not Found');})app.listen(8080)

启动服务后,能够看到页面失常运行。

node index.js --entry ./src/index.js --output main.js

热更新的实现

webpack 在热更新模式下,启动服务后,服务端会与客户端建设一个长链接。文件批改后,服务端会通过长链接向客户端推送一条音讯,客户端收到后,会从新申请一个 js 文件,返回的 js 文件会调用 webpackHotUpdatehmr 办法,用于替换掉 __webpack_modules__ 中的局部代码。

通过试验能够看到,热更新的具体流程如下:

  1. Webpack Server 与 Client 建设长链接;
  2. Webpack 监听文件批改,批改后通过长链接告诉客户端;
  3. Client 从新申请文件,替换 __webpack_modules__ 中对应局部;

建设长链接

Server 与 Client 之间须要建设长链接,能够间接应用开源计划的 socket.io 的计划。

// index.jsconst koa = require('koa')const koaSocket = require('koa-socket-2')const Build = require('./build')const build = new Build()const app = new koa()const socket = new koaSocket()socket.attach(app) // 启动长链接服务app.use(async ctx => {  ………}……// build.jsmodule.exports = class Build {  constructor() {    ……  }  genIndex() {    ……    // 新增 socket.io 客户端代码    const insertScript = `    <script src="/socket.io/socket.io.js"></script>    <script src="${output}"></script>    `    ……  }  genCode() {    let outputJS = ''    ……    // 新增代码,监听服务端推送的音讯    outputJS += `/* socket */    const socket = io()    socket.on('updateMsg', function (msg){    // 监听服务端推送的音讯    })\n`    ……  }}

监听文件批改

后面实现 build.js 的时候,通过 getScript() 办法,曾经收集了所有的依赖文件。这里只须要通过 chokidar 监听所有的依赖文件即可。

// build.jsmodule.exports = class Build {  onUpdate = function () {}  constructor() {    ……    // 获取所有js依赖    this.getScript()    // 开启文件监听    this.startWatch()  }  startWatch() {    // 监听所有的依赖文件    chokidar.watch([...this.files]).on('change', (file) => {      // 获取更新后的文件      const dir = path.dirname(file)      const content = fs.readFileSync(file, 'utf-8')      const newContent = this.processJS(dir, content)      // 将更新的文件写入内存      this.contents[file] = newContent      this.onUpdate && this.onUpdate(file)    })  }  onWatch(callback) {    this.onUpdate = callback  }}

在文件批改后,重写了 build.contents 中的文本内容,而后会触发 onUpdate 办法。所以,咱们启动服务时,须要把实现这个办法,每次触发更新的时候,须要向客户端进行音讯推送。

// index.jsconst koa = require('koa')const koaSocket = require('koa-socket-2')const Build = require('./build')const build = new Build()const app = new koa()const socket = new koaSocket()// 启动长链接服务socket.attach(app)// 文件批改后,向所有的客户端播送批改的文件名build.onWatch((file) => {  app._io.emit('updateMsg', JSON.stringify({    type: 'update', file  }));})

申请更新模块

客户端收到音讯后,申请须要更新的模块。

// build.jsmodule.exports = class Build {  genCode() {    let outputJS = ''    ……    // 新增代码,监听服务端推送的音讯    outputJS += `/* socket */    const socket = io()    socket.on('updateMsg', function (msg){        const json = JSON.parse(msg)      if (json.type === 'update') {        // 依据文件名,申请更新的模块        fetch('/update/'+json.file)          .then(rsp => rsp.text())                    .then(text => {            eval(text) // 执行模块          })      }    })\n`    ……  }}

而后在服务端中间件内解决 /update/ 相干的申请。

app.use(async ctx => {  const { method, path } = ctx    if (method === 'GET') {    if (path === '/') {      // 返回 html      ctx.body = build.genIndex()      return    } else if (nodePath.join('./', path) === build.output) {      // 返回打包后的代码      ctx.body = build.genCode()      return    } else if (path.startsWith('/update/')) {      const file = nodePath.relative('/update/', path)      const content = build.contents[file]      if (content) {        // 替换 modules 内的文件        ctx.body = `modules['${file}'] = ${            template.wrapperFn(content)          }`        return      }    }  }}

最终成果:

残缺代码

Shenfq/hrm

https://github.com/Shenfq/hmr

总结

这次本人凭感觉实现了一把 HMR,必定和 Webpack 实在的 HMR 还是有一点出入,然而对于了解 HMR 的原理还是有一点帮忙的,心愿大家阅读文章后有所播种。