中后台列表页的路由参数设计:该放路径、query 还是组件状态
列表页的筛选条件放在哪里,本质是一个状态归属问题:它到底是组件内部的临时交互状态,还是应该被外部感知、能分享、能刷新后还原的页面状态。判断标准并不复杂——如果这个状态需要支持分享链接、支持浏览器前进后退、需要被搜索引擎理解,它就不该只活在 useState 里,而应该住进 URL。
一个订单管理页面能很直观地暴露这个问题。顶上一排筛选:状态、时间范围、关键词、分页,如果全部放进组件的 useState,功能表面上跑得好好的,但两个真实场景会立刻让它露馅。运营想把“某个时间段里所有失败订单”这个筛选结果发给同事,链接发过去打开却是空的——因为筛选条件根本没在 URL 里,接收方拿到的只是一个不带任何上下文的裸页面。另一个场景是用户在列表第 3 页点开详情、看完点浏览器返回,本该回到第 3 页、筛选条件不变,实际却回到了第 1 页、筛选也清空了。这两个现象指向同一个结论:列表页的筛选条件不是组件内部状态,它是页面状态,而页面状态的归宿是 URL。
先想清楚每个参数该放哪
参数设计不是写代码时顺手决定的事,而应该先分类,这个分类后来成了每个列表页的默认动作:
- 代表资源身份的,放路径。比如
/orders/123里的123,它稳定、可分享、搜索引擎也能理解。 - 代表“同一个页面的不同视图”的,放 query。筛选、排序、分页都属于这类,比如
/orders?status=failed&page=2。 - 不适合暴露、或者只在当前交互里有意义的,留在组件状态或服务端。比如弹窗里的临时编辑内容、敏感字段、体积很大的对象。
订单列表的那排筛选,显然全属于第二类。把它们搬进 query 之后,分享、刷新、回退三个问题一起消失了——因为 URL 本身变成了可复现的状态快照。
还有一类经常被误放进 query 的参数其实该用 hash(#)。hash 片段不会被发送到服务端,getServerSideProps/服务端组件读不到它,浏览器原生的锚点定位、tab 切换这类纯客户端交互更适合放在这里,比如 /settings#security 定位到某个设置分区。判断标准是:这个状态服务端要不要感知。服务端需要用它来决定返回什么内容(筛选、排序、分页都要影响接口请求),就该用 query;纯客户端用来定位或切换视图、服务端完全不关心,用 hash 更干净,也不会污染服务端日志里的请求参数。
从 URL 读参数,不能假设它是干净的
把参数交给 URL,也意味着要开始防着它。URL 是用户能直接改的,page=-1、page=abc、甚至同名参数出现两次,都得当成正常输入来处理,不能假设它总是合法的。
一个典型的坑是 router.query 里的值可能是字符串,也可能是数组。访问 /orders?status=failed&status=paid,status 就会变成 ['failed','paid'],按字符串处理的代码直接就会挂掉。固定封两个小函数兜底是常见做法:
1function firstQueryValue(value) { 2 return Array.isArray(value) ? value[0] : value 3} 4 5function parsePage(value) { 6 const page = Number(firstQueryValue(value) || 1) 7 return Number.isInteger(page) && page > 0 ? page : 1 8}
不复杂,但它把“URL 不可信”这件事固化进了代码,省得在每个页面到处补 Array.isArray 和 isNaN 判断。
更彻底的做法是把整页的 query 一次性解析成一个强类型对象,页面里不再直接碰 router.query。用 zod 写一个 schema,能顺手把默认值、范围、白名单全管起来:
1import { z } from 'zod' 2 3const OrderListQuery = z.object({ 4 status: z.enum(['all', 'pending', 'paid', 'failed']).catch('all'), 5 keyword: z.string().trim().max(64).catch(''), 6 page: z.coerce.number().int().positive().catch(1), 7 pageSize: z.coerce.number().int().min(10).max(100).catch(20), 8 sort: z.enum(['createdAt', '-createdAt', 'amount', '-amount']).catch('-createdAt'), 9}) 10 11// router.query 里数组取第一个,再交给 schema 12function normalizeQuery(raw: Record<string, string | string[] | undefined>) { 13 const flat = Object.fromEntries( 14 Object.entries(raw).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]), 15 ) 16 return OrderListQuery.parse(flat) // 注意全用了 .catch,永远不会 throw 17}
.catch() 这个 API 表示“解析失败就退回默认值,绝不抛错”。这正是 URL 参数该有的姿态:用户乱填不该让页面白屏,而是悄悄回到一个合法状态。z.coerce.number() 还顺手把 "2" 这种字符串转成数字,省掉手动 Number() 的步骤。这一层加进来之后,页面组件拿到的永远是 { status, keyword, page, pageSize, sort } 这种干净结构,心智负担一下就轻了。
还有一点容易忽略:在客户端首次渲染时,参数不一定立刻就绪(静态优化的页面尤其如此)。依赖参数去请求数据时,更稳妥的做法是在服务端阶段就把它读出来、校验完、不合法直接 notFound,而不是让用户先看到一个半成品页面再在客户端跳来跳去。
push 还是 replace,决定了用户的返回键
参数搬进 URL 之后,还有一个细节要处理:修正非法参数时该用哪种跳转。
访问 /orders?page=-1 需要被纠正成第一页。如果用 push,这个非法 URL 会进历史记录,用户点返回又回到 page=-1,陷进一个尴尬的循环。这种“补默认值、纠正参数、登录后重定向”的场景,应该用 replace:
1router.replace({ pathname: router.pathname, query: { ...router.query, page: 1 } })
判断标准可以记成一句话:这次跳转用户以后会想“返回”到吗?想,就 push;不想,就 replace。
这个判断标准背后有更底层的机制。push/replace 本质上是浏览器 History API 的封装,history.pushState 往会话历史栈里压一条,history.replaceState 原地改当前那条。Next.js 的 router 只是在它上面套了数据预取和组件更新。理解这层就能解释一个常见现象:为什么连续改筛选条件、再点返回,有时候要点好几下才退出页面——如果每改一个筛选项都 push 了一次,历史栈里就会堆一长串只差一个参数的 URL。
正确做法是把“同一轮筛选交互”里的连续修改用 replace 合并,只有用户明确切换到一个值得返回的视图时才 push。列表页的筛选逻辑基本都可以这么写:
1function updateFilter(patch: Partial<OrderListQuery>) { 2 const next = { ...router.query, ...patch } 3 // 改筛选项时回到第 1 页,否则会出现“第 5 页但筛选后只有 2 页”的空列表 4 if ('status' in patch || 'keyword' in patch) next.page = 1 5 // 调筛选用 replace,避免历史栈被一堆中间态塞满 6 router.replace({ pathname: router.pathname, query: next }, undefined, { shallow: true }) 7}
shallow: true 是另一个容易被忽略的细节。默认情况下 Next.js 的 pages router 改 query 会重跑 getServerSideProps,意味着每敲一个关键词字符就打一次服务端。加上 shallow 后 URL 变了但不重新跑数据获取,改为在 useEffect 里监听 query 做防抖请求,体验和服务端压力都会好一截。需要注意 shallow 只在同一个页面组件内有效,跨路由它会自动失效,这一点官方文档写得很明确。
哪些数据不该塞进 URL
同样值得警惕的是把不可信数据当成可信数据处理。结算流程如果图省事把整个订单对象 JSON 序列化塞进 query:
1router.push(`/checkout?payload=${encodeURIComponent(JSON.stringify(order))}`)
demo 里跑得飞快,上线后问题会一个接一个冒出来:URL 长到被某些网关截断、用户改几个字段金额就跟着变了、刷新后状态和服务端对不上。本质问题是把“可信数据”交给了一个用户随便能改的地方——query 是纯客户端可写的文本,任何签名、任何服务端校验都没经过,浏览器地址栏改一个字符就能伪造。
更稳妥的做法是 URL 里只放一个短 ID,目标页面拿 ID 去服务端读,并且在服务端做权限校验:
1/checkout?orderId=202409070001
这样即使用户去改 URL 里的 ID,也只会撞到权限校验,看不到别人的订单。token、手机号、邮箱这类敏感信息,以及大对象、不可篡改的数据,都不该往 URL 里放。URL 适合存“可分享、可篡改也不要紧”的视图状态,仅此而已。
两套编码规则打架:URL 里的空格该是 %20 还是 +
URL 参数的编码方式看起来只有一种,实际上至少有两套规则在打架,这是排查“关键词搜索结果时好时坏”这类问题最容易被忽略的根因。
encodeURIComponent 遵循的是 RFC 3986/JavaScript 的 URI 编码规则,空格编码成 %20;而 application/x-www-form-urlencoded(表单提交、URLSearchParams 的历史渊源)编码规则里,空格习惯编码成 +。同一个中文关键词“退款 申请”,如果代码里一处用 encodeURIComponent 拼接、另一处用了字符串直接拼接或者表单序列化,就会产生 ?keyword=退款%20申请 和 ?keyword=退款+申请 两种不同的 URL 文本。后端如果只用一套统一的解码逻辑去解,必然有一半解错——+ 在 URI 编码规则里就是字面意义上的加号,不会被当成空格;反过来 %20 在表单解码规则里也不会被特殊处理。如果链路中间还经过一层按 URL 文本做 key 的缓存网关,问题会更隐蔽:?keyword=退款+申请 和 ?keyword=退款%20申请 会被当成两个不同的缓存 key 各自命中,同一个语义上完全相同的搜索词,缓存结果却时好时坏。
排查这类问题的关键是先确认链路里到底有几处在拼 URL、各自用的是哪套编码规则,而不是先怀疑后端逻辑。彻底的解法是把 URL 拼接收口,只允许走一个函数,统一编码规则:
1// 只用这一个出口拼 query,杜绝手写字符串拼接 2function buildUrl(pathname: string, params: Record<string, string | number | undefined>) { 3 const usp = new URLSearchParams() 4 for (const [k, v] of Object.entries(params)) { 5 if (v === undefined || v === '') continue // 空值不进 URL,避免 ?keyword= 这种脏参数 6 usp.set(k, String(v)) // URLSearchParams 内部统一用 %20,且自动处理特殊字符 7 } 8 const qs = usp.toString() 9 return qs ? `${pathname}?${qs}` : pathname 10}
URLSearchParams 这个内置对象其实是解决这类问题最省心的方式——它 set 时按 application/x-www-form-urlencoded 规则编码,但 toString 输出的是 %20 而非 +(这是规范里一个长期被混淆的细节:URLSearchParams 编码空格时两种写法都符合规范,但主流实现统一输出 %20),整条链路只要都经过它,编码差异就不存在了。团队规范可以定得更直接:代码里禁止出现手写的 ?a= + 变量这种拼接,一律走 URLSearchParams 或框架的对象式 API,从源头上让“两套编码规则打架”这类问题失去出现的机会。
URL 本身也有极限:长度上限和重复参数的语义
URL 长度没有统一规范上限,但现实里各处都有隐形天花板:很多 CDN、Nginx 默认 large_client_header_buffers 大概在 8K 上下,IE 时代的 2083 字符传说虽然过时但代理层仍可能截断。把整个订单 JSON 塞进 query 那种做法,挂掉的直接原因往往就是被网关按长度截了一刀,后半段参数没了,JSON.parse 直接报错。凡是可能变长的参数(多选筛选、批量 ID),都值得先评估一下最坏情况的长度,超了就改成 POST 带 body,或者后端建一个临时查询条件再返回一个短 token。
重复参数的语义也值得定清楚。?id=1&id=2 在不同框架里行为不一致:URLSearchParams.get('id') 只返回第一个 '1',getAll('id') 才给出 ['1','2'];Next.js 的 router.query.id 则直接给数组。多选场景统一用 getAll 显式表达意图,比依赖某个框架的隐式转换规则更可靠:
1const ids = new URLSearchParams(location.search).getAll('id') 2// 想要单值就明确取 get,想要多值就明确取 getAll,绝不靠默认行为去猜
参数最长能多长、能不能重复、重复了算什么——这几个问题在本地开发和 demo 阶段几乎不会暴露,只会在真实用户输入超出预期的那一刻集中出现,所以设计阶段就该主动想清楚,而不是等问题出现再补救。
App Router 下读 query 的差异
如果项目用的是 Next.js App Router 而不是 pages router,读 query 的方式不一样,要注意的限制也不同。服务端组件里可以直接从页面的 searchParams prop 拿到解析好的对象,不需要额外 hook;但客户端组件要读 query,得用 useSearchParams(),而这个 hook 有个容易踩的限制——用到它的组件会被强制标记为需要客户端渲染的动态内容,如果外层是静态生成的页面,得用 <Suspense> 包一层,否则构建时会报错提示“应该被 Suspense 边界包裹”。
1'use client' 2import { useSearchParams } from 'next/navigation' 3 4function OrderFilterBar() { 5 const searchParams = useSearchParams() 6 const status = searchParams.get('status') ?? 'all' 7 // ... 8} 9 10// 外层页面需要用 Suspense 包住依赖 useSearchParams 的部分 11export default function OrdersPage() { 12 return ( 13 <Suspense fallback={<FilterBarSkeleton />}> 14 <OrderFilterBar /> 15 </Suspense> 16 ) 17}
另外 useSearchParams() 返回的是一个只读的 ReadonlyURLSearchParams,改参数得自己拼好新的查询字符串再配合 useRouter().push/replace 跳转,不能直接对它 .set()。这一层写多了容易变成样板代码,社区里 nuqs 这类库把“状态放 URL”封装成和 useState 几乎一样的调用方式,值得在参数交互复杂的列表页评估一下,能省掉不少手写的序列化/反序列化逻辑。
App Router 还有一个和 pages router 不同的坑:改 query 时如果用 router.push('?page=2') 这种字符串写法,得留意它是相对当前路径解析的,写错前缀容易跳到意料之外的路由。更稳的是先用 URLSearchParams 基于当前参数改出新串,再拼上 pathname 一起跳,和前面 buildUrl 那套收口逻辑正好能复用。还有 router.replace 在 App Router 里默认会滚动到页面顶部,列表页翻页时这个行为往往不想要,得显式传 { scroll: false } 把它关掉——否则用户每翻一页视口都被拽回顶部,体验很割裂。这类差异不多,但都属于“本地点几下发现不对劲、翻文档才知道有个选项”的类型,提前知道能省一轮排查。
URL 是唯一真相,输入框只是它的投影
参数住进 URL 之后,还有一个反过来的方向容易被忽略:URL 变了,页面上的受控组件要不要跟着变。很多列表页把筛选值同时存了两份——一份在 useState 里驱动输入框,一份写进 URL——两份各走各的,迟早对不上。用户从收藏夹打开一个带 ?keyword=退款&status=failed 的链接,输入框却是空的、下拉框停在“全部”,因为初始化时没人拿 URL 去回填这些控件。
更干净的模型是把 URL 当成唯一真相(single source of truth),输入框只是它的一个投影:控件的值永远从 router.query 派生,用户操作只负责改 URL,改完 URL 再驱动控件重渲染。这样就不存在“两份状态同步”的问题,因为压根只有一份:
1function OrderFilters() { 2 const router = useRouter() 3 const query = normalizeQuery(router.query) // 前面那个 zod schema 4 // 输入框的值直接来自 URL,不再单独存一份 useState 5 const [draft, setDraft] = useState(query.keyword) 6 7 // URL 变了(比如点了返回、外部链接进来),把草稿同步回来 8 useEffect(() => { setDraft(query.keyword) }, [query.keyword]) 9 10 const commit = useMemo( 11 () => debounce((kw: string) => updateFilter({ keyword: kw }), 300), 12 [], 13 ) 14 return ( 15 <input 16 value={draft} 17 onChange={(e) => { setDraft(e.target.value); commit(e.target.value) }} 18 /> 19 ) 20}
这里保留一个本地 draft 是有意的:关键词是逐字符输入的,如果每敲一下都立刻改 URL、再从 URL 派生回输入框,光标位置和中文输入法的候选态都会被打乱。所以受控输入用本地 draft 顶着即时反馈,防抖之后才把稳定值 commit 进 URL——即时态归组件,稳定态归 URL,各司其职。对于下拉、单选这种一步到位的控件就不需要 draft,直接从 query 读、onChange 里 updateFilter 就行。
顺带一个容易踩的时序坑:防抖函数要用 useMemo(或 useRef)稳定住,别在每次渲染里新建一个 debounce(...),否则每次渲染都是一个全新的防抖实例,等于没防抖。这类 bug 表现为“输入停顿后请求还是一串一串地发”,排查时容易先怀疑后端,其实是前端每帧都在重置计时器。
分页、排序这些“视图态”也要一起进 URL
筛选说清楚了,容易被落下的是分页和排序——它们和筛选是同一类东西,都属于“同一份数据的不同视图”,理应一起住进 URL,但实际项目里经常只把关键词放进去,页码却留在组件 state 里,于是“分享第 3 页”“返回还在第 3 页”这些需求又回到了最初的困境。
分页进 URL 之后有个连带的判断要想清楚:改筛选条件时页码该怎么办。前面 updateFilter 里那句 next.page = 1 就是为这个——用户在第 5 页把状态从“全部”改成“已失败”,失败订单可能只有 2 页,如果不重置页码,接口会拿着 page=5 去查一个只有 2 页的结果集,返回空列表,用户看到的是“筛选之后什么都没有”,很容易误判成没数据。筛选变化必须重置页码,排序变化则通常不用——排序只改顺序不改总数,停在第 5 页依然有效。
排序参数还有个编码上的小设计值得一提:与其用两个参数 sortBy=amount&order=desc,不如合成一个带符号的 sort=-amount,前面 schema 里就是这么定的。单参数的好处是:白名单能一次性用 z.enum 枚举全部合法组合,非法值直接 .catch 回默认,不用再单独校验 order 只能是 asc/desc;URL 也更短。这类小取舍单看无所谓,但列表页的参数会随着需求不断加,早点定好“一个语义一个参数、能合就合”的规矩,后面维护会轻很多。
参数即缓存键:URL 变了,数据请求怎么跟
参数进了 URL,数据请求就应该跟着 URL 走,而不是自己另攒一套触发逻辑。用 SWR 或 React Query 这类库时,最省心的接法是直接拿归一化后的 query 当缓存键——参数一变,键就变,库自动重新请求,参数变回去又能命中之前的缓存,前进后退几乎是零延迟的:
1const query = normalizeQuery(router.query) 2const { data, isLoading } = useSWR( 3 ['/api/orders', query], // 数组键,query 是干净的强类型对象 4 ([url, q]) => fetchOrders(url, q), 5)
这里能直接把 query 塞进缓存键,恰恰得益于前面那层 zod 归一化——键必须是稳定、可比较的。如果直接拿 router.query 当键,status=failed 和 status=failed&status=paid(数组)会算出不同的键,或者参数顺序不同导致键不同,缓存命中率就废了。归一化之后,键的形状永远一致,缓存才真的可预测。这也反过来印证了一件事:把 URL 解析收口成一个纯函数,收益不止是校验,它让 URL 成了整条数据链路可靠的输入。
有一处时序要留意:router.query 在客户端首屏是分两拍就绪的。Next.js 的 pages router 在静态优化的页面上,第一次渲染时 router.query 是空对象 {},要等 router.isReady 为 true 之后才填好真实参数。如果不管这个直接拿空 query 去请求,会先打一次“无参数”的请求、参数就绪后再打一次,白白多一轮,还可能闪一下错误的默认视图。稳妥的做法是等 isReady 再触发请求,或者干脆在服务端就把参数读出来作为初始数据传下去。
一个参数改造带出的连锁收益
把这一整套做下来——分类、zod 归一、replace/shallow、URL 当缓存键——最直接的感受是,很多原本零散的需求会一起被满足。分享链接能用了,因为状态在 URL 里;前进后退符合预期了,因为历史栈没被中间态塞满;刷新不丢筛选了,因为参数不在内存里;甚至埋点也变简单了,因为“用户在什么筛选条件下做了什么”本身就写在 URL 上,不用额外记一份上下文。
反过来,如果一开始图省事把筛选全塞进 useState,这些需求会在不同时间点、由不同的人各自提出来,然后各自打补丁:分享需求来了写一段序列化、SEO 需求来了再补一段、回退问题来了又改一处,最后代码里散落着好几套彼此不一致的“状态往哪放”的逻辑。参数设计的价值就在这里——它不是某个功能,而是一层地基,地基铺对了,上面的需求大多是顺着来的。
参数错了,错误结构也要统一
列表页和它背后的接口是一起的。参数校验不过时,接口返回的错误最好也长一个样,比如统一成 { error: { code, message } }。各接口错误格式各异是个常见的历史包袱——有的返回字符串,有的 { message },有的 { errors: [] }——前端的错误提示组件为了兼容这些形态往往写得又长又乱。入口统一校验、出口统一结构,这两件事配套做,列表页才算真的稳。
路由传参不是“怎么把值拿出来”的语法问题,它是页面信息架构的一部分。参数设计得清楚,刷新、分享、回退、SEO、埋点都会顺;设计得随意,后面通常会在权限、缓存和错误处理上慢慢还债。核心原则就一句话:先决定每个参数住在哪里,再决定怎么读它。