我最初以为懒加载只是图片和路由的事,直到做一个后台管理系统时才意识到,真正拖慢体验的往往是数据请求。页面一打开,七八个接口同时在网络面板里排队,用户根本没点开任何详情,订单明细、日志列表、推荐商品却已经全部加载完了。后来我在 Vue3 项目里把所有“按需取数”的逻辑收敛成一个useLazyData组合式函数,整件事才变得清爽。简单说,useLazyData就是一个负责“延迟发起请求、统一管理 loading / error / data / 重试 / 缓存”的 Hook,适合列表页、弹窗、抽屉、下拉框选项加载这类场景。这篇文章会围绕这个 Hook 的设计思路、 API 取舍、核心实现、真实业务接入和踩坑记录展开。
1. 先理解懒加载的真实痛点
1.1 数据请求为什么需要“懒”
我见过很多项目把“数据懒加载”和“图片懒加载”混在一起,其实完全是两码事。图片懒加载是资源层面的优化,等图片进入视口再加载;数据懒加载是业务层面的优化,等用户真正需要某块数据时再发请求。比如后台管理系统里最常见的布局:左侧菜单、顶部用户信息、右侧多个 Tab 面板,如果全部在onMounted里发请求,页面初始化时会产生大量并发请求。用户可能只是来看一眼今天的销售额,根本没点开其他 Tab,结果后面几屏的数据全都白请求了。
这种情况不仅浪费后端资源,也会拖慢真正重要的首屏接口。浏览器和服务器同时要处理几十个请求,网络带宽、线程池、数据库连接都被占住,首屏核心数据反而要排队。懒加载的核心思路就是把请求时机从“组件创建时”延迟到“用户真正需要数据时”。表格数据点击查询才加载,搜索下拉框聚焦才加载,详情弹窗打开才加载,页签切换过去才加载。听起来很简单,但真正做起来会发现,数据懒加载要处理的不只是“什么时候请求”,还包括“加载中怎么反馈”“加载失败要不要保留旧数据”“重复参数请求要不要缓存”“切换参数后旧请求返回怎么处理”。这些问题不收敛成一个统一封装,每个页面都会复制粘贴一套状态管理代码。
1.2 为什么选择 Hook 而不是封装组件
面对这种需求,有人会想到封装一个LazyDataWrapper组件,用 slot 暴露数据,模板里包一层<LazyData :fetcher="fetchDetail" v-slot="{ data, loading }">。组件方案不是不能用,但会让数据逻辑被埋进模板里,不透明。后面想加缓存、加请求取消、加失败重试,都要在组件 props 和事件里来回倒腾,开发体验很差。
Store 也可以做,但全局状态把数据留在内存里,页面关掉后数据还在,有时候是优点,有时候是隐患。比如切换账号后,上一个账号的缓存数据如果没清理,下一个账号能看到上个账号的内容,这是很严重的越权风险。
Composition API 的 Hook 正好卡在中间:状态默认属于调用它的组件实例,随组件创建和销毁,逻辑又能跨页面复用。我们常说的“组合式函数就是带状态的函数”,就是这个意思。组件里一行const detail = useLazyData(fetchDetail),弹窗打开时detail.run(id)就够了。后续优化都在 Hook 内部做,模板不用改。我现在在项目里的约定是:能写在 setup 里的逻辑就不要包一层组件,除非这个组件真的承载了独立的 UI 职责。
2. useLazyData 的 API 设计与取舍
2.1 核心 API 结构
设计 Hook 的第一步不是写代码,而是定 API。我目前维护的版本大概长这样:
interface UseLazyDataOptions<T, P extends any[]> { fetcher: (...args: P) => Promise<T> immediate?: boolean defaultData?: T cacheKey?: string cacheTime?: number onSuccess?: (data: T, args: P) => void onError?: (err: unknown, args: P) => void } interface UseLazyDataResult<T, P extends any[]> { data: Ref<T | null> loading: Ref<boolean> refreshing: Ref<boolean> error: Ref<unknown> status: Ref<'idle' | 'loading' | 'success' | 'error'> run: (...args: P) => Promise<T | undefined> refresh: (...args: P) => Promise<T | undefined> retry: () => Promise<T | undefined> reset: () => void clearCache: () => void cancel: () => void }每个字段都有明确用途。data是后端返回结果,初始值可以用defaultData避免模板里写一堆data?.xxx。loading表示首屏加载,适合显示大 spinner;refreshing表示已有旧数据时的后台刷新,适合表格顶部的小刷new状态。有些人觉得只用一个loading就够了,实际做表格刷新时会很痛苦:用户已经看到了旧数据,点击刷新,你不想让整张表格变成一片空白遮罩,只想让顶部有个小图标转一下。loading和refreshing拆开,UI 层就能自己决定哪种交互。
status是一个更细的状态机,用来区分“还没请求 / 请求中 / 成功 / 失败”。首次进入页面,status 是idle,可以显示空白提示;请求失败,status 是error,可以显示错误页;请求成功,再切换到数据页。用布尔值组合也能表达,但状态一多就需要一个主状态。
2.2 immediate、缓存与取消的权衡
immediate表示要不要在创建 Hook 时立刻请求。懒加载默认不立即请求,但有些场景比较特殊,比如列表页首屏就是要加载列表,只是“加载更多”是懒的,这种情况设置immediate: true就行。需要注意 SSR 场景,如果 immediate 在 setup 里直接执行,服务端也会发请求,后面我会单独说这个问题。
缓存默认我建议关掉,显式传入cacheKey才启用。因为默认开缓存会让所有页面都出现“数据不是最新”的 bug,调用方必须对自己的数据时效负责。refresh和run的区别就在缓存上:run正常查缓存,refresh绕过缓存强制重新请求。reset清空数据,cancel中断当前请求,clearCache清掉指定缓存。这些方法名字要尽量直白,团队用起来不需要看文档太多。
还有一点值得讨论:要不要返回readonly的 Ref。我的习惯是内部状态统一用ref创建,返回值用readonly包一层再暴露。状态只能通过run修改,外部只能读,这样能防止有人把data.value = xxx直接写进组件里,绕过请求流程。虽然团队协作时可以靠 code review 把人拦住,但 API 设计层面直接封住更省心。
3. 从零实现一个可用的 useLazyData
3.1 最小可用版本
先写一个去掉各种高级特性的最小版本,让思路更清楚:
import { ref, readonly } from 'vue' export function useLazyData<T, P extends any[]>( fetcher: (...args: P) => Promise<T> ) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<unknown>(null) const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle') async function run(...args: P) { loading.value = true status.value = 'loading' error.value = null try { const result = await fetcher(...args) data.value = result status.value = 'success' return result } catch (err) { error.value = err status.value = 'error' throw err } finally { loading.value = false } } return { data: readonly(data), loading: readonly(loading), error: readonly(error), status: readonly(status), run } }这个版本已经能覆盖很多基础场景了。run被调用时先打开 loading,请求成功后写回 data,失败时把错误塞进 error,再通过 status 切换状态。注意我在 catch 里把错误存好后又throw err,这样调用方如果想用run().catch(...)也能拿到错误。如果你不希望调用方每次都要 catch,可以在 options 里加一个throwOnError开关,默认 false 时只存错误不抛出,UI 直接展示 error 状态就行。这个开关很实用。
3.2 竞态与过期请求
最小可用版本有一个致命问题:竞态。用户快速切换两个详情 ID,请求 A 发出后还没返回,用户又点了 B,请求 B 先返回并渲染,随后请求 A 才返回,这时候如果直接把 data 改成 A 的返回值,界面显示的是 A,但用户当前选中的是 B。这种 bug 很难复现,一旦出现又非常莫名其妙。
解决思路是给每次请求加一个自增序号,响应返回后判断序号是否还是最新,不是就直接丢弃:
let requestSeq = 0 let abortController: AbortController | null = null async function run(...args: P) { const currentSeq = ++requestSeq abortController?.abort() abortController = new AbortController() const signal = abortController.signal loading.value = true status.value = 'loading' error.value = null try { const result = await fetcher(...args, { signal } as any) if (currentSeq !== requestSeq) return undefined data.value = result status.value = 'success' return result } catch (err) { if (currentSeq !== requestSeq) return undefined if ((err as Error).name === 'AbortError') return undefined error.value = err status.value = 'error' throw err } finally { if (currentSeq === requestSeq) { loading.value = false } } }请求序号能防止旧响应覆盖新状态,但网络请求本身还在跑,浪费连接资源。加上AbortController后,新请求会主动取消前一个未完成的请求。需要注意,fetcher不一定支持第二个参数传入 signal。如果是 axios 封装,可以约定第二个参数是 config,内部把 signal 合并进去;如果是原生 fetch,第二个参数直接传{ signal }。团队项目里最好统一一下 fetch/axios 的封装签名,否则 Hook 里没法做通用的 signal 适配。
3.3 缓存与手动刷新
给 Hook 加缓存,本质是在 run 之前先查一下“这个参数是不是最近请求过”。实现起来不复杂:
const cacheMap = new Map<string, { expire: number; data: unknown }>() function generateKey(args: P) { const cacheKey = options.cacheKey ?? fetcher.name return `${cacheKey}_${JSON.stringify(args)}` } async function runWithCache(...args: P) { const key = generateKey(args) if (cacheMap.has(key)) { const item = cacheMap.get(key)! if (item.expire > Date.now()) { data.value = item.data as T status.value = 'success' return item.data as T } cacheMap.delete(key) } const result = await fetcher(...args) cacheMap.set(key, { expire: Date.now() + (options.cacheTime ?? 30_000), data: result }) data.value = result status.value = 'success' return result }缓存 key 的生成有一个坑:JSON.stringify(args)遇到对象参数时,字段顺序不同就会生成不同的 key,比如{ id: 1, name: 'a' }和{ name: 'a', id: 1 }是两个 key。实战中建议限制参数为简单值,或者写一个稳定的序列化函数,对对象 key 排序后再 stringify。缓存过期时间默认给 30 秒就够了,太短没意义,太长容易看到脏数据。refresh实现时直接绕过缓存逻辑,调用底层 fetcher 并更新缓存。另外,登录态变化、切换租户、用户权限更新时,一定要调用clearCache清掉所有缓存,否则容易出现上个账号的数据残留。
4. 让 Hook 适配真实业务场景
4.1 组件销毁与生命周期清理
Vue3 组件卸载后,如果请求才返回,再去更新 data 和 loading,虽然不会像 React 那样警告,但还是会造成无效状态更新,严重的会泄漏闭包。Vue 3.2 之后可以用onScopeDispose注册清理逻辑:
import { getCurrentScope, onScopeDispose } from 'vue' let disposed = false let abortController: AbortController | null = null if (getCurrentScope()) { onScopeDispose(() => { disposed = true abortController?.abort() }) } // 在 run 的 then/catch/finally 中统一判断 disposed if (disposed) return undefined这里有件事要注意:onScopeDispose只有在当前存在 effect scope 时才能安全注册,比如在组件 setup 里调用 Hook。如果有人在普通函数里调用useLazyData,没有当前 scope,直接调onScopeDispose会有警告。所以实现里要用getCurrentScope()判断一下。更复杂的场景可以用effectScope创建一个独立作用域,在外部控制所有子 effect 的销毁,适合那些把 Hook 用在组件之外的项目。
4.2 失败重试与旧数据保留
请求失败后最常见的交互是“点击重试”。实现retry很简单,在 run 里记住最后一次参数,retry 时再用同样参数调用一次:
let lastArgs: P | null = null async function run(...args: P) { lastArgs = args // ...请求逻辑 } function retry() { if (!lastArgs) return Promise.resolve(undefined) return run(...lastArgs) }还有一个容易被忽略的细节:刷新失败时不要清空旧的data。如果列表之前已经有数据,这次刷新只是失败了,旧数据应该继续留在页面上,顶部弹一个轻提示就好。如果每次失败都先data.value = null,页面会闪一下空白,用户还以为数据被删了。只有在用户主动切换参数、需要展示完全不同的内容时,才考虑调用reset清空旧数据。
refreshing状态的实现逻辑也和这个细节相关。当data已经有值的时候,再次请求应该置refreshing = true,而不是loading = true。这样 UI 可以根据refreshing显示局部刷新动画。很多初次实现 Hook 的朋友只用一个 loading,就是这个细节没处理好。
4.3 与 UI 框架组合:select、table、弹窗
懒加载最常见的使用场景就是下拉框。一个用户选择器,页面初始化时根本不需要把全部用户都查出来,等用户聚焦下拉框时再发请求,还能配合关键字搜索:
<script setup lang="ts"> const userOptions = useLazyData(fetchUserOptions, { cacheKey: 'user-options', cacheTime: 60_000 }) function handleFocus() { if (!userOptions.data.value?.length) { userOptions.run() } } </script> <template> <el-select filterable @focus="handleFocus"> <el-option v-for="item in userOptions.data.value || []" :key="item.id" :label="item.name" :value="item.id" /> </el-select> </template>这里缓存非常关键。弹窗关闭再打开,下拉框数据如果能秒开,体验会好很多。表格场景也类似,但分页加载更多通常不是简单覆盖 data,而是把新数据拼接到旧数据后面。两种做法:一种是在onSuccess回调里手动拼接data.value = [...old, ...new];另一种是给 Hook 增加append模式,传入mode: 'append'后,run 内部自己拼接。我建议先保持最小实现,用onSuccess去拼接,等确实有多个页面需要同样逻辑时再加 append 模式,避免过度设计。
5. 踩坑记录与边界问题
5.1 解构响应式丢失
这个坑在组合式函数里太经典了。有人会在 Hook 内部返回一个reactive包装的对象,比如return reactive({ data, loading, error })。组件里如果直接解构const { data, loading } = useLazyData(...),解构出来的 values 已经脱离了响应式代理,改数据时视图不会更新。为什么?因为reactive会把对象里的 ref 自动解包,解构时拿到的是 ref 后面的“裸值”,响应式追踪自然就断了。
安全的做法是返回对象里直接放 ref:
| 返回方式 | 解构后 data | 是否推荐 |
|---|---|---|
reactive({ data, loading }) | 普通值 | 不推荐 |
{ data: ref(null), loading: ref(false) } | Ref 对象 | 推荐 |
reactive({ data, loading })+toRefs | Ref 对象 | 可以,但多余 |
所以我实现的 Hook 统一返回“由 ref 组成的普通对象”,组件解构后依然是 ref,放到watch(data, ...)里正常使用。如果你在写自己的 Hook 时发现解构后页面不刷新,第一件事就是检查返回值里是不是用了 reactive 包了一层。
5.2 watch 不触发的常见原因
懒数据和外部参数联动时,很多人会写:
const detail = useLazyData(fetchDetail) watch(() => props.id, (id) => { detail.run(id) })然后发现页面第一次进入时没有数据。原因很简单:watch 默认不立即执行,props.id初始值不会触发回调。解决办法是加immediate: true,或者在 onMounted 里手动 run 一次。另一个坑是props.id可能初始为空,这时候需要先 reset,否则会带着空 id 去请求。我一般在 watch 回调里做一层守卫:
watch(() => props.id, (id) => { if (!id) { detail.reset() return } detail.run(id) }, { immediate: true })还有一点:如果一个页面里有多个参数同时影响数据,不要写多个 watch 回调各调一次 run,最好只做一个 watch,把相关参数聚合起来,这样 run 只会被触发一次,不会出现两个请求同时发出后互相覆盖的情况。
5.3 SSR 和 Keep-Alive 下的 immediate
如果项目使用 SSR,setup在服务端也会执行。immediate: true时如果直接调用 run,服务端会真的发一次请求,这往往是你不希望发生的。更稳妥的做法是把 immediate 实现成“注册 onMounted 后再请求”:
if (options.immediate) { onMounted(() => run()) }服务端不执行 onMounted,自然就不会发请求。客户端加载完后再请求,既保证了首屏数据,也避免了 SSR 请求和 hydration 不一致的问题。KeepAlive 的坑是另一类:页面被 keep-alive 缓存后,onMounted只在第一次激活时执行,后面从缓存里恢复时不会重新触发。如果你的数据需要每次激活都刷新,要在onActivated里调用 refresh;如果只需要第一次加载,用 onMounted 就够了。两个生命周期混用时要小心,别把同一个请求触发两次。
6. 我迭代这个 Hook 的一些心得
6.1 不是所有请求都应该懒
懒加载不是银弹。有些数据一定不能懒:路由守卫需要的用户权限、系统初始化配置、登录态校验、首屏必看的核心指标。这些请求如果懒,用户打开页面会先看到一片空白或骨架屏,等请求完才能渲染,体验更差。懒加载适合的是“低频、非首屏、按需查看”的数据。我见过一些团队为了统一风格,硬把所有接口都套上 useLazyData,结果首屏请求被延迟到点击按钮后才触发,用户一进来就蒙了。判断标准其实很简单:如果用户大概率会看到这个数据,提前加载反而更好;如果用户可能永远不看,才值得懒。
6.2 命名与类型是 Hook 的第一份文档
给 Hook 起一个直白的名字很重要。useLazyData这个名字一看就知道是“懒加载数据”,比useFetch、useRequest更强调“按需”这个语义。TypeScript 泛型也要跟上,至少能通过fetcher的返回类型推断出data的类型,否则用户每用一个 Hook 都要手动传类型太痛苦。我还会给每个字段写 tsdoc 注释,特别是refreshing和run/refresh的区别,新同学看到注释基本就能上手。
单元测试值得补。我用 vitest 测过几个核心场景:未调用 run 前 fetcher 不被调用、竞态场景下旧请求结果被丢弃、缓存命中时不触发新的请求、reset 后数据被清空。测试代码不多,但能给后续重构兜底。
6.3 从基础到扩展:先跑通,再优化
我现在项目里的 useLazyData 还在持续迭代,后面计划增加请求去重、多请求串行编排、localStorage 持久化缓存等功能。但我建议你在自己项目里落地时不要一步到位,先从最简单的版本开始:把 loading、error、data 收进来,跑通一个弹窗详情场景。然后再加竞态处理、缓存、重试,最后再考虑 SSR 和 KeepAlive 适配。这个顺序能让你在每个阶段都清楚地知道 Hook 到底帮你解决了什么问题。
我在项目里最开始做这个 Hook 时,也觉得多一套状态是小题大做。后来一口气把五个页面的列表逻辑改成 useLazyData,删掉的重复代码比这个 Hook 本身的代码还多,才真正理解社区为什么一直在强调“抽组合式函数”。如果你也在写 Vue3 项目,不妨先拿一个弹窗详情的接口开刀,把 loading、error、data 收进 Hook,跑通之后再考虑 cache 和 abort。最后分享一个小技巧:一个 Hook 实例尽量只管理一个资源或一个列表,页面里有关联的多个请求时,宁可组合多个 useLazyData 实例,也不要写一个超大的 useAllData。这样后面接缓存、接错误上报、接重试,都会轻松很多。