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

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

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

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

这一课很重要。

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

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

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

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

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


先讲结论

你先记住这一句:

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

这句话非常关键。

也就是说:

  1. 原始任务列表是 tasks
  2. 根据关键字和筛选条件先得到 filteredTasks
  3. 再根据 currentPagepageSize 得到 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. pageStartIndexpageEndIndex

它们表示:

  • 当前页第一条结果在总结果中的序号
  • 当前页最后一条结果在总结果中的序号

所以页面才能显示类似:

text 复制代码
当前显示第 5 - 6 条,共 6 条结果

这类摘要在后台系统里非常常见。

它能让用户知道:

  • 自己现在看到的是哪一段
  • 总结果量大概有多少

为什么一定要"先筛选,再分页"

这是这一课最容易搞错的地方之一。

正确顺序是:

text 复制代码
tasks -> filteredTasks -> paginatedTasks

为什么?

因为用户的真实意图通常是:

先限定结果范围,再在这个结果范围里翻页

举个例子:

假设总共有 100 条任务,

其中"已完成"只有 6 条。

如果你先对 100 条做分页,

再在某一页里筛选"已完成",

你看到的结果就会非常混乱:

  • 某些页可能一条都没有
  • 某些页可能只有 1 条
  • 总页数也会不对

所以你要记住:

分页永远应该建立在"当前筛选结果集"之上。


为什么筛选变化后,页码要重新校正

这是分页和筛选联动里的关键问题。

假设你现在在第 3 页,

然后把状态筛选改成"已完成",

结果筛选后只剩 1 页数据。

如果页码还停在第 3 页,就会出现:

  • 当前页超出合法范围
  • 表格可能空白
  • 用户会误以为"没有数据"

所以这一课在 useTasksPage.ts 里做了页码钳制:

只要筛选结果变化,就检查当前页是否还小于等于总页数。

如果不合法,就自动回退到最后一个合法页。

这一轮还特意把这个侦听器改成了同步刷新:

ts 复制代码
flush: 'sync'

为什么这里值得强调?

因为如果使用默认异步时机,

在同一个事件循环里,模板或测试可能先读到旧页码。

而分页钳制属于一种非常直接的联动状态修正,

同步处理更符合这里的预期。

你可以把它理解成:

筛选结果一变,页码合法性就要立刻重新确认。


为什么创建任务后也要回到第一页

这一课里还有一个细节很重要:

useTasksPage.tscreateTask() 里,

创建成功后会主动执行:

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=0page=-1page=abc

地址栏永远要做清洗。


6. 把默认第一页也一直写进 URL

这样链接会越来越冗长。


7. 页码变化也做防抖

分页点击本来就应该立即响应,防抖反而会拖慢交互。


8. 不区分"路由回填"和"页面交互"

容易出现状态循环写回。


9. 只测试 happy path,不测试非法 query

结果线上有人手动改地址,就暴露问题。


10. 忽视分页摘要这种"小信息"

其实"当前显示第几条到第几条"非常能提升列表页可理解性。


你现在应该能回答的 10 个问题

  1. 为什么列表页分页通常应该建立在筛选结果之上,而不是原始数组之上?
  2. filteredTaskspaginatedTasks 的职责区别是什么?
  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. currentPagetotalPagespaginatedTasks 这类值天然属于页面级列表状态。
  3. 真正让分页生效的关键,不只是算页码,而是模板最终改为渲染 paginatedTasks
  4. 筛选变化后,旧页码可能失效,所以必须重新校正页码。
  5. 页码钳制属于直接状态修正,这一轮用 flush: 'sync' 让它在同一轮里立刻生效。
  6. URL 不只适合同步筛选条件,也适合同步页码这类可恢复上下文。
  7. 默认值通常应该省略,所以第 1 页一般不需要保留 page=1
  8. 双向同步最重要的难点不是"会不会写",而是"能不能分清变化来源,避免互相覆盖"。
  9. 分页、筛选、URL 三者联动后,测试的价值会明显上升,因为状态组合开始变复杂。
相关推荐
码喽7号2 小时前
vue学习六:状态管理VueX
javascript·vue.js·学习
CHANG_THE_WORLD2 小时前
C++ 文件读取函数完全指南
开发语言·c++
阿正的梦工坊2 小时前
JavaScript 闭包 × C++ 类比:彻底搞懂闭包
开发语言·javascript·c++
赵优秀一一2 小时前
SQLAlchemy学习记录
开发语言·数据库·python
xuhaoyu_cpp_java2 小时前
MySql学习(四)
数据库·经验分享·笔记·sql·学习·mysql
无限进步_2 小时前
【C++】寻找字符串中第一个只出现一次的字符
开发语言·c++·ide·windows·git·github·visual studio
鸿儒5172 小时前
中医学习首篇笔记
笔记·学习·中医
smilejingwei2 小时前
用 AI 编程生成 ECharts 图表并嵌入报表的实践
前端·人工智能·echarts·bi·报表工具·商业智能
丷丩2 小时前
第3篇:技术拆解|3dtubetilecreater 前后端架构全解析(Vue+Express+PostGIS)
vue.js·3d·架构