跳至内容
·5 分钟阅读

大规模 Next.js 项目结构:我用踩坑换来的经验

关于组织 Next.js 代码库的血泪教训——让项目不会在自身重量下崩塌。基于功能的架构、路由分组、服务端/客户端边界、barrel file 陷阱,以及一个你可以直接借鉴的真实目录结构。

分享:X / TwitterLinkedIn

每个 Next.js 项目一开始都很干净。几个页面,一个 components 文件夹,可能再加一个 lib/utils.ts。你觉得效率很高。你觉得组织得很好。

然后六个月过去了。三个开发者加入。components 文件夹已经有 140 个文件了。有人创建了 utils2.ts,因为 utils.ts 已经 800 行了。没人知道哪个 layout 套的是哪个路由。构建时间四分钟,你也不确定为什么。

我经历过这个循环好几次。接下来要分享的,是我现在真正在做的事情——这些经验大部分是先发布了错误的版本之后才学到的。

默认结构很快就会崩溃#

Next.js 文档建议了一种项目结构。它没问题。对于个人博客或小工具来说够用了。但当你有超过两个开发者在同一个代码库上工作时,"按类型分类"的方式就开始出现裂痕:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     ← the beginning of the end
  app/
    page.tsx
    dashboard/
      page.tsx

问题不在于这是"错误的"。而在于它随功能线性增长,但理解力并不会跟着增长。当一个新开发者需要处理发票功能时,他们得同时打开 components/hooks/utils/types/app/。发票功能散布在五个目录中。没有一个地方能告诉你"发票功能长什么样"。

我亲眼看到一个四人团队花了一整个迭代周期重构成这种结构,确信这是"整洁"的方式。三个月内他们又开始困惑了,只不过多了更多文件夹。

基于功能的就近放置始终胜出#

在真实团队中经受住考验的模式是基于功能的就近放置。理念很简单:与某个功能相关的一切都放在一起。

不是"所有组件放一个文件夹,所有 hooks 放另一个"。恰恰相反。发票相关的所有组件、hooks、类型和工具函数都放在 features/invoicing/ 里。认证相关的一切都放在 features/auth/ 里。

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

注意这些 import 路径。它们全是相对路径,全在功能模块内部。这个 hook 不会去访问某个全局的 hooks/ 文件夹或共享的 utils/format.ts。它使用的是发票功能自己的格式化逻辑,因为发票号码的格式化是发票功能的关注点,而不是全局的。

反直觉的是:这意味着你会有一些重复代码。 你的发票功能可能有一个 format.ts,你的账单功能可能也有一个 format.ts。没关系。这实际上比一个被 14 个功能依赖、没人敢安全修改的共享 formatters.ts 要好。

我抵触这种做法很长时间。DRY 原则就是信条。但我见过共享的工具文件变成代码库中最危险的文件——为一个功能做的"小修复"导致另外三个功能崩溃。通过就近放置并控制重复,比强制共享更易维护。

我实际使用的目录结构#

这是完整的目录树。它已经在两个生产应用中经过 4-8 人团队的实战检验:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

有几点需要注意:

  • app/ 只包含路由逻辑。 页面文件应该很薄。它们从 features/ 导入并组合。一个页面文件最多 20-40 行。
  • features/ 是真正代码所在的地方。 每个功能都是自包含的。你可以删掉 features/invoicing/,其他什么都不会坏(除了那些从它导入的页面,而这恰恰是你想要的耦合方式)。
  • shared/ 不是垃圾桶。 它有严格的子分类。下面会详细说明。

路由分组:不仅仅是组织方式#

路由分组,也就是带 (括号) 的文件夹,是 App Router 中最被低估的功能之一。它们完全不影响 URL。(dashboard)/invoices/page.tsx 渲染的路径是 /invoices,而不是 /dashboard/invoices

但真正强大的是布局隔离。每个路由分组都有自己的 layout.tsx

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

认证检查在 layout 层级只做一次,而不是在每个页面里都做。公开页面有完全不同的外壳。认证页面完全没有 layout——只有一个居中的卡片。这种方式干净、明确、不容易出错。

我见过团队把认证检查放在 middleware 里、放在各个页面里、放在组件里——而且同时放在所有这些地方。选一个地方就好。对大多数应用来说,路由分组的 layout 就是那个地方。

shared/ 文件夹不是 utils/#

创造维护噩梦最快的方式就是搞一个 lib/utils.ts 文件。它一开始很小。然后它就变成了代码库的杂物抽屉——每个找不到明确归属的函数都被扔进去。

这是我的原则:shared/ 文件夹需要正当理由。 只有满足以下条件才能放入 shared/

  1. 被 3 个以上的功能使用,并且
  2. 确实是通用的(不是披着通用外衣的业务逻辑)

一个用来格式化发票到期日的日期格式化函数?那应该放在 features/invoicing/utils/。一个接收 Date 返回本地化字符串的日期格式化函数?那可以放在 shared/

shared/lib/ 子文件夹专门用于第三方集成和基础设施:数据库客户端、认证库、支付提供商。这些是整个应用依赖的东西,但没有哪个单独的功能拥有它们。

typescript
// shared/lib/db.ts — 这个属于 shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — 这个不属于 shared
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

如果你想往 shared/utils/ 里放东西,却无法给文件起一个比 helpers.ts 更具体的名字,那就停下来。那段代码大概应该放在某个功能模块里。

服务端 vs. 客户端组件:刻意划清界限#

以下是我在有性能问题的代码库中经常看到的模式:

typescript
// 看起来合理但其实是个错误
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

页面标记了 "use client"。现在它导入的所有东西——InvoiceTableInvoiceFilters、hook,以及每个依赖的依赖——全都进了客户端 bundle。你那个本可以在服务端零 JavaScript 渲染的 200 行表格,现在要发送到浏览器了。

解决办法是把 "use client" 下推到真正需要它的叶子组件:

typescript
// app/(dashboard)/invoices/page.tsx — 服务端组件(没有指令)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* 客户端组件 — 有交互式筛选器 */}
      <InvoiceTable data={invoices} /> {/* 服务端组件 — 只是渲染行 */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

我遵循的规则是:每个组件默认是服务端组件,直到它需要 useStateuseEffect、事件处理器或浏览器 API。 只有在那时才添加 "use client",并且尽量让那个组件尽可能小。

children 模式是你最好的朋友。客户端组件可以接收服务端组件作为 children

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children 可以是服务端组件 */}
    </div>
  );
}

这就是你如何让 90% 的组件树留在服务端,同时在需要的地方保持交互性。

Barrel 文件:无声的构建杀手#

我曾经在每个文件夹里都放一个 index.ts。导入看起来很清爽,对吧?

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

然后我就可以这样导入:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

看起来不错。问题是:当打包器看到这个导入时,它必须评估整个 barrel 文件才能确定 InvoiceTable 是什么。这意味着它也会加载 InvoiceRowInvoiceFormInvoicePdf,即使你只需要 table。

在一个项目中,移除内部 barrel 文件后,开发阶段的模块数量从 11,000 降到了 3,500。开发服务器的页面加载时间从 5-10 秒降到了 2 秒以内。首次加载的 JavaScript 从超过 1MB 降到了大约 200 KB。

Vercel 在 next.config.ts 中构建了 optimizePackageImports 来处理 node_modules 中的第三方包。但它无法修复你自己的 barrel 文件。对于你自己的代码,答案更简单:不要使用它们。

typescript
// 这样做
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// 不要这样做
import { InvoiceTable } from "@/features/invoicing/components";

是的,导入路径更长了。但你的构建更快了,bundle 更小了,开发服务器也不会卡顿了。这个交换我每次都愿意做。

唯一的例外:功能模块的公共 API。如果 features/invoicing/ 在功能根目录暴露一个 index.ts,里面只有 2-3 个允许其他功能导入的东西,那没问题。它充当的是边界,而不是便捷快捷方式。但子文件夹内的 barrel 文件,比如 components/index.tshooks/index.ts?干掉它们。

薄页面,厚功能#

我遵循的最重要的架构规则是:页面文件是接线,不是逻辑。

app/ 中的页面文件应该只做三件事:

  1. 获取数据(如果是服务端组件)
  2. 导入功能组件
  3. 将它们组合在一起

就这些。没有业务逻辑。没有复杂的条件渲染。没有 200 行的 JSX 树。如果一个页面文件超过 40 行,你就是把功能代码放错了地方。

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

十二行。页面获取它需要的数据,处理 not-found 情况,其余一切委托给功能模块。如果发票详情 UI 变了,你编辑 features/invoicing/,而不是 app/。如果路由变了,你移动页面文件,功能代码不受影响。

这就是你如何构建一个让 15 个开发者同时工作而不互相干扰的代码库。

残酷的现实#

没有任何目录结构能防止烂代码。我见过组织得漂漂亮亮但充满糟糕抽象的项目,也见过一团乱麻的扁平结构但因为代码本身足够清晰而很容易上手。

结构是工具,不是目标。我描述的这种基于功能的方法之所以有效,是因为它优化了在规模化时最重要的那件事:一个从未见过这个代码库的开发者,能否找到他们需要的东西并修改它,而不破坏其他部分?

如果你当前的结构对这个问题的回答是"能",那就不要因为一篇博文让你去重构——即使是这篇。

但如果你的开发者花在浏览代码库上的时间比写代码还多,如果"这个应该放哪里?"是 PR 中反复出现的问题,如果对账单的修改莫名其妙地搞坏了仪表盘——那么你的结构在跟你作对。上面的这些模式就是我每次修复这类问题的方法。

相关文章