这次遇到分页需求时,页面其实已经不是一张白纸了。原来的页面有分类筛选、搜索、详情深链和一些键盘操作,用户可以通过链接直接打开某个条目,也可以在页面里继续查找。分页是在这些交互之后追加进来的:需要有分页器,需要让 ?page= 能被刷新还原,还要支持切换分类回到第 1 页、根据 #id 自动跳到条目所在页,以及用左右方向键翻页。
乍看之下,这像是给列表底部加几个按钮。真正动手后我才发现,按钮只是最容易看见的部分。难点在于,页面里已经有好几种状态,而分页会同时读取、改变这些状态。如果每个功能都各自维护一份判断,单独点分页可能没问题,单独搜东西也可能没问题,但它们一组合,就会出现刷新回错页、筛选后显示空白、深链找不到内容、输入文字时页面突然翻走等问题。
页码只是状态的一个名字
我一开始最容易想到的实现,是增加一个 page 变量,再根据它切出当前列表。这个想法没有错,但它把问题说得太小了。page 不是一个孤立的数字,它和筛选条件、每页容量、当前 URL 以及页面焦点共同决定用户眼前看到的内容。
比如页面当前在第 3 页,每页显示 24 条。此时用户切换了分类,结果集可能只剩下 1 页。如果旧的第 3 页继续保留,页面当然可以“正常渲染”,但用户看到的会是空列表,或者一个已经没有意义的页码。这里表面上是分类变化,实际上是结果集的边界变化,原来的页码已经失效。
所以我先把分页看成状态机,而不是视觉组件:筛选条件变化时,页码必须重置;每页容量从 24 条切到 48 条时,页码也要重新计算;地址栏里的页码则需要在页面初始化时被读取,并在用户翻页时被写回。分页是否可靠,首先取决于这些状态之间有没有明确的优先级,而不是页码按钮做得多漂亮。
先处理会让旧页码失效的变化
筛选和搜索是最容易和分页打架的地方。用户在第 2 页输入一个搜索词,搜索结果可能只有几条。如果仍然沿用第 2 页,页面就会显示“没有结果”,但这个“没有结果”并不代表搜索失败,只代表旧页码不再适合新的结果集。
这次我把分类变化和搜索变化都定义为回到第 1 页。每页容量切换也按同样的思路处理,因为显示容量变了,原来的页码对应的条目范围也变了。重置不是为了让交互看起来规整,而是为了避免把上一组结果的状态误传给下一组结果。
如果不处理,后果不只是偶尔多点一下“上一页”。更糟的是,用户可能把空白页理解成系统没有数据,接着刷新、重复搜索,甚至怀疑刚才的筛选没有生效。一个看似很小的状态残留,会变成对整个页面可信度的损耗。
这里最容易误判的是,把重置逻辑分别写在分类下拉框、搜索框和容量选择器的事件里。它们当下都能工作,但以后增加新的筛选入口时,很容易漏掉其中一个。我选择让“结果集发生变化就回第 1 页”成为共同规则,是因为这个判断比某个控件的名字更稳定。控件可以增加,结果集变化的含义不会变。
URL 不是展示文字,而是恢复入口
分页和 URL 连接起来以后,页面状态就不再只存在内存里。用户打开带有 ?page=2 的链接,期待的是第 2 页;刷新页面,期待的仍然是第 2 页;把链接发给别人,对方也应该看到同一页。这个要求意味着,页码不能只在点击事件里变化,还要有一条从地址栏读回状态的路径。
我先处理初始化,再处理交互:页面加载时读取页码,确认它是有效数字后再决定当前页;用户点击下一页、上一页或直接跳转时,同时更新 URL。异常页码也必须有边界,例如小于第 1 页的值不能让列表进入奇怪状态,超过最大页数的值不能让页面停在一个不存在的页上。
这里的关键不是“把参数拼到 URL 里”,而是要决定谁有权覆盖谁。刷新时,URL 是恢复当前页的依据;筛选变化时,新结果集会让旧页码失效,此时应该重置页码并同步更新地址。URL 还原和筛选重置不是两个独立功能,它们必须共享同一套页码边界和优先级规则。
最容易走的错误捷径,是只在点击按钮后修改地址栏,初始化时却仍然从第 1 页开始。这样链接看起来有 ?page=2,页面实际却没有还原。另一种捷径是把非法页码直接当成字符串继续传下去,直到切片逻辑在更深处产生空结果。相比之下,入口处就把页码解析、限制范围,再交给列表渲染,问题更容易定位,也更不容易把异常扩散到其他状态。
深链要先找到位置,再决定页面
#id 深链比普通分页更能暴露设计是否完整。用户可能打开一个指向某条记录的链接,而这条记录并不在第 1 页。如果页面先按默认页渲染,再尝试在当前页面里查找,当然找不到目标。此时用坐标、滚动距离或临时跳转去补救,看起来能把人带过去,却没有解决数据到底属于哪一页的问题。
这次的处理顺序是先根据筛选后的完整结果集找到目标条目的位置,再用位置和每页容量计算所在页,最后把分页状态切到那一页,并让目标元素进入可见区域。这样做的好处是,定位依据来自数据本身,而不是当前页面碰巧渲染出的 DOM 位置。每页从 24 条切换到 48 条时,也会自然得到新的页码。
当然,深链也有找不到目标的情况。#id 可能对应已经被筛选条件排除的条目,也可能只是一个不存在的编号。这时不能误命中当前列表的第 1 项,否则用户会看到一个看似合理、实际错误的结果。遇到空资产 ID 之类的异常值,也应该明确判定为没有目标,而不是把空值当成第一个元素的匹配条件。
这件事让我重新意识到,深链不是分页的附属功能。它要求系统能够从一个外部输入反推完整状态。凡是需要跨页定位的需求,都应该先问“目标在结果集中的位置是什么”,再问“当前应该显示哪一页”。
键盘操作要尊重用户正在做的事
左右方向键翻页很方便,尤其是在连续查看内容时。但键盘事件有一个常见陷阱:页面能收到按键,不代表页面就应该处理这个按键。用户在搜索框里输入文字、在跳页输入框里修改数字,左右键可能只是为了移动光标。此时如果页面把它解释成上一页或下一页,焦点会被抢走,输入内容也会被打断。
因此,键盘翻页需要先判断事件目标是否处在输入框、文本区域或其他可编辑控件中。如果是,就把按键交还给控件;只有在页面处于普通浏览状态时,左右键才参与分页。这个判断看上去像是一个小的保护条件,却体现了交互里的基本边界:快捷键服务于当前任务,不能因为自己更“全局”就覆盖用户正在进行的局部操作。
专项回归时,我不会只按左右键看页码有没有变化,而会把它放进具体场景里:先聚焦搜索框,再按左右键,确认页码不变;离开输入框后再按,确认翻页生效;在第 1 页按左键、最后一页按右键,确认不会越界。一个功能是否可用,不只看它在理想路径上有没有反应,也要看它在不该反应时能不能保持安静。
把组合场景当成真正的验收标准
分页专项验证通过时,检查的并不是“下一页按钮能不能点”。我会把场景串起来:先打开带页码的链接并刷新,确认状态还原;再切换分类或搜索,确认回到第 1 页;再用一个带 #id 的深链,确认它能跨页定位;然后在输入框内按左右键,确认不会触发翻页;最后再检查异常页码、容量切换和页码跳转。
这样的顺序很重要,因为组合问题往往不会出现在单项测试里。分页器单独测试可能通过,URL 还原单独测试也可能通过,但如果 URL 恢复发生在筛选初始化之前,就可能先跳到第 3 页,再被筛选重置;如果深链定位没有使用当前结果集,就可能在筛选后找到错误的页;如果键盘监听没有焦点豁免,搜索功能就会被分页功能干扰。
如果不做组合回归,最容易交付的是一组“每个功能都能演示”的按钮,而不是一个能够长期使用的页面。交互质量的分水岭,不是每个功能能否单独成功,而是它们同时存在时是否仍然遵守同一套状态规则。
这也是我没有直接接受“只加页码按钮”这个错误捷径的原因。那样改动最少,却把真正的复杂度推迟到用户遇到深链、刷新和输入冲突时再暴露。我的判断是,先定义状态协同,再写控件,短期看多了一点思考,长期反而减少了重复修补和互相覆盖。
下次我会怎么做
下次再给已有页面追加分页,我会先画出状态关系,而不是先找一个分页组件。至少要回答几个问题:页码从哪里恢复,哪些变化会让页码失效,深链定位依据哪一份结果集,键盘事件在哪些焦点状态下必须豁免,异常页码如何被限制。
然后我会把规则收敛成一条清晰流程:先得到筛选后的完整结果集,再确定容量和最大页数;初始化时从 URL 恢复合法页码;筛选、搜索或容量变化时回第 1 页;深链先找目标位置再算页;翻页后同步 URL;键盘处理先判断焦点,最后才执行边界内的翻页。
最后验收时,我会优先测组合场景和边界场景,而不是只测按钮点击。一个流程成熟的标志,不是它能跑通一次,而是它能在下一次少依赖临场记忆。分页表面上是把长列表切成几段,实际上是在给页面增加一套状态协同协议。只有把这套协议说清楚,页码才不是装饰,深链、筛选、刷新和键盘操作才会真正成为一个整体。