简单实现 Vite 热更新

1,664 阅读4分钟

热更新(hmr)全称 Hot Module Reload,常常在构建工具里面出现。

在我们开发时候修改代码后页面会立即自动更新。这是怎么做到了的呢?

现在我们通过一行一行代码来,构建一个最简单的热更新。

问题是我们怎么做到文件变动到页面自动响应更新呢?

首先我们把整个过程分成3步

1. 监听文件变动 2. 读取文件内容 3. 通知浏览器更新页面

写出伪代码 1、 2、3

// server

watch(file); // 1
fileContent = readFile(file) // 2
send(fileContent); // 3

接下来选取方案。

第一步:监听文件变动

我们知道 nodejs 里面有个fs.watch api。它可以监听文件变动。

(fs.FSWatcher) fs.watch(filename[, options][, listener])

但是更加查看一些文档,stackoverflow

发现node原生的watch api,存在些许问题。 这个时候回去找些库来实现

使用 chokidar 来实现文件的监听

const chokidar = require('chokidar');
// One-liner for current directory
chokidar.watch('demo/chokidar').on('all', (event, path) => {
    console.log(path);
});

image 成功监听到了文件的变动,但是发现启动服务起后,就直接打印路径。 现在我们只想要文件变更时候触发事件。把代码改成这样 all 替换成 change 去掉 event 参数

const chokidar = require('chokidar');
// One-liner for current directory
chokidar.watch('demo/chokidar').on('change', (path) => {
    console.log(path);
});

重启服务器 image

现在监听文件变动没问题了。

第二步:读取文件内容

这个比较简单,因为是在服务端直接读取文件,我们就直接用 nodejs 中的 fs.readFileSync, 来实现

const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');

chokidar.watch('demo/ws-chokidar').on('change', (relativePath) => {
    const filePath = path.resolve(__dirname, '../../', relativePath);
    const data = fs.readFileSync(filePath, 'utf-8');
    console.log(data, relativePath);
    
    ws.send(data);
});

由于监听到的path参数是相对路径,而readFileSync需要读取争取的文件路径 使用 path 进行库进行拼接。现在看看效果

image

当我们随意修改change.html 内容时,控制台输出我们的文件内容和路径

第三步: 通知浏览器更新页面

我们知道通常通过 AJAX 走 http 协议只能客户端发请求到服务器,接受响应。 怎么让服务器主动通知客户端更新了哪些文件了。

很显然要用到 websocket。 虽然 websocket 在浏览器和node都有api 支持,但是解密 socket 数据还是挺麻烦。 这里我们需要用到 websocket 的库。

一种是 socket.io,一种是 ws。这里只用 ws 作为例子。

我们先写一个前端页面。简单构造一个 socket 发送和监听器。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>socket</title>
    </head>
    <body>
        <button class="button">发送</button>
        <h2>data:</h2>
        <div class="response-data"></div>

        <script>
            var oButtons = document.getElementsByClassName('button');
            var oResponseDatas = document.getElementsByClassName('response-data');
            var oResponseData = oResponseDatas.length && oResponseDatas[0];


            if (oButtons.length) {
                oButtons[0].addEventListener('click', onClick, false);
            }

            function onClick() {
                var socket = new WebSocket('ws://localhost:3000');

                socket.open = function() {
                    socket.send('123');
                }
                socket.onmessage = function(event) {
                    console.log(event.data);
                    oResponseData.innerHTML = event.data;
                }

                socket.onclose = function() {
                    console.log('close');
                }

            }
        </script>
    </body>
</html>

image 然后我们使用ws给服务器端构建发送和接收器

const chokidar = require('chokidar');
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');

const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', function connection(ws) {
    ws.on('message', function incoming(message) {
        console.log('received: %s', message);
    });

    chokidar.watch('demo/ws-chokidar').on('change', (relativePath) => {
        const filePath = path.resolve(__dirname, '../../', relativePath);
        const data = fs.readFileSync(filePath, 'utf-8');
        console.log(data, relativePath);

        ws.send(data);
    });

});

测试下效果

首先启动 node 服务器。 然后点击页面发送按钮, 构建 socket 连接。

image 成功连接

改变change.html 里面的内容。先加一个按钮试试

<button>这是一个按钮</button>

保存文件后,浏览器立即收到了消息,并更新了页面 image 很快,啪的一下!!! messge 里面收到了数据,页面也多出了一个按钮。

在change.html 加个稍微复杂点的html,看行不行

<div style="height: 300px; width: 300px; border-radius: 50%; background: crimson;text-align: center; color:white;line-height: 300px;">这是一个球</div>

image 这次大意了没有闪。啪的一下,出来了一个大红球。右边 message 里面多了几个记录。

这次就算基本完成啦。当然正式项目中远没有这么简单。剩下的靠大家直接研究了。

最后看看 vite 代码

对于模块化构建的项目来说,热更新更加复杂。其中涉及到依赖更新页面替换的问题。

我们看看 vite 是怎么做的。 截取这里部分代码。

  const mods = moduleGraph.getModulesByFile(file)

  // check if any plugin wants to perform custom HMR handling
  const timestamp = Date.now()
  const hmrContext: HmrContext = {
    file,
    timestamp,
    modules: mods ? [...mods] : [],
    read: () => readModifiedFile(file),
    server
  }

  for (const plugin of config.plugins) {
    if (plugin.handleHotUpdate) {
      const filteredModules = await plugin.handleHotUpdate(hmrContext)
      if (filteredModules) {
        hmrContext.modules = filteredModules
      }
    }
  }

  if (!hmrContext.modules.length) {
    // html file cannot be hot updated
    if (file.endsWith('.html')) {
      config.logger.info(chalk.green(`page reload `) + chalk.dim(shortFile), {
        clear: true,
        timestamp: true
      })
      ws.send({
        type: 'full-reload',
        path: config.server.middlewareMode
          ? '*'
          : '/' + normalizePath(path.relative(config.root, file))
      })
    } else {
      // loaded but not in the module graph, probably not js
      debugHmr(`[no modules matched] ${chalk.dim(shortFile)}`)
    }
    return
  }

  updateModules(shortFile, hmrContext.modules, timestamp, server)
}

具体分析下次再聊~

之后会有更多相关简单轮子的文章发布。希望持续关注前端轮子~