前言
各位掘金上的大佬们你们好,我是一个工作了一年的大学生,第二次在掘金上发文章。
距离上一篇文章已经有一年半了,工作过程中其实没什么好发的,都是些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热更新组件。
技术方案
路径图

疑惑说明
-
为什么线上服务器更新了文件就能HMR,这违反开发的直觉,线上不应该是已经打包后的静态文件吗?
因为他是14年的项目,那个时候"打包"、"CICD"、"docker"都还没有那么的成熟,所以大多数的时候都是直接手动部署,直接调库。
-
为什么要去线上服务器HMR,本地开一个服务不行吗?
因为这个项目是低代码,项目团队的代码是一套,核心代码随时都会变,如果在本地部署的话,必须要及时更新到本地来。
你会问:难道没有git吗?巧了,我在这里来了一年,真没有git,有git的存在但是系统的整个git要求很不规范,主要还是leader自己写核心代码,他一个人写,还需要git吗,只需要到处发包就是了,他只需要确保自己电脑上的版本是最新的就好了。
所以我想了一下,既然leader会到处发包,那我不如找一个测试服务器作为我自己的环境,我把我的开发代码放到服务器上,leader要发包,那么过到我测试服务器的包一定是最新的,因为发包我不用关心。
那么同时,我不仅不需要部署一套php环境,我连pg数据库都不用部署,都在云上,我只需要在本地写代码就行了,现在我拿一台win7只要我能写代码,有ftp,能上网,我都可以写。
-
原来有什么问题,会让你要写这个东西?
我是一个熟悉构建工具开发前端的新人大学生程序员,我最熟悉的,就是以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%的借鉴。