$api

View source
由 api.factory 插件创建的增强型 $fetch 实例,支持认证注入、数据解包、业务检查和多端点切换。

介绍

$api 是通过 Nuxt 插件注入的全局 $fetch 实例,内置完整的拦截器链(认证注入、业务状态码检查、数据解包、Toast 提示)。它是所有 API composable(useApiFetchuseLazyApiFetch 等)的底层引擎。

用法

const { $api } = useNuxtApp()

// GET 请求(数据已自动解包)
const users = await $api<User[]>('/users')

// POST 请求
const user = await $api<User>('/users', {
  method: 'POST',
  body: { name: 'test', email: 'test@example.com' }
})

// PUT 请求
const updated = await $api<User>(`/users/${id}`, {
  method: 'PUT',
  body: { name: 'new-name' }
})

// DELETE 请求
await $api(`/users/${id}`, { method: 'DELETE' })
$api 返回的数据已经过拦截器自动解包(提取 dataKey 字段),泛型 T 直接对应业务数据类型。

多端点切换

使用 $api.use(endpoint) 切换到指定端点:

const { $api } = useNuxtApp()

// 使用默认端点
const users = await $api('/users')

// 切换到 admin 端点
const adminUsers = await $api.use('admin')('/users')

// 保存端点引用,避免重复 use()
const adminApi = $api.use('admin')
const config = await adminApi('/config')
const stats = await adminApi('/stats')

端点实例内部有缓存机制,多次调用 $api.use('admin') 返回同一实例。

切换并对照 baseURL

$api 默认走 default 端点的 baseURL$api.use(name) 切换到别名端点后请求改用对应 baseURL。下拉切换端点即可观察 baseURL 与返回随之变化:

baseURL: /api

$api 默认走 default 端点的 baseURL,$api.use(name) 切换到别名端点后请求改用对应 baseURL。

<script setup lang="ts">
const props = defineProps<{
  endpoint: 'default' | 'v2'
}>()

const { $api } = useNuxtApp()
const publicConfig = useRuntimeConfig().public.movkApi

const data = ref<unknown>(null)
const pending = ref(false)
const error = ref<string | null>(null)

const baseURL = computed(() => publicConfig.endpoints?.[props.endpoint]?.baseURL)

async function load() {
  pending.value = true
  error.value = null
  try {
    data.value = await $api.use(props.endpoint)('/profile')
  }
  catch (err) {
    error.value = err instanceof Error ? err.message : String(err)
  }
  finally {
    pending.value = false
  }
}

watch(() => props.endpoint, load, { immediate: true })
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2 flex-wrap">
      <UBadge :color="endpoint === 'v2' ? 'secondary' : 'neutral'" variant="subtle">
        baseURL: {{ baseURL }}
      </UBadge>
      <UButton size="sm" variant="outline" icon="i-lucide-refresh-cw" :loading="pending" @click="load">
        刷新
      </UButton>
    </div>
    <p class="text-xs text-muted">
      $api 默认走 default 端点的 baseURL,$api.use(name) 切换到别名端点后请求改用对应 baseURL。
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ error ? { error } : data }}</pre>
  </div>
</template>

命令式调用

脱离 useFetch 上下文,直接通过 $api.use(name) 取得端点实例并发起裸请求:

<script setup lang="ts">
const { $api } = useNuxtApp()

const data = ref<unknown>(null)
const pending = ref(false)
const error = ref<string | null>(null)

async function call() {
  pending.value = true
  error.value = null
  try {
    data.value = await $api.use('v2')('/profile')
  }
  catch (err) {
    error.value = err instanceof Error ? err.message : String(err)
  }
  finally {
    pending.value = false
  }
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" icon="i-lucide-zap" :loading="pending" @click="call">
      $api.use('v2')('/profile')
    </UButton>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ error ? { error } : data }}</pre>
  </div>
</template>

当前生效配置

runtimeConfig.public.movkApi 暴露端点列表与默认端点名,便于运行时调试:

nuxt.configmovk.api.endpoints 注册新别名后即可被 endpoint 选项引用。

{
  "defaultEndpoint": "default",
  "endpoints": {
    "default": {
      "baseURL": "/api"
    },
    "v2": {
      "baseURL": "/api/demo/v2"
    }
  }
}
<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
</script>

<template>
  <div class="flex flex-col gap-3">
    <p class="text-sm text-muted">
<code>nuxt.config</code><code>movk.api.endpoints</code> 注册新别名后即可被 <code>endpoint</code> 选项引用。
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ { defaultEndpoint: publicConfig.defaultEndpoint, endpoints: publicConfig.endpoints } }}</pre>
  </div>
</template>
了解如何配置多个 API 端点

认证集成

模块自动接入 nuxt-auth-utils:登录后从 session[sessionTokenPath] 读取 token,所有 $api 请求自动注入 Authorization 头;遇到 401 时按 auth.unauthorized 策略清理 session 或跳转登录页。

当前 Session

useUserSession 暴露的响应式 session;登录 / 登出后调用 fetch() 同步状态:

未登录

sessionTokenPath: token

{
  "id": "a5009294-11c6-46b8-9391-c2344af8275b"
}
<script setup lang="ts">
const { session, loggedIn, fetch: refreshSession } = useUserSession()
const { $api } = useNuxtApp()
const publicConfig = useRuntimeConfig().public.movkApi

const loginPending = ref(false)
const logoutPending = ref(false)

async function login() {
  loginPending.value = true
  try {
    await $api('/demo/login', { method: 'POST' })
    await refreshSession()
  }
  finally {
    loginPending.value = false
  }
}

async function logout() {
  logoutPending.value = true
  try {
    await $api('/demo/logout', { method: 'POST' })
    await refreshSession()
  }
  finally {
    logoutPending.value = false
  }
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2 flex-wrap">
      <UBadge :color="loggedIn ? 'success' : 'neutral'" variant="subtle">
        {{ loggedIn ? '已登录' : '未登录' }}
      </UBadge>
      <UButton
        v-if="!loggedIn"
        size="sm"
        :loading="loginPending"
        icon="i-lucide-log-in"
        @click="login"
      >
        登录(写入 session.token)
      </UButton>
      <UButton
        v-else
        size="sm"
        color="error"
        variant="outline"
        :loading="logoutPending"
        icon="i-lucide-log-out"
        @click="logout"
      >
        登出(清除 session)
      </UButton>
    </div>
    <p class="text-xs text-muted">
      sessionTokenPath: {{ publicConfig.auth?.sessionTokenPath }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ session }}</pre>
  </div>
</template>

调用受保护接口

useApiFetch 自动携带 Authorization 头访问 /demo/protected;未登录时返回 401:

未登录时拦截器返回 401;已登录则携带 Authorization 头

<script setup lang="ts">
const protectedRes = useApiFetch('/demo/protected', { immediate: false })

const display = computed(() => {
  if (protectedRes.error.value) {
    return {
      statusCode: protectedRes.error.value.statusCode,
      message: protectedRes.error.value.message
    }
  }
  return protectedRes.data.value
})
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" icon="i-lucide-shield-check" @click="protectedRes.execute()">
      请求 /demo/protected
    </UButton>
    <p class="text-xs text-muted">
      未登录时拦截器返回 401;已登录则携带 Authorization 头
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ display }}</pre>
  </div>
</template>

401 处理策略

auth.unauthorized 控制重定向与 session 清理;docs 站点为便于演示关闭重定向:

auth.unauthorized 控制 401 时的行为;docs 站点关闭重定向便于演示,生产建议启用 redirect: true + loginPath

{
  "redirect": false,
  "clearSession": true,
  "loginPath": "/login"
}
<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
</script>

<template>
  <div class="flex flex-col gap-3">
    <p class="text-sm text-muted">
      <code>auth.unauthorized</code> 控制 401 时的行为;docs 站点关闭重定向便于演示,生产建议启用 <code>redirect: true</code> + <code>loginPath</code>    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ publicConfig.auth?.unauthorized }}</pre>
  </div>
</template>

令牌注入格式

headerName + tokenType 决定最终请求头形态;后端 echo authHeader 字段供核对:

{
  "headerName": "Authorization",
  "tokenType": "Bearer",
  "echoed": null
}

登录后再次请求,echoed 字段会展示拦截器实际注入的 Authorization 头。

<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
const protectedRes = useApiFetch('/demo/protected', { immediate: false })

const echoed = computed(() => {
  const data = protectedRes.data.value
  return data && typeof data === 'object'
    ? (data as { authHeader?: string }).authHeader
    : null
})
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" variant="outline" icon="i-lucide-send" @click="protectedRes.execute()">
      请求 /demo/protected 查看注入
    </UButton>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ {
      headerName: publicConfig.auth?.headerName ?? 'Authorization',
      tokenType: publicConfig.auth?.tokenType ?? 'Bearer',
      echoed
    } }}</pre>
    <p class="text-xs text-muted">
      登录后再次请求,<code>echoed</code> 字段会展示拦截器实际注入的 Authorization 头。
    </p>
  </div>
</template>

数据解包与业务校验

$api 默认按 response.dataKey 抽取业务数据并按 successCodes 校验业务码;两个开关 skipUnwrap / skipBusinessCheck 让调用方按场景接管。下拉切换模式观察返回结构差异:

  • default:按默认 data 字段抽取业务数据,调用方直接拿到去信封后的对象。
  • skip-unwrapskipUnwrap: true 不重写 response._data,保留完整 code/message/data 信封。
  • skip-business-check:legacy 接口未返回 code 字段,跳过校验后仍按 dataKey 解包出业务数据。
  • combined:外部接口信封非标,同时开两开关由调用方完全自处理响应。
default

按 dataKey(默认 data)抽取业务数据,调用方直接拿到去信封后的对象。

<script setup lang="ts">
const props = defineProps<{
  mode: 'default' | 'skip-unwrap' | 'skip-business-check' | 'combined'
}>()

const fetchers = {
  'default': useApiFetch('/profile', { key: 'unwrap-default', immediate: false }),
  'skip-unwrap': useApiFetch('/profile', { key: 'unwrap-skip-unwrap', immediate: false, skipUnwrap: true }),
  'skip-business-check': useApiFetch('/demo/legacy', { key: 'unwrap-skip-business-check', immediate: false, skipBusinessCheck: true }),
  'combined': useApiFetch('/demo/external', { key: 'unwrap-combined', immediate: false, skipBusinessCheck: true, skipUnwrap: true })
}

const active = computed(() => fetchers[props.mode])

const hints: Record<typeof props.mode, string> = {
  'default': '按 dataKey(默认 data)抽取业务数据,调用方直接拿到去信封后的对象。',
  'skip-unwrap': 'skipUnwrap: true 不重写 response._data,保留完整 code/message/data 信封。',
  'skip-business-check': 'legacy 接口未返回 code 字段,跳过校验后仍按 dataKey 解包出业务数据。',
  'combined': 'external 接口信封非标,同时开两开关由调用方完全自处理响应。'
}

watch(() => props.mode, () => active.value.execute(), { immediate: true })
</script>

<template>
  <div class="flex flex-col gap-3">
    <UBadge variant="subtle">
      {{ mode }}
    </UBadge>
    <p class="text-xs text-muted">
      {{ hints[mode] }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ active.data.value }}</pre>
  </div>
</template>

Toast 提示

$api 内置 Toast 提示,按 toast.successCodes 判定成功 / 失败;支持全局默认 + 请求级覆盖两层定制。下拉切换模式、配合「注入错误」开关观察成功 / 失败两条分支:

  • default:未传 toast 时继承全局配置,成功 / 错误均按默认样式弹出。
  • silenttoast: false 关闭所有单次提示。
  • messagesuccessMessage / errorMessage 替换默认文案。
  • error-only{ success: false } 关闭成功提示但保留错误提示。
  • custom:成功 / 错误分别覆盖 coloricon 等 ToastProps。

未传 toast 时继承全局配置,成功 / 错误均按默认样式弹出。

<script setup lang="ts">
const props = defineProps<{
  mode: 'default' | 'silent' | 'message' | 'error-only' | 'custom'
}>()

const fail = ref(false)
const target = () => `/profile${fail.value ? '?fail=1' : ''}`

const fetchers = {
  'default': useApiFetch(target, { key: 'toast-default', immediate: false }),
  'silent': useApiFetch(target, { key: 'toast-silent', immediate: false, toast: false }),
  'message': useApiFetch(target, {
    key: 'toast-message',
    immediate: false,
    toast: { successMessage: '加载成功 ✓', errorMessage: '加载失败 ✗' }
  }),
  'error-only': useApiFetch(target, { key: 'toast-error-only', immediate: false, toast: { success: false } }),
  'custom': useApiFetch(target, {
    key: 'toast-custom',
    immediate: false,
    toast: {
      success: { color: 'secondary', icon: 'i-lucide-sparkles' },
      error: { color: 'warning', icon: 'i-lucide-triangle-alert' }
    }
  })
}

const active = computed(() => fetchers[props.mode])

const hints: Record<typeof props.mode, string> = {
  'default': '未传 toast 时继承全局配置,成功 / 错误均按默认样式弹出。',
  'silent': 'toast: false 关闭所有单次提示。',
  'message': 'successMessage / errorMessage 替换默认文案。',
  'error-only': '{ success: false } 关闭成功提示但保留错误提示。',
  'custom': '为成功 / 错误分别覆盖 color、icon 等 ToastProps。'
}

const display = computed(() => active.value.error.value
  ? { error: active.value.error.value.message }
  : active.value.data.value)
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-3 flex-wrap">
      <USwitch v-model="fail" label="注入错误(验证错误分支)" />
      <UButton size="sm" variant="outline" icon="i-lucide-send" @click="active.execute()">
        触发请求
      </UButton>
    </div>
    <p class="text-xs text-muted">
      {{ hints[mode] }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ display }}</pre>
  </div>
</template>

在事件处理器中使用

事件处理器中使用 $api 是最常见的场景:

<script setup lang="ts">
const { $api } = useNuxtApp()

// 表单提交
async function handleCreate(formData: CreateUserRequest) {
  try {
    const user = await $api<User>('/users', {
      method: 'POST',
      body: formData
    })
    // Toast 已自动显示,无需手动处理
    navigateTo(`/users/${user.id}`)
  }
  catch (error) {
    // 业务错误 Toast 已自动显示
    // 此处可做额外逻辑(如表单字段高亮)
  }
}

// 删除确认
async function handleDelete(id: string) {
  await $api(`/users/${id}`, { method: 'DELETE' })
  await refreshNuxtData() // 刷新页面缓存数据
}
</script>

在 useAsyncData 中使用

当需要聚合多个接口或做复杂数据处理时,在 useAsyncData 中使用 $api

const { $api } = useNuxtApp()

// 聚合多个接口
const { data: dashboard } = await useAsyncData('dashboard', async () => {
  const [users, stats, recent] = await Promise.all([
    $api<User[]>('/users'),
    $api<Stats>('/stats'),
    $api<Activity[]>('/activity/recent')
  ])
  return { users, stats, recent }
})

封装自定义 composable

app/composables/useDashboard.ts
export function useDashboard() {
  const { $api } = useNuxtApp()

  return useAsyncData('dashboard', async () => {
    const [users, stats] = await Promise.all([
      $api<User[]>('/users'),
      $api<Stats>('/stats')
    ])
    return { users, stats }
  })
}

业务操作 composable

对于删除、审批等写操作,将 $api 调用封装到独立的 composable 中,保持页面文件精简:

app/composables/useUserActions.ts
export function useUserActions() {
  const { $api } = useNuxtApp()

  async function remove(id: string) {
    await $api(`/users/${id}`, { method: 'DELETE' })
    await refreshNuxtData()
  }

  async function create(data: CreateUserRequest) {
    return await $api<User>('/users', {
      method: 'POST',
      body: data,
    })
  }

  async function updateRole(id: string, role: UserRole) {
    return await $api<User>(`/users/${id}/role`, {
      method: 'PUT',
      body: { role },
      context: { toast: { successMessage: '角色已更新' } },
    })
  }

  return { remove, create, updateRole }
}
app/pages/users/index.vue
<script setup lang="ts">
// 数据获取用 useApiFetch
const { data: users, refresh } = await useApiFetch<User[]>('/users')

// 写操作用 $api composable
const { remove, updateRole } = useUserActions()
</script>

请求级配置

通过 context 字段传递请求级配置:

const { $api } = useNuxtApp()

// 禁用 Toast
await $api('/users', {
  context: { toast: false }
})

// 自定义 Toast 消息
await $api('/users', {
  method: 'POST',
  body: formData,
  context: {
    toast: { successMessage: '创建成功' }
  }
})

// 跳过业务状态码检查(直接返回原始解包数据)
const raw = await $api('/external-api/data', {
  context: { skipBusinessCheck: true }
})
context 字段通过 ofetch 的 FetchOptions 扩展传递,仅 Movk Nuxt 的 $api 实例识别此字段。原生 $fetch 不会处理这些选项。

API

ApiInstance

$api 的类型定义:

type ApiInstance = $Fetch & {
  use: (endpoint: string) => ApiInstance
}
$api(url, options)
Promise<T>
发送 API 请求。支持所有 ofetch 的选项,额外支持 context 字段。
$api.use(endpoint)
ApiInstance
切换到指定端点,返回该端点的 $api 实例。endpoint 类型为 MovkApiEndpointName(由模块按 movk.api.endpoints 声明合并出的字面量联合)。端点实例内部有缓存,多次调用返回同一实例。

ApiFetchContext

通过 options.context 传递的扩展配置:

toast
RequestToastOptions | false
Toast 提示配置。设为 false 禁用所有 Toast。
skipBusinessCheck
boolean
跳过业务状态码检查。设为 true 时不抛出 ApiError,但仍会解包 dataKey 字段。
skipUnwrap
boolean
跳过数据解包。设为 true 时拦截器不重写 response._data,调用方拿到 code/message/data 完整信封。与 skipBusinessCheck 正交。
Copyright © 2025 - 2026 YiXuan - MIT License