簡單實現一個 React 組件 keep-alive

前言

Vue 官方提供 keep-alive 用於緩存組件,React 則沒有,可是也有第三方插件可使用css

本文 示例代碼在線例子html

已發佈 npm地址react

npm install keep-alive-comp

# 或
yarn add keep-alive-comp
複製代碼

一、功能說明

通常來講,keep-alive 至少須要作到兩方面:git

  • 組件狀態恢復
  • 組件滾動位置恢復

二、代碼實現

思路:github

  • 在路由中/或者其餘地方,函數做爲 children,形參爲 輔助函數 cacheProps,將 輔助函數 附加到組件中(如:Context.Consumer 那樣的寫法)
  • 在組件 適當位置(好比跳轉到其餘路由)將滾動位置 scrollTop、須要保存的 state 做爲參數調用 beforeRouteLeave
  • 回到當前路由/或組件再次渲染,組件加載後,調用 輔助函數 獲取以前的 scrollTopstate 恢復到組件

2.1 輔助函數

  • beforeRouteLeave:組件卸載時調用,保存滾動位置 scrollTop、狀態 state
  • scrollRestore:再次回到組件時調用,獲取以前保存的滾動位置 scrollTop
  • stateRestore:再次回到組件時調用,獲取以前保存的狀態 state
  • deleteCache:清除組件以前保存的的滾動位置 scrollTop、狀態 state,默認最多5個組件能夠被緩存
  • getKeepAlive:獲取組件緩存的參數
// 輔助函數
export interface KeepAliveAssist {
  beforeRouteLeave?: (scrollTop: number, state: any) => void;
  scrollRestore?: () => number | null;
  stateRestore?: () => any;
  deleteCache?: () => void;
  getKeepAlive?: () => void;
}
複製代碼

2.2 組件參數

  • name:組件標記,如組件名稱
  • store:緩存存儲的地方,默認 window
  • maxLength:最大的緩存組件數,默認 5
  • children:組件子元素,如
    <KeepAlive name="list">{(props) => <List {...props} />}</KeepAlive>
export interface KeepAliveProps {
  name: string;
  store?: any;
  maxLength?: number;
  children: (cacheProps: KeepAliveAssist) => React.ReactElement;
}
複製代碼

2.3 主體代碼

import React, { useEffect } from 'react';

export interface KeepAliveProps {
  name: string;
  store?: any;
  maxLength?: number;
  children: (cacheProps: KeepAliveAssist) => React.ReactElement;
}

// 輔助函數
export interface KeepAliveAssist {
  beforeRouteLeave?: (scrollTop: number, state: any) => void;
  scrollRestore?: () => number | null;
  stateRestore?: () => any;
  deleteCache?: () => void;
  getKeepAlive?: () => void;
}

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

// 組件 keep-alive
const KeepAlive: React.FC<KeepAliveProps> = ({
  name,
  maxLength = 5,
  store = window,
  children,
}) => {
  const cacheName = `__keep_alive_cache__`;
  const isChildrenFunction = typeof children === 'function';

  useEffect(() => {
    if (!isChildrenFunction) {
      console.warn(
        'children傳遞函數,如:\n <KeepAlive name="list">{(props) => <List {...props} />}</KeepAlive>'
      );
    }
  }, []);

  const getKeepAlive = () => {
    return getItem();
  };

  const getCache = () => {
    if (!store[cacheName]) store[cacheName] = [];
    const item = store[cacheName].find((i: CacheItem) => i.name === name);
    return item?.cache() || null;
  };

  // 新增/更新緩存
  const updateCache = (newCache: any, scrollTop: number, state: any) => {
    let index = store[cacheName].findIndex((i: CacheItem) => i.name === name);
    if (index !== -1) {
      store[cacheName].splice(index, 1, {
        name,
        cache: newCache,
        scrollTop,
        state,
      });
    } else {
      store[cacheName].unshift({ name, cache: newCache, scrollTop, state });
    }

    // 最大緩存 maxLength,默認5條
    if (store[cacheName].length > maxLength) store[cacheName].pop();
  };

  // 組件在路由變化前調用
  const beforeRouteLeave = (scrollTop: number = 0, state: any) => {
    updateCache(() => children(cacheProps), scrollTop, state);
  };

  const getItem = (): CacheItem => {
    if (!store[cacheName]) store[cacheName] = [];
    const item = store[cacheName].find((i: CacheItem) => i.name === name);
    return item || null;
  };

  // 返回滾動位置
  const scrollRestore = () => {
    const item = getItem();
    return item?.scrollTop || null;
  };

  // 返回組件的state
  const stateRestore = () => {
    const item = getItem();
    return item?.state || null;
  };

  const deleteCache = () => {
    let index = store[cacheName].findIndex((i: CacheItem) => i.name === name);
    if (index !== -1) {
      store[cacheName].splice(index, 1);
      console.log(`deleteCache-name: ${name}`);
    }
  };

  const cacheProps: KeepAliveAssist = {
    beforeRouteLeave,
    scrollRestore,
    stateRestore,
    deleteCache,
    getKeepAlive,
  };

  return getCache() ?? (isChildrenFunction && children(cacheProps));
};

export default KeepAlive;
複製代碼

三、測試

使用 jest + enzyme 測試typescript

3.1 scripts - test

"scripts": {
  "test": "cross-env NODE_ENV=test jest --config jest.config.js"
},
複製代碼

3.2 jest/enzyme

yarn add -D enzyme jest babel-jest enzyme enzyme-adapter-react-16
複製代碼

若是使用 typescript ,把類型也下載下來 @types/enzyme, @types/jestnpm

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'],
};
複製代碼

3.4 index.test.js

// src/index.test.js
import React from 'react';
import { configure, shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import KeepAlive from './index';

configure({ adapter: new Adapter() });

const Child = (props) => <div className="child">ccccaaaa</div>;

describe('============= keep-alive test =============', () => {
  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('-- 成功渲染 --', () => renderSuccess(wrapper1));
  it('-- 成功附加屬性 KeepAliveAssist 到子組件 children --', () =>
    addPropsSuccess(wrapper1));
  it('-- 子組件, 附加屬性 KeepAliveAssist 返回有效值 --', () => propsValid());

  // 成功渲染
  const renderSuccess = (_wrapper) =>
    expect(_wrapper.render().text() === 'ccccaaaa').toBeTruthy();

  // 成功附加屬性
  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 返回有效值
  const propsValid = () => {
    if (count > 2) 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, cache } = getKeepAlive();
    expect(name).toBe('child');
    expect(scrollTop).toBe(10);
    expect(state).toEqual(['1', '2']);
    const _wrapper = shallow(<KeepAlive name="child">{cache()}</KeepAlive>);

    // 第二次
    renderSuccess(_wrapper);
    addPropsSuccess(_wrapper);
    propsValid(_wrapper);

    deleteCache();
    expect(getKeepAlive()).toBe(null);
  };
});
複製代碼

3.5 yarn test

執行 yarn test緩存

PS F:\code\keep-alive> yarn test
yarn run v1.17.3
$ cross-env NODE_ENV=test jest --config jest.config.js
 PASS  src/index.test.js
  ============= keep-alive test =============
    √ -- children 非函數不渲染 -- (3ms)
    √ -- 成功渲染 -- (18ms)
    √ -- 成功附加屬性 KeepAliveAssist 到子組件 children --
    √ -- 子組件, 附加屬性 KeepAliveAssist 返回有效值 -- (24ms)

  console.log src/index.tsx:99
    deleteCache-name: child

-----------|---------|----------|---------|---------|-------------------
File       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------|---------|----------|---------|---------|-------------------
All files  |   91.11 |    73.08 |   93.33 |   94.59 |
 index.tsx |   91.11 |    73.08 |   93.33 |   94.59 | 37-38
-----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        3.185s
Ran all test suites.
Done in 4.14s.
複製代碼

四、使用例子

4.1 路由文件

// 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(() => import('./pages/list'));
const Detail = lazy(() => 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;
複製代碼

4.2 列表頁

// 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(() => {
    restore();
  }, []);

  const restore = () => {
    const _scrollTop = scrollRestore();
    const _state = stateRestore();

    updateList(
      () =>
        _state?.list || [
          '11111111111111111',
          '22222222222222222',
          '33333333333333333',
          '44444444444444444',
          '55555555555555555',
          '66666666666666666',
        ]
    );
    setTimeout(() => {
      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;
複製代碼

最後

  到這裏就結束了,keep-alive 是實際上頗有用的一個需求,以前寫過使用 display: none; 的方式實現,可是須要改造路由層次,這樣也是複雜化了; 雖然並無像 Vue 那樣自動恢復一些狀態,可是也是一個不影響其餘層次的作法;也是一個不錯的方案bash

相關文章
相關標籤/搜索