普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月17日首页

Next.js 全栈开发基础:在 pages/api/*.ts 中创建接口的艺术

作者 LeonGao
2025年8月17日 13:57

在 Web 开发的世界里,前端与后端就像一对需要默契配合的舞者。前端负责优雅地展示数据,后端则默默在幕后准备数据,而接口就是它们之间传递信号的乐谱。在 Next.js 的舞台上,pages/api/*.ts就是谱写这份乐谱的最佳创作室。今天,我们就来揭开在 Next.js 中创建接口的神秘面纱,用 TypeScript 为你的全栈应用搭建起高效的数据桥梁。

接口的本质:数据交换的高速公路

在深入技术细节之前,让我们先理解接口的本质。想象你在餐厅点餐,你(前端)告诉服务员(接口)想要什么,服务员把需求传达给厨房(数据库 / 业务逻辑),然后把做好的食物(数据)端给你。这个过程中,服务员就是接口,负责规范请求格式、处理业务逻辑并返回结果。

在计算机科学中,接口本质上是客户端与服务器之间约定的数据交换格式和规则。Next.js 的 API 路由之所以强大,是因为它允许我们在同一个项目中同时编写前端页面和后端接口,就像在同一个屋檐下同时拥有餐厅大堂和厨房,大大提高了开发效率。

初探 pages/api:Next.js 的接口魔法

Next.js 的 API 路由基于一个简单而强大的约定:在pages/api目录下创建的文件会自动成为 API 接口。这个机制背后其实是 Next.js 的文件系统路由在起作用,当服务器启动时,它会扫描pages/api目录下的所有文件,为每个文件创建对应的路由端点。

比如我们创建pages/api/hello.ts文件,访问http://localhost:3000/api/hello就能调用这个接口。这种设计就像给每个接口分配了独立的办公室,它们互不干扰又能协同工作。

第一个接口:Hello World 的进阶版

让我们从经典的 Hello World 开始,创建一个能返回个性化问候的接口。在pages/api目录下新建greet.ts文件,输入以下代码:

export default function handler(req, res) {
  // 从请求中获取查询参数name
  const { name = "World" } = req.query;
  
  // 设置响应状态码为200(成功)
  res.status(200).json({ 
    message: `Hello, ${name}!`,
    timestamp: new Date().toISOString()
  });
}

这个接口做了三件事:

  1. 从请求的查询参数中获取 name,如果没有提供则默认使用 "World"
  1. 设置 HTTP 响应状态码为 200,表示请求成功
  1. 返回一个 JSON 对象,包含问候消息和当前时间戳

运行你的 Next.js 应用,访问http://localhost:3000/api/greet?name=Next.js,你会看到类似这样的响应:

{
  "message": "Hello, Next.js!",
  "timestamp": "2025-08-17T12:34:56.789Z"
}

处理不同的 HTTP 方法:接口的多面手

一个健壮的接口应该能处理不同的 HTTP 方法,就像一个多才多艺的演员能胜任不同的角色。常见的 HTTP 方法有 GET(获取数据)、POST(创建数据)、PUT(更新数据)和 DELETE(删除数据)。

让我们创建一个简单的任务管理接口,支持 GET 和 POST 方法:

// pages/api/tasks.ts
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  // 获取请求方法
  const { method } = req;
  switch (method) {
    case 'GET':
      // 处理GET请求:返回所有任务
      res.status(200).json(tasks);
      break;
    case 'POST':
      // 处理POST请求:创建新任务
      const { title } = req.body;
      
      // 验证请求数据
      if (!title) {
        return res.status(400).json({ error: "任务标题不能为空" });
      }
      
      // 创建新任务
      const newTask = {
        id: tasks.length + 1,
        title,
        completed: false
      };
      
      // 添加到任务列表
      tasks.push(newTask);
      
      // 返回创建的任务,状态码201表示资源创建成功
      res.status(201).json(newTask);
      break;
    default:
      // 处理不支持的方法
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`方法 ${method} 不被允许`);
  }
}

这个接口展示了如何根据不同的 HTTP 方法执行不同的操作:

  • 当使用 GET 方法访问时,它返回所有任务列表
  • 当使用 POST 方法并发送包含 title 的 JSON 数据时,它创建一个新任务
  • 当使用不支持的方法(如 PUT 或 DELETE)时,它返回 405 错误

你可以使用工具如 Postman 或 curl 来测试这个接口:

# 测试GET请求
curl http://localhost:3000/api/tasks
# 测试POST请求
curl -X POST -H "Content-Type: application/json" -d '{"title":"新任务"}' http://localhost:3000/api/tasks

接口参数处理:精准获取请求数据

在实际开发中,我们经常需要从不同位置获取请求数据。Next.js 的 API 路由提供了多种方式来获取这些数据,就像有多个入口可以进入一个建筑:

  1. 查询参数(Query Parameters) :位于 URL 中?后面的键值对,通过req.query获取
  1. 路径参数(Path Parameters) :URL 路径中的动态部分,通过文件名中的[param]定义
  1. 请求体(Request Body) :POST、PUT 等方法发送的数据,通过req.body获取

让我们创建一个支持路径参数的接口,用于获取单个任务:

// pages/api/tasks/[id].ts
// 假设tasks数组与前面的例子相同
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  const { id } = req.query;
  // 将id转换为数字
  const taskId = parseInt(id, 10);
  
  // 验证id是否有效
  if (isNaN(taskId)) {
    return res.status(400).json({ error: "无效的任务ID" });
  }
  
  // 查找任务
  const task = tasks.find(t => t.id === taskId);
  
  if (task) {
    res.status(200).json(task);
  } else {
    res.status(404).json({ error: "任务不存在" });
  }
}

现在,访问http://localhost:3000/api/tasks/1会返回 ID 为 1 的任务,而访问http://localhost:3000/api/tasks/99会返回 404 错误。

错误处理:接口的安全网

就像现实生活中需要应急预案一样,接口也需要完善的错误处理机制。一个好的错误处理策略应该:

  • 返回适当的 HTTP 状态码
  • 提供清晰的错误信息
  • 避免暴露敏感信息

让我们改进前面的任务接口,添加更完善的错误处理:

// pages/api/tasks/[id].ts(改进版)
let tasks = [
  { id: 1, title: "学习Next.js", completed: false },
  { id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
  try {
    const { id } = req.query;
    const taskId = parseInt(id, 10);
    
    if (isNaN(taskId)) {
      // 400 Bad Request:请求参数无效
      return res.status(400).json({ 
        error: "无效的任务ID",
        details: "ID必须是数字"
      });
    }
    
    const task = tasks.find(t => t.id === taskId);
    
    if (task) {
      // 200 OK:请求成功
      res.status(200).json(task);
    } else {
      // 404 Not Found:资源不存在
      res.status(404).json({ 
        error: "任务不存在",
        details: `没有ID为${taskId}的任务`
      });
    }
  } catch (error) {
    // 500 Internal Server Error:服务器内部错误
    console.error("处理请求时出错:", error);
    res.status(500).json({ 
      error: "服务器内部错误",
      details: "请稍后再试"
    });
  }
}

这个改进版接口使用 try-catch 块捕获可能的错误,并为不同类型的错误返回相应的状态码和详细信息,同时避免将内部错误直接暴露给客户端。

接口的性能考量:让数据流动更快

随着应用规模的增长,接口的性能变得越来越重要。以下是一些提高 API 路由性能的小贴士:

  1. 数据缓存:对于不经常变化的数据,可以使用缓存减少重复计算
  1. 请求验证:尽早验证请求数据,避免不必要的处理
  1. 分页处理:对于大量数据,使用分页减少数据传输量
  1. 异步处理:对于耗时操作,考虑使用异步处理避免阻塞

让我们实现一个带分页功能的任务列表接口:

// pages/api/tasks/paginated.ts
let tasks = [
  // 假设这里有很多任务...
  { id: 1, title: "任务1", completed: false },
  { id: 2, title: "任务2", completed: true },
  // ...更多任务
];
export default function handler(req, res) {
  try {
    // 获取分页参数,默认页码为1,每页10条
    const { page = 1, limit = 10 } = req.query;
    const pageNum = parseInt(page, 10);
    const limitNum = parseInt(limit, 10);
    
    // 验证分页参数
    if (isNaN(pageNum) || isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
      return res.status(400).json({ 
        error: "无效的分页参数",
        details: "页码和每页数量必须是正整数"
      });
    }
    
    // 计算总页数
    const totalPages = Math.ceil(tasks.length / limitNum);
    
    // 计算起始索引
    const startIndex = (pageNum - 1) * limitNum;
    
    // 获取当前页的任务
    const paginatedTasks = tasks.slice(startIndex, startIndex + limitNum);
    
    res.status(200).json({
      data: paginatedTasks,
      pagination: {
        total: tasks.length,
        page: pageNum,
        limit: limitNum,
        totalPages
      }
    });
  } catch (error) {
    console.error("分页查询出错:", error);
    res.status(500).json({ error: "服务器内部错误" });
  }
}

这个接口支持通过page和limit参数控制返回的数据量,减轻了服务器和网络的负担。

部署与注意事项:让接口飞向生产环境

当你的接口准备好部署到生产环境时,有几个重要的注意事项:

  1. 环境变量:敏感信息如数据库连接字符串应该使用环境变量,而不是硬编码在代码中
  1. CORS 设置:如果你的前端和后端不在同一个域名下,需要配置跨域资源共享(CORS)
  1. 速率限制:为了防止滥用,考虑添加速率限制功能
  1. 日志记录:添加适当的日志记录以便调试和监控

在 Next.js 中配置 CORS 非常简单,你可以使用cors中间件:

// pages/api/with-cors.ts
import cors from 'cors';
// 初始化cors中间件
const corsMiddleware = cors({
  origin: process.env.NEXT_PUBLIC_FRONTEND_URL || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE']
});
// 辅助函数:将中间件转换为Promise
function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (result) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
}
export default async function handler(req, res) {
  // 应用CORS中间件
  await runMiddleware(req, res, corsMiddleware);
  
  // 处理请求
  res.status(200).json({ message: "这个接口支持跨域请求!" });
}

总结:接口开发的艺术与科学

在 Next.js 中创建 API 接口就像在构建一座连接前端和后端的桥梁,它需要扎实的技术基础,也需要对用户需求的深刻理解。通过pages/api/*.ts文件,我们可以快速创建功能完善的接口,处理各种 HTTP 方法,获取不同来源的请求数据,并返回结构化的响应。

记住,一个好的接口应该是清晰、健壮、高效且安全的。它不仅要能正确处理正常情况,还要能优雅地应对错误;不仅要能满足当前需求,还要为未来的扩展留有余地。

随着你对 Next.js API 路由的深入了解,你可以尝试更高级的功能,如数据库集成、身份验证、文件上传等。全栈开发的世界充满了可能性,而接口就是打开这个世界的钥匙。现在,拿起这把钥匙,开始构建你的全栈应用吧!

昨天以前首页

Next.js 15 数据获取指南:掌握服务器组件与客户端数据流(七)

2025年8月15日 08:20

为什么“服务器优先”?

在探索具体的数据获取方法之前,我们必须先理解 Next.js App Router 的核心设计理念:服务器优先(Server-First)

在传统的 React 开发(例如 Create React App)中,我们习惯于在浏览器(客户端)加载完页面骨架后,再通过 useEffect 去请求数据。这会导致用户先看到一个加载中的空白状态,然后数据才姗姗来迟,这种体验并不理想,我们称之为“请求瀑布”。

Next.js 彻底改变了这一点。通过引入服务器组件(Server Components),数据获取的默认执行环境从客户端转移到了服务器

这意味着什么?

  1. 更快的初始加载:数据在服务器上获取完成,与页面HTML一同返回给浏览器。用户打开网页时,看到的就是一个内容完整的页面,不再有烦人的加载状态和布局抖动。
  2. 更安全的数据请求:你可以在服务器组件中安全地访问数据库、使用私密的 API 密钥,因为这些代码永远不会泄露到客户端。
  3. 更小的前端包体积:用于数据获取(如 fetch)和相关逻辑都留在了服务器,无需发送到浏览器,减轻了客户端的负担。

简而言之,Next.js 鼓励我们:尽可能地在服务器上获取数据。只有在确实需要交互性、且数据依赖于客户端状态时(例如,根据用户的输入进行搜索),我们才考虑在客户端获取数据。

fetch 的魔法:不仅仅是请求

在 Next.js 中,fetch API 被赋予了"魔法"。它与 React 和 Next.js 的核心渲染、缓存机制深度集成,提供了强大的请求去重和缓存控制能力。

Next.js 15 的重要变化:默认不缓存

从 Next.js 15 开始,fetch 响应默认不再被缓存。这是一个重大的行为变化,意味着:

  • 默认行为:每次请求都会从远程服务器获取最新数据
  • 性能优化:Next.js 仍会预渲染路由,输出结果会被缓存以提升性能
  • 请求去重:在同一个渲染过程中,相同 URL 和选项的 fetch 请求仍会被自动去重(Request Memoization)

请求去重机制(Request Memoization)

虽然默认不缓存响应,但 Next.js 仍提供了请求去重功能。在同一个 React 组件树的渲染过程中,相同的 fetch 请求只会执行一次:

// app/posts/page.tsx
async function getPosts() {
  // Next.js 15: 默认不缓存,每次都获取最新数据
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

// 在同一次渲染中,这两个调用只会发送一次网络请求
async function getPostsAgain() {
  // 这个请求会被去重,不会发送新的网络请求
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function Page() {
  const posts = await getPosts(); // 第一次:发送网络请求
  const morePosts = await getPostsAgain(); // 第二次:从内存返回
  // ...
}

控制缓存策略

虽然 Next.js 15 默认不缓存 fetch 响应,但你仍然可以通过配置选项来精确控制缓存行为。

1. 启用缓存(force-cache)

如果你希望缓存某些稳定的数据(如配置信息、静态内容),可以显式设置 cache: 'force-cache'

// 启用缓存,数据会被持久化存储
const res = await fetch('https://api.example.com/config', {
  cache: 'force-cache', // 显式启用缓存
});

1. 确保不缓存(no-store)

对于需要实时更新的数据(如股票价格、新闻快讯),你可以显式设置 cache 选项为 'no-store'(虽然这已经是默认行为):

// 确保每次都重新请求(Next.js 15 的默认行为)
const res = await fetch('https://api.example.com/real-time-data', {
  cache: 'no-store', // 显式禁用缓存
});

3. 定期重新验证(增量静态再生 - ISR)

你可以让数据在一定时间后自动更新。例如,一个博客文章列表,每小时更新一次就足够了。这通过 next.revalidate 选项实现:

// 启用缓存并设置重新验证时间
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 }, // 60秒后重新验证
  // 注意:使用 revalidate 时会自动启用缓存
});

重要提示:当你设置 next.revalidate 时,Next.js 会自动启用缓存,无需显式设置 cache: 'force-cache'。这个特性让你的网站兼具静态网站的访问速度和动态网站的内容更新能力。

Next.js 15 缓存行为总结

为了帮助你更好地理解 Next.js 15 的缓存变化,这里是一个快速参考表:

配置 Next.js 14 及之前 Next.js 15 说明
默认行为 自动缓存 不缓存 重大变化:默认获取最新数据
cache: 'force-cache' 缓存 缓存 显式启用缓存
cache: 'no-store' 不缓存 不缓存 显式禁用缓存
next: { revalidate: 60 } 缓存+重新验证 缓存+重新验证 自动启用缓存
请求去重 同一渲染中的相同请求仍会去重

迁移建议

  • 如果你的应用依赖自动缓存,需要显式添加 cache: 'force-cache' 或使用 next.revalidate
  • 对于实时数据,新的默认行为更符合预期,无需额外配置
  • 开发环境中,HMR 缓存仍然有效,避免了频繁的 API 调用

数据获取实战演练

理论说完了,让我们进入实战环节。

场景一:在服务器组件中获取数据(推荐)

这是最常见、也是最推荐的方式。它非常直观,就像写 Node.js 代码一样。

示例:创建一个博客文章列表页面

// app/blog/page.tsx

// 定义文章类型,这是个好习惯
interface Post {
  id: number;
  title: string;
  body: string;
}

// 异步组件,可以直接使用 await
export default async function BlogPage() {
  console.log("正在服务器上获取数据...");

  // 1. 获取数据
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10', {
    next: { revalidate: 3600 } // 每小时更新一次,自动启用缓存
  });

  if (!res.ok) {
    // 更好的错误处理方式见后文
    throw new Error('Failed to fetch posts');
  }

  const posts: Post[] = await res.json();

  // 2. 渲染UI
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">我的博客</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id} className="p-4 border rounded-md">
            <h2 className="text-xl font-semibold">{post.title}</h2>
          </li>
        ))}
      </ul>
    </main>
  );
}

就这么简单!没有 useState,没有 useEffect,也没有加载状态的管理。你只需要 async/await,剩下的交给 Next.js。

场景二:在客户端组件中获取数据

什么时候需要在客户端获取数据呢?

  • 当数据是用户专属且高度动态的(如购物车内容)。
  • 当数据依赖于用户的实时交互(如搜索框的自动完成建议)。

要在客户端组件中获取数据,你需要使用 "use client" 指令。

传统方式:useEffect + useState

在 React 19 之前,我们通常这样做:

"use client";

import { useState, useEffect } from 'react';

// ... Post 类型定义

export default function UserProfile() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch('/api/user/posts') // 假设有一个获取用户文章的 API
      .then(res => res.json())
      .then(data => {
        setPosts(data);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []); // 空依赖数组,仅在组件挂载时执行一次

  if (loading) {
    return <p>加载中...</p>;
  }

  return (
    // ... 渲染 posts
  )
}

这种方式代码量多,且需要手动管理 loadingerror 状态,比较繁琐。

现代方式:使用 React 19 use Hook

use hook 是 React 19 带来的革命性新特性,它极大地简化了在客户端组件中处理异步操作(如 fetch)的方式。

前提:你需要一个包裹 fetch 的函数,它会处理 Promise。

// lib/data.ts
import { cache } from 'react';

// `cache` 函数可以包装数据请求,确保在一次渲染中,即使多次调用 `getUserPosts`,也只执行一次。
export const getUserPosts = cache((userId: string) =>
  fetch(`https://api.example.com/users/${userId}/posts`).then((res) => res.json())
);

现在,在你的客户端组件中:

"use client";

import { use } from 'react';
import { getUserPosts } from '@/lib/data';

interface UserPostsProps {
  userId: string;
}

// ... Post 类型定义

function PostsList({ userId }: { userId: string }) {
  // 1. 使用 `use` Hook 获取数据
  // 当 `getUserPosts` 的 Promise 还在 pending 状态时,`use` 会自动抛出这个 Promise,
  // 这会被最近的 <Suspense> 边界捕获。
  const posts: Post[] = use(getUserPosts(userId));

  // 2. 渲染UI
  // 代码能执行到这里,说明数据已经成功获取
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

export default function UserProfilePage({ params }: { params: { userId: string } }) {
  return (
    <div>
      <h1 className="text-2xl">用户文章</h1>
      {/* 必须用 Suspense 包裹使用 `use` 的组件 */}
      <Suspense fallback={<p>正在加载文章列表...</p>}>
        <PostsList userId={params.userId} />
      </Suspense>
    </div>
  )
}

看到了吗?use hook 让客户端数据获取变得和服务器端一样直观简洁。它内置了对 Suspense 的支持,你不再需要手动管理 loading 状态。use 会自动“暂停”组件的渲染,直到数据准备就绪。

加载中与错误处理

一个健壮的应用必须优雅地处理加载和错误状态。Next.js 提供了专门的文件约定来解决这个问题。

使用 loading.tsx 处理加载状态

当你在服务器组件中获取数据时,Next.js 会自动寻找与你的页面平级的 loading.tsx 文件,并将其作为加载指示器。

示例:为博客页面添加入场动画

app/blog/ 目录下,创建一个 loading.tsx 文件:

// app/blog/loading.tsx
export default function Loading() {
  // 你可以在这里设计任何酷炫的加载动画
  return (
    <div className="flex justify-center items-center h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
    </div>
  );
}

现在,当用户访问 /blog 页面时,在数据加载完成前,会首先看到这个旋转动画,而不是一个空白页面。这与 React 的 Suspense 边界协同工作,提供了无缝的加载体验。

使用 error.tsx 处理错误

如果数据获取失败(例如,API 服务器宕机),Next.js 会自动捕获错误,并渲染与页面平级的 error.tsx 文件。

注意error.tsx 必须是一个客户端组件 ("use client")。

示例:为博客页面添加错误边界

app/blog/ 目录下,创建一个 error.tsx 文件:

"use client"; // 错误组件必须是客户端组件

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 你可以在这里记录错误日志
    console.error(error);
  }, [error]);

  return (
    <div className="text-center py-10">
      <h2 className="text-2xl font-bold text-red-600">糟糕,出错了!</h2>
      <p className="my-4">获取文章列表时遇到了问题,请稍后再试。</p>
      <button
        onClick={
          // 尝试重新渲染该路由段
          () => reset()
        }
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        重试
      </button>
    </div>
  );
}

现在,如果 app/blog/page.tsx 中的 fetch 抛出错误,用户将看到这个友好的错误界面,而不是一个崩溃的应用。他们还可以通过点击“重试”按钮来尝试重新加载。

进阶技巧:数据变更与更新

获取数据只是故事的一半,我们还需要更新数据。Server Actions 是 Next.js 用于在服务器上执行数据变更(创建、更新、删除)的利器。

当一个 Server Action 执行后,我们通常需要更新页面上显示的数据。Next.js 提供了两种强大的方式来重新验证缓存:

  1. revalidatePath:使特定路径下的数据缓存失效,下次访问时会重新获取。
  2. revalidateTag:更精细的控制。你可以在 fetch 时给数据打上标签,然后只让带有特定标签的数据缓存失效。

这是一个简化的示例,让你感受一下:

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function addPost(data: FormData) {
  // 1. 调用 API 创建新文章
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: JSON.stringify({ title: data.get('title') }),
  });
  
  // 2. 让所有标记为 'posts' 的数据缓存失效
  revalidateTag('posts');
}

// 在 fetch 时打上标签
fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });

进一步提升:结合数据获取库

尽管 Next.js 对 fetch API 进行了强大扩展,满足了大部分数据获取需求,但在复杂的客户端数据管理场景中(例如,需要频繁更新、离线模式、请求重试、缓存过期等),专业的客户端数据获取库能提供更强大的能力和更优雅的开发体验。

目前最受欢迎的两个库是 SWR (Stale-While-Revalidate) 和 React Query (现已更名为 TanStack Query)。它们都基于一个核心思想:“旧数据,新验证”。这意味着它们会立即返回缓存中的旧数据(如果存在),同时在后台发起新的数据请求进行验证和更新。这种模式极大地提升了用户感知的性能。

为什么使用数据获取库?

  1. 自动缓存和去重:自动管理数据缓存,避免重复请求。
  2. 自动重新验证(Revalidation):在窗口重新聚焦、网络重连等场景下自动重新请求数据,确保数据新鲜度。
  3. 错误处理和重试机制:内置完善的错误捕获和自动重试策略。
  4. 加载状态和分页/无限滚动:提供了简单的方式来管理加载状态,并支持高级的分页和无限滚动模式。
  5. 乐观更新:在数据变更时,可以先更新 UI,再等待服务器响应,提升用户体验。

SWR 示例

SWR 由 Vercel (Next.js 的创造者) 团队开发,与 Next.js 的配合非常默契。

// app/dashboard/client-data-fetcher.tsx
"use client";

import useSWR from 'swr';

interface UserData {
  id: number;
  name: string;
  email: string;
}

// 定义一个 fetcher 函数,SWR 会用它来实际请求数据
const fetcher = (url: string) => fetch(url).then(res => res.json());

export default function UserDashboard() {
  // useSWR 的第一个参数是请求的 key (通常是 URL),第二个参数是 fetcher 函数
  const { data, error, isLoading } = useSWR<UserData>('/api/me', fetcher);

  if (error) return <div className="text-red-500">加载失败</div>;
  if (isLoading) return <div className="text-blue-500">加载中...</div>;
  if (!data) return null; // 确保数据存在

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">欢迎, {data.name}!</h1>
      <p>邮箱: {data.email}</p>
      {/* 更多用户数据展示 */}
    </div>
  );
}

如何集成:

  1. 安装 SWRnpm install swryarn add swr
  2. 创建客户端组件:确保你的组件有 "use client" 指令。
  3. 包裹 SWRConfig (可选但推荐):在应用的根组件(例如 layout.tsx 或自定义 _app.tsx)中使用 SWRConfig 提供全局配置,如默认的 fetcher 或错误处理。

React Query (TanStack Query) 示例

React Query 提供了非常丰富的功能和更细粒度的控制。

// app/products/client-product-list.tsx
"use client";

import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

interface Product {
  id: number;
  name: string;
  price: number;
}

const queryClient = new QueryClient(); // 创建 QueryClient 实例

// 假设的 API 请求函数
async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }
  return res.json();
}

function ProductsList() {
  // useQuery 的第一个参数是查询键 (一个数组,用于缓存识别),第二个是查询函数
  const { data, error, isLoading } = useQuery<Product[], Error>({
    queryKey: ['products'],
    queryFn: getProducts,
  });

  if (isLoading) return <div className="text-blue-500">加载产品中...</div>;
  if (error) return <div className="text-red-500">错误: {error.message}</div>;

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">产品列表</h1>
      <ul>
        {data?.map(product => (
          <li key={product.id}> {product.name} - ¥{product.price.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
}

export default function ProductsPage() {
  return (
    // 必须用 QueryClientProvider 包裹,才能在子组件中使用 useQuery
    <QueryClientProvider client={queryClient}>
      <ProductsList />
    </QueryClientProvider>
  );
}

如何集成:

  1. 安装 React Querynpm install @tanstack/react-queryyarn add @tanstack/react-query
  2. 创建 QueryClientProvider:在应用的根组件或需要使用 React Query 的组件树顶层提供 QueryClientProvider

总结:在处理客户端数据时,如果仅仅是简单的展示,Next.js fetch + use Hook 可能已经足够。但对于需要高级缓存、优化交互、错误重试、数据同步等功能的场景,SWR 或 React Query 将是你的最佳选择。它们能让你以更声明式、更强大的方式管理客户端数据流。

优化用户体验:流式渲染与 Suspense

在现代 Web 应用中,用户体验至关重要。即使后端数据响应较慢,我们也希望用户能够尽快看到页面的骨架内容,而不是长时间的白屏。Next.js 15 结合 React 18+ 的并发特性,通过**流式渲染(Streaming)**和 Suspense 为我们带来了极致的用户体验优化。

什么是流式渲染?

想象一下,你正在访问一个包含多个独立部分(例如,一个显示产品列表,一个显示用户评论)的页面。在传统模式下,即使产品列表数据已经就绪,浏览器也必须等待所有部分的数据都加载完毕,才能开始渲染整个页面。

流式渲染改变了这一点。它允许服务器将页面的 HTML 分块发送到浏览器。

  1. 先发送“外壳”HTML:服务器可以立即发送页面布局(例如,导航栏、页脚)的 HTML,而不必等待所有数据加载完成。这让浏览器可以立即开始解析和渲染页面。
  2. 数据就绪时“流”入内容:当某个部分的数据加载完成后,服务器会以 script 标签的形式,将该部分的 HTML 和相关 JavaScript 流式地发送给浏览器。浏览器接收到这些内容后,会将其插入到页面的正确位置。

这意味着用户可以更快地看到页面内容,即使数据尚未完全加载,他们也能够感受到页面正在逐步填充。这显著提升了用户感知的性能。

Suspense 在 Next.js 中的作用

Suspense 是 React 的一个内置组件,它允许你“暂停”组件的渲染,直到其内部的异步操作(例如数据获取)完成。当异步操作处于 pending 状态时,Suspense 会渲染一个 fallback 属性提供的备用内容(例如加载指示器)。

在 Next.js 的 App Router 中,loading.tsx 文件实际上就是 Suspense 的一个应用。

// app/dashboard/layout.tsx (示例)

import { Suspense } from 'react';
import DashboardNav from './DashboardNav';
import DashboardContent from './DashboardContent';

export default function DashboardLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <section>
      <DashboardNav />
      {/* 这个 Suspense 边界会捕获 DashboardContent 内部可能出现的异步操作 */}
      <Suspense fallback={<p>加载仪表盘内容...</p>}>
        <DashboardContent />
      </Suspense>
      {children}
    </section>
  );
}

loading.tsx 是如何工作的?

当你在一个路由段中定义 loading.tsx 时,Next.js 会自动将其包裹在对应的 Suspense 边界中。例如,对于 /app/blog/page.tsx/app/blog/loading.tsx,Next.js 内部会将其处理为:

// 概念上类似于 Next.js 的内部处理
<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

分层级加载:让页面内容渐进显示

利用流式渲染和 Suspense,我们可以实现页面的分层级加载。这意味着我们可以将页面划分为多个独立的部分,每个部分在自己的数据准备就绪后独立渲染。

示例:一个复杂的用户主页

假设一个用户主页包含:

  • 顶部用户信息 (快速加载)
  • 文章列表 (可能较慢)
  • 好友推荐 (独立加载,可能最慢)
// app/profile/[userId]/page.tsx

import { Suspense } from 'react';
import UserInfo from './UserInfo'; // 假设这里不需要异步数据或数据非常快
import Articles from './Articles'; // 需要异步获取文章列表
import FriendsRecommendations from './FriendsRecommendations'; // 需要异步获取好友推荐

export default async function UserProfilePage({ params }: { params: { userId: string } }) {
  const userId = params.userId;

  return (
    <div className="p-8">
      {/* 用户信息部分,快速渲染 */}
      <UserInfo userId={userId} />

      <h2 className="text-2xl font-bold mt-8 mb-4">我的文章</h2>
      {/* 文章列表,使用 Suspense 边界包裹,数据加载时显示加载状态 */}
      <Suspense fallback={<p>加载文章中...</p>}>
        <Articles userId={userId} />
      </Suspense>

      <h2 className="text-2xl font-bold mt-8 mb-4">好友推荐</h2>
      {/* 好友推荐,独立 Suspense 边界,即便文章列表加载慢,它也可以在自己的数据就绪后显示 */}
      <Suspense fallback={<p>加载好友推荐中...</p>}>
        <FriendsRecommendations userId={userId} />
      </Suspense>
    </div>
  );
}

// app/profile/[userId]/Articles.tsx (服务器组件)
async function Articles({ userId }: { userId: string }) {
  // 模拟较慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 2000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/articles`);
  const articles = await res.json();
  return (
    <ul>
      {articles.map(article => <li key={article.id}>{article.title}</li>)}
    </ul>
  );
}

// app/profile/[userId]/FriendsRecommendations.tsx (服务器组件)
async function FriendsRecommendations({ userId }: { userId: string }) {
  // 模拟最慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 4000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/recommendations`);
  const recommendations = await res.json();
  return (
    <ul>
      {recommendations.map(friend => <li key={friend.id}>{friend.name}</li>)}
    </ul>
  );
}

在这个例子中,UserInfo 会立即显示。同时,ArticlesFriendsRecommendations 组件会并行请求数据,并在数据返回后,通过流式渲染逐步填充到页面中。这种方式极大地提升了用户感知的加载速度,因为他们不必等待最慢的数据。

总结:何时使用 Suspense?

  • 服务器组件loading.tsx 提供了页面的 Suspense 边界。
  • 客户端组件:当你需要在客户端组件内部进行异步数据获取,并希望在数据加载时显示加载状态,同时避免手动管理 loading 状态时,可以使用 React 19 的 use hook 结合 <Suspense> 组件。
  • 分层级加载:当页面包含多个独立且加载时间可能不同的部分时,为每个异步部分包裹 Suspense 边界,可以实现更平滑的渐进式加载体验。

理解并善用流式渲染和 Suspense,是构建高性能 Next.js 应用的关键一步。

服务器组件与客户端组件:数据传递与交互

Next.js App Router 引入了服务器组件(Server Components)和客户端组件(Client Components)的概念,这在提供强大功能的同时,也带来了新的数据流和交互模式。理解它们之间如何通信是掌握 Next.js 的关键。

从服务器到客户端:Props 传递

最常见的数据传递方式是通过组件的 props。服务器组件可以在渲染时获取数据,然后将这些数据作为 props 传递给嵌套的客户端组件。

重要原则:传递给客户端组件的 props 必须是**可序列化(Serializable)**的。这意味着你不能直接传递函数、Symbol、Date 对象(需要转换为字符串或时间戳)、Class 实例等非基本类型数据。如果需要传递这些类型的数据,通常需要进行转换。

示例:服务器组件传递数据给客户端组件

// app/dashboard/page.tsx (服务器组件)

import UserGreeting from './UserGreeting'; // 这是一个客户端组件

interface UserProfile {
  name: string;
  lastLogin: string; // 假设是 ISO 格式字符串
}

async function getUserProfile(): Promise<UserProfile> {
  // 在服务器上获取用户数据
  const res = await fetch('https://api.example.com/user/profile', { cache: 'no-store' });
  if (!res.ok) {
    throw new Error('Failed to fetch user profile');
  }
  return res.json();
}

export default async function DashboardPage() {
  const userProfile = await getUserProfile();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">仪表盘</h1>
      {/* 将服务器获取的数据作为 props 传递给客户端组件 */}
      <UserGreeting userName={userProfile.name} lastLogin={userProfile.lastLogin} />
      {/* 其他仪表盘内容 */}
    </main>
  );
}

// app/dashboard/UserGreeting.tsx (客户端组件)
"use client";

import { formatDistanceToNow } from 'date-fns'; // 客户端库

interface UserGreetingProps {
  userName: string;
  lastLogin: string; // 接收字符串,客户端再处理
}

export default function UserGreeting({ userName, lastLogin }: UserGreetingProps) {
  const loginTime = new Date(lastLogin); // 在客户端将字符串转换为 Date 对象
  const timeAgo = formatDistanceToNow(loginTime, { addSuffix: true, locale: 'zh-CN' });

  return (
    <div className="mb-4 p-4 bg-green-100 rounded-md">
      <p className="text-lg">你好, <span className="font-semibold">{userName}</span>!</p>
      <p className="text-sm text-gray-600">上次登录: {timeAgo}</p>
    </div>
  );
}

在这个例子中,DashboardPage (服务器组件) 获取用户数据,然后将 userNamelastLogin 作为 props 传递给 UserGreeting (客户端组件)。UserGreeting 在客户端利用 date-fns 库格式化时间,这是只有在客户端才能执行的操作。

从客户端到服务器:Server Actions

客户端组件需要与服务器端逻辑交互时,Server Actions 是最佳选择。它们允许你在客户端组件中直接调用服务器端函数,而无需手动创建 API 路由。

Server Actions 可以在任何服务器组件或 "use server" 文件中定义。

示例:客户端组件触发服务器行为

// app/comments/add-comment-form.tsx (客户端组件)
"use client";

import { useRef } from 'react';
import { addComment } from '@/app/actions'; // 引入服务器动作

export default function AddCommentForm() {
  const formRef = useRef<HTMLFormElement>(null);

  // 使用 bind 来预设参数,或者直接在 action 属性中使用箭头函数
  const handleSubmit = async (formData: FormData) => {
    await addComment(formData); 
    formRef.current?.reset(); // 提交后清空表单
  };

  return (
    <form ref={formRef} action={handleSubmit} className="p-4 border rounded-md shadow-sm">
      <h2 className="text-xl font-semibold mb-3">添加评论</h2>
      <textarea
        name="commentText"
        rows={4}
        placeholder="留下你的评论..."
        className="w-full p-2 border rounded-md mb-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
        required
      ></textarea>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        发布评论
      </button>
    </form>
  );
}

// app/actions.ts (服务器文件)
"use server";

import { revalidatePath } from 'next/cache';

export async function addComment(formData: FormData) {
  const commentText = formData.get('commentText') as string;

  if (!commentText) {
    throw new Error("评论内容不能为空。");
  }

  try {
    // 模拟数据存储到数据库或调用外部 API
    console.log(`正在保存评论: "${commentText}"`);
    await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
    
    // 重新验证路径,以便客户端能看到新评论
    revalidatePath('/comments'); 
    console.log("评论保存成功并重新验证了 /comments 路径。");

  } catch (error) {
    console.error("保存评论时出错:", error);
    throw new Error("评论发布失败,请稍后再试。");
  }
}

在这个示例中:

  1. AddCommentForm 是一个客户端组件,因为它处理用户交互和表单提交。
  2. 它通过 action 属性直接引用了 addComment 这个 Server Action。
  3. addComment 是一个在服务器上运行的异步函数,负责处理实际的业务逻辑(保存评论)。
  4. revalidatePath('/comments') 会在评论成功保存后,强制 Next.js 重新获取 /comments 路径下的最新数据,从而更新 UI。

总结

  • 服务器到客户端:通过 props 传递可序列化的数据。
  • 客户端到服务器:通过 Server Actions 触发服务器端逻辑,实现数据变更或复杂操作。Server Actions 提供了一种安全、高效的方式,将前端交互与后端逻辑紧密结合。

掌握服务器组件与客户端组件的协作机制,是构建高性能、可扩展 Next.js 应用的关键。通过合理地划分组件职责,并在必要时进行数据传递和交互,你可以充分发挥 Next.js 在服务器端渲染和客户端交互方面的优势。

健全的错误处理策略:不止是 error.tsx

在构建任何健壮的应用时,错误处理是不可或缺的一环。Next.js 提供了 error.tsx 作为路由级别的错误边界,但实际开发中,我们可能需要更细致、更灵活的错误处理方案。

error.tsx:路由级别的错误边界

我们已经在文章前面提到过 error.tsx。它是一个 React 错误边界,能够捕获其子组件树中发生的运行时错误,并提供一个备用 UI。记住,它必须是客户端组件("use client")。

适用场景:捕获整个路由段或页面渲染过程中的非预期错误。

局限性

  • 无法捕获布局组件(layout.tsx)中的错误。
  • 无法捕获同级或父级 error.tsx 中的错误。
  • 默认情况下,它会重置页面状态并刷新,可能不是所有错误场景都希望的行为。

在异步组件内部处理错误

对于服务器组件中的数据获取,你可以直接使用标准的 try...catch 语句来处理异步操作中可能发生的错误。这允许你更精确地控制错误发生时的行为,而不是简单地抛出到 error.tsx

示例:细粒度错误处理

// app/products/page.tsx

interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts() {
  try {
    const res = await fetch('https://api.example.com/products', { cache: 'no-store' }); // Next.js 15 中可省略,默认不缓存

    if (!res.ok) {
      // 如果响应状态码不是 2xx,手动抛出错误
      throw new Error(`Failed to fetch products: ${res.status} ${res.statusText}`);
    }

    const products: Product[] = await res.json();
    return products;
  } catch (error) {
    console.error("获取产品数据时出错:", error); // 记录错误
    // 你可以选择返回空数组,或者抛出更友好的错误信息
    throw new Error("抱歉,暂时无法加载产品列表。请稍后再试。"); 
  }
}

export default async function ProductsPage() {
  let products: Product[] = [];
  let errorMessage: string | null = null;

  try {
    products = await getProducts();
  } catch (error: any) {
    errorMessage = error.message;
  }

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">产品目录</h1>
      {errorMessage ? (
        <div className="text-red-600 text-center py-4">{errorMessage}</div>
      ) : (
        <ul className="space-y-4">
          {products.map((product) => (
            <li key={product.id} className="p-4 border rounded-md">
              <h2 className="text-xl font-semibold">{product.name}</h2>
              <p>价格: ¥{product.price.toFixed(2)}</p>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

这种方式的优势在于:

  • 更精确的控制:你可以在数据获取函数内部直接处理错误,而不是让它冒泡到整个页面。这对于不同类型的错误需要不同反馈时非常有用。
  • 用户友好反馈:可以在组件内部显示更具体、更友好的错误消息,而不是统一的错误页面。
  • 数据回退:在某些情况下,你可能希望在数据获取失败时,显示一部分默认数据或缓存数据,而不是完全的错误页面。

全局错误日志与监控

对于生产环境的应用,仅仅在 UI 上显示错误是不够的,你还需要将错误日志发送到外部服务进行监控和分析(如 Sentry、Datadog 等)。

  1. error.tsx 中记录error.tsx 组件的 useEffect 是一个很好的地方来记录客户端捕获的错误。

    // app/blog/error.tsx
    // ...
    useEffect(() => {
      // 将错误发送到你的日志服务
      console.error(error);
      // Sentry.captureException(error); // 示例:集成 Sentry
    }, [error]);
    // ...
    
  2. 在 Server Actions 或 API 路由中记录:由于 Server Actions 和 API 路由在服务器端运行,你可以直接使用 Node.js 环境的日志库(如 winstonpino),或者将其错误发送到云服务提供商的日志系统(如 AWS CloudWatch、Google Cloud Logging)。

    // app/actions.ts
    'use server';
    import { revalidateTag } from 'next/cache';
    
    export async function addPost(data: FormData) {
      try {
        await fetch('https://api.example.com/posts', { /* ... */ });
        revalidateTag('posts');
      } catch (error) {
        console.error("新增文章失败:", error);
        // 可以在这里返回一个错误状态给客户端
        return { success: false, message: "新增文章失败,请稍后再试。" };
      }
    }
    

总结

  • 路由级错误:使用 error.tsx 作为全局错误边界,捕获渲染期间的意外错误。
  • 组件内错误:在异步组件(尤其是服务器组件)内部使用 try...catch 进行细粒度的错误处理,提供更友好的用户反馈或回退机制。
  • 日志监控:将客户端和服务器端的错误都发送到集中式日志服务,以便及时发现和解决问题。

总结与最佳实践

  1. 服务器优先:默认在服务器组件中获取数据,以获得最佳性能和安全性。
  2. 理解 Next.js 15 的 fetch 变化:默认不再缓存响应,确保数据新鲜度。通过 cache: 'force-cache'next.revalidate 选项精细控制缓存行为。请求去重机制仍然有效,避免同一渲染中的重复请求。
  3. 拥抱 async/await:在服务器组件中,直接使用 async/await 就能获取数据,代码简洁明了。
  4. use Hook 简化客户端获取:当必须在客户端获取数据时,优先使用 React 19 的 use hook,它能与 Suspense 无缝集成,告别手动的 loading 状态管理。
  5. 专业处理边界情况:使用 loading.tsx 提供流畅的加载体验,使用 error.tsx 创建优雅的错误边界。
  6. Server Actions + Revalidation:使用 Server Actions 处理数据变更,并用 revalidatePathrevalidateTag 来保持UI与数据同步。

Next.js 嵌套路由与中间件:数据与逻辑的前哨站

作者 LeonGao
2025年8月16日 09:28

在现代 Web 应用的世界里,路由是城市道路,中间件是守在路口的警察,确保一切交通有序、安全。
Next.js 则是那位既懂交通规则、又能修路铺桥的工程师——你不仅可以在它的路网上自由嵌套路线,还可以让中间件在用户抵达目的地前对他们的身份、行李、甚至心情(如果你愿意)做检查。


一、嵌套路由的本质

在 Next.js 中,文件即路由的哲学让你少了很多配置文件的负担,但当你需要结构化复杂页面时,嵌套路由就派上了用场。

比如,你有一个博客系统:

/app
  /blog
    /page.js
    /[slug]
      /page.js
  • /blog → 博客列表页
  • /blog/[slug] → 某篇博客详情页

底层原理:

  • Next.js 会遍历 app 目录下的文件夹结构。
  • 目录名映射为 URL 路径,[param] 形式表示动态路由。
  • 嵌套文件夹会形成嵌套路由,父级路由可以包含 Layout,用来统一头部、底部、导航栏。

Layout 嵌套机制

// app/blog/layout.js
export default function BlogLayout({ children }) {
  return (
    <div>
      <header>Blog Header</header>
      <main>{children}</main>
    </div>
  );
}

这样 /blog/blog/[slug] 都会共享这个 BlogLayout,底层是组件树递归渲染,Next.js 会为每一层 Layout 建立独立 React 节点,从而实现父子关系。


二、中间件(Middleware)的使命

想象一下你有一个高档餐厅(网站),中间件就是门口的保安——

  • 检查身份证(鉴权)
  • 检查预订记录(权限控制)
  • 检查是否穿正装(条件跳转)
  • 甚至可以把迟到的人送去别的餐厅(重定向)

中间件的运行时机

  • 请求到达页面组件之前
  • 运行在 Edge Runtime(轻量、低延迟,全球分布)。
  • 可以读取和修改请求、响应。

底层机制

  • 你在项目根目录(或子目录)下放置一个 middleware.js 文件。
  • Next.js 会在构建时将它编译为 Edge Function。
  • 每次请求进入匹配的路径时,都会先经过中间件逻辑。

三、实战:嵌套路由 + 中间件

假设你有一个 /dashboard 路由和它的嵌套页面 /dashboard/settings,你想在用户进入这些页面前检查是否已登录。

目录结构:

/app
  /dashboard
    /page.js
    /settings
      /page.js
/middleware.js

中间件示例:

// middleware.js
import { NextResponse } from 'next/server';

export function middleware(req) {
  const token = req.cookies.get('token');
  
  if (!token) {
    // 未登录则跳转到登录页
    return NextResponse.redirect(new URL('/login', req.url));
  }
  
  // 已登录则放行
  return NextResponse.next();
}

// 限制中间件只匹配 dashboard 路由
export const config = {
  matcher: ['/dashboard/:path*']
};

四、嵌套路由与中间件的协作

嵌套路由提供结构化的页面层级,而中间件提供请求入口的守卫
就像机场一样:

  • 嵌套路由 → 航站楼结构(国际、国内、贵宾厅等分区)
  • 中间件 → 安检口(拦截违禁品、核对身份、放行)

好处:

  1. 安全:中间件阻挡未授权用户。
  2. 体验:减少无意义的页面渲染。
  3. 性能:Edge Runtime 在边缘节点直接处理,不必每次回到主服务器。

五、最佳实践建议

  1. 中间件逻辑要精简

    • 它运行在边缘节点,不适合做大量计算。
    • 适合做快速判断、重定向、设置 cookie。
  2. 嵌套路由中 Layout 复用 UI

    • 避免重复代码,让不同子页面共享样式和结构。
  3. 分层控制

    • 根目录 middleware.js 管全局规则。
    • 子目录 middleware.js 处理局部规则(Next.js 13+ 支持子目录中间件)。

六、幽默的尾声

嵌套路由像一座大厦的楼层结构,
中间件是大门口的保安,
而 Next.js 是那位能帮你造大厦、请保安、装电梯的承包商。

有人会问:
“那如果我没中间件,直接让所有人进来会怎样?”
——那就像把你家 Wi-Fi 密码贴在电梯里,很快就会发现隔壁邻居比你还熟悉你的路由结构

Next.js 入门实战:从零构建你的第一个 SSR 应用

作者 遂心_
2025年8月15日 23:26

序言

在当今前端开发中,Next.js 已成为构建高性能、SEO 友好应用的必备框架。今天我将带大家从零开始创建一个 Next.js 项目,并深入解析其服务器端渲染(SSR)机制的优势。

创建 Next.js 项目

Next.js 提供了便捷的脚手架工具,让我们可以快速初始化项目:

npx create-next-app@latest my-todo

这里使用 npx 命令的优势在于:

  • 无痕使用:无需全局安装依赖,避免污染全局环境
  • 即用即走:非常适合快速尝试新技术
  • 版本控制:始终使用最新版本的 create-next-app

当然,你也可以选择全局安装:

npm i -g create-next-app@latest

Next.js 与传统 React 应用的区别

特性 React (CSR) Next.js (SSR)
渲染位置 客户端浏览器 服务器端
初始加载速度 较慢(需下载所有JS) 较快(服务器返回完整HTML)
SEO 友好度 较差(爬虫难以解析) 优秀(直接返回完整内容)
适用场景 后台管理系统 内容型网站、企业站

理解 SSR 的核心优势

1. SEO 优化

传统 React 应用(CSR)在浏览器端渲染时,初始 HTML 只有一个空容器:

<div id="root"></div>

搜索引擎爬虫抓取时,只能看到一个空页面,严重影响 SEO。而 Next.js 的 SSR 在服务器端就完成了渲染,返回的是完整的 HTML 内容:

<h1>首页</h1>
<div>我在秋招,我去字节</div>

2. 性能提升

用户无需等待所有 JavaScript 加载完成就能看到内容,大大提升了首屏加载速度。

实战:创建你的第一个页面

在 Next.js 项目中,页面组件位于 app 目录下。我们创建一个简单的首页:

// app/page.tsx
import Image from "next/image";

export default function Home() {
  return (
    <>
      <h1>首页</h1>
      <div>我在秋招,我去字节</div>
    </>
  );
}

这个组件会在服务器端被渲染成 HTML,然后发送到客户端。注意我们使用了 Next.js 内置的 Image 组件,它可以自动优化图片性能。

运行你的 Next.js 应用

在项目目录下执行:

npm run dev

访问 http://localhost:3000,你将看到服务器渲染的页面。

如何验证 SSR 效果?

  1. 在浏览器中右键点击"查看页面源代码"
  2. 你将看到完整的 HTML 内容,而非空容器
  3. 这意味着搜索引擎爬虫可以直接抓取到页面内容

使用场景推荐

Next.js 特别适合以下场景:

  • 内容型网站:博客、新闻站点(SEO 关键)
  • 电商平台:商品列表页需要被搜索引擎收录
  • 企业官网:需要良好的搜索引擎排名
  • 掘金等技术社区:内容需要被广泛传播和搜索

总结

Next.js 通过 SSR 解决了传统 React 应用的两大痛点:

  1. SEO 不友好:服务器直接返回完整 HTML
  2. 首屏加载慢:用户立即看到内容而非空白页

deepseek_mermaid_20250815_05091b.png

Next.js 教程系列(二十五)测试策略:单元、集成与端到端测试

作者 鲫小鱼
2025年8月11日 09:39
前言 大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!! 第二十五章:测试

Next.js 中间件:掌握请求拦截与处理的核心机制(六)

2025年8月12日 23:58

Next.js 中间件是什么?

Next.js 中间件是一个在 请求处理 管道中运行的函数,它能够在请求到达页面或 API 路由之前 拦截请求 并对其进行处理。这种机制允许开发者执行各种操作,如 身份验证、重定向、请求修改、日志记录 等,而无需在每个路由中重复编写相同的代码。

核心原理:

Next.js 中间件基于 Edge Runtime(边缘运行时)构建,这意味着它们可以在全球的 CDN 边缘节点上运行。这种设计带来了显著的性能优势:

  1. 低延迟: 中间件在离用户最近的边缘节点执行,减少了请求到达源服务器的往返时间,从而加快了响应速度。
  2. 高扩展性: 边缘运行时能够轻松处理大量并发请求,无需担心服务器过载。
  3. 安全性: 可以在请求到达你的应用核心逻辑之前,进行身份验证、授权和安全检查。

中间件的生命周期与执行流程

Next.js 中间件的生命周期与请求处理流程紧密相连,它在请求到达应用程序代码之前或之后执行。理解其生命周期有助于更好地设计和实现中间件逻辑。

生命周期阶段

  1. 请求进入 (Incoming Request)

    • 当客户端向 Next.js 应用发送请求时,中间件是第一个接收到请求的组件。
    • 此时,中间件可以访问原始的 NextRequest 对象,其中包含了请求的所有信息(URL、Headers、Cookies 等)。
  2. 中间件执行 (Middleware Execution)

    中间件函数开始执行。在这个阶段,可以根据业务逻辑对请求进行处理。你可以执行以下操作:

    • 读取请求信息:检查请求路径、查询参数、请求头、Cookie 等。
    • 修改请求:例如,添加或修改请求头,或者根据逻辑重写 URL。
    • 执行逻辑判断:进行身份验证、权限检查、A/B 测试分流等。
    • 生成响应:直接返回一个 NextResponse 对象,从而终止请求并发送响应给客户端(例如,重定向到登录页,或返回错误信息)。
  3. 响应生成与传递 (Response Generation and Passing)

    • 如果中间件没有直接返回响应(即调用了 NextResponse.next()NextResponse.rewrite()),请求会继续传递。
    • NextResponse.next():请求继续流向匹配的 Next.js 页面或 API 路由。这是最常见的操作,表示中间件完成了它的任务,允许请求继续正常处理。
    • NextResponse.rewrite(url):请求被内部重写到另一个 URL。浏览器地址栏不会改变,但服务器会处理重写后的路径。这常用于美化 URL、国际化路由或将旧路径映射到新路径。
    • NextResponse.redirect(url):向客户端发送一个重定向响应(HTTP 307 或 308)。浏览器会收到重定向指令并加载新的 URL。这常用于未授权访问、强制 HTTPS 或处理旧链接。
  4. 页面/API 路由处理 (Page/API Route Handling)

    • 如果请求通过中间件并被允许继续,它将到达 Next.js 应用程序中匹配的页面组件或 API 路由处理程序。
    • 这些组件会生成最终的 HTML 页面或 API 响应。
  5. 响应返回 (Response Return)

    • 最终的响应(无论是中间件直接生成的,还是页面/API 路由生成的)会返回给客户端。
    • 在返回之前,中间件有机会再次修改响应头(例如,添加安全策略头、设置新的 Cookie 等)。

执行流程图解

上述流程可以用以下图表概括:

A[客户端请求] --> B{中间件 (middleware.ts)};
B -- 读取/修改请求 --> C{执行业务逻辑}; 
C -- 返回 NextResponse.next() --> D[匹配的页面/API路由];
C -- 返回 NextResponse.rewrite(url) --> D;
C -- 返回 NextResponse.redirect(url) --> E[客户端重定向];
D -- 生成响应 --> F[响应返回给客户端];
E -- 新请求 --> A;
B -- 直接返回响应 --> F;

结合使用场景分析

  • 身份验证:在中间件中检查用户会话或认证令牌。如果用户未登录且尝试访问受保护路由,中间件可以直接 redirect 到登录页。
  • A/B 测试:根据用户 ID 或其他条件,在中间件中 rewrite 请求到不同的页面版本,实现无感知的 A/B 测试。
  • 国际化 (i18n):根据用户浏览器语言偏好或 Cookie,在中间件中 rewrite URL 以包含语言前缀,例如将 /about 重写为 /en/about/zh/about,而用户在浏览器中看到的 URL 不变。
  • 日志记录与监控:在中间件中记录所有传入请求的元数据(如 IP 地址、User-Agent、请求时间等),用于后续的分析和监控,然后调用 NextResponse.next() 让请求继续。
  • 安全头部注入:在中间件中获取 NextResponse.next() 返回的响应对象,然后向其添加或修改安全相关的 HTTP 头部(如 Content-Security-Policy, X-Frame-Options 等),增强应用安全性。

同时,也需要认识到中间件并非适用于所有场景。以下是一些不适合在中间件中执行的任务:

  • 复杂的数据获取和操作:中间件不适合直接进行复杂的数据获取或操作。这些任务应在路由处理程序(Route Handlers)或服务器端工具函数中完成。
  • 繁重的计算任务:中间件应保持轻量级并快速响应,否则可能导致页面加载延迟。繁重的计算任务或长时间运行的进程应在专门的路由处理程序中完成。
  • 广泛的会话管理:虽然中间件可以处理基本的会话任务,但更广泛的会话管理应由专门的身份验证服务或在路由处理程序内部进行。
  • 直接数据库操作:不建议在中间件中执行直接的数据库操作。数据库交互应在路由处理程序或服务器端工具函数中完成。

理解这些限制有助于你更有效地利用 Next.js 中间件,并避免潜在的性能瓶颈。

如何使用 Next.js 中间件?

在 Next.js 15 中,你只需要在项目的根目录下(与 apppages 目录同级)创建一个名为 middleware.ts (或 middleware.js) 的文件即可。这个文件需要导出一个默认函数,该函数接收一个 NextRequest 对象作为参数,并返回一个 NextResponse 对象。

基本结构

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // ... 中间件逻辑 ...
  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * 匹配所有请求路径,除了以下划线开头的内部路径(如 _next/static)
     * 和文件扩展名(如 .ico, .png)
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

路径匹配 (Matching Paths)

Next.js 中间件默认会匹配项目中的所有路径。然而,在实际应用中,你通常需要让中间件只在特定的路径上运行。Next.js 提供了两种主要方式来定义中间件的运行路径:自定义 matcher 配置条件语句

matchermiddleware.ts 文件中 config 对象的一个属性,它允许你使用路径匹配模式来过滤请求。matcher 的值必须是常量,以便在构建时进行静态分析。它支持完整的正则表达式语法,因此非常灵活。

基本用法:

// middleware.ts
export const config = {
  matcher: '/about/:path*', // 匹配 /about 及其所有子路径,例如 /about/a, /about/a/b
};

匹配多个路径:

可以使用数组来匹配一个或多个路径。

// middleware.ts
export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'], // 同时匹配 /about 和 /dashboard 及其子路径
};

反向匹配(排除特定路径):

matcher 支持正则表达式,可以用来匹配除特定路径外的所有路径。这对于排除静态文件、API 路由等非常有用。

// middleware.ts
export const config = {
  matcher: [
    /*
     * 匹配所有请求路径,除了以下划线开头的内部路径(如 /_next/static, /_next/image)
     * 和根目录下的 favicon.ico 文件。
     * `?!` 是一个负向先行断言,表示不匹配紧随其后的模式。
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

matcher 配置规则:

  • 必须以 / 开头。
  • 可包含命名参数:/about/:path 可以匹配 /about/a/about/b,但不包含 /about/a/c
  • 可对命名参数使用修饰符(以 : 开头):
    • *:表示零个或多个,例如 /about/:path* 可匹配 /about/a/b/c
    • ?:表示零个或一个,例如 /about/:path? 可匹配 /about/about/a
    • +:表示一个或多个。
  • 可以使用括号中的正则表达式:/about/(.*)/about/:path_ 作用相同。

NextResponse API

NextResponse 扩展了标准的 Web Response API,用于创建、修改和返回响应。它提供了 next()redirect()rewrite() 等便捷方法,以及用于操作请求和响应头、Cookie 的功能。

1. NextResponse.next()

NextResponse.next() 允许请求继续流向匹配的 Next.js 页面或 API 路由。这是最常见的操作,表示中间件完成了它的任务,允许请求继续正常处理。你也可以通过 NextResponse.next({ request: newRequest }) 来修改请求对象并传递给下一个处理程序。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 可以在这里读取请求信息,例如记录日志
  console.log('请求路径:', request.nextUrl.pathname);

  // 继续请求,不进行任何修改
  return NextResponse.next();
}

2. NextResponse.redirect(url, status?)

NextResponse.redirect() 用于向客户端发送一个重定向响应(HTTP 307 或 308)。浏览器会收到重定向指令并加载新的 URL。这常用于未授权访问、强制 HTTPS 或处理旧链接。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const isAuthenticated = false; // 假设用户未认证
  if (!isAuthenticated && request.nextUrl.pathname !== '/login') {
    // 如果用户未认证且不在登录页,则重定向到登录页
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

3. NextResponse.rewrite(url)

NextResponse.rewrite() 允许你将一个传入路径内部重写到另一个 URL,而不会改变浏览器地址栏中的 URL。这对于创建更友好的 URL、国际化路由或将旧路径映射到新路径非常有用。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/old-page') {
    // 将 /old-page 内部重写到 /new-page,用户浏览器地址栏不变
    return NextResponse.rewrite(new URL('/new-page', request.url));
  }
  return NextResponse.next();
}

4. 操作请求/响应头 (Headers)

NextResponse 提供了 headers 属性,允许你设置响应头。你也可以通过 request.headers 访问请求头。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 设置响应头
  response.headers.set('X-Custom-Header', 'Hello from Middleware');
  response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');

  // 也可以读取请求头
  const userAgent = request.headers.get('user-agent');
  console.log('User-Agent:', userAgent);

  return response;
}

5. 操作 Cookie (Cookies)

NextRequestNextResponse 都提供了方便的 API 来获取和操作 Cookie。request.cookies 用于读取请求中的 Cookie,response.cookies 用于设置响应中的 Cookie。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 从请求中获取 Cookie
  const theme = request.cookies.get('theme');
  console.log('Current theme:', theme?.value);

  const response = NextResponse.next();

  // 在响应中设置 Cookie
  response.cookies.set('last_visit', new Date().toISOString(), { path: '/' });

  // 删除 Cookie
  // response.cookies.delete('some_old_cookie');

  return response;
}

NextResponse 总结:

NextResponse 是中间件中进行响应控制的核心。通过灵活运用 next()redirect()rewrite() 以及对 Headers 和 Cookies 的操作,你可以实现各种复杂的请求处理逻辑,从而增强 Next.js 应用的功能和用户体验。

NextRequestNextResponse

  • NextRequest 扩展了标准的 Web Request API,提供了更多 Next.js 特有的属性和方法,例如 nextUrl(包含解析后的 URL 信息)、cookies 等。
  • NextResponse 扩展了标准的 Web Response API,用于创建、修改和返回响应。它提供了 next()redirect()rewrite() 等便捷方法。

常见应用场景与实践

1. 身份验证与重定向

假设你有一个需要登录才能访问的 /dashboard 页面。你可以使用中间件来检查用户是否已认证,如果未认证则重定向到登录页。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const isAuthenticated = request.cookies.has('auth_token'); // 假设通过 cookie 判断认证状态
  const loginUrl = new URL('/login', request.url);

  // 如果用户尝试访问 /dashboard 且未认证,则重定向到登录页
  if (request.nextUrl.pathname.startsWith('/dashboard') && !isAuthenticated) {
    return NextResponse.redirect(loginUrl);
  }

  // 如果用户已认证且尝试访问 /login,则重定向到 /dashboard
  if (request.nextUrl.pathname.startsWith('/login') && isAuthenticated) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/login'], // 匹配 /dashboard 及其所有子路径,以及 /login 路径
};

实践要点:

  • 使用 request.cookies 访问请求中的 Cookie。
  • NextResponse.redirect(url) 用于执行客户端重定向(HTTP 307 或 308)。
  • new URL('/login', request.url) 构造完整的 URL,确保在不同环境下都能正确重定向。

2. URL 重写 (Rewriting)

重写允许你将一个传入路径映射到另一个内部路径,而不会改变浏览器地址栏中的 URL。这对于创建更友好的 URL 或处理内部路由非常有用。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 将 /old-page 重写到 /new-page,用户浏览器地址栏仍显示 /old-page
  if (request.nextUrl.pathname === '/old-page') {
    return NextResponse.rewrite(new URL('/new-page', request.url));
  }

  // 示例:将 /blog/post-slug 重写到 /blog/[slug] 的实际页面
  // 假设你的博客文章页面是 /app/blog/[slug]/page.tsx
  if (request.nextUrl.pathname.startsWith('/blog/')) {
    const slug = request.nextUrl.pathname.split('/').pop();
    if (slug) {
      return NextResponse.rewrite(new URL(`/blog/${slug}`, request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/old-page', '/blog/:path*'],
};

实践要点:

  • NextResponse.rewrite(url) 用于执行内部重写,URL 不变。
  • 重写通常用于将外部友好的 URL 映射到内部组件结构。

3. 设置响应头 (Setting Headers)

你可以在中间件中修改响应头,例如添加安全策略头(CSP)、设置 Cookie 或修改缓存控制。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 添加一个自定义响应头
  response.headers.set('X-Custom-Header', 'Hello from Middleware');

  // 设置一个 Cookie
  response.cookies.set('my_cookie', 'some_value', { path: '/', maxAge: 3600 });

  // 移除一个 Cookie
  // response.cookies.delete('another_cookie');

  // 设置内容安全策略 (CSP) 头
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
  );

  return response;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

实践要点:

  • 首先调用 NextResponse.next() 获取一个可修改的响应对象。
  • 通过 response.headers.set()response.cookies.set() 来操作响应头和 Cookie。

4. 国际化 (i18n) 路由

中间件是实现国际化路由的理想场所,你可以根据用户偏好或浏览器设置来重写 URL,以显示不同语言的内容。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_FILE = /\.(.*)$/;

const locales = ['en', 'zh', 'fr']; // 支持的语言
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 检查路径是否包含文件扩展名(如 .js, .css, .png 等),如果是则跳过中间件处理
  if (PUBLIC_FILE.test(pathname)) {
    return NextResponse.next();
  }

  // 检查路径是否已经包含语言前缀
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

  // 如果路径不包含语言前缀,则根据用户偏好或默认语言重写 URL
  const locale = request.cookies.get('NEXT_LOCALE')?.value || defaultLocale;

  request.nextUrl.pathname = `/${locale}${pathname}`;
  // 重写 URL,但浏览器地址栏不变
  return NextResponse.rewrite(request.nextUrl);
}

export const config = {
  matcher: [
    // 匹配所有路径,除了 API 路由、Next.js 内部文件和公共文件
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

实践要点:

  • 通过 request.nextUrl.pathname 获取当前请求路径。
  • 使用 request.cookies.get() 获取用户语言偏好。
  • NextResponse.rewrite() 用于在不改变浏览器 URL 的情况下,将请求重写到带有语言前缀的内部路径。

5. 错误处理与调试技巧

// middleware.ts
export function middleware(request: NextRequest) {
  try {
    // 业务逻辑
  } catch (error) {
    console.error('中间件错误:', error);
    return NextResponse.json(
      { error: '服务器内部错误' },
      { status: 500 }
    );
  }
}

调试工具

  1. 使用 console.log 查看边缘运行时日志
  2. 在响应头添加调试信息:
response.headers.set('X-Middleware-Debug', 'executed');
  1. 通过 ?__middlewareDebug=1 URL参数触发详细日志

Next.js 中间件的代码维护

如果项目比较简单,中间件的代码通常不会写很多,将所有代码写在一起倒也不是什么太大问题。可当项目复杂了,比如在中间件里又要鉴权、又要控制请求、又要国际化等等,各种逻辑写在一起,中间件很快就变得难以维护。如果我们要在中间件里实现多个需求,该怎么合理的拆分代码呢?

一种简单的方式是拆分为多个函数:

import { NextResponse } from 'next/server'

async function middleware1(request) {
  console.log(request.url)
  return NextResponse.next()
}

async function middleware2(request) {
  console.log(request.url)
  return NextResponse.next()
}

export async function middleware(request) {
  await middleware1(request)
  await middleware2(request)
}

export const config = {
  matcher: '/api/:path*',
}

一种更为优雅的方式是借助高阶函数:

import { NextResponse } from 'next/server'

function withMiddleware1(middleware) {
  return async (request) => {
    console.log('middleware1 ' + request.url)
    return middleware(request)
  }
}

function withMiddleware2(middleware) {
  return async (request) => {
    console.log('middleware2 ' + request.url)
    return middleware(request)
  }
}

async function middleware(request) {
  console.log('middleware ' + request.url)
  return NextResponse.next()
}

export default withMiddleware2(withMiddleware1(middleware))

export const config = {
  matcher: '/api/:path*',
}

请问此时的执行顺序是什么?试着打印一下吧。是不是感觉回到了学 redux 的时候?

但这样写起来还是有点麻烦,让我们写一个工具函数帮助我们:

import { NextResponse } from 'next/server'

function chain(functions, index = 0) {
  const current = functions[index];
  if (current) {
    const next = chain(functions, index + 1);
    return current(next);
  }
  return () => NextResponse.next();
}

function withMiddleware1(middleware) {
  return async (request) => {
    console.log('middleware1 ' + request.url)
    return middleware(request)
  }
}

function withMiddleware2(middleware) {
  return async (request) => {
    console.log('middleware2 ' + request.url)
    return middleware(request)
  }
}

export default chain([withMiddleware1, withMiddleware2])

export const config = {
  matcher: '/api/:path*',
}

请问此时的执行顺序是什么?答案是按数组的顺序,middleware1、middleware2。

如果使用这种方式,实际开发的时候,代码类似于:

import { chain } from "@/lib/utils";
import { withHeaders } from "@/middlewares/withHeaders";
import { withLogging } from "@/middlewares/withLogging";

export default chain([withLogging, withHeaders]);

export const config = {
  matcher: '/api/:path*',
}

具体写中间件时:

export const withHeaders = (next) => {
  return async (request) => {
    // ...
    return next(request);
  };
};

免费看片!一个开箱即用的、跨平台的影视聚合播放器!

作者 Java陈序员
2025年8月11日 09:31

大家好,我是 Java陈序员

今天,给大家介绍一个跨平台的开源影视聚合播放器,可免费看片!

项目介绍

MoonTV —— 一个基于 Next.js 构建、开箱即用的、跨平台的影视聚合播放器。支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。

功能特色

  • 多源聚合搜索:内置数十个免费资源站点,一次搜索立刻返回全源结果
  • 丰富详情页:支持剧集列表、演员、年份、简介等完整信息展示
  • 流畅在线播放:集成 HLS.js & ArtPlayer
  • 收藏 + 继续观看:支持 Redis/D1/Upstash 存储,多端同步进度
  • PWA:离线缓存、安装到桌面/主屏,移动端原生体验
  • 响应式布局:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸
  • 极简部署:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare
  • 智能去广告:自动跳过视频中的切片广告(实验性)

安装部署

MoonTV 支持 Vercel 和 Docker 部署,可使用 Docker 快速部署到自己的服务器上。

注意:部署时请务必设置密码保护并强烈建议关闭公网注册

Docker 一键部署

1、拉取镜像

docker pull ghcr.io/lunatechlab/moontv:latest

2、一键启动容器

## 指定并保存密码
docker run -d --name moontv -p 3000:3000 --env PASSWORD=your_password ghcr.io/lunatechlab/moontv:latest

Docker Compose 部署

1、创建 docker-compose.yaml 文件

  • LocalStorage 版本
services:
  moontv-core:
    image: ghcr.io/lunatechlab/moontv:latest
    container_name: moontv-core
    restart: unless-stopped
    ports:
      - '3000:3000'
    environment:
      - PASSWORD=your_password
    # 如需自定义配置,可挂载文件
    # volumes:
    #   - ./config.json:/app/config.json:ro
  • Redis 版本(推荐,多账户数据隔离,跨设备同步)
services:
  moontv-core:
    image: ghcr.io/lunatechlab/moontv:latest
    container_name: moontv-core
    restart: unless-stopped
    ports:
      - '3000:3000'
    environment:
      - USERNAME=admin
      - PASSWORD=admin_password
      - NEXT_PUBLIC_STORAGE_TYPE=redis
      - REDIS_URL=redis://moontv-redis:6379
      - NEXT_PUBLIC_ENABLE_REGISTER=true
    networks:
      - moontv-network
    depends_on:
      - moontv-redis
    # 如需自定义配置,可挂载文件
    # volumes:
    #   - ./config.json:/app/config.json:ro
  moontv-redis:
    image: redis:alpine
    container_name: moontv-redis
    restart: unless-stopped
    networks:
      - moontv-network
    # 如需持久化
    # volumes:
    #   - ./data:/data
networks:
  moontv-network:
    driver: bridge

2、一键启动

docker-compose up -d

部署成功后,浏览器访问:

http://{ip/域名}:30000

输入访问密码,即可开始使用。

系统截图

  • 首页

  • 搜索

  • 电影

  • 剧集

  • 综艺

  • 视频播放

本地开发

依赖环境 Node.js20+.

1、克隆项目代码

git clone https://github.com/LunaTechLab/MoonTV.git

2、进入项目目录并安装依赖

cd MoonTV

pnpm install

3、在项目根目录创建配置文件 .env.development, 并设置密码

## 填入密码
PASSWORD=

4、启动服务

pnpm run dev

5、浏览器访问

http://localhost:3000/

访问密码即为 .env.development 配置文件中设置的 PASSWORD 值。

MoonTV 凭借多源聚合搜索功能,轻松实现看片功能。部署也十分便捷,一条 Docker 命令或者可以免费托管到 Vercel。不过需要注意的是,该项目仅供学习和个人使用,请勿用于商业用途或公开服务,用户需对自身使用行为负责。如果你渴望一站式的追剧自由,不妨试试 MoonTV~

项目地址:https://github.com/LunaTechLab/MoonTV

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


❌
❌