我常用的全栈/前端项目模式
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));
}
这些函数的严格规则:
- 只进行数据访问(
SELECT、INSERT、UPDATE、DELETE) - 不包含业务逻辑
- 不做授权检查
- 不发送邮件、写入队列、调用外部接口或产生副作用
- 可被任何服务复用
这种轻量 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 */}
</>
);
}
抽取指南
如果你看到
useState、useEffect、useQuery(或类似)聚集在一起且只为一个明确目的服务 → 抽取为自定义 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 工具就会更加准确。它们会立刻“领会”这些模式并生成真正匹配的代码,而不需要你重复十次提示它们把所有东西放在正确的文件夹、正确的格式中。
所有经典的工程收益,而不使事情过于复杂。