第 10 课:列表页的异步状态怎么设计
这一课非常实战。
因为真实项目里的列表页,几乎都不是一打开就“天然有数据”的。
它们通常都要经历这样一个过程:
- 页面先挂载
- 发起请求
- 等待返回
- 再决定显示表格、空状态还是错误提示
所以这节课要解决的核心问题是:
当任务页开始从“同步假数据页面”走向“异步请求页面”时,状态该怎么设计?
先讲结论
你先记住一句最重要的话:
异步列表页不是只有“有数据”和“没数据”两种状态,它至少要区分 loading、success、error
更进一步地说,一个成熟一点的列表页,通常还要考虑:
- 首屏加载
- 刷新中
- 空数据
- 失败重试
- 旧数据是否保留
也就是说:
真正难的不是“把数据拿回来”,而是“请求过程中页面应该怎么表现”
这次我们做了什么
这一轮我们不只是写文档,而是真的把任务页改成了“异步列表页”。
新增了:
src/services/taskService.ts
更新了:
src/composables/useTasksPage.tssrc/views/TasksView.vuesrc/components/tasks/TaskPageHeader.vuesrc/components/tasks/TaskFilterBar.vuesrc/components/tasks/TaskTable.vuesrc/composables/__tests__/useTasksPage.spec.ts
这说明你现在看到的已经不是“理论上的异步状态”,
而是一个真实跑起来的 Vue 页面。
为什么之前那种写法不够真实
之前的任务页是这样工作的:
- 直接从
src/mock/tasks.ts读同步数据 - 页面一创建,
tasks里就已经有内容 - 不存在等待请求的过程
这对入门很友好,但它和真实项目还有一个很大的差距:
没有请求过程,就没有请求状态
于是你也就看不到这些关键问题:
- 加载中时页面显示什么
- 请求失败时页面显示什么
- 刷新时要不要清空旧数据
- 没有任务和筛选后为空,是不是同一种空状态
而这些,恰恰是列表页里最常见的实战问题。
为什么这次先引入 service 层
这次我们新增了:
- taskService.ts
你可以把它先理解成一个很简单的“模拟后端接口层”。
它做了两件事:
- 提供
fetchTaskList(),模拟异步获取任务列表 - 提供
createTaskRecord(),把新任务写入内存数据库
这样做的好处是:
页面不再直接依赖假数据文件,而是开始依赖“取数动作”
这一步非常重要。
因为真实项目里,页面通常不会自己知道数据从哪来,
页面只会说:
请帮我把任务列表拿来
而 service 层负责:
- 请求 API
- 返回数据
- 以后替换成真实后端实现
当前任务页的异步状态有哪些
现在的 useTasksPage.ts 里多了几个很关键的状态:
loadStateloadErrorMessageisLoadinghasSourceTasksisInitialLoading
你先不要急着背名字,先理解它们各自解决什么问题。
1.loadState
它是这次最核心的状态:
'idle'|'loading'|'success'|'error'它的意义是:
idle:还没开始请求loading:请求中success:请求成功error:请求失败
这比只写一个isLoading更完整。
因为如果你只有:
constisLoading=ref(false)你没法准确表达:
- 现在是还没请求,还是请求失败了
- 失败后页面应该渲染什么
- 成功后又该切回什么状态
所以这节课最值得你学的一点就是:
异步流程最好用“状态枚举”来描述,而不是只靠一个布尔值硬撑
2.loadErrorMessage
这个状态专门保存错误提示文案。
它的作用不是代替loadState,
而是配合loadState === 'error'时显示更具体的信息。
也就是说:
loadState负责告诉页面“现在失败了”loadErrorMessage负责告诉页面“为什么失败”
这是一种很常见的组合。
3.hasSourceTasks
这个状态非常容易被初学者忽略。
它表示:
当前原始任务源里到底有没有数据
为什么这个信息这么重要?
因为“没有数据”其实可能对应两种完全不同的场景:
场景 A
真的没有任何任务数据。
场景 B
原始任务是有的,只是你筛选完之后没有匹配结果。
这两个场景页面文案不应该一样。
所以我们用了:
filteredTaskshasSourceTasksemptyDescription
一起判断当前到底该显示哪种空状态提示。
4.isInitialLoading
这个状态也很实战。
它表示:
当前是不是首屏第一次加载,而且还没有任何旧数据可展示
为什么不能只看isLoading?
因为加载中也分两种:
首屏加载中
页面还什么都没有,这时更适合显示骨架屏。
刷新加载中
页面已经有旧数据了,这时通常不应该把表格直接清空,
而是应该保留旧数据,再加一个“刷新中”的提示。
这就是为什么我们要把:
- 首屏加载
- 刷新加载
分开看。
为什么TaskTable.vue现在更像真实项目
现在的 TaskTable.vue 不再只是“渲染一个表格”。
它开始承接列表区域最常见的 4 种显示状态:
1. 首屏加载
显示el-skeleton
2. 阻塞式错误
如果请求失败,而且手里没有任何旧数据,
显示el-result+ “重新加载”按钮。
这是一种“整块区域都被错误态接管”的设计。
3. 非阻塞式刷新
如果页面已经有旧数据,这时再次请求:
- 成功前显示“刷新中”提示
- 表格继续保留旧数据
这就是为什么我们没有在重新加载时先把tasks清空。
因为一旦清空,用户就会看到表格闪掉,体验会变差。
4. 非阻塞式错误
如果刷新失败,但旧数据还在,
页面会显示一个错误alert,同时保留旧表格。
这比“一失败就整页全空”更符合真实业务。
这次非常值得你学的一点:失败时不一定要清空旧数据
很多新手一写异步列表页,失败时会直接这样做:
tasks.value=[]这并不总是对。
如果是首屏第一次请求失败,
确实可能只能显示错误页。
但如果是“已经有旧数据,再次刷新失败”,
直接清空旧数据通常不是好体验。
因为用户至少还能先看旧内容。
所以这次我们的处理是:
- 成功时写入新任务列表
- 失败时保留旧列表
- 同时写入错误消息
这是一个非常典型、也非常实用的列表页思路。
为什么新增任务现在不会被“重新加载”冲掉
这次 service 层除了fetchTaskList(),还加了:
createTaskRecord()
它的作用是把新增任务同步写入模拟服务的内存数据库。
这样一来:
- 你在页面里新增一条任务
- 本地列表立刻更新
- 之后你再点“重新加载”
- 服务层返回的数据里仍然包含这条新任务
这一步很重要,因为它帮你避开了一个常见坑:
本地新增成功了,但一刷新又被原始假数据覆盖掉
页面层和 composable 层这次是怎么分工的
这次分工非常值得你学。
useTasksPage.ts负责
- 维护请求状态
- 调用服务层
- 保存任务列表
- 计算空状态文案
- 组织筛选、统计、新增逻辑
关键词:
页面业务逻辑
TasksView.vue负责
- 在
onMounted里触发首次加载 - 响应“重新加载”按钮点击
- 在页面层显示成功提示或等待提示
关键词:
页面组装 + 页面反馈
TaskTable.vue负责
- 根据不同状态决定渲染什么界面
- 区分骨架屏、错误态、表格、空状态
- 在错误态里抛出重试事件
关键词:
列表区域展示逻辑
为什么这次要在页面里用onMounted
现在 TasksView.vue 里有一段很关键的代码:
onMounted(()=>{voidloadTasks()})它表达的是:
页面真正挂载到界面上之后,再开始发起请求
这也是很多真实页面最常见的启动方式。
所以你现在要开始建立一个直觉:
setup里定义状态和函数onMounted里启动首次副作用
这是 Vue 页面里非常高频的一组搭配。
为什么这次还继续保留了 composable 测试
现在的 useTasksPage.spec.ts 新增了异步状态相关测试。
它测试了几件很关键的事情:
- 加载开始时是否进入
loading - 加载成功后是否进入
success - 刷新失败后是否保留旧数据
- 创建任务后是否正确插入列表
这很有教学价值,因为你会越来越清楚:
复杂页面逻辑只要边界清楚,就可以不依赖组件渲染,直接测逻辑本身
真实项目里最常见的异步列表页陷阱
你现在就可以先记住下面这 6 个坑:
1. 只有isLoading,没有完整状态机
会导致你分不清:
- 还没请求
- 请求中
- 请求失败
- 请求成功
2. 加载失败后没有错误文案
用户只会看到“出错了”,但不知道下一步该做什么。
3. 刷新时先清空旧数据
会让页面频繁闪烁,看起来像“抖一下再回来”。
4. 把“真实空数据”和“筛选后为空”混成一种状态
这会让文案不准确,也会让用户迷惑。
5. 请求失败后没有重试入口
用户只能刷新整个页面,交互很笨重。
6. 本地新增和重新加载互相覆盖
这正是我们这次专门通过 service 层内存数据库避开的坑。
你现在应该能回答的 10 个问题
- 为什么异步列表页不能只用一个
isLoading来描述全部状态? loadState里的idle / loading / success / error各自表示什么?- 为什么首屏加载和刷新加载要分开看?
- 为什么失败时不一定要把旧数据清空?
hasSourceTasks解决了什么判断问题?- 为什么“没有任何任务”和“筛选后没有结果”不是同一种空状态?
- 为什么这次要引入
taskService.ts? - 为什么新增任务要同步写入模拟服务?
TasksView.vue和TaskTable.vue这次分别承担了什么职责?- 为什么 composable 抽出来之后,异步状态也更容易测试?
这节课的动手练习
练习 1
打开 useTasksPage.ts,把里面和异步请求相关的代码只按这 3 类重新整理一遍:
- 请求状态
- 请求动作
- 请求结果衍生判断
目的:
训练你从“页面逻辑角度”读 composable,而不是只盯着模板看。
练习 2
打开 TaskTable.vue,自己口述一遍:
- 什么时候显示骨架屏
- 什么时候显示整块错误态
- 什么时候显示错误提示但保留旧表格
- 什么时候显示空状态
目的:
训练你把“状态判断”翻译成“界面表现”。
练习 3
自己尝试再加一个小功能:
给任务页增加一个“只看高优先级任务”的快捷按钮。
要求你先回答:
- 这个按钮状态适合放哪里
- 它会影响哪个
computed - 它属于组件逻辑、composable 逻辑还是 store 逻辑
目的:
让你把“异步状态设计”和“页面业务状态设计”开始放在一起思考。
这节课的复习结论
把这一课压缩成 8 句话:
- 真实列表页一定会经历异步请求过程,所以必须设计请求状态。
loadState比单独一个isLoading更能完整表达页面当前所处阶段。- 首屏加载和刷新加载虽然都叫 loading,但页面表现通常不一样。
- 请求失败时不一定要清空旧数据,很多时候保留旧数据体验更好。
- “任务真的为空”和“筛选结果为空”是两种不同的空状态。
- service 层的作用是把“取数据动作”和“页面本身”分开。
- composable 很适合承接异步列表页的请求状态和页面级业务逻辑。
- 好的异步状态设计,本质上是在回答“请求过程中每一刻页面该长什么样”。