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

3,904 阅读8分钟

前言

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

思路

我低头思考了一阵儿,想到了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的相关执行时机以及一个好的设计思路。而这些都需要我们平时不断的研究,不断的追求完美。虽然这并不能“加钱”,但这种能力以及追求是我们成为技术大牛的路途中,最宝贵的财富。当然,能够加钱最好了😍。

创作不易,欢迎点赞!