通常情况当 DOM 从页面中删除或者重建后,组件的 scroll 位置就会丢失,在 Vue 和 React 这类 ASP 框架中路由切换说到底其实就是 DOM 的删除和重建,这就会导致长列表页面在 ASP 应用中切换路由后再返回列表页面会重新构建列表 DOM 使列表被跳转到顶部丢失用户滑动位置这种糟糕的用户体验,这种情况在移动端就会给用户更加割裂的感觉。

当然,既然这都是一个通病那各大框架的路由组件肯定会考虑解决这个问题吧?是,在 React Router 中有 ScrollRestoration 同样 Vue Router 中也有 Scroll Behavior

但是,我要说但是了,当你使用过之后就会发现,为啥有些页面就还是不记录 List 组件的 scroll 滑动位置呢?其实在路由库中实现的都是记录 document 的 scroll 滑动位置,也就是当你的列表组件是自定义的一个列表或滑动组件,就算是使用了 Scroll Behavior 也是没有记录你组件的滑动位置的。其实这么实现是因为 Router 组件根本没办法(其实也不是没办法,博客后续会讲一个我的实现思路)知道你滑动组件是哪个,因为 DOM 重建了它就算记录了也不知道将滑动位置恢复到哪个组件上。

既然是因为销毁了组件的 DOM 导致的滑动位置消失,那么我们的解决思路也很简单,在组件销毁前记录组件的滑动位置,然后在组件构建时候查询一下有没有记录的组件滑动位置,如果有的话将组件滑动位置调整到记录值不就好了吗。

下面我就来试试 Vue 中怎么记录并且恢复组件的 scroll 滑动位置,由于个人喜好问题,下面的示例代码我将采用 JSX for Vue3 的技术栈来写,不过思路都是相同的你可以根据自己的前端技术栈去开发。

在 Vue 中有一个 KeepAlive 组件,其实就会缓存组件状态的。不过直接将组件套在 KeepAlive 中也是无法记录 scroll 滑动位置的,组件放在 KeepAlive 就会有 onActivated 和 onDeactivated 两个生命周期钩子函数。我们可以在其中去记录滑动位置和设置滑动位置,具体的思路就是写一个组件可以将列表组件包含住,这样我们就能知道是哪个组件(slot 的根组件)需要记录滑动位置了。

创建一个 ScrollKeepAlive 组件

import { defineComponent, KeepAlive, onActivated, onDeactivated } from 'vue';

export default defineComponent({
    name: 'ScrollKeepAlive',
    setup(props, ctx) {
        return () => {
            return (
                <>
                    <KeepAlive>
                        <KeepAliveTmp>{ctx.slots?.default && ctx.slots.default()}</KeepAliveTmp>
                    </KeepAlive>
                </>
            );
        };
    },
});

在 ScrollKeepAlive 组件中不能直接使用 onActivated 和 onDeactivated 函数,于是我们还需要套一层 KeepAliveTmp 组件。

let scrollSaved: Map<String, { top: number; left: number }> = new Map(); 
const KeepAliveTmp = defineComponent({
    setup(props, { slots }) {
        let slotsDom: any[] = [];

        onActivated(() => {
        });

        onDeactivated(() => {
        });

        return () => {
            slotsDom = slots.default ? slots.default() : [];
            return <>{slotsDom}</>;
        };
    },
});

在这里的 slotsDom 中就拿到的插槽中的根组件,并且随便创建了一个 scrollSaved 变量用于保存滑动位置信息,按之前的思路,我们需要在 onDeactivated 函数销毁之前记录 slotsDom 中根组件的滑动(scrollTop,scrollLeft)位置,首先定义一个获取根组件的函数

function getPathTo(element: any): any {
    if (element.id !== '') return '//#id["' + element.id + '"]';
    if (element === document.body) return element.tagName;
    var ix = 0;
    var siblings = element.parentNode.childNodes;
    for (var i = 0; i < siblings.length; i++) {
        var sibling = siblings[i];
        if (sibling === element) return getPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
        if (sibling.nodeType === 1 && sibling.tagName === element.tagName) ix++;
    }
}

function getElObj(): { el: any; key: String } | undefined {
    if (slotsDom.length < 0) {
        return undefined;
    }

    const el = slotsDom[0].children[0].el;
    const elKey = window.btoa(window.location.hash + getPathTo(el));
    return {
        el,
        key: elKey,
    };
}

在定义的 getElObj 函数中除了在 slotsDom 获取根组件对象之外,还通过拼接 location.hash(这里是因为我用的 WebHashHistory 如果是其他路由规则的话可以不用 hash 字段用其他可以标识当前页面的字段)和组件的 xPath 生成了一个 base64 的 key 用于作为滑动组件的唯一标识。 生成滑动组件唯一标识符的思路就是利用页面路径以及组件在页面中的位置生成出生成出组件的标识这样在一定程度上是能够实现该组件的标识在当前 DOM 树中的“唯一性”。


function handleScroll(e: any) {
    // 获取 slot 组件对象
    const elObj = getElObj();
    if (!elObj) {
        return;
    }

    // 判断滑动组件不等于当前 slot 组件,不记录滑动组件值
    // console.log('e == el', e.srcElement === elObj.el);
    if (e.srcElement !== elObj.el) {
        return;
    }

    // 记录滑动组件位置
    scrollSaved.set(elObj.key, {
        top: elObj.el.scrollTop,
        left: elObj.el.scrollLeft,
    });
}

接着定义 handleScroll 函数用于记录滑动组件的 scrollTop 和 scrollLeft 位置。根据我们刚开始的时候的思路其实是应该在 onDeactivated 函数中记录组件滑动位置的,但是其实在 onDeactivated 中获取到滑动组件的 scrollTop 和 scrollLeft 值会是 0。因为这个时候组件已经卸载了,解决方案就是监听全局滑动事件,在 onActivated 中添加滑动事件监听函数,在 onDeactivated 时卸载滑动事件监听函数。这样实际上就是在用户每次滑动组件时候就会将滑动后的值记录上。

…
onActivated(() => {
    const elObj = getElObj();
    if (!elObj) {
        return;
    }

    window.addEventListener('scroll', handleScroll, true);
});

onDeactivated(() => {
    window.removeEventListener('scroll', handleScroll, true);
});
…

然后就是在 onActivated 中先读取一下当前滑动组件有无记录的滑动位置值,如果存在滑动位置值就将滑动位置设置到组件上。

const savedPosition = scrollSaved.get(elObj.key);
if (savedPosition) {
    // 存在记录值,将组件滑动到指定位置
    elObj.el.scrollTo && elObj.el.scrollTo(savedPosition);
}

最后将你的列表或者滑动组件套在 ScrollKeepAlive 组件中就能实现滑动属性缓存特性了。

…
<ScrollKeepAlive>
    <div style={{ flex: 1, width: '100%', overflow: 'auto' }}>
        {new Array(100).fill(undefined).map((item, index) => (
            <div key={index} style={{ padding: '10px' }}>
                <span style={{ marginRight: '10px' }} >item is {index}</span>
                <button onClick={() => {
                    console.log('hello', index);
                }}>to info page</button>
            </div>
        ))}
    </div>
</ScrollKeepAlive>
…

在最后的最后,我贴一下完整的 ScrollKeepAlive 代码吧(虽然写的比较菜,其实代码还有很大优化空间),多看几遍然后按照上面的思路尝试自己写一个你会发现其实也不难。

import { defineComponent, KeepAlive, onActivated, onDeactivated } from 'vue';

let scrollSaved: Map<String, { top: number; left: number }> = new Map();
const KeepAliveTmp = defineComponent({
    setup(props, { slots }) {
        let slotsDom: any[] = [];

        function getPathTo(element: any): any {
            if (element.id !== '') return '//#id["' + element.id + '"]';
            if (element === document.body) return element.tagName;
            var ix = 0;
            var siblings = element.parentNode.childNodes;
            for (var i = 0; i < siblings.length; i++) {
                var sibling = siblings[i];
                if (sibling === element) return getPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
                if (sibling.nodeType === 1 && sibling.tagName === element.tagName) ix++;
            }
        }

        function getElObj(): { el: any; key: String } | undefined {
            if (slotsDom.length < 0) {
                return undefined;
            }

            const el = slotsDom[0].children[0].el;
            const elKey = window.btoa(window.location.hash + getPathTo(el));
            return {
                el,
                key: elKey,
            };
        }

        function handleScroll(e: any) {
            // 获取 slot 组件对象
            const elObj = getElObj();
            if (!elObj) {
                return;
            }

            // 判断滑动组件不等于当前 slot 组件,不记录滑动组件值
            // console.log('e == el', e.srcElement === elObj.el);
            if (e.srcElement !== elObj.el) {
                return;
            }

            // 记录滑动组件位置
            scrollSaved.set(elObj.key, {
                top: elObj.el.scrollTop,
                left: elObj.el.scrollLeft,
            });
        }

        onActivated(() => {
            const elObj = getElObj();
            if (!elObj) {
                return;
            }

            const savedPosition = scrollSaved.get(elObj.key);
            // console.log('onActivated savedPosition', scrollSaved);
            if (savedPosition) {
                // 存在记录值,将组件滑动到指定位置
                elObj.el.scrollTo && elObj.el.scrollTo(savedPosition);
                // console.log('onActivated savedPosition', savedPosition);
            }

            window.addEventListener('scroll', handleScroll, true);
        });

        onDeactivated(() => {
            window.removeEventListener('scroll', handleScroll, true);
        });

        return () => {
            slotsDom = slots.default ? slots.default() : [];
            return <>{slotsDom}</>;
        };
    },
});

export default defineComponent({
    name: 'ScrollKeepAlive',
    setup(props, ctx) {
        return () => {
            return (
                <>
                    <KeepAlive>
                        <KeepAliveTmp>{ctx.slots?.default && ctx.slots.default()}</KeepAliveTmp>
                    </KeepAlive>
                </>
            );
        };
    },
});

有问题可以给我博客原文 https://bin.zmide.com/?p=1143 留下评论,看到我都会回复大家的。