From 8b5f382707ef99161c05856ae44968b1d51f83c0 Mon Sep 17 00:00:00 2001 From: Cribug Date: Thu, 27 Feb 2025 17:35:31 +0800 Subject: [PATCH] feat: add useIndexDBState hook --- packages/hooks/src/useIndexDBState/README.md | 92 ++++++++++ .../useIndexDBState/__tests__/index.test.ts | 116 +++++++++++++ .../hooks/src/useIndexDBState/demo/demo1.tsx | 40 +++++ .../hooks/src/useIndexDBState/demo/demo2.tsx | 76 +++++++++ .../hooks/src/useIndexDBState/index.en-US.md | 58 +++++++ packages/hooks/src/useIndexDBState/index.ts | 158 ++++++++++++++++++ .../hooks/src/useIndexDBState/index.zh-CN.md | 58 +++++++ 7 files changed, 598 insertions(+) create mode 100644 packages/hooks/src/useIndexDBState/README.md create mode 100644 packages/hooks/src/useIndexDBState/__tests__/index.test.ts create mode 100644 packages/hooks/src/useIndexDBState/demo/demo1.tsx create mode 100644 packages/hooks/src/useIndexDBState/demo/demo2.tsx create mode 100644 packages/hooks/src/useIndexDBState/index.en-US.md create mode 100644 packages/hooks/src/useIndexDBState/index.ts create mode 100644 packages/hooks/src/useIndexDBState/index.zh-CN.md diff --git a/packages/hooks/src/useIndexDBState/README.md b/packages/hooks/src/useIndexDBState/README.md new file mode 100644 index 0000000000..f348673683 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/README.md @@ -0,0 +1,92 @@ +# useIndexDBState + +A React Hook that stores state into IndexedDB. + +## Examples + +### Basic usage + +```jsx +import React from 'react'; +import { useIndexDBState } from 'ahooks'; + +export default function Demo() { + const [message, setMessage] = useIndexDBState('message', { + defaultValue: 'Hello', + }); + + return ( + <> + setMessage(e.target.value)} + /> + + + ); +} +``` + +### Store complex object + +```jsx +import React from 'react'; +import { useIndexDBState } from 'ahooks'; + +export default function Demo() { + const [user, setUser] = useIndexDBState('user', { + defaultValue: { name: 'Ahooks', age: 1 }, + }); + + return ( + <> +
{JSON.stringify(user, null, 2)}
+ + + ); +} +``` + +## API + +```typescript +type SetState = S | ((prevState?: S) => S); + +interface Options { + defaultValue?: T | (() => T); + dbName?: string; + storeName?: string; + version?: number; + onError?: (error: unknown) => void; +} + +const [state, setState] = useIndexDBState(key: string, options?: Options); +``` + +### Params + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| key | The key of the IndexedDB record | `string` | - | +| options | Optional configuration | `Options` | - | + +### Options + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| defaultValue | Default value | `T \| (() => T)` | `undefined` | +| dbName | Name of the IndexedDB database | `string` | `ahooks-indexdb` | +| storeName | Name of the object store | `string` | `ahooks-store` | +| version | Version of the database | `number` | `1` | +| onError | Error handler | `(error: unknown) => void` | `(e) => console.error(e)` | + +### Result + +| Property | Description | Type | +|----------|-------------|------| +| state | Current state | `T` | +| setState | Set state | `(value?: SetState) => void` | \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/__tests__/index.test.ts b/packages/hooks/src/useIndexDBState/__tests__/index.test.ts new file mode 100644 index 0000000000..1475569601 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/__tests__/index.test.ts @@ -0,0 +1,116 @@ +import { renderHook, act } from '@testing-library/react'; +import useIndexDBState from '../index'; + +// 模拟 IndexedDB +const mockIndexedDB = { + open: jest.fn(), +}; + +const mockIDBOpenDBRequest = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + result: { + transaction: jest.fn(), + close: jest.fn(), + objectStoreNames: { + contains: jest.fn().mockReturnValue(false), + }, + createObjectStore: jest.fn(), + }, +}; + +const mockObjectStore = { + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), +}; + +const mockTransaction = { + objectStore: jest.fn().mockReturnValue(mockObjectStore), +}; + +// 模拟 window.indexedDB +Object.defineProperty(window, 'indexedDB', { + value: mockIndexedDB, + writable: true, +}); + +describe('useIndexDBState', () => { + beforeEach(() => { + // 设置全局 indexedDB 模拟 + mockIndexedDB.open.mockReturnValue(mockIDBOpenDBRequest); + mockIDBOpenDBRequest.result.transaction.mockReturnValue(mockTransaction); + + // 清除之前的模拟调用 + jest.clearAllMocks(); + }); + + it('should initialize with default value', async () => { + const { result } = renderHook(() => + useIndexDBState('test-key', { + defaultValue: 'default value', + }), + ); + + expect(result.current[0]).toBe('default value'); + }); + + it('should update state when setState is called', async () => { + const { result } = renderHook(() => + useIndexDBState('test-key', { + defaultValue: 'default value', + }), + ); + + act(() => { + result.current[1]('new value'); + }); + + expect(result.current[0]).toBe('new value'); + }); + + it('should handle function updater', async () => { + const { result } = renderHook(() => + useIndexDBState('test-key', { + defaultValue: 'default value', + }), + ); + + act(() => { + result.current[1]((prev) => `${prev} updated`); + }); + + expect(result.current[0]).toBe('default value updated'); + }); + + it('should handle undefined value', async () => { + const { result } = renderHook(() => + useIndexDBState('test-key', { + defaultValue: 'default value', + }), + ); + + act(() => { + result.current[1](undefined); + }); + + expect(result.current[0]).toBeUndefined(); + }); + + it('should call onError when an error occurs', async () => { + const onError = jest.fn(); + mockIndexedDB.open.mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + renderHook(() => + useIndexDBState('test-key', { + defaultValue: 'default value', + onError, + }), + ); + + expect(onError).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/demo/demo1.tsx b/packages/hooks/src/useIndexDBState/demo/demo1.tsx new file mode 100644 index 0000000000..d376e59752 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/demo/demo1.tsx @@ -0,0 +1,40 @@ +/** + * title: Basic usage + * desc: Persist state into IndexedDB + * + * title.zh-CN: 基础用法 + * desc.zh-CN: 将状态持久化存储到 IndexedDB 中 + */ + +import React from 'react'; +import useIndexDBState from '../index'; + +export default function Demo() { + const [message, setMessage] = useIndexDBState('message', { + defaultValue: 'Hello', + }); + + return ( + <> + setMessage(e.target.value)} + style={{ width: 200, marginRight: 16 }} + /> + + + + ); +} \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/demo/demo2.tsx b/packages/hooks/src/useIndexDBState/demo/demo2.tsx new file mode 100644 index 0000000000..62df7fe128 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/demo/demo2.tsx @@ -0,0 +1,76 @@ +/** + * title: Store complex object + * desc: useIndexDBState can store complex objects + * + * title.zh-CN: 存储复杂对象 + * desc.zh-CN: useIndexDBState 可以存储复杂对象 + */ + +import React, { useState } from 'react'; +import useIndexDBState from '../index'; + +interface User { + name: string; + age: number; +} + +export default function Demo() { + const [user, setUser] = useIndexDBState('user', { + defaultValue: { name: 'Ahooks', age: 1 }, + }); + + const [inputValue, setInputValue] = useState(''); + const [inputAge, setInputAge] = useState(''); + + return ( + <> +
+ + setInputValue(e.target.value)} + style={{ width: 200, marginRight: 16 }} + /> +
+
+ + setInputAge(e.target.value)} + style={{ width: 200, marginRight: 16 }} + /> +
+
+ + + +
+
+
{JSON.stringify(user, null, 2)}
+
+ + ); +} \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/index.en-US.md b/packages/hooks/src/useIndexDBState/index.en-US.md new file mode 100644 index 0000000000..b99e979c92 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/index.en-US.md @@ -0,0 +1,58 @@ +--- +nav: + path: /hooks +--- + +# useIndexDBState + +A Hook that stores state into IndexedDB. + +## Examples + +### Basic usage + + + +### Complex object + + + +## API + +```typescript +type SetState = S | ((prevState?: S) => S); + +interface Options { + defaultValue?: T | (() => T); + dbName?: string; + storeName?: string; + version?: number; + onError?: (error: unknown) => void; +} + +const [state, setState] = useIndexDBState(key: string, options?: Options); +``` + +### Params + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| key | The key of the IndexedDB record | `string` | - | +| options | Optional configuration | `Options` | - | + +### Options + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| defaultValue | Default value | `T \| (() => T)` | `undefined` | +| dbName | Name of the IndexedDB database | `string` | `ahooks-indexdb` | +| storeName | Name of the object store | `string` | `ahooks-store` | +| version | Version of the database | `number` | `1` | +| onError | Error handler | `(error: unknown) => void` | `(e) => console.error(e)` | + +### Result + +| Property | Description | Type | +|----------|-------------|------| +| state | Current state | `T` | +| setState | Set state | `(value?: SetState) => void` | \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/index.ts b/packages/hooks/src/useIndexDBState/index.ts new file mode 100644 index 0000000000..3faf1b8dbb --- /dev/null +++ b/packages/hooks/src/useIndexDBState/index.ts @@ -0,0 +1,158 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import useMemoizedFn from '../useMemoizedFn'; +import { isFunction } from '../utils'; +import isBrowser from '../utils/isBrowser'; + +export type SetState = S | ((prevState?: S) => S); + +export interface Options { + defaultValue?: T | (() => T); + dbName?: string; + storeName?: string; + version?: number; + onError?: (error: unknown) => void; +} + +function useIndexDBState(key: string, options: Options = {}) { + const { + defaultValue, + dbName = 'ahooks-indexdb', + storeName = 'ahooks-store', + version = 1, + onError = (e) => { + console.error(e); + }, + } = options; + + const [state, setState] = useState(() => { + if (isFunction(defaultValue)) { + return defaultValue(); + } + return defaultValue; + }); + + const dbRef = useRef(null); + const initialized = useRef(false); + + // 从 IndexDB 获取数据 + const getValueFromDB = async (k: string): Promise => { + return new Promise((resolve, reject) => { + if (!dbRef.current) { + resolve(undefined); + return; + } + + try { + const transaction = dbRef.current.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(k); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = (event) => { + onError(event); + reject(event); + }; + } catch (error) { + onError(error); + reject(error); + } + }); + }; + + // 更新 IndexDB 中的数据 + const updateValueInDB = async (k: string, value: T | undefined) => { + return new Promise((resolve, reject) => { + if (!dbRef.current) { + reject(new Error('Database not initialized')); + return; + } + + try { + const transaction = dbRef.current.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + let request; + + if (value === undefined) { + request = store.delete(k); + } else { + request = store.put(value, k); + } + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = (event) => { + onError(event); + reject(event); + }; + } catch (error) { + onError(error); + reject(error); + } + }); + }; + + // 初始化 IndexDB + useEffect(() => { + if (!isBrowser) { + return; + } + + const initDB = () => { + const request = window.indexedDB.open(dbName, version); + + request.onerror = (event) => { + onError(event); + }; + + request.onsuccess = (event) => { + dbRef.current = (event.target as IDBOpenDBRequest).result; + // 初始化完成后,尝试获取数据 + getValueFromDB(key).then((value) => { + if (value !== undefined) { + setState(value); + } + initialized.current = true; + }); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + }; + }; + + if (typeof window !== 'undefined' && window.indexedDB) { + initDB(); + } else { + onError(new Error('IndexedDB is not supported in this environment')); + } + + return () => { + if (dbRef.current) { + dbRef.current.close(); + dbRef.current = null; + } + }; + }, [dbName, storeName, version, key, onError]); + + // 更新状态 + const updateState = useCallback((value?: SetState) => { + const currentState = isFunction(value) ? value(state) : value; + setState(currentState); + + if (initialized.current && isBrowser) { + updateValueInDB(key, currentState).catch(onError); + } + }, [state, key, onError]); + + return [state, useMemoizedFn(updateState)] as const; +} + +export default useIndexDBState; \ No newline at end of file diff --git a/packages/hooks/src/useIndexDBState/index.zh-CN.md b/packages/hooks/src/useIndexDBState/index.zh-CN.md new file mode 100644 index 0000000000..8366e4caa6 --- /dev/null +++ b/packages/hooks/src/useIndexDBState/index.zh-CN.md @@ -0,0 +1,58 @@ +--- +nav: + path: /hooks +--- + +# useIndexDBState + +一个可以将状态持久化存储到 IndexedDB 中的 Hook。 + +## 代码演示 + +### 基础用法 + + + +### 存储复杂对象 + + + +## API + +```typescript +type SetState = S | ((prevState?: S) => S); + +interface Options { + defaultValue?: T | (() => T); + dbName?: string; + storeName?: string; + version?: number; + onError?: (error: unknown) => void; +} + +const [state, setState] = useIndexDBState(key: string, options?: Options); +``` + +### Params + +| 参数 | 说明 | 类型 | 默认值 | +|---------|-------------------------------|----------|--------| +| key | 存储在 IndexedDB 中的键名 | `string` | - | +| options | 可选配置项 | `Options`| - | + +### Options + +| 参数 | 说明 | 类型 | 默认值 | +|--------------|-----------------------------------|---------------------------|-------------------------| +| defaultValue | 默认值 | `T \| (() => T)` | `undefined` | +| dbName | IndexedDB 数据库名称 | `string` | `ahooks-indexdb` | +| storeName | 对象存储空间名称 | `string` | `ahooks-store` | +| version | 数据库版本 | `number` | `1` | +| onError | 错误处理函数 | `(error: unknown) => void`| `(e) => console.error(e)` | + +### Result + +| 参数 | 说明 | 类型 | +|----------|-------------------------|--------------------------------| +| state | 当前状态 | `T` | +| setState | 设置状态的函数 | `(value?: SetState) => void`| \ No newline at end of file