fi3ework / blog

📝
861 stars 53 forks source link

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

Open fi3ework opened 6 years ago

fi3ework commented 6 years ago

前言

上一篇文章 中介绍了前端路由的实现及 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 内部有一个状态机,有三种渲染组件的状态:

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

实现细节

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

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-router 的 computePath 方法解析 path,所以默认支持传入数组,具体详见 path-to-regexp 的 文档

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

参考

tun100 commented 5 years ago

支持一个,mark

CJY0208 commented 5 years ago

支持,之前在项目中也意识到这个问题,当时没有找到楼主的方案,自己也实现了一套

思路比较简单,Route 部分没有重造,利用原有 Route children prop 传递 function 时可控渲染行为的特点,产生一个 HOC 组件完成缓存行为

Switch 部分在业务上有要求,所以有必要支持 Switch 中的缓存路由功能,方法是继承原有 Switch 并覆写 render 部分实现支持

目前存在的最大问题,难以突破的瓶颈是,如果是嵌套路由中,上层路由是原有 Route,下层路由的缓存行为便无法自控,上层 Route 如果被卸载,内部的缓存路由也将全部卸载...

由此看来,最好还是 React 本身能给出官方的 keep-alive 方案,实现真实 dom 节点的缓存和复用...

最后贴上自己的方案地址,与楼主有幸作为同一问题的探讨者

https://github.com/CJY0208/react-router-cache-route

fi3ework commented 5 years ago

@CJY0208 Switch 与缓存其实是挺矛盾的,除非改变 Switch 「严格匹配第一个」的定义,目前 react-live-route 的处理方案是暂时放到 Switch 外面来。嵌套路由这种事感觉无解,整个 react 的哲学就决定了这种事......,感谢分享~

jamieYou commented 5 years ago

见过两个例子,一个是 react-keeper,一个是 taro 编译出来的 h5 页面,他们都是通过隐藏页面来实现的。 但是这种做法有几个问题:

  1. 被隐藏的页面监听了全局滚动(例如用了一些滚动加载的组件),即使旧页面被隐藏了,新页面的滚动还是会触发事件。同理还有音频和视频播放,页面隐藏不代表会自动暂停。

  2. 旧页面组件的层级比新页面还高,例如 antd-mobile 的弹窗是脱离当前组件的元素单独在 body 下创建的,不一定会被新页面覆盖

  3. 只要切换路由,都会创建新的对应组件。在 react-router 中,两个不同的路由如果指向同一个组件,那么在两个路由之间切换(replace),组件是不会销毁的,只会更新。react-keeper 中的话会先销毁,再创建,在做 tabbar 的切换时很不方便。

CJY0208 commented 5 years ago

@jamieYou 使用 react-router-cache-route,这 3 个问题都可以解决

  1. 利用模拟的生命周期在被缓存时解除监听或停止媒体播放,恢复时重新挂载监听或继续播放。滚动方面还可以尝试的措施为不共用全局滚动而改为每个页面设立独立的滚动容器

  2. 仅在旧页面被缓存时将旧页面 dom 节点隐藏,或者调整 z-index 将其压入最下层,新页面的渲染中不做任何样式上的干预

  3. react-router 基础上使用,行为可期

楼主的方案相同,理论上也可以解决

jamieYou commented 5 years ago

@CJY0208 主要是解决起来很多功夫,为了做缓存反而多了其他坑出来维护,感觉不划算。 我还是期望 react 能自带 keep-alive 的功能

fi3ework commented 5 years ago

在 Fiber 架构下,实现 keep-alive 是有可能的,保持对应那部分的 Fiber tree 不被卸载即可。 不过我觉这只能是 React 团队来实现,开发者改不了这个层面。

CJY0208 commented 5 years ago

@jamieYou 木有希望,官方目前明确表示不会支持,官方认为 keep-alive 带有缓存性质,是一个不纯行为,除了我们自己魔改拓展,目前别无他法

参考 https://github.com/facebook/react/issues/12039#issuecomment-411621949

fi3ework commented 5 years ago

@CJY0208 好吧,在函数式面前凉凉

CJY0208 commented 5 years ago

@fi3ework 可以理解的啦

官方对 React 的定位和期望挺高的,是一个通用基础底层架构的存在

作为基础、具有函数式特征、兼容 ssr,从这几点来看的话,官方确实没必要为了某些便捷性而给 React 引入一个有隐患的特性

但也并没有完全阻止我们的魔改之路~~哈哈,不然也不会有今天的讨论了

@jamieYou 另外还找到了一个不一样的实现方案 react-keep-alive

原理大致是利用 createPortal 将被卸载的组件保留,总体上比借助 react-router 更接近 keep-alive 书写方式和行为预期

但测试中似乎无法保留滚动位置,可能和 createPortal 工作方式有关

ZWkang commented 5 years ago

mark一下。 dan 说的方案,一种是将数据存放点分离开,一种是使用display: none display: none 组件不会出发unmount 有一些绑定事件未必会被卸载 本质上应该都是如何缓存当前router 状态,当router再次命中如何render