惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
GbyAI
GbyAI
L
LangChain Blog
Vercel News
Vercel News
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
T
Threatpost
Scott Helme
Scott Helme
T
Tailwind CSS Blog
Latest news
Latest news
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
The Register - Security
The Register - Security
罗磊的独立博客
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
雷峰网
雷峰网
A
About on SuperTechFans
T
Tenable Blog
F
Full Disclosure
Cyberwarzone
Cyberwarzone
博客园_首页
有赞技术团队
有赞技术团队
K
Kaspersky official blog

文章列表

win or mac clash 无 TUN 让 Antigravity、Chrome 强制 proxy(解决 Antigravity 无法加载选择 model、自动更新无法登录、跳转) 【大杂烩】在 pnpm 中直接修改 node_modules(.pnpm) 中的依赖项,项目中持久化 - pnpm 中的依赖处理、幽灵依赖、寻址规则等 在 html 中直接使用 Esm、Jsx 脚本快速调试和使用 React@19 和 Vue@3 源码,解决 React19 UMD 构建等问题 一键在本地批量检测并升级更新 package.json 中的模块依赖,ncu(npm-check-updates)在 npm、pnpm 或 workspace 项目中的使用教程 解决 Mac Docker Desktop 中启动出现的问题合集 通过阿里云、腾讯云无服务器搭建自定义的企业域名邮箱,实现在 QQ邮箱 收发等功能(附腾讯 SMTP 和 IMAP) 解决使用代理(clash 等)进行 SSH 连接(如 Github ssh key clone/push)出现 kex_exchange_identification 错误 静态文件资源 cdnjs, jsdelivr 抖音字节国内快速 CDN 镜像推荐【2025】- 仍在使用 bootcdn 和 staticfile CDN 请注意验证资源的完整性(SRI) pnpm monorepo 中管理依赖的最佳实践,与 Catalogs(目录)协议的使用(monorepo 中统一版本管理) Web 安全中的 Secure Contexts(安全上下文)- 解决在本地中使用 clipboard 或 Crypto 等 API 限制或关闭上下文限制 使用 serve 配合 openssl 或 mkcert 创建本地自签名可信任的证书 - 创建本地 TLS\SSL https 协议服务 利用 Github Actions 和 Acme 自动申请、更新和部署至阿里云、腾讯云 CDN Lets Encrypt SSl\TLS ECC RSA 双证书 【CSS】解决在 flex 容器中使用 align-content 或 justify-content 属性 center 居中时的溢出滚动和截断问题 - 理解 safe 关键字 在线工具 - 一键获取下载抖音无水印视频、抖音去水印解析工具、下载抖音无水印高清图集【2025 最新】 【React Router】v6 data router 在非组件(或工具方法)中如何优雅的跳转路由 【React】在本地 Html 中快速 debug(调试)React 源码 【React】结合源码和 EventLoop 分析 - 为什么 useLayoutEffect 会阻止 DOM 重绘(而 useEffect 闪烁)?为什么其内部 useState 会“同步”执行?
【React】为什么路由跳转时页面滚动高度不会被重置(保留上个页面高度)?理解 history scrollRestoration 的场景与使用,以及如何使用 React Router 重置和跳转前保留滚动高度
kshao · 2024-07-11 · via

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

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

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

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

前置场景与条件

html 中能触发滚动的长列表页和详情页(文档滚动),使用 window.location.hrefhistory.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 的前进后退(backforward),右侧为浏览器的前进后退按钮。

[{"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.scrollRestorationmanual 时,刷新和历史记录的前进后退表现,此时并不会恢复滚动高度。

恢复滚动高度的前提

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

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

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

版本:v6.4+data router

demo

使用 history 跳转

由于 react router 使用的是 history apipushStatereplaceState 等来进行跳转,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>
  );
}

效果演示

该组件将滚动高度根据 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 ,可以阻止页面的滚动高度被重置,需要注意的是,只是阻止 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;
}