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


Posted in Javascript onMay 31, 2019

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

通常我们在进行页面前后退时,浏览器通常会帮我们记录下之前滚动的位置,这使得我们不会在每次后退的时候都丢失之前的浏览器记录定位。但是在现在愈发流行的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'}}属性去隐藏它们。

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

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来判断是否匹配的,matchPath是react 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路径组件。

使用效果

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

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

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

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

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

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

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

react-keeper
react-router-cache-route
react-live-route

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
仿服务器端脚本方式的JS模板实现方法
Apr 27 Javascript
JS截取字符串常用方法整理及使用示例
Oct 18 Javascript
判断在css加载完毕后执行后续代码示例
Sep 03 Javascript
JS实现动态给图片添加边框的方法
Apr 01 Javascript
jQuery实现多级下拉菜单jDropMenu的方法
Aug 28 Javascript
简单封装js的dom查询实例代码
Jul 08 Javascript
41个Web开发者必须收藏的JavaScript实用技巧
Jul 22 Javascript
SelecT下拉框选中和取值的解决方法
Nov 22 Javascript
Move.js入门
Feb 08 Javascript
ES6入门教程之let和const命令详解
May 17 Javascript
js轮播图无缝滚动效果
Jun 17 Javascript
js实现无限层级树形数据结构(创新算法)
Feb 27 Javascript
JS实现处理时间,年月日,星期的公共方法示例
May 31 #Javascript
一文了解vue-router之hash模式和history模式
May 31 #Javascript
vue App.vue中的公共组件改变值触发其他组件或.vue页面监听
May 31 #Javascript
微信小程序环境下将文件上传到OSS的方法步骤
May 31 #Javascript
Vue Router history模式的配置方法及其原理
May 30 #Javascript
vue-cli3+ts+webpack实现多入口多出口功能
May 30 #Javascript
详解Vue项目引入CreateJS的方法(亲测可用)
May 30 #Javascript
You might like
社区(php&amp;&amp;mysql)四
2006/10/09 PHP
thinkPHP利用ajax异步上传图片并显示、删除的示例
2018/09/26 PHP
PHP迭代器和生成器用法实例分析
2019/09/28 PHP
PHP7 弃用功能
2021/03/09 PHP
Prototype使用指南之range.js
2007/01/10 Javascript
js电信网通双线自动选择技巧
2008/11/18 Javascript
jquery prop的使用介绍及与attr的区别
2013/12/19 Javascript
js动态调用css属性的小规律及实例说明
2013/12/28 Javascript
js+jquery常用知识点汇总
2015/03/03 Javascript
jQuery实现预加载图片的方法
2015/03/17 Javascript
JavaScript入门基础
2015/08/12 Javascript
js编写一个简单的产品放大效果代码
2016/06/27 Javascript
AngularJS延迟加载html template
2016/07/27 Javascript
关于前后端json数据的发送与接收详解
2017/07/30 Javascript
利用vue + element实现表格分页和前端搜索的方法
2017/12/25 Javascript
JavaScript原型对象、构造函数和实例对象功能与用法详解
2018/08/04 Javascript
简述vue状态管理模式之vuex
2018/08/29 Javascript
vue-cli V3.0版本的使用详解
2018/10/24 Javascript
antd Form组件方法getFieldsValue获取自定义组件的值操作
2020/10/29 Javascript
教大家玩转Python字符串处理的七种技巧
2017/03/31 Python
python使用jieba实现中文分词去停用词方法示例
2018/03/11 Python
Python中的TCP socket写法示例
2018/05/11 Python
Python调用C++,通过Pybind11制作Python接口
2018/10/16 Python
python调用百度地图WEB服务API获取地点对应坐标值
2019/01/16 Python
新手入门Python编程的8个实用建议
2019/07/12 Python
pandas 空数据处理方法详解
2019/11/02 Python
python opencv 检测移动物体并截图保存实例
2020/03/10 Python
Html5 audio标签样式的修改
2016/01/28 HTML / CSS
优秀求职信范文分享
2013/12/19 职场文书
函授毕业自我鉴定
2014/02/04 职场文书
学生会主席演讲稿
2014/04/25 职场文书
个人查摆问题及整改措施
2014/10/16 职场文书
2015年光棍节活动总结
2015/03/24 职场文书
无犯罪记录证明样本
2015/06/16 职场文书
使用Python+OpenCV进行卡类型及16位卡号数字的OCR功能
2021/08/30 Python
js前端图片加载异常兜底方案
2022/06/21 Javascript