Vue
官方提供keep-alive
用於緩存組件,React
則沒有,可是也有第三方插件可使用css
已發佈 npm
,地址react
npm install keep-alive-comp
# 或
yarn add keep-alive-comp
複製代碼
通常來講,keep-alive
至少須要作到兩方面:git
思路:github
children
,形參爲 輔助函數 cacheProps
,將 輔助函數 附加到組件中(如:Context.Consumer
那樣的寫法)scrollTop
、須要保存的 state
做爲參數調用 beforeRouteLeave
scrollTop
、state
恢復到組件beforeRouteLeave
:組件卸載時調用,保存滾動位置 scrollTop
、狀態 state
scrollRestore
:再次回到組件時調用,獲取以前保存的滾動位置 scrollTop
stateRestore
:再次回到組件時調用,獲取以前保存的狀態 state
deleteCache
:清除組件以前保存的的滾動位置 scrollTop
、狀態 state
,默認最多5個組件能夠被緩存// 輔助函數
export interface KeepAliveAssist {
beforeRouteLeave?: (scrollTop: number, state: any) => void;
scrollRestore?: () => number | null;
stateRestore?: () => any;
deleteCache?: () => void;
getKeepAlive?: () => void;
}
複製代碼
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;
}
複製代碼
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
"scripts": {
"test": "cross-env NODE_ENV=test jest --config jest.config.js"
},
複製代碼
yarn add -D enzyme jest babel-jest enzyme enzyme-adapter-react-16
複製代碼
若是使用 typescript
,把類型也下載下來 @types/enzyme
, @types/jest
npm
//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'],
};
複製代碼
// 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);
};
});
複製代碼
執行 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.
複製代碼
// 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;
複製代碼
// 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