preface

Vue officially provides keep-alive for caching components, while React does not, but third-party plugins do

This article sample code, online examples

NPM, address published

npm install keep-alive-comp

#or
yarn add keep-alive-comp
Copy the code

1. Function description

Generally speaking, keep-alive needs to do at least two things:

  • Component State Recovery
  • Component scroll position restored

2. Code implementation

Ideas:

  • In routing/and elsewhere, functions act aschildrenParameters forAuxiliary function cachePropsThat will beAuxiliary functionAttach to a component (e.g.Context.ConsumerWrite it that way.
  • In the componentAppropriate location(such as jumping to another route) will scroll to the positionscrollTop, need to savestateCall as an argumentbeforeRouteLeave
  • Go back to the current route/or the component is rendered again, the component is loaded after the callAuxiliary functionGet beforescrollTop,stateRestoring to components

2.1 Auxiliary Functions

  • beforeRouteLeave: called when the component is uninstalled, saving the scroll positionscrollTop, state,state
  • scrollRestore: called when returning to the component again to get the previously saved scroll positionscrollTop
  • stateRestore: called when returning to the component again to get the previously saved statestate
  • deleteCache: Clears scroll positions previously saved by the componentscrollTop, state,stateBy default, a maximum of five components can be cached
  • GetKeepAlive: Gets the parameters of the component cache
// Auxiliary functions
export interfaceKeepAliveAssist { beforeRouteLeave? :(scrollTop: number, state: any) = > void; scrollRestore? :(a)= > number | null; stateRestore? :(a)= > any; deleteCache? :(a)= > void; getKeepAlive? :(a)= > void;
}
Copy the code

2.2 Component Parameters

  • name: Component tags, such as component names
  • children: Component child elements, such as

    <KeepAlive name="list">{(props) => <List {... props} />}</KeepAlive>
export interface KeepAliveProps {
  name: string;
  children: (cacheProps: KeepAliveAssist) = > React.ReactElement;
}
Copy the code

2.3 Body Code

KeepAlive

// src/index.ts
import { useEffect } from 'react';
import useKeepAliveCache from './useKeepAliveCache';

export interface KeepAliveProps {
  name: string;
  children: (cacheProps: KeepAliveAssist) = > React.ReactElement;
}

// Auxiliary functions
export interfaceKeepAliveAssist { beforeRouteLeave? :(scrollTop: number, state: any) = > void; scrollRestore? :(a)= > number | null; stateRestore? :(a)= > any; deleteCache? :(a)= > void; getKeepAlive? :(a)= > void;
}

export interface CacheItem {
  name: string; state? :any; scrollTop? :number;
}

/** * component keep-alive * @param {*} name * @param {*} children */
const KeepAlive: React.FC<KeepAliveProps> = ({ name, children }) = > {
  const isChildrenFunction = typeof children === 'function';
  const { getItem, updateCache, deleteCache } = useKeepAliveCache();

  useEffect((a)= > {
    if(! isChildrenFunction) {console.warn(
        'children pass function, such as :\n 
      
       {(props) => < list {... props} />}
      '); }} []);const getKeepAlive = (a)= > {
    return getItem(name);
  };

  // The component is called before the route changes
  const beforeRouteLeave = (scrollTop: number = 0, state: any) = > {
    updateCache({
      name,
      state,
      scrollTop,
    });
  };

  // Return to the scroll position
  const scrollRestore = (a)= > {
    const item = getItem(name);
    returnitem? .scrollTop ||null;
  };

  // Returns the state of the component
  const stateRestore = (a)= > {
    const item = getItem(name);
    returnitem? .state ||null;
  };

  const cacheProps: KeepAliveAssist = {
    beforeRouteLeave,
    scrollRestore,
    stateRestore,
    deleteCache: (a)= > deleteCache(name),
    getKeepAlive,
  };

  return isChildrenFunction ? children(cacheProps) : null;
};

export default KeepAlive;
Copy the code

configKeepAlive

// src/configKeepAlive.ts
export interface ConfigProps {
  store: any;
  maxLength: number; useStorage? :'sessionStorage' | 'localStorage';
}

const CACHE_NAME = `__keep_alive_cache__`;
let DEFAULT_CONFIG: ConfigProps = {
  store: window,
  maxLength: 5,
  useStorage: undefined};/ / configuration
const configKeepAlive = (props: Partial<ConfigProps> = {}) = > {
  const init = (a)= >{ DEFAULT_CONFIG = { ... DEFAULT_CONFIG, ... props };const{ store, maxLength, useStorage } = DEFAULT_CONFIG; store[CACHE_NAME] = { maxLength, useStorage, cacheList: store[CACHE_NAME]? .cacheList || [], }; }; init();return{ cacheName: CACHE_NAME, ... DEFAULT_CONFIG, }; };export default configKeepAlive;
Copy the code

useKeepAliveCache

// src/useKeepAliveCache.ts
import { useEffect } from 'react';
import { CacheItem } from '. ';
import configKeepAlive from './configKeepAlive';

type UpdateStorageCache = {
  _store: any;
  _cacheName: string;
};

export type UpdateCache = {
  name: string;
  state: any;
  scrollTop: number;
};

/ / cache
const useKeepAliveCache = (a)= > {
  const { cacheName, maxLength, store, useStorage } = configKeepAlive();
  const useStorageError = 'useStorage 'can only be: "sessionStorage","localStorage";

  useEffect((a)= > {
    if (useStorage) restoreCache();
    elseclearOldStoraCache(); } []);const clearOldStoraCache = (a)= >{ store.sessionStorage? .removeItem(cacheName); store.localStorage? .removeItem(cacheName); };// Restore the cache from storage, if there is useStorage
  const restoreCache = (a)= > {
    const storageCache = getStorageCache();
    if (storageCache) store[cacheName] = storageCache;
  };

  // Invalid useStorage
  const inValidUseStorage = (): boolean= > {
    return (
      Boolean(useStorage) ! = =false&& useStorage ! = ='sessionStorage'&& useStorage ! = ='localStorage'
    );
  };

  // Get storage cache
  const getStorageCache = (
    { _store, _cacheName }: UpdateStorageCache = {
      _store: store,
      _cacheName: cacheName,
    }
  ) => {
    if (inValidUseStorage()) return console.warn(useStorageError);
    let parsedCache: any = ' ';
    constcache = _store[useStorage!] ? .getItem(_cacheName);if (cache) {
      try {
        parsedCache = JSON.parse(cache);
      } catch (err) {
        clearOldStoraCache();
        console.error('Error recovering cache from storage, storage cache deleted! ', err); }}return parsedCache;
  };

  // Update the storage cache
  const updateStorageCache = (
    { _store, _cacheName }: UpdateStorageCache = {
      _store: store,
      _cacheName: cacheName,
    }
  ) => {
    if (inValidUseStorage()) return console.warn(useStorageError); _store[useStorage!] ? .setItem( _cacheName,JSON.stringify({ ... _store[_cacheName], maxLength, useStorage }) ); };const getCacheList = (): CacheItem[] => {
    const storeCache = store[cacheName];
    return storeCache.cacheList;
  };

  const getItem = (name: string) = > {
    let cacheList = getCacheList();
    const item = cacheList.find((i: CacheItem) = > i.name === name);
    return item || null;
  };

  // Add/update the cache
  const updateCache = ({ name, scrollTop, state }: UpdateCache) = > {
    let cacheList = getCacheList();
    let index = cacheList.findIndex((i: CacheItem) = > i.name === name);
    if(index ! = =- 1) {
      cacheList.splice(index, 1, {
        name,
        state,
        scrollTop,
      });
    } else {
      cacheList.unshift({
        name,
        state,
        scrollTop,
      });
    }

    // Maximum cache length, 5 by default
    if (cacheList.length > maxLength) cacheList.pop();
    / / update of storage
    if (useStorage) updateStorageCache();
  };

  const deleteCache = (name: string) = > {
    let cacheList = getCacheList();
    let index = cacheList.findIndex((i: CacheItem) = > i.name === name);
    if(index ! = =- 1) {
      cacheList.splice(index, 1);
      / / update of storage
      if(useStorage) updateStorageCache(); }};return {
    getItem,
    updateCache,
    deleteCache,
    getStorageCache,
  };
};

export default useKeepAliveCache;
Copy the code

3, test,

Use the Jest + enzyme test

3.1 scripts – test

"scripts": {
  "test": "cross-env NODE_ENV=test jest --config jest.config.js"
},
Copy the code

3.2 jest/enzyme

yarn add -D enzyme jest babel-jest enzyme enzyme-adapter-react-16
Copy the code

If you use typescript, download the types @types/enzyme, @types/jest

3.3 jest. Config. Js

//jest.config.js
module.exports = {
  modulePaths: ['<rootDir>/src/'].moduleNameMapper: {
    '.(css|less)$': '<rootDir>/__test__/NullModule.js',},collectCoverage: true.coverageDirectory: '<rootDir>/src/'.coveragePathIgnorePatterns: ['<rootDir>/__test__/'].coverageReporters: ['text']};Copy the code

3.4 __test__

index.test.js

// __test__/index.test.js import React from 'react'; import { configure, shallow } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import KeepAlive from '.. /src/index'; import configKeepAliveTest from './configKeepAliveTest'; import useKeepAliveCacheTest from './useKeepAliveCacheTest'; configure({ adapter: new Adapter() }); const Child = (props) => <div className="child">ccccaaaa</div>; describe('============= keep-alive test =============', () => { configKeepAliveTest({ store: global, maxLength: 2, useStorage: 'sessionStorage', }); useKeepAliveCacheTest('child'); const wrapper1 = shallow( <KeepAlive name="child">{(props) => <Child {... props} />}</KeepAlive> ); const wrapper2 = shallow( <KeepAlive name="child"> <Child /> </KeepAlive> ); It ('-- children '), () => {expect(typeof wrapper2.children() === 'function').tobe (false); expect(wrapper2.html()).toBe(null); }); / / it for the first time (' - successful rendering - '() = > renderSuccess (wrapper1)); It ('-- success attach attribute KeepAliveAssist to child component children --', () => addPropsSuccess(wrapper1)); It ('-- subcomponent, attached property KeepAliveAssist returns valid --', () => propsValid()); // Const renderSuccess = (_wrapper) => expect(_wrapper. Render ().text() === 'ccccaaaa').tobetruthy (); // Const renderSuccess = (_wrapper) => expect(_wrapper. Const addPropsSuccess = (_wrapper) => {const assistProps = ['beforeRouteLeave', 'scrollRestore', 'stateRestore', 'deleteCache', 'getKeepAlive', ]; const props = _wrapper.props(); const keys = Object.keys(props); const has = assistProps.every((key) => keys.includes(key)); expect(has).toBeTruthy(); }; let count = 0; KeepAliveAssist Returns valid const propsValid = () => {if (count > 1) return; count++; const { beforeRouteLeave, scrollRestore, stateRestore, deleteCache, getKeepAlive, } = wrapper1.props(); beforeRouteLeave(10, ['1', '2']); expect(scrollRestore()).toBe(10); expect(stateRestore()).toEqual(['1', '2']); const { name, scrollTop, state } = getKeepAlive(); expect(name).toBe('child'); expect(scrollTop).toBe(10); expect(state).toEqual(['1', '2']); // beforeRouteLeave(100, ['11', '22']); expect(scrollRestore()).toBe(100); expect(stateRestore()).toEqual(['11', '22']); const { name: name2, scrollTop: scrollTop2, state: state2, } = getKeepAlive(); expect(name2).toBe('child'); expect(scrollTop2).toBe(100); expect(state2).toEqual(['11', '22']); deleteCache(); expect(getKeepAlive()).toBe(null); }; });Copy the code

configKeepAliveTest.js

// __test__/configKeepAliveTest.js
import configKeepAlive from '.. /src/configKeepAlive';

function configKeepAliveTest(config) {
  const { cacheName, store, maxLength, useStorage } = configKeepAlive(config);

  it('-- configKeepAlive test --', () => {
    expect(cacheName).toBe('__keep_alive_cache__');
    expect(store).toBe(global);
    expect(maxLength).toBe(config.maxLength);
    expect(useStorage).toBe(config.useStorage);
  });
}

export default configKeepAliveTest;
Copy the code

useKeepAliveCacheTest.js

// __test__/useKeepAliveCacheTest.js
import { renderHook } from '@testing-library/react-hooks';
import useKeepAliveCache from '.. /src/useKeepAliveCache';
import configKeepAlive from '.. /src/configKeepAlive';

function useKeepAliveCacheTest(name) {
  it('-- useStorage: undefined test --', () = > {const useStorageValue = undefined;
    const configKeepAliveProps = renderHook((a)= >
      configKeepAlive({ store: global, useStorage: useStorageValue })
    ).result.current;
    const useKeepAliveCacheProps = renderHook((a)= >useKeepAliveCache()).result .current; testHandler({ name, ... configKeepAliveProps, ... useKeepAliveCacheProps, useStorageValue, }); }); it('-- useStorage: sessionStorage 'test --', () = > {const useStorageValue = 'sessionStorage';
    const configKeepAliveProps = renderHook((a)= >
      configKeepAlive({ store: global, useStorage: useStorageValue })
    ).result.current;
    const useKeepAliveCacheProps = renderHook((a)= >useKeepAliveCache()).result .current; testHandler({ name, ... configKeepAliveProps, ... useKeepAliveCacheProps, useStorageValue, }); }); }function testHandler({ name, cacheName, store, useStorage, getItem, updateCache, deleteCache, getStorageCache, useStorageValue, }) {
  expect(useStorage).toBe(useStorageValue);

  expect(getItem(name)).toBe(null);

  const cache1 = { name, scrollTop: 10.state: { a: 'aa'}}; updateCache(cache1); expect(getItem(name)).toEqual(cache1);if (useStorageValue) {
    const cache1 = getStorageCache({ _store: store, _cacheName: cacheName });
    expect(Boolean(cache1)).not.toBeFalsy();

    // Data in non-JSON format
    store[useStorageValue].setItem(cacheName, 'dd');
    const cache2 = getStorageCache({ _store: store, _cacheName: cacheName });
    expect(cache2).toBe(' ');

    const storeCache = store[useStorageValue].getItem(cacheName);
    expect(storeCache).toBe(null);

    // useStorage does not pass as required
  } else {
    const cache = getStorageCache();
    expect(Boolean(cache)).toBeFalsy();
  }

  const cache2 = {
    name,
    scrollTop: 100.state: { a1: 'aa1'}}; updateCache(cache2); expect(getItem(name)).toEqual(cache2); deleteCache(name); expect(getItem(name)).toBe(null);
}

export default useKeepAliveCacheTest;
Copy the code

3.5 yarn test

Perform yarn test

zero9527@zero9527deMBP keep-alive (master) $ yarn testYarn Run v1.22.1 $cross-env NODE_ENV=test jest --config jest.config.js
 PASS  __test__/index.test.js
  ============= keep-alive test= = = = = = = = = = = = = ✓ configKeepAlive test - - (4 ms) ✓ useStorage: undefined test - 14 (ms) ✓ useStorage:"sessionStorage"✓ The test -- (31ms) ✓ -- children non-function not to render -- (2ms) ✓ -- successfully render -- (11ms) ✓ -- Successfully attach attribute KeepAliveAssist to child component children -- (4ms) ✓ -- Child component, Additional Properties KeepAliveAssist Return valid value -- (7ms) console.error Error recovering cache from storage, storage cache deleted! SyntaxError: Unexpected token din JSON at position 0
        at JSON.parse (<anonymous>)
        at getStorageCache (/Users/zero9527/Desktop/FE/keep-alive/src/useKeepAliveCache.ts:59:28)
        at testHandler (/Users/zero9527/Desktop/FE/keep-alive/__test__/useKeepAliveCacheTest.js:64:20) at Object.<anonymous> (/Users/zero9527/Desktop/FE/keep-alive/__test__/useKeepAliveCacheTest.js:30:5) at Object.asyncJestTest (/Users/zero9527/Desktop/FE/keep-alive/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:100:37) at resolve (/Users/zero9527/Desktop/FE/keep-alive/node_modules/jest-jasmine2/build/queueRunner.js:45:12) at new Promise (<anonymous>) at mapper (/Users/zero9527/Desktop/FE/keep-alive/node_modules/jest-jasmine2/build/queueRunner.js:28:19) at  promise.then (/Users/zero9527/Desktop/FE/keep-alive/node_modules/jest-jasmine2/build/queueRunner.js:75:41) 60 | } catch  (err) { 61 | clearOldStoraCache(); > 62 | console.error('Error recovering cache from storage, storage cache deleted! ', err);
         |                 ^
      63 |       }
      64 |     }
      65 | 

      at getStorageCache (src/useKeepAliveCache.ts:62:17)
      at testHandler (__test__/useKeepAliveCacheTest.js:64:20)
      at Object.<anonymous> (__test__/useKeepAliveCacheTest.js:30:5)

----------------------|---------|----------|---------|---------|---------------------
File                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s   -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - | -- -- -- -- -- -- -- -- - | -- -- -- -- -- -- -- -- -- - | -- -- -- -- -- -- -- -- - | -- -- -- -- -- -- -- -- - | -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- All files | | | | 95.65 71.79 93.1 97.4 | configKeepAlive. Ts | 100 | 100 | 100 | 100 | index. The ts | | | | | 89.47 85.71 44.44 89.47 34 and 35 UseKeepAliveCache. Ts | 93.22 77.78 | | 100 | 100 | 34-40,53,76,113,121 ----------------------|---------|----------|---------|---------|--------------------- Test Suites: 1 passed, 1 total Tests: 7 passed, 7 total Snapshots: 0 Total Time: 2.399s, estimated 8s Ran alltestSuites. ✨ Donein4.08 s.Copy the code

4. Use examples

4.1 Routing File

// example/Router.tsx
import React, { Suspense } from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import { lazy } from '@loadable/component';
import KeepAlive from 'keep-alive-comp';

const List = lazy((a)= > import('./pages/list'));
const Detail = lazy((a)= > import('./pages/detail'));

const Router: React.FC = ({ children }) = >( <HashRouter> {children} <Switch> <Route exact path="/" component={() => ( <Suspense fallback={<div>loading... </div>}> <KeepAlive name="list">{(props) => <List {... props} />}</KeepAlive> </Suspense> )} /> <Route exact path="/detail/:id" component={() => ( <Suspense fallback={<div>loading... </div>}> <Detail /> </Suspense> )} /> <Route path="*" render={() => <h3>404</h3>} /> </Switch> </HashRouter> ); export default Router;Copy the code

4.2 list page

// example/pages/list.tsx
import React, { useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router';
import { KeepAliveAssist } from 'keep-alive';
import '.. /styles.css';

export interface ListProps extends KeepAliveAssist {}

const List: React.FC<ListProps> = ({
  beforeRouteLeave,
  scrollRestore,
  stateRestore,
  deleteCache,
}) => {
  const history = useHistory();
  const listRef = useRef<HTMLDivElement | null> (null);
  const [scrollTop, setScrollTop] = useState(0);
  const [list, updateList] = useState([]);

  useEffect((a)= >{ restore(); } []);const restore = (a)= > {
    const _scrollTop = scrollRestore();
    const _state = stateRestore();

    updateList(
      (a)= >_state? .list || ['11111111111111111'.'22222222222222222'.'33333333333333333'.'44444444444444444'.'55555555555555555'.'66666666666666666',]); setTimeout((a)= > {
      listRef.current.scrollTop = _scrollTop;
    }, 0);
  };

  const onScroll = (e: any) = > {
    e.persist();
    const top = e.target.scrollTop;
    setScrollTop(top);
    const scrollHeight = listRef.current.scrollHeight;
    const offsetHeight = listRef.current.offsetHeight;
    if (scrollHeight - offsetHeight - top <= 50) {
      const temp = new Array(5)
        .fill(' ')
        .map((i, index) = >
          new Array(17).fill(`${list.length + index + 1}`).join(' ')); updateList((prev) = >[...prev, ...temp]); }};const toDetail = (i) = > {
    beforeRouteLeave(scrollTop, { list });
    history.push(`/detail/${i}`);
  };

  return (
    <div className="list" ref={listRef} onScroll={onScroll}>
      {list.map((i) => (
        <div className="item" key={i} onClick={()= > toDetail(i)}>
          {i}
        </div>
      ))}
    </div>
  );
};

export default List;
Copy the code

The last

So that’s the end of it. Keep-alive is actually a very useful requirement, as I wrote earlier using display: None; , but need to transform the routing level, which is also complicated; While it does not automatically restore some states like Vue does, it is a way to do it without affecting other levels; It’s also a good plan