长文档点目录定位总是不准?一个 content-visibility + 平滑滚动引发的连环坑(Vue3 实战)
目录
-
- [一、先看现象:这个 bug 很"玄学"](#一、先看现象:这个 bug 很"玄学")
- 二、页面里有哪些"会变的高度"
- 三、逐条拆开看
-
- [3.1 分批渲染:目标可能还不存在](#3.1 分批渲染:目标可能还不存在)
- [3.2 `content-visibility: auto`:坐标是"估"出来的](#3.2
content-visibility: auto:坐标是"估"出来的) - [3.3 懒加载图片:占位与真实高度不一致](#3.3 懒加载图片:占位与真实高度不一致)
- [3.4 平滑滚动把误差"固化"了](#3.4 平滑滚动把误差"固化"了)
- 四、修复思路:把开环改成闭环
- 五、代码实现
-
- [5.1 保证目标已挂载](#5.1 保证目标已挂载)
- [5.2 测量与滚动](#5.2 测量与滚动)
- [5.3 等滚动真的停下来](#5.3 等滚动真的停下来)
- [5.4 用户一动手就退出](#5.4 用户一动手就退出)
- [5.5 主流程:平滑滚 + 停后补正 + 尾随微调](#5.5 主流程:平滑滚 + 停后补正 + 尾随微调)
- 六、几个容易被忽略的细节
-
- [6.1 `contain-intrinsic-size: auto 180px`](#6.1
contain-intrinsic-size: auto 180px) - [6.2 阈值为什么是 8px 和 0.5px](#6.2 阈值为什么是 8px 和 0.5px)
- [6.3 为什么不用 `scrollend`,而用轮询判定](#6.3 为什么不用
scrollend,而用轮询判定) - [6.4 赋值 `scrollTop` 会取消进行中的平滑滚动](#6.4 赋值
scrollTop会取消进行中的平滑滚动) - [6.5 小心浏览器的滚动锚定(scroll anchoring)](#6.5 小心浏览器的滚动锚定(scroll anchoring))
- [6.6 连续点击用 token 防重入](#6.6 连续点击用 token 防重入)
- [6.7 定位前先"确保渲染批次已展开",不要指望响应式 watcher](#6.7 定位前先"确保渲染批次已展开",不要指望响应式 watcher)
- [6.1 `contain-intrinsic-size: auto 180px`](#6.1
- 七、效果
- [八、可复用的 Checklist](#八、可复用的 Checklist)
- 九、小结
场景:手册、合同、说明书这类长文档的校对页面。左侧目录树、右侧问题列表都能"点击定位"到中间正文里的某一段。正文是分隔渲染的,图片懒加载,为了性能还开了
content-visibility。问题:点定位能滚,但落点不准;连点两三次就准了;手动滚一下再点也准。
结论先放这儿:一次性测量 + 平滑滚动 = 用过期数据做决策。凡是布局会异步变化的场景,定位都得做成"闭环校正",而不是"算一次就滚"。
一、先看现象:这个 bug 很"玄学"
最初的反馈是这样的:
- 点目录,页面确实往下滚了,但滚到的位置不对,目标段落要么在屏幕外,要么在屏幕中间;
- 连点两三次,落点就准了;
- 手动往下滚一小段,再点,也准;
- 刷新页面后重新点,又需要点两三次。
这几个特征非常关键,先别急着读代码,先做一次"症状推理":
- 能滚、能滚到目标附近 → 说明选择器、数据、事件链路都是通的;
- 定位"逐渐变准" → 说明测量出来的位置在变化,而且是随着滚动次数收敛的;
- 收敛的诱因是"滚动" → 滚动过程中被改变的一定是布局尺寸。
于是问题就被压缩成一句话:定位时用到的位置数据是过期的,而滚动的过程恰好把真实布局"喂"了出来。
按这个方向去代码里找"会变的高度",很快就找到了三个嫌疑人。
二、页面里有哪些"会变的高度"
| 机制 | 现象 | 对定位的影响 |
|---|---|---|
| 分批渲染 | 首屏只渲染前 N 个块(顶层 24 个、子级 40 个) | 目标块可能压根不在 DOM 里 |
content-visibility: auto |
远离视口的块跳过布局,用 contain-intrinsic-size 估算高度 |
目标坐标是"估"出来的,误差会沿文档累积 |
| 图片懒加载 | 未加载时是一行文字占位,加载完成后替换成 <img> |
目标上方高度变大,目标被"挤"下去 |
再叠加一个放大器:平滑滚动。
js
// 简化后的原实现:一次测量 + 平滑滚动
root.scrollTo({
top: Math.max(0, root.scrollTop + target.getBoundingClientRect().top - root.getBoundingClientRect().top - 12),
behavior: 'smooth'
})
behavior: 'smooth' 的动画终点,是在动画开始之前就算好的。动画播放的这 300~500ms 里,浏览器正好在把上方那些"估算高度"换成真实高度、把占位文字换成图片------终点早就过期了。
所以它能滚(估算位置和目标大体相关),但一定不准(误差是累积的)。
三、逐条拆开看
3.1 分批渲染:目标可能还不存在
长文档不可能一次渲染几千个节点,通常会做窗口化:
js
const BATCH_SIZE = 24
const renderedCount = ref(BATCH_SIZE)
const visibleBlocks = computed(() => props.blocks.slice(0, renderedCount.value))
如果目标块的序号超过 renderedCount,#row-xxx 根本不存在,querySelector 返回 null,定位直接失败。
正常的做法是"选中哪个就展开到哪个",但这里有个时序问题:展开渲染批次是响应式的下一个 tick 才会生效的,而定位代码往往在同一个 tick 里就去查 DOM 了。
3.2 content-visibility: auto:坐标是"估"出来的
为了性能,跳过大段远离视口的布局与绘制:
css
.block-tree :deep(.bn-section),
.block-tree :deep(.bn-block-row),
.block-tree :deep(.bn-li),
.block-tree :deep(.bn-table-wrap) {
content-visibility: auto;
contain-intrinsic-size: 180px; /* 未渲染时按 180px 估高 */
}
这是长文档的性能神器,但代价是:未渲染的元素高度是猜的。
假设目标在第 300 个块,每个块平均真实高度 320px,而估算值只有 180px,那么误差就是 300 × 140 ≈ 42000px。一次测量得到的 getBoundingClientRect() 就是这个"估算坐标系"里的值。
更麻烦的是:你一旦滚过去,目标附近的元素立刻被渲染成真实高度,于是目标自己又跑掉了------这就解释了"第一次最偏、第二次近一点、第三次才准"。
3.3 懒加载图片:占位与真实高度不一致
html
<!-- 未加载时 -->
<span class="md-img-missing">【图片加载中...】</span>
<!-- 加载完成后 -->
<img src="..." />
一行提示文字大概 20px,一张图可能 120px。目标上方每有一张图加载完成,目标就往下跑 100px。
3.4 平滑滚动把误差"固化"了
即使不做平滑滚动,一次测量也依然是"以过期布局为准"。平滑滚动只是让这个误差变成了一段看起来很流畅的动画,落点反而更难被怀疑。
四、修复思路:把开环改成闭环
原实现是典型的开环控制:
测量 → 滚动(结束)
问题在于"测量"这个动作在这个页面里是不可靠的、随时间变化的输入。那就改成闭环:
测量 → 滚动 → 等它停 → 重新测量 → 再滚动 → ...... 直到稳定
具体拆成 5 件事:
- 保证目标已挂载:先展开渲染批次,再逐帧等 DOM 出现;
- 平滑滚到估算位置:保留动画观感;
- 等动画真的停下来:不能凭感觉 sleep 一个固定值;
- 停下来后重新测量并补正:偏差大就再补一段动画,偏差小就瞬时微调;
- 尾随观察一段时间:图片懒加载这类更晚的高度变化也要吃掉;
- 用户一动手立刻退出:绝不和用户抢滚动条。
五、代码实现
5.1 保证目标已挂载
ts
/** 目标块可能还在分批渲染窗口之外:先按顶层序号展开批次,再等它挂载进 DOM。 */
async function ensureRow(root: HTMLElement, anchorId: string) {
const path = findPath(props.blocks, anchorId)
if (path) {
const index = props.blocks.findIndex(block => path.includes(block.id))
if (index >= renderedCount.value) renderedCount.value = index + 1
}
// 渲染批次是响应式的,给它几帧时间
for (let i = 0; i < 12; i++) {
await nextTick()
const el = root.querySelector<HTMLElement>('#row-' + anchorId)
if (el) return el
await nextFrame()
}
return null
}
function nextFrame() {
return new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
}
注意这里不是 "设个 timeout 等 100ms",而是"每帧检查一次,出现就立刻返回"。首屏常见情况是第一个 nextTick 就拿到了,不会白等。
5.2 测量与滚动
ts
const TOP_OFFSET = 12 // 目标距离滚动区顶部留 12px
/** 目标相对滚动区顶部的偏差:正值表示目标在预期位置下方。 */
function rowDelta(root: HTMLElement, target: HTMLElement) {
return target.getBoundingClientRect().top - root.getBoundingClientRect().top - TOP_OFFSET
}
function smoothToRow(root: HTMLElement, target: HTMLElement) {
root.scrollTo({ top: Math.max(0, root.scrollTop + rowDelta(root, target)), behavior: 'smooth' })
}
5.3 等滚动真的停下来
这里没有依赖 scrollend 事件(Chrome 较新版本才有,Safari/Firefox 支持较晚),而是用"连续 4 帧 scrollTop 不变"来判定:
ts
/** 等平滑滚动停下来(连续 4 帧位置不变即认为结束)。 */
function waitScrollStop(root: HTMLElement, guard: InterruptGuard) {
return new Promise<void>(resolve => {
// 预算给足:长距离平滑滚动本身可能跑到几百毫秒,提前返回会在动画中途测量
const deadline = performance.now() + 1200
let last = root.scrollTop
let stable = 0
const tick = () => {
if (guard.interrupted || performance.now() > deadline) return resolve()
if (Math.abs(root.scrollTop - last) < 0.5) {
if (++stable >= 4) return resolve()
} else {
stable = 0
last = root.scrollTop
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
}
5.4 用户一动手就退出
ts
type InterruptGuard = { interrupted: boolean; dispose: () => void }
/** 用户一旦自己滚动/点击/按键,就停止自动校正,避免和用户抢滚动条。 */
function watchUserInterrupt(root: HTMLElement): InterruptGuard {
const guard: InterruptGuard = { interrupted: false, dispose: () => void 0 }
const stop = () => {
guard.interrupted = true
}
root.addEventListener('wheel', stop, { passive: true })
root.addEventListener('touchstart', stop, { passive: true })
root.addEventListener('pointerdown', stop)
window.addEventListener('keydown', stop)
guard.dispose = () => {
root.removeEventListener('wheel', stop)
root.removeEventListener('touchstart', stop)
root.removeEventListener('pointerdown', stop)
window.removeEventListener('keydown', stop)
}
return guard
}
5.5 主流程:平滑滚 + 停后补正 + 尾随微调
ts
let alignToken = 0
const SMOOTH_THRESHOLD = 8 // 偏差大于 8px 再补一段动画,避免动画结束后"咯噔"跳一下
const TAIL_MS = 800 // 尾随微调时长
async function alignRow(root: HTMLElement, target: HTMLElement) {
const token = ++alignToken
const guard = watchUserInterrupt(root)
try {
// 1) 按当前布局平滑滚到目标附近
smoothToRow(root, target)
await waitScrollStop(root, guard)
// 2) 动画结束后重新测量:偏差较大就再补一段动画(最多 3 轮)
for (let round = 0; round < 3; round++) {
if (token !== alignToken || guard.interrupted) return
if (Math.abs(rowDelta(root, target)) <= SMOOTH_THRESHOLD) break
smoothToRow(root, target)
await waitScrollStop(root, guard)
}
// 3) 尾随微调:随后几帧高度仍可能变化(图片懒加载等),按真实布局瞬时补齐
const deadline = performance.now() + TAIL_MS
do {
if (token !== alignToken || guard.interrupted) return
const delta = rowDelta(root, target)
if (Math.abs(delta) > 0.5) root.scrollTop = Math.max(0, root.scrollTop + delta)
await nextFrame()
} while (performance.now() < deadline)
} finally {
guard.dispose()
}
}
async function scrollToAnchor(anchorId: string): Promise<boolean> {
await nextTick()
const root = treeRef.value
if (!root || !/^\w+$/.test(anchorId)) return false
const target = await ensureRow(root, anchorId)
if (!target) return false
await alignRow(root, target)
return true
}
六、几个容易被忽略的细节
6.1 contain-intrinsic-size: auto 180px
auto 关键字让元素记住上一次渲染出的真实尺寸:
css
.block-tree :deep(.bn-block-row) {
content-visibility: auto;
contain-intrinsic-size: 180px; /* 兜底:不支持的浏览器用这条 */
contain-intrinsic-size: auto 180px; /* 支持时:记住上次真实高度 */
}
不写 auto 的话,元素每次离开视口都会退回 180px 估算值,滚动位置会来回跳;写了之后,已经渲染过的区域尺寸是稳定的,定位收敛更快。
两行都写是刻意的:不支持的浏览器会忽略第二条,回退到第一条,不会因为整条声明失效而导致"跳过布局的元素高度为 0"。
6.2 阈值为什么是 8px 和 0.5px
- 8px:第一轮平滑滚完后的残余偏差,如果超过 8px 就再补一段动画。视觉上 8px 以内的"瞬移"基本看不出来;超过一点点就会觉得"动画结束后咯噔跳了一下"。
- 0.5px:亚像素级别就不折腾了,避免无意义的抖动循环。
6.3 为什么不用 scrollend,而用轮询判定
scrollend 语义最好,但兼容性还不齐。用"连续 N 帧 scrollTop 不变"的判定方式,在所有浏览器上表现一致,代价是动画结束后会多等约 4 帧(≈64ms),人眼不可感知。
6.4 赋值 scrollTop 会取消进行中的平滑滚动
标准行为:对 scrollTop/scrollTo({behavior:'auto'}) 赋值会中断正在进行的平滑滚动,直接跳到目标位置。所以尾随微调阶段用瞬时赋值是安全的------它顺手把可能还在跑的动画(如果超时提前返回)也终止掉了。
6.5 小心浏览器的滚动锚定(scroll anchoring)
内容在视口上方变高/变矮时,浏览器会自动微调 scrollTop 来"稳住"视口内容,这会让"用户的滚动"和"浏览器的修正"难以区分。
不要 用 scrollTop 是否等于自己设置的值来判断"用户有没有滚动"------会被滚动锚定误判。用真实输入事件(wheel / touchstart / pointerdown / keydown)来判断,语义清晰且不会误伤。
6.6 连续点击用 token 防重入
一段定位流程可能持续 1s 以上(动画 + 补正 + 尾随),用户完全可能在这期间点下一个目录项。用自增 token 保证只有最后一次的循环还在生效:
ts
const token = ++alignToken
// 循环里每轮检查
if (token !== alignToken) return
6.7 定位前先"确保渲染批次已展开",不要指望响应式 watcher
watch 默认是 pre-flush,理论上在 nextTick 后 DOM 已更新。但把"目标可见性"这种定位的前置条件完全交给组件的响应式逻辑,一旦组件结构变动(比如 v-if/v-else 切换模式导致组件重建)就会失效。在定位函数里再显式展开一次,成本极低,鲁棒性高很多。
七、效果
- 首次点击就准,不再需要"点两三次";
- 保留了平滑滚动的观感,动画结束后的残余偏差也由动画走完(> 8px 再补一段);
- 动画结束后仍在后台盯 800ms,把图片懒加载、估高补齐带来的漂移吃掉;
- 用户中途滚一下,程序立刻停止干预,不会出现"我往下滚它偏要往上拉"的拉锯。
八、可复用的 Checklist
遇到"跳转/定位到某个元素"不准的问题,按这个顺序排查:
- 目标是不是还没渲染? (虚拟列表 / 分批渲染 /
v-if未挂载)→ 先确保挂载,再定位; - 元素高度是不是估算的? (
content-visibility/ 图片未加载 / 字体异步加载 / 折叠展开)→ 改成闭环校正; - 是不是只测量了一次? → 改成"测量-滚动-再测量"直到收敛;
- 是不是用了平滑滚动? → 动画终点是过期数据,必须在动画结束后补正;
- 怎么判断"动画结束"? → 优先
scrollend,降级用"连续 N 帧位置不变"; - 会不会和用户抢滚动? → 监听
wheel/touchstart/pointerdown/keydown立即退出,别用scrollTop比对; - 重复触发怎么办? → token 防重入;
- 性能兜底 :
content-visibility: auto配contain-intrinsic-size: auto <length>,让尺寸估算能收敛而不是每次归零。
九、小结
这次问题的本质不是"算错了",而是在错误的时间做了正确的计算。
前端的很多"玄学 bug"都长这样:异步的布局变化 + 一次性的同步测量。判断方法就一条------如果症状会随着重复操作而收敛,那一定是输入在变化,而不是逻辑写错了。修法也很固定:把开环改成闭环,再补上时序守卫(等稳定、防重入、可中断),误差自然会收敛到零。
如果这篇文章帮你少调一个小时的 scrollTop,欢迎点个赞。