8
头图

Students who have used webpack should know that there is a particularly useful "hot update" that can push the code to the browser without refreshing the page.

热更新

Today's article will explore the secrets of webpack hot update.

How to configure hot update

We first install some packages we need:

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

Then, we need to figure out that after webpack version webpack@4, the service needs to be started through the webpack CLI, and the packaged command and the command to start the development service are provided.

# 打包到指定目录
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",
  }
}

When starting the development service, configure the devServe attribute in the webpack configuration file to start the hot update mode.

// webpack.config.js
const 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'
    })
  ]
}

After the configuration is complete, we can start to create a new file according to the following directory structure.

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

Here because we need to operate on the DOM, in order to facilitate our direct use of jQuery (yyds), the jQuery CDN is introduced into the HTML file.

<!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>

Then operate index.js in div#app .

// src/index.js
import { setNum } from './num'

$(function() {
  let num = 0
  const $app = $('#app')
  $app.text(`同步修改结果: ${num}`)

  setInterval(() => {
    num = setNum(num) // 调用 setNum 更新 num 的值
    $app.text(`同步修改结果: ${num}`)
  }, 1e3)
})

setNum method is called once every second to update the value of the num , and then modify the text of div#app setNum method is in the num.js file. Here is the place we need to modify. By modifying this method, the page can be directly hot updated.

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

In the process of modifying the setNum method, it was found that the page was refreshed directly, and it did not achieve the expected hot update operation.

official document does not seem to say that there are other configurations to be done, which is really confusing.

Finally, after turning over the document, I found that in addition to modifying the devServer configuration, the hot update also needs to tell webpack which modules need to be hot updated in the code.

Hot module replacement: https://webpack.docschina.org/guides/hot-module-replacement/

webpack 文档

In the same way, we need to modify src/index.js to tell webpack that the src/num.js module needs to be hot updated.

import { setNum } from './num'

if (module.hot) {
  //num 模块需要进行热更新
  module.hot.accept('./num')
}

$(function() {
  ……
})

热更新

For more API introduction about module hot replacement, please see here:

hot module replacement-

If you don't manually configure webpack like me, and use jQuery, you won't notice this configuration at all. In some Loaders (style-loader, vue-loader, react-hot-loader), module hot api is called internally, which saves developers a lot of heart.

style-loader hot update code

https://github.com/webpack-contrib/style-loader/blob/6e70da0c5a37025510afe4f49ddeaf6c39daaa75/src/utils.js#L175

vue-loader hot update code

https://github.com/vuejs/vue-loader/blob/689075d763994a536022ea31348186f0a2c27460/lib/codegen/hotReload.js#L17

The principle of hot update

Before talking about hot updates, we need to take a look at how webpack packs files.

webpack packaging logic

First review the previous code, and change the previous ESM syntax to require , because webpack will also modify the ESM to 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.js
exports.setNum = (num) => {
  return --num
}

We all know that webpack is essentially a packaging tool that will package multiple js files into one js file. The following code is the code after webpack is packaged:

// 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");
})

Of course, the above code is a simplified code, and the code actually packaged by webpack will also have some code such as caching, fault tolerance, and ESM module compatibility.

We can simply simulate the packaging logic of webpack.

// build.js
const 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.js
cosnt fs = require('fs')
const Build = require('./build')
const build = new Build()

// 生成打包后的代码
const code = build.genCode()
fs.writeFileSync(build.output, code)

Start code:

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

The generated code is as follows:

/*
    所有的模块都会放到一个对象中。
    对象的 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')

In addition to packaging all js modules into one file, html-webpack-plugin also introduces the 060f8bc1c4b473 plug-in, and automatically inserts the generated output into the html.

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

Here we also build.js to simulate this behavior.

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)
  }
}

To complete the hot update, webpack also needs to start a service by itself to complete the transfer of static files. We use koa to start a simple service.

// index.js
const 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)

After starting the service, you can see that the page is running normally.

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

Implementation of hot update

In the hot update mode of webpack, after starting the service, the server will establish a long link with the client. After the file is modified, the server will push a message to the client through a long link. After the client receives it, it will request a js file again. The returned js file will call the webpackHotUpdatehmr method to replace part of the code in __webpack_modules__

Through experiments, we can see that the specific process of hot update is as follows:

  1. Webpack Server and Client establish a long link;
  2. Webpack monitors file modification and notifies the client through a long link after modification;
  3. Client requests the file again, replacing the corresponding part in __webpack_modules__

Establish a long link

A long link needs to be established between Server and Client, and the socket.io solution of the open source solution can be used directly.

// index.js
const 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.js
module.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`
    ……
  }
}

Monitor file modification

When build.js was implemented earlier, all dependent files have been collected through the getScript() Here you only need to monitor all dependent files chokidar

// build.js
module.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
  }
}

After the file is modified, build.contents is rewritten, and then the onUpdate method is triggered. Therefore, when we start the service, we need to implement this method. Every time an update is triggered, we need to push a message to the client.

// index.js
const 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
  }));
})

Request to update the module

After receiving the message, the client requests the module that needs to be updated.

// build.js
module.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/ related requests in the server-side middleware.

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
      }
    }
  }
}

final effect:

Complete code

👉 Shenfq/hrm

🔗 https://github.com/Shenfq/hmr

to sum up

This time I realized a HMR based on my feelings. It must be a little bit different from the real HMR of Webpack, but it is still a little helpful to understand the principle of HMR. I hope you will find something after reading the article.


Shenfq
4k 声望6.8k 粉丝