我常用的全栈/前端项目模式

发布: (2026年1月15日 GMT+8 19:50)
10 min read
原文: Dev.to

Source: Dev.to

请提供您想要翻译的完整文本内容,我将按照要求保留源链接、格式和代码块,仅翻译正文部分。谢谢!

Source:

我几乎在每个项目中都会使用的模式

在做了相当多的前端和全栈项目(大多是 React + TypeScript 加上某种服务器/后端)后,我发现自己总是回到同几种模式。它们提供结构、降低心智负担,并让代码库即使在增长时也感觉可维护。

这些并非革命性的发明,但却是跨不同应用都表现良好的务实选择。下面是我几乎每次都会采用的当前方案。

1. TanStack Query(React Query) – 查询键工厂

为什么: 保持查询键的一致性、可读性以及易于重构。

工厂(单一真相来源):

// lib/query-keys.ts
export const bookingKeys = {
  all: ['bookings'] as const,
  detail: (id: string) => [...bookingKeys.all, id] as const,
  upcoming: (filters: { patientId?: string; page: number }) => [
    ...bookingKeys.all,
    'upcoming',
    filters,
  ] as const,
};

在组件中使用:

useQuery({
  queryKey: bookingKeys.detail(bookingId),
  queryFn: () => getBooking(bookingId),
});

集中式失效处理:

// Same file or a companion invalidations.ts
export const invalidateOnBookingChange = (queryClient: QueryClient) => {
  // 失效所有与 bookings 相关的查询
  queryClient.invalidateQueries({ queryKey: bookingKeys.all });

  // 或更细粒度:
  // queryClient.invalidateQueries({ queryKey: bookingKeys.upcoming(...) });
};

把失效逻辑放在同一个地方,就能在仪表盘、列表页、详情页等处统一追踪和管理数据新鲜度,而不必在各组件或 mutation 中四处寻找。一次修改即可一致地波及所有相关位置。

2. 服务器端动作 / 函数 替代传统 API 路由

我使用的方案:

  • Next.js Server Actions
  • Astro Actions
  • TanStack Start 服务器函数

这些本质上仍然是类似 API 的端点——可以直接调用(通过 fetch 或表单 POST)。因此仍需对它们进行以下保护:

  • 身份验证
  • 限流
  • CSRF 令牌(视情况而定)
  • 输入校验

主要收益:

好处说明
更少的样板代码客户端直接调用函数 → 无需手动定义接口
自动类型安全类型在客户端和服务器之间流动
更易的错误处理与重新验证错误在调用处直接显现
逻辑共置表单 → 动作 → 数据库 → 响应 位于同一处
更好的 React 集成与 Suspense 与 transition 配合顺畅

这并非魔法,只是去掉了繁琐的仪式感,同时仍保留安全责任。

3. 使用 CASL 实现细粒度权限

一次性定义能力(通常基于用户/会话):

import { AbilityBuilder, createMongoAbility } from '@casl/ability';

export const defineAbilitiesFor = (user: User | null) => {
  const { can, cannot, build } = new AbilityBuilder(createMongoAbility);

  if (user?.role === 'admin') {
    can('manage', 'all');
  } else if (user) {
    can('read', 'Booking', { patientId: user.id });
    can('create', 'Booking');
    can('update', 'Booking', { patientId: user.id });
    cannot('delete', 'Booking'); // 明确的拒绝示例
  }

  return build();
};

在服务层使用:

class BookingService {
  static async updateBooking(
    user: User,
    bookingId: string,
    data: Partial<any>
  ) {
    const ability = defineAbilitiesFor(user);

    const booking = await getBookingDetails(bookingId); // 来自查询
    if (!ability.can('update', booking)) {
      throw new Error('Not authorized to update this booking');
    }

    // 继续执行更新…
    await updateBooking(bookingId, data);
  }
}

或在代码中直接检查:

if (ability.can('read', subject('Booking', { ownerId: user.id }))) {
  // 显示敏感数据
}

将权限逻辑保持为声明式、可测试且脱离业务流程的代码,使得 (后文未完)

audit and evolve.

4. Thin Data‑Access Layer (queries/ folder)

结构: 纯异步函数,仅包含 数据库语句——不做其他事。

// queries/bookings.ts
export async function getBookingDetails(id: string): Promise<any> {
  // Drizzle/Prisma/etc. query only
  return db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
}

export async function updateBooking(
  id: string,
  data: Partial<any>
): Promise<void> {
  // Pure update, no side effects
  await db.update(bookings).set(data).where(eq(bookings.id, id));
}

这些函数的严格规则:

  • 只进行数据访问(SELECTINSERTUPDATEDELETE
  • 不包含业务逻辑
  • 不做授权检查
  • 不发送邮件、写入队列、调用外部接口或产生副作用
  • 可被任何服务复用

这种轻量 DAL 使得替换 ORM 变得微不足道(只需修改 queries/ 文件),并让服务专注于编排。

5. SSR/SSG – 使用 initialData 进行 Hydrate

模式:

useQuery({
  queryKey: bookingKeys.upcoming({ page: 1 }),
  queryFn: () => actions.bookings.getUpcomingBookings({ page: 1 }),
  initialData: page === 1 ? initialUpcoming : undefined,
});

为什么重要:

  • SSR 在今天是不可或缺的。 纯 CRA 单页应用的时代已经结束。
  • React 在 2025 年初正式不再推荐新项目使用 CRA,而是推荐带有 SSR/SSG 的框架(Next.js、TanStack Start、Astro 等)。
  • 利用服务器获取的数据可以提升感知性能,减少布局偏移,并在首次渲染时为用户提供有意义的内容。

6. 经典分离:展示组件 vs. 容器组件

类型特征
展示型(dumb)只接收 props,不使用 hooks/state/fetch,纯 UI,易于单元测试和理解。
容器型(smart)处理数据、状态、编排,并向下传递 props。

示例 – 展示组件:

// Presentational – great for snapshot/visual testing
function BookingListView({
  bookings,
  isLoading,
  page,
  totalPages,
  onPageChange,
}) {
  if (isLoading) return null;

  return (
    <>
      {/* Render bookings */}
      {bookings.map((b) => (
        <div key={b.id}>{/* booking UI */}</div>
      ))}
      {/* Pagination controls */}
    </>
  );
}

对应的容器组件会负责获取数据、管理分页状态,并渲染 <BookingListView …/>

Source:

TL;DR

  • Query‑key factories → TanStack Query 的单一真相来源。
  • Server actions → 替代冗余的 API 路由,保持类型安全。
  • CASL → 声明式、集中式的权限处理。
  • Thin DAL (queries/) → 纯粹的数据库函数,便于更换 ORM。
  • SSR/SSG + initialData → 消除加载闪烁,提升首屏渲染体验。
  • Presentational vs. Container → 清晰分离,便于测试和维护。

这些模式帮助我保持代码库的可扩展性、可预测性以及开发的愉快度。欢迎根据自己的技术栈采纳其中任意适用的方案!

// BookingListView.tsx
function BookingListView({
  bookings,
  isLoading,
  page,
  totalPages,
  onPageChange,
}) {
  return (
    <>
      {isLoading ? null : bookings.map((b) => <div key={b.id}>{/* … */}</div>)}
    </>
  );
}

// Container
function BookingList() {
  const {
    bookings,
    isLoading,
    page,
    setPage,
    totalPages,
  } = useBookings();

  return <BookingListView
    bookings={bookings}
    isLoading={isLoading}
    page={page}
    totalPages={totalPages}
    onPageChange={setPage}
  />;
}

经验法则:
只要发现组件因 状态 + 数据获取 + 分页 + 错误处理 而变得臃肿 → 将这些逻辑抽取到 自定义 Hook 中。

前后对比

之前: 组件内部有 50 行以上的 useQuery / useState / 会话逻辑。

之后: 组件仅负责渲染;所有繁重的工作都交给 Hook 完成。

// PatientDashboard.tsx
function PatientDashboard({
  initialUpcoming,
  initialPast,
  initialNext,
}) {
  const {
    upcoming,
    past,
    nextAppointment,
    isLoadingUpcoming,
    upcomingPage,
    setUpcomingPage,
    // …
  } = useDashboard({
    initialUpcoming,
    initialPast,
    initialNext,
  });

  return (
    <>
      {/* Render dashboard UI */}
    </>
  );
}

抽取指南

如果你看到 useStateuseEffectuseQuery(或类似)聚集在一起且只为一个明确目的服务 → 抽取为自定义 Hook。
组件保持专注于渲染。

Provider‑agnostic Services

当你可能会更换提供商(Zoom → Google Meet → 其他)时,使用统一的接口将实现细节隐藏。

// services/meeting.ts
class MeetingService {
  static async createMeeting(input: CreateMeetingInput) {
    // 根据配置 / 环境选择策略
    return activeMeetingProvider.create(input);
  }
}

保持服务层简洁且具备未来可扩展性。

这些模式的好处

  • 可读且组织良好的代码
  • 更少奇怪的逻辑错误 – 一切都有其位置
  • 降低维护成本 – 更容易的测试,意外更少
  • 更快的功能开发 – 更少时间在与结构斗争

当所有内容遵循明确的约定(记录在单个 ARCHITECTURE.md 或类似文件中),像 Cursor 或 Copilot 之类的 AI 工具就会更加准确。它们会立刻“领会”这些模式并生成真正匹配的代码,而不需要你重复十次提示它们把所有东西放在正确的文件夹、正确的格式中。

所有经典的工程收益,而不使事情过于复杂

Back to Blog

相关文章

阅读更多 »

React 组件中的 TypeScript 泛型

介绍:泛型并不是在 React 组件中每天都会使用的东西,但在某些情况下,它们可以让你编写既灵活又类型安全的组件。

InkRows 背后的技术栈

InkRows InkRowshttps://www.inkrows.com/ 是一款现代的 note‑taking app,旨在在 web 和 mobile platforms 之间无缝工作。背后其简洁、直观的...