[mini-next.js] 热重载:开发体验优化
概述
热重载(Hot Reload)是现代前端开发工具的核心功能之一。它允许开发者在修改代码后,浏览器自动刷新页面,无需手动刷新。本文将深入分析并实现热重载功能。
核心概念
什么是热重载?
传统开发流程:
1. 修改代码
2. 手动刷新浏览器
3. 查看效果
热重载流程:
1. 修改代码
2. 文件监听器检测到变化
3. 自动重新加载
4. 浏览器自动刷新(或通过 WebSocket 通知)
热重载的优势
- 提高开发效率:无需手动刷新
- 保持状态:某些情况下可以保持应用状态
- 即时反馈:立即看到代码变化的效果
功能分析
1. 文件系统监听
职责:
- 监听 pages 和 app 目录的变化
- 检测文件创建、修改、删除
技术选择:
- 使用 chokidar 库(跨平台文件监听)
2. 模块缓存管理
职责: - 缓存已加载的模块 - 文件变化时清除相关缓存 - 重新加载模块
3. 路由重新扫描
职责: - 文件变化时重新扫描路由 - 更新路由映射表
4. 浏览器通知(简化版)
职责: - 通知浏览器刷新(简化实现:仅日志输出) - 真实 Next.js 使用 WebSocket
逐步实现
步骤 1:实现模块加载器
// src/utils/module-loader.js
export class ModuleLoader {
constructor() {
this.cache = new Map();
this.watchFiles = new Set();
}
async loadModule(filePath) {
// 检查缓存
if (this.cache.has(filePath)) {
return this.cache.get(filePath);
}
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
throw new Error(`Module not found: ${filePath}`);
}
try {
// 动态导入模块
const module = await import(`file://${filePath}`);
// 获取默认导出或命名导出
const component = module.default || module;
// 缓存模块
this.cache.set(filePath, component);
this.watchFiles.add(filePath);
return component;
} catch (error) {
console.error(`Error loading module ${filePath}:`, error);
throw error;
}
}
clearCache(filePath = null) {
if (filePath) {
this.cache.delete(filePath);
this.watchFiles.delete(filePath);
} else {
this.cache.clear();
this.watchFiles.clear();
}
}
getWatchedFiles() {
return Array.from(this.watchFiles);
}
}
关键点:
- 使用 Map 缓存模块
- 动态导入使用 file:// 协议
- 支持清除单个或全部缓存
步骤 2:实现文件监听器
// src/server/dev-server.js
import chokidar from 'chokidar';
_startWatcher() {
const watchDirs = [];
if (this.useAppRouter && fs.existsSync(this.appDir)) {
watchDirs.push(this.appDir);
}
if (this.usePagesRouter && fs.existsSync(this.pagesDir)) {
watchDirs.push(this.pagesDir);
}
if (watchDirs.length === 0) {
return;
}
this.watcher = chokidar.watch(watchDirs, {
ignored: /node_modules/,
persistent: true,
});
this.watcher.on('change', (filePath) => {
console.log(`\n🔄 文件变化: ${filePath}`);
// 清除相关模块缓存
this.moduleLoader.clearCache(filePath);
// 重新扫描路由
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 已重新加载\n`);
});
this.watcher.on('add', (filePath) => {
console.log(`\n➕ 新增文件: ${filePath}`);
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 路由已更新\n`);
});
this.watcher.on('unlink', (filePath) => {
console.log(`\n➖ 删除文件: ${filePath}`);
this.moduleLoader.clearCache(filePath);
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 路由已更新\n`);
});
}
关键点:
- 监听多个目录(app 和 pages)
- 忽略 node_modules
- 处理三种事件:change、add、unlink
- 清除缓存并重新扫描路由
步骤 3:在服务器启动时启动监听
async start() {
// ... 其他初始化代码 ...
// 启动文件监听
this._startWatcher();
// ... 启动 HTTP 服务器 ...
}
完整实现
模块加载器
// src/utils/module-loader.js
import fs from 'fs';
export class ModuleLoader {
constructor() {
this.cache = new Map();
this.watchFiles = new Set();
}
async loadModule(filePath) {
if (this.cache.has(filePath)) {
return this.cache.get(filePath);
}
if (!fs.existsSync(filePath)) {
throw new Error(`Module not found: ${filePath}`);
}
try {
const module = await import(`file://${filePath}`);
const component = module.default || module;
this.cache.set(filePath, component);
this.watchFiles.add(filePath);
return component;
} catch (error) {
console.error(`Error loading module ${filePath}:`, error);
throw error;
}
}
clearCache(filePath = null) {
if (filePath) {
this.cache.delete(filePath);
this.watchFiles.delete(filePath);
} else {
this.cache.clear();
this.watchFiles.clear();
}
}
getWatchedFiles() {
return Array.from(this.watchFiles);
}
}
文件监听器集成
// src/server/dev-server.js
import chokidar from 'chokidar';
export class DevServer {
// ... 其他代码 ...
async start() {
// ... 初始化代码 ...
// 启动文件监听
this._startWatcher();
// ... 启动服务器 ...
}
_startWatcher() {
const watchDirs = [];
if (this.useAppRouter && fs.existsSync(this.appDir)) {
watchDirs.push(this.appDir);
}
if (this.usePagesRouter && fs.existsSync(this.pagesDir)) {
watchDirs.push(this.pagesDir);
}
if (watchDirs.length === 0) {
return;
}
this.watcher = chokidar.watch(watchDirs, {
ignored: /node_modules/,
persistent: true,
});
this.watcher.on('change', (filePath) => {
console.log(`\n🔄 文件变化: ${filePath}`);
this.moduleLoader.clearCache(filePath);
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 已重新加载\n`);
});
this.watcher.on('add', (filePath) => {
console.log(`\n➕ 新增文件: ${filePath}`);
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 路由已更新\n`);
});
this.watcher.on('unlink', (filePath) => {
console.log(`\n➖ 删除文件: ${filePath}`);
this.moduleLoader.clearCache(filePath);
if (this.useAppRouter && filePath.startsWith(this.appDir)) {
this.appRouter.scanRoutes();
}
if (this.usePagesRouter && filePath.startsWith(this.pagesDir)) {
this.pagesRouter.scanRoutes();
}
console.log(`✅ 路由已更新\n`);
});
}
stop() {
if (this.watcher) {
this.watcher.close();
}
if (this.server) {
this.server.close();
}
}
}
工作流程
文件修改流程
1. 开发者修改 pages/about.js
↓
2. chokidar 检测到文件变化
↓
3. 触发 'change' 事件
↓
4. 清除模块缓存(about.js)
↓
5. 重新扫描路由(如果有结构变化)
↓
6. 下次请求时重新加载模块
新增文件流程
1. 开发者创建 pages/new-page.js
↓
2. chokidar 检测到新文件
↓
3. 触发 'add' 事件
↓
4. 重新扫描路由
↓
5. 新路由立即可用
删除文件流程
1. 开发者删除 pages/old-page.js
↓
2. chokidar 检测到文件删除
↓
3. 触发 'unlink' 事件
↓
4. 清除模块缓存
↓
5. 重新扫描路由
↓
6. 路由从映射表中移除
与真实 Next.js 的差异
已实现
- ✅ 文件系统监听
- ✅ 模块缓存清除
- ✅ 路由重新扫描
未实现(简化)
- ❌ WebSocket 通知:真实 Next.js 通过 WebSocket 通知浏览器刷新
- ❌ 增量更新:只更新变化的部分
- ❌ 状态保持:某些情况下保持应用状态
- ❌ 错误恢复:自动恢复编译错误
使用示例
启动开发服务器
npm run dev
修改文件
// pages/about.js
export default function About() {
return <h1>About Page - Updated!</h1>;
}
控制台输出:
🔄 文件变化: /path/to/pages/about.js
✅ 已重新加载
刷新浏览器
访问页面时,会使用新加载的模块,显示更新后的内容。
总结
热重载的核心是文件监听和模块缓存管理:
- 文件监听:使用
chokidar监听文件系统变化 - 缓存清除:文件变化时清除相关模块缓存
- 路由更新:重新扫描路由以反映文件结构变化
- 自动重载:下次请求时使用新代码
这个实现虽然简化,但包含了热重载的核心逻辑。在实际开发中,开发者修改代码后,服务器会自动检测变化并重新加载,大大提高了开发效率。
相关文件:
- src/utils/module-loader.js - 模块加载器实现
- src/server/dev-server.js - 文件监听器集成
- package.json - chokidar 依赖