第 13 课:分页、页码状态和 URL 同步

第 13 课:分页、页码状态和 URL 同步

这一课,我们把任务页继续往真实后台列表页推进一步:

  • 不是把全部数据一次性平铺出来
  • 而是先筛选,再分页,再渲染
  • 同时把当前页码也写进 URL,保证刷新、分享、回退都能保留上下文

这一课很重要。

因为很多初学者做到"有列表、有筛选"时,会以为任务页已经差不多了。

但真实项目里,列表页通常还要继续解决几个问题:

  • 数据很多时,用户不可能一次看完
  • 用户切到第 3 页后刷新,不能莫名其妙回到第 1 页
  • 用户改了筛选条件后,旧页码可能已经失效
  • 页面上显示的是"当前页数据",但统计摘要又要基于"筛选后的总结果"

所以这一课的核心问题是:

当列表既有筛选,又有分页,又要同步 URL 时,状态应该如何分层组织?


先讲结论

你先记住这一句:

列表页通常不是"原始数据 -> 直接渲染",而是"原始数据 -> 筛选结果 -> 当前页切片 -> 最终渲染"。

这句话非常关键。

也就是说:

  1. 原始任务列表是 tasks
  2. 根据关键字和筛选条件先得到 filteredTasks
  3. 再根据 currentPage 和 pageSize 得到 paginatedTasks
  4. 模板真正渲染的是 paginatedTasks

这个顺序不能反。

如果你先分页、再筛选,很多结果会错。


这一课我们改了什么

这一轮新增了:

  • src/components/tasks/TaskPaginationBar.vue
  • docs/13-pagination-and-query.md

这一轮更新了:

  • src/composables/useTasksPage.ts
  • src/composables/useTaskFilterQuerySync.ts
  • src/views/TasksView.vue
  • src/components/tasks/TaskPageHeader.vue
  • src/composables/__tests__/useTasksPage.spec.ts
  • src/composables/__tests__/useTaskFilterQuerySync.spec.ts
  • docs/README.md

从这里你可以看出,这一课不是"单纯加一个分页按钮"。

它其实同时训练了 4 件事:

  1. 如何在 composable 里增加分页状态
  2. 如何让列表从"筛选结果"变成"分页后的结果"
  3. 如何把页码同步到地址栏
  4. 如何给分页和筛选联动补测试

为什么分页状态应该进 useTasksPage

这一课里,分页的核心状态被放进了:

原因很直接:

分页不是某个按钮组件自己的状态,

它属于"任务页整体列表状态"的一部分。

也就是说,下面这些值都天然属于任务页:

  • currentPage
  • pageSize
  • filteredTaskCount
  • totalPages
  • paginatedTasks
  • pageStartIndex
  • pageEndIndex

如果把这些状态拆散到多个组件里,你会很快失控。

所以你要形成一个习惯:

只要一个状态会同时影响筛选、表格、摘要、分页器多个区域,它通常就应该提升到页面级 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 页,

那他根本看不到刚刚新增的任务,

体验会很割裂。

所以这里不是"技术上必须",

而是"交互上更合理"。

这类细节以后你要多想一步:

状态正确不代表体验就正确。


为什么分页器要单独拆成组件

这一课新增了:

它的职责很清晰:

  1. 显示分页摘要
  2. 显示页码切换控件
  3. 把用户点击的新页码抛给父组件

这个组件本身不保存页码状态。

它只接收:

  • currentPage
  • pageSize
  • total
  • pageStart
  • pageEnd

再通过事件把新页码抛出去:

  • current-page-change

这说明什么?

说明分页器组件是一个典型的:

展示组件 + 事件出口

它负责渲染交互界面,

真正的状态仍然由父层页面统一管理。

这就是 Vue 里很重要的单向数据流思维。


为什么模板现在要渲染 paginatedTasks

这一课里 TasksView.vue 做了一个关键变化:

之前表格拿到的是:

  • filteredTasks

现在表格拿到的是:

  • paginatedTasks

这是分页真正落地的那一步。

因为只要模板还在渲染 filteredTasks,

无论你前面算了多少页码,

页面都还是会把所有筛选结果一次性渲染出来。

所以你以后做列表分页时,要学会检查这条链路:

  1. 是否有 currentPage
  2. 是否有 totalPages
  3. 是否真的计算了当前页切片
  4. 模板最终渲染的是不是切片后的结果

少任何一步,分页都不算真正做完。


为什么页码也要同步到 URL

这是这一课的第二个重点。

之前我们已经把这些状态同步到了 URL:

  • keyword
  • status
  • priority

现在又补上了:

  • 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 个问题

  1. 为什么列表页分页通常应该建立在筛选结果之上,而不是原始数组之上?
  2. filteredTasks 和 paginatedTasks 的职责区别是什么?
  3. 为什么 currentPage 更适合放在页面级 composable,而不是分页器组件内部?
  4. 为什么筛选变化后,要重新校正页码?
  5. 为什么默认第一页通常不需要写进 URL?
  6. 为什么关键字同步适合防抖,而页码同步不适合防抖?
  7. 为什么地址栏里的 page 必须先解析和校验?
  8. 为什么创建新任务后回到第一页更符合交互预期?
  9. 为什么要区分"路由回填导致的状态变化"和"用户操作导致的状态变化"?
  10. 为什么分页相关逻辑非常适合补单元测试?

这一课的动手练习

练习 1

打开 useTasksPage.ts,自己口述一遍下面这些值分别负责什么:

  • currentPage
  • pageSize
  • filteredTaskCount
  • totalPages
  • paginatedTasks
  • pageStartIndex
  • pageEndIndex

目标:

把"分页状态树"真正讲清楚,而不是只会抄代码。


练习 2

自己在任务页里做下面这些操作,并观察地址栏:

  1. 先切到第 2 页
  2. 再切换状态筛选
  3. 再刷新页面

重点观察:

  • URL 里什么时候会出现 page
  • 什么时候 page 会消失
  • 刷新后页码和筛选是否会恢复

目标:

感受"页面状态"和"URL 状态"是怎么协同的。


练习 3

试着自己思考下面这些状态,哪些应该同步到 URL,哪些不应该:

  1. 当前页码
  2. 关键字筛选
  3. 新增任务弹窗是否打开
  4. 当前选中的任务详情抽屉
  5. 排序方式

目标:

训练你判断:

一个状态到底是不是"值得被刷新和分享保留的页面上下文"。


这节课的复习结论

把这一课压缩成 9 句话:

  1. 列表分页的正确链路通常是"原始数据 -> 筛选结果 -> 当前页切片 -> 模板渲染"。
  2. currentPage、totalPages、paginatedTasks 这类值天然属于页面级列表状态。
  3. 真正让分页生效的关键,不只是算页码,而是模板最终改为渲染 paginatedTasks。
  4. 筛选变化后,旧页码可能失效,所以必须重新校正页码。
  5. 页码钳制属于直接状态修正,这一轮用 flush: 'sync' 让它在同一轮里立刻生效。
  6. URL 不只适合同步筛选条件,也适合同步页码这类可恢复上下文。
  7. 默认值通常应该省略,所以第 1 页一般不需要保留 page=1。
  8. 双向同步最重要的难点不是"会不会写",而是"能不能分清变化来源,避免互相覆盖"。
  9. 分页、筛选、URL 三者联动后,测试的价值会明显上升,因为状态组合开始变复杂。
相关推荐
chenbingjie_c3 小时前
C++ 进阶核心知识点总结:模板、内存分区、new/delete、内存池
开发语言·c++
福兮说4 小时前
前端下载大文件,点了按钮半天没反应:fetch + blob 的三个问题和三种替代写法
前端·javascript·性能优化·文件下载
GreenTea4 小时前
我把 Agent 的 while 循环拆掉了:一种你可能没想到的 Agent 架构
前端·后端·架构
科技观察哨4 小时前
六足平台选型与纳米定位系统集成:HEB-640六自由度位移台在半导体光刻对准中的参数边界与国产替代评估
前端·人工智能
Escalating_xu4 小时前
【C 语言】深入理解指针(1·下):指针运算、野指针、assert 与传址实战
java·c语言·开发语言
实心儿儿4 小时前
Qt — Qt 事件
开发语言·qt
差不多的周周4 小时前
《骨架上下文:基于零镜头骨架的动作识别的骨架侧上下文提示学习》论文核心部分详解
学习
niucloud-admin5 小时前
JAVA V6 多商户商城 开发文档——web前端
前端
周杰偷奶茶5 小时前
【Java】数据类型与变量
java·开发语言
code斗5 小时前
Java数据结构:堆详解
java·开发语言·数据结构