
我最初以为懒加载只是图片和路由的事直到做一个后台管理系统时才意识到真正拖慢体验的往往是数据请求。页面一打开七八个接口同时在网络面板里排队用户根本没点开任何详情订单明细、日志列表、推荐商品却已经全部加载完了。后来我在 Vue3 项目里把所有“按需取数”的逻辑收敛成一个useLazyData组合式函数整件事才变得清爽。简单说useLazyData就是一个负责“延迟发起请求、统一管理 loading / error / data / 重试 / 缓存”的 Hook适合列表页、弹窗、抽屉、下拉框选项加载这类场景。这篇文章会围绕这个 Hook 的设计思路、 API 取舍、核心实现、真实业务接入和踩坑记录展开。1. 先理解懒加载的真实痛点1.1 数据请求为什么需要“懒”我见过很多项目把“数据懒加载”和“图片懒加载”混在一起其实完全是两码事。图片懒加载是资源层面的优化等图片进入视口再加载数据懒加载是业务层面的优化等用户真正需要某块数据时再发请求。比如后台管理系统里最常见的布局左侧菜单、顶部用户信息、右侧多个 Tab 面板如果全部在onMounted里发请求页面初始化时会产生大量并发请求。用户可能只是来看一眼今天的销售额根本没点开其他 Tab结果后面几屏的数据全都白请求了。这种情况不仅浪费后端资源也会拖慢真正重要的首屏接口。浏览器和服务器同时要处理几十个请求网络带宽、线程池、数据库连接都被占住首屏核心数据反而要排队。懒加载的核心思路就是把请求时机从“组件创建时”延迟到“用户真正需要数据时”。表格数据点击查询才加载搜索下拉框聚焦才加载详情弹窗打开才加载页签切换过去才加载。听起来很简单但真正做起来会发现数据懒加载要处理的不只是“什么时候请求”还包括“加载中怎么反馈”“加载失败要不要保留旧数据”“重复参数请求要不要缓存”“切换参数后旧请求返回怎么处理”。这些问题不收敛成一个统一封装每个页面都会复制粘贴一套状态管理代码。1.2 为什么选择 Hook 而不是封装组件面对这种需求有人会想到封装一个LazyDataWrapper组件用 slot 暴露数据模板里包一层LazyData :fetcherfetchDetail 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 UseLazyDataOptionsT, P extends any[] { fetcher: (...args: P) PromiseT immediate?: boolean defaultData?: T cacheKey?: string cacheTime?: number onSuccess?: (data: T, args: P) void onError?: (err: unknown, args: P) void } interface UseLazyDataResultT, P extends any[] { data: RefT | null loading: Refboolean refreshing: Refboolean error: Refunknown status: Refidle | loading | success | error run: (...args: P) PromiseT | undefined refresh: (...args: P) PromiseT | undefined retry: () PromiseT | undefined reset: () void clearCache: () void cancel: () void }每个字段都有明确用途。data是后端返回结果初始值可以用defaultData避免模板里写一堆data?.xxx。loading表示首屏加载适合显示大 spinnerrefreshing表示已有旧数据时的后台刷新适合表格顶部的小刷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. 从零实现一个可用的 useLazyData3.1 最小可用版本先写一个去掉各种高级特性的最小版本让思路更清楚import { ref, readonly } from vue export function useLazyDataT, P extends any[]( fetcher: (...args: P) PromiseT ) { const data refT | null(null) const loading ref(false) const error refunknown(null) const status refidle | 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 Mapstring, { 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 langts const userOptions useLazyData(fetchUserOptions, { cacheKey: user-options, cacheTime: 60_000 }) function handleFocus() { if (!userOptions.data.value?.length) { userOptions.run() } } /script template el-select filterable focushandleFocus el-option v-foritem in userOptions.data.value || [] :keyitem.id :labelitem.name :valueitem.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 })toRefsRef 对象可以但多余所以我实现的 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如果项目使用 SSRsetup在服务端也会执行。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。这样后面接缓存、接错误上报、接重试都会轻松很多。