加载中...

技术栈示例: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 为例(后端已就绪并重生成契约后):

  1. npm run openapi:check 确认新端点进了契约 → npm run openapi:generate
  2. entities/payment/model/types.ts(前端领域模型)+ payment-adapter.ts(防腐)。
  3. entities/payment/api/use-payments.ts(包装生成 hook、定 staleTime/select)。
  4. features/payment/ui/:交互组件,只调 entities/payment/api 的 hook。
  5. routes/ 或某个 widgets/ 里接进去。
  6. 跑边界 linter,确认没跨层 / 没直接用 generated。

💡 顺序铁律:永远"后端先定 endpoint → 重生成契约 → 前端再消费"。反过来手写前端接口,就破坏了"契约单一事实源"。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关