# 简单实现一个 React 组件 keep-alive
# 前言
Vue
官方提供keep-alive
用于缓存组件,React
则没有,但是也有第三方插件可以使用
已发布 npm
,地址
npm install keep-alive-comp
# 或
yarn add keep-alive-comp
# 1、功能说明
一般来说,keep-alive
至少需要做到两方面:
- 组件状态恢复
- 组件滚动位置恢复
# 2、代码实现
思路:
- 在路由中/或者其他地方,函数作为
children
,形参为 辅助函数cacheProps
,将 辅助函数 附加到组件中(如:Context.Consumer
那样的写法) - 在组件 适当位置(比如跳转到其他路由)将滚动位置
scrollTop
、需要保存的state
作为参数调用beforeRouteLeave
- 回到当前路由/或组件再次渲染,组件加载后,调用 辅助函数 获取之前的
scrollTop
、state
恢复到组件
# 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;
# 3、测试
使用 jest
+ enzyme
测试
# 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/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'],
};
# 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、使用例子
# 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
那样自动恢复一些状态,但是也是一个不影响其他层次的做法;也是一个不错的方案