[mini-next.js] App Router:新路由系统实现
概述
App Router 是 Next.js 13+ 引入的新路由系统,它基于文件夹结构而不是文件结构来定义路由,并支持嵌套布局、Server Components 等新特性。本文将深入分析并实现 App Router 的核心功能。
核心概念
Pages Router vs App Router
Pages Router(旧)
pages/
├── index.js → /
├── about.js → /about
└── posts/
└── [id].js → /posts/:id
_app.js 全局布局
App Router(新)
app/
├── layout.js (根布局,必需)
├── page.js → /
├── about/
│ └── page.js → /about
└── posts/
├── layout.js (嵌套布局)
└── [id]/
└── page.js → /posts/:id
page.js 文件
- 嵌套布局:每个路由段可以有 layout.js
特殊文件
App Router 使用特殊文件来定义路由和布局:
page.js:使路由可访问(必需)layout.js:定义布局(可选,但根布局必需)loading.js:加载状态(未实现)error.js:错误边界(未实现)not-found.js:404 页面(未实现)
功能分析
1. 路由扫描
差异:
- Pages Router:扫描文件
- App Router:扫描文件夹,查找 page.js
算法:
1. 递归遍历 app 目录
2. 查找 page.js 文件(确定路由)
3. 查找 layout.js 文件(确定布局)
4. 将文件夹路径转换为 URL 路径
2. 嵌套布局
概念:
- 每个路由段可以有自己的 layout.js
- 布局会自动嵌套:根布局 → 嵌套布局 → 页面
示例:
app/
├── layout.js (根布局)
├── page.js (/)
└── posts/
├── layout.js (posts 布局)
└── [id]/
└── page.js (/posts/:id)
渲染顺序:
RootLayout
└── PostsLayout
└── PostPage
3. 路由匹配
与 Pages Router 类似,但需要:
- 匹配文件夹路径
- 查找 page.js 文件
- 收集所有相关布局
逐步实现
步骤 1:创建 App Router 类结构
import fs from 'fs';
import path from 'path';
export class AppRouter {
constructor(appDir) {
this.appDir = appDir;
this.routes = new Map();
this.dynamicRoutes = new Map();
this.layouts = new Map();
}
}
设计说明: - 类似 Pages Router 的结构 - 额外存储布局信息
步骤 2:实现路由扫描
scanRoutes() {
this.routes.clear();
this.dynamicRoutes.clear();
this.layouts.clear();
if (!fs.existsSync(this.appDir)) {
return;
}
this._scanDirectory(this.appDir, '');
}
关键点: - 清空旧路由和布局 - 检查目录存在 - 从根目录开始扫描
步骤 3:递归扫描目录
_scanDirectory(dir, routePath) {
const files = fs.readdirSync(dir);
// 检查是否有 page.js 文件(这是路由的入口)
const pageFile = this._findSpecialFile(files, 'page');
if (pageFile) {
const fullPath = path.join(dir, pageFile);
const route = this._getRouteFromPath(routePath);
if (route.isDynamic) {
this.dynamicRoutes.set(route.pattern, {
...route,
pagePath: fullPath,
layoutPath: this._findLayoutPath(dir),
});
} else {
this.routes.set(route.path, {
...route,
pagePath: fullPath,
layoutPath: this._findLayoutPath(dir),
});
}
}
// 递归扫描子目录
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory() && !file.startsWith('_')) {
const newRoutePath = routePath === '' ? file : `${routePath}/${file}`;
this._scanDirectory(filePath, newRoutePath);
}
}
}
关键点:
- 查找 page.js 文件确定路由
- 查找 layout.js 文件确定布局
- 排除以 _ 开头的目录(如 _components)
步骤 4:查找特殊文件
_findSpecialFile(files, type) {
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
for (const ext of extensions) {
const filename = `${type}${ext}`;
if (files.includes(filename)) {
return filename;
}
}
return null;
}
_findLayoutPath(dir) {
const files = fs.readdirSync(dir);
const layoutFile = this._findSpecialFile(files, 'layout');
if (layoutFile) {
return path.join(dir, layoutFile);
}
return null;
}
功能:
- 支持多种文件扩展名
- 查找 page.js 和 layout.js
步骤 5:从路径生成路由信息
_getRouteFromPath(routePath) {
// 空路径表示根路由
if (routePath === '') {
return { path: '/', isDynamic: false, params: [] };
}
// 处理动态路由 [id]
const segments = routePath.split('/');
const dynamicSegments = [];
let pattern = '^/';
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const dynamicMatch = segment.match(/^\[(.+)\]$/);
if (dynamicMatch) {
const paramName = dynamicMatch[1];
dynamicSegments.push(paramName);
pattern += '([^/]+)';
} else {
// 转义特殊字符
pattern += segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
if (i < segments.length - 1) {
pattern += '/';
}
}
pattern += '$';
const fullPath = `/${routePath}`;
if (dynamicSegments.length > 0) {
return {
path: fullPath,
pattern: new RegExp(pattern),
isDynamic: true,
params: dynamicSegments,
};
}
return { path: fullPath, isDynamic: false, params: [] };
}
处理逻辑:
- 空路径 → 根路由 /
- [id] 文件夹 → 动态路由参数
- 生成正则表达式匹配模式
步骤 6:实现路由匹配
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;
}
与 Pages Router 类似,但返回的路由信息包含:
- pagePath:页面文件路径
- layoutPath:当前路由段的布局路径
渲染系统实现
App Router 渲染器
App Router 的渲染需要处理嵌套布局:
import React from 'react';
import { renderToString } from 'react-dom/server';
import fs from 'fs';
import path from 'path';
export async function renderAppPage(route, moduleLoader, appDir) {
try {
// 加载页面组件
const PageComponent = await moduleLoader.loadModule(route.pagePath);
// 加载所有布局组件(从根到当前路由)
const layouts = [];
const segments = route.path.split('/').filter(s => s);
// 先检查根布局
const rootLayoutPath = getLayoutPath(route, '', appDir);
if (rootLayoutPath) {
const RootLayout = await moduleLoader.loadModule(rootLayoutPath);
layouts.push({ component: RootLayout, path: rootLayoutPath });
}
// 然后检查每个路由段的布局
let currentPath = '';
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
const layoutPath = getLayoutPath(route, currentPath, appDir);
if (layoutPath) {
const LayoutComponent = await moduleLoader.loadModule(layoutPath);
layouts.push({ component: LayoutComponent, path: layoutPath });
}
}
// 准备页面 props
const pageProps = {
...route.params,
searchParams: {},
};
// 构建组件树:从最外层布局到最内层页面
let component = React.createElement(PageComponent, pageProps);
// 从内到外包裹布局(从最后一个布局到第一个布局)
for (let i = layouts.length - 1; i >= 0; i--) {
const Layout = layouts[i].component;
component = React.createElement(
Layout,
{ children: component }
);
}
// 渲染为 HTML
const html = renderToString(component);
return html;
} catch (error) {
console.error('App Router render error:', error);
throw error;
}
}
function getLayoutPath(route, segmentPath, appDir) {
const dirPath = segmentPath === ''
? appDir
: path.join(appDir, segmentPath);
if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath);
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
for (const ext of extensions) {
const layoutFile = `layout${ext}`;
if (files.includes(layoutFile)) {
return path.join(dirPath, layoutFile);
}
}
}
return null;
}
关键点:
1. 加载页面组件:从 pagePath 加载
2. 收集布局:从根到当前路由的所有布局
3. 嵌套渲染:从内到外包裹布局
4. 渲染 HTML:使用 renderToString
在开发服务器中集成
// src/server/dev-server.js
import { AppRouter } from '../router/app-router.js';
import { renderFullAppPage } from '../renderer/app-renderer.js';
constructor(options = {}) {
// ...
this.appDir = path.join(this.dir, 'app');
// 检测使用哪种路由系统
this.useAppRouter = fs.existsSync(this.appDir);
this.usePagesRouter = fs.existsSync(this.pagesDir);
// 初始化路由系统
if (this.useAppRouter) {
this.appRouter = new AppRouter(this.appDir);
}
if (this.usePagesRouter) {
this.pagesRouter = new FileSystemRouter(this.pagesDir);
}
}
async handlePageRoute(req, res, pathname, url) {
// App Router 优先(与 Next.js 行为一致)
if (this.useAppRouter) {
const appRoute = this.appRouter.matchRoute(pathname);
if (appRoute) {
return this.handleAppRoute(req, res, appRoute, url);
}
}
// 尝试 Pages Router
if (this.usePagesRouter) {
const pagesRoute = this.pagesRouter.matchRoute(pathname);
if (pagesRoute) {
return this.handlePagesRoute(req, res, pagesRoute, url);
}
}
return this.send404(res, pathname);
}
async handleAppRoute(req, res, route, url) {
try {
const routeWithParams = {
...route,
params: route.params || {},
appDir: this.appDir,
};
routeWithParams.searchParams = Object.fromEntries(url.searchParams);
const html = await renderFullAppPage(routeWithParams, this.moduleLoader, {
title: 'Mini Next.js - App Router',
appDir: this.appDir,
});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (error) {
console.error(`Error rendering App Router route ${route.path}:`, error);
this.sendError(res, 500, error.message);
}
}
总结
App Router 的核心是文件夹结构和嵌套布局:
- 文件夹即路由:文件夹结构映射到 URL,需要
page.js文件 - 嵌套布局:每个路由段可以有
layout.js,自动嵌套 - 特殊文件:
page.js使路由可访问,layout.js定义布局
与 Pages Router 相比,App Router 提供了更灵活的路由和布局系统,更适合大型应用。
相关文件:
- src/router/app-router.js - App Router 实现
- src/renderer/app-renderer.js - App Router 渲染实现
- examples/app-router-app/ - App Router 示例