虚拟列表与 Scroll Restoration
虚拟列表是为了提高页面性能而出现的。我们知道,一个页面上的 DOM 树越复杂,节点越多性能越低,每次重排(reflow)的成本越高。于是,虚拟列表出现了。虚拟列表的原理是只渲染可视部分以及部分预渲染的节点,待滚动之后替换可视部分节点。余下的空间则用 padding-top
padding-bottom
撑开。
本篇文章不讨论如何实现一个虚拟列表,此类文章网上有很多。但是有关于回退页面无法回到虚拟列表上一次的位置的文章却很少。默认情况下,在后退页面时,浏览器会自动回到上一次浏览的位置。(如果设置 history.scrollRestoration = 'auto'
,默认为 auto
)
但是如果用了虚拟列表,这里的虚拟列表跟随 document 根节点(document.documentElement)滚动,即使开启了 Restoration,回退页面后仍然无法回到上一次的位置。这是因为虚拟列表需要计算得出整个容器的高度,在计算之前容器没有高度,浏览器就不能回到之前的滚动高度了,因为高度不存在。
一种方式是,记录之前虚拟列表容器的高度,在回退回来之后先用之前记录的值去撑开整个容器高度,待虚拟列表加载后去除。这样有个问题是虚拟列表无法知道当前的位置原来是什么内容,因为虚拟列表都是按照单个 Node 高度去计算的,整体高度是一个预估值,不能知道当前位置具体是什么。
对于 react-virtuoso 这个库,没有直接暴露给我们每个 Node 计算后的高度,也没有一个自身的 State 想要缓存状态不太现实。一个不好的解决方案是用提供的接口在每次滚动后记录一个 Range,Range 是一个当前渲染内容的索引,在之后的渲染后可以用自身的 scrollTo
方法跳转。这样有个坏处是会出现跳动,原先在顶部直接跳动到了原先的位置,还是个预估值。既不准确也不符合 UX 逻辑。
之后,我又找到一个比较小众的库,virtual-scroller,不仅仅可以在 React 使用,他独立封装了一个 Core,可以单独在各个框架中使用,即使在 VanillaJS 中使用,小众的库功能肯定不会很多,但是基本的功能也都有,也可以 fork 一份出来进行修改和扩展。选择此库的原因是他暴露了自身的 State,可以缓存每个 State 在之后的渲染中使用。该库没有文档,没有 type definition,通过翻看源码我们可以知道,可以在 Router Change 之前获取到该组件的 Ref,记录下该组件的 State,在后面的渲染中注入 initialState。
import Router from 'next/router'
import { useEffect, useMemo, useRef } from 'react'
import VirtualScroller from 'virtual-scroller/react'
const cacheState = {}
const cachePrevTop = {}
if ('window' in globalThis) {
window.debug = {
cacheState,
cachePrevTop,
}
}
export default function Test() {
const cacheKey = useMemo(
() => ('window' in globalThis ? location.pathname : ''),
[],
)
const ref = useRef()
useEffect(() => {
// history.scrollRestoration = 'manual'
const $scroll = document.scrollingElement
requestAnimationFrame(() => {
$scroll.scrollTop = cachePrevTop[cacheKey]
// console.log('top')
})
const handler = () => {
cachePrevTop[cacheKey] = $scroll.scrollTop
if (ref.current) {
cacheState[cacheKey] = { ...ref.current.state }
}
}
Router.events.on('routeChangeStart', handler)
return () => {
Router.events.off('routeChangeStart', handler)
}
}, [])
return (
<div className="">
<VirtualScroller
ref={ref}
items={Array.from({ length: 50 }, (_, i) => i)}
itemComponent={Item}
initialState={cacheState[cacheKey]}
/>
</div>
)
}