Oasis's Cloud

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

[mini-next.js] 文件系统路由:Pages Router 实现

作者:oasis


概述

文件系统路由是 Next.js 的核心特性之一。它允许开发者通过文件系统结构来定义路由,无需手动配置路由表。本文将深入分析并实现 Pages Router。

核心概念

文件即路由

在 Pages Router 中,pages 目录下的文件结构直接映射到 URL 路径:

pages/
├── index.js          → /
├── about.js          → /about
├── contact.js        → /contact
└── posts/
    ├── index.js      → /posts
    └── [id].js       → /posts/:id

路由规则

  1. index.js 特殊处理pages/index.js 映射到根路径 /
  2. 文件路径 = URL 路径pages/about.js/about
  3. 目录嵌套pages/posts/index.js/posts
  4. 动态路由pages/posts/[id].js/posts/:id

功能分析

1. 路由扫描

需要扫描 pages 目录,构建路由映射表:

输入pages 目录路径
输出:路由映射表(静态路由 + 动态路由)

算法: 1. 递归遍历 pages 目录 2. 识别页面文件(.js, .jsx, .ts, .tsx) 3. 将文件路径转换为 URL 路径 4. 识别动态路由([param] 文件名) 5. 构建路由映射

2. 路由匹配

根据 URL 路径匹配对应的路由:

输入:URL 路径(如 /posts/123
输出:路由信息 + 动态参数

算法: 1. 先尝试精确匹配静态路由 2. 如果失败,尝试动态路由匹配 3. 提取动态路由参数 4. 返回匹配的路由信息

3. 动态路由处理

处理文件名中的 [param] 语法:

逐步实现

步骤 1:创建路由类结构

export class FileSystemRouter {
  constructor(pagesDir) {
    this.pagesDir = pagesDir;
    this.routes = new Map();        // 静态路由
    this.dynamicRoutes = new Map(); // 动态路由
  }
}

设计说明: - 使用 Map 存储路由,查找效率高 - 分离静态和动态路由,匹配时先尝试静态路由

步骤 2:实现文件扫描

scanRoutes() {
  this.routes.clear();
  this.dynamicRoutes.clear();

  if (!fs.existsSync(this.pagesDir)) {
    return;
  }

  this._scanDirectory(this.pagesDir, '');
}

关键点: - 清空旧路由,支持重新扫描 - 检查目录是否存在 - 从根目录开始递归扫描

步骤 3:递归扫描目录

_scanDirectory(dir, routePath) {
  const files = fs.readdirSync(dir);

  for (const file of files) {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);

    if (stat.isDirectory()) {
      // 递归扫描子目录
      const newRoutePath = routePath === '' ? file : `${routePath}/${file}`;
      this._scanDirectory(filePath, newRoutePath);
    } else if (this._isPageFile(file)) {
      // 处理页面文件
      const route = this._getRouteFromFile(routePath, file);
      const fullPath = filePath;

      if (route.isDynamic) {
        this.dynamicRoutes.set(route.pattern, {
          ...route,
          filePath: fullPath,
        });
      } else {
        this.routes.set(route.path, {
          ...route,
          filePath: fullPath,
        });
      }
    }
  }
}

关键点: - 区分文件和目录 - 递归处理子目录 - 只处理页面文件(排除 _app.js 等特殊文件)

步骤 4:识别页面文件

_isPageFile(filename) {
  return /\.(js|jsx|ts|tsx)$/.test(filename) && 
         !filename.startsWith('_') && 
         filename !== 'api';
}

规则: - 支持 .js, .jsx, .ts, .tsx 扩展名 - 排除以 _ 开头的文件(如 _app.js) - 排除 api 目录(API 路由单独处理)

步骤 5:从文件生成路由信息

_getRouteFromFile(routePath, filename) {
  // 移除文件扩展名
  const name = filename.replace(/\.(js|jsx|ts|tsx)$/, '');

  // index.js 映射到父路径
  if (name === 'index') {
    const path = routePath === '' ? '/' : `/${routePath}`;
    return { path, isDynamic: false, params: [] };
  }

  // 处理动态路由 [id].js -> /:id
  const dynamicMatch = name.match(/^\[(.+)\]$/);
  if (dynamicMatch) {
    const paramName = dynamicMatch[1];
    const fullPath = routePath === '' 
      ? `/${name}` 
      : `/${routePath}/${name}`;
    const pattern = this._createDynamicPattern(routePath, paramName);
    return {
      path: fullPath,
      pattern,
      isDynamic: true,
      params: [paramName],
    };
  }

  // 普通路由
  const path = routePath === '' 
    ? `/${name}` 
    : `/${routePath}/${name}`;
  return { path, isDynamic: false, params: [] };
}

处理逻辑: 1. index.js:映射到父路径 2. [id].js:识别为动态路由,生成匹配模式 3. 普通文件:直接映射到 URL 路径

步骤 6:创建动态路由匹配模式

_createDynamicPattern(routePath, paramName) {
  // 转义特殊字符
  const escapedPath = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  if (routePath === '') {
    return new RegExp(`^/([^/]+)$`);
  }
  return new RegExp(`^/${escapedPath}/([^/]+)$`);
}

关键点: - 转义路径中的特殊字符(如 ., + 等) - 生成正则表达式匹配动态参数 - ([^/]+) 匹配非斜杠字符

示例: - pages/posts/[id].js/^\/posts\/([^\/]+)$/ - 匹配 /posts/123,提取 id = "123"

步骤 7:实现路由匹配

matchRoute(urlPath) {
  // 先尝试精确匹配
  if (this.routes.has(urlPath)) {
    return this.routes.get(urlPath);
  }

  // 尝试动态路由匹配
  for (const [pattern, route] of this.dynamicRoutes) {
    const match = urlPath.match(pattern);
    if (match) {
      const params = {};
      route.params.forEach((paramName, index) => {
        params[paramName] = match[index + 1];
      });
      return {
        ...route,
        params,
      };
    }
  }

  return null;
}

匹配策略: 1. 优先精确匹配:O(1) 时间复杂度 2. 动态路由匹配:遍历所有动态路由模式 3. 提取参数:将匹配结果转换为参数对象

完整实现

import fs from 'fs';
import path from 'path';

export class FileSystemRouter {
  constructor(pagesDir) {
    this.pagesDir = pagesDir;
    this.routes = new Map();
    this.dynamicRoutes = new Map();
  }

  scanRoutes() {
    this.routes.clear();
    this.dynamicRoutes.clear();

    if (!fs.existsSync(this.pagesDir)) {
      return;
    }

    this._scanDirectory(this.pagesDir, '');
  }

  _scanDirectory(dir, routePath) {
    const files = fs.readdirSync(dir);

    for (const file of files) {
      const filePath = path.join(dir, file);
      const stat = fs.statSync(filePath);

      if (stat.isDirectory()) {
        const newRoutePath = routePath === '' ? file : `${routePath}/${file}`;
        this._scanDirectory(filePath, newRoutePath);
      } else if (this._isPageFile(file)) {
        const route = this._getRouteFromFile(routePath, file);
        const fullPath = filePath;

        if (route.isDynamic) {
          this.dynamicRoutes.set(route.pattern, {
            ...route,
            filePath: fullPath,
          });
        } else {
          this.routes.set(route.path, {
            ...route,
            filePath: fullPath,
          });
        }
      }
    }
  }

  _isPageFile(filename) {
    return /\.(js|jsx|ts|tsx)$/.test(filename) && 
           !filename.startsWith('_') && 
           filename !== 'api';
  }

  _getRouteFromFile(routePath, filename) {
    const name = filename.replace(/\.(js|jsx|ts|tsx)$/, '');

    if (name === 'index') {
      const path = routePath === '' ? '/' : `/${routePath}`;
      return { path, isDynamic: false, params: [] };
    }

    const dynamicMatch = name.match(/^\[(.+)\]$/);
    if (dynamicMatch) {
      const paramName = dynamicMatch[1];
      const fullPath = routePath === '' 
        ? `/${name}` 
        : `/${routePath}/${name}`;
      const pattern = this._createDynamicPattern(routePath, paramName);
      return {
        path: fullPath,
        pattern,
        isDynamic: true,
        params: [paramName],
      };
    }

    const path = routePath === '' 
      ? `/${name}` 
      : `/${routePath}/${name}`;
    return { path, isDynamic: false, params: [] };
  }

  _createDynamicPattern(routePath, paramName) {
    const escapedPath = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (routePath === '') {
      return new RegExp(`^/([^/]+)$`);
    }
    return new RegExp(`^/${escapedPath}/([^/]+)$`);
  }

  matchRoute(urlPath) {
    if (this.routes.has(urlPath)) {
      return this.routes.get(urlPath);
    }

    for (const [pattern, route] of this.dynamicRoutes) {
      const match = urlPath.match(pattern);
      if (match) {
        const params = {};
        route.params.forEach((paramName, index) => {
          params[paramName] = match[index + 1];
        });
        return {
          ...route,
          params,
        };
      }
    }

    return null;
  }
}

测试示例

const router = new FileSystemRouter('./pages');

// 扫描路由
router.scanRoutes();

// 测试静态路由
const aboutRoute = router.matchRoute('/about');
// { path: '/about', filePath: './pages/about.js', ... }

// 测试动态路由
const postRoute = router.matchRoute('/posts/123');
// { path: '/posts/[id]', filePath: './pages/posts/[id].js', params: { id: '123' }, ... }

总结

Pages Router 的核心是文件系统到 URL 路径的映射

  1. 扫描阶段:遍历文件系统,构建路由映射表
  2. 匹配阶段:根据 URL 路径查找对应的文件
  3. 参数提取:从动态路由中提取参数值

这个实现虽然简化,但包含了 Next.js Pages Router 的核心逻辑。在下一篇文章中,我们将实现开发服务器,将路由系统与 HTTP 服务器结合起来。


相关文件: - src/router/file-system-router.js - 完整实现 - examples/basic-app/pages/ - 示例页面