@@ -4,6 +4,166 @@ export type UrlSearchParams = Record<string, string | string[]>;
44
55const URL_STATE_EVENT = 'edram:urlstatechange' ;
66
7+ // ---------------------------------------------------------------------------
8+ // parsers:按 key 配置的值解析器
9+ // ---------------------------------------------------------------------------
10+
11+ /**
12+ * 单个字段的解析器。`parse` / `stringify` 均可选:
13+ * - 缺 `parse` → 该 key 读取时保持 `string | string[]`;
14+ * - 缺 `stringify` → 写回时走默认序列化(`String()` 强转 / 数组展开)。
15+ */
16+ export interface Parser < T > {
17+ parse ?: ( value : string | string [ ] ) => T | null ;
18+ stringify ?: ( value : T ) => string | string [ ] ;
19+ }
20+
21+ /** 函数简写:直接传函数即被当作 `parse`。 */
22+ export type ParserFn < T > = ( value : string | string [ ] ) => T | null ;
23+
24+ /** `parsers[key]` 可接受的两种形态。 */
25+ export type ParserInput < T > = ParserFn < T > | Parser < T > ;
26+
27+ type ParserMap = Record < string , ParserInput < any > > ;
28+
29+ type NormalizedParser = {
30+ parse ?: ParserFn < any > ;
31+ stringify ?: ( value : any ) => string | string [ ] ;
32+ } ;
33+
34+ const normalizeParser = ( input : ParserInput < any > ) : NormalizedParser =>
35+ typeof input === 'function' ? { parse : input } : input ;
36+
37+ /** 标量 parser 容忍传入数组:取首项。 */
38+ const toScalar = ( value : string | string [ ] ) : string =>
39+ Array . isArray ( value ) ? ( value [ 0 ] ?? '' ) : value ;
40+
41+ /** 字符串原样透传(数组取首项)。 */
42+ export const parseAsString = {
43+ parse : ( value : string | string [ ] ) => toScalar ( value ) ,
44+ stringify : ( value : string ) => value ,
45+ } satisfies Parser < string > ;
46+
47+ /** 整数:parseInt(., 10),NaN → null;stringify 取整。 */
48+ export const parseAsInteger = {
49+ parse : ( value : string | string [ ] ) => {
50+ const n = Number . parseInt ( toScalar ( value ) , 10 ) ;
51+ return Number . isNaN ( n ) ? null : n ;
52+ } ,
53+ stringify : ( value : number ) => String ( Math . trunc ( value ) ) ,
54+ } satisfies Parser < number > ;
55+
56+ /** 浮点:parseFloat,NaN → null。 */
57+ export const parseAsFloat = {
58+ parse : ( value : string | string [ ] ) => {
59+ const n = Number . parseFloat ( toScalar ( value ) ) ;
60+ return Number . isNaN ( n ) ? null : n ;
61+ } ,
62+ stringify : ( value : number ) => String ( value ) ,
63+ } satisfies Parser < number > ;
64+
65+ /** 布尔:'true' → true,其余 → false;stringify 'true' / 'false'。 */
66+ export const parseAsBoolean = {
67+ parse : ( value : string | string [ ] ) => toScalar ( value ) === 'true' ,
68+ stringify : ( value : boolean ) => ( value ? 'true' : 'false' ) ,
69+ } satisfies Parser < boolean > ;
70+
71+ /**
72+ * 数组:把 `string | string[]` 归一为数组,逐项走 `item.parse`(丢弃解析失败的 null)。
73+ * stringify 产出 `string[]` → 展开为多值 `?k=a&k=b`,沿用同名 key→数组行为。
74+ */
75+ export function parseAsArrayOf < T > ( item : Parser < T > ) : Required < Parser < T [ ] > > {
76+ return {
77+ parse : ( value ) => {
78+ const list = Array . isArray ( value ) ? value : [ value ] ;
79+ const out : T [ ] = [ ] ;
80+ for ( const raw of list ) {
81+ const parsed = item . parse ?.( raw ) ?? null ;
82+ if ( parsed !== null ) {
83+ out . push ( parsed ) ;
84+ }
85+ }
86+ return out ;
87+ } ,
88+ stringify : ( value ) =>
89+ value . map ( ( entry ) => {
90+ const s = item . stringify ? item . stringify ( entry ) : String ( entry ) ;
91+ return Array . isArray ( s ) ? ( s [ 0 ] ?? '' ) : s ;
92+ } ) ,
93+ } ;
94+ }
95+
96+ /** 任意 JSON 结构:JSON.parse / JSON.stringify,解析失败 → null。 */
97+ export function parseAsJson < T = unknown > ( ) : Required < Parser < T > > {
98+ return {
99+ parse : ( value ) => {
100+ try {
101+ return JSON . parse ( toScalar ( value ) ) as T ;
102+ } catch {
103+ return null ;
104+ }
105+ } ,
106+ stringify : ( value ) => JSON . stringify ( value ) ,
107+ } ;
108+ }
109+
110+ /** 对已解析出的 raw 对象按 parsers 逐 key 应用 `parse`(仅对 url 中存在的 key)。 */
111+ function applyParsers (
112+ raw : UrlSearchParams ,
113+ parsers ?: ParserMap ,
114+ ) : Record < string , unknown > {
115+ if ( ! parsers ) {
116+ return raw ;
117+ }
118+ const out : Record < string , unknown > = { ...raw } ;
119+ for ( const key of Object . keys ( parsers ) ) {
120+ if ( ! ( key in raw ) ) {
121+ continue ;
122+ }
123+ const parser = normalizeParser ( parsers [ key ] ) ;
124+ if ( parser . parse ) {
125+ out [ key ] = parser . parse ( raw [ key ] ) ;
126+ }
127+ }
128+ return out ;
129+ }
130+
131+ /** 把 typed state 还原为 raw(声明且有 stringify 的走 stringify,否则原样)。 */
132+ function serializeState (
133+ state : Record < string , unknown > ,
134+ parsers ?: ParserMap ,
135+ ) : UrlSearchParams {
136+ const raw : UrlSearchParams = { } ;
137+ for ( const [ key , value ] of Object . entries ( state ) ) {
138+ if ( value == null ) {
139+ continue ;
140+ }
141+ const parser =
142+ parsers && key in parsers ? normalizeParser ( parsers [ key ] ) : undefined ;
143+ raw [ key ] = parser ?. stringify
144+ ? parser . stringify ( value )
145+ : ( value as string | string [ ] ) ;
146+ }
147+ return raw ;
148+ }
149+
150+ /** state 中各 key 的值类型:由 parse(含函数简写)决定,无 parse 的保持原样。 */
151+ type InferParser < P > = P extends ( ...args : never [ ] ) => infer R
152+ ? R | null
153+ : P extends { parse : ( ...args : never [ ] ) => infer R }
154+ ? R | null
155+ : string | string [ ] ;
156+
157+ export type ParsedState < P extends ParserMap > = {
158+ [ K in keyof P ] : InferParser < P [ K ] > ;
159+ } & {
160+ [ key : string ] : string | string [ ] | InferParser < P [ keyof P ] > ;
161+ } ;
162+
163+ // ---------------------------------------------------------------------------
164+ // 默认整串 parse / stringify + URL 外部 store
165+ // ---------------------------------------------------------------------------
166+
7167function defaultParse ( search : string ) : UrlSearchParams {
8168 const params = new URLSearchParams ( search ) ;
9169 const result : UrlSearchParams = { } ;
@@ -65,13 +225,15 @@ function writeUrl(search: string, navigateMode: 'push' | 'replace'): void {
65225 window . dispatchEvent ( new Event ( URL_STATE_EVENT ) ) ;
66226}
67227
68- export interface UseUrlStateOptions {
228+ export interface UseUrlStateOptions < P extends ParserMap = { } > {
69229 /** 初始值,仅在 url 中缺失对应 key 时兜底,不会主动写回 url */
70- defaultSearchParams ?: UrlSearchParams ;
230+ defaultSearchParams ?: Partial < ParsedState < P > > ;
71231 /** 受控值。传入则代表受控,state 直接取该值,setState 仅触发 onChange */
72- searchParams ?: UrlSearchParams ;
232+ searchParams ?: ParsedState < P > ;
73233 /** 状态变化回调。searchParams = 新的对象值,search = 序列化后的 querystring */
74- onChange ?: ( searchParams : UrlSearchParams , search : string ) => void ;
234+ onChange ?: ( searchParams : ParsedState < P > , search : string ) => void ;
235+ /** 按 key 配置的值解析器;声明的 key 在 state 里为 typed 值,未声明的保持 string | string[] */
236+ parsers ?: P ;
75237 /** querystring 字符串 → 状态对象,默认基于 URLSearchParams */
76238 parse ?: ( search : string ) => UrlSearchParams ;
77239 /** 状态对象 → querystring 字符串,默认基于 URLSearchParams */
@@ -80,16 +242,16 @@ export interface UseUrlStateOptions {
80242 navigateMode ?: 'push' | 'replace' ;
81243}
82244
83- type SetStateArg =
84- | UrlSearchParams
85- | ( ( prev : UrlSearchParams ) => UrlSearchParams ) ;
86- type SetState = ( next : SetStateArg ) => void ;
245+ type SetState < S > = ( next : S | ( ( prev : S ) => S ) ) => void ;
87246
88- function useUrlState ( options : UseUrlStateOptions = { } ) : [ UrlSearchParams , SetState ] {
247+ function useUrlState < P extends ParserMap = { } > (
248+ options : UseUrlStateOptions < P > = { } ,
249+ ) : [ ParsedState < P > , SetState < ParsedState < P > > ] {
89250 const {
90251 defaultSearchParams,
91252 searchParams,
92253 onChange,
254+ parsers,
93255 parse = defaultParse ,
94256 stringify = defaultStringify ,
95257 navigateMode = 'replace' ,
@@ -102,6 +264,9 @@ function useUrlState(options: UseUrlStateOptions = {}): [UrlSearchParams, SetSta
102264 const onChangeRef = useRef ( onChange ) ;
103265 onChangeRef . current = onChange ;
104266
267+ const parsersRef = useRef ( parsers ) ;
268+ parsersRef . current = parsers ;
269+
105270 const parseRef = useRef ( parse ) ;
106271 parseRef . current = parse ;
107272
@@ -114,19 +279,26 @@ function useUrlState(options: UseUrlStateOptions = {}): [UrlSearchParams, SetSta
114279 getServerSnapshot ,
115280 ) ;
116281 const uncontrolledState = useMemo (
117- ( ) => ( { ...defaultsRef . current , ...parseRef . current ( search ) } ) ,
282+ ( ) =>
283+ ( {
284+ ...defaultsRef . current ,
285+ ...applyParsers ( parseRef . current ( search ) , parsersRef . current ) ,
286+ } ) as ParsedState < P > ,
118287 [ search ] ,
119288 ) ;
120- const state = isControlled ? searchParams : uncontrolledState ;
289+ const state = isControlled ? ( searchParams as ParsedState < P > ) : uncontrolledState ;
121290
122291 const stateRef = useRef ( state ) ;
123292 stateRef . current = state ;
124293
125- const setState = useCallback < SetState > (
294+ const setState = useCallback < SetState < ParsedState < P > > > (
126295 ( next ) => {
127296 const value =
128- typeof next === 'function' ? next ( stateRef . current ) : next ;
129- const nextSearch = stringifyRef . current ( value ) ;
297+ typeof next === 'function'
298+ ? ( next as ( prev : ParsedState < P > ) => ParsedState < P > ) ( stateRef . current )
299+ : next ;
300+ const raw = serializeState ( value , parsersRef . current ) ;
301+ const nextSearch = stringifyRef . current ( raw ) ;
130302 if ( ! isControlled ) {
131303 writeUrl ( nextSearch , navigateMode ) ;
132304 }
0 commit comments