Huma + Orval 前后端 API 自动生成方案

本文记录从 Go 后端生成 OpenAPI,到 React 前端生成 TypeScript API 的完整操作。

最终调用形式:

api.post.list();
api.post.get();
api.article.list();
api.articleType.list();

一、核心步骤

整个流程只有 6 步:

  1. 后端通过 Huma 注册 API,并暴露 /openapi.yaml
  2. 后端给每个 Group 设置 Tag,Tag 用作前端模块名。
  3. 后端将 operationId 处理成全局唯一的“动作-资源”,例如 list-post
  4. 前端 Orval 使用 mode: "tags",按 Tag 生成不同文件。
  5. 前端 Orval 将 list-post 转换为局部函数名 list
  6. 生成结束后运行 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.NewGroupoperationId 的唯一性范围仍然是整份 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 中不能同时出现两个都会被转换成 listoperationId

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 会:

  1. 扫描 src/api/generated 下所有 .ts 文件。
  2. 将文件名作为模块名。
  3. article-type 转换为 articleType
  4. 使用 import * as post 导入每个模块。
  5. 生成统一的 api 对象。

该脚本通过 Orval Hook 自动执行:

hooks: {
  afterAllFilesWrite: {
    command: 'node src/api/gen-index.mjs',
  },
},

五、日常新增接口流程

以后新增接口时按下面顺序操作。

后端

  1. 在正确的 Huma Group 中注册接口。
  2. 设置局部动作名,例如 listgetcreateupdateremove
  3. 确认 Group Modifier 会追加资源名。
  4. 确认 Group 设置了正确的 Tag。
  5. 启动后端并检查 /openapi.yaml

示例:

huma.Post(postGroup, "", handler.Create, func(op *huma.Operation) {
	op.OperationID = "create"
	op.Summary = "新增"
})

最终必须生成:

create-post

前端

  1. 确保后端已启动。
  2. 执行 pnpm gen:api
  3. 检查对应 Tag 文件是否更新。
  4. 检查 src/api/index.ts 是否包含新模块。
  5. 运行前端类型检查或构建。
  6. 使用生成后的函数,不手写重复请求。

六、注意事项

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 配置函数

如果不希望每个接口都重复设置 OperationIDSummary,可以增加一个小函数:

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. 优化优先级

建议按下面顺序实施:

  1. 先统一所有后端 operationId 为“动作-资源”。
  2. 再提取后端 NewAPIGroup,避免新模块破坏规范。
  3. 给 Orval operationName 增加格式校验。
  4. gen-index.mjs 增加文件过滤。
  5. 增加生成后的 TypeScript 检查。
  6. 最后接入 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 会同时生成:

  1. 普通 Axios 请求函数。
  2. 基于请求函数封装的 SWR Query Hook。
  3. 基于请求函数封装的 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 层保持解耦,后续更换页面结构或数据请求方案时影响较小。

posted @ 2026-08-08 17:40  丁少华  阅读(6)  评论(0)    收藏  举报