站里最长的那篇,正文里有十九个二级标题。
我自己读它都觉得烦。想回看某一段,只能凭印象上下滑,滑过头再滑回来。手机上连个滚动条都没得抓。
所以想加一个文内目录。动手前先定了一条:目录是加的,不是本就该有的。这个站有一条死规矩 —— 关掉脚本,页面必须完整。
星期四晚上:滚动监听,能用
第一版很直白。听 scroll,在回调里遍历所有标题,找出最后一个跑过视口顶部的,把高亮挪给它。
window.addEventListener('scroll', function () {
var current = null;
headings.forEach(function (h) {
if (h.getBoundingClientRect().top <= 80) current = h;
});
setCurrent(current);
}, { passive: true });
桌面上完全正常。我看了半分钟,觉得这事就算完了。
星期五:手机上滑得很涩
手机上滚起来有种说不清的滞涩。我起初怀疑是页面太长,后来把那段脚本注掉再试,滞涩没了。
十九个标题,每秒算几十次位置,全是布局查询,都挤在滚动那一下里。数字不算大,对一台不新的手机够呛。
还有个更隐蔽的毛病:从上往下滚,高亮是准的;从下往上滚,偶尔会跳过中间一节。滚动快的时候,浏览器会把若干次位置变化合并成一次,中间那几个标题在脚本看来就像从没出现过 —— 而它们明明在我眼前滑过去了。
星期六下午:换掉整套思路
第二版用 IntersectionObserver。想法和我原来那套不同:我不再问「现在滚到哪儿了」,而是把标题交给浏览器盯着,告诉它「这几个元素进出某条带子的时候叫我一声」。
var observer = new IntersectionObserver(callback, {
rootMargin: '-10% 0px -70% 0px',
threshold: 0
});
rootMargin 那串数需要解释:上下各裁掉一块,剩下的窄条就是「当前章节」的位置。上面留 10%,下面切掉 70%,高亮就停在视口偏上的地方,跟视线落点差不多。这组数我试了七八次,没什么道理可讲。
换完之后,我的高亮逻辑还是错的
第一版我只记「最近一个进入视口的标题」。滚得快的时候,后进来的会把前面那个覆盖掉,高亮跳到不相干的地方,往回滚更乱。
改成一个数组:进视口推进去,出视口删掉,每次取最靠前的那个。这段我写了两遍才对 —— 第一遍用的是类似压栈的写法,正着滚没事,倒着滚全乱。
看不见的那部分
目录的标记是一个 nav 加有序列表,不是一堆 div。顺序在这里有意义,不该丢。
高亮不用 class,用 aria-current="true"。样式表只针对这个属性换颜色。这样视觉和辅助技术读的是同一个状态,不会出现「看起来高亮,读起来没有」的分裂。
最后一步是兜底:二级标题少于三个就不生成目录。三个都不到,目录除了占地方没别的用。
降级怎么验
写完之后我把脚本注掉,刷新,从头读到尾。文章完整,标题都在,锚点也在,只是没有目录。
再看一眼源码:十九个 h2 连同 id 全在 HTML 里,目录本身不在。这正确 —— 没有脚本,读者本来也没有「点击跳转」这个动作可做。
还有一件我没弄清楚
aria-current 的取值不止一种,有 page、step、location 这些。按字面看,「我正在文内的这个位置」应该用 location,但我没把握,最后写的是 true。true 也是合法取值,只是比 location 模糊。
我暂时不改。哪天有读屏用户说这里念得不对,那就是我错了。
现在
目录就在标题下面、正文前面,点一下跳过去,滚到哪一节高亮跟着走。一共六十来行脚本,没有依赖。
我没把它做成「固定在屏幕边上」的那种。一个人的站,边上多一个常驻小方块,挡住的正文比它省下的时间多。就这样挺好。