state-in-url 4.1.10 → 4.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.CN.md CHANGED
@@ -1,74 +1,125 @@
1
1
  [English](./README.md) | [한국어](./README.KO.md) | 简体中文
2
2
 
3
3
  <div align="center">
4
- <img src="/assets/Logo_big.png?raw=true" alt="state-in-url logo"/>
4
+ <img src="/packages/example-nextjs14/public/Logo_symbol.png" alt="state-in-url logo" width="120px" />
5
5
 
6
- <div>轻松在不相关的React组件之间共享状态,具有IDE自动完成和TS验证功能。无需任何麻烦或样板代码。</div>
6
+ # State in url
7
+ </div>
8
+
9
+ <div align="center">
7
10
  </div>
8
11
 
9
12
  <div align="center">
10
- <hr />
11
13
 
12
14
  [![npm](https://img.shields.io/npm/v/state-in-url.svg)](https://www.npmjs.com/package/state-in-url)
13
- ![Tests](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml/badge.svg?branch=master)
15
+ ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/state-in-url.svg)
14
16
  [![Codacy Badge](https://app.codacy.com/project/badge/Grade/73be54068b7f41b0b74a252579ac09ec)](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
17
+
18
+ ![Tests](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml/badge.svg?branch=master)
19
+ [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/73be54068b7f41b0b74a252579ac09ec)](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
15
20
  [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](https://github.com/asmyshlyaev177/state-in-url/)
16
- ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/state-in-url.svg)
21
+ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)]([https://github.com/semantic-release/semantic-release](https://github.com/asmyshlyaev177/state-in-url))
22
+
23
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/asmyshlyaev177/state-in-url/badge)](https://scorecard.dev/viewer/?uri=github.com/asmyshlyaev177/state-in-url)
24
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9679/badge)](https://www.bestpractices.dev/projects/9679)
25
+ [![license](https://img.shields.io/github/license/asmyshlyaev177/state-in-url.svg?style=flat-square)](https://github.com/asmyshlyaev177/state-in-url/blob/master/LICENSE)
26
+ <!-- or by embedding this in your HTML:
27
+ <a href="https://www.bestpractices.dev/projects/9679"><img src="https://www.bestpractices.dev/projects/9679/badge"></a> -->
28
+
17
29
  </div>
18
30
 
19
31
  <div align="center">
20
32
 
21
- <h4 align="center">如果发现bug,请随时提出issue</h4>
33
+ <h4 align="center">如果您发现漏洞或有功能需求,请随时提交issue</h4>
22
34
 
23
- ![演示-gif](https://github.com/asmyshlyaev177/state-in-url/assets/19854148/c9802601-4d42-4362-b3e4-37ff87c3b97f)
35
+ ![Demo-gif](https://github.com/asmyshlyaev177/state-in-url/blob/master/assets/Demo-gif.gif)
24
36
 
25
- <a href="https://state-in-url.dev/" target="_blank">演示</a> |
26
- <a href="https://state-in-url.netlify.app/" target="_blank">DEMO2</a> |
37
+ # 演示页面
27
38
 
28
- <hr />
39
+ <a href="https://state-in-url.dev" target="_blank">演示页面</a> |
40
+ <a href="https://state-in-url.netlify.app/" target="_blank">镜像链接</a>
29
41
 
30
- 添加⭐️来支持项目!
42
+ <a href="https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md" target="_blank">URI 长度限制: <b>安全上限为 12KB</b> </a>
31
43
 
32
44
  <hr />
33
45
 
34
- </div>
46
+ 点个 <a href="#"><img src="https://raw.githubusercontent.com/acervenky/animated-github-badges/master/assets/starbadge.gif" width="25" height="25"></a> 并且 <a href="https://github.com/asmyshlyaev177" target="_blank">关注我</a> 来支持这个项目!
47
+
48
+ 非常感激在[discussions](https://github.com/asmyshlyaev177/state-in-url/discussions/1)上提交你的反馈或意见
49
+
50
+ 如果觉得有用,就分享一下吧。
51
+ [X.com](https://twitter.com/intent/tweet?&url=https://github.com/asmyshlyaev177/state-in-url)
52
+ [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://github.com/asmyshlyaev177/state-in-url)
53
+ [FB](https://www.facebook.com/sharer.php?u=https://github.com/asmyshlyaev177/state-in-url)
54
+ [VK](http://vk.com/share.php?url=https://github.com/asmyshlyaev177/state-in-url)
35
55
 
36
- ## 为什么使用state-in-url?
56
+ <hr />
37
57
 
38
- `state-in-url`是一个简单的状态管理工具,可以与URL同步。在不相关的React组件之间共享复杂状态,将状态同步到URL,对TS友好,兼容NextJS。
58
+ </div>
39
59
 
40
- # 使用场景
60
+ [直接给我看代码!](#useurlstate)
41
61
 
42
- - 🙃 在不改变URL的情况下,在不同组件之间共享状态,是信号和其他状态管理工具的良好替代方案
43
- - 🔗 包含完整应用程序状态的可共享URL
44
- - 🔄 在页面重新加载之间轻松持久化状态
45
- - 🧠 在不相关的客户端组件之间同步数据
46
- - 🧮 在URL中存储未保存的用户表单
62
+ ## 为什么使用 `state-in-url`?
47
63
 
48
- # 特性
64
+ 将任何用户状态存储在查询参数中;想象一下在浏览器 URL 中嵌入 JSON 数据,同时保持数据的类型和结构。例如数字会被解码为数字而不是字符串,日期会被解码为日期,支持对象和数组等复杂结构。
65
+ 简单、快速,并且支持静态 TypeScript 验证。深度链接(即 URL 同步)变得轻而易举。
49
66
 
50
- - 🧩 **简单**:无需提供者、reducers、样板代码或新概念
51
- - 📘 **TypeScript支持和类型安全**:保留数据类型和结构,通过IDE建议、强类型和JSDoc注释增强开发体验
52
- - ⚛️ **框架灵活性**:为Next.js和React.js应用程序提供单独的钩子,以及用于纯JS的函数
53
- - ⚙ **经过充分测试**:单元测试和Playwright测试
54
- - ⚡ **快速**:最少的重新渲染
55
- - 🪶 **轻量级**:零依赖,占用空间小
67
+ 提供了适用于 Next.js 和 react-router 的 `useUrlState` 钩子,以及适用于其他 JavaScript 场景的辅助工具。
68
+ 由于现代浏览器支持超长的 URL,而用户并不关心查询字符串(通常的操作是全选、复制和粘贴)。
69
+
70
+ 是时候将查询字符串用于状态管理了,正如它最初的设计初衷。
71
+ 这个库为你处理了所有繁琐的工作。
72
+
73
+ 这个库是 NUQS 的一个很好的替代品。
74
+
75
+ ### 使用场景
76
+
77
+ - 将未保存的用户表单或页面筛选条件存储在 URL 中
78
+ - 将 URL 与 React 状态同步
79
+ - 在不修改 URI 的情况下,直接在无关的客户端组件之间同步数据
80
+ - 支持共享包含应用状态的 URL(深度链接,URL 状态同步)
81
+ - 轻松实现页面刷新后的状态持久化
82
+
83
+ ### 特性
84
+
85
+ - 🧩 **简单易用**:无需提供者(providers)、reducers 或样板代码,API 类似 `React.useState`,几乎没有学习成本。
86
+ - 📘 **TypeScript 验证/自动补全**:状态是一个对象,根据 TypeScript 定义,IDE 和测试中会自动进行静态验证。
87
+ - ✨ **支持复杂数据**:嵌套对象、日期和数组,像 JSON 一样工作,但存储在 URL 中。
88
+ - ☂ **默认值支持**:如果 URL 中没有参数,会自动使用默认值。
89
+ - ⌨ **结构化定义**:所有可能的值在开始时定义,避免访问不存在的键。
90
+ - **兼容性**:保留第三方查询参数不变。
91
+ - **灵活性**:同一页面可以使用多个状态对象,只需使用不同的键。
92
+ - **快速**:最小化重新渲染,编码和解码大对象仅需约 [1ms](https://github.com/asmyshlyaev177/state-in-url/blob/87c8c7c995c5cd7d9e7aa039c30bfe64b24abe4b/packages/urlstate/encoder/encoder.test.ts#L185)。
93
+ - **服务端渲染支持**:可在 Server Components 中使用,支持 Next.js 14 和 15。
94
+ - **轻量级**:零依赖,库大小小于 2KB。
95
+ - **开发者体验优化**:提供良好的文档、JSDoc 注释和示例。
96
+ - **框架灵活性**:提供 `Next.js` 和 `react-router` 的钩子,以及适用于其他框架或纯 JavaScript 的辅助工具。
97
+ - **全面测试**:包含单元测试和针对 Chrome/Firefox/Safari 的 Playwright 测试([测试详情](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml))。
98
+ - **宽松的许可证**:MIT 许可证,可自由使用。
56
99
 
57
100
  ## 目录
58
101
 
59
- - [安装](#安装)
60
- - [Next.js的`useUrlState`](#nextjs的useurlstate钩子)
61
- - [React.js的`useSharedState`](#reactjs的usesharedstate钩子)
62
- - [React.js的`useUrlEncode`](#reactjs的useurlencode钩子)
63
- - [纯JS使用的`encodeState`和`decodeState`](#纯js使用的encodestate和decodestate辅助函数)
64
- - [自动同步状态到URL](#自动同步状态)
65
- - [低级`encode`和`decode`函数](#encode和decode辅助函数)
66
- - [最佳实践](#最佳实践)
67
- - [注意事项](#注意事项)
68
- - [联系与支持](#联系与支持)
69
- - [更新日志](#更新日志)
70
- - [许可证](#许可证)
71
- - [灵感来源](#灵感来源)
102
+ - [安装](#安装)
103
+ - [`useUrlState` 钩子](#useurlstate)
104
+ - [Next.js 专用](#nextjs-专用的-useUrlState-钩子)
105
+ - [React-Router 专用](#React-Router-专用的-useUrlState-钩子)
106
+ - [其他辅助工具](#其他钩子和辅助工具)
107
+ - [其他路由器的 `useUrlStateBase` 钩子](#其他路由器的-useUrlStateBase-钩子)
108
+ - [React.js/Next.js 的 `useSharedState` 钩子](#reactjs-的-useSharedState-钩子)
109
+ - [React.js 的 `useUrlEncode` 钩子](#reactjs-的-useUrlEncode-钩子)
110
+ - [纯 JS 使用的 `encodeState` 和 `decodeState` 辅助函数](#encodeState-和-decodeState-辅助函数)
111
+ - [底层 `encode` 和 `decode` 函数](#encode-和-decode-辅助函数)
112
+ - [最佳实践](#最佳实践)
113
+ - [注意事项](#注意事项)
114
+ - [其他](#其他)
115
+ - [路线图](#路线图)
116
+ - [贡献指南](#贡献指南与本地运行说明)
117
+ - [联系与支持](#联系与支持)
118
+ - [更新日志](#更新日志)
119
+ - [许可证](#许可证)
120
+ - [灵感来源](#灵感来源)
121
+
122
+ ---
72
123
 
73
124
  ## 安装
74
125
 
@@ -83,29 +134,38 @@ yarn add state-in-url
83
134
  pnpm add state-in-url
84
135
  ```
85
136
 
86
- ### 2. 编辑tsconfig.json
137
+ ### 2. 编辑 `tsconfig.json`
138
+
139
+ 在 `tsconfig.json` 的 `compilerOptions` 中设置 `"moduleResolution": "Bundler"`,或 `"moduleResolution": "Node16"`,或 `"moduleResolution": "NodeNext"`。
140
+ 可能还需要设置 `"module": "ES2022"` 或 `"module": "ESNext"`。
141
+
142
+ ---
87
143
 
88
- 在`tsconfig.json`的`compilerOptions`中设置`"moduleResolution": "Node16"`或`"moduleResolution": "NodeNext"`
144
+ ## `useUrlState`
89
145
 
90
- ## Next.js的useUrlState钩子
146
+ 主钩子,接收初始状态作为参数,返回状态对象、更新 URL 的回调函数以及仅更新状态的回调函数。
147
+ 所有使用相同 `state` 对象的组件会自动同步。
91
148
 
92
- [文档](packages/urlstate/next/useUrlState)
149
+ ### Next.js 专用的 `useUrlState` 钩子
93
150
 
94
- `useUrlState`是一个为Next.js应用程序设计的自定义React钩子,使客户端组件之间的通信变得简单。它允许你共享任何复杂的状态并将其与URL搜索参数同步,提供了一种在页面重新加载之间持久化状态并通过URL共享应用程序状态的方法。
151
+ [完整 API 文档](packages/urlstate/next/useUrlState)
95
152
 
96
- ### 使用示例
153
+ [React-Router 示例](#React-Router-专用的-useUrlState-钩子)
97
154
 
98
- #### 基本用法
155
+ #### 使用示例
99
156
 
100
- 1. 定义状态形状
157
+ ##### 基础用法
101
158
 
102
- ```typescript
103
- // countState.ts
104
- // 状态形状应该存储在一个常量中,不要直接传递对象
105
- export const countState: CountState = { count: 0 }
159
+ 1. 定义状态结构及默认值
106
160
 
107
- type CountState = { count: number }
108
- ```
161
+ ```typescript
162
+ // userState.ts
163
+ // 只有与默认值不同的参数会被放入 URL。
164
+ export const userState: UserState = { name: '', age: 0 }
165
+
166
+ // 使用 `Type` 而非 `Interface`!
167
+ type UserState = { name: string, age: number }
168
+ ```
109
169
 
110
170
  2. 导入并使用
111
171
 
@@ -113,136 +173,206 @@ pnpm add state-in-url
113
173
  'use client'
114
174
  import { useUrlState } from 'state-in-url/next';
115
175
 
116
- import { countState } from './countState';
176
+ import { userState } from './userState';
117
177
 
118
178
  function MyComponent() {
119
- // 用于从服务器组件使用searchParams
120
- // 例如 export default async function Home({ searchParams }: { searchParams: object }) {
121
- // const { state, updateState, updateUrl } = useUrlState(countState, searchParams);
122
- const { state, updateState, updateUrl } = useUrlState(countState);
123
-
124
- // 不允许你意外地直接修改状态,需要TS
125
- // state.count = 2 // <- 错误
179
+ // 可以传递 `replace` 参数,控制 `setUrl` 使用 `router.push` 还是 `router.replace`,默认为 replace=true
180
+ // 可以传递 `searchParams` 从服务端组件传入,如果需要从服务端组件获取数据,传递 `useHistory: false`
181
+ const { urlState, setUrl, setState, reset } = useUrlState(userState);
126
182
 
127
183
  return (
128
184
  <div>
129
- <p>计数:{state.count}</p>
130
-
131
- <button onClick={() => updateUrl({ count: state.count + 1 }), { replace: true }}>
132
- 增加(更新URL)
133
- </button>
134
-
135
- // 与React.useState相同的API
136
- <button onClick={() => updateState(currState => ({...currState, count: currState.count + 1 }) )}>
137
- 增加(仅本地)
138
- </button>
139
- <button onClick={() => updateUrl()}>
140
- 同步更改到URL
141
- // 或者不同步,只共享状态
142
- </button>
143
-
144
- <button onClick={() => updateUrl(state)}>
185
+ // 如果 URL 为空,urlState.name 将返回 `userState` 的默认值
186
+ <input value={urlState.name}
187
+ // 与 React.useState 相同的 API,例如 setUrl(currVal => currVal + 1)
188
+ onChange={(ev) => setUrl({ name: ev.target.value }) }
189
+ />
190
+ <input value={urlState.age}
191
+ onChange={(ev) => setUrl({ age: +ev.target.value }) }
192
+ />
193
+
194
+ <input value={urlState.name}
195
+ onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
196
+ // 可以立即更新状态,但根据需要同步到 URL
197
+ onBlur={() => setUrl()}
198
+ />
199
+
200
+ <button onClick={reset}>
145
201
  重置
146
202
  </button>
203
+
147
204
  </div>
148
205
  )
149
206
  }
150
207
  ```
151
208
 
152
- #### 复杂状态形状示例
209
+ ##### 自定义钩子以方便处理状态片段
210
+ <details>
211
+ <Summary>示例</Summary>
153
212
 
154
- ```typescript
155
- interface UserSettings {
156
- theme: 'light' | 'dark';
157
- fontSize: number;
158
- notifications: boolean;
159
- }
213
+ ```typescript
214
+ 'use client';
160
215
 
161
- export const userSettings: UserSettings {
162
- theme: 'light',
163
- fontSize: 16,
164
- notifications: true,
165
- }
216
+ import React from 'react';
217
+ import { useUrlState } from 'state-in-url/next';
218
+
219
+ const form: Form = {
220
+ name: '',
221
+ age: undefined,
222
+ agree_to_terms: false,
223
+ tags: [],
224
+ };
225
+
226
+ type Form = {
227
+ name: string;
228
+ age?: number;
229
+ agree_to_terms: boolean;
230
+ tags: {id: string; value: {text: string; time: Date } }[];
231
+ };
232
+
233
+ export const useFormState = ({ searchParams }: { searchParams?: object }) => {
234
+ const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, {
235
+ searchParams,
236
+ });
237
+
238
+ // 第一次导航会推送新的历史记录
239
+ // 后续导航将替换该记录
240
+ // 这样历史记录中只有两个条目 - ['/url', '/url?key=param']
241
+
242
+ const replace = React.useRef(false);
243
+ const setUrl = React.useCallback((
244
+ state: Parameters<typeof setUrlBase>[0],
245
+ opts?: Parameters<typeof setUrlBase>[1]
246
+ ) => {
247
+ setUrlBase(state, { replace: replace.current, ...opts });
248
+ replace.current = true;
249
+ }, [setUrlBase]);
250
+
251
+ return { urlState, setUrl, resetUrl: reset };
252
+ };
253
+ ```
254
+ </details>
255
+
256
+ <hr />
257
+
258
+ ##### 复杂状态结构
259
+
260
+ <details>
261
+ <Summary>示例</Summary>
262
+
263
+ ```typescript
264
+ export const form: Form = {
265
+ name: '',
266
+ age: undefined,
267
+ agree_to_terms: false,
268
+ tags: [],
269
+ };
270
+
271
+ type Form = {
272
+ name: string;
273
+ age?: number;
274
+ agree_to_terms: boolean;
275
+ tags: { id: string; value: { text: string; time: Date } }[];
276
+ };
166
277
  ```
167
278
 
168
279
  ```typescript
169
280
  'use client'
170
281
  import { useUrlState } from 'state-in-url/next';
171
282
 
172
- import { userSettings } from './userSettings';
173
-
174
- function SettingsComponent() {
175
- // `state` 将从UserSettings类型推断!
176
- const { state, updateUrl } = useUrlState(userSettings);
177
-
178
- const toggleTheme = () => {
179
- updateUrl(current => ({
180
- ...current,
181
- theme: current.theme === 'light' ? 'dark' : 'light',
182
- }));
183
- };
184
-
185
- // 空闲时同步状态到URL
186
- const timer = React.useRef(0 as unknown as NodeJS.Timeout);
187
- React.useEffect(() => {
188
- clearTimeout(timer.current);
189
- timer.current = setTimeout(() => {
190
- // 将通过内容而不是引用比较状态,只为新值触发更新
191
- updateUrl(state);
192
- }, 500);
283
+ import { form } from './form';
193
284
 
194
- return () => {
195
- clearTimeout(timer.current);
196
- };
197
- }, [state, updateUrl]);
285
+ function TagsComponent() {
286
+ // `urlState` 会从 Form 类型推断!
287
+ const { urlState, setUrl } = useUrlState(form);
288
+
289
+ const onChangeTags = React.useCallback(
290
+ (tag: (typeof tags)[number]) => {
291
+ setUrl((curr) => ({
292
+ ...curr,
293
+ tags: curr.tags.find((t) => t.id === tag.id)
294
+ ? curr.tags.filter((t) => t.id !== tag.id)
295
+ : curr.tags.concat(tag),
296
+ }));
297
+ },
298
+ [setUrl],
299
+ );
198
300
 
199
301
  return (
200
302
  <div>
201
- <h2>用户设置</h2>
202
- <p>主题:{state.theme}</p>
203
- <p>字体大小:{state.fontSize}px</p>
204
- <button onClick={toggleTheme}>切换主题</button>
205
- {/* 其他UI元素用于更新其他设置 */}
303
+ <Field text="标签">
304
+ <div className="flex flex-wrap gap-2">
305
+ {tags.map((tag) => (
306
+ <Tag
307
+ active={!!urlState.tags.find((t) => t.id === tag.id)}
308
+ text={tag.value.text}
309
+ onClick={() => onChangeTags(tag)}
310
+ key={tag.id}
311
+ />
312
+ ))}
313
+ </div>
314
+ </Field>
206
315
  </div>
207
316
  );
208
317
  }
209
- ...
210
-
211
- // 其他组件
212
- function Component() {
213
- const { state } = useUrlState(defaultSettings);
214
318
 
215
- return (
216
- <div>
217
- <p>通知 {state.notifications ? '开启' : '关闭'}</p>
218
- </div>
219
- )
220
- }
319
+ const tags = [
320
+ {
321
+ id: '1',
322
+ value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
323
+ },
324
+ {
325
+ id: '2',
326
+ value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
327
+ },
328
+ {
329
+ id: '3',
330
+ value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
331
+ },
332
+ ];
221
333
  ```
222
334
 
223
- #### 自动同步状态
335
+ [示例页面代码](https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-nextjs14/src/app/Form.tsx)
336
+ </details>
337
+
338
+ ##### 仅更新状态并手动同步到 URL
339
+
340
+ <details>
341
+ <Summary>示例</Summary>
342
+
343
+ ```typescript
224
344
 
225
- ```typescript
226
345
  const timer = React.useRef(0 as unknown as NodeJS.Timeout);
227
346
  React.useEffect(() => {
228
347
  clearTimeout(timer.current);
229
348
  timer.current = setTimeout(() => {
230
- // 将通过内容而不是引用比较状态,只为新值触发更新
231
- updateUrl(state);
349
+ // 会比较状态内容而非引用,仅对新值触发更新
350
+ setUrl(urlState);
232
351
  }, 500);
233
352
 
234
353
  return () => {
235
354
  clearTimeout(timer.current);
236
355
  };
237
- }, [state, updateUrl]);
356
+ }, [urlState, setUrl]);
238
357
  ```
239
358
 
240
- #### 服务器端渲染示例
359
+ 在 `onBlur` 时同步状态更符合实际使用场景。
360
+
361
+ ```typescript
362
+ <input onBlur={() => updateUrl()} .../>
363
+ ```
364
+
365
+ </details>
366
+
367
+ ##### 服务端渲染
368
+
369
+ <details>
370
+ <Summary>示例</Summary>
241
371
 
242
372
  ```typescript
243
373
  export default async function Home({ searchParams }: { searchParams: object }) {
244
374
  return (
245
- <Form sp={searchParams} />
375
+ <Form searchParams={searchParams} />
246
376
  )
247
377
  }
248
378
 
@@ -252,17 +382,21 @@ import React from 'react';
252
382
  import { useUrlState } from 'state-in-url/next';
253
383
  import { form } from './form';
254
384
 
255
- const Form = ({ sp }: { sp: object }) => {
256
- const { state, updateState, updateUrl } = useUrlState(form, sp);
385
+ const Form = ({ searchParams }: { searchParams: object }) => {
386
+ const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
257
387
  }
258
388
  ```
259
389
 
260
- #### 在`layout`组件中使用钩子
390
+ </details>
391
+
392
+ ##### 在 `layout` 组件中使用钩子
261
393
 
262
- 这是一个棘手的部分,因为使用app router的nextjs不允许从服务器端访问searchParams。有一个使用中间件的解决方法,但它并不漂亮,而且可能在nextjs更新后停止工作。
394
+ <details>
395
+ <Summary>示例</Summary>
396
+ 这是一个棘手的部分,因为 Next.js 的 app router 不允许从服务端访问 searchParams。可以使用中间件解决,但不够优雅,且可能在 Next.js 更新后失效。
263
397
 
264
398
  ```typescript
265
- // 添加到适当的`layout.tsc`
399
+ // 添加到适当的 `layout.tsc`
266
400
  export const runtime = 'edge';
267
401
 
268
402
  // middleware.ts
@@ -282,7 +416,7 @@ export function middleware(request: NextRequest) {
282
416
  return response;
283
417
  }
284
418
 
285
- // 目标layout组件
419
+ // 目标布局组件
286
420
  import { headers } from 'next/headers';
287
421
  import { decodeState } from 'state-in-url/encodeState';
288
422
 
@@ -300,9 +434,16 @@ export default async function Layout({
300
434
  </div>
301
435
  );
302
436
  }
437
+
438
+
303
439
  ```
304
440
 
305
- #### 任意状态形状示例(不推荐)
441
+ </details>
442
+
443
+ ##### 任意状态结构(不推荐)
444
+
445
+ <details>
446
+ <Summary>示例</Summary>
306
447
 
307
448
  ```typescript
308
449
  'use client'
@@ -311,12 +452,112 @@ import { useUrlState } from 'state-in-url/next';
311
452
  const someObj = {};
312
453
 
313
454
  function SettingsComponent() {
314
- const { state, updateUrl, updateState } = useUrlState<object>(someObj);
455
+ const { urlState, setUrl, setState } = useUrlState<object>(someObj);
456
+ }
457
+ ```
458
+
459
+ </details>
460
+
461
+ ### React-Router 专用的 `useUrlState` 钩子
462
+
463
+ API 与 Next.js 版本相同,只是可以传递 [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99) 类型的选项。
464
+
465
+ [API 文档](packages/urlstate/react-router/useUrlState)
466
+
467
+ #### 示例
468
+
469
+ ```typescript
470
+ export const form: Form = {
471
+ name: '',
472
+ age: undefined,
473
+ agree_to_terms: false,
474
+ tags: [],
475
+ };
476
+
477
+ type Form = {
478
+ name: string;
479
+ age?: number;
480
+ agree_to_terms: boolean;
481
+ tags: { id: string; value: { text: string; time: Date } }[];
482
+ };
483
+
484
+ ```
485
+
486
+ ```typescript
487
+ import { useUrlState } from 'state-in-url/react-router';
488
+
489
+ import { form } from './form';
490
+
491
+ function TagsComponent() {
492
+ const { urlState, setUrl, setState } = useUrlState(form);
493
+
494
+ const onChangeTags = React.useCallback(
495
+ (tag: (typeof tags)[number]) => {
496
+ setUrl((curr) => ({
497
+ ...curr,
498
+ tags: curr.tags.find((t) => t.id === tag.id)
499
+ ? curr.tags.filter((t) => t.id !== tag.id)
500
+ : curr.tags.concat(tag),
501
+ }));
502
+ },
503
+ [setUrl],
504
+ );
505
+
506
+ return (
507
+ <div>
508
+ <Field text="标签">
509
+ <div className="flex flex-wrap gap-2">
510
+ {tags.map((tag) => (
511
+ <Tag
512
+ active={!!urlState.tags.find((t) => t.id === tag.id)}
513
+ text={tag.value.text}
514
+ onClick={() => onChangeTags(tag)}
515
+ key={tag.id}
516
+ />
517
+ ))}
518
+ </div>
519
+ </Field>
520
+
521
+ <input value={urlState.name}
522
+ onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
523
+ // 可以立即更新状态,但根据需要同步到 URL
524
+ onBlur={() => setUrl()}
525
+ />
526
+ </div>
527
+ );
315
528
  }
529
+
530
+ const tags = [
531
+ {
532
+ id: '1',
533
+ value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
534
+ },
535
+ {
536
+ id: '2',
537
+ value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
538
+ },
539
+ {
540
+ id: '3',
541
+ value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
542
+ },
543
+ ];
316
544
  ```
317
545
 
318
- ## React.js的`useSharedState`钩子
319
- 用于在任何React组件之间共享状态的钩子,已在Next.js和Vite中测试。
546
+ [示例代码](packages/example-react-router6/src/Form-for-test.tsx)
547
+
548
+ ---
549
+
550
+ ## 其他钩子和辅助工具
551
+
552
+ ### 其他路由器的 `useUrlStateBase` 钩子
553
+
554
+ 用于创建与其他路由器(如 react-router 或 tanstack router)兼容的 `useUrlState` 钩子。
555
+
556
+ [API 文档](packages/urlstate/useUrlStateBase)
557
+
558
+ ### React.js 的 `useSharedState` 钩子
559
+
560
+ 用于在任何 React 组件之间共享状态的钩子,已在 Next.js 和 Vite 中测试。
320
561
 
321
562
  ```typescript
322
563
  'use client'
@@ -329,59 +570,96 @@ function SettingsComponent() {
329
570
  }
330
571
  ```
331
572
 
332
- [文档](packages/urlstate/useSharedState/README.md)
573
+ [API 文档](packages/urlstate/useSharedState/README.md)
333
574
 
334
- ## React.js的`useUrlEncode`钩子
575
+ ### React.js 的 `useUrlEncode` 钩子
335
576
 
336
- [文档](packages/urlstate/useUrlEncode/README.md)
577
+ [API 文档](packages/urlstate/useUrlEncode/README.md)
337
578
 
338
- ## 纯JS使用的`encodeState`和`decodeState`辅助函数
579
+ ### `encodeState` 和 `decodeState` 辅助函数
339
580
 
340
- [文档](packages/urlstate/encodeState/README.md)
581
+ [API 文档](packages/urlstate/encodeState/README.md)
341
582
 
342
- ## `encode`和`decode`辅助函数
583
+ ### `encode` 和 `decode` 辅助函数
343
584
 
344
- [文档](packages/urlstate/encoder/README.md)
585
+ [API 文档](packages/urlstate/encoder/README.md)
586
+
587
+ ---
345
588
 
346
589
  ## 最佳实践
347
590
 
348
- - 将您的状态形状定义为常量,以确保一致性
349
- - 使用TypeScript以增强类型安全性和自动完成功能
350
- - 避免在URL参数中存储敏感信息
351
- - 使用`updateState`进行频繁更新,使用`updateUrl`同步更改到URL
352
- - 在Next.js中使用`Suspence`包装客户端组件
353
- - 使用这个[扩展](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors)以获得可读的TS错误
591
+ - 将状态结构定义为常量
592
+ - 使用 TypeScript 增强类型安全和自动补全
593
+ - 避免在 URL 参数中存储敏感信息(如 SSN、API 密钥等)
594
+ - 使用此 [扩展](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors) 以获得可读的 TS 错误提示
595
+
596
+ 可以为状态片段创建状态钩子,并在应用程序中重复使用。例如:
597
+ ```Typescript
598
+ type UserState = {
599
+ name: string;
600
+ age: number;
601
+ other: { id: string, value: number }[]
602
+ };
603
+ const userState = {
604
+ name: '',
605
+ age: 0,
606
+ other: [],
607
+ };
608
+
609
+ export const useUserState = () => {
610
+ const { urlState, setUrl, reset } = useUrlState(userState);
611
+
612
+ // 其他逻辑
613
+
614
+ // 导航到其他页面时重置查询参数
615
+ React.useEffect(() => {
616
+ return reset
617
+ }, [])
618
+
619
+ return { userState: urlState, setUserState: setUrl };;
620
+ }
621
+
622
+ ```
354
623
 
355
624
  ## 注意事项
356
625
 
357
- 1. 只能传递可序列化的值,`Function`、`BigInt`或`Symbol`不会工作,可能类似`ArrayBuffer`的东西也不行。
358
- 2. Vercel服务器限制头部(查询字符串和其他内容)的大小为**14KB**,所以保持您的URL状态在~5000个单词以下。https://vercel.com/docs/errors/URL_TOO_LONG
359
- 3. 已在使用app router的`next.js` 14中测试,不计划支持pages。
626
+ 1. 只能传递可序列化的值,`Function`、`BigInt` 或 `Symbol` 无法使用,`ArrayBuffer` 等也可能无法使用。任何可以序列化为 JSON 的内容都可以使用。
627
+ 2. Vercel 服务器限制标头大小(查询字符串和其他内容)为 **14KB**,因此请将 URL 状态保持在约 5000 字以内。<https://vercel.com/docs/errors/URL_TOO_LONG>
628
+ 3. 已在 `next.js` 14/15 的 app router 中测试,暂无支持 pages 的计划。
360
629
 
361
- ## 本地运行
630
+ ---
362
631
 
363
- 克隆此仓库,运行`npm install`然后
632
+ ## 其他
364
633
 
365
- ```sh
366
- npm run dev
367
- ```
634
+ ### 贡献指南与本地运行说明
368
635
 
369
- 转到[localhost:3000](http://localhost:3000)
636
+ 参见 [贡献文档](CONTRIBUTING.md)
637
+
638
+ ## 路线图
639
+
640
+ - [x] `Next.js` 钩子
641
+ - [x] `react-router` 钩子
642
+ - [ ] `remix` 钩子
643
+ - [ ] `svelte` 钩子
644
+ - [ ] `astro` 钩子
645
+ - [ ] 在 hash 中存储状态的钩子?
370
646
 
371
647
  ## 联系与支持
372
648
 
373
- - 创建[GitHub issue](https://github.com/asmyshlyaev177/state-in-url/issues)报告bug、提出功能请求或提问
649
+ - 创建 [GitHub issue](https://github.com/asmyshlyaev177/state-in-url/issues) 以报告错误、请求功能或提出问题
374
650
 
375
- ## [更新日志](https://github.com/asmyshlyaev177/state-in-url/blob/master/CHANGELOG.md)
651
+ ## [更新日志](CHANGELOG.md)
376
652
 
377
653
  ## 许可证
378
654
 
379
- 本项目采用[MIT许可证](https://github.com/asmyshlyaev177/state-in-url/blob/master/LICENSE)。
655
+ 本项目采用 [MIT 许可证](LICENSE)。
380
656
 
381
657
  ## 灵感来源
382
658
 
383
- [在Vue中使用URL存储状态](https://dev.to/jacobandrewsky/using-url-to-store-state-in-vue-275c)
659
+ [NUQS](https://github.com/47ng/nuqs)
660
+
661
+ [在 Vue 中使用 URL 存储状态](https://dev.to/jacobandrewsky/using-url-to-store-state-in-vue-275c)
384
662
 
385
- [在URL中存储状态](https://antonz.org/storing-state/)
663
+ [在 URL 中存储状态](https://antonz.org/storing-state/)
386
664
 
387
- [NextJS useSearchParams](https://nextjs.org/docs/app/api-reference/functions/use-search-params)
665
+ [NextJS useSearchParams](https://nextjs.org/docs/app/api-reference/functions/use-search-params)
package/README.md CHANGED
@@ -57,6 +57,8 @@ Share if it useful for you.
57
57
 
58
58
  </div>
59
59
 
60
+ [Just Show Me Code!](#useurlstate)
61
+
60
62
  ## Why use `state-in-url`?
61
63
 
62
64
  Store any user state in query parameters; imagine JSON in a browser URL. All of it with keeping types and structure of data, e.g. numbers will be decoded as numbers not strings, dates as dates, etc, objects and arrays supported.
@@ -1 +1 @@
1
- import{encode as f,decode as a}from"../encoder/encoder.mjs";import{getParams as i}from"../utils.mjs";function m(o,e,n){const r=i(n);return Object.entries(o||{}).forEach(([t,c])=>{const s=e?.[t];JSON.stringify(c)!==JSON.stringify(s)&&r.set(t,f(c))}),r.toString()}function d(o,e){return{...e||{},...Object.fromEntries([...i(o).entries()].map(([n,r])=>{const t=e?.[n];return[n,a(r,t)??t]}))}}export{d as decodeState,m as encodeState};
1
+ import{encode as f,decode as m}from"../encoder/encoder.mjs";import{getParams as c}from"../utils.mjs";function a(r,e,n){const o=c(n);for(const[t,i]of Object.entries(r||{})){const s=e?.[t];JSON.stringify(i)!==JSON.stringify(s)&&o.set(t,f(i))}return o.toString()}function d(r,e){return{...e||{},...Object.fromEntries([...c(r).entries()].map(([n,o])=>{const t=e?.[n];return[n,m(o,t)??t]}))}}export{d as decodeState,a as encodeState};
@@ -1 +1 @@
1
- import{SYMBOLS as r}from"../constants/constants.mjs";import{typeOf as u}from"../utils.mjs";function p(e){return typeof e=="function"||typeof e=="symbol"?"":l(e)?e:JSON.stringify(structuredClone(e),i).replace(/'/g,"%27").replace(/"/g,"'")}function i(e,t){const c=u(t);if(c!=="object"&&c!=="array")return a(t);if(c==="object"){const n=t;return Object.keys(n).forEach(o=>{n[o]=i(e,n[o])}),n}return c==="array"?t.map(n=>i(e,n)):t}const a=e=>{switch(u(e)){case"date":return r.date+new Date(e).toISOString();case"undefined":return r.undefined;default:return e}};function y(e,t){return f(e.replace(/'/g,'"').replace(/%27/g,"'"),t)}function f(e,t){try{return JSON.parse(e,d)}catch{return t}}function d(e,t){return typeof t=="string"?s(t):t}const s=e=>{if(e!==r.undefined)return e?.startsWith?.(r.date)?new Date(e.slice(1)):e},g=new RegExp(`^(${r.undefined}|${r.date})`),l=e=>g.test(String(e));export{y as decode,s as decodePrimitive,p as encode,a as encodePrimitive,f as parseJSON,d as reviver};
1
+ import{SYMBOLS as n}from"../constants/constants.mjs";import{typeOf as u}from"../utils.mjs";function p(e){return typeof e=="function"||typeof e=="symbol"?"":l(e)?e:JSON.stringify(structuredClone(e),c).replace(/'/g,"%27").replace(/"/g,"'")}function c(e,r){const o=u(r);if(o!=="object"&&o!=="array")return f(r);if(o==="object"){const t=r;for(const i of Object.keys(t))t[i]=c(e,t[i]);return t}return o==="array"?r.map(t=>c(e,t)):r}const f=e=>{switch(u(e)){case"date":return n.date+new Date(e).toISOString();case"undefined":return n.undefined;default:return e}};function y(e,r){return a(e.replace(/'/g,'"').replace(/%27/g,"'"),r)}function a(e,r){try{return JSON.parse(e,s)}catch{return r}}function s(e,r){return typeof r=="string"?d(r):r}const d=e=>{if(e!==n.undefined)return e?.startsWith?.(n.date)?new Date(e.slice(1)):e},g=new RegExp(`^(${n.undefined}|${n.date})`),l=e=>g.test(String(e));export{y as decode,d as decodePrimitive,p as encode,f as encodePrimitive,a as parseJSON,s as reviver};
@@ -1 +1 @@
1
- import i from"react";import{TIMEOUT as R}from"../constants/constants.mjs";import{useInsertionEffect as T}from"../useInsertionEffect.mjs";import{useSharedState as v}from"../useSharedState/useSharedState.mjs";import{useUrlEncode as L}from"../useUrlEncode/useUrlEncode.mjs";import{filterUnknownParamsClient as P}from"../utils.mjs";function b(t,a,u){const{parse:s,stringify:o}=L(t),{state:l,getState:r,setState:n}=v(t,()=>u?.({parse:s})||t),m=i.useRef(void 0);T(()=>{const e=()=>{const w=s(P(t));n(w)};return window.addEventListener(U,e),()=>{window.removeEventListener(U,e),clearTimeout(m.current)}},[n]);const c=i.useRef([]),p=i.useCallback((e,w)=>{const d=typeof e=="function"?e(r()):{...r(),...e},h=o(d,y(t)),S=`${window.location.pathname}${h.length?"?":""}${h}${window.location.hash}`,$=`${window.location.pathname}${window.location.search}${window.location.hash}`;if(S===$)return;n(d);const{replace:g,...k}=w||{};c.current.push([g?"replace":"push",S,k]),clearTimeout(m.current),queueMicrotask(()=>{c.current.length&&(m.current=setTimeout(()=>{const f=c.current.at(-1);c.current=[],a[f[0]](f[1],f[2])},R))})},[a,o,r]),E=i.useCallback(e=>{n(t),p(t,e)},[p,n]);return{updateState:n,updateUrl:p,state:l,reset:E,getState:r}}function y(t){const a=Object.keys(t),u=window.location.search,s=new URLSearchParams(u),o=new URLSearchParams;return s.forEach((l,r)=>!a.includes(r)&&o.set(r,l)),o}const U="popstate";export{b as useUrlStateBase};
1
+ import u from"react";import{TIMEOUT as L}from"../constants/constants.mjs";import{useInsertionEffect as T}from"../useInsertionEffect.mjs";import{useSharedState as v}from"../useSharedState/useSharedState.mjs";import{useUrlEncode as y}from"../useUrlEncode/useUrlEncode.mjs";import{filterUnknownParamsClient as R}from"../utils.mjs";function b(t,a,l){const{parse:s,stringify:o}=y(t),{state:c,getState:n,setState:r}=v(t,()=>l?.({parse:s})||t),m=u.useRef(void 0);T(()=>{const e=()=>{const f=s(R(t));r(f)};return window.addEventListener(U,e),()=>{window.removeEventListener(U,e),clearTimeout(m.current)}},[r]);const i=u.useRef([]),p=u.useCallback((e,f)=>{const d=typeof e=="function"?e(n()):{...n(),...e},h=o(d,C(t)),S=`${window.location.pathname}${h.length?"?":""}${h}${window.location.hash}`,g=`${window.location.pathname}${window.location.search}${window.location.hash}`;if(S===g)return;r(d);const{replace:k,...E}=f||{};i.current.push([k?"replace":"push",S,E]),clearTimeout(m.current),queueMicrotask(()=>{i.current.length&&(m.current=setTimeout(()=>{const w=i.current.at(-1);i.current=[],a[w[0]](w[1],w[2])},L))})},[a,o,n]),$=u.useCallback(e=>{r(t),p(t,e)},[p,r]);return{updateState:r,updateUrl:p,state:c,reset:$,getState:n}}function C(t){const a=Object.keys(t),l=window.location.search,s=new URLSearchParams(l),o=new URLSearchParams;for(const[c,n]of s)!a.includes(c)&&o.set(c,n);return o}const U="popstate";export{b as useUrlStateBase};
package/dist/utils.mjs CHANGED
@@ -1 +1 @@
1
- const c=n=>{const t=typeof n,r=n===null,o=Array.isArray(n),e=n instanceof Date;return r&&"null"||e&&"date"||o&&"array"||!r&&!e&&!o&&t==="object"&&"object"||t},i=typeof window>"u",l=n=>new URLSearchParams(typeof n=="string"?u(n):n?.toString?.()||""),u=n=>n.split("?")?.[1]||n||"",f=(n,t)=>JSON.stringify(n)===JSON.stringify(t);function w(n){const t=new URLSearchParams;return s(n,[...new URLSearchParams(window.location.search).entries()]).forEach(([r,o])=>t.set(r,o)),t.toString()}function p(n,t){return Object.fromEntries(s(n,Object.entries(t||{})))}function s(n,t){const r=Object.keys(n),o=[];for(let e=0;e<t.length;e++)r.includes(t[e]?.[0])&&o.push([t[e][0].replace(/\+/g," "),t[e][1]]);return o}function y(n,t){const r=Object.assign({},n);return Object.entries(n).forEach(([o])=>{const e=o,a=t[e]!==void 0;r[e]=a?t[e]:n[e]}),r}const g={push:n=>{window&&window.history.pushState(null,"",n)},replace:n=>{window&&window.history.replaceState(null,"",n)}},h=/apple/i.test(!i&&navigator?.userAgent||"");export{y as assignValue,s as filterUnknown,p as filterUnknownParams,w as filterUnknownParamsClient,l as getParams,f as isEqual,i as isSSR,h as isSafari,g as routerHistory,c as typeOf};
1
+ const c=n=>{const t=typeof n,e=n===null,o=Array.isArray(n),r=n instanceof Date;return e&&"null"||r&&"date"||o&&"array"||!e&&!r&&!o&&t==="object"&&"object"||t},i=typeof window>"u",l=n=>new URLSearchParams(typeof n=="string"?u(n):n?.toString?.()||""),u=n=>n.split("?")?.[1]||n||"",f=(n,t)=>JSON.stringify(n)===JSON.stringify(t);function w(n){const t=new URLSearchParams,e=s(n,[...new URLSearchParams(window.location.search).entries()]);for(const[o,r]of e)t.set(o,r);return t.toString()}function p(n,t){return Object.fromEntries(s(n,Object.entries(t||{})))}function s(n,t){const e=Object.keys(n),o=[];for(let r=0;r<t.length;r++)e.includes(t[r]?.[0])&&o.push([t[r][0].replace(/\+/g," "),t[r][1]]);return o}function y(n,t){const e=Object.assign({},n);for(const[o]of Object.entries(n)){const r=o,a=t[r]!==void 0;e[r]=a?t[r]:n[r]}return e}const g={push:n=>{window&&window.history.pushState(null,"",n)},replace:n=>{window&&window.history.replaceState(null,"",n)}},S=/apple/i.test(!i&&navigator?.userAgent||"");export{y as assignValue,s as filterUnknown,p as filterUnknownParams,w as filterUnknownParamsClient,l as getParams,f as isEqual,i as isSSR,S as isSafari,g as routerHistory,c as typeOf};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "state-in-url",
3
- "version": "4.1.10",
3
+ "version": "4.1.11",
4
4
  "description": "Store state in URL as in object, types and structure are preserved, with TS validation. Same API as React.useState, wthout any hasssle or boilerplate. Next.js@14-15 and react-router@6-7.",
5
5
  "homepage": "https://state-in-url.dev",
6
6
  "repository": {
@@ -355,6 +355,7 @@
355
355
  "babel-plugin-module-resolver": "^5.0.2",
356
356
  "chokidar": "^4.0.1",
357
357
  "commitizen": "^4.3.0",
358
+ "commitlint": "^19.8.0",
358
359
  "conventional-recommended-bump": "^10.0.0",
359
360
  "cross-env": "^7.0.3",
360
361
  "cz": "^1.8.2",