挑战21天手写前端框架 day9 50行代码实现页面状态保持 keepalive
阅读本文需要 15 分钟,编写本文耗时 2 小时
什么是页面状态保持
所谓的页面状态保持就是从 A 页面到 B 页面,再回到 A 页面的时候,希望 A 页面能够保持在离开前的状态。常见的业务场景就是从列表页进去详情页面,再回到列表页面时,期望页面的搜索查询状态,页面的滚动状态能够保持在原来的位置上。这个需求特别是在做移动端的时候,可以说是必备的需求,因为在 pc 端页面上我们还可以通过在 url 上保存分页信息来让返回体验保留在一个可以接受的状态上。但是在移动端,我们的长列表页面都是通过滚动加载更多的方式请求的数据,如果没有状态保持,那可能用户滚动了十几页然后进入详情页回来,又要从第一页开始重新滚动,这样的交互体验是极差的。
还有一个比较常见的场景就是移动端填写长表单,其中的某一个值需要跳转一个新页面获取,回来还要将数据还原。如果没有状态保持,我们都是将临时的表单数据放到全局的数据流中,等到返回页面的时候,再从全局的数据流中加载数据。特别是有些数据复杂度很高的时候,这个开发非常的耗费精力,因为你要时刻关注用户什么时间点会离开。
可能我们的交付项目多是在移动端,所以状态保持几乎都是每一个项目的强需求。这在 vue 中很早就提供了一个配置实现,而在 React 中缺迟迟没有官方提供。所以社区上也有不少的朋友在鼓捣这个方案。
像我的好朋友CJY,就特别擅长和热衷于此。在 react-route@6 之前,我们提供了两种不同的方案在 umi 生态中使用。用的人还不少,反响也挺好。但是实现上还是比较复杂的,没有一点基础的朋友,要掌握这些是比较困难的。
但是在 react-route@6 发布之后,我们有了一种更加简单优雅的方式实现,这就是我今天要介绍的内容。
实现原理
先简单的说一下实现原理吧。先来看两段简单的代码吧。他们的作用都是一样的,都是控制组件显隐。
import React, { useState } from 'react';
const CountItem = () => {
const [count, setCount] = useState(0);
return <div onClick={() => setCount(count + 1)}>{count}</div>
}
const Hello = () => {
const [show, setShow] = useState(true);
return <>
{show && <CountItem />}
</>
}
上面的逻辑是当 show
为 true
时,渲染 <CountItem />
,当 show
为 false
时,<CountItem />
会被销毁。
import React, { useState } from 'react';
const CountItem = () => {
const [count, setCount] = useState(0);
return <div onClick={() => setCount(count + 1)}>{count}</div>
}
const Hello = () => {
const [show, setShow] = useState(true);
return <>
{<div hidden={!show}><CountItem /></div>}
</>
}
上面的逻辑最大的差别就是 当 show
为 false
时,<CountItem />
的根节点 <div>
被隐藏,而不会被销毁。
还有一个关键的知识点,在不使用 react-route@6
的情况下,它是必须的。那就是 key
在 React 中的作用。
const Hello = () => {
const [show, setShow] = useState(true);
return <>
{<div key="保持key不变"><CountItem /></div>}
</>
}
只要在返回的 dom 中,保持 key 不变,就不会触发 React 的重绘。这也是为什么在 list map 的时候,一定要指定一个 key
的关键原因。这个内容不是今天的重点,这里就不深入展开,如果你的项目中用不上 react-route@6
但你有需要状态保持,那你可以参考 alita@2 中的实现。
效果展示
从上面的动图可以看出,我们在首页和用户页面,页面状态都得到了保持,在用户页面点击清除缓存之后,用户页面的状态被重置到初始状态。
50行的源码实现
上面提到的效果,完全符合我们的预期需求,并且实现也非常的简单和优雅。主要是使用了上下文来保存页面数据,通过调用 react-route@6
的 useOutlet
来取到真实的 demo。
import React, { useRef, createContext, useContext } from 'react';
import { useOutlet, useLocation, matchPath } from 'react-router-dom'
import type { FC } from 'react';
export const KeepAliveContext = createContext<KeepAliveLayoutProps>({ keepalive: [], keepElements: {} });
const isKeepPath = (aliveList: any[], path: string) => {
let isKeep = false;
aliveList.map(item => {
if (item === path) {
isKeep = true;
}
if (item instanceof RegExp && item.test(path)) {
isKeep = true;
}
if (typeof item === 'string' && item.toLowerCase() === path) {
isKeep = true;
}
})
return isKeep;
}
export function useKeepOutlets() {
const location = useLocation();
const element = useOutlet();
const { keepElements, keepalive } = useContext<any>(KeepAliveContext);
const isKeep = isKeepPath(keepalive, location.pathname);
if (isKeep) {
keepElements.current[location.pathname] = element;
}
return <>
{
Object.entries(keepElements.current).map(([pathname, element]: any) => (
<div key={pathname} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout" hidden={!matchPath(location.pathname, pathname)}>
{element}
</div>
))
}
<div hidden={isKeep} style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }} className="rumtime-keep-alive-layout-no">
{!isKeep && element}
</div>
</>
}
interface KeepAliveLayoutProps {
keepalive: any[];
keepElements?: any;
dropByCacheKey?: (path: string) => void;
}
const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
const { keepalive, ...other } = props;
const keepElements = React.useRef<any>({})
function dropByCacheKey(path: string) {
keepElements.current[path] = null;
}
return (
<KeepAliveContext.Provider value={{ keepalive, keepElements, dropByCacheKey }} {...other} />
)
}
export default KeepAliveLayout;
配置 keepalive
配置 keepalive 支持字符串和正则,通过它来判断,当前页面是否需要状态保持,因为如果整个项目的页面都保持状态的话,对性能是很大的消耗。方法 isKeepPath
的实现也很简单。
useKeepOutlets
使用 useKeepOutlets
取到需要渲染的组件,它包含了当前页面的组件,和缓存中的组件。通过判断当前页面是否是需要保持的页面来对页面 DOM 做一个 hidden
显隐开关。
指的注意的是所有被指定状态保持的页面在首次渲染之后,都会被挂载在页面 DOM 树上,仅仅是使用 !matchPath(location.pathname, pathname)
控制显隐。
而没有被指定状态保持的页面,则是使用 {!isKeep && element}
控制,走 React 组件正常的生命周期。
React.useRef({}) 与 {}
const keepElements = React.useRef<any>({})
使用 React.useRef<any>({})
来做页面数据保存的节点,是因为我们的上下文不被重新渲染的话 keepElements
就不会被重置。用它替代了 key
的特性。
发布 @malitajs 组织下的包
新建 packages/keepalive/src/index.tsx
,将上面的代码放进去。
修改包名 packages/keepalive/package.json
为 @malitajs/keepalive
。
修改 main 入口 "main": "lib/index.js",
和 types 文件路径 "types": "lib.index.d.ts",
。
添加发包配置 "publishConfig": { "access": "public" },
。
增加构建脚本 "scripts": { "build": "tsc", },
增加 tsconfig packages/keepalive/tsconfig.json
,值得注意的是 jsx
和 declaration
配置,"declaration": true
才会输出 .d.ts
文件。
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"declaration": true,
"outDir": "./lib",
"rootDir": "./src"
},
"include": ["src","client"]
}
构建后执行发包 npm publish
,发布成功之后大家就可以在项目中安装使用它了。
这个包是在 alita@3 中经过 20 几个项目验证过生产环境的,比较靠谱的,可以酌情考虑是否用到生产上。
授之以鱼 @malita/keepalive
将今天的内容封装成独立的 React 中的状态保持组件
安装
yarn add @malita/keepalive
使用
import KeepAliveLayout, { useKeepOutlets, KeepAliveContext } from '@malita/keepalive';
import { useLocation } from 'react-router-dom';
import React, { useState, useContext } from 'react';
// 使用 useKeepOutlets 取到当前渲染的页面内容,可能是缓存内容
const Layout = () => {
const element = useKeepOutlets();
return (
{element}
)
}
// 使用 KeepAliveLayout 包裹上下文
const App = () => {
return (
<KeepAliveLayout keepalive={[/./]}>
// App
</KeepAliveLayout>
);
}
// 使用 useContext 取到 dropByCacheKey 清除缓存
const Home = () => {
const { dropByCacheKey } = useContext<any>(KeepAliveContext);
const { pathname } = useLocation();
return (
<button onClick={() => dropByCacheKey(pathname)}> Click Me! Clear Cache!</button>
)
}
感谢阅读,今天的内容实现事比较简单的,但是确实我们踩过好多坑之后才走出的比较舒服的一条路,如果你觉得对你有说帮助,别忘了给我点赞哦。感谢感谢。
转载自:https://juejin.cn/post/7088705614603354142