React Router缓存路由
本篇算是读书笔记,书是《深入理解React Router:从原理到实践》以下代码实现基于react-router5在React Router中,一般情况下原生Route所负责渲染的组件在命中路由时进行挂载,而在导航时离开,路由未命中时组件将被销毁,分别对应了组件的componentDidMount与componentWillUnmount生命周期。如果想在导航后页面得到缓存呢?在Vue或Angul
本篇算是读书笔记,书是《深入理解React Router:从原理到实践》
以下代码实现基于react-router5,demo地址,代码里有微前端相关demo,所以缓存路由默认没开,src/App.js下的导入注释调了,放开注释替换原生Route组件即可。
在React Router中,一般情况下原生Route所负责渲染的组件在命中路由时进行挂载,而在导航时离开,路由未命中时组件将被销毁,分别对应了组件的componentDidMount与componentWillUnmount生命周期。
如果想在导航后页面得到缓存呢?在Vue或Angular中,有对应的keep-alive与路由复用策略可以实现页面缓存,React Router没有提供。一般有以下两种实现方案:
- 状态存在内存中。比如使用Redux、Mobx或自定义内存变量,在页面离开前,将页面内用户产生的数据存储在内存中,并销毁页面的DOM节点。下次导航过来时可以直接使用存储的状态。
- 不销毁DOM节点,对其进行缓存。一般通过CSS方式缓存DOM隐藏页面。
以上方案各有优缺点,酌情选择,下面仅介绍第二种方案。
Route的运行流程
阐述方案之前有必要先梳理一下Route组件的运行流程。
Route的组件渲染方式有三种
-
通过component属性渲染
如果路径匹配成功,就将component传入的组件通过
React.createElement
方式进行创建,并且注入match、location、history变量,进行Route的渲染工作。如果路径匹配失败,Route则会返回null,曾渲染过的component组件将会被销毁。 -
通过render属性渲染
开发者能自行接管
React.createElement
的行为,在匹配成功后,可以通过渲染插槽的形式渲染对应的组件。这种方式能控制传入组件中的props参数。<Route path='/a/b/c' render={(props) => { const newHistory = {...props.history}; return <Component {...props} history={newHistory} /> }} />
-
通过children属性渲染
此种渲染方式拥有最高的自由度。如果children是一个函数,则它将被无条件渲染(包括Route未命中),如果它不是函数,则它可能是合法的React组件,这时则仅在Route命中时渲染children。
这种特性可以做很多事,路由缓存就依赖此特性。我们还可以用它来达到用路由控制弹窗的效果:
<Route path={`{props.match.params.path}/modal-A`} children={({match}) => <Modal visible={match} />} />
这里的path使用父路由组件的path加上自己path的方式,children函数无条件调用,但若是Route未匹配到,match未null,modal就不会渲染,反之亦反。
缓存Route
实现的核心在于使用Route的children属性进行可控渲染。CacheRoute除了拥有原始Route的能力,还基于Route提供了几类扩展能力:路由组件状态缓存、remount机制和渲染优化。
-
路由组件状态缓存
利用Route children渲染特性,可在match为null(路由未命中)时设置style隐藏组件,实现CSS隐藏式页面切换:
<Route children={({match, location, history}) => <Modal visible={match} />} />
-
remount机制
remount机制指的是React组件进行一次unmount销毁,清除DOM,再重新挂载DOM的机制。在React中可有多种方案实现,常见的做法是通过给同一个React组件设置不同的key,利用React的diff机制。如果key相同,则组件执行的是更新周期;如果发现key不同,则会销毁原组件,重新挂载新的key对应的组件。如在每个组件初始化时生成了key值,在需要重新挂载时再为该组件重新生成另一个key值,便可实现组件的重新挂载。
import React from 'react'; export default function Remount(props) { // 初始化key const keyRef = React.useRef(Math.random() + Date.now()); if(props.shouldRemountComponent){ // 更新key keyRef.current = Math.random() + Date.now(); } return React.cloneElement(React.Children.only(props.children), {key: keyRef.current}) }
-
渲染优化
对于通过CSS缓存的组件,在路由切换过程中,无论路由命中与否,由于组件没有被销毁,组件都会执行更新的生命周期,页面在未命中路由时也会调用render函数,这在路由未命中时是没有必要的。
引入记忆渲染的能力,在类组件中,可通过父组件或高阶组件的shouldComponentUpdate判断是否有必要渲染子组件;或者对于函数组件,使用React.memo记录渲染结果。
React.memo接受两个参数,第一个组件,第二个props比较函数,通过判断nextProps.match及nextProps.match.isExact,提供两类记忆化父组件,如果nextProps.match或nextProps.match.isExact有值,则说明路由匹配命中,应该渲染;反之,则缓存上次渲染结果,组件不进入渲染的生命周期:
import React from 'react' import { RouteChildrenProps } from 'react-router'; interface Props extends RouteChildrenProps{ children?: any; } function MemoChildrenWithRouteMatch(props: Props) { return props.children; } export default React.memo(MemoChildrenWithRouteMatch, (preProps, nextProps) => { // 不命中就不渲染,仅在match有值时才渲染组件 return !nextProps.match; }) function MemoChildrenWithRouteExactMatch(props: Props) { return props.children; } export const MemoChildrenWithRouteMatchExact = React.memo( MemoChildrenWithRouteExactMatch, (preProps, nextProps) => { // 不命中就不渲染,仅在match有值切绝对匹配时才渲染组件 return !(nextProps.match && nextProps.match.isExact); })
可将Route的匹配结果routeProps传入Cache组件,并将需要缓存的组件作为Cache组件的子组件:
<Route path={path} children={({ routeProps: RouteChildrenProps }) => ( <MemoChildrenWithRouteMatch {...routeProps}> {/*<Component />*/} </MemoChildrenWithRouteMatch> ) />
结合以上三方面能力,模仿Route源码(见下文)实现CacheRoute如下:
import * as React from "react";
import { Route, RouteChildrenProps, RouteProps } from "react-router";
import { omit } from "lodash";
import MemoChildrenWithRouteMatch, {
MemoChildrenWithRouteMatchExact
} from "./Cache";
import Remount from "./Remount";
interface Props {
forceHide?: boolean;
shouldReMount?: boolean;
shouldDestroyDomWhenNotMatch?: boolean;
shouldMatchExact?: boolean;
}
export default function CacheRoute(props: RouteProps & Props) {
const routeHadRenderRef = React.useRef(false);
return (
<Route
{...omit(props, "component", "render", "children")}
children={(routeProps: RouteChildrenProps) => {
const Component = props.component;
const routeMatch = routeProps.match;
let match = !!routeMatch;
if (props.shouldMatchExact) {
match = routeMatch && routeMatch.isExact;
}
if (props.shouldDestroyDomWhenNotMatch) {
if (!match) routeHadRenderRef.current = false;
// 按正常逻辑
if (props.render) {
return match && props.render(routeProps);
}
return (
match && Component && React.createElement(Component, routeProps)
);
} else {
const matchStyle = {
// 隐藏
display: match && !props.forceHide ? "block" : "none"
};
if (match && !routeHadRenderRef.current) {
routeHadRenderRef.current = true;
}
let shouldRender = true;
if (!match && !routeHadRenderRef.current) {
shouldRender = false;
}
const MemoCache = props.shouldMatchExact
? MemoChildrenWithRouteMatchExact
: MemoChildrenWithRouteMatch;
// css隐藏保留dom
let component;
if (props.render) {
component = props.render(routeProps);
} else {
component = <Component {...routeProps} />;
}
return (
shouldRender && (
<div style={matchStyle}>
{/*提供remount能力*/}
<Remount shouldRemountComponent={props.shouldReMount}>
{/*提供渲染优化*/}
<MemoCache {...routeProps}>{component}</MemoCache>
</Remount>
</div>
)
);
}
}}
/>
);
}
CacheRoute的Props在原有RouteProps的基础上增加了4个props:
interface Props {
forceHide?: boolean; // 是否强制刷新,true则无论是否命中均隐藏渲染(为Switch组件服务)
shouldReMount?: boolean;// 是否重新挂载
shouldDestoryDomWhenNotMatch?: boolean;// 渲染模式是否为销毁模式,true则在未命中时销毁
shouldMatchExact?: boolean;// 组件缓存时是全匹配缓存还是模糊匹配缓存
}
使用方式
<CacheRoute path='/baz' component={Baz} />
需要注意,当存在某页面初始加载成功,其余页面路由未命中的情况时,如/a页面初始加载,其余/c、/d、/e……路径下的路由未命中,如果未经任何逻辑处理,则组件会以CSS隐藏方式得到渲染进而挂载。这种情况应该注意,/c、/d等页面组件不应渲染,应只有路由命中成功之后,才能进行初始渲染及后续对DOM进行保留。代码中使用routeHadRenderRef来记录此状态,只有当match为true时,才会设置routeHadRenderRef.current为true。如果组件是DOM销毁,则也应把routeHadRenderRef.current设置为false。
const routeHadRenderRef = React.useRef(false);
加入Switch
若将CacheRoute与Switch结合,则会造成无法缓存的问题,原因在于原生Switch组件仅渲染一个命中子组件,其余未命中子组件都将被销毁,这样应该缓存的Route在Switch的控制下无法得到有效缓存。
基于此问题,需要设计一个匹配CacheRoute的Switch组件,它要满足以下目标:
- 与原生Switch一致,在视图上,Switch仅选择展示第一个命中的子组件。
- 如果 Switch的子组件 CacheRoute曾经命中渲染过,则 CacheRoute所渲染的组件应该缓存。
- Switch仅缓存曾经渲染成功的CacheRoute组件,从未渲染成功的CacheRoute组件不会被缓存。
参考Switch源码(见下文)的实现,从react-router包中引入matchPath、__RouterContext等,重新实现能缓存Route的Switch:
import * as React from "react";
import { __RouterContext, matchPath, SwitchProps } from "react-router";
import invariant from "tiny-invariant";
const RouterContext = __RouterContext;
interface SwitchPropsExt extends SwitchProps {
// 不缓存模式
noCache?: boolean;
}
function CacheSwitch(props: SwitchPropsExt) {
// 需要一个数组记录哪些子组件曾经渲染过
const renderedComponentsRef = React.useRef<string[]>([]);
return (
<RouterContext.Consumer>
{context => {
const location = props.location || context.location;
// 用于保存渲染组件的数组 由于CacheSwitch可能渲染多个子组件
// 不像Switch仅渲染一个子组件 CacheSwitch需要缓存子组件
// 通过数组保存所有需要渲染的子组件
const components = [];
// 标识第一次命中匹配
let isMatched = false;
React.Children.forEach(props.children, child => {
// 还原Switch的行为 在命中后不再进行后续child的操作
if (props.noCache && isMatched) {
return;
}
if (React.isValidElement(child)) {
const element:any = child;
// 取到Route的path
const path = element.props.path || element.props.from;
// 与Switch的match一致
const match = path
? matchPath(location.pathname, { ...child.props, path })
: context.match;
const compnentIdentity = element.key || path;
// 渲染组件方法,通过判断命中数组确保组件命中过
const renderComponent = forceHide => {
invariant(compnentIdentity, `请确认组件${element.type}的key`);
// 曾经渲染过的组件应该继续渲染,隐藏与否根据forceHide参数决定
renderedComponentsRef.current.includes(compnentIdentity) &&
components.push(
// 使用cloneElement保留原element的props
React.cloneElement(element, {
// 与Switch一致,将Switch的location传入Route
location,
// 为Route传入computedMatch,Route便无需再计算一次命中情况
computedMatch: match,
// 在noCache时 Route行为与原生一致,
// 使用CacheRoute需要传入对应字段
shouldDestroyDomWhenNotMatch: props.noCache,
// 渲染组件的css强制控制
forceHide: forceHide,
// 使用key是必要的,因为Switch下的children是组件数组
// 优先使用element的key,如果没有,使用path作为key
key: compnentIdentity
})
);
};
if (match) {
if (!isMatched) {
// 此组件已经满足渲染要求 更新标识符
!renderedComponentsRef.current.includes(compnentIdentity) &&
renderedComponentsRef.current.push(compnentIdentity);
//第一次匹配成功
isMatched = true;
//第一次匹配成功,不强制隐藏
renderComponent(false);
} else {
//非第一次匹配成功,强制隐藏
renderComponent(true);
}
} else {
//未匹配成功,强制隐藏
renderComponent(true);
}
}
});
// 更新一次标识组件渲染的数组,以满足在key变化时,旧的key能得到清理
renderedComponentsRef.current = components.map(element => element.key);
return components;
}}
</RouterContext.Consumer>
);
}
组件还支持不缓存属性noCache。通过此属性,Switch仅保留第一个命中路径的子组件,并且在渲染该命中子组件时,也将props.noCache赋值到shouldDestroyDomWhenNotMatch,促使CacheRoute在相关场景中销毁组件。
实现效果
react-router源码
Route
class Route extends React.Component {
render() {
return (
<RouteContext/Consumer>
{context => {
// 确保使用了上下文
invariant(context, 'You should not use <Route> outside a <Router>');
// 可从props中设置Route的location,一般不设置,默认从上下文中获取
const location = this.props.location || context.location;
// 计算该路由是否命中,可以从Switch组件已计算值中获取,或者使用matchPath计算,或者继承上下文中的match
const match = this.props.computedMatch
? this.props.computedMatch
: (
this.props.path
? matchPath(location.pathname, this.props)
: context.match;
)
// 构建新的上下文
const props = {...context, location, match};
let {children, component, render} = this.props;
// Preact兼容
if(Array.isArray(children) && children.length === 0){
children = null;
}
return (
// 提供新的上下文
<RouterContext.Provider value={props}>
{props.match
? children
// 匹配成功进行渲染时,children函数渲染优先级最高
? typeof children === 'function'
? children(props)
: children
// component 渲染优先级次之
: component
? React.createElement(component, props)
// render渲染优先级最低
: render
? render(props)
: null
// 匹配失败时调用children函数渲染
: typeof children === 'function'
? children(props)
: null}
</RouterContext.Provider>
)
}}
</RouteContext/Consumer>
)
}
}
Switch
class Switch wxtends React.Component {
render() {
return (
<RouterContext.Consumer>
{
context => {
const location = this.props.location || context.location;
let element, match;
React.Children.forEach(this.props.children, child => {
if(match == null && React.isValidElement(child)) {
element = child;
// 从Route中读取path或从Redirect中读取from
const path = child.props.path || child.props.from;
// 当match不为null,即第一次匹配到,会记录match
match = path
? matchPath(location.pathname, {...child.props, path})
: context.match;
}
});
// 保存原子组件的属性,并将Switch的location及计算出的computedMatch传入子组件
return match
? React.cloneElement(element, {location, computedMatch: match})
: null
}
}
</RouterContext.Consumer>
)
}
}
参考
《深入理解React Router:从原理到实践》
更多推荐
所有评论(0)