V

Vite HMR 热更新流程可视化

模拟浏览器原生 ESM 环境下模块热替换的完整链路

WebSocket 已连接
HMR 模式 React Fast Refresh
ws://localhost:5173
选择要修改的文件:
🌸 模块依赖树 7 个模块
⚡ HMR 热替换流程 等待触发
1
文件变更检测
chokidar 监听文件系统变更
等待文件变更...
2
重新编译模块
ESBuild 按需编译变更模块
等待编译...
3
更新模块图
更新服务端模块依赖图
等待更新...
4
WebSocket 推送
发送 HMR payload 到浏览器
等待推送...
5
HMR 冒泡 & 处理
沿依赖链查找 accept 处理
等待处理...
📄 模块源码对比 更新前 / 更新后
📄 更新前(旧模块)
// 等待触发更新...
📄 更新后(新模块)
// 等待触发更新...
📊 HMR 统计 实时
0
HMR 更新次数
0
热替换成功
0
全量刷新
0ms
平均耗时
📖 HMR 日志 0 条
--:--:-- INFO Vite HMR 可视化演示已启动,点击「模拟修改文件」触发热更新
`, after: ` ` }, utils: { name: 'utils.ts', icon: 'ts', before: `// 原模块内容 export function formatDate(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return y + '-' + m + '-' + d } export function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1) }`, after: `// 更新后模块内容 export function formatDate(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return y + '-' + m + '-' + d } export function formatTime(date: Date): string { const h = String(date.getHours()).padStart(2, '0') const min = String(date.getMinutes()).padStart(2, '0') return h + ':' + min } export function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1) } // 新增 formatTime 函数 // 注意:非组件模块变更 → 依赖它的模块会全量刷新` }, styles: { name: 'styles.css', icon: 'css', before: `/* 原模块内容 */ :root { --primary: #646cff; --bg: #ffffff; --text: #213547; } body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 20px; }`, after: `/* 更新后模块内容(CSS HMR) */ :root { --primary: #646cff; --primary-hover: #535bf2; --bg: #ffffff; --bg-secondary: #f8fafc; --text: #213547; --text-secondary: #64748b; } body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 20px; line-height: 1.6; } /* CSS HMR 不触发 JS 模块更新,直接替换样式 */ .card { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }` } } // ==================== Module Tree ==================== const moduleTreeData = { id: 'root', name: 'App.tsx (入口)', icon: 'root', hasAccept: true, children: [ { id: 'pages', name: 'Home.tsx', icon: 'react', hasAccept: true, children: [ { id: 'header', name: 'Header.tsx', icon: 'react', hasAccept: false, children: [ { id: 'button', name: 'Button.tsx', icon: 'react', hasAccept: true, children: [] } ] }, { id: 'counter', name: 'Counter.vue', icon: 'vue', hasAccept: true, children: [] } ] }, { id: 'utils', name: 'utils.ts', icon: 'ts', hasAccept: false, children: [] }, { id: 'styles', name: 'styles.css', icon: 'css', hasAccept: true, children: [] } ] } let selectedFile = 'button' let isUpdating = false let autoSimInterval = null let stats = { updates: 0, accepted: 0, reloads: 0, totalTime: 0 } let logId = 1 // ==================== Render ==================== function renderModuleTree(nodes, container, depth = 0) { nodes.forEach(node => { const li = document.createElement('li') const hasChildren = node.children && node.children.length > 0 const nodeDiv = document.createElement('div') nodeDiv.className = 'module-node' nodeDiv.id = 'node-' + node.id nodeDiv.dataset.id = node.id const icon = document.createElement('span') icon.className = 'node-icon ' + node.icon icon.textContent = node.icon === 'root' ? 'A' : node.icon === 'react' ? 'R' : node.icon === 'vue' ? 'V' : node.icon === 'ts' ? 'TS' : node.icon === 'css' ? '#' : 'JS' const name = document.createElement('span') name.className = 'node-name' name.textContent = node.name const status = document.createElement('span') status.className = 'node-status idle' status.id = 'status-' + node.id status.textContent = '就绪' nodeDiv.appendChild(icon) nodeDiv.appendChild(name) nodeDiv.appendChild(status) li.appendChild(nodeDiv) if (hasChildren) { const childrenUl = document.createElement('ul') childrenUl.className = 'tree-children' renderModuleTree(node.children, childrenUl, depth + 1) li.appendChild(childrenUl) } container.appendChild(li) }) } function renderTree() { const container = document.getElementById('moduleTree') container.innerHTML = '' renderModuleTree([moduleTreeData], container) } renderTree() updateModuleCount() // ==================== HMR Simulation ==================== function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } function now() { const d = new Date() return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0') } function addLog(level, msg) { const container = document.getElementById('logContainer') const entry = document.createElement('div') entry.className = 'log-entry' entry.innerHTML = '' + now() + '' + level.toUpperCase() + '' + msg + '' container.appendChild(entry) container.scrollTop = container.scrollHeight document.getElementById('logCount').textContent = container.querySelectorAll('.log-entry').length + ' 条' } function showToast(type, msg) { const container = document.getElementById('toastContainer') const toast = document.createElement('div') toast.className = 'toast ' + type toast.textContent = msg container.appendChild(toast) setTimeout(() => { toast.style.opacity = '0' toast.style.transform = 'translateX(20px)' toast.style.transition = 'all 0.3s' setTimeout(() => toast.remove(), 300) }, 3000) } function updateModuleCount() { const count = document.querySelectorAll('.module-node').length document.getElementById('moduleCount').textContent = count + ' 个模块' } function updateStats(type) { stats.updates++ if (type === 'accept') stats.accepted++ if (type === 'reload') stats.reloads++ document.getElementById('statUpdates').textContent = stats.updates document.getElementById('statAccepted').textContent = stats.accepted document.getElementById('statReloads').textContent = stats.reloads } function resetModuleStatuses() { document.querySelectorAll('.module-node').forEach(el => { const status = el.querySelector('.node-status') status.className = 'node-status idle' status.textContent = '就绪' el.classList.remove('highlight-update', 'highlight-accept', 'highlight-reload') }) } function setModuleStatus(id, statusClass, label) { const node = document.getElementById('node-' + id) if (!node) return const status = node.querySelector('.node-status') status.className = 'node-status ' + statusClass status.textContent = label node.classList.remove('highlight-update', 'highlight-accept', 'highlight-reload') if (statusClass === 'updating') node.classList.add('highlight-update') if (statusClass === 'accepted') node.classList.add('highlight-accept') if (statusClass === 'reload') node.classList.add('highlight-reload') } function getAffectedModules(fileId) { // 模拟 HMR 依赖链 const affectedMap = { button: ['button', 'header', 'pages', 'root'], header: ['header', 'pages', 'root'], counter: ['counter', 'pages', 'root'], utils: ['utils', 'root'], styles: ['styles'] } return affectedMap[fileId] || [fileId] } function getHmrResult(fileId) { // 模拟 HMR accept 结果 const resultMap = { button: { type: 'accept', msg: 'Button.tsx 自身有 accept() → 热替换成功,状态保留' }, header: { type: 'accept', msg: 'Header.tsx 无 accept() → HMR 冒泡到 Home.tsx → Home 有 accept() → 热替换' }, counter: { type: 'accept', msg: 'Counter.vue 内置 Vue HMR → 模板/脚本热替换,组件状态保留' }, utils: { type: 'reload', msg: 'utils.ts 无 accept() → 冒泡到根模块 → 无处理 → 执行全量刷新' }, styles: { type: 'accept', msg: 'styles.css 变更 → CSS HMR 通道 → 仅替换样式,不更新 JS' } } return resultMap[fileId] } function selectFile(btn, fileId) { if (isUpdating) return document.querySelectorAll('.file-btn').forEach(b => b.classList.remove('selected')) btn.classList.add('selected') selectedFile = fileId // 更新预览 const fileData = FILE_SOURCES[fileId] document.getElementById('beforeCode').textContent = fileData.before document.getElementById('afterCode').textContent = fileData.after } async function simulateUpdate() { if (isUpdating) return isUpdating = true document.getElementById('btnSimulate').disabled = true const fileData = FILE_SOURCES[selectedFile] const affected = getAffectedModules(selectedFile) const result = getHmrResult(selectedFile) // Update preview document.getElementById('beforeCode').textContent = fileData.before document.getElementById('afterCode').textContent = fileData.after // Reset states resetModuleStatuses() document.getElementById('flowStatus').textContent = '进行中...' document.querySelectorAll('.flow-step').forEach(s => { s.classList.remove('active', 'done', 'error') }) const startTime = performance.now() // Step 1: File change detected document.querySelector('[data-step="1"]').classList.add('active') document.getElementById('step1Detail').textContent = '检测到文件变更: ' + fileData.name setModuleStatus(selectedFile, 'updating', '变更中') addLog('info', '检测到文件变更: ' + fileData.name + '') await sleep(400) // Step 2: Recompile document.querySelector('[data-step="1"]').classList.remove('active') document.querySelector('[data-step="1"]').classList.add('done') document.querySelector('[data-step="2"]').classList.add('active') document.getElementById('step2Detail').textContent = 'ESBuild 编译 ' + fileData.name + ' (' + (fileData.after.length - fileData.before.length > 0 ? '+' : '') + (fileData.after.length - fileData.before.length) + ' 字符)' addLog('hmr', 'ESBuild 编译: ' + fileData.name + ' 完成') await sleep(500) // Step 3: Update module graph document.querySelector('[data-step="2"]').classList.remove('active') document.querySelector('[data-step="2"]').classList.add('done') document.querySelector('[data-step="3"]').classList.add('active') document.getElementById('step3Detail').textContent = '更新模块图,受影响模块: ' + affected.join(', ') addLog('info', '更新模块图, 受影响模块: ' + affected.join(', ') + '') await sleep(400) // Step 4: WebSocket push document.querySelector('[data-step="3"]').classList.remove('active') document.querySelector('[data-step="3"]').classList.add('done') document.querySelector('[data-step="4"]').classList.add('active') document.getElementById('step4Detail').textContent = '发送 HMR payload: { type: "update", path: "/src/' + fileData.name + '", timestamp: ' + Date.now() + ' }' document.getElementById('wsStatusDot').className = 'status-dot updating' document.getElementById('wsStatusText').textContent = '推送中...' addLog('hmr', 'WebSocket 推送 HMR payload → 浏览器') await sleep(500) // Step 5: HMR bubble & handle document.querySelector('[data-step="4"]').classList.remove('active') document.querySelector('[data-step="4"]').classList.add('done') document.querySelector('[data-step="5"]').classList.add('active') document.getElementById('step5Detail').textContent = result.msg document.getElementById('wsStatusDot').className = 'status-dot connected' document.getElementById('wsStatusText').textContent = '已连接' // Highlight affected modules const acceptModules = affected.filter(a => a !== selectedFile) for (let i = 0; i < acceptModules.length; i++) { setModuleStatus(acceptModules[i], 'updating', '冒泡中') await sleep(150) } await sleep(300) // Final result const endTime = performance.now() const elapsed = Math.round(endTime - startTime) stats.totalTime = Math.round((stats.totalTime * (stats.updates) + elapsed) / (stats.updates + 1)) if (result.type === 'accept') { document.querySelector('[data-step="5"]').classList.remove('active') document.querySelector('[data-step="5"]').classList.add('done') document.getElementById('flowStatus').textContent = '热替换成功!' document.getElementById('flowStatus').style.color = '#22c55e' // Set all affected to accepted affected.forEach(id => { setModuleStatus(id, 'accepted', '已替换') }) setModuleStatus(selectedFile, 'accepted', '已替换') updateStats('accept') addLog('success', 'HMR 热替换成功! 耗时 ' + elapsed + 'ms') showToast('success', 'HMR 热替换成功! (' + elapsed + 'ms)') document.getElementById('hmrMode').textContent = 'React Fast Refresh' } else { document.querySelector('[data-step="5"]').classList.remove('active') document.querySelector('[data-step="5"]').classList.add('error') document.getElementById('flowStatus').textContent = '全量刷新!' document.getElementById('flowStatus').style.color = '#ef4444' affected.forEach(id => { setModuleStatus(id, 'reload', '刷新') }) updateStats('reload') addLog('warn', 'HMR 冒泡到根模块未处理 → 执行 全量页面刷新') showToast('warning', '全量页面刷新! (耗时 ' + elapsed + 'ms)') document.getElementById('hmrMode').textContent = 'Full Reload' } document.getElementById('statTime').textContent = stats.totalTime + 'ms' document.getElementById('btnSimulate').disabled = false isUpdating = false } function simulateFullReload() { if (isUpdating) return resetModuleStatuses() document.getElementById('flowStatus').textContent = '全量刷新!' document.getElementById('flowStatus').style.color = '#ef4444' document.getElementById('hmrMode').textContent = 'Full Reload' document.querySelectorAll('.flow-step').forEach(s => { s.classList.remove('active', 'done', 'error') }) document.querySelector('[data-step="1"]').classList.add('done') document.querySelector('[data-step="2"]').classList.add('done') document.querySelector('[data-step="3"]').classList.add('done') document.querySelector('[data-step="4"]').classList.add('done') document.querySelector('[data-step="5"]').classList.add('error') document.getElementById('step5Detail').textContent = '未找到 accept 处理器 → location.reload()' document.querySelectorAll('.module-node').forEach(el => { const status = el.querySelector('.node-status') status.className = 'node-status reload' status.textContent = '刷新' el.classList.add('highlight-reload') }) updateStats('reload') addLog('error', '手动触发 全量页面刷新') showToast('error', '全量页面刷新!') } function toggleAutoSimulate() { const btn = document.getElementById('btnAutoSim') if (autoSimInterval) { clearInterval(autoSimInterval) autoSimInterval = null btn.innerHTML = ' 自动播放' btn.className = 'btn btn-success' document.getElementById('btnSimulate').disabled = false } else { document.getElementById('btnSimulate').disabled = true btn.innerHTML = ' 停止' btn.className = 'btn btn-danger' const files = ['button', 'header', 'counter', 'utils', 'styles'] let idx = 0 autoSimInterval = setInterval(() => { const fileBtn = document.querySelector('[data-file="' + files[idx % files.length] + '"]') if (fileBtn) { selectFile(fileBtn, files[idx % files.length]) simulateUpdate() } idx++ }, 3000) } } function clearLogs() { const container = document.getElementById('logContainer') container.innerHTML = '' const entry = document.createElement('div') entry.className = 'log-entry' entry.innerHTML = '--:--:--INFO日志已清除' container.appendChild(entry) document.getElementById('logCount').textContent = '1 条' } // Init: show button code document.addEventListener('DOMContentLoaded', () => { const fileData = FILE_SOURCES[selectedFile] document.getElementById('beforeCode').textContent = fileData.before document.getElementById('afterCode').textContent = fileData.after })