Huma + Orval 前后端 API 自动生成方案
本文记录从 Go 后端生成 OpenAPI,到 React 前端生成 TypeScript API 的完整操作。
最终调用形式:
api.post.list();
api.post.get();
api.article.list();
api.articleType.list();
一、核心步骤
整个流程只有 6 步:
- 后端通过 Huma 注册 API,并暴露
/openapi.yaml。 - 后端给每个 Group 设置 Tag,Tag 用作前端模块名。
- 后端将
operationId处理成全局唯一的“动作-资源”,例如list-post。 - 前端 Orval 使用
mode: "tags",按 Tag 生成不同文件。 - 前端 Orval 将
list-post转换为局部函数名list。 - 生成结束后运行
gen-index.mjs,组装成api.post.list()。
对应关系:
后端路由 /post
↓
OpenAPI Tag: post
OpenAPI operationId: list-post
↓
Orval 生成 generated/post.ts
↓
operationName 将 list-post 转成 list
↓
gen-index.mjs 将 post.ts 组装到 api.post
↓
api.post.list()
二、后端操作
后端项目:
/Users/admin/files.localized/mcode/blog-server
步骤 1:初始化 Huma
文件:internal/bootstrap/app.go
后端使用 humago.New 将 Huma 注册到 Go 的 http.ServeMux:
package bootstrap
import (
"blog-server/internal/infrastructure"
"blog-server/internal/modules/article"
"blog-server/internal/modules/post"
sharedApi "blog-server/internal/shared/api"
"log"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type App struct {
Router *http.ServeMux
Config *infrastructure.Config
Database *gorm.DB
Server *http.Server
Redis *redis.Client
}
func (app *App) Run() {
addr := ":" + app.Config.HTTPPort
server := &http.Server{
Addr: addr,
Handler: sharedApi.Cors(app.Router),
}
log.Printf("HTTP 服务已启动: http://localhost%s", addr)
log.Printf("OpenAPI 文档: http://localhost%s/docs", addr)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func NewApp() *App {
cfg := infrastructure.LoadConfig()
db, err := infrastructure.NewPostgres(cfg.Database)
if err != nil {
panic(err)
}
router := http.NewServeMux()
humaConfig := huma.DefaultConfig("My API", "1.0.0")
humaConfig.CreateHooks = nil
api := humago.New(router, humaConfig)
redisClient, _ := infrastructure.NewRedis(cfg.Redis)
article.RegisterModule(db, api)
post.RegisterModule(db, api)
return &App{
Router: router,
Config: cfg,
Database: db,
Redis: redisClient,
}
}
Huma 默认提供:
http://localhost:8080/docs
http://localhost:8080/openapi.yaml
http://localhost:8080/openapi.json
前端 Orval 使用的是:
http://localhost:8080/openapi.yaml
步骤 2:注册 Post 路由
文件:internal/modules/post/api/router.go
完整推荐写法:
package api
import (
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(handler *PostHandler, api huma.API) {
postGroup := huma.NewGroup(api, "/post")
postGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-post"
op.Tags = []string{"post"}
op.Description = "说说"
})
huma.Get(postGroup, "", handler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
huma.Get(postGroup, "/{id}", handler.Get, func(op *huma.Operation) {
op.OperationID = "get"
op.Summary = "获取单条"
})
huma.Post(postGroup, "", handler.Create, func(op *huma.Operation) {
op.OperationID = "create"
op.Summary = "新增"
})
huma.Put(postGroup, "/{id}", handler.Update, func(op *huma.Operation) {
op.OperationID = "update"
op.Summary = "更新"
})
huma.Delete(postGroup, "/{id}", handler.Delete, func(op *huma.Operation) {
op.OperationID = "remove"
op.Summary = "删除"
})
}
生成的 OpenAPI 关键信息:
paths:
/post:
get:
tags:
- post
operationId: list-post
post:
tags:
- post
operationId: create-post
/post/{id}:
get:
tags:
- post
operationId: get-post
put:
tags:
- post
operationId: update-post
delete:
tags:
- post
operationId: remove-post
步骤 3:注册 Article 路由
文件:internal/modules/article/api/router.go
完整推荐写法:
package api
import (
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(
articleHandler *ArticleHandler,
articleTypeHandler *ArticleTypeHandler,
api huma.API,
) {
articleGroup := huma.NewGroup(api, "/article")
articleGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-article"
op.Tags = []string{"article"}
})
huma.Get(articleGroup, "", articleHandler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
articleTypeGroup := huma.NewGroup(api, "/article-type")
articleTypeGroup.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-article-type"
op.Tags = []string{"article-type"}
})
huma.Get(
articleTypeGroup,
"",
articleTypeHandler.List,
func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
},
)
}
生成的 OpenAPI 关键信息:
paths:
/article:
get:
tags:
- article
operationId: list-article
/article-type:
get:
tags:
- article-type
operationId: list-article-type
步骤 4:启动并检查后端
启动后端:
cd /Users/admin/files.localized/mcode/blog-server
go run .
浏览器打开:
http://localhost:8080/openapi.yaml
确认至少存在:
list-post
get-post
create-post
update-post
remove-post
list-article
list-article-type
三、前端操作
前端项目:
/Users/admin/files.localized/mcode/new-blog
步骤 1:安装依赖并增加生成命令
安装依赖:
pnpm add axios
pnpm add -D orval
package.json 增加:
{
"scripts": {
"gen:api": "orval"
},
"dependencies": {
"axios": "^1.19.0"
},
"devDependencies": {
"orval": "^8.23.0"
}
}
步骤 2:创建 Axios 请求适配器
文件:src/api/custom-axios.ts
当前原文代码:
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
});
export const customAxios = async <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig,
): Promise<T> => {
const response = await axiosInstance<T>({
...config,
...options,
headers: {
...config.headers,
...options?.headers,
},
});
return response.data;
};
export type ErrorType<T> = AxiosError<T>;
步骤 3:创建 Orval 配置
文件:orval.config.ts
当前原文代码:
import { defineConfig } from 'orval';
export default defineConfig({
myApi: {
input: 'http://localhost:8080/openapi.yaml',
output: {
clean: true,
tsconfig: './tsconfig.app.json',
target: './src/api/generated/api.ts',
schemas: './src/api/generated/models',
client: 'axios-functions',
mode: 'tags',
override: {
mutator: {
path: './src/api/custom-axios.ts',
name: 'customAxios',
},
operationName: (operation, route, verb) => {
const id = operation.operationId || '';
if (id.includes('-')) {
return id.split('-')[0];
}
return id;
},
},
},
hooks: {
afterAllFilesWrite: {
command: 'node src/api/gen-index.mjs',
injectGeneratedDirsAndFiles: false,
},
},
},
});
步骤 4:创建 API 总入口生成脚本
文件:src/api/gen-index.mjs
当前原文代码:
import { readdir, writeFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
const generatedDir = 'src/api/generated';
const outputFile = 'src/api/index.ts';
const camelCase = (value) =>
value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const entries = await readdir(generatedDir, { withFileTypes: true });
const tags = entries
.filter(
(entry) => entry.isFile() && extname(entry.name) === '.ts',
)
.map((entry) => basename(entry.name, '.ts'));
tags.sort();
const imports = tags
.map((tag) => {
const variable = camelCase(tag);
return `import * as ${variable} from './generated/${tag}';`;
})
.join('\n');
const properties = tags
.map((tag) => ` ${camelCase(tag)},`)
.join('\n');
const content = `// 此文件由脚本自动生成,请勿手动修改。
${imports}
export const api = {
${properties}
} as const;
export default api;
`;
await writeFile(outputFile, content, 'utf8');
步骤 5:执行生成
先保证后端正在运行,然后执行:
cd /Users/admin/files.localized/mcode/new-blog
pnpm gen:api
生成目录大致如下:
src/api/
├── custom-axios.ts
├── gen-index.mjs
├── index.ts
└── generated/
├── article.ts
├── article-type.ts
├── post.ts
└── models/
步骤 6:检查自动生成的总入口
文件:src/api/index.ts
自动生成内容:
// 此文件由脚本自动生成,请勿手动修改。
import * as article from './generated/article';
import * as articleType from './generated/article-type';
import * as post from './generated/post';
export const api = {
article,
articleType,
post,
} as const;
export default api;
步骤 7:业务页面调用
import api from '@/api';
import { useMount } from 'ahooks';
function Blog() {
useMount(async () => {
const response = await api.post.list();
console.log(response);
});
return <div>Blog</div>;
}
export default Blog;
四、代码讲解
前面的内容是实际操作,下面单独解释各部分为什么这样写。
1. 后端 Tag 决定前端模块
后端代码:
op.Tags = []string{"post"}
配合前端:
mode: 'tags'
Orval 会生成:
src/api/generated/post.ts
因此 Tag 负责的是 api.post 中的 post。
对于:
op.Tags = []string{"article-type"}
Orval 生成 article-type.ts,之后 gen-index.mjs 将文件名转换成 articleType。
2. 后端 OperationID 必须全局唯一
下面的写法不符合 OpenAPI 规范:
// /post
op.OperationID = "list"
// /article
op.OperationID = "list"
即使路由属于不同的 huma.NewGroup,operationId 的唯一性范围仍然是整份 OpenAPI 文档。
因此后端 Modifier 会追加资源名:
op.OperationID = op.OperationID + "-post"
接口先设置:
op.OperationID = "list"
最终 OpenAPI 得到:
list-post
3. 为什么使用“动作-资源”
当前 Orval 配置取 operationId 中第一个 - 之前的内容:
return id.split('-')[0];
对应转换:
list-post -> list
get-post -> get
create-post -> create
list-article -> list
list-article-type -> list
如果后端使用 post-list,当前规则会得到 post(),不符合目标。因此本方案统一使用“动作-资源”。
4. Orval operationName 负责局部函数名
OpenAPI 中必须使用全局唯一的:
list-post
list-article
Orval 生成时将它们分别变成:
// generated/post.ts
export const list = ...;
// generated/article.ts
export const list = ...;
两个 list 位于不同的 Tag 文件中,因此可以通过模块隔离:
api.post.list();
api.article.list();
同一个 Tag 内仍然不能出现两个相同的局部动作。例如同一个 post Tag 中不能同时出现两个都会被转换成 list 的 operationId。
5. custom-axios.ts 的作用
Orval 只负责生成接口函数,请求最终统一交给:
customAxios
它负责:
- 设置后端
baseURL。 - 接收 Orval 生成的 Axios 配置。
- 合并调用方额外传入的配置。
- 合并请求头。
- 只返回
response.data。
以后需要添加 Token、响应拦截器或统一错误提示,可以集中修改 axiosInstance,不需要修改生成文件。
6. gen-index.mjs 的作用
Orval 的 mode: 'tags' 会生成多个文件,但不会自动生成项目需要的:
api.post.list()
gen-index.mjs 会:
- 扫描
src/api/generated下所有.ts文件。 - 将文件名作为模块名。
- 将
article-type转换为articleType。 - 使用
import * as post导入每个模块。 - 生成统一的
api对象。
该脚本通过 Orval Hook 自动执行:
hooks: {
afterAllFilesWrite: {
command: 'node src/api/gen-index.mjs',
},
},
五、日常新增接口流程
以后新增接口时按下面顺序操作。
后端
- 在正确的 Huma Group 中注册接口。
- 设置局部动作名,例如
list、get、create、update、remove。 - 确认 Group Modifier 会追加资源名。
- 确认 Group 设置了正确的 Tag。
- 启动后端并检查
/openapi.yaml。
示例:
huma.Post(postGroup, "", handler.Create, func(op *huma.Operation) {
op.OperationID = "create"
op.Summary = "新增"
})
最终必须生成:
create-post
前端
- 确保后端已启动。
- 执行
pnpm gen:api。 - 检查对应 Tag 文件是否更新。
- 检查
src/api/index.ts是否包含新模块。 - 运行前端类型检查或构建。
- 使用生成后的函数,不手写重复请求。
六、注意事项
1. 不要手动修改 generated
以下目录由 Orval 管理:
src/api/generated
配置中存在:
clean: true
每次生成前 Orval 都可能清理旧文件,手动修改会丢失。
src/api/index.ts 也由 gen-index.mjs 自动覆盖,不应手动维护。
2. 生成前必须启动后端
Orval 的输入是 HTTP 地址:
input: 'http://localhost:8080/openapi.yaml'
如果后端未启动,pnpm gen:api 无法获得接口文档。
3. OperationID 命名必须统一
本方案固定使用:
动作-资源
不要混用:
list-post
post-get
list1
list2
否则 Orval 的 operationName 无法稳定生成正确函数名。
4. 同一 Tag 内局部动作不能重复
下面两个 ID 虽然在 OpenAPI 中不同:
list-post
list-post-history
但当前 operationName 都会转换成:
list
如果它们属于同一个 post Tag,就会产生命名冲突。此时应使用不同动作:
list-post
listHistory-post
前端生成:
api.post.list();
api.post.listHistory();
七、最终职责划分
后端负责
- 提供 OpenAPI 文档。
- 维护接口请求和响应 Schema。
- 使用 Tag 定义前端业务模块。
- 使用“动作-资源”保证
operationId全局唯一。
前端负责
- Orval 读取 OpenAPI 文档。
- 按 Tag 拆分 API 文件。
- 将全局
operationId转换成模块内函数名。 - 使用
customAxios统一发送请求。 - 使用
gen-index.mjs组装api.<模块>.<方法>()。
最终结果:
api.post.list();
api.post.get();
api.post.create();
api.article.list();
api.articleType.list();
八、后续优化建议
前面的方案已经可以正常使用。下面是项目接口增多后可以继续做的优化,不是首次接入的必需步骤。
1. 后端统一封装 NewAPIGroup
目前每个模块都要重复编写:
group := huma.NewGroup(api, "/post")
group.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-post"
op.Tags = []string{"post"}
})
可以在共享 API 包中增加公共方法。
例如新建:
internal/shared/api/group.go
完整代码:
package api
import "github.com/danielgtaylor/huma/v2"
func NewGroup(api huma.API, path, resource string) *huma.Group {
group := huma.NewGroup(api, path)
group.UseSimpleModifier(func(op *huma.Operation) {
op.OperationID = op.OperationID + "-" + resource
op.Tags = []string{resource}
})
return group
}
业务模块改为:
package api
import (
sharedApi "blog-2026ddd-server/internal/shared/api"
"github.com/danielgtaylor/huma/v2"
)
func RegisterRoutes(handler *PostHandler, api huma.API) {
postGroup := sharedApi.NewGroup(api, "/post", "post")
huma.Get(postGroup, "", handler.List, func(op *huma.Operation) {
op.OperationID = "list"
op.Summary = "列表"
})
}
这样可以保证所有模块统一使用:
动作-资源
同时避免某个模块忘记设置 Tag 或忘记补全 operationId。
命名注意
共享包本身已经叫 api,业务路由包也可能叫 api,因此导入时建议使用别名:
sharedApi "blog-2026ddd-server/internal/shared/api"
2. 后端统一动作名称
随着接口数量增加,容易出现:
delete
remove
detail
get
add
create
同类操作使用不同名字会让前端 API 不统一。建议约定基础动作:
list
get
create
update
remove
复杂业务动作使用明确的驼峰名称:
publish
archive
listHistory
batchRemove
changeStatus
生成结果示例:
listHistory-post
batchRemove-post
changeStatus-post
前端对应:
api.post.listHistory();
api.post.batchRemove();
api.post.changeStatus();
3. 提取后端 Operation 配置函数
如果不希望每个接口都重复设置 OperationID 和 Summary,可以增加一个小函数:
package api
import "github.com/danielgtaylor/huma/v2"
func operation(id, summary string) func(*huma.Operation) {
return func(op *huma.Operation) {
op.OperationID = id
op.Summary = summary
}
}
路由注册可以缩短为:
huma.Get(postGroup, "", handler.List, operation("list", "列表"))
huma.Get(postGroup, "/{id}", handler.Get, operation("get", "获取单条"))
huma.Post(postGroup, "", handler.Create, operation("create", "新增"))
huma.Put(postGroup, "/{id}", handler.Update, operation("update", "更新"))
huma.Delete(postGroup, "/{id}", handler.Delete, operation("remove", "删除"))
这个封装只负责重复字段,不应把 Handler、HTTP Method 等业务信息隐藏进去。
4. 前端增强 operationName 校验
当前代码:
operationName: (operation, route, verb) => {
const id = operation.operationId || '';
if (id.includes('-')) {
return id.split('-')[0];
}
return id;
},
可以增加明确的错误提示,避免后端忘记设置 operationId 时静默生成异常函数名:
operationName: (operation, route, verb) => {
const id = operation.operationId;
if (!id) {
throw new Error(`缺少 operationId: ${verb.toUpperCase()} ${route}`);
}
const [action, resource] = id.split('-');
if (!action || !resource) {
throw new Error(
`operationId 必须使用“动作-资源”格式,当前值: ${id}`,
);
}
return action;
},
不过资源名可能包含连字符,例如:
list-article-type
因此这里只检查前两个片段是否存在,不能要求 split('-') 后只能得到两个元素。
5. 增强 gen-index.mjs 的文件过滤
当前脚本会导入 generated 根目录下的所有 .ts 文件。为了避免以后出现辅助文件时被误加入 api 对象,可以显式排除非 Tag 文件。
import { readdir, writeFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
const generatedDir = 'src/api/generated';
const outputFile = 'src/api/index.ts';
const excludedFiles = new Set(['api.ts']);
const camelCase = (value) =>
value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const entries = await readdir(generatedDir, { withFileTypes: true });
const tags = entries
.filter(
(entry) =>
entry.isFile() &&
extname(entry.name) === '.ts' &&
!excludedFiles.has(entry.name),
)
.map((entry) => basename(entry.name, '.ts'))
.sort();
const imports = tags
.map((tag) => {
const variable = camelCase(tag);
return `import * as ${variable} from './generated/${tag}';`;
})
.join('\n');
const properties = tags
.map((tag) => ` ${camelCase(tag)},`)
.join('\n');
const content = `// 此文件由脚本自动生成,请勿手动修改。
${imports}
export const api = {
${properties}
} as const;
export default api;
`;
await writeFile(outputFile, content, 'utf8');
如果 Orval 后续改变输出结构,只需要维护 excludedFiles 或改为读取一份明确的 Tag 列表。
6. 给 customAxios 增加拦截器
当前 customAxios 已经统一了请求入口。后续可以在这里增加 Token 和统一错误处理。
示例:
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// 在这里执行统一的登录失效处理。
}
return Promise.reject(error);
},
);
不要直接修改 Orval 生成的请求函数,因为重新生成后会被覆盖。
7. 增加生成后的自动校验
可以把生成命令扩展为:
{
"scripts": {
"gen:api": "orval && tsc -b"
}
}
这样每次生成后立即执行 TypeScript 检查,可以及时发现:
- 接口名称冲突。
- Schema 变化导致的业务调用错误。
customAxios类型不匹配。- 旧代码仍然使用已经删除的接口。
如果完整构建较慢,也可以单独增加:
{
"scripts": {
"gen:api": "orval",
"gen:api:check": "orval && tsc -b"
}
}
8. 在 CI 中检查生成文件是否最新
多人协作时,后端 OpenAPI 已更新但前端忘记重新生成是常见问题。
CI 可以执行:
pnpm gen:api
git diff --exit-code -- src/api/generated src/api/index.ts
如果生成后产生差异,说明仓库中的 API 文件不是最新版本,CI 应提示开发者重新生成并提交。
这一检查需要 CI 能访问对应的 OpenAPI 文档。更稳定的方式是由后端在 CI 中生成并保存 OpenAPI 文件,前端读取本地文件:
input: '../blog-2026ddd-server/openapi.yaml'
本地开发仍可继续使用 HTTP 地址,二者可以通过环境变量切换。
9. 推荐的最终目录结构
后端:
blog-2026ddd-server/
├── internal/
│ ├── shared/api/
│ │ ├── group.go
│ │ └── error.go
│ └── modules/
│ ├── article/api/router.go
│ └── post/api/router.go
└── docs/
└── openapi-orval-api-generation.md
前端:
new-blog/
├── orval.config.ts
└── src/api/
├── custom-axios.ts
├── gen-index.mjs
├── index.ts
└── generated/
├── article.ts
├── article-type.ts
├── post.ts
└── models/
10. 优化优先级
建议按下面顺序实施:
- 先统一所有后端
operationId为“动作-资源”。 - 再提取后端
NewAPIGroup,避免新模块破坏规范。 - 给 Orval
operationName增加格式校验。 - 给
gen-index.mjs增加文件过滤。 - 增加生成后的 TypeScript 检查。
- 最后接入 CI,检查生成文件是否最新。
九. 同时SWR Hooks
当页面需要请求状态、缓存、自动刷新和请求去重时,可以让 Orval 直接生成 SWR Hooks。
安装 SWR:
pnpm add swr
修改 orval.config.ts:
import { defineConfig } from 'orval';
export default defineConfig({
myApi: {
input: 'http://localhost:8080/openapi.yaml',
output: {
clean: true,
tsconfig: './tsconfig.app.json',
target: './src/api/generated/api.ts',
schemas: './src/api/generated/models',
client: 'swr',
httpClient: 'axios',
mode: 'tags',
override: {
mutator: {
path: './src/api/custom-axios.ts',
name: 'customAxios',
},
operationName: (operation) => {
const id = operation.operationId || '';
if (id.includes('-')) {
return id.split('-')[0];
}
return id;
},
},
},
hooks: {
afterAllFilesWrite: {
command: 'node src/api/gen-index.mjs',
injectGeneratedDirsAndFiles: false,
},
},
},
});
这里需要注意:
client: 'swr',
httpClient: 'axios',
并不表示只能生成 SWR Hook。
Orval 会同时生成:
- 普通 Axios 请求函数。
- 基于请求函数封装的 SWR Query Hook。
- 基于请求函数封装的 SWR Mutation Hook。
因此不需要同时配置:
client: 'axios-functions'
client 只能选择一个生成器,而 SWR 生成器本身就会保留底层请求函数。
直接调用 Axios 请求函数
在 React 组件之外,或者不需要缓存时,可以直接调用生成的请求函数:
import api from '@/api';
const posts = await api.post.list();
const post = await api.post.get(1);
await api.post.create({
content: '新的说说',
});
customAxios 只返回 response.data,因此这里得到的是接口响应数据,而不是完整的 AxiosResponse。
使用 SWR Query Hook
GET 接口会自动生成 Query Hook:
import api from '@/api';
function PostList() {
const {
data,
error,
isLoading,
isValidating,
mutate,
} = api.post.useList({
swr: {
revalidateOnFocus: false,
revalidateOnReconnect: true,
dedupingInterval: 2000,
},
});
if (isLoading) {
return <div>加载中...</div>;
}
if (error) {
return <div>加载失败</div>;
}
return (
<div>
{data?.map((post) => (
<div key={post.id}>{post.content}</div>
))}
<button onClick={() => mutate()} disabled={isValidating}>
重新加载
</button>
</div>
);
}
获取单条数据:
const { data, isLoading } = api.post.useGet(postId, {
swr: {
enabled: Boolean(postId),
},
});
enabled: false 时不会发起请求,适合 ID 尚未准备好的场景。
使用 SWR Mutation Hook
POST、PUT、DELETE 接口会生成 Mutation Hook。
新增:
const {
trigger: createPost,
isMutating,
error,
} = api.post.useCreate();
await createPost({
content: '新的说说',
});
修改:
const {
trigger: updatePost,
isMutating,
} = api.post.useUpdate(postId);
await updatePost({
content: '修改后的内容',
});
删除:
const {
trigger: removePost,
isMutating,
} = api.post.useRemove(postId);
await removePost();
Mutation Hook 不会在组件渲染时自动执行,只有调用 trigger 才会发送请求。
写操作完成后刷新列表缓存
新增、修改或删除成功后,可以重新验证列表缓存:
import { mutate } from 'swr';
import api from '@/api';
const { trigger: createPost } = api.post.useCreate();
async function handleCreate() {
await createPost({
content: '新的说说',
});
await mutate(api.post.getListKey());
}
如果当前组件已经调用了列表 Hook,也可以直接使用列表 Hook 返回的 mutate:
const postList = api.post.useList();
const createPost = api.post.useCreate();
async function handleCreate() {
await createPost.trigger({
content: '新的说说',
});
await postList.mutate();
}
Axios 与 SWR 的职责
Axios 负责:
- Base URL。
- Token 和公共请求头。
- 请求、响应拦截器。
- 超时配置。
- HTTP 错误对象。
- 实际发送网络请求。
SWR 负责:
- 请求缓存。
- 相同请求去重。
- 加载和错误状态。
- 页面聚焦后重新验证。
- 网络恢复后重新验证。
- 多个组件之间共享服务端数据。
- Mutation 和乐观更新。
因此最终结构是:
React 组件
↓
SWR Query / Mutation Hook
↓
Orval 生成的请求函数
↓
customAxios
↓
axiosInstance
↓
后端 API
对于不需要缓存的场景,可以跳过 SWR,直接调用 Orval 生成的请求函数:
普通函数或事件处理
↓
Orval 生成的请求函数
↓
customAxios
↓
后端 API
这样既保留了普通 Axios 函数的灵活性,也获得了 SWR 的缓存和服务端状态管理能力。
十、此方案的好处
1. 同时满足 OpenAPI 规范和前端调用习惯
后端使用全局唯一的 operationId:
list-post
list-article
避免 OpenAPI 校验、接口文档和其他代码生成工具出现命名冲突。
前端经过 Tag 分组和名称转换后,仍然可以使用简洁的调用方式:
api.post.list();
api.article.list();
全局唯一性由后端负责,模块内的易用性由前端负责,两者互不冲突。
2. 前后端接口类型始终保持一致
请求参数、响应结构和字段类型都来自后端 OpenAPI,不需要在前端重复手写 TypeScript 类型。
当后端修改 DTO 后,前端重新执行:
pnpm gen:api
即可同步最新类型。字段删除、类型变化或必填规则变化会在 TypeScript 检查阶段暴露,减少运行时才发现问题的情况。
3. 减少重复的接口代码
前端不再为每个接口重复编写:
axios.get(...);
axios.post(...);
也不需要重复维护请求参数类型、响应类型和接口路径。Orval 根据 OpenAPI 自动生成这些内容,开发者只需要调用生成后的函数。
4. API 按业务模块组织
后端 Tag 会直接映射成前端模块:
post -> api.post
article -> api.article
article-type -> api.articleType
相比把所有接口函数放在一个文件中,按模块组织更容易查找,也能避免不同业务之间的函数名相互污染。
5. 可以安全使用 list、get 等简短方法名
如果没有模块分层,前端可能需要使用:
listPost();
listArticle();
getPost();
本方案通过 Tag 提供模块命名空间,因此可以写成:
api.post.list();
api.article.list();
api.post.get();
调用代码更短,同时仍然能从模块名看出接口所属业务。
6. 请求配置集中管理
所有生成接口统一经过 customAxios。以后增加以下能力时,只需要修改一个文件:
- API Base URL。
- Token 和公共请求头。
- 请求、响应拦截器。
- 登录失效处理。
- 统一错误提示。
- 超时时间和重试策略。
生成代码只描述接口本身,请求基础设施由项目统一维护。
7. 后端是唯一接口事实来源
接口路径、HTTP Method、参数、响应和 Schema 都以后端 OpenAPI 为准。
这样可以避免同时维护以下多份容易不一致的定义:
- 后端路由和 DTO。
- 人工编写的接口文档。
- 前端请求函数。
- 前端 TypeScript 类型。
后端修改接口后重新生成前端代码即可完成同步。
8. 接口变更更容易被发现
生成文件发生变化时,可以直接通过 Git Diff 查看:
- 新增了哪些接口。
- 删除了哪些接口。
- 哪些请求参数发生变化。
- 哪些响应字段发生变化。
- 哪些类型从可选变成必填。
配合 TypeScript 检查和 CI,可以让不兼容修改在合并代码前被发现。
9. 新模块接入成本低
新增业务模块时,后端只需要创建 Group、设置 Tag 并遵循 动作-资源 命名约定。前端重新生成后,会自动获得新的 Tag 文件和 API 模块。
例如后端新增:
Tag: comment
operationId: list-comment
前端生成后即可使用:
api.comment.list();
不需要手动修改 API 总入口。
10. 不绑定具体业务页面
生成的 API 是普通 TypeScript 函数,不依赖某个 React 页面或组件,也不强制使用特定状态管理方案。
同一接口可以用于:
- React 页面和组件。
- 自定义 Hook。
- 表单提交。
- 数据预加载。
- 普通工具函数。
- 单元测试。
因此接口层和 UI 层保持解耦,后续更换页面结构或数据请求方案时影响较小。

浙公网安备 33010602011771号