Webpack 熱更新原理

用過 webpack 的同窗應該都知道,有一個特別好用的『熱更新』,在不刷新頁面的狀況下,就能將代碼推到瀏覽器。javascript

熱更新

今天的文章將會探尋一下 webpack 熱更新的祕密。html

如何配置熱更新

咱們先安裝一些咱們須要的包:vue

npm i webpack webpack-cli -D
npm i webpack-dev-server -D
npm i html-webpack-plugin -D
複製代碼

而後,咱們須要弄明白,webpack 從版本 webpack@4 以後,須要經過 webpack CLI 來啓動服務,提供了打包的命令和啓動開發服務的命令。java

# 打包到指定目錄
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 屬性,便可開啓熱更新模式。node

// 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'
    })
  ]
}
複製代碼

配置完畢後,咱們能夠開始按下面的目錄結構新建文件。react

├── src
│   ├── index.js
│   └── num.js
├── index.html
├── package.json
└── webpack.config.js
複製代碼

這裏由於須要對 DOM 進行操做,爲了方便咱們直接使用 jQuery (yyds),在 HTML 文件中引入 jQuery 的 CDN。jquery

<!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 進行操做。webpack

// 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 方法,更新變量 num 的值,而後修改 div#app 的文本。setNum 方法在 num.js 文件中,這裏就是咱們須要修改的地方,經過修改該方法,讓頁面直接進行熱更新。git

// src/num.js
export const setNum = (num) => {
  return ++num // 讓 num 自增
}
複製代碼

修改 setNum 方法的過程當中,發現頁面直接刷新了,並無達到預想中的熱更新操做。github

官方文檔中好像也沒說還有什麼其餘的配置要作,真是讓人迷惑。

最後把文檔翻爛了以後,發現,熱更新除了要修改 devServer 配置以外,還須要在代碼中告訴 webpack 哪些模塊是須要進行熱更新的。

模塊熱替換:webpack.docschina.org/guides/hot-…

webpack 文檔

同理,咱們須要修改 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 熱更新代碼

github.com/webpack-con…

vue-loader 熱更新代碼

github.com/vuejs/vue-l…

熱更新的原理

在講熱更新以前,咱們須要先看看 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.js
exports.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.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
const 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.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)
複製代碼

啓動服務後,能夠看到頁面正常運行。

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.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`
    ……
  }
}
複製代碼

監聽文件修改

前面實現 build.js 的時候,經過 getScript() 方法,已經收集了全部的依賴文件。這裏只須要經過 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
  }
}
複製代碼

在文件修改後,重寫了 build.contents 中的文本內容,而後會觸發 onUpdate 方法。因此,咱們啓動服務時,須要把實現這個方法,每次觸發更新的時候,須要向客戶端進行消息推送。

// 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
  }));
})
複製代碼

請求更新模塊

客戶端收到消息後,請求須要更新的模塊。

// 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/ 相關的請求。

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 的原理仍是有一點幫助的,但願你們閱讀文章後有所收穫。

相關文章
相關標籤/搜索