移动端锚点方案

5 阅读4分钟

一、背景与目标

在移动端长页面中,通过「锚点」帮助用户快速定位模块位置,并在锚点列表展开时,准确高亮当前用户所处的内容模块

二、初始方案与暴露的问题

初始设计思路

  • 标题(title)作为锚点

  • 点击锚点时,将对应标题滚动至页面顶部

  • 当用户展开锚点面板时:

    • 计算所有 title 的位置信息
    • 选取 当前可视区域内,距离顶部最近的 title
    • 作为当前激活锚点(高亮)

出现的问题

问题 1:title 无法覆盖所有滚动状态

当某个模块内容 高度超过一屏 时:

  • 页面可视区域内 可能完全不存在 title
  • 此时无法命中任何锚点
  • 导致锚点高亮状态缺失或错误

结论
仅以 title 作为锚点定位点,覆盖范围不完整,无法描述整个页面的滚动状态。

三、锚点粒度的调整:从「标题」到「模块」

优化思路

将锚点的定位对象,从「标题节点」升级为「完整模块」。

调整后的锚点定义

  • 每个锚点对应一个 完整内容模块
  • 锚点 id 绑定在模块容器(section / block)上
  • title 仅作为模块的视觉起点,而非判断依据

优势

  • 模块本身具备 连续的空间覆盖
  • 任意滚动位置,都必然落在某个模块内
  • 从根本上解决“无可视锚点”的问题

四、新问题:多个模块同时可见,激活谁?

场景说明

在移动端视口中:

  • 常常会出现 同时展示 2 个甚至多个模块
  • 需要定义一个规则,确定“当前模块是谁”

旧规则的问题

如果仍采用:

“选择距离顶部最近的模块”

会出现以下不符合直觉的情况:

  • 某模块仅在顶部露出极小一部分
  • 实际用户注意力已集中在下方模块
  • 但激活状态仍停留在上一个模块

结论
“距离顶部最近” ≠ “用户正在阅读的模块”。

五、激活规则重构:基于「可视占比」

新的判断原则

谁在当前视口中占据的可视高度最大,谁就是当前激活模块。

具体规则说明

  1. 获取所有模块在视口中的可视区域高度

  2. 过滤掉:

    • 完全不可见的模块
  3. 在可见模块中:

    • 计算每个模块的 可视高度
  4. 选择:

    • 可视高度最大的模块
    • 作为当前激活锚点 id

这个规则的优势

  • 符合用户阅读直觉
  • 不受模块高度差异影响
  • 对超长模块和短模块都友好
  • 避免“只露一点点却被激活”的问题

六、移动端场景下的交互取舍

是否需要实时高亮?

在移动端实际使用中:

  • 锚点列表默认是 收起状态

  • 用户并不会持续关注锚点变化

  • 实时监听滚动更新高亮:

    • 性能成本高
    • 交互收益低

最终交互决策

仅在锚点列表“展开”时计算激活状态

行为流程

  1. 用户点击「展开锚点」
  2. 立即计算当前页面模块可视情况
  3. 根据「最大可视高度规则」确定激活 id
  4. 高亮对应锚点项

优点

  • 降低计算频率
  • 减少滚动监听

七、核心实现代码(示例)

/**
 * 获取可视区内高度最高的内容块对应的标题ID
 * @param {Object} contentRefs - 内容块DOM元素引用 {标题id: 内容块DOM元素}
 * @param {number} headerHeight - 头部高度(用于偏移计算)
 * @returns {string|null} - 可视区内高度最高的内容块对应的标题id
 */
function getHighestVisibleBlock(contentRefs, headerHeight = 64) {
  if (!contentRefs || Object.keys(contentRefs).length === 0) {
    return null;
  }
  
  let maxVisibleHeight = 0;
  let highestBlockId = null;
  
  Object.entries(contentRefs).forEach(([titleId, contentEl]) => {
    if (!contentEl) return;
    
    // 计算内容块在可视区内的可见高度
    const visibleHeight = getVisibleHeight(contentEl, headerHeight);
    
    // 如果可见高度大于当前最大值,更新
    if (visibleHeight > maxVisibleHeight) {
      maxVisibleHeight = visibleHeight;
      highestBlockId = titleId;
    }
    
    // 如果可见高度相等,选择更靠上的元素
    if (Math.abs(visibleHeight - maxVisibleHeight) < 1 && highestBlockId) {
      const currentTop = contentEl.getBoundingClientRect().top;
      const highestEl = contentRefs[highestBlockId];
      const highestTop = highestEl ? highestEl.getBoundingClientRect().top : Infinity;
      
      if (currentTop < highestTop) {
        highestBlockId = titleId;
      }
    }
  });
  
  return highestBlockId;
}

/**
 * 计算元素在可视区内的可见高度
 * @param {HTMLElement} element - DOM元素
 * @param {number} headerHeight - 头部高度
 * @returns {number} - 可见高度(像素)
 */
function getVisibleHeight(element, headerHeight = 64) {
  if (!element) return 0;
  
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight;
  
  // 1. 如果元素完全在可视区上方
  if (rect.bottom <= headerHeight) {
    return 0;
  }
  
  // 2. 如果元素完全在可视区下方
  if (rect.top >= windowHeight) {
    return 0;
  }
  
  // 3. 计算元素与可视区的交集部分
  const visibleTop = Math.max(rect.top, headerHeight);
  const visibleBottom = Math.min(rect.bottom, windowHeight);
  
  // 4. 返回可见高度
  return Math.max(0, visibleBottom - visibleTop);
}