徹底搞懂並實現webpack熱更新原理

目錄

HMR是什麼

HMRHot Module Replacement是指當你對代碼修改並保存後,webpack將會對代碼進行重新打包,並將改動的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,去實現局部更新頁面而非整體刷新頁面。接下來將從使用到實現一版簡易功能帶領大家深入淺出HMR

文章首發於@careteen/webpack-hmr,轉載請註明來源即可。

使用場景

scenario

如上圖所示,一個註冊頁面包含用戶名密碼郵箱三個必填輸入框,以及一個提交按鈕,當你在調試郵箱模塊改動了代碼時,沒做任何處理情況下是會刷新整個頁面,頻繁的改動代碼會浪費你大量時間去重新填寫內容。預期是保留用戶名密碼的輸入內容,而只替換郵箱這一模塊。這一訴求就需要藉助webpack-dev-server的熱模塊更新功能。

相對於live reload整體刷新頁面的方案,HMR的優點在於可以保存應用的狀態,提高開發效率。

配置使用HMR

配置webpack

首先借助webpack搭建項目

  • 初識化項目並導入依賴
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
  • 配置文件webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 開發模式不壓縮代碼,方便調試
  entry: './src/index.js', // 入口文件
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    })
  ]
}
  • 新建src/index.html模板文件
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack Hot Module Replacement</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
  • 新建src/index.js入口文件編寫簡單邏輯
var root = document.getElementById('root')
function render () {
  root.innerHTML = require('./content.js')
}
render()
  • 新建依賴文件src/content.js導出字符供index渲染頁面
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
  • 配置package.json
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
  • 然後npm run dev即可啓動項目
  • 通過npm run build打包生成靜態資源到dist目錄

接下來先分析下dist目錄中的文件

解析webpack打包後的文件內容

  • webpack自己實現的一套commonjs規範講解
  • 區分commonjs和esmodule

dist目錄結構

.
├── index.html
└── main.js

其中index.html內容如下

<!-- ... -->
<div id="root"></div>
<script type="text/javascript" src="main.js"></script></body>
<!-- ... -->

使用html-webpack-plugin插件將入口文件及其依賴通過script標籤引入

先對main.js內容去掉註釋和無關內容進行分析

(function (modules) { // webpackBootstrap
  // ...
})
({
  "./src/content.js":
    (function (module, exports) {
      eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
    }),
  "./src/index.js": (function (module, exports, __webpack_require__) {
    eval("var root = document.getElementById('root')\nfunction render () {\n  root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
  })
});

可見webpack打包後會產出一個自執行函數,其參數爲一個對象

"./src/content.js": (function (module, exports) {
  eval("...")
}

鍵爲入口文件或依賴文件相對於根目錄的相對路徑,值則是一個函數,其中使用eval執行文件的內容字符。

  • 再進入自執行函數體內,可見webpack自己實現了一套commonjs規範
(function (modules) {
  // 模塊緩存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判斷是否有緩存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 沒有緩存則創建一個模塊對象並將其放入緩存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加載
      exports: {}
    };
    // 執行模塊函數
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 將狀態置爲已加載
    module.l = true;
    // 返回模塊對象
    return module.exports;
  }
  // ...
  // 加載入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果對上面commonjs規範感興趣可以前往我的另一篇文章手摸手帶你實現commonjs規範

給出上面代碼主要是先對webpack的產出文件混個眼熟,不要懼怕。其實任何一個不管多複雜的事物都是由更小更簡單的東西組成,剖開它認識它愛上它。

配置HMR

接下來配置並感受一下熱更新帶來的便捷開發

webpack.config.js配置

  // ...
  devServer: {
    hot: true
  }
  // ...

./src/index.js配置

// ...
if (module.hot) {
  module.hot.accept(['./content.js'], () => {
    render()
  })
}

當更改./content.js的內容並保存時,可以看到頁面沒有刷新,但是內容已經被替換了。

這對提高開發效率意義重大。接下來將一層層剖開它,認識它的實現原理。

HMR原理

core

如上圖所示,右側Server端使用webpack-dev-server去啓動本地服務,內部實現主要使用了webpackexpresswebsocket

  • 使用express啓動本地服務,當瀏覽器訪問資源時對此做響應。
  • 服務端和客戶端使用websocket實現長連接
  • webpack監聽源文件的變化,即當開發者保存文件時觸發webpack的重新編譯。

    • 每次編譯都會生成hash值已改動模塊的json文件已改動模塊代碼的js文件
    • 編譯完成後通過socket向客戶端推送當前編譯的hash戳
  • 客戶端的websocket監聽到有文件改動推送過來的hash戳,會和上一次對比

    • 一致則走緩存
    • 不一致則通過ajaxjsonp向服務端獲取最新資源
  • 使用內存文件系統去替換有修改的內容實現局部刷新

上圖先只看個大概,下面將從服務端和客戶端兩個方面進行詳細分析

debug服務端源碼

core

現在也只需要關注上圖的右側服務端部分,左側可以暫時忽略。下面步驟主要是debug服務端源碼分析其詳細思路,也給出了代碼所處的具體位置,感興趣的可以先行定位到下面的代碼處設置斷點,然後觀察數據的變化情況。也可以先跳過閱讀此步驟。

  1. 啓動webpack-dev-server服務器,源代碼地址@webpack-dev-server/webpack-dev-server.js#L173
  2. 創建webpack實例,源代碼地址@webpack-dev-server/webpack-dev-server.js#L89
  3. 創建Server服務器,源代碼地址@webpack-dev-server/webpack-dev-server.js#L107
  4. 添加webpack的done事件回調,源代碼地址@webpack-dev-server/Server.js#L122

    1. 編譯完成向客戶端發送消息,源代碼地址@webpack-dev-server/Server.js#L184
  5. 創建express應用app,源代碼地址@webpack-dev-server/Server.js#L123
  6. 設置文件系統爲內存文件系統,源代碼地址@webpack-dev-middleware/fs.js#L115
  7. 添加webpack-dev-middleware中間件,源代碼地址@webpack-dev-server/Server.js#L125

    1. 中間件負責返回生成的文件,源代碼地址@webpack-dev-middleware/middleware.js#L20
  8. 啓動webpack編譯,源代碼地址@webpack-dev-middleware/index.js#L51
  9. 創建http服務器並啓動服務,源代碼地址@webpack-dev-server/Server.js#L135
  10. 使用sockjs在瀏覽器端和服務端之間建立一個 websocket 長連接,源代碼地址@webpack-dev-server/Server.js#L745

    1. 創建socket服務器,源代碼地址@webpack-dev-server/SockJSServer.js#L34

服務端簡易實現

上面是我通過debug得出dev-server運行流程比較核心的幾個點,下面將其抽象整合到一個文件中

啓動webpack-dev-server服務器

先導入所有依賴

const path = require('path') // 解析文件路徑
const express = require('express') // 啓動本地服務
const mime = require('mime') // 獲取文件類型 實現一個靜態服務器
const webpack = require('webpack') // 讀取配置文件進行打包
const MemoryFileSystem = require('memory-fs') // 使用內存文件系統更快,文件生成在內存中而非真實文件
const config = require('./webpack.config') // 獲取webpack配置文件

創建webpack實例

const compiler = webpack(config)

compiler代表整個webpack編譯任務,全局只有一個

創建Server服務器

class Server {
  constructor(compiler) {
    this.compiler = compiler
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服務器已經在${port}端口上啓動了`)
    })
  }
}
let server = new Server(compiler)
server.listen(8000)

在後面是通過express來當啓動服務的

添加webpack的done事件回調

  constructor(compiler) {
    let sockets = []
    let lasthash
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      lasthash = stats.hash
      // 每當新一個編譯完成後都會向客戶端發送消息
      sockets.forEach(socket => {
        socket.emit('hash', stats.hash) // 先向客戶端發送最新的hash值
        socket.emit('ok') // 再向客戶端發送一個ok
      })
    })
  }

webpack編譯後提供提供了一系列鉤子函數,以供插件能訪問到它的各個生命週期節點,並對其打包內容做修改。compiler.hooks.done則是插件能修改其內容的最後一個節點。

編譯完成通過socket向客戶端發送消息,推送每次編譯產生的hash。另外如果是熱更新的話,還會產出二個補丁文件,裏面描述了從上一次結果到這一次結果都有哪些chunk和模塊發生了變化。

使用let sockets = []數組去存放當打開了多個Tab時每個Tab的socket實例

創建express應用app

let app = new express()

設置文件系統爲內存文件系統

let fs = new MemoryFileSystem()

使用MemoryFileSystemcompiler的產出文件打包到內存中。

添加webpack-dev-middleware中間件

  function middleware(req, res, next) {
    if (req.url === '/favicon.ico') {
      return res.sendStatus(404)
    }
    // /index.html   dist/index.html
    let filename = path.join(config.output.path, req.url.slice(1))
    let stat = fs.statSync(filename)
    if (stat.isFile()) { // 判斷是否存在這個文件,如果在的話直接把這個讀出來發給瀏覽器
      let content = fs.readFileSync(filename)
      let contentType = mime.getType(filename)
      res.setHeader('Content-Type', contentType)
      res.statusCode = res.statusCode || 200
      res.send(content)
    } else {
      return res.sendStatus(404)
    }
  }
  app.use(middleware)

使用expres啓動了本地開發服務後,使用中間件去爲其構造一個靜態服務器,並使用了內存文件系統,使讀取文件後存放到內存中,提高讀寫效率,最終返回生成的文件。

啓動webpack編譯

  compiler.watch({}, err => {
    console.log('又一次編譯任務成功完成了')
  })

以監控的模式啓動一次webpack編譯,當編譯成功之後執行回調

創建http服務器並啓動服務

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    // ...
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服務器已經在${port}端口上啓動了`)
    })
  }

使用sockjs在瀏覽器端和服務端之間建立一個 websocket 長連接

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    let io = require('socket.io')(this.server)
    io.on('connection', (socket) => {
      sockets.push(socket)
      socket.emit('hash', lastHash)
      socket.emit('ok')
    })
  }

啓動一個 websocket服務器,然後等待連接來到,連接到來之後存進sockets池

當有文件改動,webpack重新編譯時,向客戶端推送hashok兩個事件

服務端調試階段

感興趣的可以根據上面debug服務端源碼所帶的源碼位置,並在瀏覽器的調試模式下設置斷點查看每個階段的值。

node dev-server.js

使用我們自己編譯的dev-server.js啓動服務,可看到頁面可以正常展示,但還沒有實現熱更新。

下面將調式客戶端的源代碼分析其實現流程。

debug客戶端源碼

core

現在也只需要關注上圖的左側客戶端部分,右側可以暫時忽略。下面步驟主要是debug客戶端源碼分析其詳細思路,也給出了代碼所處的具體位置,感興趣的可以先行定位到下面的代碼處設置斷點,然後觀察數據的變化情況。也可以先跳過閱讀此步驟。

debug客戶端源碼分析其詳細思路

  1. webpack-dev-server/client端會監聽到此hash消息,源代碼地址@webpack-dev-server/index.js#L54
  2. 客戶端收到ok的消息後會執行reloadApp方法進行更新,源代碼地址index.js#L101
  3. 在reloadApp中會進行判斷,是否支持熱更新,如果支持的話發射webpackHotUpdate事件,如果不支持則直接刷新瀏覽器,源代碼地址reloadApp.js#L7
  4. 在webpack/hot/dev-server.js會監聽webpackHotUpdate事件,源代碼地址dev-server.js#L55
  5. 在check方法裏會調用module.hot.check方法,源代碼地址dev-server.js#L13
  6. HotModuleReplacement.runtime請求Manifest,源代碼地址HotModuleReplacement.runtime.js#L180
  7. 它通過調用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代碼地址JsonpMainTemplate.runtime.js#L23
  8. 調用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通過JSONP請求獲取到最新的模塊代碼,源代碼地址JsonpMainTemplate.runtime.js#L14
  9. 補丁JS取回來後會調用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代碼地址JsonpMainTemplate.runtime.js#L8
  10. 然後會調用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法動態更新模塊代碼,源代碼地址HotModuleReplacement.runtime.js#L222
  11. 然後調用hotApply方法進行熱更新,源代碼地址HotModuleReplacement.runtime.js#L257HotModuleReplacement.runtime.js#L278

客戶端簡易實現

上面是我通過debug得出dev-server運行流程比較核心的幾個點,下面將其抽象整合成一個文件

webpack-dev-server/client端會監聽到此hash消息

在開發客戶端功能之前,需要在src/index.html中引入socket.io

<script src="/socket.io/socket.io.js"></script>

下面連接socket並接受消息

let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
  console.log('客戶端連接成功')
}
let hotCurrentHash // lastHash 上一次 hash值 
let currentHash // 這一次的hash值
socket.on('hash', (hash) => {
  currentHash = hash
})

將服務端webpack每次編譯所產生hash進行緩存

客戶端收到ok的消息後會執行reloadApp方法進行更新

socket.on('ok', () => {
  reloadApp(true)
})

reloadApp中判斷是否支持熱更新

// 當收到ok事件後,會重新刷新app
function reloadApp(hot) {
  if (hot) { // 如果hot爲true 走熱更新的邏輯
    hotEmitter.emit('webpackHotUpdate')
  } else { // 如果不支持熱更新,則直接重新加載
    window.location.reload()
  }
}

在reloadApp中會進行判斷,是否支持熱更新,如果支持的話發射webpackHotUpdate事件,如果不支持則直接刷新瀏覽器。

在webpack/hot/dev-server.js會監聽webpackHotUpdate事件

首先需要一個發佈訂閱去綁定事件並在合適的時機觸發。

class Emitter {
  constructor() {
    this.listeners = {}
  }
  on(type, listener) {
    this.listeners[type] = listener
  }
  emit(type) {
    this.listeners[type] && this.listeners[type]()
  }
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
  if (!hotCurrentHash || hotCurrentHash == currentHash) {
    return hotCurrentHash = currentHash
  }
  hotCheck()
})

會判斷是否爲第一次進入頁面和代碼是否有更新。

上面的發佈訂閱較爲簡單,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能需要先訂閱後發佈,此時可以移步@careteen/event-emitter。其實現原理也挺簡單,需要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時可以取出所有事件執行。

在check方法裏會調用module.hot.check方法

function hotCheck() {
  hotDownloadManifest().then(update => {
    let chunkIds = Object.keys(update.c)
    chunkIds.forEach(chunkId => {
      hotDownloadUpdateChunk(chunkId)
    })
  })
}

上面也提到過webpack每次編譯都會產生hash值已改動模塊的json文件已改動模塊代碼的js文件

此時先使用ajax請求Manifest即服務器這一次編譯相對於上一次編譯改變了哪些module和chunk。

然後再通過jsonp獲取這些已改動的module和chunk的代碼。

調用hotDownloadManifest方法

function hotDownloadManifest() {
  return new Promise(function (resolve) {
    let request = new XMLHttpRequest()
    //hot-update.json文件裏存放着從上一次編譯到這一次編譯 取到差異
    let requestPath = '/' + hotCurrentHash + ".hot-update.json"
    request.open('GET', requestPath, true)
    request.onreadystatechange = function () {
      if (request.readyState === 4) {
        let update = JSON.parse(request.responseText)
        resolve(update)
      }
    }
    request.send()
  })
}

調用hotDownloadUpdateChunk方法通過JSONP請求獲取到最新的模塊代碼

function hotDownloadUpdateChunk(chunkId) {
  let script = document.createElement('script')
  script.charset = 'utf-8'
  // /main.xxxx.hot-update.js
  script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
  document.head.appendChild(script)
}

這裏解釋下爲什麼使用JSONP獲取而不直接利用socket獲取最新代碼?主要是因爲JSONP獲取的代碼可以直接執行。

調用webpackHotUpdate方法

當客戶端把最新的代碼拉到瀏覽之後

window.webpackHotUpdate = function (chunkId, moreModules) {
  // 循環新拉來的模塊
  for (let moduleId in moreModules) {
    // 從模塊緩存中取到老的模塊定義
    let oldModule = __webpack_require__.c[moduleId]
    // parents哪些模塊引用這個模塊 children這個模塊引用了哪些模塊
    // parents=['./src/index.js']
    let {
      parents,
      children
    } = oldModule
    // 更新緩存爲最新代碼 緩存進行更新
    let module = __webpack_require__.c[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
      parents,
      children,
      hot: window.hotCreateModule(moduleId)
    }
    moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
    module.l = true // 狀態變爲加載就是給module.exports 賦值了
    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })
    hotCurrentHash = currentHash
  }
}

hotCreateModule的實現

實現我們可以在業務代碼中定義需要熱更新的模塊以及回調函數,將其存放在hot._acceptedDependencies中。

window.hotCreateModule = function () {
  let hot = {
    _acceptedDependencies: {},
    dispose() {
      // 銷燬老的元素
    },
    accept: function (deps, callback) {
      for (let i = 0; i < deps.length; i++) {
        // hot._acceptedDependencies={'./title': render}
        hot._acceptedDependencies[deps[i]] = callback
      }
    }
  }
  return hot
}

然後在webpackHotUpdate中進行調用

    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })

最後調用hotApply方法進行熱更新

客戶端調試階段

經過上述實現了一個基本版的HMR,可更改代碼保存的同時查看瀏覽器並非整體刷新,而是局部更新代碼進而更新視圖。在涉及到大量表單的需求時大大提高了開發效率。

問題

  • 如何實現commonjs規範?
感興趣的可前往debug CommonJs規範瞭解其實現原理。
  • webpack實現流程以及各個生命週期的作用是什麼?
webpack主要藉助了tapable這個庫所提供的一系列同步/異步鉤子函數貫穿整個生命週期。webpack生命週期基於此我實現了一版簡易的webpack,源碼100+行,食用時伴着註釋很容易消化,感興趣的可前往看個思路。
  • 發佈訂閱的使用和實現,並且如何實現一個可先訂閱後發佈的機制?
上面也提到需要使用到發佈訂閱模式,且只支持先發布後訂閱功能。對於一些較爲複雜的場景可能需要先訂閱後發佈,此時可以移步@careteen/event-emitter。其實現原理也挺簡單,需要維護一個離線事件棧存放還沒發佈就訂閱的事件,等到訂閱時可以取出所有事件執行。
  • 爲什麼使用JSONP而不用socke通信獲取更新過的代碼?
因爲通過socket通信獲取的是一串字符串需要再做處理。而通過JSONP獲取的代碼可以直接執行。

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章