成为全栈·React 管理后台篇·列表页范式:让分页、筛选和返回位置进入 URL

成为全栈·React 管理后台篇·列表页范式:让分页、筛选和返回位置进入 URL

页码、筛选和排序不是表格组件的临时状态,而是"用户正在查看哪一组数据"的完整描述。只要这个描述值得刷新、返回或分享,就应该进入 URL。

前言

管理后台的列表页看起来都差不多:搜索框、几个筛选项、一张表格和分页器。真正容易出问题的,却不是表格怎么画,而是用户离开再回来以后还能不能回到原处。

我在这个项目里遇到过很典型的场景:编辑第 4 页的一篇待审文章,保存后点返回,页面却回到"全部状态"的第 1 页。审核人员要重新选择状态、重新翻页,再凭记忆寻找刚才那篇文章。刷新浏览器以后,筛选条件同样全部消失。

把这些状态塞进组件的 useState,单次渲染没有问题,却丢掉了浏览器原本就具备的导航能力。本项目最后让 URL 成为列表查询状态的载体,再由 Query key 和接口请求共同消费它。

本文要解决什么

  • 为什么分页、筛选和排序应该写进 search params。
  • 怎样读取非法参数并回落到安全默认值。
  • 搜索防抖为什么可能在恢复页面时误把页码重置为 1。
  • 编辑页怎样保存完整返回位置。
  • 总页数缩小时,当前页如何夹正。

前置阅读:列表接口三件套:分页、筛选、排序TanStack Query 不只是缓存:失效、派生与失败恢复

列表 URL 是一份可执行的视图描述

下面这个地址已经完整说明了用户在看什么:

text 复制代码
/articles?page=4&pageSize=20&status=pending&sort=-createdAt&keyword=React

它可以被刷新、收藏、复制,也会进入浏览器历史。只用 useState 保存同样的数据,则只有当前组件知道;组件卸载以后,上下文随之消失。

这并不意味着列表的一切都要进入 URL。删除确认框是否打开、当前鼠标悬停在哪一行、批量操作是否正在执行,都属于短暂交互。URL 保存的是会改变"数据集合身份"的条件。

状态 是否进入 URL 原因
page、pageSize 决定查询区间
status、category、keyword 决定数据集合
sort 决定结果顺序
删除确认框 无法分享,也不应刷新恢复
请求 loading 是一次请求过程,不是用户选择
当前页勾选项 通常否 依赖当前返回数据,跨页面恢复容易误操作

用一个 hook 收住解析和写入规则

若每个列表页都直接调用 useSearchParams,很快会出现不同规则:一个页面允许 page=0,另一个把空筛选写成 status=,还有一个换 pageSize 后忘了回第 1 页。

项目把这些规则集中在 useTableQuery

ts 复制代码
const rawPage = Number(params.get('page'))
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1

const rawSize = Number(params.get('pageSize'))
const pageSize = [10, 20, 50].includes(rawSize)
  ? rawSize
  : defaultSize

const sort = params.get('sort') ?? undefined

URL 是外部输入,用户可以手改,也可能从旧链接进入,所以不能直接相信。非法页码回到 1,不支持的 pageSize 回到默认值;未知筛选仍可以保留在 query 中,由具体页面决定是否消费。

写入时采用合并策略,undefined 和空字符串表示删除参数:

ts 复制代码
const patch = useCallback((next: Record<string, QueryValue>) => {
  writer.current((prev) => {
    const params = new URLSearchParams(prev)
    for (const [key, value] of Object.entries(next)) {
      if (value === undefined || value === '') params.delete(key)
      else params.set(key, String(value))
    }
    return params
  }, { replace: true })
}, [])

这里使用 replace: true,避免用户输入五个字符就制造五条浏览器历史。翻页究竟用 replace 还是 push 可以按产品习惯调整;当前后台更看重"返回上一业务页面",而不是逐页倒退。

改筛选时必须回到第一页

用户正在第 8 页,随后把状态改成"待审核"。新集合也许只有两页。如果继续请求第 8 页,页面会先显示空态,用户很容易误以为没有待审文章。

所以筛选、排序和 pageSize 变化都同时重置 page:

ts 复制代码
const setPageSize = (size: number) =>
  patch({ pageSize: size, page: 1 })

const setSort = (sort?: string) =>
  patch({ sort, page: 1 })

const setFilters = (filters: Record<string, QueryValue>) =>
  patch({ ...filters, page: 1 })

清空筛选则保留 pageSize。每页条数更接近用户偏好,没有必要随着筛选一起丢掉:

ts 复制代码
const next = new URLSearchParams()
if (prev.has('pageSize')) {
  next.set('pageSize', prev.get('pageSize') ?? '')
}

这些细节放进共享 hook 后,文章、评论、用户列表就不会各自发明一套分页行为。

防抖搜索有一个隐蔽的恢复陷阱

关键词搜索通常要防抖,否则每次键盘输入都发请求。最初的实现只有一个本地 value,300 毫秒后调用 setFilters。但页面从 URL 恢复时也会设置 value,effect 误以为这是用户新输入,再次提交筛选并把 page 重置为 1。

最终实现同时观察 URL keyword 与输入值:

ts 复制代码
export const useKeywordFilter = (
  keyword: string,
  onChange: (filters: { keyword?: string }) => void,
) => {
  const [value, setValue] = useState(keyword)

  useEffect(() => {
    setValue(keyword)
  }, [keyword])

  useEffect(() => {
    if (value.trim() === keyword) return
    const timer = setTimeout(
      () => onChange({ keyword: value.trim() || undefined }),
      300,
    )
    return () => clearTimeout(timer)
  }, [value, keyword, onChange])

  return [value, setValue] as const
}

value.trim() === keyword 这一行区分了"URL 恢复"与"用户修改"。前者只同步输入框,不重新提交;后者才在防抖后更新 URL,并按照筛选规则回到第 1 页。

这个问题很难靠静态页面发现。必须从带 page 和 keyword 的地址直接进入,或者从编辑页返回,才能看到页码是否被悄悄重置。

返回位置不能只保存 pathname

进入编辑页时,列表把当前路径和查询串一起放进路由 state:

ts 复制代码
const from = location.pathname + location.search

navigate(`/articles/${id}/edit`, {
  state: { from },
})

编辑页只接受符合预期的返回地址:

ts 复制代码
const from = (location.state as { from?: string } | null)?.from
const returnTo = from?.startsWith('/articles?')
  ? from
  : '/articles'

保存成功或用户点返回时,重新导航到完整 URL,页码与筛选自然恢复。这里的校验不是安全边界,但能避免任意 state 把后台带到意外位置。

如果用户直接打开编辑页,没有 from,就回文章列表默认状态。这是合理降级;我们无法恢复一个从未进入过的列表上下文。

删除最后一条数据后,要夹正当前页

第 4 页只有一条记录,删除后总页数变成 3。URL 仍写着 page=4,接口可能返回空列表。分页器在收到真实 totalPages 后把页码夹回有效范围:

ts 复制代码
useEffect(() => {
  const lastPage = Math.max(totalPages, 1)
  if (page > lastPage) onPageChange(lastPage)
}, [page, totalPages, onPageChange])

为什么不在解析 URL 时完成?因为 URL 解析阶段还不知道服务器会返回多少页。页码是否合法分成两层:小于 1、非整数属于语法非法,可以立即修正;超过 totalPages 属于数据非法,必须等查询结果。

当 total 为 0 时,界面仍显示第 1 / 1 页,避免出现"第 1 / 0 页"这种机械但难懂的结果。

Query key 必须消费规范化后的参数

页面最终显式组装接口 query:

ts 复制代码
const listQuery = {
  page,
  pageSize,
  sort,
  status,
  category,
  tag,
  keyword: keyword || undefined,
}

const list = useAdminArticles(listQuery)

同一个对象同时进入 query key 和请求函数,于是 URL、缓存身份与服务端查询保持一致。若请求使用原始 params,query key 却使用规范化结果,缓存和实际请求就可能错位。

批量选择必须受当前查询范围约束

勾选状态虽然不进入 URL,却必须知道自己属于哪个 URL 查询范围。否则用户在"待审核第 2 页"选中三篇文章,再切到"已发布第 1 页",旧 id 仍留在 selection 中;点击批量删除时,界面上看不见的记录也可能被操作。

文章列表把规范化查询序列化成选择范围:

ts 复制代码
const listQuery = { page, pageSize, sort, status, category, tag, keyword }
const batch = useBatchSelection(JSON.stringify(listQuery))

hook 将 scope 和 ids 一起保存。查询范围变化时,当前渲染立即返回空选择,并在 effect 中清理旧 id 与失败信息:

ts 复制代码
const selected = selection.scope === scope ? selection.ids : []

useEffect(() => {
  if (scope !== selection.scope) {
    setFailures([])
    setSelection({ scope, ids: [] })
  }
}, [scope, selection.scope])

表格的"全选"也只遍历当前页 pageKeys,不会暗中选中缓存里的其他页。跨页全选当然可以实现,但需要服务端提供明确的查询批量操作语义,并在界面上告诉用户"选中当前筛选下的全部 N 条"。用前端 id 数组假装跨页全选,既不完整,也容易误操作。

适用边界

URL 很适合离散、可序列化的查询条件,却不适合大型草稿、敏感信息和短暂 UI 动画。高级筛选条件非常多时,可以把它们编码为稳定 JSON 或保存为服务端视图,但仍要给 URL 一个能够恢复的视图标识。

浏览器历史策略也要按场景选择。面向消费者的商品列表,用户可能希望后退时逐步撤销筛选;高频后台操作使用 replace 往往更顺畅。这不是技术上的唯一答案。

小结

列表状态进入 URL 后,刷新、分享、返回和浏览器导航自动拥有了共同语义。共享 hook 负责校验参数、合并写入、筛选回首页和清空规则;搜索 hook 区分 URL 恢复与用户输入;分页器再根据服务端结果夹正页码。

各位看官,一个可靠列表页的标准,不只是能显示数据。请在第 4 页加上筛选和关键词,进入编辑页再返回,然后刷新浏览器。所有条件仍在,用户才算真正回到了原来的工作现场。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
FungLeo1 天前
成为全栈·React 管理后台篇·服务端状态、会话状态、界面状态:不要都塞进 Zustand
react·状态管理·zustand·react hook form·成为全栈·tanstack query
FungLeo1 天前
成为全栈·React 管理后台篇·前端鉴权闭环:内存令牌、刷新旋转与路由守卫
react·路由守卫·zustand·刷新令牌·成为全栈·前端鉴权
余槐i2 天前
从useState到Agent状态:React状态管理为何在AI工作流中失灵?
react·状态管理·ai agent·langgraph·tool calling
liangshanbo12152 天前
React useTransition 和 useDeferredValue 面试题整理
react·usetransition
梦想的颜色2 天前
【AI科普】AI 时代,纯 H5+CSS PK React & Vue:前端技术孰优孰劣深入剖析
ai·前端框架·大模型·vue·react·html5·vibecoding
liangshanbo12153 天前
面试题:React 中 useReducer 和 useState 有什么区别?什么时候应该使用 useReducer?
react·usereducer
FungLeo3 天前
成为全栈·React 管理后台篇·后台骨架:布局、数据路由与分层守卫
react·管理后台·权限控制·react router·前端路由·成为全栈
秋秋小事3 天前
React hooks总览
react
FungLeo4 天前
成为全栈·Node 后端篇·点赞系统:幂等点赞与计数原子增减
node.js·成为全栈·点赞系统·幂等点赞·计数原子增减