第 13 课:分页、页码状态和 URL 同步
这一课,我们把任务页继续往真实后台列表页推进一步:
- 不是把全部数据一次性平铺出来
- 而是先筛选,再分页,再渲染
- 同时把当前页码也写进 URL,保证刷新、分享、回退都能保留上下文
这一课很重要。
因为很多初学者做到"有列表、有筛选"时,会以为任务页已经差不多了。
但真实项目里,列表页通常还要继续解决几个问题:
- 数据很多时,用户不可能一次看完
- 用户切到第 3 页后刷新,不能莫名其妙回到第 1 页
- 用户改了筛选条件后,旧页码可能已经失效
- 页面上显示的是"当前页数据",但统计摘要又要基于"筛选后的总结果"
所以这一课的核心问题是:
当列表既有筛选,又有分页,又要同步 URL 时,状态应该如何分层组织?
先讲结论
你先记住这一句:
列表页通常不是"原始数据 -> 直接渲染",而是"原始数据 -> 筛选结果 -> 当前页切片 -> 最终渲染"。
这句话非常关键。
也就是说:
- 原始任务列表是
tasks - 根据关键字和筛选条件先得到
filteredTasks - 再根据
currentPage和pageSize得到paginatedTasks - 模板真正渲染的是
paginatedTasks
这个顺序不能反。
如果你先分页、再筛选,很多结果会错。
这一课我们改了什么
这一轮新增了:
src/components/tasks/TaskPaginationBar.vuedocs/13-pagination-and-query.md
这一轮更新了:
src/composables/useTasksPage.tssrc/composables/useTaskFilterQuerySync.tssrc/views/TasksView.vuesrc/components/tasks/TaskPageHeader.vuesrc/composables/__tests__/useTasksPage.spec.tssrc/composables/__tests__/useTaskFilterQuerySync.spec.tsdocs/README.md
从这里你可以看出,这一课不是"单纯加一个分页按钮"。
它其实同时训练了 4 件事:
- 如何在 composable 里增加分页状态
- 如何让列表从"筛选结果"变成"分页后的结果"
- 如何把页码同步到地址栏
- 如何给分页和筛选联动补测试
为什么分页状态应该进 useTasksPage
这一课里,分页的核心状态被放进了:
原因很直接:
分页不是某个按钮组件自己的状态,
它属于"任务页整体列表状态"的一部分。
也就是说,下面这些值都天然属于任务页:
currentPagepageSizefilteredTaskCounttotalPagespaginatedTaskspageStartIndexpageEndIndex
如果把这些状态拆散到多个组件里,你会很快失控。
所以你要形成一个习惯:
只要一个状态会同时影响筛选、表格、摘要、分页器多个区域,它通常就应该提升到页面级 composable。
这一课新增了哪些分页状态
现在 useTasksPage.ts 里,多了这些核心内容。
1. currentPage
它表示:
用户当前正在看第几页
这是分页最核心的状态。
2. pageSize
它表示:
每一页固定展示多少条任务
这一课里我们把它抽成了常量:
ts
const TASKS_PER_PAGE = 4
这样做的好处是:
- 分页逻辑统一使用它
- 分页摘要统一使用它
- 后面如果要改每页条数,只改一处
3. filteredTaskCount
它表示:
筛选结果一共有多少条
注意,它不是原始任务总数。
它必须基于 filteredTasks 计算。
因为分页针对的是"筛选后的结果集",不是针对原始任务数组。
4. totalPages
它表示:
筛选结果一共分成多少页
计算公式是:
ts
Math.ceil(filteredTaskCount / pageSize)
但这一课里还做了一层保护:
ts
Math.max(1, ...)
这表示即使当前一个结果都没有,
我们仍然保留"第 1 页"的概念,
避免页码系统出现 0 页这种奇怪状态。
5. paginatedTasks
它表示:
当前页真正要渲染到表格里的任务数组
它的核心思路是:
ts
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
return filteredTasks.slice(startIndex, endIndex)
你要重点理解这里:
filteredTasks 是"筛选后的全集",paginatedTasks 是"当前页子集"。`
模板以后应该优先渲染 paginatedTasks,
而不是继续直接渲染 filteredTasks。
6. pageStartIndex 和 pageEndIndex
它们表示:
- 当前页第一条结果在总结果中的序号
- 当前页最后一条结果在总结果中的序号
所以页面才能显示类似:
text
当前显示第 5 - 6 条,共 6 条结果
这类摘要在后台系统里非常常见。
它能让用户知道:
- 自己现在看到的是哪一段
- 总结果量大概有多少
为什么一定要"先筛选,再分页"
这是这一课最容易搞错的地方之一。
正确顺序是:
text
tasks -> filteredTasks -> paginatedTasks
为什么?
因为用户的真实意图通常是:
先限定结果范围,再在这个结果范围里翻页
举个例子:
假设总共有 100 条任务,
其中"已完成"只有 6 条。
如果你先对 100 条做分页,
再在某一页里筛选"已完成",
你看到的结果就会非常混乱:
- 某些页可能一条都没有
- 某些页可能只有 1 条
- 总页数也会不对
所以你要记住:
分页永远应该建立在"当前筛选结果集"之上。
为什么筛选变化后,页码要重新校正
这是分页和筛选联动里的关键问题。
假设你现在在第 3 页,
然后把状态筛选改成"已完成",
结果筛选后只剩 1 页数据。
如果页码还停在第 3 页,就会出现:
- 当前页超出合法范围
- 表格可能空白
- 用户会误以为"没有数据"
所以这一课在 useTasksPage.ts 里做了页码钳制:
只要筛选结果变化,就检查当前页是否还小于等于总页数。
如果不合法,就自动回退到最后一个合法页。
这一轮还特意把这个侦听器改成了同步刷新:
ts
flush: 'sync'
为什么这里值得强调?
因为如果使用默认异步时机,
在同一个事件循环里,模板或测试可能先读到旧页码。
而分页钳制属于一种非常直接的联动状态修正,
同步处理更符合这里的预期。
你可以把它理解成:
筛选结果一变,页码合法性就要立刻重新确认。
为什么创建任务后也要回到第一页
这一课里还有一个细节很重要:
在 useTasksPage.ts 的 createTask() 里,
创建成功后会主动执行:
ts
currentPage.value = 1
原因是新任务被插入到了数组最前面。
如果用户此时还停留在第 2 页、第 3 页,
那他根本看不到刚刚新增的任务,
体验会很割裂。
所以这里不是"技术上必须",
而是"交互上更合理"。
这类细节以后你要多想一步:
状态正确不代表体验就正确。
为什么分页器要单独拆成组件
这一课新增了:
它的职责很清晰:
- 显示分页摘要
- 显示页码切换控件
- 把用户点击的新页码抛给父组件
这个组件本身不保存页码状态。
它只接收:
currentPagepageSizetotalpageStartpageEnd
再通过事件把新页码抛出去:
current-page-change
这说明什么?
说明分页器组件是一个典型的:
展示组件 + 事件出口
它负责渲染交互界面,
真正的状态仍然由父层页面统一管理。
这就是 Vue 里很重要的单向数据流思维。
为什么模板现在要渲染 paginatedTasks
这一课里 TasksView.vue 做了一个关键变化:
之前表格拿到的是:
filteredTasks
现在表格拿到的是:
paginatedTasks
这是分页真正落地的那一步。
因为只要模板还在渲染 filteredTasks,
无论你前面算了多少页码,
页面都还是会把所有筛选结果一次性渲染出来。
所以你以后做列表分页时,要学会检查这条链路:
- 是否有
currentPage - 是否有
totalPages - 是否真的计算了当前页切片
- 模板最终渲染的是不是切片后的结果
少任何一步,分页都不算真正做完。
为什么页码也要同步到 URL
这是这一课的第二个重点。
之前我们已经把这些状态同步到了 URL:
keywordstatuspriority
现在又补上了:
page
也就是说,现在任务页的地址可能长这样:
text
/tasks?status=待评审&page=2
这么做的价值非常大:
1. 刷新后还能回到原来的页码
如果用户正在第 2 页刷新页面,
系统应该尽量恢复到第 2 页,而不是丢上下文。
2. 分享链接时,别人能看到同样的上下文
用户把链接发给同事时,
不仅筛选条件保留下来,
连第几页也保留下来。
3. 浏览器前进后退行为更自然
用户切换页码、筛选条件后,
再使用浏览器回退,
页面状态也能对应恢复。
这就是后台系统里非常常见的"可恢复列表上下文"。
这一课里 URL 同步做了哪些增强
页码相关逻辑主要补在了:
这里你要重点理解 4 件事。
1. 从地址栏读取 page
现在它会从 route.query.page 里解析页码。
但不会无脑使用,
而是先经过 parseCurrentPage() 校验:
- 必须是整数
- 必须大于等于 1
非法值都会回退到默认第一页。
这说明:
地址栏是外部输入,永远不能假设它一定合法。
2. 生成查询参数时,默认第一页不写进 URL
这一课里保留了一个很好的习惯:
ts
if (currentPage !== 1) {
nextQuery.page = String(currentPage)
} else {
delete nextQuery.page
}
为什么?
因为:
page=1没有额外信息量- 留着只会让 URL 更啰嗦
所以默认值通常应该省略。
这也是前面状态筛选和优先级筛选已经在做的事情。
3. 页码变化要立刻同步,不需要防抖
这一课你会发现:
- 关键字变化用防抖
- 页码变化直接立刻同步
为什么?
因为页码切换是低频且明确的用户动作。
用户点一下"第 2 页",
我们就应该立刻把 page=2 写进 URL。
它和关键字输入不一样。
关键字输入是高频、连续的;
分页点击是低频、离散的。
所以这里又一次体现出:
不同交互要用不同副作用策略。
4. 筛选变化后,要重置页码并清理旧的 page
这一点非常重要。
假设当前 URL 是:
text
/tasks?status=待评审&page=3
如果用户把状态改成"已完成",
而"已完成"只有 1 页结果,
那旧的 page=3 就已经不合理了。
所以这一课里在筛选变化时,
会把页码重置回第 1 页,
同时把旧的 page 参数从 URL 里移除。
这一步是在告诉你:
某些状态并不是彼此独立的,它们之间有业务约束。
筛选条件一旦改变,
旧页码就可能失效,
所以必须一起调整。
为什么这次还要特别处理"路由回填"和"本地改动"
这也是中级前端很容易踩坑的一点。
现在 useTaskFilterQuerySync.ts 里,
同一套状态会发生两种来源的变化:
第一种:来自地址栏
例如:
- 用户刷新页面
- 用户前进后退
- 用户手动改了 URL
这时应该:
route.query -> 页面状态
第二种:来自页面交互
例如:
- 用户输入关键字
- 用户切换筛选
- 用户点击分页器
这时应该:
页面状态 -> route.query
如果你不区分这两种来源,
就很容易出现:
- 刚从路由回填进来的值,又被侦听器反向覆盖掉
- 明明是同一个状态,却不断互相写来写去
所以这一课继续强化了一个很重要的工程思维:
双向同步时,一定要识别"这次变化是谁发起的"。
这一课为什么要补测试
这次补的测试主要有两类:
第一类:分页切片是否正确
测试会验证:
- 每页展示条数是否正确
- 第 2 页切片是否正确
- 页内摘要序号是否正确
- 筛选后页码是否会自动回到合法范围
第二类:页码和 URL 是否协同正确
测试会验证:
- 初始进入页面时,是否能从 URL 读出
page - 改页码时,是否会立刻写回
page - 回到第 1 页时,是否会自动省略
page - 改筛选条件时,是否会自动重置页码
这说明你现在已经不只是测试"有没有值",
而是在测试:
多个状态之间是否按预期联动
这就是列表页工程复杂度真正开始上来的地方。
真实项目里分页最容易犯的 10 个错误
1. 直接拿原始数组分页,不先经过筛选
这样会导致筛选和分页完全错位。
2. 分页器组件自己偷偷保存页码
这样父层、表格、地址栏就会很难保持一致。
3. 模板里继续渲染 filteredTasks
这样分页只是"算出来了",但没有"真正生效"。
4. 筛选变化后不处理旧页码
结果就是当前页超范围,页面看起来像"没有数据"。
5. 把非法页码直接信任为有效输入
例如 page=0、page=-1、page=abc。
地址栏永远要做清洗。
6. 把默认第一页也一直写进 URL
这样链接会越来越冗长。
7. 页码变化也做防抖
分页点击本来就应该立即响应,防抖反而会拖慢交互。
8. 不区分"路由回填"和"页面交互"
容易出现状态循环写回。
9. 只测试 happy path,不测试非法 query
结果线上有人手动改地址,就暴露问题。
10. 忽视分页摘要这种"小信息"
其实"当前显示第几条到第几条"非常能提升列表页可理解性。
你现在应该能回答的 10 个问题
- 为什么列表页分页通常应该建立在筛选结果之上,而不是原始数组之上?
filteredTasks和paginatedTasks的职责区别是什么?- 为什么
currentPage更适合放在页面级 composable,而不是分页器组件内部? - 为什么筛选变化后,要重新校正页码?
- 为什么默认第一页通常不需要写进 URL?
- 为什么关键字同步适合防抖,而页码同步不适合防抖?
- 为什么地址栏里的
page必须先解析和校验? - 为什么创建新任务后回到第一页更符合交互预期?
- 为什么要区分"路由回填导致的状态变化"和"用户操作导致的状态变化"?
- 为什么分页相关逻辑非常适合补单元测试?
这一课的动手练习
练习 1
打开 useTasksPage.ts,自己口述一遍下面这些值分别负责什么:
currentPagepageSizefilteredTaskCounttotalPagespaginatedTaskspageStartIndexpageEndIndex
目标:
把"分页状态树"真正讲清楚,而不是只会抄代码。
练习 2
自己在任务页里做下面这些操作,并观察地址栏:
- 先切到第 2 页
- 再切换状态筛选
- 再刷新页面
重点观察:
- URL 里什么时候会出现
page - 什么时候
page会消失 - 刷新后页码和筛选是否会恢复
目标:
感受"页面状态"和"URL 状态"是怎么协同的。
练习 3
试着自己思考下面这些状态,哪些应该同步到 URL,哪些不应该:
- 当前页码
- 关键字筛选
- 新增任务弹窗是否打开
- 当前选中的任务详情抽屉
- 排序方式
目标:
训练你判断:
一个状态到底是不是"值得被刷新和分享保留的页面上下文"。
这节课的复习结论
把这一课压缩成 9 句话:
- 列表分页的正确链路通常是"原始数据 -> 筛选结果 -> 当前页切片 -> 模板渲染"。
currentPage、totalPages、paginatedTasks这类值天然属于页面级列表状态。- 真正让分页生效的关键,不只是算页码,而是模板最终改为渲染
paginatedTasks。 - 筛选变化后,旧页码可能失效,所以必须重新校正页码。
- 页码钳制属于直接状态修正,这一轮用
flush: 'sync'让它在同一轮里立刻生效。 - URL 不只适合同步筛选条件,也适合同步页码这类可恢复上下文。
- 默认值通常应该省略,所以第 1 页一般不需要保留
page=1。 - 双向同步最重要的难点不是"会不会写",而是"能不能分清变化来源,避免互相覆盖"。
- 分页、筛选、URL 三者联动后,测试的价值会明显上升,因为状态组合开始变复杂。