长文档点目录定位总是不准?一个 content-visibility + 平滑滚动引发的连环坑(Vue3 实战)

长文档点目录定位总是不准?一个 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)
    • 七、效果
    • [八、可复用的 Checklist](#八、可复用的 Checklist)
    • 九、小结

场景:手册、合同、说明书这类长文档的校对页面。左侧目录树、右侧问题列表都能"点击定位"到中间正文里的某一段。正文是分隔渲染的,图片懒加载,为了性能还开了 content-visibility。

问题:点定位能滚,但落点不准;连点两三次就准了;手动滚一下再点也准。

结论先放这儿:一次性测量 + 平滑滚动 = 用过期数据做决策。凡是布局会异步变化的场景,定位都得做成"闭环校正",而不是"算一次就滚"。

一、先看现象:这个 bug 很"玄学"

最初的反馈是这样的:

  • 点目录,页面确实往下滚了,但滚到的位置不对,目标段落要么在屏幕外,要么在屏幕中间;
  • 连点两三次,落点就准了;
  • 手动往下滚一小段,再点,也准;
  • 刷新页面后重新点,又需要点两三次。

这几个特征非常关键,先别急着读代码,先做一次"症状推理":

  1. 能滚、能滚到目标附近 → 说明选择器、数据、事件链路都是通的;
  2. 定位"逐渐变准" → 说明测量出来的位置在变化,而且是随着滚动次数收敛的;
  3. 收敛的诱因是"滚动" → 滚动过程中被改变的一定是布局尺寸。

于是问题就被压缩成一句话:定位时用到的位置数据是过期的,而滚动的过程恰好把真实布局"喂"了出来。

按这个方向去代码里找"会变的高度",很快就找到了三个嫌疑人。

二、页面里有哪些"会变的高度"

机制 现象 对定位的影响
分批渲染 首屏只渲染前 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 件事:

  1. 保证目标已挂载:先展开渲染批次,再逐帧等 DOM 出现;
  2. 平滑滚到估算位置:保留动画观感;
  3. 等动画真的停下来:不能凭感觉 sleep 一个固定值;
  4. 停下来后重新测量并补正:偏差大就再补一段动画,偏差小就瞬时微调;
  5. 尾随观察一段时间:图片懒加载这类更晚的高度变化也要吃掉;
  6. 用户一动手立刻退出:绝不和用户抢滚动条。

五、代码实现

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

遇到"跳转/定位到某个元素"不准的问题,按这个顺序排查:

  1. 目标是不是还没渲染? (虚拟列表 / 分批渲染 / v-if 未挂载)→ 先确保挂载,再定位;
  2. 元素高度是不是估算的? (content-visibility / 图片未加载 / 字体异步加载 / 折叠展开)→ 改成闭环校正;
  3. 是不是只测量了一次? → 改成"测量-滚动-再测量"直到收敛;
  4. 是不是用了平滑滚动? → 动画终点是过期数据,必须在动画结束后补正;
  5. 怎么判断"动画结束"? → 优先 scrollend,降级用"连续 N 帧位置不变";
  6. 会不会和用户抢滚动? → 监听 wheel / touchstart / pointerdown / keydown 立即退出,别用 scrollTop 比对;
  7. 重复触发怎么办? → token 防重入;
  8. 性能兜底 :content-visibility: auto 配 contain-intrinsic-size: auto <length>,让尺寸估算能收敛而不是每次归零。

九、小结

这次问题的本质不是"算错了",而是在错误的时间做了正确的计算。

前端的很多"玄学 bug"都长这样:异步的布局变化 + 一次性的同步测量。判断方法就一条------如果症状会随着重复操作而收敛,那一定是输入在变化,而不是逻辑写错了。修法也很固定:把开环改成闭环,再补上时序守卫(等稳定、防重入、可中断),误差自然会收敛到零。

如果这篇文章帮你少调一个小时的 scrollTop,欢迎点个赞。

相关推荐
JudithHuang1 小时前
React 路由:React Router
前端·react.js·前端框架
FPGA信号处理1 小时前
【信号检测与估计】第四节课:非高斯噪声下的 BLUE、极大似然与 EM 算法
人工智能·算法·机器学习
赵得C1 小时前
创建 SVG 图标预览页面:从零实现到解决 CORS 问题
前端·javascript·html
IT_陈寒1 小时前
Java的HashMap线程安全问题让我深夜掉光了头发
前端·人工智能·后端
三掌柜6661 小时前
ArkWeb 手记 04|Web 返回键别再一刀切
前端·harmonyos
光影少年1 小时前
RN与Flutter架构区别
前端·flutter·react native·react.js·架构·node.js
peter67682 小时前
css学习小结
前端·css·学习
liangshanbo12152 小时前
什么是CSS原子化?它的优劣势是什么?
前端·css·tensorflow·css原子化
.道阻且长.2 小时前
C++ 11:可变参数模板
前端·c++·算法