正文两侧的位置很贵。左边放什么、右边放什么、文末放什么,决定了读者会不会在读的过程中被拽走。
这一页只有三样辅助:目录、进度条、相关阅读。三样都有明确的"不许再往前一步"。
目录只到 h3
extractToc 在构建期扫原始 Markdown,只收 ## 和 ###:
const match = /^(#{2,3})\s+(.+?)\s*$/.exec(line)两个细节:
- 先剥掉围栏代码块。
# 注释在代码块里是语法,不是标题。stripFences先把它整段删掉再扫。 - 只到 h3。h4 在长文里会出现十几次,收进去之后目录本身变成第二篇正文,左栏从"地图"退化成"缩写版"。
左栏 130px、13px、弱化色,sticky 吸顶。它的信息密度必须明显低于正文,否则读者的视线会在两栏之间来回跳。
高亮不用 IntersectionObserver
useActiveSection 的做法很朴素:监听滚动,取"顶部已经越过 120px 线"的最后一个标题。
for (const id of ids) {
const element = document.getElementById(id)
if (!element) continue
if (element.getBoundingClientRect().top - offset <= 0) current = id
}没用 IO 的原因是它在这里不稳:IO 的 threshold 描述的是"元素露出多少",而当一节比视口还高时,多个标题会同时处于"可见"状态,谁 active 取决于回调顺序。用"最后一个越过顶线"来判定,语义和读者的感受一致。
还有一个边界:到底部时强制高亮最后一项。否则最后一节太短、永远越不过顶线,目录会一直停在倒数第二项。
进度条只有 2px
<div className="fixed inset-x-0 top-0 z-50 h-[2px] origin-left bg-brand/70"
style={{ transform: `scaleX(${progress})` }} />它是阅读页唯一随滚动变化的东西。用 scaleX 而不是 width:前者只动 transform,在合成器上跑;后者每帧触发布局,长文里能明显感到掉帧。
2px 是上限。再粗一点就会开始"被看见"——而它存在的意义是提供位置感,不是装饰。
相关阅读的分数可以解释
文末的「接着读」最多三条,打分规则写在 lib/content.ts:
score = 共同标签数 × 10 + (同主题 ? 4 : 0)然后零分直接丢弃。为凑满三张卡而推荐一篇无关的,比空着更糟——读者点进去发现没关系,下次就不再信这个区块。
候选池是笔记 + 复盘,不只是笔记。只有十几篇内容时,单看同类凑不出三条;复盘里恰好有 tailwind vite mdx 这些标签,跨类型反而接得上。
排序里有一条容易被忽略的约束:不用 localeCompare。它依赖运行时的 ICU,Node 和浏览器可能给出不同顺序——预渲染和 hydration 一错位,React 就会报 mismatch。这里全是数值比较。
右栏刻意是空的
三栏里,右边 104px 只放成熟度、更新时间、标签三样。没有"热门文章"、没有"作者简介"、没有订阅框。
理由和目录一样:左右两栏的密度一旦接近正文,读者就会开始横向比较,而阅读需要的是纵向的连续。空着不是没内容可放,是决定不放。
留言
还没有人说话读到这里,说两句。不用登录,昵称随便填。