平时开发过程中,使用 react router 等单页应用跳转路由时,会发现当前页面在拥有滚动高度时,跳转的下个页面同样会保留上个页面的滚动高度(保留最小滚动高度),或者刷新、前进后退时仍会保留上次的滚动高度。
在某些场景下,我们需要重置滚动高度,比如列表页进入详情页,相反的情况下又需要保留高度。需要确定浏览器对于滚动的处理,可以先看下,其在原生中的滚动场景表现。
哪些场景浏览器会恢复滚动高度?哪些场景会重置高度?
下面内容皆为 Chrome 浏览器对于滚动高度的处理测试,请注意时效性和不同设备及浏览器之间的差距。
前置场景与条件
html 中能触发滚动的长列表页和详情页(文档滚动),使用 window.location.href 和 history.back() 或 history.go() 等来测试跳转场景下的滚动条情况。
列表页进入详情页和当前页面刷新时
左为使用 a 标签,右为使用 location.href 的资源跳转效果。
[{"url":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrl4er4zxyg30m80h4dh5.gif?imageMogr2/thumbnail/!50p","dataset":{"originPic":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrl4er4zxyg30m80h4dh5.gif","thumbnail":""}},{"url":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrl4er6q1mg30m80h4403.gif?imageMogr2/thumbnail/!50p","dataset":{"originPic":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrl4er6q1mg30m80h4403.gif","thumbnail":""}}]
刷新时的滚动场景
使用前进后退按钮和 history api 时
左侧为 history api 的前进后退(back、forward),右侧为浏览器的前进后退按钮。
[{"url":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrlaefm09cg30m80h440n.gif?imageMogr2/thumbnail/!50p","dataset":{"originPic":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrlaefm09cg30m80h440n.gif","thumbnail":""}},{"url":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrlahhs8z9g30p00g277m.gif?imageMogr2/thumbnail/!50p","dataset":{"originPic":"https://static.ksh7.com/post/react-reset-scroll-height/0085UwQ9gy1hrlahhs8z9g30p00g277m.gif","thumbnail":""}}]
小结
在不使用 history api 的情况下,a 标签还是操作 location 等进行资源跳转时,浏览器都会去加载该资源,并重新渲染新的 html。所以,即便是同域名并且同路径(例如 href 指向当前资源)都可以认为是加载一个新文档,此时的滚动高度为默认值。
当在刷新或前进后退时,浏览器会根据 history.scrollRestoration 来决定是否恢复滚动高度,默认值为 auto,所以默认情况下,当你跳转至其他页面再返回时仍能保留上次的滚动高度。当你设置为 manual 时,浏览器将不会保留上次的滚动高度
history.scrollRestoration 为 manual 时,刷新和历史记录的前进后退表现,此时并不会恢复滚动高度。
恢复滚动高度的前提
需要 body 或 html 其中不为 overflow: hidden(设置其中一个为 hidden的话,则浏览器将认为你不需要滚动)。其实 block 的 overflow 的默认值为 visible,不会出现滚动条,默认情况下你可以观察到 html 或 body 元素高度与内容高度大致相同(与其他元素出现滚动的机制差异),若你为这两个元素设置边框或缩小宽度,会发现滚动条并不在元素内,而是在视口内。
这两个元素在不同的浏览器内核中都有其特殊的处理,若想利用恢复滚动高度的特性的话,需要保持当前文档拥有滚动高度,而不是某个元素内的滚动高度。
若你的内容为异步,且渲染耗时过长(window.onload 左右)时,将不会恢复滚动高度。或新内容的高度短于之前的滚动高度,则兼容新内容的最大高度。
版本:v6.4+,data router
使用 history 跳转
由于 react router 使用的是 history api 如 pushState 或 replaceState 等来进行跳转,MDN 中也说明了,使用该方法修改 url 后,浏览器并不会去加载该 url。所以在一个有滚动高度的长列表页中跳转至有高度的详情页时,仍会保留上个页面的高度。
因为 history 的特性,可以让单页应用切换路由时不需要再去 reload,所以在 url 的切换时的组件切换,是为 router 模拟页面切换的效果,除非下一个组件的内容无法撑起当前的文档滚动高度。或内容为异步且渲染较慢时,会重置高度(或兼容下个页面的最大滚动高度),不然将会一直保持当前文档的滚动高度。
使用 history.scrollRestoration ?在刷新时该属性将很有用的重置滚动高度,在单页应用中更多的场景为列表进入详情,所以仅仅使用该属性并不能完全覆盖场景,仍需要在应用中编写逻辑。
ScrollRestoration 接受参数 getKey storageKey,推荐将其放置在 root route 中(例如 Layout),且只需要在 root 中渲染一个即可运行。
function RootRouteComponent() {
return (
<div>
{/* ... */}
<ScrollRestoration
getKey={(location, matches) => {
const paths = ['/home', '/notifications'];
return paths.includes(location.pathname)
? // home and notifications restore by pathname
location.pathname
: // everything else by location like the browser
location.key;
}}
/>
</div>
);
}
效果演示
该组件将滚动高度根据 key(getKey 方法中获取,默认为 location.key,由 push 或 replace 时,使用 createLocation 创建的不重复的 key ) value 方式储存至 Session Storage 中,来实现 history 跳转时重置滚动高度,历史记录前进后退时恢复滚动高度(每次的 history 记录都将是一条新的记录,也就是可以恢复不同的高度,getKey 中返回相同的 key 除外)。
getKey
在此方法中对同路径返回相同的 key 时,将会一直为上次的保持滚动高度(在使用 push 或 replace 等方法后被保存)。
history 前进后退时也重置滚动高度
每次返回不同的 key 即可
const getKey = (location, matches) => {
return Math.random();
};
在路由跳转时使用 preventScrollReset ,可以阻止页面的滚动高度被重置,需要注意的是,只是阻止 ScrollRestoration 组件对高度的重置,但浏览器的滚动特性仍会保持。比如多个导航都阻止了高度重置,那将和不使用该组件时表现一致。
<Link preventScrollReset={true} />
<Form preventScrollReset={true} />
navigate('/home', { preventScrollReset: true, })
/packages/react-router-dom/index.tsx
let { restoreScrollPosition, preventScrollReset } = useDataRouterState( DataRouterStateHook.UseScrollRestoration, ); React.useLayoutEffect(() => { // .... // preventScrollReset 为 true 时,不会重置高度 if (preventScrollReset === true) { return; } // otherwise go to the top on new locations window.scrollTo(0, 0); }, [location, restoreScrollPosition, preventScrollReset]);
为什么在开发环境中 刷新 会重置高度?
先说结论 StrictMode 模式导致的,具体原因感觉是 react-router@6.24.x(在测版本) 的 bug,在严格模式下,会导致其多次重置滚动高度。
源码部分
在 RouterProvider 内使用 useLayoutEffect 订阅 updateState 变化,在 ScrollRestoration 中的 useLayoutEffect 内执行 enableScrollRestoration 方法来 updateState 更新 restoreScrollPosition。由于 effect 的执行是深度优先,后 push 到 updateQueue 的 effect 先执行,所以在组件 ScrollRestoration 中的 layoutEffect 触发的 updateState 不会被 RouterProvider 监听,因为此时 RouterProvider 中的 subscribe 还未被执行。
所以此时的滚动高度恢复依靠 pageHide 设置的 scrollRestoration 为 auto 让浏览器自行恢复滚动高度。若未开启严格模式,重置高度的 useLayoutEffect 将只会执行一次(条件分支到重置),因为 layoutEffect 为同步任务,由于浏览器的滚动恢复执行时机,在同步任务内无法覆盖后面任务的滚动设置,多次的滚动变更时,浏览器会优化渲染执行,保留最后的变更。
/packages/react-router-dom/index.tsx
function RouterProvider() { // 订阅来自 react router 的 state 变化,如 updateState React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); } // 高度重置与恢复 function useScrollRestoration({ getKey, storageKey }) { let { restoreScrollPosition, preventScrollReset } = useDataRouterState( DataRouterStateHook.UseScrollRestoration, ); // Trigger manual scroll restoration while we're active React.useEffect(() => { window.history.scrollRestoration = 'manual'; return () => { window.history.scrollRestoration = 'auto'; }; }, []); // window pageHide 事件:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/pagehide_event,单页应用可以通过刷新来测试 usePageHide( React.useCallback(() => { if (navigation.state === 'idle') { let key = (getKey ? getKey(location, matches) : null) || location.key; savedScrollPositions[key] = window.scrollY; } try { sessionStorage.setItem( storageKey || SCROLL_RESTORATION_STORAGE_KEY, JSON.stringify(savedScrollPositions), ); } catch (error) {} window.history.scrollRestoration = 'auto'; }, [storageKey, getKey, navigation.state, location, matches]), ); React.useLayoutEffect(() => { // ... // 启用 `ScrollRestoration`,此处使用 updateState 更改 restoreScrollPosition let disableScrollRestoration = router?.enableScrollRestoration( savedScrollPositions, () => window.scrollY, getKeyWithoutBasename, ); return () => disableScrollRestoration && disableScrollRestoration(); }, [router, basename, getKey]); // 重置高度 React.useLayoutEffect(() => { // .... // otherwise go to the top on new locations window.scrollTo(0, 0); }, [location, restoreScrollPosition, preventScrollReset]); }
effect 入栈顺序
function pushEffect(tag, create, destroy, deps) {
var effect = {
tag: tag,
create: create,
destroy: destroy,
deps: deps,
// Circular
next: null,
};
var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue;
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
var lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
var firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
























