产品:能实现长列表的滚动恢复嘛?我:... 得加钱

语言: CN / TW / HK

theme: Chinese-red

前言

某一天,产品经理找到我,他希望我们能够给用户更好的体验,提供长列表的滚动记忆功能。就是说当鼠标滚轮滚动到长列表的某个位置时,单击一个具体的列表项,就切换路由到了这个列表项的详情页;当导航返回到长列表时,还能回到之前滚动到的位置去。

思路

我低头思考了一阵儿,想到了history引入的scrollRestoration属性,也许可以一试。于是我回答,可以实现,一天工作量吧😂。产品经理听到后,满意地走了,但是我后知后觉,我为数不多的经验告诉我,这事儿可能隐隐有风险😨。但是没办法,no zuo no die。

scrollRestoration

Chrome46之后,history引入了scrollRestoration属性。该属性提供两个值,auto(默认值),以及manual。当设置为auto时,浏览器会原生地记录下window中某个元素的滚动位置。此后不管是刷新页面,还是使用pushState等方法改变页面路由,始终可以让元素恢复到之前的屏幕范围中。但是很遗憾,他只能记录下在window中滚动的元素,而我的需求是某个容器中滚动。
完犊子😡,实现不了。
其实想想也是,浏览器怎么可能知道开发者想要保存哪个DOM节点的滚动位置呢?这事只有开发者自己知道,换句话说,得自己实现。于是乎,想到了一个大致思路是:

发生滚动时将元素容器当时的位置保存起来,等到长列表再次渲染时,再对其重新赋值scrollTop和scrollLeft

真正的开发思路

其实不难想到,滚动恢复应该属于长列表场景中的通用能力,既然如此,那...,夸下的海口是一天,所以没招,只能根据上述的简单思路实现了一个,很low,位置信息保存在localStorage中,算是交了差。但作为一个有追求的程序员,这事必须完美解决,既然通用那么公共组件提上日程😎。在肝了几天之后,出炉的完美解决方案:

在路由进行切换、元素即将消失于屏幕前,记录下元素的滚动位置,当元素重新渲染或出现于屏幕时,再进行恢复。得益于React-Router的设计思路,类似于Router组件,设计滚动管理组件ScrollManager,用于管理整个应用的滚动状态。同理,类似于Route,设计对应的滚动恢复执行者ScrollElement,用以执行具体的恢复逻辑。

滚动管理者-ScrollManager

滚动管理者作为整个应用的管理员,应该具有一个管理者对象,用来设置原始滚动位置,恢复和保存原始的节点等。然后通过Context,将该对象分发给具体的滚动恢复执行者。其设计如下: typescript export interface ScrollManager { /** * 保存当前的真实DOM节点 * @param key 缓存的索引 * @param node * @returns */ registerOrUpdateNode: (key: string, node: HTMLElement) => void; /** * 设置当前的真实DOM节点的元素位置 * @param key 缓存的索引 * @param node * @returns */ setLocation: (key: string, node: HTMLElement | null) => void; /** * 设置标志,表明location改变时,是可以保存滚动位置的 * @param key 缓存的索引 * @param matched * @returns */ setMatch: (key: string, matched: boolean) => void; /** * 恢复位置 * @param key 缓存的索引 * @returns */ restoreLocation: (key: string) => void; /** * 清空节点的缓存 * @param key * @returns */ unRegisterNode: (key: string) => void; } - 上述Manager虽然提供了各项能力,但是缺少了缓存对象,也就是保存这些位置信息的地方。使用React.useRef,其设计如下: typescript //缓存位置的具体内容 const locationCache = React.useRef<{ [key: string]: { x: number; y: number }; }>({}); //原生节点的缓存 const nodeCache = React.useRef<{ [key: string]: HTMLElement | null; }>({}); //标志位的缓存 const matchCache = React.useRef<{ [key: string]: boolean; }>({}); //清空节点方法的缓存 const cancelRestoreFnCache = React.useRef<{ [key: string]: () => void; }>({}); - 有了缓存对象,我们就可以实现manager,使用key作为缓存的索引,关于key会在ScrollElement中进行说明。 typescript const manager: ScrollManager = { registerOrUpdateNode(key, node) { nodeCache.current[key] = node; }, unRegisterNode(key) { nodeCache.current[key] = null; //及时清除 cancelRestoreFnCache.current[key] && cancelRestoreFnCache.current[key](); }, setMatch(key, matched) { matchCache.current[key] = matched; if (!matched) { //及时清除 cancelRestoreFnCache.current[key] && cancelRestoreFnCache.current[key](); } }, setLocation(key, node) { if (!node) return; locationCache.current[key] = { x: node?.scrollLeft, y: node?.scrollTop }; }, restoreLocation(key) { if (!locationCache.current[key]) return; const { x, y } = locationCache.current[key]; nodeCache.current[key]!.scrollLeft = x; nodeCache.current[key]!.scrollTop = y; }, }; - 之后,便可以通过Context将manager对象向下传递 typescript <ScrollManagerContext.Provider value={manager}> {props.children} </ScrollManagerContext.Provider> - 除了上述功能外,manager还有一个重要功能:获知元素在导航切换前的位置。在React-Router中一切路由状态的切换都由history.listen来发起,由于history.listen可以监听多个函数。所以可以在路由状态切换前,插入一段监听函数,来获得节点相关信息。 typescript location改变 ---> 获得节点位置信息 ---> 路由update - 在实现中,使用了一个状态shouldChild,来确保监听函数一定在触发顺序上先于Router中的监听函数。实现如下: ```typescript const [shouldChild, setShouldChild] = React.useState(false);

//利用useLayoutEffect的同步,模拟componentDidMount,为了确保shouldChild在Router渲染前设置 React.useLayoutEffect(() => { //利用history提供的listen监听能力 const unlisten = props.history.listen(() => { const cacheNodes = Object.entries(nodeCache.current); cacheNodes.forEach((entry) => { const [key, node] = entry; //如果matchCache为true,表明从当前路由渲染的页面离开,所以离开之前,保存scroll if (matchCache.current[key]) { manager.setLocation(key, node); } }); });

    //确保该监听先入栈,也就是监听完上述回调函数后才实例化Router
    setShouldChild(true);
    //销毁时清空缓存信息
    return () => {
        locationCache.current = {};
        nodeCache.current = {};
        matchCache.current = {};
        cancelRestoreFnCache.current = {};
        Object.values(cancelRestoreFnCache.current).forEach((cancel) => cancel());
        unlisten();
    };

}, []);

//改造context传递 {shouldChild && props.children} - 真正使用时,管理者组件要放在Router组件外侧,来控制Router实例化:typescript ... ```

滚动恢复执行者-ScrollElement

ScrollElement的主要职责其实是控制真实的HTMLElement元素,决定缓存的key,包括决定何时触发恢复,何时保存原始HTMLElement的引用,设置是否需要保存的位置等等。ScrollElement的props设计如下: typescript export interface ScrollRestoreElementProps { /** * 必须缓存的key,用来标志缓存的具体元素,位置信息以及状态等,全局唯一 */ scrollKey: string; /** * 为true时触发滚动恢复 */ when?: boolean; /** * 外部传入ref * @returns */ getRef?: () => HTMLElement; children?: React.ReactElement; } - ScrollElement本质上可以看作为一个代理,会拿到子元素的Ref,接管其控制权。也可以自行实现getRef传入组件中。首先要实现的就是滚动发生时,记录位置能力: ```typescript useEffect(() => { const handler = function (event: Event) {‘ //nodeRef就是子元素的Ref if (nodeRef.current === event.target) { //获取scroll事件触发target,并更新位置 manager.setLocation(props.scrollKey, nodeRef.current); } };

//使用addEventListener的第三个参数,实现在window上监听scroll事件
window.addEventListener('scroll', handler, true);
return () => window.removeEventListener('scroll', handler, true);

}, [props.scrollKey]); - 接下来处理路由匹配以及DOM变更时处理的能力。注意,这块使用了对`useLayoutEffect`和`useEffect`执行时机的理解处理:typescript //使用useLayoutEffect主要目的是为了同步处理DOM,防止发生闪动 useLayoutEffect(() => { if (props.getRef) { //处理getRef获取ref //useLayoutEffect会比useEffect先执行,所以nodeRef一定绑定的是最新的DOM nodeRef.current = props.getRef(); }

if (currentMatch) {
    //设置标志,表明当location改变时,可以保存滚动位置
    manager.setMatch(props.scrollKey, true);
    //更新ref,代理的DOM可能会发生变化(比如key发生了变化,remount元素)
    nodeRef.current && manager.registerOrUpdateNode(props.scrollKey, nodeRef.current);
    //恢复原先滑动过的位置,可通过外部props通知是否需要进行恢复
    (props.when === undefined || props.when) && manager.restoreLocation(props.scrollKey);
} else {
    //未命中标志设置,不要保存滚动位置
    manager.setMatch(props.scrollKey, false);
}

//每次update注销,并重新注册最新的nodeRef,解决key发生变化的情况
return () => manager.unRegisterNode(props.scrollKey);

}); - 上述代码,表示在初次加载或者每次更新时,会根据当前的Route匹配结果与否来处理。如果匹配,则表示ScrollElement组件应是渲染的,此时在`effect`中执行更新Ref的操作,为了解决key发生变化时DOM发生变化的情况,所以需要每次更新都处理。 - 同时设置标识位,相当于告诉`manager`,node节点此刻已经渲染成功了,可以在离开页面时保存位置信息;如果路由不匹配,那么则不应该渲染,`manager`此刻也不用保存这个元素的位置信息。主要是为了解决存在路由缓存的场景。 - 也可以通过`when`来控制恢复,主要是用来解决异步请求数据的场景。 - 最后判断ScrollElement的子元素是否是合格的typescript //如果有getRef,直接返回children if (props.getRef) { return props.children as JSX.Element; }

const onlyOneChild = React.Children.only(props.children); //代理第一个child,判断必须是原生的tag if (onlyOneChild && onlyOneChild.type && typeof onlyOneChild.type === 'string') { //利用cloneElement,绑定nodeRef return React.cloneElement(onlyOneChild, { ref: nodeRef }); } else { console.warn('-----滚动恢复失败,ScrollElement的children必须为单个html标签'); }

return props.children as JSX.Element; ```

多次尝试机制

在某些低版本的浏览器中,可能存在一次恢复并不如预期的情况。所以实现多次尝试能力,其原理就是用一个定时器多次执行callback,同时设定时间上限,并返回一个取消函数给外部,如果最终结果理想则取消尝试,否则再次尝试直到时间上限内达到理想位置。更改恢复函数: typescript restoreLocation(key) { if (!locationCache.current[key]) return; const { x, y } = locationCache.current[key]; //多次尝试机制 let shouldNextTick = true; cancelRestoreFnCache.current[key] = tryMutilTimes( () => { if (shouldNextTick && nodeCache.current[key]) { nodeCache.current[key]!.scrollLeft = x; nodeCache.current[key]!.scrollTop = y; //如果恢复成功,就取消 if (nodeCache.current[key]!.scrollLeft === x && nodeCache.current[key]!.scrollTop === y) { shouldNextTick = false; cancelRestoreFnCache.current[key](); } } }, props.restoreInterval || 50, props.tryRestoreTimeout || 500 ); }, 至此,滚动恢复的组件全部完成。具体源代码可以到github查看,欢迎star。 http://github.com/confuciusthinker/my-scroll-restore

效果

scroll-restore.gif

总结

一个滚动恢复功能,如果想要健壮,完善地实现。其实需要掌握Router,Route相关的原理、history监听路由变化原理、React Effect的相关执行时机以及一个好的设计思路。而这些都需要我们平时不断的研究,不断的追求完美。虽然这并不能“加钱”,但这种能力以及追求是我们成为技术大牛的路途中,最宝贵的财富。当然,能够加钱最好了😍。

创作不易,欢迎点赞!