阅读本文需要 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
时,渲染 ,当
show
为 false
时, 会被销毁。
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
时, 的根节点
被隐藏,而不会被销毁。
还有一个关键的知识点,在不使用 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({})
来做页面数据保存的节点,是因为我们的上下文不被重新渲染的话 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> ) } 复制代码
感谢阅读,今天的内容实现事比较简单的,但是确实我们踩过好多坑之后才走出的比较舒服的一条路,如果你觉得对你有说帮助,别忘了给我点赞哦。感谢感谢。