做 Vue3 开发这两年,我发现自己日常碰到的不是那些花哨的组件动画,反而是一遍又一遍的“数据什么时候加载”问题。列表页首屏要做懒加载,Tab 切换要按需请求,搜索按钮点了才拉数据,滚动到底部继续分页……这些需求归拢到一起,就是今天想聊的 useLazyData Hook:一个在 Vue3 组合式 API 体系里,把数据请求做成“默认不执行、按需触发、状态齐全”的自定义 Hook。它解决的远不只是“晚点发请求”这一件事,而是把 loading、data、error、重新执行、重置这一整套状态管理收拢到一个函数里。适合正在写后台管理系统、移动端 H5、商城列表页,或者对组合式函数设计感兴趣的同学参考,代码可以直接抄回去按需改。
1. 懒加载这件事,为什么需要重新设计
1.1 首屏性能与请求时机:懒加载到底懒的是什么
不少人一提“懒加载”就想到图片懒加载,或者路由组件拆包。但在业务数据层面,懒加载的意义是另一回事:把请求动作从“组件创建”延后到“用户真正需要看到数据的那个瞬间”。这个延后带来两个直接收益,第一是首屏请求变少,接口压力和数据流量都会被压缩;第二是用户感知变快,因为每一个请求都对应一个明确的用户意图,页面不会出现一堆无用的 loading 转圈。
我用一个生活例子解释:点外卖和囤菜的区别。如果你不确定家里今天要不要做饭,一股脑把所有菜都买好放冰箱,那大概率会有菜放坏。懒加载的逻辑是——用户开始点菜了,你再把对应食材拿出来处理。放到前端场景里,一个后台管理系统的详情页,可能有基础信息、操作日志、统计数据三块内容,用户大多数时候只是来看基础信息的,一进页面就把三个接口全部打过去,既浪费服务器资源,又让首屏耗时被无关请求拖慢。如果把后两块做成懒加载,等用户切到对应 Tab 或者滚动到那个区域时再拉数据,整体体验会好很多。
不过,懒加载的目标说到底不是“完全不发请求”,而是“让请求时机跟随用户行为”。如果只做到“组件挂载时不发请求”,然后在某个事件里手动调一下接口,那还只是最原始的懒加载。真正值得设计成一个 Hook 的,是像 loading 状态、错误处理、竞态保护、防抖、重置这一整套围绕“延迟执行”展开的状态逻辑。这也是 useLazyData 比普通“写法改造”更有价值的地方——它把散落在每个页面里的请求状态管理统一成了同一个模型。
1.2 常规写法的三个坑
先说我在真实项目里看到最多、也踩过最多的三种写法。
第一种是onMounted里一把梭。无论组件在哪种场景下被创建,都在挂载后立刻把数据拉回来。这种写法在单体页面里问题不大,但一旦进入 Tab 页、抽屉、弹窗这类“组件创建了但用户可能根本不会看”的场景,就非常浪费。尤其弹窗类组件,有些管理系统在打开弹窗的瞬间就把里面的全部接口发了一遍,用户关掉再打开又是一遍,请求量直接翻倍。
第二种是用v-if加一个 created flag 包着子组件,通过“子组件挂载时才请求”间接实现懒加载。这个思路本身没错,但问题在于状态分散。父组件要维护 flag,子组件要维护自己的 loading 和 error,一旦切换逻辑变复杂,比如“首次进入默认加载,第二次切换要重新请求”,控制权在两个组件之间来回传递,代码很快就看不懂了。
第三种是在事件回调里手动管理状态。比如点击查询按钮时,自己写isLoading.value = true,然后 try catch 里请求、赋值、报错。这种写法最直接,也是问题最隐蔽的:每个按钮都要复制一遍同样的逻辑,防抖和竞态处理很难做到统一。同一个页面里,一个接口有竞态 bug,另一个接口没有,排查起来格外难受。
这三条路我都走过。说实话,它们都能跑,但都只解决“某个页面”的问题,没有解决“一类页面”的问题。
1.3 一个 Hook 解决什么
useLazyData 想做的,是把下面这几件事固定在同一个模具里:
- 数据状态:data、loading、error 全部收纳,组件里不用再写散装 ref;
- 执行时机:默认不执行,由 execute 方法或依赖变化触发;
- 生命周期管理:执行时自动处理 loading 开始和结束,组件卸载后不再赋值;
- 可组合能力:支持与其他逻辑(分页、路由守卫、IntersectionObserver)组合。
说得更直接一点:如果一个页面的数据请求需要满足“某些条件满足时才发”“失败后能重试”“切走再切回要重新拉”“请求结果要防竞态”,那么它就是 useLazyData 的目标场景。这类场景在后台管理系统里几乎占了一半以上,所以沉淀这样一个 Hook 的性价比很高——写一次,长期复用。
2. useLazyData 的 API 设计:先定接口再写实现
2.1 从 useAsyncData、useFetch 里借鉴的设计取舍
写这个 Hook 之前,我参考了 Nuxt 的 useAsyncData、VueUse 的 useAsyncState,以及社区里很火的 alova 与 swrv。它们有一个共同点:都在返回 data、loading、error 三件套,都提供手动触发执行的方法。差异主要在于“执行时机”的默认值:useFetch 默认自动执行,VueUse 的 useAsyncState 也默认立即执行,而 useLazyData 的定位是“数据请求默认不执行”。
这个“默认不执行”就是“懒”字的来源。它意味着,组件本身没有副作用,副作用完全由开发者指定的触发条件来引爆。在 API 设计上,这带来两个调整:第一个是 immediate 参数,默认 false,但允许调用方改成 true,退化成普通请求 Hook;第二个是返回值里的 execute,用于手动触发请求,并且必须支持重复调用。也就是说,useLazyData 不是替代 useFetch,而是它的一种更受控的变体。
另一个取舍是关于依赖触发的。我最初设计时想学习 watch 的用法,给 Hook 传一个 watchSource 参数,内部自动监听依赖变化并重新请求。但后来放弃了,因为自动监听一旦结合防抖和竞态,心智负担会明显上升,很多人会搞不清楚请求到底是被谁触发的。最终我选择让 useLazyData 保持“执行器”形态,外部想监听依赖变化,自己调用 execute 即可。这样 Hook 的职责单一,组合起来也更灵活。
2.2 参数与返回值的完整约定
useLazyData 的接口我最终定成了这样:
| 参数 | 类型 | 说明 |
|---|---|---|
| requestFn | () => Promise<T> | 核心请求函数,必须返回 Promise |
| options | UseLazyDataOptions | 可选配置,包含 immediate、debounce、onSuccess、onError 等 |
| 返回值 | 类型 | 说明 |
|---|---|---|
| data | Ref<T | null> | 请求成功后的数据 |
| loading | Ref<boolean> | 是否正在请求中 |
| error | Ref<Error | null> | 请求失败时的错误对象 |
| execute | (...args) => Promise<T> | 手动触发请求,返回当次结果 |
| reset | () => void | 清空数据、错误,恢复初始状态 |
| isFirstLoad | Ref<boolean> | 是否还没有发过请求,用于骨架屏判断 |
这里有几个细节值得展开。第一,data 的初始值是 null 而不是 undefined,这样模板里判断“是否有数据”会更直观。第二,loading 不包含“初始未执行”的状态,因为懒加载场景里“还没请求”和“正在请求”是两种不同的 UI 状态,前者适合显示占位提示,后者适合显示转圈,所以单独给了 isFirstLoad。第三,execute 返回 Promise 是刻意为之,调用方可以用 await 拿到当次结果做额外操作,比如请求成功后自动关闭弹窗。
2.3 为什么返回 ref 而不是 reactive
这是我实际开发中被问得最多的问题。使用 reactive 看起来更简洁,可以在模板里直接写 data.list,但实际上有坑:reactive 对象在解构时会丢失响应性。如果使用者在组件里写了const { data, execute } = useLazyData(...),reactive 解构出来的 data 就变成了普通对象,后续赋值不会触发视图更新,而且报错不明显,很难排查。
返回 ref 就没有这个困扰。ref 在模板中会被自动解包,在逻辑里又保持了引用语义,解构后依然能维持响应性。另外一个原因是类型推导更稳定,ReturnType 可以直接推出 Ref<T | null>、Ref ,使用方拿到手就知道怎么用。虽然 ref 在代码里要写 .value,稍显啰嗦,但在一个需要经常被组合、被解构的 Hook 中,这一点啰嗦换来的稳定性是值得的。
3. 核心实现:把 useLazyData 从零写出来
3.1 状态初始化与执行开关
直接上核心代码。这个版本我做了简化,保留了最关键的执行与状态逻辑,方便阅读。
import { ref, readonly } from 'vue' interface UseLazyDataOptions<T> { immediate?: boolean debounce?: number onSuccess?: (data: T) => void onError?: (error: Error) => void } export function useLazyData<T = any>( requestFn: (...args: any[]) => Promise<T>, options: UseLazyDataOptions<T> = {} ) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) const isFirstLoad = ref(true) let timer: ReturnType<typeof setTimeout> | null = null async function run(args: any[]): Promise<T> { loading.value = true error.value = null isFirstLoad.value = false try { const result = await requestFn(...args) data.value = result options.onSuccess?.(result) return result } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) options.onError?.(error.value) throw error.value } finally { loading.value = false } } async function execute(...args: any[]): Promise<T> { if (timer) clearTimeout(timer) if (options.debounce) { return new Promise((resolve, reject) => { timer = setTimeout(() => { run(args).then(resolve).catch(reject) }, options.debounce) }) } return run(args) } function reset() { data.value = null error.value = null isFirstLoad.value = true loading.value = false } if (options.immediate) { execute() } return { data: readonly(data), loading: readonly(loading), error: readonly(error), isFirstLoad: readonly(isFirstLoad), execute, reset } }代码里我用了 readonly 包裹返回的 ref,目的是防止外部直接篡改状态,只能通过 execute 和 reset 修改。这个约束在多人协作的团队里特别好用,能有效避免“某个人为了偷懒直接改 data.value”的情况。在一些项目里我看到直接把 data 返回成普通 ref,结果页面里出现data.value.push(...)这种操作,时间长了状态流就乱了。
3.2 竞态处理:最后一次请求说了算
懒加载场景最容易遇到竞态问题。拿搜索框举例:用户输入关键词后快速按下回车,第一次请求还在路上,第二次请求已经发出去了。如果第一次请求比第二次晚返回,页面就会出现旧结果覆盖新结果的诡异 bug。我在真实项目里遇到过好几次,排查起来特别痛苦,因为表现不稳定,时好时坏。
解决竞态的通用思路是给每次请求编号,只有最后一次请求的结果允许写入:
let requestSeq = 0 async function run(args: any[]) { const seq = ++requestSeq loading.value = true error.value = null isFirstLoad.value = false try { const result = await requestFn(...args) if (seq === requestSeq) { data.value = result onSuccess?.(result) } return result } catch (e) { if (seq === requestSeq) { error.value = e instanceof Error ? e : new Error(String(e)) } throw e } finally { if (seq === requestSeq) { loading.value = false } } }这里的核心是 requestSeq 递增计数器。每次 execute 都会使计数器加一,返回结果时只有拿到最新计数的请求才有权限写状态。如果中途被更新的请求覆盖,旧请求即使返回了也会被丢弃。这个方案比 AbortController 更轻量,兼容性更好,不用考虑浏览器对 fetch 中断的支持差异。当然,如果接口是基于 fetch 的,也可以配合 AbortController 直接把网络层请求取消掉,减少带宽浪费,两种方式不冲突,可以同时用。
3.3 防抖与重复触发保护
懒加载 Hook 里的防抖要分场景讨论。搜索联想这种高频输入场景,防抖时间给到 300ms 到 500ms 比较合适;而按钮点击触发的查询,防抖时间可以更短,甚至不需要防抖,但要加上“重复点击保护”——即 loading 状态下再次点击不再发起新请求。
我这里给 execute 加了一个简单的防抖实现,核心是 setTimeout 延迟执行。不过有一点需要提醒:防抖只应该作用于“用户高频触发”的场景,如果请求本身有严格的实时性要求,防抖反而会带来延迟。比如详情页切换 Tab 时,用户连续切多个 Tab,我们希望最后一次切换立刻生效,用防抖反而会让响应变慢。所以 options.debounce 应该设计成可选,而不是默认开启。
重复触发保护则在 execute 入口处判断:
async function execute(...args: any[]) { if (loading.value && !options.allowConcurrent) { return data.value as T } // ... }当 allowConcurrent 为 false(默认值)时,如果当前已有请求在飞,新的 execute 调用会被忽略,避免同一接口被连续点击按钮打出去十几个请求。这个开关在分页“加载更多”场景特别重要,滚动到底部如果同时触发多次 execute,没有这层保护,页面上会出现重复数据。
注意:防抖和 loading 保护同时存在时,
loading判断要在防抖之前,否则防抖排队期间 loading 还是 false,会放行多个请求。
3.4 stale data 策略与手动重置
懒加载有个容易被忽略的体验问题:重新触发请求时,旧的 data 要不要保留?很多场景里,用户点完查询按钮,在等待结果的这一两秒里,应该让页面上继续显示旧数据,而不是先清空成 null 导致页面闪烁。这个策略叫 stale data(过期数据展示)。useLazyData 默认保留了旧 data,除非显式调用 reset 才会清空,这样切 Tab、点查询的体验会顺滑很多。
reset 的典型使用场景是退出登录、切换账号、进入编辑页前清理缓存。例如用户 A 的数据展示在页面上,用户 B 登录后直接进入同一个页面,如果不 reset,B 会先看到 A 的数据,这个隐患可大可小。所以我的建议是,在包含敏感信息的页面里,路由切换离开时主动 reset 一次,或者干脆给 data 加一个“用户维度缓存 key”,按用户隔离数据。
4. 接入真实业务:四种高频场景直接抄
4.1 Tab 页签切换时惰性加载
先看最常见的场景。管理后台的详情页经常有“基础信息 / 操作日志 / 关联订单”这种 Tab 结构,如果一进页面就把所有 Tab 的数据都拉回来,首屏请求会多出不少。用 useLazyData 改造后,每个 Tab 的数据都变成独立的懒加载单元,只有第一次切到该 Tab 时才触发请求。
<script setup lang="ts"> import { watch, ref } from 'vue' import { useLazyData } from '@/hooks/useLazyData' const activeTab = ref('base') const logData = useLazyData(() => fetchLogs(activeTab.value)) watch(activeTab, (val) => { if (val === 'logs') { logData.execute() } }) </script> <template> <div class="tabs"> <button @click="activeTab = 'base'">基础信息</button> <button @click="activeTab = 'logs'">操作日志</button> </div> <div v-if="activeTab === 'logs'"> <p v-if="logData.isFirstLoad">切换后才会请求日志数据</p> <p v-else-if="logData.loading">正在加载...</p> <div v-else>{{ logData.data }}</div> </div> </template>这里我刻意把触发逻辑放在调用方而不是 Hook 内部,因为 Tab 切换的时机跟业务高度相关:有的场景只在第二次切到时请求,有的场景每次切回都刷新,这些差异 Hook 管不了,交给调用方反而最灵活。实际项目中如果 Tab 非常多,可以再封装一个 useTabLazyData,把“首次切换才触发”的逻辑收敛成一个小工具,但底层还是复用 useLazyData。
4.2 搜索表单里“点查询才发请求”
搜索列表页是懒加载 Hook 的另一个主战场。这类页面的共同点是:初始化时通常要自动加载一次全部数据,之后用户填完筛选条件点击查询,用新条件重新请求。useLazyData 的 immediate 参数这时候就派上用场了。
const params = reactive({ keyword: '', status: '' }) const listData = useLazyData( () => fetchList({ ...params }), { immediate: true, debounce: 300, onSuccess: (res) => { total.value = res.total } } ) function handleSearch() { listData.execute() }这里有个我反复提醒自己的点:搜索参数不要写在 requestFn 外部然后用全局变量读取,一定要把参数作为函数闭包的一部分传给 Hook。这样每次 execute 被调用时,requestFn 都能拿到最新的参数快照。如果参数是异步获取的,execute 也支持传参,可以在 handleSearch 里把参数直接传给 execute 覆盖闭包里的旧值。具体用哪种,看团队的代码风格,但保持一致性很重要,否则排查参数错乱时要花很多时间。
4.3 上拉加载更多:分页与追加模式
列表页的“加载更多”其实是 useLazyData 的一个增强场景。普通 useLazyData 每次 execute 都会覆盖 data,但分页需要的是“追加数据”。我的做法是在 Hook 之外包一层组合逻辑,不污染 useLazyData 本身,保持它的职责单一。
const page = ref(1) const list = ref<Item[]>([]) const finished = ref(false) const pageData = useLazyData( () => fetchPage({ page: page.value, size: 20 }), { allowConcurrent: false } ) async function loadMore() { if (pageData.loading.value || finished.value) return const res = await pageData.execute() if (res.list.length === 0) { finished.value = true return } list.value.push(...res.list) page.value++ }这里的关键是:list 是页面自己的响应式状态,useLazyData 只负责管理“当前这一页的请求状态”。这样解耦之后,分页逻辑、排序逻辑、条件筛选逻辑都可以各管各的,不会缠在一起。我见过有人把分页也塞进通用 Hook,结果功能越加越多,最终变成了一个谁也不敢动的巨无霸,这种教训不值得重复。
4.4 进入视口才加载:IntersectionObserver 配合
还有一种懒加载是“内容区域进入视口后才请求”,常见于首页的信息流、大屏可视化的数据面板。它的核心是配合 IntersectionObserver 观察组件是否可见,一旦可见就触发 execute。
const targetRef = ref<HTMLElement>() const chartData = useLazyData(() => fetchChartData()) onMounted(() => { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && chartData.isFirstLoad.value) { chartData.execute() observer.disconnect() } }) }) if (targetRef.value) observer.observe(targetRef.value) })这样做的价值在于,页面滚动到图表区域附近才开始拉接口,如果用户根本没滚到那个位置,请求就不会发出。实时数据这块要注意一点:如果需要轮询或定时刷新,记得在组件卸载时清理定时器和 observer,不然会留下内存泄漏。懒加载 Hook 本身在卸载后不会自动停掉飞行中的请求,所以我一般会在 onBeforeUnmount 里用一个释放标记把请求结果丢弃,避免“死组件”继续更新状态。
5. 常见问题与排查技巧实录
5.1 数据有了但页面不更新
这个问题在懒加载场景下出现率极高,根因往往是响应性丢失。常见情况是把 Hook 返回的 ref 塞进 reactive 对象,或者用响应式解构工具过度解包,导致 data 变成一个普通值。我曾经在新项目里用 Vue 3.5 的响应式解构特性封装过一次,结果列表赋值后页面纹丝不动,排查了半天才发现是解构把 ref 的响应性拆掉了。
排查方法很简单:在赋值前后打一次console.log(data),把值打印出来看看是不是带 .value 的 RefImpl 对象。如果输出的是普通对象或值,说明响应性已经在某个环节被拆掉。我的建议是不要过度依赖自动解包,模板里直接用没问题,但逻辑代码里尽量显式 .value,并且避免把 Hook 返回的 ref 再嵌套进另一个 reactive 容器。
5.2 快速连续操作时竞态覆盖
我遇到过一个真实事故:筛选区有“价格从高到低”和“价格从低到高”两个按钮,用户快速点了两下,结果列表按照错误的顺序渲染出来。原因就是第一次请求比第二次后返回,把应该新的排序结果覆盖了。这类问题不是偶发,只要用户操作够快,必然出现。
解决思路前面已经给出:requestSeq 递增计数,只有最新请求有写入权限。这类 bug 最怕的不是修,而是复现时机不稳定,所以我的建议是直接在 Hook 层面强制加入竞态保护,而不是每次在业务代码里单独处理。如果你还在用没有竞态保护的请求封装,强烈建议升级一下思路,把请求序号作为核心机制内置进去。
5.3 组件卸载后 loading 不结束
懒加载场景里经常出现组件销毁但请求还在空中的情况。如果在请求完成后执行了赋值操作,而组件已经卸载,虽然 Vue 3 在大多数情况下不会抛错,但状态更新仍然可能造成意料之外的副作用,比如某个全局 store 被误改,或者触发不必要的 watcher。
我的做法是在 useLazyData 的 run 方法里加一个内部 token,在组件卸载时通过一个dispose方法让 token 失效。如果你不想在 Hook 里自动注册生命周期,也可以像竞态处理那样在调用方维护一个释放标记,卸载时置为 true,请求返回后检查标记再决定要不要写状态。无论如何,不要让已经“死掉”的组件再去改状态,这是前端状态管理的底线。
5.4 依赖变化后旧数据闪烁
懒加载通常会保留旧 data,但如果依赖条件变了,比如从查看“订单 A”切到“订单 B”,旧的订单 A 数据在订单 B 加载完成之前一直显示,会造成一种“页面内容与当前上下文不符”的错觉。
这种情况下我建议手动判断是否要清旧数据。useLazyData 的 reset 可以清空数据,但清空会让页面出现空白 loading 态,体验也不一定更好。折中方案是给 data 加一个来源标记,比如 sourceId,切换时比较来源不同则显示骨架屏,来源相同则保留旧数据实现无缝刷新。这个逻辑放在业务层处理,Hook 本身不掺和。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 数据赋值后页面不更新 | 响应式丢失,data 被解包成普通值 | 返回 ref,不放进 reactive 容器 |
| 快速操作后结果顺序错乱 | 请求竞态,旧请求覆盖新结果 | requestSeq 递增计数,丢弃过期结果 |
| 组件卸载后仍在更新状态 | 飞行中请求完成时组件已销毁 | 卸载时递增 seq 或调用 dispose |
| 切换上下文后短暂显示旧数据 | stale data 策略保留旧值 | 结合 sourceId 判断是否清空 |
| 点击按钮重复发起多个请求 | 没有 loading 保护 | execute 入口判断 loading 并返回旧 data |
| 请求失败后页面一直转圈 | 错误分支忘记关闭 loading | 确保 finally 中统一关闭 loading |
这个表我整理自真实项目里的排查记录,每一条都是踩过坑才写进去的。建议你收藏一份放到团队 wiki 里,新人遇到类似问题可以先查表再动手,少走很多弯路。
6. 进阶玩法:让 Hook 长出生态
6.1 与 usePagination 组合成全套列表方案
useLazyData 设计得足够收敛,所以能很自然地和其他 Hook 组合。我在内部项目里把它和自定义的 usePagination 配合,做出了一个既支持懒加载又支持分页的列表模板。
export function useLazyPagination<T>( fetchFn: (page: number) => Promise<{ list: T[]; total: number }> ) { const page = ref(1) const pageSize = ref(20) const total = ref(0) const { data, loading, error, execute, reset } = useLazyData( () => fetchFn(page.value), { onSuccess: (res) => { total.value = res.total } } ) const list = computed(() => data.value?.list ?? []) function loadMore() { if (loading.value) return page.value++ execute() } function reload() { page.value = 1 return execute() } return { list, page, pageSize, total, loading, error, loadMore, reload, reset } }组合的核心思想是:useLazyData 管理单次请求的状态,usePagination 管理分页的页码和总数,二者各司其职。只要每个 Hook 的职责边界清楚,组合起来几乎不需要额外胶水代码。这也是我在团队里反复强调的原则:先设计可组合的原子 Hook,再写业务导向的复合 Hook,而不是一开始就写一个什么都能干的大神类。
6.2 泛型约束与完整类型推导
useLazyData 的另一个价值是类型安全。通过泛型 T,请求结果的类型可以从 requestFn 的返回类型自动推导出来,使用方无需手动标注。对 TypeScript 项目来说,这意味着重构接口字段时,所有引用点会在编译期报错,而不是运行时才发现字段名写错。
interface User { id: number name: string } const { data } = useLazyData<User[]>(() => fetchUsers()) // data 的类型被推导为 Ref<User[] | null>为了让类型推导更好用,我在 Hook 的 execute 返回类型上做了一点设计:execute 返回 Promise ,所以调用方可以用 await result 拿到类型明确的数据。这样即使不用 data.value,也能安全地访问请求结果。建议你在自己的项目里给 useLazyData 加上完整的泛型签名,这会在后续维护时大幅减少类型相关的返工。
6.3 定位对比:与 alova、VueUse、swrv 的边界
有些同学会问,社区不是已经有 alova、VueUse 的 useAsyncState、swrv 这些方案了吗,为什么还要自己写一个?我的看法是,这类库解决的是“请求状态管理”的大而全方案,而 useLazyData 解决的是“懒加载时机控制”这个小而专的问题。两者不是替代关系,而是可以共存。
| 方案 | 默认执行时机 | 主打特性 | 适合场景 |
|---|---|---|---|
| VueUse useAsyncState | 立即执行 | 轻量、功能聚焦 | 简单请求状态 |
| alova | 可配置 | 多模式请求、缓存、状态管理 | 复杂请求体系 |
| swrv | 立即执行 | SWR 策略:缓存优先、后台刷新 | 数据强一致性场景 |
| useLazyData | 默认不执行 | 时机控制、竞态保护、组合灵活 | Tab 懒加载、按需查询、分页 |
如果想快速跑通业务,直接用 VueUse 和 alova 都没问题。但如果你像我一样,项目里有大量“部分数据必须延迟加载”的需求,并且希望请求状态完全受控,那自己沉淀一个极简的 useLazyData 反而比引入大库更清晰。毕竟这个 Hook 只有不到一百行代码,任何团队成员花一个小时就能完全读懂。
6.4 实战体会与适用边界
最后说点实在的使用体会。我自己把这套 Hook 用在公司后台管理系统和移动端 H5 里接近一年,最大的感受是:懒加载不是银弹,它最适合“数据区域独立、用户触发明确、请求可延后”的场景,比如 Tab 内容、抽屉详情、滚动加载。但如果你的页面本身数据量不大,或者用户一进来就必须看到全部内容,那强行懒加载只会增加代码复杂度,没有任何收益。
还有一个边界值得注意:useLazyData 默认不执行,意味着如果某些页面忘了触发 execute,页面会一直停留在“未加载”状态。为了规避这个问题,我在团队里立了一个约定:凡是使用 useLazyData 的页面,必须在模板里体现 isFirstLoad 状态,至少要有一个“点击加载”或空白占位提示,避免用户以为页面坏了。这个约定看起来很笨,但实际运行下来,它帮我减少了好几次线上“白屏”投诉。
再分享一个小技巧:给 useLazyData 的 requestFn 传参时,尽量用函数工厂而不是组件实例里的 this。比如const factory = (id: number) => () => fetchDetail(id),这样每个实例持有一份独立闭包,组件之间互不干扰,配合 keep-alive 缓存时也不会出现参数串号。这个小细节在维护老项目时帮了我不少忙,每次排查参数错乱时都会想起这个约定。