React URL 查询参数:解析与路由订阅

React URL 查询参数:解析与路由订阅

在 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

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。