Oasis's Cloud

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

[mini-next.js] 开发服务器:HTTP 服务器和请求处理

作者:oasis


概述

开发服务器是 Next.js 的核心组件,它负责启动 HTTP 服务器、处理客户端请求、匹配路由并渲染页面。本文将深入分析并实现开发服务器的核心功能。

核心功能分析

1. HTTP 服务器

职责: - 启动 HTTP 服务器 - 监听指定端口 - 处理客户端请求

技术选择: - 使用 Node.js 原生 http 模块 - 简单、轻量,无需额外依赖

2. 请求处理流程

HTTP 请求
    ↓
解析 URL
    ↓
路由匹配
    ↓
加载组件
    ↓
渲染 HTML
    ↓
返回响应

3. 路由处理

职责: - 根据 URL 路径匹配路由 - 处理静态路由和动态路由 - 提取查询参数和动态参数

4. 静态资源服务

职责: - 服务 public 目录下的静态文件 - 设置正确的 Content-Type - 处理 404 情况

5. API 路由处理

职责: - 处理 /api/* 路径 - 执行 API 处理函数 - 返回 JSON 响应

逐步实现

步骤 1:创建服务器类结构

import http from 'http';
import fs from 'fs';
import path from 'path';
import { FileSystemRouter } from '../router/file-system-router.js';
import { ModuleLoader } from '../utils/module-loader.js';
import { renderFullPage } from '../renderer/ssr-renderer.js';

export class DevServer {
  constructor(options = {}) {
    this.dir = options.dir || process.cwd();
    this.port = options.port || 3000;
    this.hostname = options.hostname || 'localhost';

    this.pagesDir = path.join(this.dir, 'pages');
    this.publicDir = path.join(this.dir, 'public');

    this.router = new FileSystemRouter(this.pagesDir);
    this.moduleLoader = new ModuleLoader();

    this.server = null;
  }
}

设计说明: - 接受配置选项(目录、端口、主机名) - 初始化路由系统和模块加载器 - 准备目录路径

步骤 2:实现服务器启动

async start() {
  console.log(`🚀 启动开发服务器...`);
  console.log(`📁 项目目录: ${this.dir}`);
  console.log(`📄 Pages 目录: ${this.pagesDir}`);

  // 扫描路由
  this.router.scanRoutes();
  console.log(`✅ 发现 ${this.router.routes.size} 个静态路由`);
  console.log(`✅ 发现 ${this.router.dynamicRoutes.size} 个动态路由`);

  // 创建 HTTP 服务器
  this.server = http.createServer((req, res) => {
    this.handleRequest(req, res).catch(err => {
      console.error('Request error:', err);
      this.sendError(res, 500, err.message);
    });
  });

  // 监听端口
  this.server.listen(this.port, this.hostname, () => {
    console.log(`\n✨ 服务器运行在 http://${this.hostname}:${this.port}\n`);
  });
}

关键点: - 启动前扫描路由 - 创建 HTTP 服务器,绑定请求处理函数 - 错误处理:捕获异步错误

步骤 3:实现请求处理入口

async handleRequest(req, res) {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const pathname = url.pathname;

  // 处理静态资源
  if (pathname.startsWith('/_next/static') || pathname.startsWith('/static')) {
    return this.serveStatic(req, res, pathname);
  }

  // 处理 API 路由
  if (pathname.startsWith('/api/')) {
    return this.handleApiRoute(req, res, pathname);
  }

  // 处理页面路由
  return this.handlePageRoute(req, res, pathname, url);
}

路由优先级: 1. 静态资源(/static/*) 2. API 路由(/api/*) 3. 页面路由(其他路径)

设计说明: - 使用 URL API 解析请求 URL - 分离不同类型的请求处理 - 清晰的优先级顺序

步骤 4:实现页面路由处理

async handlePageRoute(req, res, pathname, url) {
  // 匹配路由
  const route = this.router.matchRoute(pathname);

  if (!route) {
    return this.send404(res, pathname);
  }

  try {
    // 加载页面组件
    const Component = await this.moduleLoader.loadModule(route.filePath);

    // 准备 props(包含查询参数和动态路由参数)
    const props = {
      ...route.params,
      query: Object.fromEntries(url.searchParams),
    };

    // 渲染页面
    const html = renderFullPage(Component, props);

    // 发送响应
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(html);
  } catch (error) {
    console.error(`Error rendering ${pathname}:`, error);
    this.sendError(res, 500, error.message);
  }
}

处理流程: 1. 路由匹配:使用路由系统匹配 URL 2. 组件加载:动态加载页面组件 3. Props 准备:合并动态参数和查询参数 4. 渲染:服务端渲染组件为 HTML 5. 响应:返回 HTML 响应

关键点: - 异步处理(async/await) - 错误处理 - 设置正确的 Content-Type

步骤 5:实现 API 路由处理

async handleApiRoute(req, res, pathname) {
  // 将 /api/xxx 转换为 pages/api/xxx.js
  const apiPath = pathname.replace('/api/', '');

  // 尝试不同的文件扩展名
  const extensions = ['.js', '.jsx', '.ts', '.tsx'];
  let apiFilePath = null;

  for (const ext of extensions) {
    const filePath = path.join(this.pagesDir, 'api', `${apiPath}${ext}`);
    if (fs.existsSync(filePath)) {
      apiFilePath = filePath;
      break;
    }
  }

  if (!apiFilePath) {
    return this.send404(res, pathname);
  }

  try {
    const handler = await this.moduleLoader.loadModule(apiFilePath);

    // 执行 API 处理函数
    const handlerFn = typeof handler === 'function' ? handler : handler.default;

    if (typeof handlerFn !== 'function') {
      throw new Error('API route must export a default function');
    }

    const result = await handlerFn(req, res);

    if (!res.headersSent) {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(result || { success: true }));
    }
  } catch (error) {
    console.error(`Error handling API ${pathname}:`, error);
    if (!res.headersSent) {
      this.sendError(res, 500, error.message);
    }
  }
}

处理流程: 1. 路径转换/api/hellopages/api/hello.js 2. 文件查找:尝试多种扩展名 3. 加载处理函数:动态导入 API 模块 4. 执行处理:调用处理函数 5. 返回响应:JSON 格式

关键点: - 支持多种文件扩展名 - 检查响应是否已发送 - 错误处理

步骤 6:实现静态资源服务

serveStatic(req, res, pathname) {
  // 简化版:只处理 public 目录
  const filePath = path.join(this.publicDir, pathname.replace('/static/', ''));

  if (!fs.existsSync(filePath)) {
    return this.send404(res, pathname);
  }

  const ext = path.extname(filePath);
  const contentType = this.getContentType(ext);

  const content = fs.readFileSync(filePath);
  res.writeHead(200, { 'Content-Type': contentType });
  res.end(content);
}

getContentType(ext) {
  const types = {
    '.html': 'text/html',
    '.css': 'text/css',
    '.js': 'application/javascript',
    '.json': 'application/json',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml',
  };
  return types[ext] || 'application/octet-stream';
}

功能: - 从 public 目录读取文件 - 根据文件扩展名设置 Content-Type - 同步读取文件(简化实现)

步骤 7:实现错误处理

send404(res, pathname) {
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(`
    <!DOCTYPE html>
    <html>
    <head><title>404 - Page Not Found</title></head>
    <body>
      <h1>404 - Page Not Found</h1>
      <p>无法找到页面: ${pathname}</p>
    </body>
    </html>
  `);
}

sendError(res, statusCode, message) {
  res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(`
    <!DOCTYPE html>
    <html>
    <head><title>Error ${statusCode}</title></head>
    <body>
      <h1>Error ${statusCode}</h1>
      <pre>${message}</pre>
    </body>
    </html>
  `);
}

设计说明: - 返回友好的错误页面 - 显示错误信息 - 设置正确的状态码

完整实现

import http from 'http';
import fs from 'fs';
import path from 'path';
import { FileSystemRouter } from '../router/file-system-router.js';
import { ModuleLoader } from '../utils/module-loader.js';
import { renderFullPage } from '../renderer/ssr-renderer.js';

export class DevServer {
  constructor(options = {}) {
    this.dir = options.dir || process.cwd();
    this.port = options.port || 3000;
    this.hostname = options.hostname || 'localhost';

    this.pagesDir = path.join(this.dir, 'pages');
    this.publicDir = path.join(this.dir, 'public');

    this.router = new FileSystemRouter(this.pagesDir);
    this.moduleLoader = new ModuleLoader();

    this.server = null;
  }

  async start() {
    console.log(`🚀 启动开发服务器...`);
    console.log(`📁 项目目录: ${this.dir}`);
    console.log(`📄 Pages 目录: ${this.pagesDir}`);

    this.router.scanRoutes();
    console.log(`✅ 发现 ${this.router.routes.size} 个静态路由`);
    console.log(`✅ 发现 ${this.router.dynamicRoutes.size} 个动态路由`);

    this.server = http.createServer((req, res) => {
      this.handleRequest(req, res).catch(err => {
        console.error('Request error:', err);
        this.sendError(res, 500, err.message);
      });
    });

    this.server.listen(this.port, this.hostname, () => {
      console.log(`\n✨ 服务器运行在 http://${this.hostname}:${this.port}\n`);
    });
  }

  async handleRequest(req, res) {
    const url = new URL(req.url, `http://${req.headers.host}`);
    const pathname = url.pathname;

    if (pathname.startsWith('/_next/static') || pathname.startsWith('/static')) {
      return this.serveStatic(req, res, pathname);
    }

    if (pathname.startsWith('/api/')) {
      return this.handleApiRoute(req, res, pathname);
    }

    return this.handlePageRoute(req, res, pathname, url);
  }

  async handlePageRoute(req, res, pathname, url) {
    const route = this.router.matchRoute(pathname);

    if (!route) {
      return this.send404(res, pathname);
    }

    try {
      const Component = await this.moduleLoader.loadModule(route.filePath);
      const props = {
        ...route.params,
        query: Object.fromEntries(url.searchParams),
      };
      const html = renderFullPage(Component, props);

      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(html);
    } catch (error) {
      console.error(`Error rendering ${pathname}:`, error);
      this.sendError(res, 500, error.message);
    }
  }

  async handleApiRoute(req, res, pathname) {
    const apiPath = pathname.replace('/api/', '');
    const extensions = ['.js', '.jsx', '.ts', '.tsx'];
    let apiFilePath = null;

    for (const ext of extensions) {
      const filePath = path.join(this.pagesDir, 'api', `${apiPath}${ext}`);
      if (fs.existsSync(filePath)) {
        apiFilePath = filePath;
        break;
      }
    }

    if (!apiFilePath) {
      return this.send404(res, pathname);
    }

    try {
      const handler = await this.moduleLoader.loadModule(apiFilePath);
      const handlerFn = typeof handler === 'function' ? handler : handler.default;

      if (typeof handlerFn !== 'function') {
        throw new Error('API route must export a default function');
      }

      const result = await handlerFn(req, res);

      if (!res.headersSent) {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(result || { success: true }));
      }
    } catch (error) {
      console.error(`Error handling API ${pathname}:`, error);
      if (!res.headersSent) {
        this.sendError(res, 500, error.message);
      }
    }
  }

  serveStatic(req, res, pathname) {
    const filePath = path.join(this.publicDir, pathname.replace('/static/', ''));

    if (!fs.existsSync(filePath)) {
      return this.send404(res, pathname);
    }

    const ext = path.extname(filePath);
    const contentType = this.getContentType(ext);
    const content = fs.readFileSync(filePath);

    res.writeHead(200, { 'Content-Type': contentType });
    res.end(content);
  }

  getContentType(ext) {
    const types = {
      '.html': 'text/html',
      '.css': 'text/css',
      '.js': 'application/javascript',
      '.json': 'application/json',
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.gif': 'image/gif',
      '.svg': 'image/svg+xml',
    };
    return types[ext] || 'application/octet-stream';
  }

  send404(res, pathname) {
    res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <!DOCTYPE html>
      <html>
      <head><title>404 - Page Not Found</title></head>
      <body>
        <h1>404 - Page Not Found</h1>
        <p>无法找到页面: ${pathname}</p>
      </body>
      </html>
    `);
  }

  sendError(res, statusCode, message) {
    res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <!DOCTYPE html>
      <html>
      <head><title>Error ${statusCode}</title></head>
      <body>
        <h1>Error ${statusCode}</h1>
        <pre>${message}</pre>
      </body>
      </html>
    `);
  }

  stop() {
    if (this.server) {
      this.server.close();
    }
  }
}

总结

开发服务器的核心是请求路由和响应生成

  1. HTTP 服务器:使用 Node.js 原生模块
  2. 请求处理:根据路径类型分发到不同处理器
  3. 路由匹配:使用路由系统匹配页面
  4. 组件加载:动态加载 React 组件
  5. HTML 渲染:服务端渲染组件(下一篇文章详述)

在下一篇文章中,我们将深入实现服务端渲染系统,这是 Next.js 的核心特性之一。


相关文件: - src/server/dev-server.js - 完整实现 - src/cli/dev.js - CLI 入口