平时开发过程中,使用 react router 等单页应用跳转路由时,会发现当前页面在拥有滚动高度时,跳转的下个页面同样会保留上个页面的滚动高度(保留最小滚动高度),或者刷新、前进后退时仍会保留上次的滚动高度。

在某些场景下,我们需要重置滚动高度,比如列表页进入详情页,相反的情况下又需要保留高度。需要确定浏览器对于滚动的处理,可以先看下,其在原生中的滚动场景表现。

哪些场景浏览器会恢复滚动高度?哪些场景会重置高度?

下面内容皆为 Chrome 浏览器对于滚动高度的处理测试,请注意时效性和不同设备及浏览器之间的差距。

前置场景与条件

html 中能触发滚动的长列表页和详情页(文档滚动),使用 window.location.hrefhistory.back()history.go() 等来测试跳转场景下的滚动条情况。

列表页进入详情页和当前页面刷新时

左为使用 a 标签,右为使用 location.href 的资源跳转效果。

刷新时的滚动场景

reload

使用前进后退按钮和 history api

左侧为 history api 的前进后退(backforward),右侧为浏览器的前进后退按钮。

小结

在不使用 history api 的情况下,a 标签还是操作 location 等进行资源跳转时,浏览器都会去加载该资源,并重新渲染新的 html。所以,即便是同域名并且同路径(例如 href 指向当前资源)都可以认为是加载一个新文档,此时的滚动高度为默认值。

当在刷新或前进后退时,浏览器会根据 history.scrollRestoration 来决定是否恢复滚动高度,默认值为 auto,所以默认情况下,当你跳转至其他页面再返回时仍能保留上次的滚动高度。当你设置为 manual 时,浏览器将不会保留上次的滚动高度

history.scrollRestorationmanual 时,刷新和历史记录的前进后退表现,此时并不会恢复滚动高度。

恢复滚动高度的前提

需要 bodyhtml 其中不为 overflow: hidden(设置其中一个为 hidden的话,则浏览器将认为你不需要滚动)。其实 blockoverflow 的默认值为 visible,不会出现滚动条,默认情况下你可以观察到 htmlbody 元素高度与内容高度大致相同(与其他元素出现滚动的机制差异),若你为这两个元素设置边框或缩小宽度,会发现滚动条并不在元素内,而是在视口内。

这两个元素在不同的浏览器内核中都有其特殊的处理,若想利用恢复滚动高度的特性的话,需要保持当前文档拥有滚动高度,而不是某个元素内的滚动高度。

若你的内容为异步,且渲染耗时过长(window.onload 左右)时,将不会恢复滚动高度。或新内容的高度短于之前的滚动高度,则兼容新内容的最大高度。

在 React Router 中的表现

版本:v6.4+data router

demo

使用 history 跳转

由于 react router 使用的是 history apipushStatereplaceState 等来进行跳转,MDN 中也说明了,使用该方法修改 url 后,浏览器并不会去加载该 url。所以在一个有滚动高度的长列表页中跳转至有高度的详情页时,仍会保留上个页面的高度。

因为 history 的特性,可以让单页应用切换路由时不需要再去 reload,所以在 url 的切换时的组件切换,是为 router 模拟页面切换的效果,除非下一个组件的内容无法撑起当前的文档滚动高度。或内容为异步且渲染较慢时,会重置高度(或兼容下个页面的最大滚动高度),不然将会一直保持当前文档的滚动高度。

使用 history.scrollRestoration ?在刷新时该属性将很有用的重置滚动高度,在单页应用中更多的场景为列表进入详情,所以仅仅使用该属性并不能完全覆盖场景,仍需要在应用中编写逻辑。

ScrollRestoration

react router v6 中提供了组件 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>
  );
}

效果演示

该组件将滚动高度根据 keygetKey 方法中获取,默认为 location.key,由 pushreplace 时,使用 createLocation 创建的不重复的 keyvalue 方式储存至 Session Storage 中,来实现 history 跳转时重置滚动高度,历史记录前进后退时恢复滚动高度(每次的 history 记录都将是一条新的记录,也就是可以恢复不同的高度,getKey 中返回相同的 key 除外)。

getKey

在此方法中对同路径返回相同的 key 时,将会一直为上次的保持滚动高度(在使用 pushreplace 等方法后被保存)。

history 前进后退时也重置滚动高度

每次返回不同的 key 即可

const getKey = (location, matches) => {
  return Math.random();
};

preventScrollReset

在路由跳转时使用 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 的执行是深度优先,后 pushupdateQueueeffect 先执行,所以在组件 ScrollRestoration 中的 layoutEffect 触发的 updateState 不会被 RouterProvider 监听,因为此时 RouterProvider 中的 subscribe 还未被执行。

所以此时的滚动高度恢复依靠 pageHide 设置的 scrollRestorationauto 让浏览器自行恢复滚动高度。若未开启严格模式,重置高度的 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;
}