在 React 应用中读取 URL 查询参数,难点不在字符串解析,而在“URL 变化后组件何时重新渲染”。正则表达式通常会漏掉重复参数、+、编码字符和空值;直接读取 window.location.search 又不会自动订阅前端路由变化。
首选路由库提供的 Hook
如果项目使用 React Router,直接使用它的 useSearchParams。路由库已经处理导航订阅,不需要自己监听 History API。
import { useSearchParams } from 'react-router-dom'
export function OrderPage() {
const [searchParams, setSearchParams] = useSearchParams()
const keyword = searchParams.get('keyword') ?? ''
const page = Number(searchParams.get('page') ?? '1')
const tags = searchParams.getAll('tag')
function goToPage(nextPage: number) {
const next = new URLSearchParams(searchParams)
next.set('page', String(nextPage))
setSearchParams(next)
}
// ...
}
URLSearchParams.getAll() 会保留 ?tag=a&tag=b 这样的重复参数。用 Object.fromEntries(searchParams) 会只留下其中一个值,不适合数组语义。
把解析和订阅分开
若多个页面需要相同的类型转换,可以写一个只负责解析的 Hook,让路由库提供当前 search 字符串:
import { useMemo } from 'react'
interface ListQuery {
keyword: string
page: number
tags: string[]
preview: boolean
}
function parsePositiveInt(
value: string | null,
fallback: number
) {
if (value === null) return fallback
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0
? parsed
: fallback
}
export function useListQuery(search: string): ListQuery {
return useMemo(() => {
const params = new URLSearchParams(search)
return {
keyword: params.get('keyword')?.trim() ?? '',
page: parsePositiveInt(params.get('page'), 1),
tags: params.getAll('tag'),
preview: params.get('preview') === '1'
}
}, [search])
}
在 React Router 中:
import { useLocation } from 'react-router-dom'
function ListPage() {
const { search } = useLocation()
const query = useListQuery(search)
// ...
}
这里把 search 字符串作为依赖,而不是依赖一个每次渲染都新建的普通对象。
不使用路由库时
浏览器的 popstate 会在历史记录前进、后退时触发,但调用 history.pushState() 或 replaceState() 本身不会触发 popstate。因此,独立应用应通过统一的导航模块发布变更,而不是让每个 Hook 都猴子补丁 history。
const LOCATION_CHANGE = 'app:location-change'
export function navigate(url: string, replace = false) {
if (replace) {
window.history.replaceState(null, '', url)
} else {
window.history.pushState(null, '', url)
}
window.dispatchEvent(new Event(LOCATION_CHANGE))
}
export function subscribeLocation(
onChange: () => void
) {
window.addEventListener('popstate', onChange)
window.addEventListener('hashchange', onChange)
window.addEventListener(LOCATION_CHANGE, onChange)
return () => {
window.removeEventListener('popstate', onChange)
window.removeEventListener('hashchange', onChange)
window.removeEventListener(LOCATION_CHANGE, onChange)
}
}
再用 React 的 useSyncExternalStore 订阅:
import { useSyncExternalStore } from 'react'
export function useLocationSearch() {
return useSyncExternalStore(
subscribeLocation,
() => window.location.search,
() => ''
)
}
服务端快照返回空字符串,避免 SSR 阶段访问 window。水合后客户端会读取真实 URL;如果服务端也需要查询参数,应由框架路由层把它作为初始数据传入。
类型转换要显式
查询参数永远来自外部输入。TypeScript 类型声明不会在运行时把字符串变成数字或枚举:
const allowedSort = new Set(['newest', 'price'])
function parseSort(params: URLSearchParams) {
const value = params.get('sort')
return value && allowedSort.has(value)
? (value as 'newest' | 'price')
: 'newest'
}
数字要检查有限性和范围,布尔值要约定编码方式,JSON 参数要捕获解析错误。敏感状态、权限和完整业务对象不应放进可复制、可记录的 URL。
MDN 参考:URLSearchParams。