Next.js 的 useQueryState
Next.js 的 useQueryState 钩子 - 类似 React.useState,但存储在 URL 查询字符串中
特性
- 🔀 同时支持
app
和pages
路由器 - 🧘♀️ 简单:URL 是唯一真实来源
- 🕰 替换历史记录或追加以使用后退按钮导航状态更新
- ⚡️ 内置常见状态类型的解析器(整数、浮点数、布尔值、日期等)
- ♊️ 使用
useQueryStates
关联查询字符串 - 📡 URL 查询更新默认使用浅层模式,可选择通知服务器组件
- 🗃 新功能:服务器缓存,用于在嵌套服务器组件中类型安全地访问 searchParams
- ⌛️ **新功能:**支持
useTransition
以获取服务器更新的加载状态
安装
pnpm add nuqs
yarn add nuqs
npm install nuqs
注意:包正在更名为:
nuqs
:tada:1.x 版本也将在
next-usequerystate
下可用, 但 2.x 及以后版本将只在nuqs
下发布。
我应该使用哪个版本?
Next.js 版本范围 | 支持的 nuqs / next-usequerystate 版本 |
---|---|
>=14.0.4 | nuqs@latest |
14.0.3 | nuqs@latest ,需启用 windowHistorySupport 实验性标志,详见 #417 |
14.0.2 | 不兼容,详见问题 #388 和 Next.js PR #58297 |
>= 13.1 && <= 14.0.1 | nuqs@latest |
< 13.1 | next-usequerystate@1.7.3 |
使用方法
'use client' // app 路由器:仅在客户端组件中有效
import { useQueryState } from 'nuqs'
export default () => {
const [name, setName] = useQueryState('name')
return (
<>
<h1>你好,{name || '匿名访客'}!</h1>
<input value={name || ''} onChange={e => setName(e.target.value)} />
<button onClick={() => setName(null)}>清除</button>
</>
)
}
文档
useQueryState
需要一个必要参数:在查询字符串中使用的键。
与 React.useState
类似,它返回一个数组,包含查询字符串中的值(字符串类型,如果未找到则为 null
)和一个状态更新函数。
我们的 Hello World 示例的输出示例:
URL | name 值 | 备注 |
---|---|---|
/ | null | URL 中没有 name 键 |
/?name= | '' | 空字符串 |
/?name=foo | 'foo' | |
/?name=2 | '2' | 默认总是返回字符串,参见下面的解析部分 |
解析
如果你的状态类型不是字符串,你必须在第二个参数对象中传递一个解析函数。
我们为常见和更高级的对象类型提供了解析器:
import {
parseAsString,
parseAsInteger,
parseAsFloat,
parseAsBoolean,
parseAsTimestamp,
parseAsIsoDateTime,
parseAsArrayOf,
parseAsJson,
parseAsStringEnum,
parseAsStringLiteral,
parseAsNumberLiteral
} from 'nuqs'
useQueryState('tag') // 默认为字符串
useQueryState('count', parseAsInteger)
useQueryState('brightness', parseAsFloat)
useQueryState('darkMode', parseAsBoolean)
useQueryState('after', parseAsTimestamp) // 状态为 Date 类型
useQueryState('date', parseAsIsoDateTime) // 状态为 Date 类型
useQueryState('array', parseAsArrayOf(parseAsInteger)) // 状态为 number[] 类型
useQueryState('json', parseAsJson<Point>()) // 状态为 Point 类型
// 枚举(仅限字符串类型)
enum Direction {
up = 'UP',
down = 'DOWN',
left = 'LEFT',
right = 'RIGHT'
}
const [direction, setDirection] = useQueryState(
'direction',
parseAsStringEnum<Direction>(Object.values(Direction)) // 传递允许的值列表
.withDefault(Direction.up)
)
// 字面量(仅限字符串类型)
const colors = ['red', 'green', 'blue'] as const
const [color, setColor] = useQueryState(
'color',
parseAsStringLiteral(colors) // 传递只读的允许值列表
.withDefault('red')
)
// 字面量(仅限数字类型)
const diceSides = [1, 2, 3, 4, 5, 6] as const
const [side, setSide] = useQueryState(
'side',
parseAsNumberLiteral(diceSides) // 传递只读的允许值列表
.withDefault(4)
)
您可以传入自定义的 parse
和 serialize
函数:
import { useQueryState } from 'nuqs'
export default () => {
const [hex, setHex] = useQueryState('hex', {
// TypeScript 将根据 `parse` 返回的内容自动推断它是一个数字
parse: (query: string) => parseInt(query, 16),
serialize: value => value.toString(16)
})
}
在服务器组件中使用解析器
注意:关于在服务器组件中实现类型安全的更友好方式,请参阅在服务器组件中访问 searchParams 部分。
如果您希望在服务器组件中解析 searchParams,您需要从 nuqs/server
导入解析器,这个模块不包含 "use client"
指令。
然后您可以使用 parseServerSide
方法:
import { parseAsInteger } from 'nuqs/server'
type PageProps = {
searchParams: {
counter?: string | string[]
}
}
const counterParser = parseAsInteger.withDefault(1)
export default function ServerPage({ searchParams }: PageProps) {
const counter = counterParser.parseServerSide(searchParams.counter)
console.log('服务器端计数器: %d', counter)
return (
...
)
}
查看服务器端解析演示以获取一个展示如何在客户端和服务器代码之间重用解析器配置的实时示例。
注意:解析器不会验证您的数据。如果您期望正整数或特定形状的 JSON 编码对象,您需要将解析器的结果传递给模式验证库,比如 Zod。
默认值
当 URL 中不存在查询字符串时,默认行为是返回 null
作为状态。
这可能会使状态更新和 UI 渲染变得繁琐。看看这个存储在 URL 中的简单计数器示例:
import { useQueryState, parseAsInteger } from 'nuqs'
export default () => {
const [count, setCount] = useQueryState('count', parseAsInteger)
return (
<>
<pre>计数: {count}</pre>
<button onClick={() => setCount(0)}>重置</button>
{/* 在 setCount 中处理 null 值很烦人: */}
<button onClick={() => setCount(c => c ?? 0 + 1)}>+</button>
<button onClick={() => setCount(c => c ?? 0 - 1)}>-</button>
<button onClick={() => setCount(null)}>清除</button>
</>
)
}
您可以指定在这种情况下要返回的默认值:
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
const increment = () => setCount(c => c + 1) // c 永远不会是 null
const decrement = () => setCount(c => c - 1) // c 永远不会是 null
const clearCount = () => setCount(null) // 从 URL 中移除查询
注意:默认值是 React 内部的,它不会被写入 URL。
将状态设置为 null
将从查询字符串中移除该键,并将状态设置为默认值。
选项
历史记录
默认情况下,状态更新是通过用更新后的查询替换当前历史记录条目来完成的。
您可以将此视为 git squash
的一种形式,其中所有更改状态的操作都合并到单个历史记录值中。
您也可以选择为每个状态更改推送一个新的历史记录项,按键进行,这将让您使用后退按钮来导航状态更新:
// 默认:用新状态替换当前历史记录
useQueryState('foo', { history: 'replace' })
// 将状态更改追加到历史记录:
useQueryState('foo', { history: 'push' })
history
选项的任何其他值都会回退到默认值。
您也可以在调用状态更新函数时覆盖历史记录模式:
const [query, setQuery] = useQueryState('q', { history: 'push' })
// 这会覆盖钩子声明设置:
setQuery(null, { history: 'replace' })
浅更新
默认情况下,查询状态更新以 客户端优先 的方式进行:不会向服务器发出网络调用。
这相当于将 Next.js pages 路由器的 shallow
选项设置为 true
,或在 app 路由器中使用实验性的 windowHistorySupport
标志。
要选择让查询更新通知服务器(在 pages 路由器中重新运行 getServerSideProps
,在 app 路由器中重新渲染服务器组件),您可以将 shallow
设置为 false
:
const [state, setState] = useQueryState('foo', { shallow: false })
// 您也可以在调用 setState 时传递选项:
setState('bar', { shallow: false })
滚动
Next.js 路由器在导航更新时会滚动到页面顶部,这在使用本地状态更新查询字符串时可能不太理想。
默认情况下,查询状态更新不会滚动到页面顶部,但您可以选择启用此行为(这在 1.8.0 版本之前是默认行为):
const [state, setState] = useQueryState('foo', { scroll: true })
// 您也可以在调用 setState 时传递选项:
setState('bar', { scroll: true })
限制 URL 更新频率
由于浏览器对历史 API 的速率限制,内部对 URL 的更新会被排队并限制在默认的 50ms 内,这似乎能满足大多数浏览器的需求,即使在发送高频率的查询更新时,比如绑定到文本输入或滑块。
Safari 的速率限制要高得多,需要大约 340ms 的限制。如果您最终需要更长的更新间隔时间,可以在选项中指定:
useQueryState('foo', {
// 最多每秒向服务器发送一次更新
shallow: false,
throttleMs: 1000
})
// 您也可以在调用 setState 时传递选项:
setState('bar', { throttleMs: 1000 })
注意:钩子返回的状态始终会立即更新,以保持 UI 的响应性。只有对 URL 的更改和使用
shallow: false
时的服务器请求会被限制频率。
如果多个钩子在同一个事件循环tick中设置了不同的限制值,将使用最高的值。此外,低于 50ms 的值将被忽略,以避免速率限制问题。了解更多。
过渡
当与 shallow: false
结合使用时,您可以使用 useTransition
钩子来获取加载状态,以在服务器使用更新后的 URL 重新渲染服务器组件时显示。
将 useTransition
的 startTransition
函数传入选项中以启用此行为(这会自动为你设置 shallow: false
):
'use client'
import React from 'react'
import { useQueryState, parseAsString } from 'nuqs'
function ClientComponent({ data }) {
// 1. 提供你自己的 useTransition 钩子:
const [isLoading, startTransition] = React.useTransition()
const [query, setQuery] = useQueryState(
'query',
// 2. 将 `startTransition` 作为选项传入:
parseAsString().withOptions({ startTransition })
)
// 3. 当通过 `setQuery` 更新查询时,
// 服务器重新渲染并流式传输 RSC 负载期间,`isLoading` 将为 true。
// 显示加载状态
if (isLoading) return <div>加载中...</div>
// 使用数据进行正常渲染
return <div>{/*...*/}</div>
}
配置解析器、默认值和选项
你可以使用构建器模式来方便地指定所有这些内容:
useQueryState(
'counter',
parseAsInteger.withDefault(0).withOptions({
history: 'push',
shallow: false
})
)
你也可以为自定义解析器获得这种模式,并与其他解析器组合:
import { createParser, parseAsHex } from 'nuqs'
// 将你的解析器/序列化器包装在 `createParser` 中
// 可以使其获得构建器模式和服务器端解析能力:
const hexColorSchema = createParser({
parse(query) {
if (query.length !== 6) {
return null // 对于无效输入始终返回 null
}
return {
// 当组合其他解析器时,它们也可能返回 null。
r: parseAsHex.parse(query.slice(0, 2)) ?? 0x00,
g: parseAsHex.parse(query.slice(2, 4)) ?? 0x00,
b: parseAsHex.parse(query.slice(4)) ?? 0x00
}
},
serialize({ r, g, b }) {
return (
parseAsHex.serialize(r) +
parseAsHex.serialize(g) +
parseAsHex.serialize(b)
)
}
})
// 例如:直接设置常用选项
.withOptions({ history: 'push' })
// 或在使用时设置:
useQueryState(
'tribute',
hexColorSchema.withDefault({
r: 0x66,
g: 0x33,
b: 0x99
})
)
注意:可以在 hex-colors 演示 中查看此示例的运行情况。
多个查询(批处理)
你可以在单个事件循环中调用多个状态更新函数,它们将异步应用到 URL:
const MultipleQueriesDemo = () => {
const [lat, setLat] = useQueryState('lat', parseAsFloat)
const [lng, setLng] = useQueryState('lng', parseAsFloat)
const randomCoordinates = React.useCallback(() => {
setLat(Math.random() * 180 - 90)
setLng(Math.random() * 360 - 180)
}, [])
}
如果你想知道 URL 何时更新以及包含什么内容,可以等待状态更新函数返回的 Promise,它会给你更新后的 URLSearchParameters 对象:
const randomCoordinates = React.useCallback(() => {
setLat(42)
return setLng(12)
}, [])
randomCoordinates().then((search: URLSearchParams) => {
search.get('lat') // 42
search.get('lng') // 12,已被排队并批量更新
})
实现细节(Promise 缓存)
返回的 Promise 会被缓存,直到下一次刷新 URL 发生。因此,在同一事件循环中对任何钩子的 setState 的所有调用都将返回相同的 Promise 引用。
由于对 Web History API 的调用进行了节流,Promise 可能会被缓存多个事件循环。批处理更新将被合并并一次性刷新到 URL。这意味着如果在刷新发生之前有另一个更新覆盖了它,并非每个 setState 都会反映到 URL 上。
返回的 React 状态会立即反映所有设置的值,以保持 UI 的响应性。
useQueryStates
对于应该始终一起移动的查询键,你可以使用 useQueryStates
和一个包含每个键类型的对象:
import { useQueryStates, parseAsFloat } from 'nuqs'
const [coordinates, setCoordinates] = useQueryStates(
{
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
},
{
history: 'push'
}
)
const { lat, lng } = coordinates
// 一次性设置所有(或部分)键:
const search = await setCoordinates({
lat: Math.random() * 180 - 90,
lng: Math.random() * 360 - 180
})
在服务器组件中访问 searchParams
如果你想在深层嵌套的服务器组件中访问 searchParams(即不在 Page 组件中),你可以使用 createSearchParamsCache
以类型安全的方式实现。
注意:解析器不验证你的数据。如果你期望正整数或特定形状的 JSON 编码对象,你需要将解析器的结果传递给模式验证库,如 Zod。
// searchParams.ts
import {
createSearchParamsCache,
parseAsInteger,
parseAsString
} from 'nuqs/server'
// 注意:从 'nuqs/server' 导入以避免 "use client" 指令
export const searchParamsCache = createSearchParamsCache({
// 在这里列出你的搜索参数键和相关的解析器:
q: parseAsString.withDefault(''),
maxResults: parseAsInteger.withDefault(10)
})
// page.tsx
import { searchParamsCache } from './searchParams'
export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
// ⚠️ 不要忘记在这里调用 `parse`。
// 你可以从返回的对象中访问类型安全的值:
const { q: query } = searchParamsCache.parse(searchParams)
return (
<div>
<h1>搜索结果:{query}</h1>
<Results />
</div>
)
}
function Results() {
// 在子服务器组件中访问类型安全的搜索参数:
const maxResults = searchParamsCache.get('maxResults')
return <span>显示最多 {maxResults} 个结果</span>
}
缓存仅对当前页面渲染有效(参见 React 的 cache
函数)。
注意:缓存仅适用于服务器组件,但你可以与 useQueryStates
共享解析器声明,以在客户端组件中实现类型安全:
// searchParams.ts
import { parseAsFloat, createSearchParamsCache } from 'nuqs/server'
export const coordinatesParsers = {
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
}
export const coordinatesCache = createSearchParamsCache(coordinatesParsers)
// page.tsx
import { coordinatesCache } from './searchParams'
import { Server } from './server'
import { Client } from './client'
export default function Page({ searchParams }) {
coordinatesCache.parse(searchParams)
return (
<>
<Server />
<Suspense>
<Client />
</Suspense>
</>
)
}
// server.tsx
import { coordinatesCache } from './searchParams'
export function Server() {
const { lat, lng } = coordinatesCache.all()
// 或单独访问键:
const lat = coordinatesCache.get('lat')
const lng = coordinatesCache.get('lng')
return (
<span>
纬度: {lat} - 经度: {lng}
</span>
)
}
// client.tsx
// prettier-ignore
;'use client'
import { useQueryStates } from 'nuqs'
import { coordinatesParsers } from './searchParams'
export function Client() {
const [{ lat, lng }, setCoordinates] = useQueryStates(coordinatesParsers)
// ...
}
序列化器辅助函数
为了用状态值填充 <Link>
组件,你可以使用 createSerializer
辅助函数。
将描述搜索参数的对象传递给它,它会给你一个函数,你可以用值调用该函数,生成一个像钩子那样序列化的查询字符串。
示例:
import {
createSerializer,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
parseAsStringLiteral
} from 'nuqs/server'
const searchParams = {
search: parseAsString,
limit: parseAsInteger,
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
sortBy: parseAsStringLiteral(['asc', 'desc'] as const)
}
// 通过传递要接受的搜索参数的描述来创建一个序列化函数
const serialize = createSerializer(searchParams)
// 然后,传递一些值(子集)并将它们渲染成查询字符串
serialize({
search: 'foo bar',
limit: 10,
from: new Date('2024-01-01'),
// 这里我们省略 `to`,它不会被添加
sortBy: null // null 值也不会被渲染
})
// ?search=foo+bar&limit=10&from=2024-01-01T00:00:00.000Z
基础参数
返回的 serialize
函数可以接受一个基础参数,在此基础上附加/修改搜索参数:
serialize('/path?baz=qux', { foo: 'bar' }) // /path?baz=qux&foo=bar
const search = new URLSearchParams('?baz=qux')
serialize(search, { foo: 'bar' }) // ?baz=qux&foo=bar
const url = new URL('https://example.com/path?baz=qux')
serialize(url, { foo: 'bar' }) // https://example.com/path?baz=qux&foo=bar
// 传递 null 会删除现有值
serialize('?remove=me', { foo: 'bar', remove: null }) // ?foo=bar
测试
目前,测试使用 useQueryState(s)
的组件行为的最佳方式是端到端测试,使用像 Playwright 或 Cypress 这样的工具。
在隔离环境中运行使用 Next.js 路由器的组件需要模拟它,这正在为应用路由器开发中。
有关更多与测试相关的讨论,请参见 issue #259。
调试
你可以通过在浏览器中将 localStorage 中的 debug
项设置为 nuqs
,并重新加载页面来启用调试日志。
// 在你的开发者工具中:
localStorage.setItem('debug', 'nuqs')
注意:与
debug
包不同,这不支持通配符,但你可以组合使用:localStorage.setItem('debug', '*,nuqs')
日志行将以 [nuqs]
作为 useQueryState
的前缀,以 [nuq+]
作为 useQueryStates
的前缀,同时还有其他内部调试日志。
用户计时标记也会被记录,用于使用浏览器的开发者工具进行高级性能分析。
在提交issue时提供调试日志总是受欢迎的。🙏
注意事项
由于 Next.js 页面路由器在 SSR 上下文中不可用,这个钩子在 SSR/SSG 上总是返回 null
(或提供的默认值)。
这个限制不适用于应用路由器。
SEO
如果你的页面使用查询字符串作为本地状态,你应该为你的页面添加一个规范 URL,以告诉 SEO 爬虫忽略查询字符串并索引没有它的页面。
在应用路由器中,这是通过元数据对象完成的:
import type { Metadata } from 'next'
export const metadata: Metadata = {
alternates: {
canonical: '/url/path/without/querystring'
}
}
然而,如果查询字符串定义了页面显示的内容(例如:YouTube 的观看 URL,如 https://www.youtube.com/watch?v=dQw4w9WgXcQ
),你的规范 URL 应该包含相关的查询字符串,你仍然可以使用 useQueryState
来读取它:
// page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { useQueryState } from 'nuqs'
import { parseAsString } from 'nuqs/server'
type Props = {
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const videoId = parseAsString.parseServerSide(searchParams.v)
return {
alternates: {
canonical: `/watch?v=${videoId}`
}
}
}
有损序列化
如果你的序列化器损失精度或不能准确表示底层状态值,当重新加载页面或从 URL 恢复状态(例如:在导航时)时,你将失去这种精度。
示例:
const geoCoordParser = {
parse: parseFloat,
serialize: v => v.toFixed(4) // 损失精度
}
const [lat, setLat] = useQueryState('lat', geoCoordParser)
在这里,设置纬度为 1.23456789 将渲染 URL 查询字符串为 lat=1.2345
,而内部 lat
状态将被正确设置为 1.23456789。
重新加载页面后,状态将被错误地设置为 1.2345。
许可证
由 François Best 用 ❤️ 制作 在工作中使用这个软件包吗?赞助我以帮助支持和维护。