11|滚动行为:位置是导航合同的一部分
第六章概览 · 目标:让前进、后退、hash 和异步内容的滚动行为可解释;不要把每次导航都粗暴地滚到页面顶部。
三种导航,三种期望
- 新页面或新资源: 通常从顶部开始,避免用户看到上一页的中间内容。
- 浏览器后退/前进: 优先使用 Router 提供的
savedPosition,恢复用户离开时的位置。 - hash 锚点: 滚到
to.hash对应元素;如果页面有固定头部,需加入偏移量。
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 }
}
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
scrollBehavior 返回位置对象,也可以返回 Promise。异步返回适合等待过渡或目标元素出现;但不要无条件等待一个永远不会完成的 Promise,否则导航已经完成,滚动却永远悬空。
savedPosition 与应用状态
Router 能恢复浏览器历史中的位置,但它不知道虚拟列表、内嵌滚动容器或用户在弹窗内的滚动。页面如果使用 main 以外的滚动容器,需要为该容器建立自己的 owner 和清理策略,不能把 window.scrollTo 当万能答案。
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 }
}2
3
4
5
上面的路径条件只是一个业务选择:如果后退到同一详情页仍应恢复位置,就不能擅自过滤。先写用户期望,再写条件。
不存在的锚点必须先判断
{ el: to.hash } 只是把选择目标的工作交给 Router;返回这个对象不等于元素一定存在。把 DOM 查找与策略分开,普通测试就能传入“存在/缺失”两个结果。浏览器中 hash 可能含空格或标点,按 ID 读取应先解码并用 getElementById(),避免把它误当 CSS 选择器。
function findHashElement(hash: string) {
try { return document.getElementById(decodeURIComponent(hash.slice(1))) }
catch { return null }
}2
3
4
等待异步锚点,也要允许结束等待
评论可能在导航确认后才加入DOM。此时一次getElementById得到null,不代表稍后仍没有节点。下面使用 MutationObserver 观察文档子节点变化:每次变化重新查找id;找到或超过时限后,断开观察并清除计时器。它只在浏览器scrollBehavior中调用,不放在SSR模块顶层。
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 })
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
把它接入createRouter的scrollBehavior选项。这里的router指创建完成后的同一实例;回调在导航时才执行。
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 }
}2
3
4
5
6
7
8
9
10
假设先导航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 缺失、固定头部高度或导航方向后,策略仍有明确结果。