本篇算是读书笔记,书是《深入理解React Router:从原理到实践》

以下代码实现基于react-router5,demo地址,代码里有微前端相关demo,所以缓存路由默认没开,src/App.js下的导入注释调了,放开注释替换原生Route组件即可。

在React Router中,一般情况下原生Route所负责渲染的组件在命中路由时进行挂载,而在导航时离开,路由未命中时组件将被销毁,分别对应了组件的componentDidMount与componentWillUnmount生命周期。

如果想在导航后页面得到缓存呢?在Vue或Angular中,有对应的keep-alive与路由复用策略可以实现页面缓存,React Router没有提供。一般有以下两种实现方案:

  1. 状态存在内存中。比如使用Redux、Mobx或自定义内存变量,在页面离开前,将页面内用户产生的数据存储在内存中,并销毁页面的DOM节点。下次导航过来时可以直接使用存储的状态。
  2. 不销毁DOM节点,对其进行缓存。一般通过CSS方式缓存DOM隐藏页面。

以上方案各有优缺点,酌情选择,下面仅介绍第二种方案。

Route的运行流程

阐述方案之前有必要先梳理一下Route组件的运行流程。

IZxfAO.jpg

Route的组件渲染方式有三种

  1. 通过component属性渲染

    如果路径匹配成功,就将component传入的组件通过React.createElement方式进行创建,并且注入match、location、history变量,进行Route的渲染工作。如果路径匹配失败,Route则会返回null,曾渲染过的component组件将会被销毁。

  2. 通过render属性渲染

    开发者能自行接管React.createElement的行为,在匹配成功后,可以通过渲染插槽的形式渲染对应的组件。这种方式能控制传入组件中的props参数。

    <Route
     path='/a/b/c'
     render={(props) => {
         const newHistory = {...props.history};
         return <Component {...props} history={newHistory} />
     }}
    />
    
  3. 通过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机制和渲染优化。

  1. 路由组件状态缓存

    利用Route children渲染特性,可在match为null(路由未命中)时设置style隐藏组件,实现CSS隐藏式页面切换:

    <Route
     children={({match, location, history}) => <Modal visible={match} />}
    />
    
  2. 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})
    }
    
  3. 渲染优化

    对于通过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组件,它要满足以下目标:

  1. 与原生Switch一致,在视图上,Switch仅选择展示第一个命中的子组件。
  2. 如果 Switch的子组件 CacheRoute曾经命中渲染过,则 CacheRoute所渲染的组件应该缓存。
  3. 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在相关场景中销毁组件。

实现效果

Lo5Zpn.gif

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:从原理到实践》

Logo

前往低代码交流专区

更多推荐