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

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 等)。

执行流程图解

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

ini 复制代码
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 对象。

基本结构

typescript 复制代码
// 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 的值必须是常量,以便在构建时进行静态分析。它支持完整的正则表达式语法,因此非常灵活。

基本用法:

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

匹配多个路径:

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

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

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

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

typescript 复制代码
// 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 }) 来修改请求对象并传递给下一个处理程序。

typescript 复制代码
// 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 或处理旧链接。

typescript 复制代码
// 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、国际化路由或将旧路径映射到新路径非常有用。

typescript 复制代码
// 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 访问请求头。

typescript 复制代码
// 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;
}

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

typescript 复制代码
// 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 页面。你可以使用中间件来检查用户是否已认证,如果未认证则重定向到登录页。

typescript 复制代码
// 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 或处理内部路由非常有用。

typescript 复制代码
// 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 或修改缓存控制。

typescript 复制代码
// 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,以显示不同语言的内容。

typescript 复制代码
// 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. 错误处理与调试技巧

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

调试工具

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

Next.js 中间件的代码维护

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

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

javascript 复制代码
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*',
}

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

javascript 复制代码
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 的时候?

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

javascript 复制代码
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。

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

javascript 复制代码
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*',
}

具体写中间件时:

javascript 复制代码
export const withHeaders = (next) => {
  return async (request) => {
    // ...
    return next(request);
  };
};
相关推荐
学Java的bb2 小时前
JavaWeb-后端Web实战(IOC + DI)
前端
pe7er3 小时前
React Native 多环境配置全攻略:环境变量、iOS Scheme 和 Android Build Variant
前端·react native·react.js
柯北(jvxiao)3 小时前
Vue vs React 多维度剖析: 哪一个更适合大型项目?
前端·vue·react
知识分享小能手3 小时前
Vue3 学习教程,从入门到精通,Vue 3 + Tailwind CSS 全面知识点与案例详解(31)
前端·javascript·css·vue.js·学习·typescript·vue3
石小石Orz4 小时前
React生态蓝图梳理:前端、全栈与跨平台全景指南
前端
袁煦丞4 小时前
8.12实验室 指尖魔法变出艺术感 Excalidraw:cpolar内网穿透实验室第495个成功挑战
前端·程序员·远程工作
烛阴4 小时前
Dot
前端·webgl
Gene_20224 小时前
使用行为树控制机器人(三) ——通用端口
前端·机器人
excel5 小时前
JavaScript 中的二进制数据:ArrayBuffer 与 SharedArrayBuffer 全面解析
前端