ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Sim 项目 URL 查询参数状态管理(nuqs)实战指南:把视图状态放进链接,而不是全局 Store

2026/9/10 11:51:20 拓冰建站 浏览量
Sim 项目 URL 查询参数状态管理(nuqs)实战指南:把视图状态放进链接,而不是全局 Store Sim 项目 URL 查询参数状态管理nuqs实战指南把视图状态放进链接而不是全局 Store【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim本文基于 SimGitHub_Trending/sim16/sim仓库中.claude/rules/sim-url-state.md这一规则文档系统讲解该 Next.js 多租户工作区如何使用 nuqs 管理 URL / Query-Param 状态什么时候该把状态放进 URL、什么时候该留在 React Query / Zustand / useState如何用每个特性自带的search-params.ts作为单一事实来源以及深链接、排序、日期、防抖搜索等高频场景的落地写法。读完你将掌握一套可复制的「决策表 约定 反模式」工程规范并能在源码中逐一印证。为什么 Sim 要单独立一条 URL 状态规则Sim 是协作式 AI Agent / 工作流构建平台其前端apps/sim是一个大型 Next.js App Router 应用工作区内有 Files、Tables、Knowledge、Logs、Integrations、Settings 等几十个功能面页面级视图状态激活的 Tab、筛选条件、搜索词、分页、列表/网格视图、被深链打开的实体抽屉如果各自为政很快会演化出「同一种状态三份拷贝、刷新即丢失、链接不可分享」的混乱。因此仓库在 .claude/rules/sim-url-state.md 中把 URL 查询参数状态管理固化为一条强制规则**URL query state 统一由nuqs中挂载一次位于根布局第 252–254 行的NuqsAdapter包裹处禁止重复添加。该规则文档是「什么该进 URL、怎么接线」的唯一事实来源。状态归属决策框架每个状态只选一个家规则文档给出的核心判断框架是每一份状态只能有一个归属四选一归属触发条件典型示例URLnuqs值得放进链接的客户端视图状态Tab、筛选、搜索、排序、分页、选中实体 id、作为「目的地」打开的 view 抽屉/弹窗?tablicenses、?categoryCommunication、?page3、?skillIdabcReact Query从接口拉取的服务器/远程数据详见 .claude/rules/sim-queries.mduseMcpServers(workspaceId)、useSkills(workspaceId)Zustand跨组件客户端状态但绝不能进 URL高频、大体量、瞬时、或 socket 同步的画布平移/缩放、实时光标、拖拽状态、调整宽度、未保存缓冲区、协作实时选中useState纯局部、单组件 UI 状态也是防抖 URL 搜索框的「即时镜像」hover 标志、瞬时对话框目标、防抖搜索框的实时输入文本进 URL 的门槛是同时满足全部条件可分享shareable、可深链deep-linkable、可收藏bookmarkable、刷新与前进/后退后仍存活——并且离散、低频、体量小。只要有一条不满足就不该放进 URL。这条规则在源码中有直接印证例如 apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts 中mcpServerId、fork-id、group-id、custom-block-id等「打开一个实体」的参数使用history: push目的地进浏览器历史而server-tab、group-tab这类 Tab 视图状态使用history: replace原地切换不进历史——这正是「URL 装视图状态、历史栈区分目的地与原地操作」的落地。明确禁止的反模式Anti-patterns规则文档列出了一组必须避免的写法读状态时直接useSearchParams().get(...)或new URLSearchParams(window.location.search)手拼查询字符串 router.replace/router.push来改状态。关键判断如果目标路径等于当前路径那就是查询参数修改不是导航——哪怕写成完整路径模板也一样。手写重序列化天然有损模板遗漏的参数会被全部丢弃。必须用 nuqs settersetParams({ key: null }, { history: replace, scroll: false })null总是移除该 key且只触碰你点名的参数。之所以要显式写这两个选项是因为它们本就是 nuqs 默认值见下文「约定」显式写出是文档化同时能防御那些共享 options 里设了history: push的分组例如 files 的filesUrlKeys把「剥离参数」误写进后退栈用window.history.replaceState/pushState改参数把 URL 状态复制进 store再用 effect /popstate监听同步双份事实来源把高频或大体量状态放进 URL光标、平移/缩放、未防抖的按键、大 JSON 块在客户端代码里import { z } from zod做参数校验——应使用 nuqs 解析器parseAsString、parseAsInteger、parseAsBoolean、parseAsStringLiteral、parseAsArrayOf或自定义createParser。以下做法不是反模式保持原样即可出站 URL 构造new URLSearchParams({...})用于拼href、下载端点、外部 WebSocket/API URL或window.open(_, _blank)的目的地路由导航router.push(/path/[id]?folderIdx)这种改变路径的导航。nuqs setter 只改当前路径上的 query跨路径导航仍归router管一次性读取的认证/重定向信号token、callbackUrl、redirect、error、invite_flow、new邀请注册流程、upgraded、redirect_workflow等。这些是「读一次即消费常为读后剥离」的导航信号不是同步视图状态留在useSearchParams上即可。注意 key 名按页面而异Files 的new是真正的 nuqs 参数见 files/search-params.ts 中兼容?new1线格式的parseAsNewFlag而 invite 的new是一次性注册信号。记忆型列表偏好例外Remembered list-preferenceFiles、Tables、Knowledge 三个模块允许通过useResourceListPreferences记住上次使用的筛选/排序快照但它只是兜底偏好不是第二个实时事实来源模块打开期间 nuqs 始终是权威干净进入模块时持久化状态水合后Zustand 只被查询一次URL 里显式的筛选/排序参数永远优先——即便它解析结果等于模块默认值。完整解析后的 URL 快照成为被记住的值缺失字段使用 URL 默认值不与存储合并显式的筛选/排序操作会同时把同一份完整快照提交给 nuqs 和 Zustand绝不后续用同步 effect 或popstate监听镜像 URL 变化搜索与文件夹导航保持 URL-only不进入持久化快照。该配置在 files/search-params.ts 中可看到落地的filesListPreferenceConfigmodule: files、sortColumns、filterKeystype/size/uploadedBy以及defaultPreference。每个特性一个search-params.ts单一事实来源规则的核心组织约定在特性目录下放一个search-params.ts导出解析器映射表以及共享 options。客户端useQueryStates/useQueryState和服务端组件createSearchParamsCachefromnuqs/server都从这一个文件 import。解析器从nuqs/server导入保证模块在客户端与服务端上下文都能安全加载。仓库中实际存在 34 个这样的文件例如files/search-params.tsknowledge/search-params.tssettings/[section]/search-params.tstables/search-params.tsee/audit-logs/search-params.ts编写约定Conventions每个解析器都.withDefault(...)保证读取非空。刻意做成可空的解析器动态默认值、仅自定义范围日期、可空排序必须附注释说明原因筛选 / 搜索 / 开关 / 分页{ history: replace, clearOnDefault: true }—— 干净的 URL、不制造后退栈噪音。注意history: replace、clearOnDefault: true、shallow: true三者本来就是 nuqs v2 的默认值——显式写出前两个是文档化并防御 options 不同的分组如history: pushshallow: true可完全省略应进浏览器历史的导航切换文件夹、打开深链实体{ history: push }shallow: false仅在 Server Component / loader 必须重读该参数时使用服务端重渲染的加载态中用 React 的startTransition通过.withOptions({ startTransition, shallow: false })传入URL key 使用短、稳定、kebab-case。重命名 key 是对共享链接的破坏性变更必须当作 breaking change 处理。当解析器映射表的 key 是 camelCase便于解构时通过共享 options 对象的urlKeys选项映射线格式 key见 files 的uploadedBy: uploaded-by、audit-logs 的timeRange: time-rangenuqs 也导出UrlKeystypeof parsers类型助手做独立映射throttleMs在 nuqs 中已弃用——用limitUrlUpdates: throttle(ms)/debounce(ms)限流 URL 写入下面的防抖搜索 hook 已内置该逻辑跨 surface 共享、但默认值不同的解析器如parseAsTimeRange必须把未知 token 解析为null——绝不能解析成某个 surface 的默认值这样每个消费方自己的.withDefault(...)决定回退值不透明/字面量值用parseAsStringLiteral([...] as const)自定义线格式用createParser用createParser解析无法用比较的值数组、对象、Date时必须定义eq——clearOnDefault靠它判断默认值否则空数组/空对象默认值永远不会从 URL 剥离。内置的parseAsArrayOf(...)自带eq只有 string/number/boolean 的自定义解析器可以省略。数组示例eq: (a, b) a.length b.length a.every((v, i) v b[i])。示例——分组过滤器单一事实来源规则文档给出的标准骨架注意*UrlKeys后缀是仓库对「特性共享 options 对象」的命名约定对象内可以再包含 nuqs 的urlKeyskey 重映射——两者是不同事物// apps/sim/app/workspace/[workspaceId]/things/search-params.ts import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from nuqs/server const VIEW_MODES [list, grid] as const export const thingsParsers { search: parseAsString.withDefault(), tags: parseAsArrayOf(parseAsString).withDefault([]), view: parseAsStringLiteral(VIEW_MODES).withDefault(list), } as const /** Clean URLs, no back-stack churn for filter changes. */ export const thingsUrlKeys { history: replace, clearOnDefault: true, } as const真实仓库中 files/search-params.ts 的做法与之完全一致并做了更细的分组filesParsersfolderId / new / shareFileId导航类filesUrlKeys用history: push与filesFilterParserssearch / type / size / uploadedBy筛选类filesFilterUrlKeys用history: replaceurlKeys重映射分开定义并注释说明「筛选写入绝不能进浏览器历史」。客户端接线useQueryStates分组/useQueryState单个use client import { useQueryStates } from nuqs import { thingsParsers, thingsUrlKeys } from /app/workspace/[workspaceId]/things/search-params export function useThingFilters() { const [filters, setFilters] useQueryStates(thingsParsers, thingsUrlKeys) // filters.search / filters.tags / filters.view 非空默认值已应用 // setFilters({ view: grid }) —— 传 null 清除单个 key 回到默认值 return { filters, setFilters } }单个参数用useQueryState(key, parser)const [serverId, setServerId] useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser)服务端读取createSearchParamsCache当 Server Component 或 loader 需要读参数时用同一个解析器映射表构建缓存// in a server component / page.tsx import { createSearchParamsCache } from nuqs/server import { thingsParsers } from /app/workspace/[workspaceId]/things/search-params const thingsCache createSearchParamsCache(thingsParsers) export default async function Page({ searchParams }: { searchParams: PromiseRecordstring, string | string[] | undefined }) { const { search, view } await thingsCache.parse(await searchParams) // ... }如果客户端参数变更后必须由服务端重读则在写入时设置shallow: false。Suspense 边界页面入口必须有真实 fallback由于useQueryState/useQueryStates内部读取useSearchParams任何使用它们的客户端组件必须位于Suspense边界之下Next.js 的硬性要求。规则明确绝不在页面入口写fallback{null}——路由同级的loading.tsx默认导出才是正确 fallback一个骨架屏同时服务「路由级导航过渡」Next 自动渲染和「页内 suspend」本边界渲染。import { KnowledgeBase } from /app/workspace/[workspaceId]/knowledge/[id]/base import KnowledgeBaseLoading from /app/workspace/[workspaceId]/knowledge/[id]/loading Suspense fallback{KnowledgeBaseLoading /} KnowledgeBase id{id} knowledgeBaseName{kbName || Knowledge Base} / /Suspense参考实现knowledge/[id]/page.tsx。唯一例外是「连续性优先的同级切换」刻意保持当前视图挂载、遵循 .claude/rules/sim-react-performance.md 中完整路由关键数据 intent-prefetch 规则的场景——它仍需要真实的页内 Suspense fallback只是省略会在目标就绪前替换当前 peer 的路由级loading.tsx。另外包裹lazy()组件的内层Suspense是反例那里fallback{null}才是正确的恰好让 suspend 在最近的边界解决而不是闪烁整个路由见.claude/rules/sim-imports.md的「Code-splitting through barrels」。防抖搜索输入用useDebouncedSearchSetter规则禁止手搓「useState镜像 useDebounce URL 写回 effect」的组合也禁止内联手写防抖接线。统一使用 apps/sim/hooks/use-debounced-search-setter.ts 导出的useDebouncedSearchSetternuqs 值即时更新输入框直接由它控制、保持跟手只有URL 写入通过 nuqs 内置的limitUrlUpdates: debounce(ms)防抖。import { useDebouncedSearchSetter } from /hooks/use-debounced-search-setter // 分组 useQueryStates 内搜索——组内离散筛选保持即时写入 const setSearch useDebouncedSearchSetter((value, options) setFilters({ search: value }, options)) // 独立单参数——直接传 useQueryState 的 setter const setSearch useDebouncedSearchSetter(setSearchParam) // 非默认窗口例如 files 的 200ms const setSearch useDebouncedSearchSetter(write, { debounceMs: 200 })从源码可见其关键设计use-debounced-search-setter.ts输入trim()后为空则写入null并立即执行参数即刻剥离、不残留非空时携带limitUrlUpdates: debounce(debounceMs)写原始值SEARCH_DEBOUNCE_MS来自/lib/url-state。配套要点绝不向控制输入框的参数写入 trim 过的值——会吃掉用户输入中的尾部空格导致多词查询打不出来。hook 写原始值trim 只用于空值判断读取侧喂给查询/筛选时才 trim保持 fetch/过滤防抖当搜索值喂给 React Query key 或昂贵的内存过滤时从即时 nuqs 值派生出防抖值const debounced useDebounce(urlSearch, SEARCH_DEBOUNCE_MS)喂给 query 的是防抖值即时值只给输入框。小型静态列表上的廉价内存过滤可以直接读即时值设置页列表搜索使用useSettingsSearch()settings/components/use-settings-search中的共享?search绑定保持clearOnDefault空值清参、既有默认值、history: replace。仓库中的参照logsuse-log-filters.ts分组、query 保持防抖、integrations廉价内存过滤、即时值、tables过滤保持防抖。排序约定sortdir两个标量参数可排序列表使用两个标量参数绝不序列化{column,direction}对象。用 apps/sim/lib/url-state/sort-params.ts 的createSortParams放在特性的search-params.ts里构建消费端用 apps/sim/hooks/use-url-sort.ts 的useUrlSort禁止重新声明SORT_DIRECTIONS/默认常量或手搓activeSort/onSort/onClear接线// search-params.tsserver-safe import { createSortParams } from /lib/url-state const THING_SORT_COLUMNS [name, created, updated] as const export const thingsSortParams createSortParams(THING_SORT_COLUMNS, { column: updated, direction: desc, })// componentclient import { useUrlSort } from /hooks/use-url-sort const { sort, dir, activeSort, onSort, onClear } useUrlSort(thingsSortParams, thingsUrlKeys) // activeSort/onSort/onClear 直接接入 SortConfigsort/dir 喂给 query key 和比较器两种模式取决于是否传默认值Defaulted常见——传列表现有默认排序必须精确匹配。干净 URL 即默认顺序显式选择默认值会因clearOnDefault坍缩回干净 URL「清除排序」写回默认值。useUrlSort在默认状态下派生出activeSort: nullNullable——省略默认值适用于「无排序」与「显式按回退列排序」行为不同的列表例如 document chunks无排序时 query 完全省略sortBy使用服务端自身顺序。参数不带默认值显式选择始终留在 URL 中「清除排序」写入null剥离两个参数。useUrlSort的源码实现印证了两种模式的差异use-url-sort.tssortDefault ! null时若当前值等于默认值则activeSort返回nullonClear写回默认值否则activeSort反映显式选择onClear写{ sort: null, dir: null }。onSort还会先用params.columns校验列名未知列直接 no-op。排序参数与分组过滤器解析器映射表平级共存每个参数只定义一次useUrlSort自己持有useQueryStatesnuqs 会让同 key 的 hooks 保持同步两个参数都带共享筛选 options{ history: replace, clearOnDefault: true }。自由形式的用户自定义列如tables/[tableId]无法用parseAsStringLiteral保持用parseAsString手写并复用共享的SORT_DIRECTIONS。Files 的真实实现见 files/search-params.ts六列FILE_SORT_COLUMNS默认updated desc与列表默认顺序一致。URL 中的日期只存yyyy-MM-dd本地时间用自定义 parser日期类参数日历锚点、日期筛选只存yyyy-MM-dd当只关心「天」时绝不序列化完整Date/时间戳。本地 vs UTC 要选对 parser。nuqs 内置的parseAsIsoDate是UTC 基准serialize走toISOString().slice(0, 10)parse到 UTC 午夜。如果你的Date是本地时间例如本地时间助手产出、被date-fns的startOfWeek/isSameDay读取——这些都是本地的在非 UTC 时区下刷新/深链/前进后退会偏移 ±1 天。本地时间日期计算应使用小型的本地日期createParser在本地日历字段上序列化/解析getFullYear/getMonth/getDate↔new Date(y, m-1, d)并带比较 y/m/d 的eq。只有值确实是 UTC/UTC 午夜时才用parseAsIsoDateconst parseAsLocalDate createParser({ parse: (v) { const [y, m, d] v.split(-).map(Number) return y m d ? new Date(y, m - 1, d) : null }, serialize: (v) ${v.getFullYear()}-${String(v.getMonth() 1).padStart(2, 0)}-${String(v.getDate()).padStart(2, 0)}, eq: (a, b) a.getFullYear() b.getFullYear() a.getMonth() b.getMonth() a.getDate() b.getDate(), })当默认值是动态的如「今天」时把参数做成可空省略.withDefault在 hook 里派生回退值const anchor param ?? today——这样干净 URL 即动态默认值导航回去写入null清除参数。选中实体深链只存 id派生对象要深链某行/弹窗/抽屉到某个实体时只存它的 id再从已加载的列表中查对象——绝不把对象序列化进 URLconst [skillId, setSkillId] useQueryState(skillIdParam.key, { ...skillIdParam.parser, history: push, // 打开实体是目的地back 关闭它 clearOnDefault: true, }) // 派生——不要复制进 useState 或用 effect 同步 const editingSkill skillId ? (skills.find((s) s.id skillId) ?? null) : null只在 id能解析到已加载实体时才打开面板/弹窗——绝不以裸参数为准否则失效/过期 id已删除实体、旧书签会渲染出残缺详情视图同时仍在加载的列表还会闪一下。死 id 只需回退到列表残留参数无害。由于这读取useSearchParams页面需要Suspense边界。「新建」流程没有 id留在局部useState。关闭用replace打开用push。打开已 push 一条历史记录关闭就不能再 push。用 setter 的单次调用 options 关闭——setSkillId(null, { history: replace })——这样从列表按 Back 会离开页面而不是重新打开详情。参考实现散布在 settings 各页面mcp.tsx、workflow-mcp-servers.tsx、access-control、custom-blocks、forks参数定义集中在 settings/[section]/search-params.ts例如mcpServerId/server-tab、group-id/group-tab、custom-block-id、data-drain-id。详情视图内的次级参数如激活 Tabserver-tab在同一关闭处理器里用各自 setter 清除——nuqs 会合并在同一 tick 的多次写入为一次 URL 更新。可复用组件既作设置/列表页渲染、又可嵌进弹窗如BYOKKeyManager暴露可选的受控searchTerm/onSearchTermChangeprops页面消费方绑定 URLuseSettingsSearch()弹窗消费方省略 props 保持局部状态。绝不在可能挂载于非目的地上下文的组件内部绑定 URL 状态。读取后剥离的深链Read-then-strip对于「预打开弹窗/抽屉、且不应残留在 URL」的临时深链如 integrations 的?connectoauth、knowledge 的?addConnector读参数、在useRef守卫后执行一次动作然后清除setParam(null, { history: replace, scroll: false })。参考实现integration-block-detail.tsx。Files 的new标志也遵循同一模式见 files/search-params.ts 注释挂载时读一次、路由稳定后剥离让编辑器以 compose 模式打开。工作流编辑器的例外什么绝不能进 URL工作流编辑器apps/sim/app/workspace/[workspaceId]/w/**通过socket-provider.tsx实时/socket 同步其视图状态刻意采用 Zustand store 承载而非 URL。禁止把以下状态移入 URL实时光标与广播的实时选中presence经 socket 发出、节流平移 / 缩放 / 视口ReactFlow 自持、连续、不持久化拖拽状态与调整宽高面板/终端/侧边栏——高频作为本地偏好持久化临时的 diff 暂存hasActiveDiff、baselineWorkflow、diffAnalysis。看似可分享、但当前留在 Zustand 的边界候选面板activeTab——持久化本地偏好接入了 SSR 防闪路径data-panel-active-tab_hasHydrated移入 URL 会拆掉这套机制并引入加载时 Tab 闪烁风险。画布模式useCanvasModeStore的mode同样是持久化布局偏好不是目的地面板编辑器的currentBlockIdstores/panel/editor/store.ts——唯一真正可分享的候选「看这个 block」深链但它已持久化且与面板打开编排纠缠。为它加 URL 参数是新功能而非迁移应单独设计并对活 socket 做运行时验证不应在一次清扫中顺手完成。编辑器内的经验法则状态只要与 socket 耦合、高频、与视口相关、或是持久化的尺寸/偏好就留在 Zustand。拿不准就保持原状并标记出来——不要硬把脆弱的 URL 状态塞进画布。小结Sim 的 URL 状态规范可以浓缩为一句话URL 只装「离散、低频、体量小、且值得被链接」的视图状态其余交给 React Query / Zustand / useState所有 URL 参数的定义收敛在特性旁的search-params.ts客户端与服务端共享同一份解析器映射表。配合history: replaceclearOnDefault: true保持链接干净、history: push标记目的地导航、useDebouncedSearchSetter防抖搜索写入、createSortParams/useUrlSort统一排序、本地日期 parser 规避时区偏移这套规范让整个apps/sim的 URL 状态在几十个功能面间保持一致且可审计——文档本身的进一步细节可回到 .claude/rules/sim-url-state.md 查阅仓库中 34 个search-params.ts文件就是这份规范最完整的活体样例库。【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考