Oasis's Cloud

一个人的首要责任,就是要有雄心。雄心是一种高尚的激情,它可以采取多种合理的形式。
—— 《一个数学家的辩白》

[mini-next.js] 热重载:开发体验优化

作者:oasis


概述

热重载(Hot Reload)是现代前端开发工具的核心功能之一。它允许开发者在修改代码后,浏览器自动刷新页面,无需手动刷新。本文将深入分析并实现热重载功能。

核心概念

什么是热重载?

传统开发流程

1. 修改代码
2. 手动刷新浏览器
3. 查看效果

热重载流程

1. 修改代码
2. 文件监听器检测到变化
3. 自动重新加载
4. 浏览器自动刷新(或通过 WebSocket 通知)

热重载的优势

  1. 提高开发效率:无需手动刷新
  2. 保持状态:某些情况下可以保持应用状态
  3. 即时反馈:立即看到代码变化的效果

功能分析

1. 文件系统监听

职责: - 监听 pagesapp 目录的变化 - 检测文件创建、修改、删除

技术选择: - 使用 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`);
  });
}

关键点: - 监听多个目录(apppages) - 忽略 node_modules - 处理三种事件:changeaddunlink - 清除缓存并重新扫描路由

步骤 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 的差异

已实现

未实现(简化)

使用示例

启动开发服务器

npm run dev

修改文件

// pages/about.js
export default function About() {
  return <h1>About Page - Updated!</h1>;
}

控制台输出

🔄 文件变化: /path/to/pages/about.js
✅ 已重新加载

刷新浏览器

访问页面时,会使用新加载的模块,显示更新后的内容。

总结

热重载的核心是文件监听和模块缓存管理

  1. 文件监听:使用 chokidar 监听文件系统变化
  2. 缓存清除:文件变化时清除相关模块缓存
  3. 路由更新:重新扫描路由以反映文件结构变化
  4. 自动重载:下次请求时使用新代码

这个实现虽然简化,但包含了热重载的核心逻辑。在实际开发中,开发者修改代码后,服务器会自动检测变化并重新加载,大大提高了开发效率。


相关文件: - src/utils/module-loader.js - 模块加载器实现 - src/server/dev-server.js - 文件监听器集成 - package.json - chokidar 依赖