Skip to content

封装请求

要点

  • 当前如果页面自己写请求,问题通常集中在四个地方
  • 这个模块要解决的,不只是「把 fetch 包起来」这么简单
  • 当前封装出来的结果如下
  • 这份封装里最重要的一个地方,其实不是 fetch,而是 resolveBaseURL()
  • 只统一 baseURL 还不够

内容

1. 概述

前面已经把 API 路由按域拆开,也把 web 子站里的请求按 api.ts 文件拆开了。但当前还有一个问题没有解决:web 子站这边,服务端组件和客户端组件的请求方式还没有统一

我们想要的方式是,封装一个方法:可以直接通过如下方式调用接口

typescript
// index.tsx
import type { PingRequest, PingResponse } from '@repo/contracts'

import { http } from '@/http'

export function postPing(payload: PingRequest) {

  return http.post<PingRequest, PingResponse>('/rpc/system/ping', payload)

}

如果不提前封装,请求代码很快就会出现两种分裂:

  • 服务端组件里自己拼 fetch(url, init)
  • 客户端组件里再写一套 baseURL、query、JSON body 和错误兜底

所以这篇文章要做的事情很明确:封装一个单入口的 http 模块,让 web/admin 子站里的服务端组件和客户端组件都走同一套请求方式。

2. 为什么这里不能继续让页面直接写请求

当前如果页面自己写请求,问题通常集中在四个地方:

  • 每个页面都要自己拼 baseURL
  • 每个页面都要自己处理 query string
  • 每个页面都要自己序列化 POST body
  • 每个页面都要自己兜底网络异常,手动拼 ApiResponse

这些逻辑单看都不大,但它们本质上属于「请求基础设施」,不该散落在页面里。

页面真正该关心的是:

  • 调哪个接口
  • 传什么 payload
  • 怎么展示结果

而不是每次都重新处理 fetch 的公共细节。

所以当前最合理的方式,是把这些底层细节收进一个统一的 http 模块,让调用方只关心接口语义。

3. 统一 http 模块要解决哪几件事

这个模块要解决的,不只是「把 fetch 包起来」这么简单。

它至少要统一处理下面几件事:

  • 当前该从哪里拿 baseURL
  • GET 请求怎么拼 query
  • POST 请求怎么序列化 JSON body
  • 网络失败时怎么返回统一的 ApiResponse

也就是说,调用方不应该自己读取环境变量,baseURL 的单一真源应该集中在 getWebClientEnv()

这些判断都应该放到 http 模块内部。

4. 单入口 http 模块怎么写

当前封装出来的结果如下:

typescript
// apps/web/src/http.ts
import type { ApiResponse } from '@repo/contracts'

import { BizCode } from '@repo/contracts'

import { getWebClientEnv } from '@/config/env/client'

export type HttpQuery = Record<

  string,

  string | number | boolean | undefined

>

export type HttpGetOptions = {

  query?: HttpQuery

  init?: RequestInit

}

export type HttpPostOptions = {

  init?: RequestInit

}

// 单入口 http 模块。调用方不需要关心当前运行在服务端还是客户端。

function resolveBaseURL() {

  return getWebClientEnv().NEXT_PUBLIC_API_BASE_URL

}

function buildSearchParams(query?: HttpQuery) {

  if (!query) {

    return ''

  }

  const params = new URLSearchParams()

  for (const [key, value] of Object.entries(query)) {

    if (value === undefined) {

      continue

    }

    params.set(key, String(value))

  }

  const search = params.toString()

  return search ? `?${search}` : ''

}

function createRequestInit(

  method: 'GET' | 'POST',

  payload: unknown,

  init?: RequestInit,

): RequestInit {

  if (method === 'GET') {

    return {

      method,

      ...init,

    }

  }

  return {

    method,

    headers: {

      'content-type': 'application/json',

      ...(init?.headers ?? {}),

    },

    body: JSON.stringify(payload),

    ...init,

  }

}

// 所有 GET/POST 都会收敛到这里:拼 URL、序列化 JSON、调用 fetch、统一异常结构。

async function request<TData>(

  method: 'GET' | 'POST',

  path: string,

  options?: {

    payload?: unknown

    query?: HttpQuery

    init?: RequestInit

  },

): Promise<ApiResponse<TData>> {

  try {

    const url = new URL(

      `${path}${buildSearchParams(options?.query)}`,

      resolveBaseURL(),

    ).toString()

    const response = await fetch(

      url,

      createRequestInit(method, options?.payload, options?.init),

    )

    return await response.json()

  } catch (error) {

    return {

      ok: false,

      error: {

        code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,

        message: error instanceof Error ? error.message : 'API request failed',

      },

      meta: {

        requestId: 'unavailable',

        timestamp: new Date().toISOString(),

      },

    }

  }

}

export const http = {

  get<TData>(path: string, options?: HttpGetOptions) {

    return request<TData>('GET', path, {

      query: options?.query,

      init: options?.init,

    })

  },

  post<TReq, TData>(

    path: string,

    payload: TReq,

    options?: HttpPostOptions,

  ) {

    return request<TData>('POST', path, {

      payload,

      init: options?.init,

    })

  },

}

5. resolveBaseURL()

这份封装里最重要的一个地方,其实不是 fetch,而是 resolveBaseURL()

当前项目的运行环境有两套:

  • 服务端组件运行在 Node/Next 服务端
  • 客户端组件运行在浏览器

两边共用 getWebClientEnv().NEXT_PUBLIC_API_BASE_URL,因此不会出现服务端和浏览器域名配置漂移。现在把它集中到 resolveBaseURL() 里,调用方也不需要自己读取环境变量。

这正是这次封装最重要的价值:把运行时差异藏在底层,而不是分散到页面和每个 api.ts 里。

6. query、body 和异常的处理

只统一 baseURL 还不够。

如果 query string、POST body、异常结构还散在外面,页面一样会越来越臃肿。

这份封装里,buildSearchParams() 负责把 GET 请求参数统一转成 query string,顺手处理掉 undefined 值。

createRequestInit() 则把 GET 和 POST 的差异收口了:

  • GET 不带 body
  • POST 自动补 content-type: application/json
  • POST 自动 JSON.stringify(payload)

这样后面页面和 api.ts 文件都不需要再重复写:

  • new URLSearchParams(...)
  • headers: { 'content-type': 'application/json' }
  • body: JSON.stringify(payload)

异常处理同样值得单独收口。

现在约定的是:一旦 fetch 抛错,不把异常继续抛给页面,而是直接返回统一的 ApiResponse<TData> 失败结构:

  • ok: false
  • error.code = BizCode.SYSTEM_UPSTREAM_TIMEOUT
  • error.message 放原始错误信息
  • meta 填一份兜底值

这样页面层始终拿到的是同一种 envelope,不需要一边处理 try/catch,一边处理业务错误码。

7. 如何使用

以订单详情接口为例:

typescript
// apps/web/src/api/order/detail.api.ts
import type { OrderDetailRequest, OrderDetailResponse } from '@repo/contracts'

import { http } from '@/http'

export function postOrderDetail(payload: OrderDetailRequest) {

  return http.post<OrderDetailRequest, OrderDetailResponse>(

    '/rpc/order/detail',

    payload,

  )

}

这里非常简洁,只需要把具体接口和具体类型对接起来

它不再关心:

  • baseURL 怎么拿
  • body 怎么序列化
  • fetch 异常怎么兜底
  • 返回值外层结构怎么统一

这些都已经在 http 里解决了。

这就是理想的分层状态:

  • http.ts 负责请求基础设施
  • api/*.api.ts 负责接口语义映射
  • 页面负责展示

8. 页面里的使用

组件里的调用方式可以简化成这样:

tsx
// apps/web/app/verify/order/detail/page.tsx
import { postOrderDetail } from '@/api/order/detail.api'

export default async function OrderDetailPage() {

  const payload = { id: 'order-001' }

  const result = await postOrderDetail(payload)

  return (

    <main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-6 py-12 md:px-10">

      <h1 className="text-3xl font-semibold tracking-tight text-foreground">Order / detail</h1>

      <pre className="rounded-[var(--radius-card)] border border-border bg-muted/40 p-5 text-sm leading-6 text-muted-foreground">

        {JSON.stringify({ payload, result }, null, 2)}

      </pre>

    </main>

  )

}

这就是这套封装真正想要的结果:页面只保留接口调用和结果展示,不碰请求底座。

9. 后续的扩展

这次的封装得到的收益至少有五个:

  • 服务端和客户端请求入口统一了
  • baseURL 解析只保留一份
  • query / body / JSON 序列化逻辑只保留一份
  • fetch 异常统一转换成 ApiResponse 失败结构
  • 每个接口文件和页面都变得更简洁

更重要的是,后面不管 web 和 admin 再加多少接口,请求方式都不会发生变化

我们后面还需要根据需求继续增加:

  • http.put
  • http.delete
  • 统一鉴权 header
  • 统一超时控制
  • 统一埋点

这些能力都可以继续加在同一个入口上,而不是回到每个页面各写一套

基于 MIT 协议开源