Sawana Huang Avatar

Sawana Huang

Design Patterns

Next.js 框架下客户端和服务端设计模式的分析、讨论、实践与示例

设计模式

在 Next.js 框架下,网站应用在客户端和服务端都会使用各种设计模式。本文将分析、讨论、解释、实践这些设计模式,并提供示例代码。

在这篇文档中,我会和你讨论设计模式的思路,它们的应用模式,以及一些实际的例子。主要基于 Nextjs 框架和 Typescript。

Overview

在现代 Web 工程(尤其是 React/Next.js 生态)中,我们不再死记硬背传统的“23种 GOF 设计模式”。相反,我们会混合使用传统的面向对象模式(在服务端逻辑、API 路由中常见)和React 特有的组件模式(在前端交互中常见)

第一类:通用架构与逻辑模式 (偏后端/业务逻辑)

  • 单例模式 (Singleton Pattern):保证一个类只有一个实例,并提供一个访问它的全局访问点。
  • 工厂模式 (Factory Pattern):定义一个用于创建对象的接口,让子类决定实例化哪一个类。
  • 策略模式 (Strategy Pattern):定义一系列算法(业务规则),把它们一个个封装起来,并且使它们可以相互替换。
  • 责任链模式 (Chain of Responsibility Pattern):使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。
  • 适配器模式 (Adapter Pattern):将一个类的接口转换成客户希望的另一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作。
  • 代理模式 (Proxy Pattern):为其他对象提供一种代理以控制对这个对象的访问。

第二类:React 组件与前端模式 (偏 UI/交互)

  • 提供者模式 (Provider Pattern):解决组件树层级过深时的数据传递问题(Prop Drilling),通过上下文共享状态。
  • 复合组件模式 (Compound Component Pattern):一组组件协同工作,共享隐式状态,以此来构建复杂的 UI 控件。
  • 自定义 Hook 模式 (Custom Hooks Pattern):将组件逻辑(状态、副作用)提取到可重用的函数中。这是 React 这一代的“逻辑复用”标准,取代了以前的 Mixins 和 HOC。
  • 容器/展示组件模式 (Container/Presentational Pattern):将“逻辑处理(如何工作)”与“UI 渲染(看起来怎样)”分离。
  • 依赖注入 (Dependency Injection - DI):一种实现控制反转(IoC)的技术,将依赖对象传给被调用者,而不是被调用者自己创建。

单例模式

| 保证一个类只有一个实例,并提供一个访问它的全局访问点。

单例模式要解决的问题在于我们需要创建和维护一个带有状态的实例,这个实例应该在一开始就被创建,在过程中被更新和访问。

它的核心思路在于检查这个实例有没有被创建,如果没有则创建,有则复用。

在 Nextjs 中,我们会在客户端和服务端都会遇到需要使用单例模式的情况。

Redux-Toolkit 和 Prisma 中的单例模式

一个典型的客户端例子是 Redux-tookit 的 store 对象,我们通常会在多个组件中共用一个 store。根据官方的文档,它采用了if检查的方式:

provider.tsx (example)
const storeRef = useRef<AppStore>(undefined);
if (!storeRef.current) {
  // Create the store instance the first time this renders
  storeRef.current = makeStore();
}

如果你的 eslint 配置比较严格,它可能会不喜欢你访问和更改 ref.current 。这时你可以采用另一个方案,React 的 useState 惰性初始化方法,它和前一个方法的效果是一致的:

provider.tsx (example)
const [store] = useState(() => makeStore());

如果你感兴趣,我在学习 Redux-toolkit + Nextjs 时创建了一个 demo 项目

而在服务端,典型的例子是 Prisma 在 Nextjs 中创建数据库连接时的 prisma 实例:

lib/prisma.ts
import { PrismaClient } from "../app/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const globalForPrisma = global as unknown as {
  prisma: PrismaClient;
};

const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL,
});

// *关键代码在这里
const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    adapter,
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

策略模式 (Strategy Pattern)

| 定义一系列算法(业务规则),把它们一个个封装起来,并且使它们可以相互替换。

策略模式要解决的问题是代码中日益膨胀的 if-elseswitch-case 逻辑。当一个业务行为(如登录、支付、导出文件)存在多种实现方式时,硬编码的条件判断会违反“开闭原则”(OCP),即每次新增一种方式都要修改原有代码。

它的核心思路是:利用多态。定义一个统一的接口,让不同的策略类去实现这个接口,在运行时根据上下文动态选择策略。你也可以认为它是一个“超级 switch” 方法

在 TypeScript 和 Next.js 的实践中,策略模式通常由以下四个核心要素构成:

  1. 策略接口 (The Interface/Contract): 这是所有策略必须遵守的“契约”。它规定了策略长什么样(比如必须有一个 authenticate 方法)。它的存在保证了调用方可以统一地对待所有策略,而无需关心具体细节。

  2. 具体策略 (Concrete Strategies): 这是真正的“干活工兵”。它们是实现了上述接口的具体逻辑(如 EmailStrategy, GoogleStrategy)。我们可以选择使用 Class(当需要依赖注入或维护内部状态时)或者简单的 Function/Object(当逻辑是纯函数时)来实现。

  3. 策略注册表 (The Registry): 这是一个映射表(通常是 Record<string, Strategy> 或 Map)。它充当了“调度中心”的角色,负责根据传入的 Key(如 'google')快速匹配到对应的具体策略。

  4. 调用方 (The Client): 这是我们的业务主流程(如 Server Action)。它只持有策略接口和注册表。关键在于,调用方完全不认识具体的策略实现类,它只是向注册表索要一个策略,然后执行接口定义的方法。

你可能会问:“我直接写一个 switch 语句不也一样吗?”

策略模式的真正价值在于解耦维护性,具体体现在当我们新增一个策略(例如新增“微信登录”)时的变更成本:

  • 需要动的地方:你需要创建一个新的 WechatStrategy 文件,并在 注册表 中添加一行配置。
  • 不需要动的地方:你完全不需要修改 调用方(主业务流程)的代码,也不需要修改 策略接口

这就完美符合了软件工程中的 开闭原则 (Open/Closed Principle):对扩展开放,对修改关闭。这极大地降低了因修改核心逻辑而引入 Bug 的风险。

多渠道登录的策略模式示例

以“多渠道登录”为例,我们在 Server Action 中通常需要处理 Email、Google、Github 等多种登录方式。

首先,定义统一的策略接口和具体的实现类:

lib/auth/strategies.ts
import { z } from "zod";

// 1. 策略接口 (The Interface)
// 所有的登录策略都必须遵守这个协议
export interface AuthStrategy {
  name: string;
  authenticate(
    formData: FormData,
  ): Promise<{ success: boolean; userId?: string }>;
}

// 2. 具体策略 (Concrete Strategies)

// 策略 A: 邮箱密码登录
export const emailStrategy: AuthStrategy = {
  name: "email",
  async authenticate(formData) {
    const email = formData.get("email");
    const password = formData.get("password");
    // 模拟数据库验证逻辑
    if (email === "[email protected]" && password === "123456") {
      return { success: true, userId: "user_01" };
    }
    return { success: false };
  },
};

// 策略 B: Google OAuth (模拟)
export const googleStrategy: AuthStrategy = {
  name: "google",
  async authenticate(formData) {
    const idToken = formData.get("id_token");
    // 模拟 Google Token 验证
    if (idToken) {
      return { success: true, userId: "google_user_123" };
    }
    return { success: false };
  },
};

// 策略 C: Github OAuth (模拟)
export const githubStrategy: AuthStrategy = {
  name: "github",
  async authenticate(formData) {
    // ... Github 验证逻辑
    return { success: true, userId: "github_user_456" };
  },
};

接下来,在 Next.js 的 Server Action 中,我们使用一个 Map 来替代 switch 语句,实现策略的动态分发:

app/actions/login.ts
"use server";

import {
  emailStrategy,
  googleStrategy,
  githubStrategy,
  type AuthStrategy,
} from "@/lib/auth/strategies";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

// 3. 策略注册表 (The Registry)
// 使用 Record 来管理策略,避免 switch-case
const strategies: Record<string, AuthStrategy> = {
  email: emailStrategy,
  google: googleStrategy,
  github: githubStrategy,
};

// 4. 调用方 (The Client)
export async function loginAction(formData: FormData) {
  const type = formData.get("type") as string;

  // *关键代码:动态匹配策略
  const strategy = strategies[type];

  if (!strategy) {
    throw new Error(`Unsupported login type: ${type}`);
  }

  // 执行策略,主流程无需关心具体是哪种实现
  const result = await strategy.authenticate(formData);

  if (result.success) {
    cookies().set("session", result.userId!);
    redirect("/dashboard");
  } else {
    return { error: "Authentication failed" };
  }
}

设计指南:处理超过三个条件分支时引入策略模式

  1. 消除条件判断:当你发现某个函数的 switch 分支超过 3 个,且每个分支逻辑都很复杂时,应立即考虑重构为策略模式。
  2. 利用 TypeScript 索引类型:在定义 strategies 映射表时,可以使用 TypeScript 的 keyof 确保类型安全,防止调用不存在的策略。
  3. 依赖注入:如果策略逻辑复杂(例如依赖数据库),可以将策略定义为 Class (类),并在实例化时注入 DB Client,而不是像示例中那样使用简单的 Object (函数)。

工厂模式 (Factory Pattern)

| 定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。

工厂模式的核心在于封装创建逻辑。在复杂的应用中,对象的创建往往不是简单的 new Class(),而是需要根据特定条件(如配置、环境、输入数据)来决定创建哪种对象,或者在创建过程中需要进行复杂的初始化参数组装。

简单来说,策略模式封装了一系列方法,工厂模式封装了一系列“实例”。如果说策略模式是“选择怎么做(算法)”,那么工厂模式就是“选择创建什么(对象)”。

核心思路:将“使用”与“创建”解耦

在没有工厂模式时,调用者需要亲自实例化对象,这意味着调用者必须依赖具体的类。一旦类名发生变化,或者实例化逻辑变复杂,调用者代码就必须修改。

引入工厂后:

  1. 调用者 (Client):只告诉工厂“我想要一个什么样的对象(比如类型是 'hero')”。
  2. 工厂 (Factory):负责处理所有脏活累活(判断类型、初始化 props、错误处理),最后把成品对象交给调用者。

前端示例:CMS 动态组件工厂

在 Next.js 结合 Headless CMS(如 Strapi, Contentful)的开发中,这是工厂模式最高频的出现场景。

假设这样的场景,后端 API 返回一个包含不同页面区块(Blocks)的 JSON 数组,前端需要根据 block_type 动态渲染对应的 React 组件。我们不希望在页面组件中写一堆 if-else 来判断渲染哪个组件。

我们可以创建一个“组件工厂”:

components/block-factory.tsx
import { HeroSection } from "@/components/hero-section";
import { FeatureList } from "@/components/feature-list";
import { CallToAction } from "@/components/cta";
import { EmptyBlock } from "@/components/empty-block";

// 1. 定义组件映射表 (The Registry)
// 这就像是工厂的“生产目录”
const BLOCK_COMPONENTS: Record<string, React.ComponentType<any>> = {
  hero_section: HeroSection,
  feature_list: FeatureList,
  cta_button: CallToAction,
};

interface BlockData {
  id: string;
  type: string; // CMS 返回的区块类型标识
  data: any; // 区块的具体数据
}

// 2. 组件工厂函数 (The Factory Component)
export function BlockFactory({ block }: { block: BlockData }) {
  // 根据类型查找组件
  const Component = BLOCK_COMPONENTS[block.type];

  // 处理未知类型的“容错生产”
  if (!Component) {
    console.warn(`Unknown block type: ${block.type}`);
    return <EmptyBlock type={block.type} />;
  }

  // 生产组件实例
  return <Component key={block.id} {...block.data} />;
}

在页面中使用这个工厂:

app/page.tsx
import { BlockFactory } from "@/components/block-factory";

async function getPageContent() {
  // 模拟从 CMS 获取的数据
  return [
    { id: "1", type: "hero_section", data: { title: "Hello Factory" } },
    { id: "2", type: "feature_list", data: { items: ["A", "B"] } },
    { id: "3", type: "unknown_type", data: {} }, // 测试容错
  ];
}

export default async function Page() {
  const blocks = await getPageContent();

  return (
    <main>
      {blocks.map((block) => (
        // 把渲染任务全权委托给工厂
        <BlockFactory key={block.id} block={block} />
      ))}
    </main>
  );
}

后端实战:API 统一错误构建工厂

在 Next.js 的 API Route 中,我们经常需要返回各种 HTTP 错误。为了保持 API 响应格式的统一(例如都包含 code, message, timestamp),我们可以使用工厂模式来生成 Response 对象,而不是在每个路由里手动拼凑 JSON。

lib/api/response-factory.ts
import { NextResponse } from "next/server";

// 错误响应工厂
export class ApiResponseFactory {
  static success(data: any, status = 200) {
    return NextResponse.json(
      {
        success: true,
        data,
        timestamp: new Date().toISOString(),
      },
      { status },
    );
  }

  static error(message: string, code: string, status = 400) {
    return NextResponse.json(
      {
        success: false,
        error: { code, message },
        timestamp: new Date().toISOString(),
      },
      { status },
    );
  }

  // 具体的工厂方法:未授权错误
  static unauthorized(message = "Unauthorized access") {
    return this.error(message, "AUTH_ERROR", 401);
  }

  // 具体的工厂方法:未找到资源
  static notFound(resource = "Resource") {
    return this.error(`${resource} not found`, "NOT_FOUND", 404);
  }
}

使用方式:

app/api/user/route.ts
import { ApiResponseFactory } from "@/lib/api/response-factory";

export async function GET(request: Request) {
  const session = null; // 模拟无权限

  if (!session) {
    // 语义清晰,且无需关心底层 JSON 结构
    return ApiResponseFactory.unauthorized();
  }

  return ApiResponseFactory.success({ user: "Sawana" });
}

设计指南:简单工厂 vs 工厂方法

  1. 简单工厂 (Simple Factory):像上面的 BlockFactoryApiResponseFactory,直接在一个函数或类中处理所有创建逻辑。这在 Web 开发中最常用,适合产品种类(组件类型)不经常剧烈变化的场景。
  2. 工厂方法 (Factory Method):定义一个创建接口,让子类决定实例化哪个类。这在前端较少见,通常用于编写极度复杂的、需要高度扩展的底层库(如 ORM 的数据库连接器,支持 PG, MySQL, SQLite 等不同驱动的创建)。
  3. React 特性:在 React 中,组件本身就是工厂。高阶组件 (HOC) 和 Render Props 本质上也是工厂模式的变体,它们都在动态地“生产”UI。

适配器模式 (Adapter Pattern)

| 将一个类的接口转换成客户希望的另一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作。

适配器模式的核心在于兼容。当外部依赖发生变更(如 API 升级),或者我们需要对接旧版本客户端时,我们不修改核心业务逻辑,而是通过一个中间层来转换数据结构。

适配器模式在现代全栈开发中非常常见,特别是当我们依赖第三方服务(如 Stripe, Auth0, CMS)时。第三方 API 的数据结构可能会随着版本升级而发生断裂式变化,但我们不希望为了适应新的 API 而重写整个应用层或前端组件的代码。

它的核心思路是:创建一个中间层(函数或类),将“外部的、不可控的数据结构”映射为“内部的、可控的接口”。

在 Next.js 项目中,这种模式常用于 API RouteServer Actions 的数据处理层。

Stripe API 的版本控制中用到的适配器

一个经典的案例是 Stripe 的 API 版本控制。Stripe 能够长期支持旧版本的 API 请求,其核心机制是内部维护了一套 Transformer(转换器)。当用户请求旧版本(如 2023-xx)时,系统会将最新的内部数据结构通过转换器"回滚"映射为旧版本的格式。我是在 ByteMonk 的 Why Stripe's API Never Breaks | Date-Based Versioning Explained 视频中了解了关于 Stripe API 基础设施的细节。

我们可以模仿这个思路,在 Next.js 中处理接口的版本兼容。

假设我们的系统内核已经升级到 2025 版,但仍需支持 2023 版的外部调用。我们需要定义两个版本的接口,并实现一个 Transformer 函数:

lib/adapters/order-transformer.ts
// 1. Target Interface (客户端期望的旧版本 2023)
interface OrderV2023 {
  id: string;
  charge_amount: number; // 单位: 分
  created: number; // timestamp
}

// 2. Adaptee Interface (系统内部的新版本 2025)
interface OrderV2025 {
  id: string;
  payment_intent: {
    amount_decimal: string; // 精度更高的字符串
    currency: string;
  };
  created_at: Date; // Date 对象
}

/**
 * Transformer: 将新版数据结构 适配为 旧版数据结构
 * 这里的核心是单纯的数据映射,不包含业务副作用
 */
export function transformToV2023(order: OrderV2025): OrderV2023 {
  return {
    id: order.id,
    // 转换金额格式:String -> Number
    charge_amount: parseInt(order.payment_intent.amount_decimal, 10),
    // 转换时间格式:Date -> Timestamp
    created: Math.floor(order.created_at.getTime() / 1000),
  };
}

在 Next.js 的 API 路由中,我们根据请求头中的版本号动态应用这个适配器:

app/api/orders/route.ts
import { transformToV2023 } from "@/lib/adapters/order-transformer";

export async function GET(request: Request) {
  const version = request.headers.get("stripe-version");

  // 模拟获取系统内部最新数据 (V2025)
  const currentOrder = await db.order.findFirst();

  if (version === "2023-08-16") {
    // 应用适配器,返回旧版结构
    return Response.json(transformToV2023(currentOrder));
  }

  // 默认返回最新结构
  return Response.json(currentOrder);
}

设计指南:无状态的 Transformer 函数

当我们自己在工程中实现适配器模式时,有几条关键的实践原则:

  1. 保持纯函数 (Pure Function):适配器函数应该只负责数据结构的转换(Mapping),不要在里面执行数据库查询或 API 请求等副作用。
  2. 类型优先 (Type First):利用 TypeScript,先定义好 Target (输出) 和 Adaptee (输入) 的 Interface,这能最大程度避免字段映射错误。
  3. 单向依赖:适配器应该依赖于“旧接口”和“新接口”,而核心业务逻辑不应该依赖适配器。适配器是边缘层的胶水代码。

代理模式 (Proxy Pattern)

| 为其他对象提供一种代理以控制对这个对象的访问。

代理模式的核心关键词是 控制 (Control)增强 (Enhancement)

它在不改变原始对象(或函数)接口的前提下,在访问目标对象之前或之后加入额外的逻辑。这就像是一个“中间人”或“拦截器”,它拦截了外界的请求,处理完一些杂事(如鉴权、缓存、日志)后,再决定是否把请求放行给目标。

核心思路:拦截与透传

与适配器模式(改变接口形状)不同,代理模式严守接口一致性。调用者不知道自己调用的是代理还是真实对象。

在 Next.js 中,代理模式主要解决以下横切关注点(Cross-cutting Concerns):

  • 保护代理:控制访问权限(鉴权)。
  • 缓存代理:为昂贵的操作提供结果缓存。
  • 远程/虚拟代理:隐藏复杂的网络请求细节。

宏观实战:Next.js Middleware

Next.js 的 middleware.ts 本质上就是整个应用路由系统的保护代理

场景:所有的页面请求在到达具体的 page.tsx 之前,都会先经过 Middleware。我们在这里拦截请求,检查用户是否登录(鉴权),如果未登录则重定向,否则“放行”到目标页面。

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

// 这就是一个宏观的代理函数
export function middleware(request: NextRequest) {
  // 1. 前置拦截逻辑 (Pre-processing)
  const token = request.cookies.get("auth_token");

  if (!token) {
    // 控制访问:拒绝请求并重定向
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // 2. 增强请求 (Enhancement)
  // 比如给请求头加一个追踪 ID
  const response = NextResponse.next();
  response.headers.set("x-trace-id", crypto.randomUUID());

  // 3. 放行 (Forwarding)
  return response;
}

export const config = {
  matcher: "/dashboard/:path*",
};

微观实战:使用 JS Proxy 增强 API 客户端

在代码层面,JavaScript 提供了强大的原生 Proxy 对象。我们可以利用它来创建一个自动处理 Token 注入和错误日志的 API 客户端,而无需在每次 fetch 时都手动写 header。

场景:我们需要一个 apiClient 对象,它拥有和 fetch 一样的能力,但会自动在 Header 中带上 Token,并且在请求失败时自动上报日志。

lib/api-client-proxy.ts
type FetchFn = typeof fetch;

// 目标对象:原始的 fetch 函数
const originalFetch: FetchFn = fetch;

// 创建代理处理器 (Handler)
const fetchHandler: ProxyHandler<FetchFn> = {
  // 拦截函数调用 (Traps)
  apply: async function (target, thisArg, argumentsList) {
    const [url, config] = argumentsList;

    // 1. 前置增强:自动注入 Authorization Header
    const enhancedConfig: RequestInit = {
      ...config,
      headers: {
        ...config?.headers,
        Authorization: `Bearer ${process.env.API_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
    };

    console.log(`[Proxy Log] Requesting: ${url}`);

    try {
      // 2. 透传调用:执行原始 fetch
      const response = await target.apply(thisArg, [url, enhancedConfig]);

      // 3. 后置增强:统一错误检查
      if (!response.ok) {
        console.error(`[Proxy Error] ${response.status} on ${url}`);
        // 可以在这里抛出自定义错误工厂生成的 Error
      }

      return response;
    } catch (error) {
      // 异常捕获
      console.error("[Proxy Network Fail]", error);
      throw error;
    }
  },
};

// 创建代理实例
// 使用时,apiClient 就和 fetch 一模一样,但自带神力
export const apiClient = new Proxy(originalFetch, fetchHandler);

使用时,调用者完全无感知:

app/actions/get-data.ts
"use server";
import { apiClient } from "@/lib/api-client-proxy";

export async function getData() {
  // 看起来像普通 fetch,实际上已经经过了 Proxy 的处理
  // 自动带上了 Token,且会有日志输出
  const res = await apiClient("https://api.example.com/data");
  return res.json();
}

设计指南:代理 vs 装饰器 vs 适配器

这三个模式很容易混淆,这里有一个快速区分的心智模型:

  1. 代理模式 (Proxy)不改变接口,主要目的是控制访问(权限、缓存、懒加载)。
    • 例子:Middleware 拦截请求。
  2. 适配器模式 (Adapter)改变接口,主要目的是兼容
    • 例子:把 Stripe V2 数据转成 V1 结构。
  3. 装饰器模式 (Decorator)不改变接口,主要目的是功能增强(通常用于类的方法)。
    • 注:JS 的 Proxy 可以看作是一种动态的装饰器实现。在 Next.js 中,高阶组件 (HOC) 也是一种组件级的代理/装饰器。

TO BE CONTINUE...