[mini-next.js] 文件系统路由:Pages Router 实现
概述
文件系统路由是 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
路由规则
- index.js 特殊处理:
pages/index.js映射到根路径/ - 文件路径 = URL 路径:
pages/about.js→/about - 目录嵌套:
pages/posts/index.js→/posts - 动态路由:
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] 语法:
[id].js→ 匹配单个参数- 需要生成正则表达式进行匹配
- 提取参数值
逐步实现
步骤 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 路径的映射:
- 扫描阶段:遍历文件系统,构建路由映射表
- 匹配阶段:根据 URL 路径查找对应的文件
- 参数提取:从动态路由中提取参数值
这个实现虽然简化,但包含了 Next.js Pages Router 的核心逻辑。在下一篇文章中,我们将实现开发服务器,将路由系统与 HTTP 服务器结合起来。
相关文件:
- src/router/file-system-router.js - 完整实现
- examples/basic-app/pages/ - 示例页面