「进度」这个词在互联网里被用滥了:下载进度、安装进度、加载进度。但阅读进度不一样——它不是机器在干活,是人在读字。把它做成一根进度条,就像给一首诗配了一张进度表。
所以墨栈的阅读体系不止一条线。它有三层:顶栏的细线负责「此刻在哪」,右侧的路径负责「这段旅程怎么拐」,移动端的圆环负责「随时可以回来」。
状态层:阅读是可恢复的旅程§
在讨论任何视觉之前,先定义数据。阅读状态只有三样东西:
fraction——0 到 1 的滚动位置,易失,更新频繁;history[slug]——按文章保存的上次滚动位置,持久化到 localStorage;activeHeading——滚动监视器报告的当前标题 id。
history 不是「读过的最大进度」。往上翻,存下来的数字会跟着变小。首页的「继续阅读」和文内的恢复芯片,用的都是这个离开时的位置。
zustand 的两个存储策略§
fraction 必须易失:它跟着滚动一直变,写进存储就是灾难。history 必须持久:它是「继续阅读」的全部根基。zustand 的 partialize 恰好把这两类状态切开,而且只把 history 交给 localStorage:
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export const useReading = create<ReadingState>()(
persist(
(set) => ({
history: {},
fraction: 0,
activeHeading: null,
setFraction: (f) => {
const rounded = Math.round(f * 1000) / 1000;
set((s) => (Math.abs(s.fraction - rounded) < 0.0005 ? s : { fraction: rounded }));
},
setActiveHeading: (id) => set((s) => (s.activeHeading === id ? s : { activeHeading: id })),
setProgress: (slug, fraction) =>
set((s) => {
const rounded = Math.round(fraction * 1000) / 1000;
if (Math.abs((s.history[slug] ?? 0) - rounded) < 0.002) return s;
return { history: { ...s.history, [slug]: rounded } };
}),
}),
{
name: "inkstack-reading-v1",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ history: s.history }),
}
)
);存储键是 inkstack-reading-v1。形状一旦要改,换键比做迁移更干净。
阈值守卫:别让状态抖动§
每个 setter 在变化不够大时返回原来的 state,而不是一个新对象。fraction 先收成三位小数,变化不到 0.0005 不广播;历史进度变化不到 0.002 不落笔;标题 id 相同则直接跳过。顶栏自己还更克制:动画值挪动超过 0.0024 才推进 store。这不是优化,是礼貌——高频更新静悄悄地流过,百分比读数才不会跟着每一帧重渲染。
文章页另有一条慢路径:每 900ms,以及离开页面前,把当前滚动写进 history。顶栏负责「此刻」,这条路径负责「下次还能找回来」。
顶栏:两像素的戏§
最细的线,最克制。它用 rAF 做非线性缓动,让「追」比「贴」更有质感。进入文章时先把 fraction 归零,避免上一篇的进度闪在这一篇的顶上:
useEffect(() => {
useReading.getState().setFraction(0);
let raf = 0;
let current = 0;
let lastPushed = -1;
const step = () => {
const doc = document.documentElement;
const max = doc.scrollHeight - window.innerHeight;
const target = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
current += (target - current) * 0.14;
if (Math.abs(target - current) < 0.0009) current = target;
if (fillRef.current) fillRef.current.style.transform = "scaleX(" + current + ")";
if (Math.abs(current - lastPushed) > 0.0024) {
lastPushed = current;
useReading.getState().setFraction(current);
}
raf = current === target ? 0 : requestAnimationFrame(step);
};
const start = () => {
if (!raf) raf = requestAnimationFrame(step);
};
start();
window.addEventListener("scroll", start, { passive: true });
window.addEventListener("resize", start);
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", start);
window.removeEventListener("resize", start);
};
}, []);注意两点:scaleX 直接改 DOM,不经过 setState;误差收进 0.0009 之后停掉 rAF。一个闲置页面不应该还在燃烧帧。滚动比例用 scrollY / (scrollHeight - innerHeight),和后面路径、历史进度用的是同一把尺子。
右侧:一条跟着标题拐弯的线§
桌面端的主体。目录不再是一排圆点和一条竖直填充条,而是一条贴在标题左侧的 SVG 路径:二级标题走最内侧的车道,每深一级向右 12px。
function laneX(depth: number): number {
return 8 + depth * 12;
}车道变了的时候,折线不硬拐。垂直段占着间隙的两头,中间 55% 用一段三次贝塞尔接上,两端仍然是竖直的。同一级标题之间则是一根直线。
进度画在路径上§
当前标题的纵坐标投到这条路径上,二分大约 18 次,找到对应的弧长,再写成 stroke-dasharray。圆点用 getPointAtLength 贴在同一个位置。路径形状没变、读者也没有打开「减少动效」时,300ms 内用 cubic-bezier(0.4, 0, 0.2, 1) 从旧弧长走到新弧长。换文章或路径重算时直接跳到终点,避免把整条目录播一遍。
当前只有两种状态§
列表项不再区分「已读 / 未读」。data-state 只有 active 和 rest:当前标题字色提到最亮的纸色、字重 500;其余保持辅文色,200ms 的颜色过渡。二级标题前面带「1、」「2、」这样的序号,三级、四级只靠 12px / 24px 的缩进,字色再淡一档。
滚动快到页底(fraction ≥ 0.995,且和真实滚动位置相差不超过 0.08)时,路径直接点亮最后一节,避免末节标题已经滚出观察区、高亮却停在倒数第二节。
换页时把指针按住§
客户端切到另一篇文章时,页面还在回顶。选择器如果跟着标题走,侧栏会从底闪到顶。navPin 在目录 id 变化后把索引冻在 0,等滚动停稳——或者最多 4 秒——再放开。这个标记活在模块内存里:客户端导航能记住「上一篇是谁」,整页刷新会把它清掉,所以刷新后仍尊重浏览器留下的真实滚动位置。
侧栏自己溢出时,当前行会被滚进可视区,上下各留 24px,避免激活项贴着裁切边缘。
移动端:圆环与抽屉§
屏幕窄到放不下侧栏时,体系收缩成一个右下角的浮标:SVG 圆环画着当前进度,点开是一张从底部升起的抽屉。抽屉里的路径和桌面是同一份列表。
圆环的 dash 数学§
圆环周长 2π × 14 ≈ 87.96,剩余量写成 CSS 变量 --ring,由 stroke-dashoffset 吃掉。写在 DOM 上,不进 React 渲染;只有抽屉开着的时候,百分比才会推进组件状态,供「已读 n%」那一行使用。
const RING = 2 * Math.PI * 14;
function paintRing(el: SVGCircleElement, fraction: number) {
el.style.setProperty("--ring", (RING * (1 - fraction)).toFixed(2) + "px");
}抽屉:别挡着人看正文§
抽屉的进入曲线是 cubic-bezier(0.22, 1, 0.36, 1) 的「快出慢停」,400ms 收尾。点任意节点,先关抽屉、等 80ms 再滚动——给关闭让路,滚动就不会和抽屉打架。标题副文写着阅读时间、字数和已读百分比:约 分钟 · 字 · 已读 %。
跳转本身看系统偏好:开了减少动效就用 auto,否则 smooth。
动效预算表§
动效是最容易超支的东西。这张表是墨栈阅读体系的「预算单」——路径描边控制在 300ms,没有两秒一轮的呼吸节点:
| 元素 | 时长 | 缓动 | 作用 |
|---|---|---|---|
| 顶栏填充 | 追到误差 < 0.0009 即停 | 0.14 线性阻尼 | 平滑追踪 |
| 路径描边 | 300ms | 0.4, 0, 0.2, 1 | 沿折线走到当前标题 |
| 标题字色 | 200ms | color | 点明当前节 |
| 抽屉进场 | 400ms | 0.22, 1, 0.36, 1 | 空间层级 |
| 圆环进度 | 400ms | 同上 | 即时反馈 |
减少动效打开时,路径描边直接落点,圆环和标题的过渡被关掉,跳转也不再平滑。不尊重这个开关的动效,都只是噪音。
三步,把「进度」还给读者§
- 顶栏只答「此刻在哪」,用 2px 和阻尼表达尊敬;
- 路径只答「读到哪一节」,线跟着标题的深浅拐弯,可点、可跟随;
- 圆环只答「回来吧」,把最深的状态藏进最小的形状。
进度体系的终点不是数字,是安全感:读者随时可以离开,也随时被欢迎回来。