一个在多年的技术债项目中写出来的miniHMR热更新工具

前言

各位掘金上的大佬们你们好,我是一个工作了一年的大学生,第二次在掘金上发文章。

距离上一篇文章已经有一年半了,工作过程中其实没什么好发的,都是些CRUD。

今天这一篇,心血来潮,感觉更有启发意义一点和给我留下了深刻的印象。

背景

我入职了一家PHP公司,这个公司代码是2014年开始写的,大家可以想象,代码的设计思想和技术栈都不能与现在的技术栈和设计思想媲美,甚至很多代码规范做的都是很不足的。

整体的技术是:

  • 前后端不分离,没有明确的分离界限
  • 没有严格意义上的开发环境和测试环境
  • 挺完善但复杂的接近1万行代码的前端核心库(单文件)
  • 复杂并看不懂的没有注释的10万行代码php后端文件
  • 没有严格标准的文件存放风格、文件命名标准、代码编写风格
  • 拥有多个子系统、近20万用户、每日上300万的请求访问
  • 没有拖拽设计的低代码表单设计
  • 对于前端部分,没有Vite/WebPack,没有Vue/React(有也只有用cdn的newVue()的方式),没有scss/less(因为没有postcss),甚至可以省略css,使用一个数据驱动的js库自带的样式生成逻辑
  • 25个人只有5个核心研发,20个人全是业务开发,每个人的职能分工可以互相转换

这样一个系统,他凭着对业务的精准把控,和对需求方的极致配合,也做到了很多的业绩点和优势。

  • 对一个特定行业的业务做到了全覆盖
  • 有上千个不同的业务模块、功能模块
  • 对接了近百个不同的系统、平台、授权中心等
  • 年销售额达千万级别
  • 拥有全国级别的业务视野

那么在这家公司之中,原本我进去是搞WebGIS开发的,但是由于项目推不动,我只能写点其他的"科研"东西,来证明我没有上班打游戏、天天刷抖音。

在一年多的时间里,我将一些20年之后,或者以webpack/vite为发展主轴线的新时代前端工程化理念融入在14年的项目之中,我写了一点东西。

那么就是今天这个东西,我是如何在14年的项目中实现开发中的一个重要工具: HMR热更新组件。

技术方案

路径图

疑惑说明

  1. 为什么线上服务器更新了文件就能HMR,这违反开发的直觉,线上不应该是已经打包后的静态文件吗?

    因为他是14年的项目,那个时候"打包"、"CICD"、"docker"都还没有那么的成熟,所以大多数的时候都是直接手动部署,直接调库。

  2. 为什么要去线上服务器HMR,本地开一个服务不行吗?

    因为这个项目是低代码,项目团队的代码是一套,核心代码随时都会变,如果在本地部署的话,必须要及时更新到本地来。

    你会问:难道没有git吗?巧了,我在这里来了一年,真没有git,有git的存在但是系统的整个git要求很不规范,主要还是leader自己写核心代码,他一个人写,还需要git吗,只需要到处发包就是了,他只需要确保自己电脑上的版本是最新的就好了。

    所以我想了一下,既然leader会到处发包,那我不如找一个测试服务器作为我自己的环境,我把我的开发代码放到服务器上,leader要发包,那么过到我测试服务器的包一定是最新的,因为发包我不用关心。

    那么同时,我不仅不需要部署一套php环境,我连pg数据库都不用部署,都在云上,我只需要在本地写代码就行了,现在我拿一台win7只要我能写代码,有ftp,能上网,我都可以写。

  3. 原来有什么问题,会让你要写这个东西?

    我是一个熟悉构建工具开发前端的新人大学生程序员,我最熟悉的,就是以HMR和动态加载的vite模式,现在这个系统,他如果要写一串代码,要验证是否正确需要:

相当的繁琐,对于宏观上来说,你手动部署了一遍。

我们leader的做法是,使用ftpGUI软件,在ftp服务通道上编写代码,写完保存,ftp文件自动保存然后上传替换原文件,然后在页面上刷新,点页面验证。

如果说:功能很复杂,用到的库更多,页面交互更复杂,那怎么办,这样调试调不死你。

为了优化这个问题我做了什么

  • 实现了本地socket,监听文件更改
  • 实现了自动FTP功能:可以上传/修改/新建/删除
  • 实现了远端服务器文件监听:使用轮询来监听服务器的代码有没有改动
  • 实现了客户端浏览器对php、js、css文件的热更新

四者是一个链路下来,不能缺少,缺一个就不能实现整个流程,在每一个点中,就要延伸一些问题点,实现一些定制化功能,比如如何规范权限、如何知道需要监听哪些文件等

同事反馈

简直是魔法、牛逼、这太直接了。

代码实现

xHMR:本地文件监听与FTP服务

首先他是一个node服务,需要这些库

js 复制代码
  "dependencies": {
    "basic-ftp": "^5.0.5",   //顾名思义
    "chokidar": "^4.0.3",    //文件监听库
    "express": "^5.1.0",  //express框架,开启本机监听端口
    "ws": "^8.18.3"  //websocket
  }
  • hmr-ftp.config.js

顾名思义 ------ 配置文件

js 复制代码
// hmr.config.js
export default {
  ftp: {
    host: '',
    user: '',
    password: '',
    remoteRoot: '/develop/xHMRServer', // 上传到服务器的哪个路径[重要!!!不要写错]
    secure: false
  },
  //过滤文件监听类型
  watch: {
    ignorePatterns: [
      /node_modules/,
      /\.log$/, 
      'Database.php.log'
    ]
  }
}
  • hmr-server.js

核心文件,用于本地文件监听和FTP上传

js 复制代码
// hmr-server.js
import http from 'http'
import express from 'express'
import { WebSocketServer } from 'ws'
import chokidar from 'chokidar'
import path from 'path'
import { fileURLToPath } from 'url'

// 读取ftp配置
import ftp from 'basic-ftp'
import pathLib from 'path'
import config from './hmr-ftp.config.js'

// 获取监听目录路径
const watchDir = process.argv[2]
if (!watchDir) {
  console.error('❌ 请提供要监听的目录,例如:node hmr-server.js ./public')
  process.exit(1)
}
let isFtp = process.argv[3]
if (!isFtp) {
  console.error('❌ 请确认是否上传ftp!请检查config文件是否配置正确。')
  process.exit(1)
}
isFtp = isFtp == 'true'

let isCreateDir = process.argv[4]
if (!isCreateDir) {
  console.error('❌ 请确认是否自动创建目录!请检查config文件是否配置正确。')
  process.exit(1)
}
isCreateDir = isCreateDir == 'true'

// 基础路径处理
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
const server = http.createServer(app)

// WebSocket 服务监听在 35729 端口
const wss = new WebSocketServer({ server })
const clients = new Set()

wss.on('connection', (ws) => {
  clients.add(ws)
  ws.on('close', () => clients.delete(ws))
})

async function broadcastReload() {
  for (const ws of clients) {
    await ws.send(JSON.stringify({ type: 'reload' }))
  }
}

// ✅ FTP 上传函数
async function uploadToFtp(localPath, relativePath) {
  const client = new ftp.Client()
  try {
    await client.access(config.ftp)
    const remotePath = path.posix.join(config.ftp.remoteRoot, relativePath.replace(/\\/g, '/'))
    const remoteDir = path.posix.dirname(remotePath)
    console.log('📤 上传文件:')
    console.log('  本地路径:', localPath)
    console.log('  相对路径:', relativePath)
    console.log('  远程上传路径:', config.ftp.remoteRoot)
    console.log('  远程拼接路径:', remotePath)

    // 自动创建远程目录(递归)
    if(isCreateDir){
      await client.ensureDir(remoteDir)
      console.log('✅ 已经自动创建远端目录',remoteDir)
    }else{
      console.log('⛔ 跳过自动创建目录,可能会FTP 550错误, isCreateDir=',isCreateDir)
    }

    await client.uploadFrom(localPath, remotePath)
    console.log(`📤 已上传到 FTP : ${remotePath}`)
  } catch (err) {
    console.error('❌ FTP 上传失败:', err.message)
  }
  client.close()
}

// 监听文件变更
chokidar.watch(watchDir, {
  ignoreInitial: true,
  ignored: (filePath) => {
    return config.watch.ignorePatterns.some(pattern => {
      if (typeof pattern === 'string') {
        return filePath.endsWith(pattern)
      } else if (pattern instanceof RegExp) {
        return pattern.test(filePath)
      }
      return false
    })
  }
}).on('all', async (event, fullPath) => {
  console.log(`-------------------------------------`)
  console.log(`[HMR] 文件变更 :${event} → ${fullPath}`)

  if (event === 'add' || event === 'change') {
    const relativePath = pathLib.relative(watchDir, fullPath)

    const tasks = []

    if (isFtp) {
      tasks.push(
        uploadToFtp(fullPath, relativePath).catch(err => {
          console.error('❌ FTP 上传失败:', err.message)
        })
      )
    } else {
      console.log('⛔ 跳过 FTP上传 isFtp =', isFtp)
    }

    tasks.push(
      Promise.resolve().then(() => {
        broadcastReload()
        console.log('✅ 本机开发网址刷新!')
      })
    )

    await Promise.allSettled(tasks)
  }
})

// 提供客户端 JS
app.get('/auto-reload.js', (req, res) => {
  res.setHeader('Content-Type', 'application/javascript')
  res.end(`
    const socket = new WebSocket('ws://' + location.hostname + ':35729')
    socket.addEventListener('message', (e) => {
      const msg = JSON.parse(e.data)
      if (msg.type === 'reload') {
        console.log('[HMR] Reloading page...')
        location.reload()
      }
    })
  `)
})

// 启动服务
server.listen(35729, () => {
  console.log(`✅ HMR 服务已启动,监听目录:${watchDir}`)
  console.log(`📡 WebSocket 地址:ws://localhost:35729`)
  console.log(`🔒 参数设置 是否上传${isFtp} 是否自动创建目录${isCreateDir}`)
})

有了这个东西,你就可以直接通过FTP,让本地代码和服务器代码实现同步。

xHMRServer:服务器监听与HMR

首先它由两个文件组成,分别运用了不同的思想

  • hmr.js 热更新前端核心文件
js 复制代码
(function () {
  // 记录状态标志
  let isRecording = false;
  let recordedAssets = null;
  // 轮询对象
  let checkTimer = null;
  // 记录当前已加载的 JS 和 CSS 文件信息
  function recordLoadedAssets() {
    const assets = {
      js: [],
      css: [],
      timestamp: new Date().getTime()
    };

    // 记录所有加载的 JS 文件
    const scriptTags = document.querySelectorAll('script[src]');
    scriptTags.forEach(script => {
      const url = new URL(script.src, location.origin);
      assets.js.push({
        src: url.pathname // 只保留路径部分
      });
    });

    // 记录所有加载的 CSS 文件
    const linkTags = document.querySelectorAll('link[rel="stylesheet"]');
    linkTags.forEach(link => {
      const url = new URL(link.href, location.origin);
      assets.css.push({
        href: url.pathname // 只保留路径部分
      });
    });

    return assets;
  }

  // 开始记录资源
  function Recording() {
    if (isRecording) {
      showNotification('已经有记录存在请先清除 (刷新页面即可)', 'warning');
      return;
    }

    isRecording = true;
    recordedAssets = recordLoadedAssets();

    // 存储到全局变量,便于后续访问
    window._recordedAssets = recordedAssets;

    console.log('资源已保存:', recordedAssets);
    const jsCount = recordedAssets.js.length;
    const cssCount = recordedAssets.css.length;

    // 添加视觉提示
    showNotification(`资源已保存(JS: ${jsCount} | CSS: ${cssCount})(Ctrl+F8开启监听)`, 'info');
  }

  // 停止记录并显示差异
  function Watching() {
    if (!isRecording) {
      console.log('未开始记录,请先按 Ctrl+F7 开始记录');
      return;
    }

    // 开始轮询检测
    startCheckServerAssets();

    // 添加视觉提示
    showNotification('资源变化监听开启 (Ctrl+F9关闭)', 'success');
  }

  // 封装 xajax 请求函数
  async function handleXjax(url, data) {
    try {
      const res = await new Promise((resolve, reject) => {
        xAjax({ url, data: data }, function (res) {
          if (res !== undefined && res !== null) resolve(res);
        });
      });
      return res;
    } catch (err) {
      webix.message({ type: "error", text: "请求出错!" });
      return false;
    }
  }

  // 开启轮询
  function startCheckServerAssets() {
    if (checkTimer) return;
    checkTimer = setInterval(() => {
      (async () => {
        if (!recordedAssets) return;

        const paths = [
          ...recordedAssets.js.map(f => f.src),
          ...recordedAssets.css.map(f => f.href)
        ];

        try {
          // 调用 handleXjax
          const serverData = await handleXjax('ext/xHMRServer/requestFileInfo.php', { path: paths });
          console.log('serverData', serverData);
          if(serverData && serverData.code == 4001){
            stopWatching();
            window.location.reload();
          }
          if (!serverData) return;
          paths.forEach(p => {
            // 找到对应的记录对象
            const recorded = recordedAssets.js.find(f => f.src === p) || recordedAssets.css.find(f => f.href === p);
            const serverHash = serverData[p]?.hash;
            if (!serverHash) return;

            // 如果记录里没有 hash,第一次存入,不提示
            if (!recorded.hash) {
              recorded.hash = serverHash;
              return;
            }

            // 已有 hash,和服务端对比
            if (recorded.hash !== serverHash) {
              showNotification(`文件变化:${p}`, 'info');
              recorded.hash = serverHash; // 更新 hash
              // CSS 热更新
              if (recorded.href) {
                // 去掉 ?t=xxx 防止匹配失败
                const safePath = p.split('?')[0];

                // 用所有 <link> 遍历查找,而不是 querySelector
                const linkEl = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
                  .find(link => link.href.includes(safePath));

                if (!linkEl) return;

                // 创建一个新的 link
                const newLink = linkEl.cloneNode();
                newLink.href = `${safePath}?hmr=${Date.now()}`; // 用字符串区分时间戳

                newLink.onload = () => {
                  linkEl.parentNode.removeChild(linkEl);
                };

                // 插入 link,否则不会触发加载
                linkEl.parentNode.insertBefore(newLink, linkEl.nextSibling);
                showNotification(`CSS 热更新执行完毕`, 'success');
              }

              // JS 热更新 → 刷新页面
              if (recorded.src) {
                showNotification(`JS 文件变化,页面将刷新: ${p}`, 'warning');
                reloadDevPage();
              }

              // CSS 变化通知
              if (recorded.href) {
                showNotification(`CSS 文件已更新: ${p}`, 'info');
              }
            }
          });
        } catch (err) {
          console.error('检测资源变化出错', err);
        }
      })(); // 立即执行 async 函数表达式
    }, 3000);

  }

  // 停止轮询
  function stopWatching() {
    if (checkTimer) {
      clearInterval(checkTimer);
      checkTimer = null;
      // 添加视觉提示
      showNotification('已停止资源变化检测', 'success');
    }
  }


  // 显示简单通知
  function showNotification(message, type = 'info') {
    // 检查是否已经存在通知元素
    let notification = document.getElementById('assets-recording-notification');

    if (!notification) {
      // 创建新的通知元素
      notification = document.createElement('div');
      notification.id = 'assets-recording-notification';
      notification.style.position = 'fixed';
      notification.style.top = '20px';
      notification.style.right = '20px';
      notification.style.padding = '12px 20px';
      notification.style.borderRadius = '4px';
      notification.style.color = 'white';
      notification.style.fontWeight = 'bold';
      notification.style.zIndex = '9999';
      notification.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)';
      notification.style.transition = 'opacity 0.3s ease';
      document.body.appendChild(notification);
    }

    // 设置通知样式和内容
    if (type === 'info') {
      notification.style.backgroundColor = '#3498db';
    } else if (type === 'success') {
      notification.style.backgroundColor = '#2ecc71';
    } else if (type === 'warning') {
      notification.style.backgroundColor = '#f39c12';
    }

    notification.textContent = message;
    notification.style.opacity = '1';

    // 3秒后自动隐藏
    setTimeout(() => {
      notification.style.opacity = '0';
      setTimeout(() => {
        if (notification.parentNode) {
          notification.parentNode.removeChild(notification);
        }
      }, 300);
    }, 4000);
  }

  function reloadDevPage() {
    let tabId = $$("tabbarX").getValue()
    let tab = tabId.split("^")
    if (tab[0] == "XHome") {
      loadHomepageX()
    } else if (tab[0] == "XDashboardView") {
      loadDashboard($$("XDashboardView").config.rID, true)
    } else if (tab[0] == "diyDashboard") {
      diyMyDashboard();
    } else if (tab[0] == "XBigScreenView") {
      let bigScreen = {
        "view": "iframe",
        "id": "XBigScreenView",
        "src": $$("XBigScreenView").config.src
      }
      webix.ui(bigScreen, $$("XBigScreenView"))
    } else if (tab[0] == "edit") {
      webixEdit(tab[1], $$(tab[0] + "^" + tab[1]).config.cfg.edit_rid)
    } else if ($$("report^" + tab[0]) && $$("report^" + tab[0]).isVisible()) {
      addReport(tab[0], tab[0])
    } else {
      addList(tab[0], tab[0])
    }
  }

  // 快捷键
  window.addEventListener('keydown', e => {
    // Ctrl + F6 重新加载当前页面
    if (e.ctrlKey && e.key === 'F6') {
      reloadDevPage();
      e.preventDefault();
    }
    // Ctrl + F7 记录资源
    if (e.ctrlKey && e.key === 'F7') {
      Recording();
      e.preventDefault();
    }
    // Ctrl + F8 开始监听记录资源
    else if (e.ctrlKey && e.key === 'F8') {
      Watching();
      e.preventDefault();
    }
    // Ctrl + F9 停止监听记录资源
    else if (e.ctrlKey && e.key === 'F9') {
      stopWatching();
      e.preventDefault();
    }
  });
})();

由于公司的生产环境不允许开websocket,所以只能使用轮询去判断在服务器端的源文件有没有进行更改,如果有则进行操作,对于js来说,需要刷新页面,对于css来说,可以直接更改,让样式重新计算(相当于你控制台手动改),那么对于后端php来说,根本不用管,因为我这是前后端不分离,每次请求都会调新的文件。

  • requestFileInfo.php 去查询服务器中,我监听的文件有没有改动
php 复制代码
<?php
/**
 * Created by Vscode.
 * @desc : requestFileInfo 获取文件的信息
 * @date : 2025-10-23
 */
define('ROOT_PATH', __DIR__ . '/../../');
include_once(ROOT_PATH . '/Database.php');

if (empty(CURRENT_USER)) exit;

$judge = true;

if (strpos($_SESSION['userid'], 'admin') === false) {
    echo json_encode([
        'code' => 4001,
        'msg' => '权限不足'
    ], JSON_UNESCAPED_UNICODE);
    $judge = false;
}

// ✅校验输入
if (empty($_POST['path'])) {
    echo json_encode([
        'code' => 400,
        'msg' => '缺少参数'
    ], JSON_UNESCAPED_UNICODE);
    $judge = false;
}

$paths = json_decode($_POST['path'], true);
if (!is_array($paths)) {
    echo json_encode([
        'code' => 400,
        'msg' => '参数错误'
    ], JSON_UNESCAPED_UNICODE);
    $judge = false;
}
if($judge){
    $result = [];
    // 服务器本地文件根路径(你可以改成实际项目目录)
    $basePath = ''; // 或你项目实际路径

    foreach ($paths as $path) {
        
        $filePath = realpath($basePath . $path);

        if (!$filePath || !file_exists($filePath)) {
            $result[$path] = [
                'exists' => false,
            ];
            continue;
        }

        $content = file_get_contents($filePath);
        $result[$path] = [
            'exists'   => true,
            'filename' => basename($filePath),
            'size'     => strlen($content),
            'hash'     => md5($content),
            'mtime'    => filemtime($filePath),
        ];
    }

    echo json_encode($result, JSON_UNESCAPED_UNICODE);
}

心得分享

做这个东西,其实只是我的练手,在工作之余去写有提升自己能力的事情,这个流程中还有很多问题,很多细节需要去探讨,去深究,比如说我要上git怎么办,我需要打包cicd怎么办,这些都要写。

但对于我现在的实际开发来说,这些就已经够用了,能够提升我10%-20%的开发速度,把更多的时间转移到开发思路上,而不是一些重复性的工作。

这个分享只在于我解决问题思路的分享,而不是适用于您实际的开发情况,任何的代码,没有100%的复用,唯有思路,有趋近于100%的借鉴。

相关推荐
云中雾丽5 小时前
React.forwardRef 实战代码示例
前端
Moonbit5 小时前
倒计时 2 天|Meetup 议题已公开,Copilot 月卡等你来拿!
前端·后端
Glink5 小时前
现在开始将Github作为数据库
前端·算法·github
小仙女喂得猪5 小时前
2025 跨平台方案KMP,Flutter,RN之间的一些对比
android·前端·kotlin
举个栗子dhy5 小时前
第二章、全局配置项目主题色(主题切换+跟随系统)
前端·javascript·react.js
sorryhc6 小时前
开源的SSR框架都是怎么实现的?
前端·javascript·架构
前端架构师-老李6 小时前
npm、yarn、pnpm的对比和优略
前端·npm·node.js·pnpm·yarn
fox_6 小时前
别再混淆 call/apply/bind 了!一篇讲透用法、场景与手写逻辑(二)
前端·javascript
潜心编码6 小时前
基于vue的停车场管理系统
前端·javascript·vue.js