返回定位到上次浏览位置 - scrollTop

554 阅读4分钟

背景:首页多场考试【分页请求的数据】,点击进入开始考试页。产品想要的效果:在开始考试页点击返回定位到首页上次浏览的位置。项目框架中未使用第三方keepAlive缓存路由(缓存路由会损失部分数据实时性,要根据项目实际情况酌情使用),另外考试是分页的数据,使用scrollTop定位需要多次定位。

接下来是一步步,由简单逐渐优化到最终代码(核心代码)的过程,github可以获取全部代码:

一、前置知识

(1)scrollTop介绍:

MDN描述Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数。

// 设置滚动的距离
element.scrollTop = intValue;

(2) es6的export共用数据

通过es6的exort导出的函数或数据可以供外部import引入使用。当多次import同一文件时,只会在第一次import时执行文件中的代码,后续import不会再次执行,可以直接使用。

export 相当于闭包,export function外部的数据是共享的。

// share.js
let shareData = "我是供export function 公共使用的数据";

export function setShareData(newValue) {
    console.log("shareData:", shareData);
    shareData = newValue;
}
// first.js
import { setShareData } from "share.js";

setShareData("first文件中修改了公共数据shareData");// 输出:我是供export function 公共使用的数据
// second.js
import { setShareData } from "share.js";

setShareData("second文件中修改了公共数据shareData"); // 输出:first文件中修改了公共数据shareData

二、返回定位

切换到另一个页面需要保留当前页面的位置信息,数据就需要保存到全局【store】或不随页面销毁的位置【闭包】,接下来使用闭包的方式实现:

返回 -> 元素是否可滚动 -> 请求数据 -> 渲染页面 -> 元素是否可滚动 -> 设置scrollTop -> 请求分页数据

//index.tsx
import React, { useRef, useEffect, useCallback, ReactElement, useImperativeHandle, forwardRef } from "react";
// 全局保存定位信息
let allScrollTop = {};

interface IPropScrollWrapper {
    scrollKey: string,
    children?: ReactElement,
    outerClass?: string,
    outerHandleScroll?: Function
}

function ScrollWrapper(props: IPropScrollWrapper) {
    const scroll = useRef<HTMLDivElement>(null);
    // 渲染时是否被定位
    const isSetPosition = useRef(false);
    // 实时保存滚动位置
    const inTimeScrollTop = useRef(0);
    // 定位
    const setPosition = useCallback((lastScrollTop) => {
        if (scroll.current) {
            scroll.current.scrollTop = lastScrollTop;
        }
    }, []);
    // 每次渲染都会执行
    useEffect(() => {
        if (allScrollTop[props.scrollKey] === undefined) {
            allScrollTop[props.scrollKey] = 0;
        }
        // 当scroll.current关联元素可滚动时,设置scrollTop到指定位置
        if (!isSetPosition.current
                && scroll.current && isScrollElement(scroll.current)) {
            isSetPosition.current = true;
            setPosition(allScrollTop[props.scrollKey]);
        }
    });
    // 销毁时保存位置信息到全局
    useEffect(() => {
        return () => {
            allScrollTop[props.scrollKey] = inTimeScrollTop.current;
        }
    }, []);
    
    // 滚动时触发 - 实时记录位置信息和调用外部传入滚动函数
    const handleScroll = useCallback(() => {
        let { scrollTop } = scroll.current;
        inTimeScrollTop.current = scrollTop;
        props.outerHandleScroll?.();
    }, [props.outerHandleScroll]);
    
    // 工具方法
    // 判断元素是否可滚动
    const isScrollElement = useCallback((element: HTMLElement): boolean => {
        return element.scrollHeight > element.clientHeight
    }, []);
    
    return (
        <div className={ styles['scroll-wrapper'] + " " +  props.outerClass } ref={ scroll } onScroll={ handleScroll }>
            { props.children }
        </div>
    )
}

export default forwardRef(ScrollWrapper);

三、增加分页定位

首页考试列表数据是通过多次分页数据获取的,滚动时通过antd-mobile的 InfiniteScroll监听并获取分页数据,当缓存位置在第二页时,上面的方法只定位一次到第一页的尾部。

返回 -> 元素是否可滚动 -> 请求数据 -> 渲染页面 -> 元素是否可滚动 -> setTimeout 16ms设置一次scrollTop -> 请求分页数据

//index.tsx

// 增加是否到达之前位置,没有,设置定位并通过setTimeout 16ms再次执行定位
const setPosition = useCallback((lastScrollTop) => {
    if (scroll.current && !isRollFinish(scroll.current, lastScrollTop)) {
        if(scroll.current.scrollTop == lastScrollTop) {
            return;
        }
        scroll.current.scrollTop = lastScrollTop;
        setTimeout(() => setPosition(lastScrollTop), 16);
    }
}, []);

四、遇到的问题

当不同tab下的container包裹在同一个滚动组件中时,在tab1中container滚动区域最大1200,tab2下container滚动区域最大是800。

当缓存tab1为1000时,打开tab2,container中的内容会一直再最底部持续滚动。当然这是自己代码中写的setTimeout(func, 16),不到缓存的位置会一直设置定位。。。所以应该可以让父组件清除缓存信息。 截屏2022-06-12 下午4.52.50.png

//index.tsx

// 销毁时全局是否需要保存位置信息
const isRecordScrollPosition = useRef(true);

// 销毁时判断是否保存位置信息
useEffect(() => {
    return () => {
        if (!isRecordScrollPosition.current) {
            delete allScrollTop[props.scrollKey];
            return;
        }
        allScrollTop[props.scrollKey] = inTimeScrollTop.current;
    }
}, []);

// ------供外部调用 start-------
// 销毁时清除定位信息
const clearThisPosition = useCallback(() => {
    isRecordScrollPosition.current = false;
}, []);

// 下次定位到顶部(切换tab时展示内容定位顶部)
const setThisPositionToTop = useCallback(() => {
    allScrollTop[props.scrollKey] = 0;
}, []);

// 获取组件内部滚动区域ref
const getScrollRef = useCallback(():null | HTMLDivElement => {
    return scroll.current;
}, []);

useImperativeHandle(ref,()=>{
    const handleRefs = {
        clearThisPosition,
        setThisPositionToTop,
        getScrollRef
    }
    return handleRefs
 },[]);
// ------供外部调用 end-------

五、实际使用

{/* 分页 key:router+序号,防止重名 */}
<ScrollWrapper ref={scrollRef} scrollKey={ props.history.location.pathname + "0" }>
{
    !!mockExamList?.length && 
    <>
        {
            mockExamList.map(item => (
                <MockExamCard key ={ item.examNumber } { ...item } />
            ))
        }
        <InfiniteScroll loadMore={loadMore} hasMore={hasMore} threshold={ 50 }>
            <>
                {hasMore ? (
                    <>
                        <span>Loading</span><DotLoading />
                    </>
                ) : (
                    <span>没有更多考试啦~</span>
                )}
            </>
        </InfiniteScroll>
    <>
}
</ScrollWrapper>

最终代码

需要的可以私聊获取