数据存储有效期的方案,支持 localStorage 和 sessionStorage 两种实现。

import { useState, useCallback, useMemo } from 'react';

type IType = 'localStorage' | 'sessionStorage' | undefined;

function useExpireStorage<T>(key: string, defaultValue?: T, type: IType = 'localStorage') {
  const privateKey: string = `private_storage_key_${key}`;
  const storage: Storage = window[type];
  const [state, setState] = useState(defaultValue);

  /**
   * 设置键值
   * @param [any] val 值
   * @param [number = 0] maxAge 存储时间:s | ms
   */
  const setValue = useCallback((val: T, maxAge: number = 0) => {
    // 设置持续时间小于10的自动转化
    const duration = maxAge < 10 ? maxAge * 1000 : maxAge;
    // 返回有效期
    const expires = maxAge === 0 ? 0 : Date.now() + duration;
    const data = {
      val,
      expires,
    };
    storage[privateKey] = JSON.stringify(data);
    setState(val);
  }, []);

  // 移除键值
  const remove = useCallback(() => {
    delete storage[privateKey];
    setState(defaultValue);
  }, []);

  // 返回当前存储的值
  const value: T = useMemo(() => {
    // 防止首次取不到值出现的错误
    const data = storage[privateKey] && JSON.parse(storage[privateKey]);
    if (!data) {
      return state;
    }

    // 不设定期限或者处于有效期内
    if (data.expires === 0 || Date.now() < data.expires) {
      return data.val;
    }

    // 移除键值,防止超期缓存
    remove();
    return state;
  }, [state]);

  return {
    value,
    setValue,
    remove,
  };
}

export default useExpireStorage;

单元测试

import { renderHook, act } from '@testing-library/react-hooks';
import useExpireStorage from '../index';

const privateKey = 'private_storage_key_';

const callSetValue = (hook: any, value: any, expire?: number) => {
  act(() => {
    hook.result.current.setValue(value, expire);
  });
};

const callRemove = (hook: any) => {
  act(() => {
    hook.result.current.remove();
  });
};

describe('useExpireStorage', () => {
  it('should be defined', () => {
    expect(useExpireStorage).toBeDefined();
  });

  beforeAll(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.clearAllTimers();
  });

  afterAll(() => {
    jest.useRealTimers();
  });

  it('test on init', () => {
    const hook = renderHook(() => useExpireStorage('storage', 'Hello World !!!'));
    expect(hook.result.current.value).toEqual('Hello World !!!');
  });

  it('test on methods', async () => {
    const hook = renderHook(() => useExpireStorage('storage'));
    expect(hook.result.current.value).toBeUndefined();
    callSetValue(hook, 'Hello World !!!');
    expect(hook.result.current.value).toEqual('Hello World !!!');
    callRemove(hook);
    expect(hook.result.current.value).toBeUndefined();
  });

  it.each(['localStorage', 'sessionStorage'])('test on expire for %d', (type: any) => {
    const hook = renderHook(() => useExpireStorage('storage', '', type));
    callSetValue(hook, 'Hello World !!!', 1000);
    expect(hook.result.current.value).toEqual('Hello World !!!');
    // 设置强制过期
    callSetValue(hook, '123', -1);
    const storage: Storage | any = window[type];
    expect(storage.getItem(`${privateKey}storage`)).toBeNull();
  });
});