nuqs 的标语 “React 的类型安全的搜索参数状态管理器”,仅代表了 nuqs 在幕后所做工作的很小一部分。类型安全只是冰山一角。水面之下还有隐藏的危险,你(或者,更好地说,你的工具)需要注意这些危险。
读取 vs 写入
人们想要类型安全的 URL 状态时,首先要做的事情之一就是引入验证库(Zod、Valibot、ArkType,任何符合 Standard Schema 的库),来将 URLSearchParams 解析为可在应用中使用的有效数据类型。
这是 读取 类型安全,而且实现起来相当容易。
你可以在这里停下脚步,想知道为什么还需要另一个第三方库,直到你需要 写入 带有 复杂 状态的搜索参数。任何无法简单使用 .toString() 字符串化的内容,都需要一个 序列化 步骤,这个步骤必须与解析匹配 (数学 nerds 称此属性为 双射性)。
验证库不提供从对象或数据类型逆向转换回原始字符串的功能。为了解决这个问题,nuqs 提供了 内置解析器 来处理常见数据类型,但你也可以 创建自己的解析器,为你的复杂数据类型在 URL 中提供一个美观、紧凑的表示。
这里紧凑性是一个重要的属性:URL 有大小限制,就像本地存储或 Cookie 一样。
虽然 2000 个字符通常被认为是安全的,但 HTTP 规范允许多达 8KB 的空间,但实际限制取决于你分享 URL 的媒介,以及用户是否愿意点击非常长的链接。请记住:
URL 是用户将看到的第一个 UI 元素。请以此为出发点设计它。
自己试试看:你更愿意点击哪个链接?
- https://example.com?page=1&size=50&filters=genre:fantasy&sort=releaseYear:asc
- https://example.com?pagination=%7B%22pageIndex%22%3A1%2C%22pageSize%22%3A50%7D&filters=%5B%7B%22id%22%3A%22genre%22%2C%22value%22%3A%22fantasy%22%7D%5D&orderBy=%7B%22id%22%3A%22releaseYear%22%2C%22desc%22%3Afalse%7D
提示:什么状态放入 URL?
只将你想要 与他人分享 的状态存储在 URL 中(包括你未来的自己,通过书签和历史导航)。
仅仅因为方便跨页面重新加载持久化状态而将其放入 URL,可能会导致你过度使用这种模式。
反序列化、解析、验证
验证库仍然有一个非常重要的作用:确保你的状态是 运行时安全的。即使在反序列化为正确的数据类型后,仍可能存在你想要避免的无效 值,这些值无法仅通过静态类型表示。诸如:
- 纬度/经度坐标介于 -90/+90 或 -180/+180 之间的数字
- 以特定方式格式化的字符串(如电子邮件或 UUID)
- 大于给定纪元的日期
在读取时,验证应该在 反序列化之后 发生,在一个相对接近期望输出(由解析确保)的数据类型上。
同样,在写入时,验证应该在 序列化之前 发生,以确保无效状态不会持久化到 URL 中。
时间安全
nuqs 代码库的 80% 不涉及 类型安全,而是 时间安全。
History API(大多数路由器用于在不进行完整页面加载的情况下更新 URL)的一个鲜为人知的问题是,浏览器会对 URL 更新进行限速,出于安全原因。
调用更新太快会抛出错误,从而可能导致你的应用崩溃。
并非所有浏览器在限速方面都相同:Chrome 和 Firefox 允许调用之间大约 50ms 以确保安全,但 Safari 的限制更严格,需要调用之间大约 120ms。
这个问题在使用高频输入绑定 URL 状态时会显现,例如文本框 <input type="text"> 或滑块 <input type="range">。
你可以通过保持输入 非受控 并将 URL 更新推迟到稍后时间(在防抖超时或按钮按下之后)来解决这个问题,但受控输入有其目的。如果你想跟随外部 URL 更新来重置状态,或者 React 树的其他部分需要这个状态,可以在任何需要访问共享状态的地方调用 useQueryState(s)(就像使用 Zustand、Jotai 等全局状态管理器一样),nuqs 将 将状态 提升 到 URL 中给你。
nuqs 不要求用户代码的刚性,而是拥抱浏览器限制,并使用 限流队列 和 乐观 URL 更新 来解决时间安全问题。
这还允许 批处理 来自不同来源的状态更新,并 自动拼接 它们,这是在手动实现时最常见的痛点之一。
const [lat, setLat] = useQueryState('lat', parseAsFloat)
const [lng, setLng] = useQueryState('lng', parseAsFloat)
const randomCoordinates = () => {
// 这些将被批处理到一个单一的 URL 更新中
setLat(Math.random() * 180 - 90)
setLng(Math.random() * 360 - 180)
}当使用相关状态时,你可能想要使用 useQueryStates 以获得更好的类型安全。
即将推出的 防抖功能 使这个过程更加明显,增加了处理用户离开当前页面时中止待处理更新的额外复杂性。这可以防止陈旧的状态更新应用于错误的路径名或覆盖链接状态(最后用户操作获胜)。
不可变性
一旦你将 URL 在野外分享出去,它们就变得 不可变。但你的应用绝非不可变。
通过添加 URL 状态引入的状态性使其类似于数据库模式。每个共享 URL 都是一个不可变的数据库快照,你的应用程序需要在整个生命周期中能够处理它,以履行那些链接中编码的承诺。但这不应阻止你对应用程序接受的预期模式进行更改。
就像数据库模式一样,我们可以使用 迁移 来处理这个问题:
- 使用中间件捕获旧 URL
- 将旧状态模式迁移到应用程序当前期望的模式
- 重定向到更新的 URL 以继续
我们已经 探索了声明式方法 来实现这一点,但这不仅仅对 nuqs 用户有益,因此它可能作为补充包实现。
// 注意:这是一个假设的 API。
const applyMigrations = createURLMigration([
{
// 将 /?hello=world 转换为 /?name=world
type: "rename-key",
from: "hello",
to: "name",
},
{
// 将 /?hello=world&bye-bye=gone 转换为 /?hello=world
type: "remove-key",
key: "bye-bye",
},
{
// 将 /?date=2022-01-01T00:00:00Z 转换为 /?date=1640995200000
type: "update-value",
key: "date",
action: (value) => {
const date = parseAsIsoDateTime.parse(value);
if (date === null) {
return null;
}
return parseAsTimestamp.serialize(date);
},
},
{
type: "custom", // 完全控制(伴随巨大力量…)
action: (request) => {
const searchParams = new URLSearchParams(request.nextUrl.searchParams);
const value = parseAsFoo.parse(searchParams.get('foo'))
searchParams.delete('foo')
searchParams.set('bar', parseAsBar.serialize(value))
return { applied: true, searchParams }
},
},
]);
export function middleware(request: NextRequest) {
const result = applyMigrations(request);
if (result.applied) {
return result.response;
}
return NextReponse.next();
}时间旅行
在更新 URL 时,你可以选择要么 替换 当前历史条目,要么 推送 一个新的(这在 nuqs 中使用 history: 'push' | 'replace' 选项 完成)。
推送一个新的历史条目允许你将浏览器的后退/前进按钮用作撤销/重做功能,这看起来像 Redux Devtools 的时间旅行状态调试。
但这是有代价的:现在你有两个可以操纵历史的更新来源:
- 触发更新的原始 UI 元素
- 后退/前进按钮
这通常在 ?modalOpen=true 状态中遇到,其中后退/前进按钮可能与关闭模态的 X 按钮冲突。根据用户是通过 UI 打开模态还是通过链接进入,Back 按钮可能有不同的行为。
破坏后退按钮 会导致令人沮丧的用户体验,正确处理它涉及几个步骤:
- 意识到用户可以 在任何状态 下进入你的应用
- 从该状态开始,他们可以使用 后退按钮或你的 UI
- 记住后退/前进是为 导航类交互 提供的契约
实验性的 Navigation API 有望在未来更容易处理这些情况。
结论
实现 URL 状态的类型安全不是终点,而是旅程的开始:URL 状态管理库和应用程序代码需要处理其他事情,以提供真正安全且持久的状态管理。
这篇文章是我在 React Paris 演讲的摘录,在这里观看更多细节和提示: