注册
web

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

前言


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


思路


我低头思考了一阵儿,想到了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,将该对象分发给具体的滚动恢复执行者。其设计如下:


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,其设计如下:

//缓存位置的具体内容
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中进行说明。

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对象向下传递

<ScrollManagerContext.Provider value={manager}>
{props.children}
</ScrollManagerContext.Provider>


  • 除了上述功能外,manager还有一个重要功能:获知元素在导航切换前的位置。在React-Router中一切路由状态的切换都由history.listen来发起,由于history.listen可以监听多个函数。所以可以在路由状态切换前,插入一段监听函数,来获得节点相关信息。

location改变 ---> 获得节点位置信息 ---> 路由update


  • 在实现中,使用了一个状态shouldChild,来确保监听函数一定在触发顺序上先于Router中的监听函数。实现如下:

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传递
<ScrollManagerContext.Provider value={manager}>
{shouldChild && props.children}
</ScrollManagerContext.Provider>



  • 真正使用时,管理者组件要放在Router组件外侧,来控制Router实例化:

<ScrollRestoreManager history={history}>
<Router history={history}>
...
</Router>

</ScrollRestoreManager>

滚动恢复执行者-ScrollElement


ScrollElement的主要职责其实是控制真实的HTMLElement元素,决定缓存的key,包括决定何时触发恢复,何时保存原始HTMLElement的引用,设置是否需要保存的位置等等。ScrollElement的props设计如下:


export interface ScrollRestoreElementProps {
/**
* 必须缓存的key,用来标志缓存的具体元素,位置信息以及状态等,全局唯一
*/

scrollKey: string;
/**
* 为true时触发滚动恢复
*/

when?: boolean;
/**
* 外部传入ref
* @returns
*/

getRef?: () => HTMLElement;
children?: React.ReactElement;
}


  • ScrollElement本质上可以看作为一个代理,会拿到子元素的Ref,接管其控制权。也可以自行实现getRef传入组件中。首先要实现的就是滚动发生时,记录位置能力:

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变更时处理的能力。注意,这块使用了对useLayoutEffectuseEffect执行时机的理解处理:

//使用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的子元素是否是合格的

//如果有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,同时设定时间上限,并返回一个取消函数给外部,如果最终结果理想则取消尝试,否则再次尝试直到时间上限内达到理想位置。更改恢复函数:


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。 github.com/confuciusth…


效果


scroll-restore.gif


总结


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


创作不易,欢迎点赞!


作者:青春地平线
来源:juejin.cn/post/7186600603936620603

0 个评论

要回复文章请先登录注册