每个 Next.js 项目都是以相同的方式开始的:你运行 `npx create-next-app`,编写一些页面代码,或许再添加一两个 API 路由,这时一切看起来都井井有条。

但随后项目开始发展,功能越来越多。可能会出现第二个应用程序,或者一个独立的管理员控制面板、一个营销网站,又或者一个专为移动端设计的 API。突然间,你发现自己需要在不同的代码仓库之间复制组件,重复编写业务逻辑,还会为这些认证功能的归属问题争论不休,同时不禁会问自己:到底哪里出了问题?

答案几乎总是与架构有关——或者说,是缺乏合理的架构设计。这里所指的架构并不是那种记录在 Notion 文档中的抽象概念,而是真正融入你的代码结构、模块划分方式,以及你在项目开始时就使用的工具中的架构。

这篇文章将为你提供实用的建议,帮助你在 Next.js 中构建层次分明、可复用的架构。

你将学习到关于 App Router 的组件布局规则,如何根据功能需求设计可扩展的代码文件夹结构,如何利用 Turborepo 在不同应用程序之间共享逻辑,如何使用 Server Components 明确界定数据获取的范围,如何制定与你的代码层次结构相匹配的测试策略,以及如何配置 CI/CD 流程,以确保只构建和测试那些真正发生了变化的部分。

读完这篇文章后,你将获得一个可以实际应用的蓝图,而不仅仅是一个可供欣赏的设计方案。

目录

核心问题:无意识的耦合

当某个组件直接访问全局状态存储时,当一个页面从三个不同的目录中导入功能模块时,当你的认证逻辑分散在 `/lib`、`/helpers` 和 `/utils` 等文件夹中且没有明确的负责者时,每一个文件都会过度了解其他所有文件的运作方式。

这个应用程序仍然可以正常运行。但如今,修改其中一个部分就会导致另外三个部分出现故障;新功能的集成需要花费整整一周的时间;而添加第二个应用程序时,往往意味着需要复制第一个应用程序中的一半代码。

分层架构通过为所有内容指定明确的位置,并使这些位置具有特定的意义,从而解决了这些问题。

第一层:应用路由器与代码组织结构

Next.js 13及后续版本引入了应用路由器,这种基于文件系统的路由模型具有非常强大的功能:它允许将与某个路由相关的所有内容都放在该路由对应的文件夹中。

在应用路由器出现之前,页面文件存放在/pages目录下,组件文件存放在/components目录下,而数据获取逻辑则分散在各处。应用路由器的出现改变了这一状况——现在,一个路由段可以将其布局、加载状态、错误处理机制、服务器交互逻辑,甚至本地组件都集中在同一个文件夹中。

代码组织的真正含义

/dashboard这个路由为例,在应用路由器的架构下,其对应的文件夹结构可能如下所示:

app/
  dashboard/
    page.tsx              # 该路由的入口文件
    layout.tsx            # 专门用于仪表盘的布局文件
    loading.tsx           # 显示加载状态的文件
    error.tsx             # 处理错误情况的文件
    components/
      StatsCard.tsx       # 仅在仪表盘中使用
      ActivityFeed.tsx
    lib/
      queries.ts          # 专为该路由设计的数据获取逻辑
      formatters.ts       | 用于仪表盘的数据格式化函数

关键在于:StatsCard.tsxqueries.ts并不属于整个应用程序,它们只属于/dashboard这个路由段。因此,当你删除或重构仪表盘相关代码时,只会影响到这个文件夹,而不会影响其他部分的功能。

这就是所谓的“代码组织”。这个概念本身并不新鲜,但应用路由器使得在Next.js中采用这种结构变得非常自然、便捷。

就近原则

一个很好的设计准则是:文件应该尽可能地放在它被使用的位置附近。如果某个文件只在一个路由中被使用,那么它就应该放在这个路由对应的文件夹中;如果它被同一个父路由段下的两个不同路由所使用,那么它的位置应该上升一级;而如果它在整个应用程序中都被使用,那么它就应该被放在一个共享的文件夹中。

app/
  (marketing)/          | 营销相关路由组
    layout.tsx          | 营销页面共用的布局文件
    page.tsx
    about/
      page.tsx
  (dashboard)/
    layout.tsx          | 应用程序页面共用的布局文件
    dashboard/
      page.tsx
    settings/
      page.tsx

通过将相关文件放在同一个文件夹中,我们可以方便地在不同的路由段之间共享布局资源,而不会导致URL结构变得复杂。这种设计方式能够清晰地划分不同功能模块,使得营销页面和应用页面可以使用完全不同的界面样式,而无需通过复杂的URL技巧来实现这一点。

第二层:基于功能的文件夹结构

代码组合处理的是路由层面的问题。但大型应用程序会遇到一些跨领域的需求——这些需求既不属于任何特定的路由,也不属于通用的工具函数。

大多数项目正是在这里出现问题:/components文件夹变成了杂乱无章的存储空间,/lib则成了垃圾文件的收纳处,而且没有人能就useAuth应该放在哪里达成一致。

基于功能的文件夹结构能够为这种混乱带来秩序。

按领域而非文件类型进行组织

不要按照文件的“类型”来对它们进行分类,而应该根据它们“实现的功能”来分组。

src/
  features/
    auth/
      components/
        LoginForm.tsx
        AuthGuard.tsx
      hooks/
        useAuth.ts
        useSession.ts
      lib/
        tokenStorage.ts
        validators.ts
      types.ts
      index.ts            # 公共API,只导出其他部分所需的内容

    billing/
      components/
        PricingTable.tsx
        SubscriptionBadge.tsx
      hooks/
        useSubscription.ts
      lib/
        stripe.ts
      types.ts
      index.ts

    notifications/
      ...

每个功能模块都是一个独立的单元。它拥有自己的组件、钩子函数、工具函数以及类型定义。最重要的是,它还有一个主文件index.ts,这个文件定义了它的公共API——即应用程序的其他部分可以被允许导入的内容。

通过主文件出口来界定各模块的边界

index.ts文件是必不可少的。正是这个文件确保了各个功能模块之间不会相互干扰、发生混乱。

// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
export type { AuthUser, AuthState } from './types';

// 不被导出,属于内部实现细节:
// tokenStorage.ts, validators.ts

现在,应用程序的其他部分应该从@/features/auth路径导入相关内容,而绝不应该从@/features/auth/lib/tokenStorage路径导入。如果你修改了内部存储令牌的方式,那么应用的其他部分也不会受到影响。这就是封装的本质——它不仅仅是一种理论原则,更是通过文件夹结构来强制实现的。

共享资源与功能模块

并不是所有的内容都适合被纳入某个功能模块中。一些真正通用的工具函数,比如cn()这样的辅助函数、日期格式化工具或基础HTTP客户端,应该被放在共享层中:

src/
  shared/
    components/
      Button.tsx
      Modal.tsx
      Spinner.tsx
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    lib/
      http.ts
      dates.ts
    ui/              # shadcn/ui或设计系统组件

规则是:shared/层对任何具体的功能模块都一无所知;各个功能模块可以从shared/层导入所需资源,但shared/层永远不会从某个功能模块中导入内容。

第三层:使用Turborepo的单仓库架构(在多个应用中实现代码共享)

单仓库架构在初期能发挥很大作用,但大多数团队最终都会开发出多个应用程序:一个面向客户的Next.js应用、一个管理面板、一个独立的营销网站,或许还有一些API服务。

问题就出现了:如何在不进行复制粘贴的情况下在这些应用之间共享代码呢?

答案就是使用包含共享组件的单仓库架构,而目前对于使用Next.js的团队来说,Turborepo正是实现这一目标的最佳工具。

单仓库架构的结构

一个结构合理的Turborepo仓库应该如下所示:

my-platform/
  apps/
    web/              # 面向客户的Next.js应用
    admin/            # 内部管理面板(同样使用Next.js技术)
    marketing/        # 营销网站
  packages/
    ui/               # 共享组件库
    config/           # 共享的ESLint、TypeScript及Tailwind配置文件
    auth/             # 共享的身份验证相关工具和类型定义
    database/         # Prisma客户端及查询辅助函数
    utils/            # 通用实用工具
  turbo.json
  package.json        # 根目录工作区配置文件

apps/文件夹中存放可部署的应用程序;packages/文件夹则包含各应用程序所依赖的共享代码。这些应用程序并不会直接相互导入代码,所有代码共享都通过packages/文件夹来完成。

如何创建共享包

一个共享包其实就是包含一个package.json文件的文件夹,其他工作区的成员都可以依赖这个包。

// packages/ui/package.json
{
  "name": "@my-platform/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}
// packages/ui/src/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Card } from './Card';

现在,你的各个应用程序就可以像使用普通的npm包一样来使用这个共享包了:

// apps/web/package.json
{
  "dependencies": {
    "@my-platform/ui": "*"
  }
}
// apps/web/app/dashboard/page.tsx
import { Card, Button } from '@my-platform/ui';

只要在packages/ui文件夹中修改某个组件的代码,所有使用该组件的应用程序都会自动得到更新,无需进行任何复制粘贴操作,也不会出现代码不一致的情况。

重要提示:由于这个共享包直接引用了TypeScript源文件(而非编译后的结果),因此每个使用它的Next.js应用都必须告诉打包工具对其进行类型转换。你可以在自己的Next.js配置文件中添加相关设置:

// apps/web/next.config.ts
const config: import('next').NextConfig = {
  transpilePackages: ['@my-platform/ui', '@my-platform/auth', '@my-platform/utils'],
};

export default config;

如果没有这个设置,构建过程就会因为语法错误而失败。默认情况下,Next.js并不会自动将 `node_modules` 目录中的包或工作区中依赖的库进行转译。另一种方法是将每个包都编译到 `dist/` 目录中,并将它们的 `exports` 对象设置在该目录下,但这种方法会导致每个包都需要经过额外的构建步骤,从而降低开发效率。对于内部使用的单仓库项目来说,使用 `transpilePackages` 这个选项才是更简单、更可行的解决方案。

`turbo.json` 构建流程

Turborepo 的真正优势在于它的构建流程。它能够理解你的包与应用程序之间的依赖关系,缓存构建结果,并在可能的情况下并行执行各项任务。

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

`^build` 这一语法表示:在构建某个包之前,必须先构建其所有依赖项。因此,如果 `apps/web` 依赖于 `packages/ui`,Turborepo 会确保在开始构建 `apps/web` 之前先构建 `packages/ui`。通过远程缓存机制,如果 `packages/ui` 没有发生变化,Turborepo 就会完全跳过重新构建它的步骤,即使是在不同的持续集成环境中或不同团队成员的机器上也是如此。

包与应用程序的区别

一个很有用的区分方法:

位于 `packages/` 目录下 位于 `apps/` 目录下
设计系统 / 用户界面基础组件 路由定义
认证相关工具与类型 特定于应用程序的布局代码
数据库客户端及查询逻辑 针对特定功能的页面代码
通用的 TypeScript 配置文件 API 路由处理函数
数据分析相关抽象层 特定于环境的配置信息
通用钩子函数(如 `useDebounce`) 特定于应用程序的业务逻辑代码

如果两个应用程序都需要相同的逻辑,那么这种逻辑应该被放在一个包中;而如果只有其中一个应用程序需要这种逻辑,那么它就应该保留在那个特定的应用程序中——即使你认为另一个应用程序将来也可能需要它。过早地进行抽象化处理与完全不进行抽象化处理一样有害。

第 4 层:服务器组件与数据获取边界

App Router 的服务器组件模型可以说是 Next.js 所推出过的最具架构意义的变更,同时也是最容易被误解的变更之一。

大多数开发者将其视为一种性能优化手段。确实如此,但更重要的是,它实际上代表着一个架构上的边界。理解这一边界的定位,并有意识地围绕这一边界进行设计,才能让可扩展的 App Router 代码库与那些与框架相冲突的代码库区分开来。

两种不同的“世界”模型

App Router 中的每一个组件都属于以下两个“世界”之一:

服务器组件(默认设置)仅在服务器端运行。它们可以直接获取数据、访问数据库、读取环境变量,同时还能减少发送到浏览器的 JavaScript 代码量。不过,它们无法使用浏览器提供的 API、`useState`、`useEffect` 或事件处理函数。

客户端组件(使用 `'use client'` 语法)在浏览器中运行(在服务器端渲染/数据同步过程中也会执行)。它们可以使用钩子、处理事件,并访问浏览器的 API,但无法直接 await 服务器端的资源。

指令 `'use client'` 并不意味着“这些组件仅在浏览器中运行”,而是表示“这里是服务器与客户端交互开始的分界点”。任何被客户端组件 导入 的模块都会成为客户端代码包的一部分。

而通过 children 等属性 传递给客户端组件的服务器端组件 仍然保持其仅适用于服务器端的特性:它们在服务器上被渲染成 HTML 数据并传输给客户端,因此不会被包含在客户端代码包中。正是这种区别使得下面的组合模式能够正常工作。

划分边界

我们的目标是将使用 `'use client'` 的界限尽可能地向页面结构的底层延伸,将数据获取和复杂逻辑留在服务器端,而只将真正的交互功能留给客户端组件。

在实际开发中效果很好的一个模式如下:

// app/dashboard/page.tsx,服务器端组件
// 获取数据,无需使用 'use client' 指令
import { getMetrics } from '@/features/analytics/lib/queries';
import { MetricsDashboard } from './components/MetricsDashboard';

export default async function DashboardPage() {
  const metrics = await getMetrics();   // 直接调用数据库接口,无需进行 API 请求
  return ;
}
// app/dashboard/components/MetricsDashboard.tsx,服务器端组件
// 组织页面布局,将交互功能委托给客户端组件
import { StatsCard } from './StatsCard';
import { ChartSection } from './ChartSection';

export function MetricsDashboard({ data }) {
  return (
    
); }
// app/dashboard/components/ChartSection.tsx,客户端组件
// 需要浏览器 API 的交互式图表
'use client';

import { useState } from 'react';
import { LineChart, RangeSelector } from '@my-platform/ui';

export function ChartSection({ points }) {
  const [range, setRange] = useState('7d');
  return (
    
); }

数据从服务器端流向客户端,且这一流程是单向的:服务器负责执行耗时较长的操作(如数据库查询),并将可序列化的数据作为属性传递给客户端;客户端收到的则是已经准备好用于渲染的数据集——既没有加载提示,也没有额外的客户端请求流程。

将数据获取与路由结合使用

服务器端组件提供了一个非常强大的模式:可以直接将数据获取操作与需要使用这些数据的路由关联起来,这样一来,在很多情况下就无需再进行全局状态管理了。

app/
  orders/
    page.tsx              # 等待getOrders()函数执行结果,然后渲染订单列表
    [id]/
      page.tsx            # 等待getOrder(id)函数执行结果,然后渲染单个订单的详细信息
      loading.tsx         # 在等待数据时显示加载提示界面
      components/
        OrderTimeline.tsx  // 服务器端组件,用于渲染订单时间线数据
        CancelButton.tsx  // 使用“客户端模式”编写,需要添加点击处理逻辑

每个页面都会根据自身需求获取相应的数据。当使用Promise.all或并行路由机制时,嵌套的布局和页面可以同时进行数据请求。loading.tsx这个文件可以让你在不需要手动编写任何标签的情况下,轻松实现加载提示效果。

何时使用数据获取层而非直接查询

随着应用程序规模的增长,你需要一种统一的数据访问方式。以下是一个实用的设计模式:

// packages/database/src/queries/orders.ts
// 该函数在服务器端执行,任何服务器组件都可以导入并使用它

import { db } from '../client';

export async function getOrdersByUser(userId: string) {
  return db.order.findMany({
    where: { userId },
    include: { items: true },
    orderBy: { createdAt: 'desc' },
  });
}
// packages/database/src/index.ts
export { getOrdersByUser } from './queries/orders';
export { getProductById } from './queries/products';
// ...

你的服务器组件应该从@my-platform/database包中导入所需的函数。而客户端组件则永远不需要直接使用这个包:如果它们需要修改数据,就会调用API路由或服务器端动作。

用于数据修改的服务器端动作

数据获取是通过服务器组件来完成的,但数据的修改操作需要单独的处理机制。服务器端动作(使用'use server'语法)允许你定义一些服务器端的函数,客户端组件可以直接调用这些函数,从而无需编写繁琐的API路由代码。

// app/orders/[id]/actions.ts
'use server';

import { db } from '@my-platform/database';
import { revalidatePath } from 'next/cache';

export async function cancelOrder(orderId: string) {
  await db.order.update({
    where: { id: orderId },
    data: { status: 'cancelled', cancelledAt: new Date() },
  });

  revalidatePath `/orders/${orderId}`);
}
// app/orders/[id]/components/CancelButton.tsx
'use client';

import { cancelOrder } from '../actions';
import { useTransition } from 'react';

export function CancelButton({ orderId }: { orderId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    
  );
}

相关的架构决策如下:

  • 对于那些与特定路由关联的修改操作,应使用“服务器动作”来实现(例如取消订单、更新用户信息)。

  • 而对于那些会被外部客户端使用的修改操作,则应通过API路由来处理(比如Webhook、移动应用或第三方集成系统)。

“服务器动作”能够将修改逻辑紧密地与触发该操作的UI界面关联起来;而API路由则为外部客户端提供了稳定的接口规范。

这样,数据流的架构就清晰明了了:服务器组件负责处理数据的读取操作,“服务器动作”负责数据的写入操作,而客户端组件则充当连接这两者的交互界面。

第五层:分层代码库的测试策略

“测试金字塔”这个概念在理论上听起来很合理,但在实际应用中往往会遇到问题——通常是因为代码库没有明确的边界可供测试。当所有组件相互交织在一起时,每项测试都很容易变成集成测试。

但你所构建的分层架构改变了这一状况:每一层都有明确的职责范围,因此你可以在适当的抽象层次上对每一层进行测试。

在合适的粒度上测试每一层

分层架构与“测试金字塔”的理念非常契合:

层次 测试类型 使用的工具
packages/文件夹中的代码(实用工具、数据库查询等) 单元测试 Vitest
features/文件夹中的代码(钩子函数、库模块、组件等) 单元测试 + 集成测试 Vitest + React Testing Library
应用程序的路由页面(属于服务器组件) 集成测试 Vitest + 自定义渲染逻辑
关键的用户交互流程(如结账、认证等) 端到端测试 Playwright

测试的目标是:对共享的代码包进行全面测试,对各个功能模块进行彻底验证,检查页面之间的集成是否正确,而只有对于那些最重要的交互流程,才需要使用端到端测试。

并不是所有的功能都需要进行端到端测试,如果将端到端测试作为默认的测试策略,那将会是团队可能会犯下的最严重的错误之一。

对共享代码包进行单元测试

packages/文件夹中的代码是最容易进行测试的。这些代码都是纯TypeScript编写的,且没有与任何框架发生耦合关系。可以使用Vitest来进行测试:


// packages/utils/src/dates.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatRelativeDate } from './dates';

describe('formatRelativeDate', () => {
  beforeEach(() => {
    // 为了避免在午夜附近出现测试结果不稳定的情况,需要设置虚拟时间
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('当输入的是当天日期时,该函数应返回"today"`', () => {
    expect(formatRelativeDate(new Date()).toBe('today');
  });

  it('当输入的是前一天日期时,该函数应返回"yesterday"'', () => {
    const yesterday = new Date('2026-03-14T15:00:00Z');
    expect(formatRelativeDate(yesterday)).toBe('yesterday');
  });
});

请将测试文件与源代码文件放在同一个目录中。例如,如果有一个`dates.ts`文件,那么应该有一个与之对应的`dates.test.ts`文件。不要使用单独的`__tests__`文件夹,因为这些文件夹其实是那些代码结构较为混乱的项目遗留下来的习惯。

测试功能模块

功能模块中包含了大部分业务逻辑,因此它们需要接受最多的测试。关键原则是:只需测试功能模块的公共API,而无需测试其内部实现。


// features/auth/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAuth } from '../hooks/useAuth';
import { createWrapper } from '@/test/utils'; // 用于包装测试代码的函数

describe('useAuth', () => {
  it('当会话存在时,会返回已认证的状态', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper({ session: mockSession }),
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user.email).toBe(mockSession.user.email);
  });

  it('当会话为空时,会重定向到登录页面', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper({ session: null }),
    });

    expect(result.current.isAuthenticated).toBe(false);
  });
});

请注意,这些测试代码是直接从相应的功能模块中导入相关函数的,而不是从该模块的`index.ts`文件中导入的。功能模块中的公开API才是被测试的对象;而那些内部使用的钩子函数或工具函数则会在单元级别进行测试。这种区分是有意为之的。

测试服务器组件

服务器组件实际上是返回JSX代码的异步函数。目前,直接对这些组件进行测试仍然是一个正在发展中的技术领域。React的测试工具并不支持对异步组件进行原生处理,因此如果直接调用`await DashboardPage()`然后再将结果传递给`render()`方法,很可能会遇到一些问题(比如上下文丢失、`act()`方法会报错,或者根据具体的配置环境导致测试失败)。

目前最可靠的方法是分别对各个层次进行测试:首先模拟数据层,确认相关数据被正确地调用;然后使用静态属性来测试展示层组件。


// app/dashboard/components/MetricsDashboard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MetricsDashboard } from './Metrics Dashboard';

describe('MetricsDashboard', () => {
  it('能够根据提供的数据渲染收入指标', () => {
    render(
      
    );

    expect(screen.getByText('£84,200')).toBeInTheDocument();
  });
});

// features/analytics/lib/queries.test.ts
import { describe, it, expect } from 'vitest';
import { getMetrics } from './queries';

describe('getMetrics', () => {
  it('能够正确返回收入数据及趋势信息', async () => {
    const metrics = await getMetrics();

    expect(metrics.revenue).toBeGreaterThan(0);
    expect(Array.isArray_metrics.trend)).toBe(true);
  });
});

关键要点是:应该在数据层边界进行测试,而不是在数据库层或网络层进行测试。数据查询相关的测试位于packages/database目录中;用于展示数据的组件也有针对静态属性的专门测试。服务器组件负责将这些测试环节连接起来,而这种连接关系的正确性则通过端到端测试来验证,因为端到端测试更适合用来检测跨异步边界出现的集成问题。

使用Playwright进行端到端测试

只有那些涉及多个层且出现故障后果严重的流程才适合使用Playwright来进行测试,例如认证、结账流程以及会引发副作用的表单提交操作。对于视觉回归测试或处理静态内容的场景,则不适合使用Playwright,因为这样做既耗时又效率低下。

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('用户能够登录并进入控制面板', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page)._haveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' }).toBeVisible();
});

端到端测试文件应放在单体仓库根目录下的e2e/文件夹中。这些测试文件涉及整个项目中的所有应用,因此不应该被放置在任何单个应用的目录中。

在单体仓库中配置Vitest测试

每个包和应用程序都有自己的vitest.config.ts文件,但它们可以通过共享包来使用相同的基配置文件:

// packages/config/vitest.base.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});
// apps/web/vitest.config.ts
import { mergeConfig } from 'vitest/config';
import base from '@my-platform/config/vitest.base';

export default mergeConfig(base, {
  test: {
    include: ['src/**/*.test.{ts,tsx}', 'app/**/*.test.{ts,tsx}'],
  },
});

这样的配置能够确保所有应用和包都使用统一的测试设置,从而避免重复劳动。

第6层:使用Turborepo实现持续集成/持续部署

如果一个单体仓库没有配备智能的持续集成管道,那么它仅仅就是一个大型代码库而已。而Turborepo的真正价值在于持续集成环节——通过缓存机制和智能的任务调度功能,它能够显著缩短构建和测试所需的时间。

核心理念:仅运行那些发生了变化的部分

传统的持续集成管道会在每次提交代码时执行所有测试任务。在单体仓库中,这意味着即使你只是修改了apps/web中的某个辅助功能,apps/admin的测试也会被自动执行。而Turborepo凭借其对依赖关系的精准理解,能够有效避免这种情况的发生。

当你运行 `turbo test` 时,Turborepo会执行以下操作:

  1. 根据你的 `package.json` 文件生成依赖关系图。

  2. 检查哪些包发生了变化(与上次缓存的状态进行对比)。

  3. 仅对发生变化的包及其依赖项运行测试。

  4. 将测试结果缓存起来。如果没有发生变化,系统会立即从缓存中恢复之前的结果。

如果 `packages/ui` 发生变化,那么 `packages/ui`、`apps/web` 和 `apps/admin` 的测试也会被触发(因为这些模块都依赖于 `packages/ui`)。而如果只有 `apps/web` 发生变化,那么只有 `apps/web` 的测试会被执行。

远程缓存

如果没有远程缓存,Turborepo的本地缓存对持续集成流程并无帮助——每次运行都会从头开始。而有了远程缓存,构建和测试产生的结果会被存储在云端,并在所有的持续集成工具以及开发者的机器上共享。

# 使用 Turborepo 的远程缓存进行登录(Vercel)
npx turbo login
npx turbo link

如果你希望将结果保存在自己的基础设施上,也可以使用自托管的缓存服务器。一旦配置完成,对于仅修改了 `apps/web` 分支的持续集成任务,执行时间可能会从 8 分钟缩短到 45 秒,因为所有的 `packages/*` 相关操作都会从缓存中获取所需数据。

适用于生产环境的 GitHub Actions 流程

以下是一个完整的流程示例:该流程利用了 Turborepo 的缓存机制,仅执行必要的任务,并将代码检查、测试和构建操作分解为多个并行执行的步骤:

# .github/workflows/ci.yml
name: 持续集成流程

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_team: ${{ secrets.TURBO_TEAM }}

jobs:
  lint:
    name: 代码检查
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo lint --filter="...[origin/main]"

  test:
    name: 测试
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo test --filter="...[origin/main]"

  build:
    name: 构建
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo build --filter="...[origin/main]"

  e2e:
    name: 后端测试
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: 构建应用程序(如果未发生变化,会从 Turborepo 缓存中恢复数据)
        run: npx turbo build --filter="apps/web"

      - name: 运行后端测试
        run: npx turbo e2e

E2E工作模式假定Playwright的`webServer`配置能够自动启动应用程序。请在您的`playwright.config.ts`文件中配置此项:

// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run start --prefix apps/web',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

通过这种方式,Playwright会在测试运行之前启动生产服务器,并在测试结束后将其关闭——因此在持续集成环境中无需进行任何手动服务器管理操作。

`–filter=”…[origin/main]”`这个参数是关键所在。它告诉Turborepo仅针对自`main`分支以来发生变化的包以及所有依赖于这些变更包的其他包来执行相应的任务。这是整个流程中效果最为显著的优化措施。

过滤策略

Turborepo的`–filter`参数非常灵活,了解其使用方法是非常有必要的:

# 仅针对那些相对于`main`分支发生变化的包来执行任务
turbo test --filter="...[origin/main]"

# 为某个特定应用程序及其所有依赖项执行任务
turbo build --filter="apps/web..."

# 除了某个特定应用程序之外,为其他所有项目执行任务
turbo test --filter="!apps/admin"

# 为所有应用程序(而非单独的包)执行任务
turbo build --filter="./apps/*"

对于大多数持续集成流程而言,在功能分支上使用`–filter=”…[origin/main]”`,而在`main`分支上进行`turbo run test build`操作(不使用任何过滤条件),这种配置方式是较为合适的。这样既能快速获得对代码变更的反馈,又能确保`main`分支上的所有代码仍然能够正常运行。

带有应用程序级过滤功能的部署流程

当需要将应用程序部署到Vercel、Netlify或任何支持按应用程序进行部署的平台时,Turborepo可以帮助您判断哪些应用程序确实发生了变化,从而跳过那些未发生变化的应用程序的部署过程:

# .github/workflows/deploy.yml
- name: 检查Web应用程序是否发生了变化
  id: check-web
  run: |
    CHANGED=$(npx turbo run build --filter="apps/web...[origin/main]" --dry=json | jq '.packages | length')
    echo "changed=\(CHANGED" >> \)GITHUB_OUTPUT

- name: 部署Web应用程序
  if: steps.check-web.outputs_changed != '0'
  run: vercel deploy --prod
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

这种配置方式可以确保:当只有营销网站发生了变化时,管理员应用程序不会被触发进行部署,从而减少部署所需的时间和成本,同时也能降低任何部署失败所带来的影响范围。

环境变量管理

在单仓库持续集成环境中,环境变量的管理是一个比较棘手的问题:每个应用程序都需要自己独立的配置信息,但有些环境变量却是需要在多个应用程序之间共享的。

一种规范的配置方式如下:

# .env文件(位于仓库根目录下,在本地开发环境中所有应用程序都会共享这些配置)
DATABASE_URL=...
REDIS_URL=...

# apps/web/.env.local文件(仅针对web应用程序的特定配置)
NEXT_PUBLIC_APP_URL=https://app.example.com
STRIPE_KEY=...

# apps/admin/.env.local文件(仅针对admin应用程序的特定配置)
NEXT_PUBLIC_APP_URL=https://admin.example.com
ADMIN_SECRET=...

在持续集成环境中,应将组织级别的共享密钥存储在GitHub的秘密管理功能中,而特定于应用的密钥则应作为仓库级别的密钥保存,并限定在相应的环境范围内使用。

切勿将密钥存储在turbo.json文件或任何已提交的代码文件中。相反,应在管道步骤中使用env变量,并利用Turborepo中的globalEnv字段来指定哪些环境变量在发生变化时需要强制清除缓存:

// turbo.json
{
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_APP_URL", "STRIPE_KEY"],
      "dependsOn": ["^build"],
      "outputs": [".next/**"]
    }
  }
}

这样设置后,Turborepo就会知道:如果DATABASE_URL发生变化,就需要清除所有任务的缓存;而如果NEXT_PUBLIC_APP_URL发生变化,那么只需要清除build任务对应的缓存即可。如果不这样做,就有可能导致Turborepo使用与当前环境不同的配置来重新生成编译结果,从而引发一些难以发现的错误。

整体架构设计

下面是整个系统的完整架构图:

my-platform/
  apps/
    web/
      app/
        (marketing)/
          layout.tsx
          page.tsx
          about/page.tsx
        (app)/
          layout.tsx            # 需经过身份验证才能访问的界面层
          dashboard/
            page.tsx            # 服务器端组件,用于获取数据
            loading.tsx
            components/
              MetricsDashboard.tsx
              ChartSection.tsx  # 使用客户端逻辑
          orders/
            page.tsx
            [id]/
              page.tsx
              components/
                OrderTimeline.tsx
                CancelButton.tsx  # 使用客户端逻辑
      src/
        features/
          auth/
            components/
            hooks/
            lib/
            index.ts
          billing/
            ...
        shared/
          components/
          hooks/
          lib/
    admin/
      app/
        ...                     # 同样的层次结构
      src/
        features/
          ...
  packages/
    ui/                         # 公共的UI组件库
    auth/                       # 共用的认证逻辑模块
    database/                   # Prisma数据库及相关查询逻辑
    config/                     # ESLint配置、TypeScript配置等
    utils/                      # 通用辅助函数
  turbo.json
  package.json

请注意,'use client'这一标记仅出现在那些需要与用户交互的组件中:例如ChartSection.tsx需要使用useState来管理状态,而CancelButton.tsx则需要处理点击事件并使用useTransition来实现动画效果。它们上面的各个组件(如MetricsDashboard.tsxOrderTimeline.tsx等)都运行在服务器端,负责获取数据并生成页面布局,而不会向浏览器发送任何JavaScript代码。

整个系统的层次结构非常清晰明了:

  1. Turborepo包:最基础的一层。这些包具有通用性,可重复使用,使用时无需了解特定应用的细节。

  2. 共享功能层:涉及多个应用共性的功能模块。这类模块可以调用其他包,但并不了解路由机制的具体实现。

  3. 功能模块:包含特定业务逻辑的代码模块,这些逻辑被封装在相应的文件中。

  4. 应用路由器:负责处理路由规则、布局安排以及各个组件的协同工作。它会调用功能模块和包来完成任务,数据流经服务器组件,而交互逻辑则由客户端组件负责实现。

常见误区及避免方法

“暂时就把这些代码放在 /utils 目录里吧。” 这种做法最终会导致代码混乱。如果无法明确某个工具函数的用途,那么它应该被放入专门的功能文件夹中,而不是被随意放置在通用目录里。

过早地将代码提取为独立包:并非所有代码都适合被提取成共享包。只有在有其他组件需要使用这些代码时,才应该将其提取出来。过早进行抽象化处理会增加维护成本,并导致不必要的耦合现象。

将客户端组件放在结构的最顶层:如果某个路由文件的page.tsx文件中在顶部就使用了'use client'指令,那么你就浪费了服务器组件所提供的很多优势。应该将这种指令放到需要使用它的交互逻辑部分。

循环依赖的包结构:如果packages/auth依赖于packages/database,而packages/database又依赖于packages/auth,就会形成循环依赖。必须保证依赖关系图是单向的:每个包都应该只属于一个明确的抽象层次。

包含所有内容的“桶状文件”:桶状文件应该仅用于暴露应用程序中其他部分需要使用的接口,而不应成为整个文件夹中所有文件的索引。

总结

良好的架构设计并不在于寻找完美的结构,而在于让正确的决策变得容易做出,错误的决策则难以实施。

  • 合理的组件布局有助于快速找到所需的功能模块。

  • 功能模块的设计可以有效防止不相关的业务逻辑之间产生不必要的耦合。

  • Turborepo工具便于代码的共享,同时也能有效避免代码重复编写。

  • 服务器组件的使用可以让你在需要的地方轻松获取数据,同时减少向浏览器发送不必要的JavaScript代码。

这些理念其实并不新鲜。分层架构、职责分离以及代码封装都是早已被广泛认可的编程原则。Next.js和Turborepo则为我们在JavaScript代码中实现这些原则提供了现代化的工具。

最佳的设置时机是在项目开始时;其次就是现在,在下一个功能模块被添加之前,及时调整代码结构,以免后续的工作变得更加复杂。

Comments are closed.