1. 现象与触发场景 (Symptom & Scenario)

在连续滚动渲染(如长文档、虚拟列表、PDF 阅览器、图文流)中,通常使用 IntersectionObserver 监听每一页(或卡片)占位槽(Slot)的进入视口事件,按需渲染内部的 Heavy DOM(如 <canvas>、图片、复杂图表)。

触发条件

  1. 页面已在视口中完整或部分可见,内部内容已被渲染。
  2. 触发外部布局变动(例如:用户点击侧边栏收缩/展开按钮、调整视口尺寸、旋转页面、切换自适应比例 fit-width / fit-page)。
  3. 容器代码为了防止高度瞬间塌陷致使 scrollTop 清零,复用了已有的槽位 DOM 元素,仅调整其 style.width 与 minHeight,并将内部画板清空重置为占位骨架屏(例如:“墨色载入中...”)。
  4. 业务代码期望依赖已挂载的 IntersectionObserver 再次检测并触发渲染。

异常症状

  • 占位槽的外观尺寸发生自适应变化(形状变宽或变窄)。
  • 但视口中的内容一直停留在骨架屏(“墨色载入中...”),真正的 Canvas 或内容迟迟未加载。
  • 只有向远处滚动使该页脱离视口再滚回,或者从外部目录/文件列表中重新点击该文件彻底重建整套 DOM 时,内容才能恢复。

2. 根本原因深度剖析 (Root Cause)

W3C IntersectionObserver 触发机制

根据 W3C 交叉观察器规范(Intersection Observer Specification):

  • IntersectionObserver 的回调函数仅在被观察目标的相交比(Intersection Ratio)跨越了预设阈值(Thresholds,如 0.01)或者目标初次进入/离开观察视口时被排入任务队列分发。
  • 如果一个元素本来就已经在视口内部(例如 isIntersecting: true,比率大于 0.01),而容器通过修改 innerHTML 清空了其内部画板、更新了宽高:

    • 只要该元素自身在视口内依然保持相交且没有越过新的阈值,浏览器引擎不会向其分发任何新的 IntersectionObserverEntry!
    • 内部的渲染标记集合(如 renderedPages)已在重排前被清空,导致该槽位永远处于“未渲染,但也没有相交事件通知”的死锁状态。

3. 彻底解决方案 (Resolution Architecture)

要彻底根治该问题,不能仅依赖被动的观察器事件,必须采用主动可视区测算 + 观察器生命周期重置 + 异步版本令牌保护的三层架构:

3.1 主动可视区测算渲染 (renderVisiblePages)

在布局重排与阅读位置还原(restoreReadingPosition)完成之后,立即通过真实的几何边界进行主动遍历与即时渲染:

/**
 * 测算并立即渲染当前滚动视口及预加载区间(±600px)内的所有可见页槽
 */
public renderVisiblePages(): void {
  if (!this.scrollContainer || !this.pagesWrapper || this.totalPages <= 0 || !this.pdfDoc) return;
  if (!this.scrollContainer.isConnected || this.scrollContainer.clientHeight <= 0) return;

  const containerRect = this.scrollContainer.getBoundingClientRect();
  const PRELOAD_MARGIN = 600;
  const topBound = containerRect.top - PRELOAD_MARGIN;
  const bottomBound = containerRect.bottom + PRELOAD_MARGIN;

  const slots = Array.from(this.pagesWrapper.querySelectorAll('.pdf-page-slot')) as HTMLElement[];
  for (const slot of slots) {
    const pageNum = parseInt(slot.getAttribute('data-page-num') || '0', 10);
    if (pageNum <= 0 || this.renderedPages.has(pageNum)) continue;

    const rect = slot.getBoundingClientRect();
    // 严格判断槽位垂直投影与视口预加载区是否有交集
    if (rect.bottom >= topBound && rect.top <= bottomBound) {
      this.renderPage(pageNum, slot);
    }
  }
}

3.2 观察器生命周期断开与重新绑定

在复用既有 DOM 槽位并擦除旧画板时,必须显式重置 IntersectionObserver 与位置探针,清除其在引擎底层的缓存相交状态:

if (existingSlots.length === this.totalPages) {
  // 复用槽位尺寸...
  if (this.intersectionObserver) {
    this.intersectionObserver.disconnect();
    existingSlots.forEach((slot) => this.intersectionObserver?.observe(slot));
  }
  if (this.scrollSpyObserver) {
    this.scrollSpyObserver.disconnect();
    existingSlots.forEach((slot) => this.scrollSpyObserver?.observe(slot));
  }
}

3.3 异步渲染版本令牌保护 (renderGeneration)

由于侧栏切换带有过渡动画(CSS transition 300ms),ResizeObserver、transitionend 与定时器可能会在短时间内连续触发多次自适应重排。若旧的异步 pdfDoc.getPage() 仍在排队,可能与新一轮重绘冲突。因此引入代际标记:

private renderGeneration: number = 0;

private recomputeAndRenderAll(): void {
  this.renderGeneration++;
  // ... 取消旧任务,清空 renderedPages,重新排版
}

private async renderPage(pageNum: number, slotEl: HTMLElement): Promise<void> {
  if (!this.pdfDoc || this.renderedPages.has(pageNum)) return;
  this.renderedPages.add(pageNum);
  const generation = this.renderGeneration;

  try {
    const page = await this.pdfDoc.getPage(pageNum);
    // 关键校验:若在等待异步页面对象期间布局已再次重算,立即丢弃,杜绝幽灵重绘
    if (generation !== this.renderGeneration) return;

    // 创建 canvas 并开始实际渲染...
  } catch (err: any) {
    // ...
  }
}

4. 下次如何快速识别 (Diagnostic Heuristics)

  • 特征 1:控制台没有任何 JS 报错,网络面板没有失败请求。
  • 特征 2:DOM 树上卡在 Loading / Skeleton 状态,对应元素已经有具体的宽高。
  • 特征 3:只要用户轻微滚动页面使目标进出视口一次,或者缩小放大视口,内容突然正常呈现。
  • 结论:100% 为懒加载观察器对已处于相交状态的目标未触发阈值跳变事件。

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend