请求合并的核心不是简单减少 HTTP 请求次数,而是重新组织请求的时序与形状。在 Vue 3 的组合式 API 环境下,响应式状态、生命周期钩子与异步请求之间存在天然的耦合关系,如果只是把每个组件的请求单独发出,很容易出现重复请求、竞态条件或首屏等待过长的问题。工程化请求合并要解决的是如何在不破坏组件边界的前提下,把分散的请求汇聚成更高效的数据获取流程。

为什么需要请求合并:从 N+1 到批量语义
N+1 问题最早在 ORM 领域被大量讨论,但它同样适用于前端数据请求。假设一个仪表盘页面需要加载当前用户信息、用户最近发布的文章以及未读通知。如果按照最简单的组件化思路,用户卡片组件请求一次用户接口,文章列表组件请求一次文章接口,通知面板组件再请求一次通知接口,这三个请求各自独立发送。表面上代码结构很清晰,但实际运行时会带来额外的网络往返开销,并且在移动网络或高延迟场景下,每个请求的握手成本会被放大。
GraphQL 与 Batch API 分别从不同层面回应这个问题。GraphQL 允许客户端在一次查询里声明多个资源,由服务端统一聚合后返回;Batch API 则是在 REST 体系下,把多个独立的 HTTP 请求打包成一个批量请求,服务端解析后分别执行并返回对应结果。前者更适合数据形态复杂、嵌套关系多的场景,后者更适合大量高频、可独立执行的小请求。
请求合并也并非没有代价。合并意味着需要在客户端引入排队或等待机制,这会增加单个请求的响应延迟;同时,原本独立的请求一旦被合并,错误处理、缓存失效和请求去重都会变得更复杂。在 Vue 3 中做工程化设计时,需要明确哪些请求适合合并,哪些请求需要保持独立直发。
// 未合并:三个请求依次发出,组件需要等待所有响应
const user = await axios.get('/api/users/me')
const posts = await axios.get('/api/posts?limit=10')
const notifications = await axios.get('/api/notifications')
// 合并为一次 Batch 请求
const batchResponse = await axios.post('/api/batch', {
requests: [
{ id: 'user', path: '/api/users/me' },
{ id: 'posts', path: '/api/posts?limit=10' },
{ id: 'notifications', path: '/api/notifications' }
]
})
GraphQL 路径:查询片段与服务端聚合
GraphQL 的天然优势在于声明式的查询语法。在 Vue 3 项目中,通常使用 @vue/apollo-composable 或 urql 等库来管理 GraphQL 查询。通过查询片段,不同组件可以维护自己关心的字段,而最终执行时这些片段会被合并成一个完整的 GraphQL 操作。这样既保留了组件的数据独立性,又减少了实际的 HTTP 请求数量。
例如,一个仪表盘页面可以拆分为用户信息组件和文章列表组件。用户信息组件只声明用户字段,文章列表组件只声明文章字段以及作者关联信息。GraphQL 客户端会根据这些片段生成一次查询,服务端只需要处理一个请求,然后返回聚合后的 JSON。前端再通过缓存规范化将结果拆回给各个组件。
<script setup lang="ts">
import { useQuery, gql } from '@vue/apollo-composable'
const FRAGMENT_USER = gql`
fragment UserFields on User {
id
name
email
}
`
const FRAGMENT_POST = gql`
fragment PostFields on Post {
id
title
author {
...UserFields
}
}
${FRAGMENT_USER}
`
const { result, loading } = useQuery(gql`
query Dashboard {
user(id: "me") {
...UserFields
}
posts(limit: 10) {
...PostFields
}
}
${FRAGMENT_USER}
${FRAGMENT_POST}
`)
</script>
服务端的 GraphQL 层通常会配合 DataLoader 来避免解析阶段的 N+1 查询。DataLoader 会把同一批执行周期内的数据库查询按 key 收集并批量执行,这与客户端的请求合并形成前后呼应。Vue 3 前端还需要关注缓存规范化,避免同一实体在多个查询中重复存储,造成状态不一致或内存浪费。
值得注意的是,GraphQL 合并查询并不总是银弹。片段之间的隐式依赖可能让组件变得难以独立维护,过度聚合还可能导致单个请求响应体过大,影响首屏渲染。因此需要配合 GraphQL 代码生成工具或 ESLint 插件,保持片段职责单一,必要时将大查询拆分为多个独立操作。
自建 Batch 队列:时间窗口、去重与错误隔离
如果后端不提供 GraphQL 接口,Batch API 是更直接的工程化方案。客户端在短时间内收集多个请求,把它们打包成一个批量请求发送给服务端,服务端解析请求列表后逐个执行,并以数组形式返回结果。这种方式对后端侵入较小,只需要实现一个通用的批量解析端点即可。
自建 Batch 队列的关键在于时间窗口设计。通常可以利用 setTimeout 设置 20 毫秒左右的收集窗口,在这个窗口内到达的请求进入同一个队列。队列内部使用 Map 结构,以请求方法和 URL 的哈希值作为 key,相同 key 的请求只保留一份,并将所有调用者的 Promise 回调绑定到同一个结果上。窗口结束后统一发送,并将结果广播给所有等待者。
function createBatchQueue(delay = 20) {
const queue = new Map()
let timer = null
function push(key, task) {
return new Promise(function (resolve, reject) {
if (!queue.has(key)) {
queue.set(key, { task: task, callbacks: [] })
}
const entry = queue.get(key)
entry.callbacks.push({ resolve: resolve, reject: reject })
if (!timer) {
timer = setTimeout(flush, delay)
}
})
}
async function flush() {
timer = null
const entries = Array.from(queue.values())
queue.clear()
await Promise.allSettled(entries.map(async function (entry) {
try {
const result = await entry.task()
entry.callbacks.forEach(function (cb) {
cb.resolve(result)
})
} catch (err) {
entry.callbacks.forEach(function (cb) {
cb.reject(err)
})
}
}))
}
return {
push: push,
flush: flush,
cancel: function () {
clearTimeout(timer)
}
}
}
错误隔离同样重要。一个批量请求可能包含多个子请求,如果其中一个子请求失败,不能让整个批量请求全部失败。服务端需要返回每个子请求的独立状态,客户端根据结果分别 resolve 或 reject 对应的 Promise。在客户端队列中,如果批量发送阶段出现网络错误,则所有等待者都会收到同一个错误,此时需要提供统一的重试入口。
Key 的设计直接影响合并效果。简单的 URL 拼接不足以区分不同请求体,尤其是 POST 请求。通常需要把 method、URL、排序后的 body 或 query 参数组合起来计算哈希值。只有 key 完全一致的请求才能合并,否则会出现数据串扰。时间窗口的取值也需要根据实际场景调整:交互类请求 20 毫秒以内基本无感,而首屏数据获取则可以通过微任务或预取请求来消除等待。
Vue 3 工程化封装:useMergedRequest 与调试边界
把 Batch 队列封装成 Vue 3 组合式函数,可以让组件无需关心底层合并细节。通常将模块放在 src\composables\useMergedRequest.js 或 src\utils\batchQueue.js 中,通过 reactive 维护请求状态,使用 readonly 对外暴露只读状态,借助 onScopeDispose 在组件卸载时清理未完成的队列定时器。
import { reactive, readonly, onScopeDispose } from 'vue'
export function useMergedRequest(fetcher, options = {}) {
const state = reactive({
data: null,
loading: false,
error: null,
pending: 0
})
const queue = createBatchQueue(options.delay || 20)
async function run(key, payload) {
state.loading = true
state.pending += 1
state.error = null
try {
const data = await queue.push(key, function () {
return fetcher(payload, key)
})
state.data = data
return data
} catch (err) {
state.error = err
throw err
} finally {
state.pending -= 1
if (state.pending === 0) {
state.loading = false
}
}
}
onScopeDispose(function () {
queue.cancel()
})
return {
state: readonly(state),
run: run
}
}
在组件中使用时,只需要传入一个实际的请求函数和合并 key。例如多个列表项同时需要获取用户详情时,它们会共享同一个 key,队列只发出一次请求;而 key 不同时则进入不同的批量分组。这样既保留了组件调用的简洁性,又避免了重复请求。
调试请求合并需要额外的可观测性。可以在队列内部为每次批量发送生成一个 traceId,记录哪些 key 被合并、哪些 key 命中缓存、哪些请求因为超时被单独发送。Vue 3 的 watch 可以用来追踪 state.pending 的变化,配合 Vue Devtools 观察请求合并的生命周期。但并非所有请求都适合合并:非幂等请求、文件上传、大响应体下载或需要极端低延迟的操作,应该保持直发,避免队列带来的额外复杂度。
GraphQL 与 Batch API 并非二选一的关系。在同一个 Vue 3 项目中,可以针对复杂的数据聚合使用 GraphQL,而对大量高频的小型 REST 请求使用 Batch 队列。真正重要的是建立一套清晰的请求层规范,让合并策略对组件透明、对调试友好,并且能够随业务变化灵活调整。工程化的核心不在于选择了哪一项技术,而在于是否用可维护的方式解决了请求效率和一致性问题。