在 Vue3 项目里做首屏优化时我经常看到类似的场面一个列表页的onMounted里塞了五六七八个请求接口各管各的loading 状态各写各的报错处理各报各的。代码能跑但每次一上线就想重构。懒加载这个概念大家都懂可真要在业务里落地尤其是把数据获取的时机、状态、缓存、竞态这些琐碎问题统一收口你会发现组件里全是重复逻辑。这篇文章想跟你聊聊我自己设计的useLazyDataHook——一个把 Vue3 懒加载思路收进自定义 Hook 的方案顺带把设计过程中的取舍、踩坑和进阶玩法一起讲透。如果你正在封装自己的数据请求层或者想理解 Composition API 到底怎么落地这篇应该对你有用。1. 为什么需要 useLazyData从能用到好用的差距1.1 业务痛点接口越多代码越乱先还原一个典型场景。你在做一个后台管理系统页面结构大概是筛选区 表格 分页。第一次写的时候逻辑是能跑通的onMounted里调一次getList把list、loading这些 ref 挨个赋值。等需求迭代几轮之后问题就来了。组件里出现了至少三组状态变量list、loading、error而getList函数里不仅要处理请求还要处理loading的开关、错误的捕获、空数据的兜底。如果页面上有主表格 侧边栏详情 统计卡片三块彼此独立的数据同样的状态声明和维护逻辑就要复制三遍。这时候你再看自己的代码会发现除了变量名不同逻辑几乎一模一样——这就是抽 Hook 最强烈的信号。我见过很多团队用ref axios直接写也能跑但能用和好用之间的差距就在这些细节里请求竞态怎么处理组件卸载后异步回调还在触发怎么办缓存怎么做同一个接口在多个组件里复用状态该放谁身上这些问题如果不在架构层面解决就会散落在各个业务组件里以每个人的写法都不一样的形式存在。1.2 现成方案的缺憾组件库懒加载是半自动提到懒加载很多人第一反应是组件库里的Skeleton、v-loadmore指令或者是IntersectionObserver做滚动触发。这些方案确实能解决什么时候加载的问题但只解决了前半截。组件库的懒加载通常是视图层的懒加载滚动到底部了通知你去加载下一页图片进入视口了通知你去替换src。但加载中要显示什么、加载失败要展示什么、数据回来之后怎么合并、接口参数怎么管理这些状态机逻辑组件库不管。结果就是你仍然要在业务代码里维护一大批loadingMore、finished、pageIndex之类的状态。而真正的懒加载应该是一个完整闭环数据没被需要时不请求需要时才请求请求过程中有状态反馈请求完有缓存机制请求失败有兜底能力。这些逻辑放在视图层做很容易和业务耦合更好的做法是收敛到一个独立的自定义 Hook 里让视图层只关心我拿到什么数据、什么状态至于数据怎么来的不关组件的事。1.3 useLazyData 要解决的四个核心问题我在设计useLazyData时给自己列了一份问题清单每个问题都对应现在 Hook 里的一个设计点。第一状态要收敛。不能再用loading ref(false)加error ref(null)这种散装状态而是用一个统一的状态机表达idle - loading - success / error所有派生状态都从状态机计算出来。第二时机要可控。有时候页面初始化就要数据有时候希望用户点击按钮才触发有时候希望某个数据变化后自动重新请求。immediate和deps参数就是为了解决这些场景而不是让调用方去watch一堆东西。第三竞态要可解。用户快速翻页时上一页的请求可能比下一页的请求晚返回这时候页面数据会闪回到旧页面。Hook 内部必须有能力丢弃过期响应。第四扩展要容易。不同业务对返回数据的处理不一样有人要原样赋值有人要做一层transform有人要追加合并。所以 Hook 的边界要清晰把不确定的部分通过参数暴露出去。2. 接口设计一个 Hook 对外该长什么样2.1 返回值的语义data、status、loading、error 的职责划分到了设计对外接口的时候我首先确定的是返回值。一个数据请求 Hook 最少要有三样东西数据本体、请求状态、触发函数。我最终返回的结构是这样{ data: RefT | null, status: Refidle | loading | success | error, loading: ReadonlyRefboolean, error: RefError | null, run: (...args: any[]) Promisevoid, refresh: () Promisevoid }设计时有个细节值得单独说loading不是独立的状态变量而是从status派生出来的计算属性。为什么不直接ref(false)因为加载中本质上不是一种新状态而是loading这个状态的别名。如果loading和status都是独立的 ref就可能出现status已经是error了但loading还是true的脏状态。用派生状态的好处是状态源只有status一个所有界面判断都由它集中输出永远不会出现多个状态变量互相矛盾的情况。这也是我在重构老代码时最强烈的感受以前总是忘记把loading置回false现在不需要了因为status一变loading自动跟着变。2.2 入参设计fetcher、immediate、transform、onSuccess、onError确定了返回值再看入参。useLazyData的第一个参数是一个数据获取函数我在这里故意用fetcher而不是直接绑死axios。理由是Hook 不应该感知任何 HTTP 库的存在axios、fetch、甚至纯函数计算将来都可以接入。export interface UseLazyDataOptionsT, P extends any[] { immediate?: boolean deps?: WatchSource[] transform?: (raw: any) T onSuccess?: (data: T) void onError?: (error: Error) void }immediate控制初始化时是否立即请求默认是true。但很多懒加载场景恰恰要设成false比如只有用户点击查询按钮才去请求。transform是我后面加上的一个参数。实际项目里接口返回的经常是{ code: 0, data: { list: [] } }这种包装结构如果每个调用方都在fetcher里手动解包那fetcher就不纯粹了。加一个transform回调允许调用方声明我拿到raw之后怎么变成我要的T既保持了数据处理的灵活性又让useLazyData内部不用关心业务包装。onSuccess和onError更像是事件出口。它们的存在是为了那些副作用——比如请求成功后关掉弹窗、请求失败后ElMessage.error提示。这些副作用不该写在组件里自己 watch直接在 Hook 参数里声明会更内聚。2.3 为什么是字符串状态机而不是一堆布尔值你可能注意到我用的是status: idle | loading | success | error而不是一个loading加一个error。原因除了上面说的脏状态问题还有一个更实际的理由状态机让界面渲染和逻辑判断都变得可以直接 switch。界面层很好用。骨架屏组件可以这样写template div v-ifstatus idle等待触发/div Skeleton v-else-ifstatus loading / ErrorState v-else-ifstatus error :errorerror retryrefresh / template v-else !-- 正常内容 -- /template /template模板层面只用做一次状态判断不需要v-loading、v-iferror多个指令叠加。逻辑层面也是一样需要禁用按钮时判断status loading需要显示空态时判断status success data?.length 0。整条链路的状态流转一目了然新接手代码的人看一眼类型定义就明白这个 Hook 的生命周期了。3. 核心实现从基础版到完整版的演进过程3.1 第一版实现四行代码的状态管理先看一个最基础的实现思路帮助理解核心骨架import { computed, ref, shallowRef } from vue export type LazyStatus idle | loading | success | error export function useLazyDataT any( fetcher: () PromiseT, options: { immediate?: boolean; transform?: (raw: any) T } {} ) { const data shallowRefT | null(null) const error shallowRefError | null(null) const status refLazyStatus(idle) const loading computed(() status.value loading) const run async () { status.value loading error.value null try { const raw await fetcher() const result options.transform ? options.transform(raw) : raw data.value result status.value success } catch (err) { error.value err instanceof Error ? err : new Error(String(err)) status.value error } } if (options.immediate ! false) { run() } return { data, error, status, loading, run } }这个版本已经能覆盖 80% 的基础需求了。里面有三个设计点我想单独解释一下。第一data和error我用的是shallowRef而不是ref。Vue3 的ref在赋值深层对象时会自动用reactive做深层响应式代理这在大多数场景是好事但在数据量大的列表页其实有性能开销。列表数据往往是一次性整体替换不需要对内部每一行都建立响应式依赖用shallowRef只追踪.value的替换能省掉一截不必要的代理成本。第二run函数不接收参数。第一版我故意把fetcher设计成无参的因为数据获取参数通常来自外部作用域useLazyData(() getList({ page: page.value, keyword: keyword.value }))。这样run的职责就非常纯粹——触发一次请求而参数由闭包自己管理。第三异常类型做了归一化。catch到的可能不是Error实例比如某些 SDK 会直接抛字符串。这里统一包装成Error保证调用方拿到的一定是规范的对象。3.2 竞态处理给每次请求加一个身份证基础版能跑但没过几天你会发现 bug快速翻页时先发的请求往往比后发的请求慢。比如用户从第 1 页跳到第 3 页第 1 页的接口还在路上翻到第 2 页又触发了请求结果第 1 页的响应最后才到直接把页面数据覆盖成了旧数据。这种问题统称竞态条件处理思路是给每次请求发一个自增 ID响应回来时只有最新 ID 对应的请求才有资格更新状态。在 Vue3 的 Composition API 里这个思路特别自然let requestId 0 const run async () { const currentId requestId // 领取新身份证 status.value loading error.value null try { const raw await fetcher() if (currentId ! requestId) return // 身份证过期丢弃本次结果 const result options.transform ? options.transform(raw) : raw data.value result status.value success } catch (err) { if (currentId ! requestId) return error.value err instanceof Error ? err : new Error(String(err)) status.value error } }这个改进虽然只有三行代码却是整个 Hook 从能用到靠谱的关键一步。requestId存在 Hook 的闭包作用域里每次run都会更新它。后发请求一旦执行之前所有未完成的请求在结果到达后都会因为身份证不对而静默退出既不更新数据也不触发onSuccess/onError最大程度避免旧请求覆盖新数据。3.3 组件卸载后的安全回调一行代码解决警告Vue3 里还有一个不能不处理的坑组件都销毁了异步请求才回来结果data.value result触发了一个组件卸载后更新状态的警告。在 Vue3 里直接改ref不会真的报错崩溃但状态已经没人消费了属于无效更新如果回调里操作 DOM 或者调用组件实例方法就会埋下隐患。正确做法是借助 Vue3 的作用域 API在 Hook 被销毁时让所有在飞的请求自动失效import { getCurrentScope, onScopeDispose } from vue if (getCurrentScope()) { onScopeDispose(() { requestId // 让所有在途请求的 currentId 与 requestId 不一致 }) }这一招很妙组件卸载时Vue 会触发onScopeDispose此时把requestId自增一次所有尚未返回的请求在返回后都会因为 ID 不匹配而被丢弃不需要额外标记组件已卸载来处处判断。这个实现比我在网上看到的各种isUnmounted标记更简洁也完全贴合 Vue3 的执行模型。3.4 加入 deps 自动触发像 watch 一样响应式请求基础版解决了初始化和手动触发还差一个高频需求依赖的响应式数据变化后自动重新请求。最常见的就是筛选区改动后列表自动刷新。我们在run的封装外提供一个监听入口import { watch, type WatchSource } from vue export function useLazyDataT, P extends any[]( fetcher: (...args: P) PromiseT, options: UseLazyDataOptionsT, P { deps?: WatchSource[] } {} ) { // ...基础实现略 if (options.deps?.length) { watch(options.deps, () run(), { immediate: options.immediate ! false }) } else if (options.immediate ! false) { run() } return { data, error, status, loading, run, refresh } }这里有两个边界要注意。当deps存在时初始化是否请求由watch的immediate决定当deps不存在时才走单独的immediate分支。这个取舍的思路是deps和immediate同时配置时以deps的声明为准避免出现既立即请求又等 deps 触发的重复请求。refresh函数是我顺手加的语义是重新执行并用最新参数请求。它和run的区别在于refresh更强调对上次请求的重新发起比如错误重试按钮就适合绑定它。实现上两者都调用同样的请求内核。4. 进阶玩法滚动加载、缓存、与 Pinia 的协作4.1 基于 useLazyData 封装 useLazyList滚动到底自动加载useLazyData处理单次请求但真正业务里大量用到的是分页列表 滚动加载。与其在组件里循环依赖useLazyData不如直接在上层再包一层 Hook。我管它叫useLazyList。import { ref, shallowRef, watch } from vue export function useLazyListT( fetcher: (page: number, pageSize: number) PromiseT[], options: { pageSize?: number; immediate?: boolean } {} ) { const list shallowRefT[]([]) const page ref(1) const finished ref(false) const pageSize options.pageSize ?? 10 const loadData async (targetPage: number) { const rawList await fetcher(targetPage, pageSize) list.value targetPage 1 ? rawList : [...list.value, ...rawList] if (rawList.length pageSize) { finished.value true } } const { loading, error, status, refresh } useLazyData(() loadData(page.value), { immediate: options.immediate ! false }) const loadMore async () { if (loading.value || finished.value) return page.value 1 await loadData(page.value) } return { list, page, finished, loading, error, status, loadMore, refresh } }为什么滚动加载适合再包一层因为请求列表和请求下一页在状态合并上有一点点特殊处理逻辑第一页的数据要整体覆盖list后续页的数据要追加进list同时用rawList.length pageSize判断是否到底。这些业务规则放进useLazyList后组件里的滚动回调就只剩一行const handleScroll () { if (container.scrollHeight - container.scrollTop 100) { loadMore() } }useLazyList复用了useLazyData的竞态处理和状态机但页面不再是简单地覆盖一次数据而是增量合并。这种基础钩子 业务钩子的层级关系是我在设计 Hook 时很看重的通用能力下沉特定业务上浮每一层都保持简单。4.2 带缓存的 useLazyData同一个 key 不重复请求另一个常见诉求是缓存。后台管理系统里字典数据分类树用户选项这类数据几乎所有页面都要用每次进页面都重新请求一次显然浪费。我在useLazyData外层做了一个带缓存的版本核心思路是基于key把请求结果存到一个模块级 Map 里。const cache new Mapstring, any() export function useLazyDataCachedT( key: string, fetcher: () PromiseT, options: UseLazyDataOptionsT { cacheTime?: number } {} ) { const cached cache.get(key) const instance useLazyData(fetcher, { ...options, immediate: cached ? false : options.immediate }) if (cached) { instance.data.value cached.data instance.status.value success } const runWithCache async () { const result await instance.run() cache.set(key, instance) if (options.cacheTime) { setTimeout(() cache.delete(key), options.cacheTime) } return result } return { ...instance, run: runWithCache } }这个方案有几个细节需要留意。第一缓存的是整个 Hook 实例还是只有数据我这里缓存的是实例是为了让未设置immediate: false的调用方也能直接拿到已有的data和status避免闪一下 loading。第二cacheTime用于控制过期时间本质是定时删除缓存 key避免数据永远旧下去。第三缓存的粒度是字符串 key调用方需要保证 key 能唯一标识同样的数据源 同样的参数。不过我不建议所有请求都上缓存只有不可变或几乎不变的数据才值得缓存。列表查询这种强动态数据用了缓存反而容易出明明改了数据页面却显示旧内容的困惑。缓存是给读多写少的场景准备的。4.3 和 Pinia 的分工Hook 管状态Store 管共享项目里用了 Pinia 之后很多人会问一个问题useLazyData和 Pinia 里的 state/action 是不是重叠了我的判断是两者解决的问题不同需要分工合作。useLazyData解决的是组件内部的数据获取逻辑复用它天然适合组件私有状态。比如一个区块的展开详情、一个弹窗里的远程数据这些数据只有当前组件会用到放进 Pinia 反而把 store 撑得很大还要处理组件卸载后 state 怎么办的问题。Pinia 解决的是跨组件共享状态。多个页面或者 layout 和页面都要读取同一个字典数据或者一个操作需要更新多个不相关组件的数据这时候才应该上 store。在我的项目里常见的组合方式是store 里只写获取数据并写入 state的 action组件里用useLazyData包一层负责组件自己的loading和交互反馈。const store useDictStore() const { data, status, loading, refresh } useLazyData( () store.fetchDicts(category), { immediate: true } )这样分工很清晰store.fetchDicts返回 Promise内部处理接口调用和共享 state 的写入useLazyData在组件层负责请求时机、状态映射和错误处理。两份逻辑互不干扰组件依然可以精准控制自己的加载体验全局数据也统一收口在了 store 里。5. 实战避坑我在项目里踩过的问题5.1 筛选条件变化导致重复请求deps自动触发的功能很好用但有个隐蔽的坑筛选表单里的对象如果直接被v-model绑定并传入deps每次输入都会触发watch导致用户还没选完筛选条件接口就连续打出去了。比如deps: [filters]而filters是一个响应式对象filters.name每个字符变化都会触发重新请求。这显然不是想要的行为。解决办法有两种要么对 deps 做结构化设计用watch(() [filters.page, filters.status])代替直接监听整个对象要么在useLazyData内部对deps做防抖处理。我选择在 Hook 内部给deps触发加一个debounce选项export function useLazyDataT( fetcher: () PromiseT, options: UseLazyDataOptionsT { debounce?: number } {} ) { // 在 watch 回调里套一层防抖 const debouncedRun options.debounce ? useDebounceFn(run, options.debounce) : run if (options.deps?.length) { watch(options.deps, () debouncedRun(), { ... }) } }这里的useDebounceFn可以自己写个 10 行的小工具也可以直接引lodash-es。防抖时间一般给 200ms 到 300ms既能避免重复请求又不会让用户觉得筛选响应迟钝。5.2 shallowRef 与 transform 的组合陷阱shallowRef有个容易被忽略的特性它不追踪内部对象的属性变化。如果某个页面的逻辑是请求返回后给data里的第一项加一个字段写成data.value[0].extra 1界面是不会更新的因为shallowRef根本不监听内部属性。这不是 bug是shallowRef的语义。解决办法是需要修改内部属性时要么用reactive代替要么通过transform在数据源头就构造完整结构要么在修改后强制重新赋值const next { ...data.value[0], extra: 1 } data.value [next, ...data.value.slice(1)]为了降低调用方踩这个坑的概率我在 Hook 文档里写清楚了一条准则shallowRef适合整体替换的数据如果业务要对返回结果做局部修改就在transform里把结构整理好后续再整体替换。这不是 Hook 的问题而是选型需要提前对齐心智模型。5.3 错误重试时的 loading 闪烁一个体验细节点击重试按钮时如果status从error直接变到loading按钮里的文字会短暂从重试变成加载中…然后如果错误又变回重试。这在快速点击时会显得卡顿也容易让用户觉得怎么又失败了。我后来在refresh里加了一个判断如果当前已经是error状态重试时先保持error界的展示等请求真正进入等待状态后 100ms 再切换成 loading。这个优化本质上是给错误重试场景做了个 UI 缓冲。const refresh async () { if (status.value error) { status.value success // 先用 success 拿掉错误页视觉上立即响应 await run() } else { await run() } }这里其实有个取舍先用success兜着理论上如果用户当前展示的还是错误提示这条状态会先更新成看起来有数据但没有内容的状态。所以严格一点的做法是给refresh加一个silent选项由调用方决定重试时要不要展示 loading 骨架。这样既保留了默认的流畅体验也给了页面自定义的权利。5.4 模块级缓存 Map 的内存泄漏隐患useLazyDataCached里的模块级cacheMap 有一个潜在问题如果缓存的数据结构很大、或者 key 无限增长内存会被无谓占用。字典类的数据还好如果有人在 key 里拼接了用户输入或随机参数Map 就会无限膨胀。我的习惯是给缓存加两层约束一是必须有cacheTime到期自动删除二是默认只在页面初始化时读取缓存跑完一次请求后如果没有明确的写入缓存标记就不往 Map 里放。简单说缓存应该是显式的不是隐式的const runWithCache async () { if (!cached) { const result await instance.run() if (options.cacheable ! false) { cache.set(key, { data: instance.data.value, status: success }) } } }这样设计之后缓存变成了调用方有意开启的行为而不是默认缓存一切自然就规避了缓存了不该缓存的大数据这类问题。6. 从使用到复用把 useLazyData 沉淀成团队资产6.1 类型安全让 Hook 的泛型帮你减少低级错误我在项目里推广useLazyData时最初收到的反馈是封装了之后代码读起来多了一层。但真正让他们改观的是类型安全带来的信心。比如下面的用法interface UserItem { id: number name: string avatar: string } const { data, status, loading } useLazyDataUserItem[]( () fetchUserList() ) if (status.value success) { console.log(data.value.length) // TS 在这里会提示 data.value 可能为 null }因为有泛型约束开发者在写data.value.length时会收到 TypeScript 的提示data.value可能是null。这会倒逼你写空值判断从类型层面提前消灭了一类接口返回 null 导致页面白屏的 bug。我建议在团队里只暴露有完整类型签名的封装版本不要让人直接复制基础版改成any满天飞。6.2 统一错误处理onError 与业务告警的联动工程化到一定程度你会在onError里做的事会越来越统一记录日志、上报监控、弹出提示。这些逻辑如果每个页面各写各的就会出现有的页面报错弹 ElMessage有的页面报错写 console有的页面直接静默失败的混乱。我的做法是在 Hook 的基础上再做一层项目内封装export function useApiT( fetcher: () PromiseT, options: { successTip?: string; errorTip?: string } {} ) { const instance useLazyData(fetcher, { onError: (error) { if (options.errorTip) { ElMessage.error(options.errorTip) } else { ElMessage.error(error.message || 请求失败) } trackError(error) // 公司内部的上报方法 } }) return instance }这层封装不强求所有团队统一但建议至少要有一个项目级的默认出口。否则每个页面都写onError的展示逻辑Hook 的复用价值就打了折扣。6.3 最后分享一个小技巧调试状态机useLazyData用状态机管理生命周期后调试时有个小技巧可以给status加一个watch在开发环境下打印状态流转痕迹快速定位请求卡在哪个环节。if (import.meta.env.DEV) { watch(status, (val, old) { console.debug([useLazyData] status: ${old} - ${val}) }) }这个点最初是我调试滚动加载问题时加的。当时loadMore触发后页面数据不更新我以为是接口问题后来一看状态痕迹发现是finished提前变成true导致后续loadMore直接被拦截了。状态痕迹一打印问题的定位时间至少缩短了一半。后来这个watch就被我保留在了基础 Hook 里只对本地开发可见。它不产生业务负担却能在关键时候帮你快速还原现场。