spa应用中的路由缓存问题与解决方案

4,251 阅读10分钟

单页面应用中的路由缓存问题

通常我们在进行页面前后退时,浏览器通常会帮我们记录下之前滚动的位置,这使得我们不会在每次后退的时候都丢失之前的浏览器记录定位。但是在现在愈发流行的SPA(single page application 单页面应用)中,当我们从父级页面打开子级页面,或者从列表页面进入详情页面,此时如果回退页面,会发现之前我们浏览的滚动记录没有了,页面被置顶到了最顶部,仿佛是第一次进入这个页面一样。
这是因为在spa页面中的url与路由容器页面所对应,当页面路径与其发生不匹配时,该页面组件就会被卸载,再次进入页面时,整个组件的生命周期就会完全重新走一遍,包括一些数据的请求与渲染,所以之前的滚动位置和渲染的数据内容也都完全被重置了。

vue中的解决方式

vue.js最贴心的一点就是提供了非常多便捷的API,为开发者考虑到很多的应用场景。在vue中,如果想缓存路由,我们可以直接使用内置的keep-alive组件,当keep-alive包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。

内置组件keep alive

keep-alive是Vue.js的一个内置组件。它主要用于保留组件状态或避免重新渲染。

使用方法如下:

<keep-alive :include="['a', 'b']">
  <component :is="view"></component>
</keep-alive>

keep-alive组件会去匹配name名称为 'a', 'b' 的子组件,在匹配到以后会帮助组件缓存优化该项组件,以达到组件不会被销毁的目的。

实现原理

先简要看下keep-alive组件内部实现代码,具体代码可以见Vue GitHub

created () {
  this.cache = Object.create(null)
  this.keys = []
}

created生命周期中会用Object.create方法创建一个cache对象,用来作为缓存容器,保存vnode节点。
Tip: Object.create(null)创建的对象没有原型链更加纯净

render () {
  const slot = this.$slots.default
  const vnode: VNode = getFirstComponentChild(slot)
  const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  if (componentOptions) {
    // check pattern  检查匹配是否为缓存组件,主要根据include传入的name来对应
    const name: ?string = getComponentName(componentOptions)
    const { include, exclude } = this
    if (
      // not included   该判断中判断不被匹配,则直接返回当前的vnode(虚拟dom)
    (include && (!name || !matches(include, name))) ||
    // excluded
    (exclude && name && matches(exclude, name))
    ) {
      return vnode
    }

    const { cache, keys } = this
    const key: ?string = vnode.key == null
      // same constructor may get registered as different local components
      // so cid alone is not enough (#3269)
      ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
      : vnode.key
    if (cache[key]) {
      //查看cache对象中已经缓存了该组件,则vnode直接使用缓存中的组件实例
      vnode.componentInstance = cache[key].componentInstance
      // make current key freshest  
      remove(keys, key)
      keys.push(key)
    } else {
      //未缓存的则缓存实例
      cache[key] = vnode
      keys.push(key)
      // prune oldest entry
      if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode)
      }
    }

    vnode.data.keepAlive = true
  }
  return vnode || (slot && slot[0])
}

上述代码主要是在render函数中对是否是缓存渲染进行判断

vue keep-alive内部实现的基本流程就是:
  1. 首先通过getFirstComponentChild获取到内部的子组件
  2. 然后拿到该组件的name与keep-alive组件上定义的include与exclude属性进行匹配,
  3. 如果不匹配就表示不缓存组件,就直接返回该组件的vnode(vnode就是一个虚拟的dom树结构,由于原生dom上的属性非常多,消耗巨大,使用这种模拟方式会减少很多dom操作的开销)
  4. 如果匹配到,则在cache对象中查看是否已经缓存过该实例,如果有就直接将缓存的vnode的componentInstance(组件实例)覆盖到目前的vnode上面,否则将vnode存储在cache中。

React中的解决方案

在react中没有提供类似于vue的keep-alive的解决方案,这意味这我们可能需要自己编写一些代码或者通过一些第三方的模块来解决。

在React项目GitHub的该issue中进行了相关讨论,开发维护人员给出了两种方式来解决:

  • 将数据与组件分开缓存。例如,你可以将state提升到一个不会被卸载的父级组件,或者像redux一样将其放在一个侧面缓存中。我们也正在为此开发一类的API支持(context)。
  • 不要去卸载你要“保持活动”的视图,只需使用style={{display:'none'}}属性去隐藏它们。

1. 集中的状态管理恢复快照方式

在React中通过redux或mobx集中的状态管理来缓存页面数据以及滚动条等信息,以达到缓存页面的效果。

componentDidMount() {
  const {app: {dataSoruce = [], scrollTop}, loadData} = this.props;
  if (dataSoruce.length) { //判断redux中是否已经有数据源
    // 有数据则不再加载收据,只恢复滚动状态
    window.scrollTo(0, scrollTop);
  } else { //没有数据就去请求数据源
    this.props.loadData(); // 在redux中定义的数据请求的action
  }
}

handleClik = () => {
  在点击进入下一级页面前先保存当前的滚动距离
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  const {saveScrollTop} = this.props;
  saveScrollTop(scrollTop);
}

首先我们可以在redux中为页面定义异步的action,将请求回来的数据放入集中的store中(redux的该相关具体用法不在细述)。在sotre里我们可以保存当前页面的数据源、滚动条高度以及其他一些可能要用到的分页数据等来帮助我们恢复状态。

componentDidMount生命周期里,首先根据redux里store中的对应的字段,判断是否已经加载过数据源。如果已经缓存过数据则不再去请求数据源,只去恢复一下store里的存储过的一些滚动条位置信息等。如果还未请求过数据,就使用在redux中定义的异步action去请求数据,在将数据在reducer里将数据存到store中。 在render函数里,我们只需要读取redux里存储的数据即可。

为了保留要缓存页面的一些状态信息,如滚动条、分页、操作状态,我们可以在进行对应操作时候将这些信息存入redux的store中,这样当我们恢复页面时,就可以将这些对应状态一一读取并还原。

2. 使用display的属性来切换显示隐藏路由组件

想要display的属性来切换显示隐藏路由组件,首先要保证路由组件不会在url变化时候被卸载。
在react-router中最使用的Route组件,它可以通过我们定义的path属性来与页面路径来进行匹配,并渲染对应的组件,从而达到保持UI与URL同步变化的效果。

首先简要看下Route组件的实现 GitHub Route.js

return (
  <RouterContext.Provider value={props}>
    {children && !isEmptyChildren(children)
      ? children
      : props.match  // props.match 属性来确定是否要渲染组件
        ? component
          ? React.createElement(component, props)
          : render
            ? render(props)
            : null
        : null}
  </RouterContext.Provider>
);

上述代码出现在关键的render方法最后的return中

Route组件会根据props对象中的match属性来确定是否要渲染组件,如果match匹配到了就使用Route组件上传递的component或者render属性来渲染对应组件,否则就返回null。

然后溯源而上,我们找到了props对象中关于match的定义:

const location = this.props.location || context.location;
const match = this.props.computedMatch
  ? this.props.computedMatch // <Switch> already computed the match for us
  : this.props.path
    ? matchPath(location.pathname, this.props)
    : context.match;

const props = { ...context, location, match };

上述代码显示,match首先会从组件的this.props中的computedMatch属性来判断:
如果this.props中存在computedMatch则直接使用定义好的computedMatch属性赋值给match,
否则如果this.props.path存在,就会使用matchPath方法来根据当前的location.pathname来判断是否匹配。

然而在react router的Route组件API文档中我们似乎没有看到过有关于computedMatch的介绍,不过在源码中有一行这样的注释

// <Switch> already computed the match for us

该注释说在<Switch>组件中已经为我们计算了该匹配。

接下来我们再去了解一下Switch组件

Switch组件只会渲染第一个被location匹配到的并且作为子元素的<Route>或者<Redirect>

我们翻开Switch组件的实现源码

let element, match; // 定义最后返回的组件元素,和match匹配变量
  
  React.Children.forEach(this.props.children, child => {
    if (match == null && React.isValidElement(child)) { // 如果match没有内容则进入该判断
      element = child;
  
      const path = child.props.path || child.props.from;
  
      match = path  // 该三元表达式只有在匹配到后会给match赋值一个对象,否则match一直为null
        ? matchPath(location.pathname, { ...child.props, path })
        : context.match;
    }
  });
  
  return match
    ? React.cloneElement(element, { location, computedMatch: match })
    : null;

首先我们找到computedMatch属性是在React.cloneElement方法中,cloneElement方法会将追加定义的属性合并到该clone组件元素上,并返回clone后的React组件,等于就是将新的props属性传入组件并返回新组件。

在上文中找到computedMatch的值match也是根据matchPath来判断是否匹配的,matchPathreact router中的一个API,该方法会根据你传入的第一个参数pathname与第二个要匹配的props属性参数来判断是否匹配。如果匹配就返一个对象类型并包含相关的属性,否则返回null。

React.Children.forEach循环子元素的方法中,matchPath方法判断当前pathname是否匹配,如果匹配就给定义的match变量进行赋值,所以当match被赋值以后,后续的循环就也不会再进行匹配赋值,因为Switch组件只会渲染第一次与之匹配的组件。

3. 实现一个路由缓存组件

我们知道Switch组件只会渲染第一项匹配的子组件,如果可以将匹配到的组件都渲染出来,然后只用display的block和none来切换是否显示,这也就实现了第二种解决方案。

参照Switch组件来封装一个RouteCache组件:

import React from 'react';
import PropTypes from 'prop-types';
import {matchPath} from 'react-router';
import {Route} from 'react-router-dom';

class RouteCache extends React.Component {

  static propTypes = {
    include: PropTypes.oneOfType([
      PropTypes.bool,
      PropTypes.array
    ])
  };

  cache = {}; //缓存已加载过的组件

  render() {
    const {children, include = []} = this.props;

    return React.Children.map(children, child => {
      if (React.isValidElement(child)) { // 验证是否为是react element
        const {path} = child.props;
        const match = matchPath(location.pathname, {...child.props, path});

        if (match && (include === true || include.includes(path))) {
          //如果匹配,则将对应path的computedMatch属性加入cache对象里
          //当include为true时,缓存全部组件,当include为数组时缓存对应组件
          this.cache[path] = {computedMatch: match};
        }

        //可以在computedMatch里追加入一个display属性,可以在路由组件的props.match拿到
        const cloneProps = this.cache[path] && Object.assign(this.cache[path].computedMatch, {display: match ? 'block' : 'none'});

        return <div style={{display: match ? 'block' : 'none'}}>{React.cloneElement(child, {computedMatch: cloneProps})}</div>;
      }

      return null;
    });
  }
}

// 使用
<RouteCache include={['/login', '/home']}>
  <Route path="/login" component={Login} />
  <Route path="/home" component={App} />
</RouteCache>

在阅读了源码后,我们知道Route组件会根据它的this.props.computedMatch来判断是否要渲染该组件。

我们在组件内部创建一个cache对象,将已经匹配到的组件的computedMatch属性写入该缓存对象中。这样即使当url不再匹配时,也能通过读取cache对象中该路径的值,并使用React .cloneElement方法将computedMatch属性赋值给组件的props。这样已缓存过的路由组件就会被一直渲染出来,组件就不会被卸载掉。

因为组件内部可能会包裹多个路由组件,所以使用React.Children.map方法将内部包含的子组件都循环返回。

为了UI与路由对应显示正确,我们通过当前的计算得出的match属性,来隐藏掉不匹配的组件,只为我们展示匹配的组件即可。如果你不想在组件外再套一层div,也可以在组件内部通过this.props.match中的display属性来切换显示组件。

仿照vue keep alive的形式,设置一个 include 参数API。当参数为true时缓存内部的所有子组件,当参数为数组时则缓存对应的path路径组件。

使用效果


在最初时,从未被url匹配过的组件不会被渲染,里面的dom结构是空的。


当切换到对应组件时,当前的组件被渲染,而之前已匹配的组件不会被卸载,只是被隐藏


在输出日志中可以看到,当我们不停的来回切换时,componentDidMount生命周期也只执行一次,在props.match中我们可以获取到当前的display值。

4. 另外的也可以采用一些第三方组件模块来实习缓存机制:

  1. react-keeper
  2. react-router-cache-route
  3. react-live-route