技术栈示例:React + TypeScript + TanStack Query + TanStack Router + orval(代码生成) + axios,架构用 FSD(Feature-Sliced Design)。配套 README / 后端骨架。
一、FSD 目录树
web/
├── orval.config.mjs # 代码生成配置(读后端 OpenAPI)
├── package.json # openapi:generate / check / mock 脚本
├── eslint.config.js # 含 FSD 边界规则(boundaries / steiger)
└── src/
├── app/ # 应用装配:providers、QueryClient、Router 挂载
├── routes/ # 路由(TanStack Router)
├── widgets/ # 大块复合 UI(多 feature 拼成的区域)
├── features/ # "用户能做的事"
│ └── order/ui/ # 如 create-order-modal.tsx
├── entities/ # ★前端侧限界上下文★(对称后端 modules)
│ └── order/
│ ├── model/
│ │ ├── types.ts # 前端领域模型 Order
│ │ └── order-adapter.ts# 防腐:后端 DTO → 前端领域模型(也可放 shared/api/adapters)
│ └── api/
│ └── use-orders.ts # 领域包装 hook(缓存策略 + adapter)
└── shared/ # 通用底座
├── api/
│ ├── client.ts # axios 实例 + 拦截器(统一 token/错误)
│ ├── query-client.ts # QueryClient 单例
│ ├── generated/ # ★orval 生成,不可手改★
│ │ ├── endpoints/ # 每个后端 tag 一个文件(React Query hooks)
│ │ └── models/ # 与后端同源的 TS 类型
│ └── adapters/ # 防腐层:*-adapter.ts
├── components/ui/ # 设计系统组件(Button/Dialog/…)
├── lib/ # 工具
├── store/ # 仅放"客户端状态"(UI 偏好等),不放服务端数据
└── config/
FSD 依赖方向:
app → routes → widgets → features → entities → shared,只能从上往下依赖,同层之间不许互相 import。这是前端版的"分层 + 边界"。
二、契约流水线:OpenAPI + orval(前后端零漂移的根)
orval.config.mjs
import { defineConfig } from "orval";
export default defineConfig({
api: {
input: { target: process.env.OPENAPI_SCHEMA_URL ?? "http://127.0.0.1:8000/openapi.json" },
output: {
mode: "tags-split", // 一个后端 tag/上下文一个文件
target: "./src/shared/api/generated/endpoints",
schemas: "./src/shared/api/generated/models",
client: "react-query",
clean: true, // 每次全量重生成,杜绝手改残留
mock: { type: "msw" }, // 顺带生成 msw mock
override: { mutator: { path: "./src/shared/api/client.ts", name: "customInstance" } },
},
},
});
package.json 脚本
{ "scripts": {
"openapi:generate": "orval", // 拉契约→生成 endpoints+models
"openapi:check": "node ./scripts/openapi-check.mjs", // 检测契约漂移
"openapi:mock": "node ./scripts/openapi-mock.mjs" // 起 msw mock,后端没好也能开发
} }
💡 铁律:
generated/不可手改——它是后端契约的投影。后端改字段 → 重生成 → 前端编译期报错,漂移在编码期被掐死。
三、三层 api 漏斗(最该学的前端模式)
生成的原始 hook 不直接给 UI 用,中间垫两层:
shared/api/generated/endpoints/orders → useListOrders() ① 原始:机器生成、纯传输、不可改
↓ 包装
entities/order/api/use-orders.ts → useOrders() ② 领域包装:缓存策略 + adapter 适配
↓ 消费
features/order/ui/create-order-modal.tsx ③ UI:只管渲染交互
① shared/api/client.ts — 统一 mutator(横切收敛,前端版"API 网关")
import Axios from "axios";
import { getAuthToken } from "./auth-token";
export const apiClient = Axios.create({ baseURL: import.meta.env.VITE_API_BASE, withCredentials: true });
apiClient.interceptors.request.use((cfg) => { // 所有请求自动注入 token
const t = getAuthToken(); if (t) cfg.headers.Authorization = `Bearer ${t}`;
return cfg;
});
// orval 用的 customInstance:统一出入口(baseURL/鉴权/错误归一都在这)
export const customInstance = <T>(config): Promise<T> => apiClient(config).then((r) => r.data);
② entities/order/api/use-orders.ts — 领域包装(缓存 + adapter)
import { useListOrders } from "@/shared/api/generated/endpoints/orders/orders";
import { adaptOrderDtoToDomain } from "@/shared/api/adapters/order-adapter";
const ORDERS_STALE_TIME = 30_000; // 少变数据,30s 内不重发
export function useOrders(params?) {
return useListOrders(params, {
query: {
staleTime: ORDERS_STALE_TIME, // a. 缓存策略
select: (res) => res.data?.items.map(adaptOrderDtoToDomain) ?? [], // b. 适配成前端领域模型
},
});
// 反模式守护:不把结果同步进 store——React Query 缓存就是唯一服务端真相源
}
② shared/api/adapters/order-adapter.ts — 前端防腐层
import type { OrderListItemDTO } from "@/shared/api/generated/models";
import type { Order } from "@/entities/order/model/types";
// 职责:字段重命名(snake→camel)、补纯前端字段、屏蔽后端结构变化
export function adaptOrderDtoToDomain(dto: OrderListItemDTO): Order {
return { id: dto.id, buyerId: dto.buyer_id, total: dto.total, statusColor: colorOf(dto.status) };
}
③ features/order/ui/create-order-modal.tsx — UI(只调 hook)
import { useCreateOrder } from "@/shared/api/generated/endpoints/orders/orders";
export function CreateOrderModal() {
const { mutate, isPending } = useCreateOrder(); // 直接用生成的 mutation
// 成功后用 queryClient.invalidateQueries 让列表自动刷新
}
| 漏斗层 | 文件 | 职责 | 类比后端 |
|---|---|---|---|
| ① 生成层 | shared/api/generated/ |
契约原始映射,不可手改 | 接口层裸 DTO |
| ② 领域层 | entities/*/api + shared/api/adapters |
缓存/失效策略、DTO→领域模型适配 | 防腐层 + 仓储 |
| ③ 业务层 | features/*/ui |
交互渲染,只 useXxx() |
应用层用例 |
四、FSD 边界 linter(前端的"边界执法",必配)
后端有 import-linter,前端也要把 FSD 规则做成机器执法,否则同层乱 import、generated 被手改,迟早烂。两种方案:
方案 A:steiger(FSD 官方 linter)
npm i -D steiger @feature-sliced/steiger-plugin
# steiger.config.js 启用 FSD 规则集;CI 跑 npx steiger ./src
方案 B:eslint-plugin-boundaries(更可定制)
// eslint.config.js(核心规则示意)
import boundaries from "eslint-plugin-boundaries";
export default [{
plugins: { boundaries },
settings: { "boundaries/elements": [
{ type: "app", pattern: "src/app/*" },
{ type: "features", pattern: "src/features/*" },
{ type: "entities", pattern: "src/entities/*" },
{ type: "shared", pattern: "src/shared/*" },
]},
rules: {
"boundaries/element-types": ["error", { default: "disallow", rules: [
{ from: "features", allow: ["entities", "shared"] }, // feature 只能用 entities/shared
{ from: "entities", allow: ["shared"] }, // entity 只能用 shared
{ from: "shared", allow: ["shared"] }, // shared 不许向上依赖
// features 不许 import 其它 feature;任何层不许 import app —— 默认 disallow 已覆盖
]}],
"no-restricted-imports": ["error", { patterns: [
{ group: ["*/generated/*"], message: "generated 是契约投影,禁止手改/绕过领域包装直接用" },
]}],
},
}];
💡 配上之后,前后端就两边对称地机器执法边界了——这正是分析 BIE 时发现的"唯一缺口",骨架里直接补上。
五、前后端对称速记
| 维度 | 后端 | 前端 |
|---|---|---|
| 切分 | modules/<ctx> |
entities/<ctx> + features/<ctx> |
| 分层 | api/application/domain/infrastructure | app/routes/widgets/features/entities/shared |
| 依赖方向 | api→application→domain←infra | 上→下,同层不互依赖 |
| 防腐 | DTO 在 api 边界转一次 | adapters/*-adapter.ts |
| 契约 | 产出 OpenAPI(源头) | 消费 OpenAPI(orval 生成,不可改) |
| 边界执法 | import-linter | steiger / eslint-plugin-boundaries |
| 横切收敛 | core(鉴权/响应/异常) | shared/api/client.ts(token/错误) |
六、加一个前端上下文清单
以加 payment 为例(后端已就绪并重生成契约后):
npm run openapi:check确认新端点进了契约 →npm run openapi:generate。entities/payment/model/:types.ts(前端领域模型)+payment-adapter.ts(防腐)。entities/payment/api/:use-payments.ts(包装生成 hook、定 staleTime/select)。features/payment/ui/:交互组件,只调entities/payment/api的 hook。- 在
routes/或某个widgets/里接进去。 - 跑边界 linter,确认没跨层 / 没直接用 generated。
💡 顺序铁律:永远"后端先定 endpoint → 重生成契约 → 前端再消费"。反过来手写前端接口,就破坏了"契约单一事实源"。
