一、背景与目标
在移动端长页面中,通过「锚点」帮助用户快速定位模块位置,并在锚点列表展开时,准确高亮当前用户所处的内容模块。
二、初始方案与暴露的问题
初始设计思路
-
以 标题(title)作为锚点
-
点击锚点时,将对应标题滚动至页面顶部
-
当用户展开锚点面板时:
- 计算所有 title 的位置信息
- 选取 当前可视区域内,距离顶部最近的 title
- 作为当前激活锚点(高亮)
出现的问题
问题 1:title 无法覆盖所有滚动状态
当某个模块内容 高度超过一屏 时:
- 页面可视区域内 可能完全不存在 title
- 此时无法命中任何锚点
- 导致锚点高亮状态缺失或错误
结论:
仅以 title 作为锚点定位点,覆盖范围不完整,无法描述整个页面的滚动状态。
三、锚点粒度的调整:从「标题」到「模块」
优化思路
将锚点的定位对象,从「标题节点」升级为「完整模块」。
调整后的锚点定义
- 每个锚点对应一个 完整内容模块
- 锚点 id 绑定在模块容器(section / block)上
- title 仅作为模块的视觉起点,而非判断依据
优势
- 模块本身具备 连续的空间覆盖
- 任意滚动位置,都必然落在某个模块内
- 从根本上解决“无可视锚点”的问题
四、新问题:多个模块同时可见,激活谁?
场景说明
在移动端视口中:
- 常常会出现 同时展示 2 个甚至多个模块
- 需要定义一个规则,确定“当前模块是谁”
旧规则的问题
如果仍采用:
“选择距离顶部最近的模块”
会出现以下不符合直觉的情况:
- 某模块仅在顶部露出极小一部分
- 实际用户注意力已集中在下方模块
- 但激活状态仍停留在上一个模块
结论:
“距离顶部最近” ≠ “用户正在阅读的模块”。
五、激活规则重构:基于「可视占比」
新的判断原则
谁在当前视口中占据的可视高度最大,谁就是当前激活模块。
具体规则说明
-
获取所有模块在视口中的可视区域高度
-
过滤掉:
- 完全不可见的模块
-
在可见模块中:
- 计算每个模块的 可视高度
-
选择:
- 可视高度最大的模块
- 作为当前激活锚点 id
这个规则的优势
- 符合用户阅读直觉
- 不受模块高度差异影响
- 对超长模块和短模块都友好
- 避免“只露一点点却被激活”的问题
六、移动端场景下的交互取舍
是否需要实时高亮?
在移动端实际使用中:
-
锚点列表默认是 收起状态
-
用户并不会持续关注锚点变化
-
实时监听滚动更新高亮:
- 性能成本高
- 交互收益低
最终交互决策
仅在锚点列表“展开”时计算激活状态
行为流程
- 用户点击「展开锚点」
- 立即计算当前页面模块可视情况
- 根据「最大可视高度规则」确定激活 id
- 高亮对应锚点项
优点
- 降低计算频率
- 减少滚动监听
七、核心实现代码(示例)
/**
* 获取可视区内高度最高的内容块对应的标题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);
}