先看场景
场景1
如上图所示,当在数万级别的数据中,选择一条,点击查看,跳转到当前数据的详情页,当点击按钮返回返回来,或者是浏览器前进后退等其他操作,返回到列表页的时候。要记录当前列表的位置。也就是要还原点击查看查看前的页面。但是当点击tab菜单按钮的时候,要清除页面信息。
场景2
如上图所示,当我们编辑内容的时候,一些数据可能从其他页面获得,所以要求,无论切换路由,切换页面,当前页面的编辑信息均不能被置空,只有点击确定 ,重置,表单才内容置空。
场景3
场景一 + 场景二 是更复杂的缓存页面信息场景。
什么是keep-alive
对于上述场景,vue有比较好的解决方案,就是keep-alive组件,这是vue的内置组件,看看官方说明(vue/keep-alive)
react实际开发中保存页面状态的常用方式
- 将页面状态保存到redux中,回到该页面时,获取store中该页面的状态,填充到页面中
- 将页面状态保存在localStorage中,回到页面从localStorage获取状态
但这样做的弊端也比较多:
- 表单组件是非受控组件,是无法缓存下来的;
- dom状态是缓存不了的,比如手动添加的一些样式等,常见的滚动条位置也无法回到原来位置;
- 还有就是实际情况比较复杂,有富文本组件,是无法直接获取绑定的state的;
- 对于页面一些数据,还得重新发请求获取。
react-router根据路由对页面的处理方式
react-router主要是通过Route组件和Switch组件控制页面的切换的,看一下route.js源码中控制页面展示的核心代码:
const match = this.props.computedMatch
? this.props.computedMatch
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = { ...context, location, match };
return (
<RouterContext.Provider value={props}>
{props.match
? children
? typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: children
: component
? React.createElement(component, props)
: render
? render(props)
: null
: typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: null
}
</RouterContext.Provider>
)
computedMatch是Switch组件为了挺高效率,计算好匹配结果,然后直接传给route组件的props中,通过match结果,判断如何渲染children。
如果没有Switch组件包裹,则Route组件通过props中path判断是否匹配,如果没有匹配,再进一步判断children是不是function,如果是,会执行function,否则就返回null。
具体优先级如下:
- match
- children === function ? children(props): children
- component ? React.createElement(component, props)
- render ? render(props) : null
- unmatch
- children === function ? children() : null
知道了Route控制渲染的逻辑,再看看Switch是怎么通过computedMatch匹配组件的
React.Children.forEach(this.props.children, child => {
if (match == null && React.isValidElement(child)) {
element = child;
const path = child.props.path || child.props.from;
match = path
? matchPath(location.pathname, { ...child.props, path })
: context.match;
}
});
return match
? React.cloneElement(element, { location, computedMatch: match })
: null;
这里的匹配比较简单,遍历children的节点,matchPath匹配该节点pathname,将结果赋值到computedMatch传递给Route组件。
结论
- 对于Switch直接包裹的Route组件是优先根据props中的computedMatch来判断是否渲染children;
- 对于单独的Route组件,有path的情况下,是根据children是否是function来进行判断,是function会执行children方法。
基于Route组件和Switch组件的处理方式进行扩展
对于Switch的核心代码的扩展
child = React.cloneElement(element, {
location,
computedMatch: match,
...(isNull(match)
? {
computedMatchForCacheRoute: {
[COMPUTED_UNMATCH_KEY]: true
}
}
: null)
})
对于没有匹配到的组件,给Route组件props增加一个参数computedMatchForCacheRoute,用以未匹配的Route组件进行判断
对应Route组件的改变
if (computedMatchForCacheRoute) {
props.computedMatch = computedMatchForCacheRoute
}
判断有computedMatchForCacheRoute属性,就覆盖computedMatch的值,Route组件中的this.props.computedMatch就都有值,所有Switch的子Route都会保留。
这样对于Switch包裹的Route组件实际上都是匹配成功的,后续对于未匹配的页面进行隐藏即可;
对于Route组件,不管匹配结果如何,只要我们将children组件用函数配置,最终都会通过children函数来控制渲染结果。
对比如下图:
通过Route的children函数来控制组件的展示
页面缓存涉及的关键点:
- 对于未匹配的组件,实际上是保留了dom,对于未匹配的组件,将组件进行隐藏;
- 缓存生命周期的注入,用以控制缓存页面的副作用;
- 也可以给每个组件添加cachekey,手动控制缓存组件;
- 对于动态路由,为了页面性能,需要控制缓存组件的数量;
关于缓存生命周期的注入和调用
参考vue生命周期函数,对应添加useDidCache,useDidRecover生命周期函数;
- function组件会自动执行这两个函数进行注册;
import React, { useEffect } from 'react'
import { useDidCache, useDidRecover } from 'react-router-cache-route'
function HomePage(props) {
useDidCache(() => {
console.log('List did cache')
})
useDidRecover(() => {
console.log('List did recover')
})
return (
...
)
}
- 对于Class组件,需要手动调用,使用CacheRoute的组件将会得到一个名为cacheLifecycles的属性,里面包含两个额外生命周期的注入函数 didCache 和 didRecover,分别在组件被缓存和被恢复时触发。
以下是Class组件注入缓存生命周期方式
import React, { Component } from 'react'
export default class List extends Component {
constructor(props) {
super(props)
props.cacheLifecycles.didCache(this.componentDidCache)
props.cacheLifecycles.didRecover(this.componentDidRecover)
}
componentDidCache = () => {
console.log('List cached')
}
componentDidRecover = () => {
console.log('List recovered')
}
render() {
return (
...
)
}
}
看看注入缓存生命周期函数源码:
// CacheComponent
import { Provider as CacheRouteProvider } from './context'
.
.
cacheLifecycles = {
__listener: {}, // Class
__didCacheListener: {},
__didRecoverListener: {},
on: (eventName, func) => {
const id = Math.random()
const listenerKey = `__${eventName}Listener`
this.cacheLifecycles[listenerKey][id] = func
return () => {
delete this.cacheLifecycles[listenerKey][id]
}
},
// class组件
didCache: listener => {
this.cacheLifecycles.__listener['didCache'] = listener
},
didRecover: listener => {
this.cacheLifecycles.__listener['didRecover'] = listener
}
}
render () {
return (
<CacheRouteProvider value={this.cacheLifecycles}>
{children(this.cacheLifecycles)}
</CacheRouteProvider>
)
}
cacheLifecycles注入到component实例中
// CacheRoute
<CacheComponent {...props}>
{cacheLifecycles => (
<Updatable when={isMatch(props.match)}>
{() => {
Object.assign(props, { cacheLifecycles })
if (component) {
return React.createElement(component, props)
}
return (render || children)(props)
}
}
</Updatable>
)}
</CacheComponent>
关于function函数缓存生命周期的注入
// context.js
import { useEffect, useContext } from 'react'
import createContext from 'mini-create-react-context'
import { isArray, isFunction, run } from '../helpers'
const context = createContext()
export default context
export const { Provider, Consumer } = context
function useCacheRoute(lifecycleName, effect) {
if (!isFunction(useContext)) {
return
}
const cacheLifecycles = useContext(context)
useEffect(() => {
const off = cacheLifecycles.on(lifecycleName, effect)
return () => off()
}, [])
}
export const useDidCache = useCacheRoute.bind(null, 'didCache')
export const useDidRecover = useCacheRoute.bind(null, 'didRecover')
缓存组件内部通过添加matched状态决定调用缓存生命周期函数
缓存组件unmatch => setState({ matched: false }) => 触发componentDidUpdate => 执行this.cacheLifecycles.__didCacheListener()
手动控制缓存组件
缓存容器:
添加CacheKey属性,CacheComponent就会保存组件引用
<CacheRoute path="/home" component={Home} cacheKey="home" />
通过manager.register保存cacheKey和组件实例
同时对于动态路由,保存的是该cacheKey多个组件的集合对象,pathname作为key
// CacheComponent.js
constructor(props, ...args) {
super(props, ...args)
const { cacheKey, multiple } = props
if (cacheKey) {
if (multiple) {
const { pathname } = props
manager.register(cacheKey, {
...manager.getCache()[cacheKey],
[pathname]: this
})
} else {
manager.register(cacheKey, this)
}
}
}
// manager.js
import CacheComponent from './CacheComponent'
import { get, run } from '../helpers'
const __components = {}
export const register = (key, component) => {
__components[key] = component
}
const dropComponent = component => run(component, 'reset')
export const dropByCacheKey = key => {
const cache = get(__components, [key])
if (!cache) {
return
}
if (cache instanceof CacheComponent) {
dropComponent(cache)
} else {
Object.values(cache).forEach(dropComponent)
}
}
同时通过dropByCacheKey执行缓存组件的reset方法,清除缓存组件的dom实例
组件中setState({ cached: false })控制render函数返回null
动态路由的缓存限制
对于动态路由,为了保证页面性能,得控制缓存数量上限
// CacheRoute
if (multiple && isMatchCurrentRoute) {
this.cache[currentPathname + currentSearch] = {
updateTime: Date.now(),
pathname: currentPathname,
search: currentSearch,
render: renderSingle
}
Object.entries(this.cache)
.sort(([, prev], [, next]) => next.updateTime - prev.updateTime) // 降序排列,删除最早缓存的数据
.forEach(([pathname], idx) => {
if (idx >= maxMultipleCount) {
delete this.cache[pathname]
}
})
}
添加multiple属性,限制缓存上限,超过上限,将最早缓存的移除
总结
对于要求保存页面完整状态的基本需求是能达到了,但是加大了页面的负担,对于未匹配的缓存组件处理只是单独的进行了隐藏处理,后续这块应该是首要优化点,对于未匹配的组件是否能移除dom结构,同时避免移除带来的负面影响。