Skip to content

11|滚动行为:位置是导航合同的一部分 ​

第六章概览 · 目标:让前进、后退、hash 和异步内容的滚动行为可解释;不要把每次导航都粗暴地滚到页面顶部。

三种导航,三种期望 ​

  • 新页面或新资源: 通常从顶部开始,避免用户看到上一页的中间内容。
  • 浏览器后退/前进: 优先使用 Router 提供的 savedPosition,恢复用户离开时的位置。
  • hash 锚点: 滚到 to.hash 对应元素;如果页面有固定头部,需加入偏移量。
ts
import { createRouter, createWebHistory } from 'vue-router'

export const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, _from, savedPosition) {
    if (savedPosition) return savedPosition

    if (to.hash) {
      return {
        el: to.hash,
        top: 72,
        behavior: 'smooth'
      }
    }

    return { top: 0 }
  }
})

scrollBehavior 返回位置对象,也可以返回 Promise。异步返回适合等待过渡或目标元素出现;但不要无条件等待一个永远不会完成的 Promise,否则导航已经完成,滚动却永远悬空。

savedPosition 与应用状态 ​

Router 能恢复浏览器历史中的位置,但它不知道虚拟列表、内嵌滚动容器或用户在弹窗内的滚动。页面如果使用 main 以外的滚动容器,需要为该容器建立自己的 owner 和清理策略,不能把 window.scrollTo 当万能答案。

ts
scrollBehavior(to, from, savedPosition) {
  if (savedPosition && to.path === from.path) return savedPosition
  if (to.hash) return { el: to.hash, top: 64 }
  return { top: 0, left: 0 }
}

上面的路径条件只是一个业务选择:如果后退到同一详情页仍应恢复位置,就不能擅自过滤。先写用户期望,再写条件。

不存在的锚点必须先判断 ​

{ el: to.hash } 只是把选择目标的工作交给 Router;返回这个对象不等于元素一定存在。把 DOM 查找与策略分开,普通测试就能传入“存在/缺失”两个结果。浏览器中 hash 可能含空格或标点,按 ID 读取应先解码并用 getElementById(),避免把它误当 CSS 选择器。

ts
function findHashElement(hash: string) {
  try { return document.getElementById(decodeURIComponent(hash.slice(1))) }
  catch { return null }
}

等待异步锚点,也要允许结束等待 ​

评论可能在导航确认后才加入DOM。此时一次getElementById得到null,不代表稍后仍没有节点。下面使用 MutationObserver 观察文档子节点变化:每次变化重新查找id;找到或超过时限后,断开观察并清除计时器。它只在浏览器scrollBehavior中调用,不放在SSR模块顶层。

ts
export function waitForId(id: string, timeoutMs = 500): Promise<HTMLElement | null> {
  const existing = document.getElementById(id)
  if (existing) return Promise.resolve(existing)
  if (timeoutMs <= 0) return Promise.resolve(null)

  return new Promise(resolve => {
    const finish = (element: HTMLElement | null) => {
      observer.disconnect()
      clearTimeout(timer)
      resolve(element)
    }
    const observer = new MutationObserver(() => {
      const element = document.getElementById(id)
      if (element) finish(element)
    })
    const timer = setTimeout(() => finish(null), timeoutMs)
    observer.observe(document.documentElement, { childList: true, subtree: true })
  })
}

把它接入createRouter的scrollBehavior选项。这里的router指创建完成后的同一实例;回调在导航时才执行。

ts
async scrollBehavior(to, _from, savedPosition) {
  if (savedPosition) return savedPosition
  if (!to.hash) return { top: 0, left: 0 }
  let id: string
  try { id = decodeURIComponent(to.hash.slice(1)) }
  catch { return { top: 0, left: 0 } }
  const element = await waitForId(id)
  if (router.currentRoute.value.fullPath !== to.fullPath) return false
  return element ? { el: element, top: 64 } : { top: 0, left: 0 }
}

假设先导航a的comments,等待期间又去了b。a的观察者稍后结束时若直接返回滚动目标,就可能把b的窗口滚走;最后的位置比较让失效导航不执行滚动。最多等待500ms是这个示例的策略,不保证网络一定完成;慢于时限时回顶部并结束观察。需要更精确地处理“同一fullPath离开后又回来”的应用,应以一次导航的身份标记替代位置比较。

观察实验:让页面先显示正文,100ms后通过v-if加入 id="comments" 的区域,确认等待找到元素;再改成始终不出现,确认500ms后回退且观察停止。完成第九章测试环境后可以把这两条路径自动化。DOM测试可以证明目标和清理,实际偏移、固定头部和滚动动画仍应在真实浏览器测量。

变化条件与边界 ​

  • #missing 没有对应元素时,浏览器不能滚到目标;应回退到顶部或返回 undefined,并在测试中覆盖。
  • behavior: 'smooth' 会改变观察时序;自动化测试应等待滚动完成或检查目标选择,而不是硬等固定毫秒。
  • 固定头部的高度来自 CSS/布局时,写死 top 需要维护;复杂布局可让目标元素使用 scroll-margin-top。
  • SSR 阶段没有真实窗口滚动;滚动策略属于客户端导航,不应让服务端渲染依赖 window。

练习:补齐文章页的滚动合同 ​

为文章列表、文章详情和 #comments 写 scrollBehavior,并列出四个可观察结果:

  • 从列表进入新文章时到顶部。
  • 从详情页后退时恢复 savedPosition。
  • 进入 #comments 时定位评论区域,并避开 64px 固定头部。
  • hash 目标不存在时不抛异常,并选择明确的回退位置。

用一个假的 to/from/savedPosition 表驱动测试,答案见第六章练习答案。

本篇验收 ​

  • 能解释 savedPosition、hash 和新页面顶部的优先级。
  • 能判断页面窗口滚动和内部滚动容器是否属于同一责任。
  • 改变 hash 缺失、固定头部高度或导航方向后,策略仍有明确结果。

用心记录每一步成长