Oasis's Cloud

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

[mini-next.js] App Router:新路由系统实现

作者:oasis


概述

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
- 文件即路由:文件路径直接映射到 URL - 单一布局:使用 _app.js 全局布局

App Router(新)

app/
├── layout.js         (根布局,必需)
├── page.js           → /
├── about/
│   └── page.js       → /about
└── posts/
    ├── layout.js     (嵌套布局)
    └── [id]/
        └── page.js   → /posts/:id
- 文件夹即路由:文件夹结构映射到 URL,需要 page.js 文件 - 嵌套布局:每个路由段可以有 layout.js

特殊文件

App Router 使用特殊文件来定义路由和布局:

功能分析

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.jslayout.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 的核心是文件夹结构和嵌套布局

  1. 文件夹即路由:文件夹结构映射到 URL,需要 page.js 文件
  2. 嵌套布局:每个路由段可以有 layout.js,自动嵌套
  3. 特殊文件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 示例