为 react-router 写一个可以缓存的 Route

4,747 阅读9分钟

原文发布于我的 GitHub blog

前言

上一篇文章 中介绍了前端路由的实现及 react-router-v4(以下简称 rr4) 的源码分析,目前阶段 rr4 已经基本垄断了 react 生态圈的路由,虽然 v4 版本成功完成了一切皆组件的蜕变,但其实它本身还有诸多问题,比如 keep-alive。

keep-alive 的叫法取自 vue-keep-alive,在 vue 中,可以将某组件暂存于内存,然后跳转到其他页面再从内存中将这个组件拿出来。换算到路由中,我们可以想象这样一个情景 —— 有一个商品列表页,每个商品点进去都跳转到对应的商品详情页面,用户每次浏览完一个商品详情之后回退,列表页会重新渲染,那么如果用户已经往下划了几屏之后回退,那么每次返回后都要先滑到上次浏览的位置,这种体验可以说是灾难性的。

现在的浏览器非常贴心的实现了 Scroll Restoration(后退时恢复滚动位置),这在非 SPA 页面有非常好的体验效果,但是在 SPA 中,会有以下问题:

  1. 浏览器试图恢复滚动距离时,页面可能还没有加载完毕。因为回退的页面需要重新 mount,可能存在异步加载的部分,导致页面出现跳动。
  2. 点击链接进入页面就不会应用滚动恢复这一行为。只有在点击浏览器按钮的前进后退按钮时,才会触发 popstate 事件并触发 scroll restoration,通过点击链接无法触发滚动恢复。
  3. 这是非规范的一个 API(详见),所以各个浏览器的实现并不完全一致。

其实 iOS 和 Android 端的路由转换是十分理想的 —— 支持转场动画,手势返回,keep-alive。

本文中我们试图解决为 rr4 实现一个可以缓存的 Route 来解决上面例子中的问题,并借此探索一下 rr4 目前阶段的不足之处及可以加强的地方。说句题外话,rr4 的核心开发者又新搞了一个 reach-router 路由库,针对 rr4 的缺点进行了针对性的改进,已经钦点了是下一代的路由旗舰管理库。

轮子

先放上我造的轮子的仓库地址 react-live-route 感受一下本文的最终目的,react-live-route 可以使路由在路径不匹配时隐藏而不被卸载,在匹配路径时完全恢复离开页面时的样子。欢迎 star 和提 issue。

PC 端可以预览 demo

移动端扫码试玩 (点一下玩一年)

qr

思路

我们先重新将要解决的问题整理一下:

我们有列表页面和详情页,在列表页点击项目进入对应的详情页时,尽量保留列表页的视图与数据状态(包括滚动位置)。在从详情页回退到列表页的时候,希望列表页能恢复到上次离开时的状态。

其中我们要恢复的状态:

  1. 页面的滚动位置。
  2. 路由组件的一切状态,包括路由的组件的所有子元素的状态。

并且要做到无痛兼容 rr4,侵入性越小越好。我们的目标是为 react-router 设计一个增强型的 Route 组件,可以像 iOS 和 Android 端的路由切换一样“隐藏”上一个导航的页面,在这里有两种解决问题的思路:

思路1

unmount 时储存状态,re-mount 时取回状态

在列表页将要 unmount 的时候,将需要保留的数据状态存在 context(或者 window.sessionStorage 等等)

**优点:**可以在 unmount 和 re-mount 时利用生命周期。

缺点:

  1. 需要自己选择要存储的信息。
  2. 父组件无法拿到子组件的状态进行保存。
  3. 会重新 unmount 和 re-mount,这其实是不应该发生的,被隐藏的列表页应该是“潜伏”在详情页的下面,等到重新进入列表页时才出现,而不是已经被 unmount 了。

思路2

不 unmount,只是根据路由隐藏/显示对应页面

在切换到详情页的路径时,不将列表页 unmount,而是 display: none 掉它,在从详情页返回列表页的时候,再 display: block 将列表页显示回来。

优点: 简单粗暴,因为没有卸载组件,所以可以不用管页面的数据状态的保存情况。只需要管理好恢复显示、隐藏与正常 re-render,再恢复滚动位置即可。

缺点: 配合转场动画可能会有问题。

由于思路 1 的实现有很大的局限性,所以按照思路 2 来进行实现。

实现

增强的 Route 组件称为 LiveRoute,我们首先要确定,这个增强组件在什么情况下起作用,以及它有哪几种状态,react-router 有一篇关于 Scroll Restoration 的文章 ,是关于 react-router 去除了滚动恢复的功能的原因,其中有提到原因:

What got tricky for me was defining an "opt-out" API for when I didn't want the window scroll to be managed.

就是因为实际的应用情况太多变,他们无法合适的判断什么时候需要进行滚动恢复的管理。

在一开始我是打算使用成对的路由来实现,其中一个 LiveRoute 的存活状态去控制另一个需要保留存活的 LiveRoute:

<LiveRoute path='/list' liveKey='listToItem' component={List}/>
<LiveRoute path='/item/:id' onLiveKey='listToItem' component={Item}/>

但是路由间需要在 router 上创建 context 来辅助通信,如下是 react-router 正常更新一次的流程,路由间的通信会再一次触发被通知的路由的 setState,这是无法避免的,但是 Route 作为整个应用中非常靠上的组件,副作用要尽可能的小。

2018-06-22 111552

换个思路,其实缓存页面的匹配规则就是控制页面的隐藏/恢复显示与正常卸载,而 rr4 正常的路由匹配规则就是控制渲染/卸载,通过 path 这个 props 来完成。那么我们直接给 LiveRoute 一个额外的来控制隐藏/恢复显示的 livePath 的路径即可,其规则就可以直接套用 path,当路由 livePath 匹配时,则处于隐藏状态,其他路径则按照 rr4 的规则正常渲染/卸载。调用方法:

<LiveRoute path='/list' livePath='/item/:id' component={List}/>

如此一来,LiveRoute 显示状态的依赖变为 context.router,这样做的好处是依赖变的简单,所有的路由都会“同时”获得依赖的更新,并且相互之间没有耦合。

LiveRoute 状态

LiveRoute 内部有一个状态机,有三种渲染组件的状态:

  • HIDE_RENDER:livePath 匹配则需要将 LiveRoute 渲染的组件隐藏掉。进入此状态时需要备份页面的滚动位置,然后通过 ReactDOM.findDOMNode 来获取路由渲染的组件的 DOM,将 dom.style.display = 'none',并备份修改之前的 display 的属性。

  • NORMAL_RENDER_MATCH:路由正常渲染并且匹配上了。调用原版 Route 的渲染方法即可

    if (component) return match ? React.createElement(component, props) : null;
    if (render) return match ? render(props) : null;
    

但是在每次正常匹配渲染的时候都要保存当前的 context.router,作为之后隐藏渲染时需要保持渲染所需的 router,在 componnetDidUpdate 后查看有没有备份的滚动位置,如果有就恢复滚动位置并清除备份的滚动位置。

  • NORMAL_RENDER_UNMATCH:正常渲染但是不匹配,即要卸载当前路由的组件。要做的就比较简单了,清空 LiveRoute 中保存的 DOM 的引用,清除掉保存的滚动位置,然后调用原版的的 Route 的渲染方法(卸载)即可。

实现细节

如何保护路由渲染的组件存活

routerlivePath 匹配 的时候需要将 LiveRoute 置为隐藏状态。

但是新的 router 传入必然会计算出一个新的 match 去 setState,而新的 setState 与当前的 path 并不匹配,所以 LiveRoute 每次隐藏渲染时需要在 componentWillReceiveProps 中计算上次的 prevMatch。 在 render 的部分,需要当前的 router 在计算传递给组件的 props,所以需要在最后一次正常渲染的时候保存当前的 router。 最后,将 prevMatch 作为 setState 的 match,再拿出之前保存的 _prevRoute 完成渲染,一句话说就是将最后一次正常渲染的参数给保留了下来并在需要隐藏的时候拿出来伪装成最后一次正常渲染,再将 DOM 隐藏就完成了核心功能

保存滚动位置

由于 LiveRoute 拦截了路由的卸载,所以滚动位置不需要再存储在全局的 sessionStorage 中,LiveRoute 会一直存活,滚动位置直接可以保存为 LiveRoute 的属性。并且,相比 sessionStorage 必须先 JSON.stringify() 保存对象的操作,有了更高的可拓展性。

Switch

有一个问题就是与 Switch 的不兼容性,这个是采用 display:none 这种方法无法避免的,我也在 文档 中写到了。因为 Switch 的目的就是仅渲染第一个匹配的子元素,而 LiveRoute 的目的是强行渲染不匹配的子元素,所以不能在 Switch 中直接嵌套一个 LiveRoute 来使用。解决方法也简单,就是将 LiveRoute 从 Switch 中拿到外面来,不要让 LiveRoute 和 Switch 相互干扰,但是要注意此时 LiveRoute 的渲染与否也失去了 Switch 的跳过功能了。

滚动位置的不变性

在一些情况下 LiveRoute 的 DOM 将会被直接修改,所以在切换路由时滚动位置将不会改变而界面已经发生改变。这并不是 react-live-route 带来的问题,你可以手动将页面滚动到顶部,这篇 react-router 提供的 教学文章 中可以提供一些帮助。另外,如果 LiveRoute 将要恢复滚动位置,由于 React 的渲染顺序,它将发生在 LiveRoute 渲染的组件的滚动操作之后发生(滚动操作发生在 componentDidMount 或 componentDidUpdate 中)。

总结

react-live-route 实现了路由的缓存及复原,但是还有一些其他的问题需要解决,比如与转场动画的兼容性及给 LivePath 传入一个数组来实现多规则匹配的问题。

最后再放上 react-live-route 的仓库地址 react-live-route,欢迎 star 和提出 issue。

参考