Skip to content

Commit f1ff8b3

Browse files
edramclaude
andcommitted
feat(react-hooks): useUrlState 新增 per-key parsers 数据格式化
按 key 把 querystring 解析成 typed 值,写回 url 时自动反序列化; 未声明的 key 保持 string | string[]。parsers[key] 支持传函数(当作 parse)或 { parse?, stringify? } 对象(两者均可选)。导出内置解析器 parseAsString / parseAsInteger / parseAsFloat / parseAsBoolean / parseAsArrayOf / parseAsJson 及 Parser / ParserInput 类型。 向后兼容:不传 parsers 时行为不变,state 仍为 Record<string, string | string[]>。计划见 plans 中对应文件的「增量」章节。 Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 6716e42 commit f1ff8b3

4 files changed

Lines changed: 657 additions & 15 deletions

File tree

.changeset/url-state-parsers.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@edram/react-hooks': minor
3+
---
4+
5+
useUrlState 新增 per-key `parsers`:按 key 把 querystring 解析成 typed 值(写回 url 自动反序列化),未声明的 key 保持 `string | string[]``parsers[key]` 可传函数(当作 `parse`)或 `{ parse?, stringify? }` 对象(两者均可选)。同时导出内置解析器 `parseAsString` / `parseAsInteger` / `parseAsFloat` / `parseAsBoolean` / `parseAsArrayOf` / `parseAsJson``Parser` / `ParserInput` 类型。

packages/react-hooks/src/useUrlState.ts

Lines changed: 186 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,166 @@ export type UrlSearchParams = Record<string, string | string[]>;
44

55
const 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+
7167
function 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

Comments
 (0)