在文章页面添加「稍后再读」书签按钮,点击后文章内容自由落体掉出屏幕、侧栏列表静默刷新,同时支持在侧栏点击已收藏文章直接跳转。纯前端 localStorage 存储,无后端依赖。
IMPORTANTFuwari 使用 Swup 4 做页面切换,侧栏(包括「稍后再读」widget)位于
#swup-container外部,不受 Swup DOM 替换影响。所有交互脚本必须使用<script is:inline>以保证在 Swup 切页后仍可执行。
一、数据结构
使用 localStorage 存储,key 为 readLater,值为 JSON 数组,上限 20 条,最新添加的排在最前:
[ { "slug": "post-slug", "title": "文章标题", "url": "/posts/post-slug/", "addedAt": 1716950000000 }]slug:文章标识,用于去重判断url:相对路径(/posts/xxx/),兼容 Swup 的navigate()方法addedAt:Date.now()时间戳,用于显示”5分钟前”等相对时间
二、i18n 配置
2.1 新增枚举
编辑 src/i18n/i18nKey.ts,在枚举末尾追加:
readLater = "readLater",readLaterAdd = "readLaterAdd",readLaterRemove = "readLaterRemove",readLaterEmpty = "readLaterEmpty",2.2 各语言翻译
编辑 src/i18n/languages/*.ts(共 9 个文件),在 translation 对象中追加对应的 key-value。以 zh_CN.ts 和 en.ts 为例:
[I18nKey.readLater]: "稍后再读",[I18nKey.readLaterAdd]: "加入稍后再读",[I18nKey.readLaterRemove]: "移除稍后再读",[I18nKey.readLaterEmpty]: "暂无文章",
// en.ts[I18nKey.readLater]: "Read Later",[I18nKey.readLaterAdd]: "Add to Read Later",[I18nKey.readLaterRemove]: "Remove from Read Later",[I18nKey.readLaterEmpty]: "No saved articles",2.3 类型声明
编辑 src/global.d.ts,在 Window 接口中添加:
ReadLaterStore?: { getList: () => Array<{slug: string; title: string; url: string; addedAt: number}>; saveList: (list: Array<{slug: string; title: string; url: string; addedAt: number}>) => void; has: (slug: string) => boolean; add: (item: {slug: string; title: string; url: string}) => void; remove: (slug: string) => void; renderButton: () => void; renderSidebar: () => void; pulseReadLaterCard: () => void;};三、ReadLaterButton 组件
新建 src/components/ReadLaterButton.astro,放在文章标题右侧,与 ShareButton 并列。
3.1 模板
两个图标切换显示:未收藏时显示 bookmark-outline,已收藏时显示实心 bookmark,通过 CSS 类 .saved 控制显隐。
---import { Icon } from "astro-icon/components";
interface Props { slug: string; title: string; url: string; class?: string;}const { slug, title, url } = Astro.props;const className = Astro.props.class || "";---
<div class:list={["readlater-container inline-flex items-center", className]}> <button id="read-later-btn" class="readlater-btn btn-plain rounded-lg h-9 w-9 flex items-center justify-center" data-slug={slug} data-title={title} data-url={url} aria-label="稍后再读" > <Icon name="material-symbols:bookmark-outline-rounded" class="readlater-icon text-[1.1rem]" /> <Icon name="material-symbols:bookmark-rounded" class="readlater-active-icon text-[1.1rem]" /> </button></div>3.2 样式
使用 <style is:inline>,因为按钮的 .saved 类由 JS 动态添加,Astro 的 scoped style 无法匹配。
.readlater-container { display: inline-flex; align-items: center; }.readlater-btn .readlater-icon { display: inline; }.readlater-btn .readlater-active-icon { display: none; }.readlater-btn.saved .readlater-icon { display: none; }.readlater-btn.saved .readlater-active-icon { display: inline; }.readlater-btn.saved { color: var(--primary) !important; }3.3 ReadLaterStore 全局对象
整个功能的核心是 window.ReadLaterStore,一个挂载在 window 上的全局对象,被按钮组件和侧栏 widget 共享。使用 if (!window.ReadLaterStore) 守卫,确保无论哪个组件先加载,都只初始化一次。
if (!window.ReadLaterStore) { var STORE_KEY = 'readLater'; var MAX_ITEMS = 20;
window.ReadLaterStore = { getList: function() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || []; } catch(e) { return []; } }, saveList: function(list) { try { if (list.length > MAX_ITEMS) list = list.slice(0, MAX_ITEMS); localStorage.setItem(STORE_KEY, JSON.stringify(list)); } catch(e) {} }, has: function(slug) { return this.getList().some(function(i) { return i.slug === slug; }); }, add: function(item) { // 先过滤掉同 slug 条目,再插入最前面,天然去重且保留最新 var list = this.getList().filter(function(i) { return i.slug !== item.slug; }); list.unshift({ slug: item.slug, title: item.title, url: item.url, addedAt: Date.now() }); this.saveList(list); }, remove: function(slug) { var list = this.getList().filter(function(i) { return i.slug !== slug; }); this.saveList(list); }, // ... renderButton / renderSidebar / pulseReadLaterCard };}renderButton() 同步按钮的视觉状态(检查 localStorage 中是否有当前文章 slug),renderSidebar() 重绘侧栏列表 HTML,两者均在 add/remove 后立即调用。
3.4 自由落体动画
这是实现中最复杂的部分。需求是:点击收藏后,文章内容向下掉落并消失,同时露出首页内容,整个过程没有页面刷新感。
克隆策略
核心矛盾:如果直接隐藏原始内容(visibility: hidden),内容区会变空白,看起来像”刷新”。
解决方案:不隐藏原内容,克隆 #content-wrapper 并用 position: fixed + z-index: 60 覆盖在原内容上方。原内容始终可见,被克隆体遮住,用户无感。
function prepareFallClone() { var contentEl = document.getElementById('content-wrapper'); if (!contentEl) return null; var rect = contentEl.getBoundingClientRect();
var clone = contentEl.cloneNode(true); clone.removeAttribute('id'); clone.style.cssText = 'position:fixed;left:' + rect.left + 'px;top:' + rect.top + 'px;' + 'width:' + rect.width + 'px;height:' + rect.height + 'px;' + 'z-index:60;margin:0;overflow:hidden;pointer-events:none;' + 'will-change:transform,opacity;transform-origin:center center;' + 'opacity:1;animation:none;'; // ← 关键:覆盖 onload-animation 的 opacity:0 document.body.appendChild(clone); // 不隐藏原内容 —— 克隆体遮着,无空白闪烁 return { clone: clone, contentEl: contentEl };}必须设置animation:none
#content-wrapper带有onload-animation类(opacity: 0; animation: 300ms fade-in-up)。克隆体会继承这个动画并从头播放,导致克隆体初始opacity: 0而完全不可见。必须在 inline style 中显式设置opacity:1;animation:none;覆盖。
动画与 Fetch 并行
动画和首页数据获取同时进行。两个异步任务通过 cloneDone 和 fetchDone 两个布尔标志协调,只有两者都完成时才执行 reveal() 替换内容:
function animateFallClone(state, callback) { var clone = state.clone; var cloneDone = false, fetchDone = false, newContentEl = null;
function reveal() { if (!cloneDone || !fetchDone) return; // 两者都就绪才执行 if (clone.parentNode) clone.remove(); if (newContentEl) { newContentEl.style.opacity = '1'; // 瞬间显示,无渐显 newContentEl.style.transition = ''; } window.scrollTo(0, 0); if (window.ReadLaterStore) window.ReadLaterStore.renderSidebar(); callback(); }
// CSS Transition: 自由落体 requestAnimationFrame(function() { requestAnimationFrame(function() { clone.style.transition = 'transform 1s cubic-bezier(0.4,0,1,1), opacity 1s ease-in'; clone.style.transform = 'translateY(120vh) scale(0.3)'; clone.style.opacity = '0'; }); });
clone.addEventListener('transitionend', function() { cloneDone = true; reveal(); }); setTimeout(function() { cloneDone = true; reveal(); }, 1500); // 安全超时
// Fetch 首页 HTML fetch(window.location.origin + '/') .then(function(r) { return r.text(); }) .then(function(html) { var doc = new DOMParser().parseFromString(html, 'text/html'); var newSwup = doc.getElementById('swup-container'); var swupEl = document.getElementById('swup-container'); if (newSwup && swupEl) { swupEl.innerHTML = newSwup.innerHTML; // 替换内容区 newContentEl = document.getElementById('content-wrapper'); if (newContentEl) { newContentEl.style.opacity = '0'; // 先隐藏,等动画结束再显示 newContentEl.style.transition = 'none'; } } fetchDone = true; reveal(); }) .catch(function() { window.location.href = '/'; }); // fallback}为什么用 fetch + innerHTML 而不是 swup.navigate()?
swup.navigate('/')会触发 Swup 自己的退出动画(约 300ms),在动画期间内容区opacity: 0,用户会看到明显的内容消失再出现。用fetch+innerHTML直接替换内容,配合opacity:0 → 1瞬间切换,完全跳过 Swup 的动画周期,实现真正的”无刷新”。
时序图
点击书签├─ prepareFallClone() → 克隆体创建,覆盖在原内容上方├─ localStorage.add() → 写入数据(被克隆体遮住,用户无感)├─ renderButton() → 按钮变为已收藏状态├─ renderSidebar() → 侧栏列表更新(被克隆体遮住)│├─ animateFallClone()│ ├─ [并行] clone: translateY(120vh) scale(0.3) opacity:0 ← 1秒│ ├─ [并行] fetch('/') → 获取首页 HTML → 替换 #swup-container│ │ → newContentEl opacity:0(先隐藏)│ └─ 两者都完成 → reveal()│ → 移除克隆体│ → newContentEl opacity:1(瞬间显示首页)│ → renderSidebar()四、侧栏 Widget
新建 src/components/widget/ReadLater.astro,使用 Fuwari 原生的 WidgetLayout 壳子。
4.1 模板
---import I18nKey from "../../i18n/i18nKey";import { i18n } from "../../i18n/translation";import WidgetLayout from "./WidgetLayout.astro";
interface Props { class?: string; style?: string; }const className = Astro.props.class;const style = Astro.props.style;---
<WidgetLayout name={i18n(I18nKey.readLater)} id="read-later" class={className} style={style}> <div id="read-later-content"> <div id="read-later-empty" class="hidden text-sm text-black/50 dark:text-white/50 py-1"> {i18n(I18nKey.readLaterEmpty)} </div> <div id="read-later-list" class="flex flex-col gap-0.5"></div> </div></WidgetLayout>4.2 侧栏列表渲染
侧栏组件的 <script is:inline> 中也包含一个 ReadLaterStore 定义(带 if (!window.ReadLaterStore) 守卫),以及独立的 renderReadLaterSidebar() 函数。
关键点是事件委托:列表的点击事件绑定在 #read-later-list 容器上,而不是每个 <a> 标签上。这样无论列表内容如何更新(innerHTML 重写),点击处理始终有效:
var __readLaterListDelegated = false;
function renderReadLaterSidebar() { var listEl = document.getElementById('read-later-list'); var emptyEl = document.getElementById('read-later-empty'); if (!listEl) return; var items = window.ReadLaterStore.getList();
// 事件委托:只绑定一次 if (!__readLaterListDelegated) { __readLaterListDelegated = true; listEl.addEventListener('click', function(e) { var link = e.target.closest('a[data-slug]'); if (!link) return; e.preventDefault(); var slug = link.dataset.slug; var url = link.getAttribute('href'); // 先从列表中删除,再跳转 if (window.ReadLaterStore) { window.ReadLaterStore.remove(slug); renderReadLaterSidebar(); } if (url) { if (window.swup && window.swup.navigate) { window.swup.navigate(url); // SPA 跳转,无全量刷新 } else { window.location.href = url; } } }); }
// 渲染列表条目(带 data-slug 属性供事件委托使用) if (items.length === 0) { listEl.innerHTML = ''; if (emptyEl) emptyEl.classList.remove('hidden'); return; } if (emptyEl) emptyEl.classList.add('hidden'); listEl.innerHTML = items.map(function(item) { return '<a href="' + item.url + '" data-slug="' + item.slug + '" ' + 'class="read-later-item btn-plain rounded-lg w-full h-9 px-2 ' + 'flex items-center gap-2 text-sm cursor-pointer ...">' + '<span class="flex-1 overflow-hidden whitespace-nowrap text-ellipsis">' + item.title.replace(/</g, '<') + '</span>' + '<span class="text-xs text-black/30 dark:text-white/30 shrink-0">' + timeAgo(item.addedAt) + '</span>' + '</a>'; }).join('');}侧栏 widget 还注册了 Swup 的 page:view 钩子,在每次切页后重新渲染列表(此时列表 DOM 已被 Swup 替换为初始空状态):
window.swup.hooks.on('page:view', function() { setTimeout(function() { renderReadLaterSidebar(); }, 50);});五、接入页面
5.1 文章页
编辑 src/pages/posts/[...slug].astro,在 PostMetadata 旁插入 ReadLaterButton:
import ReadLaterButton from "../../components/ReadLaterButton.astro";
// 在 metadata 区域:<div class="flex items-start gap-4 mb-5"> <PostMetadata class="flex-1 min-w-0 overflow-hidden" ... /> <div class="flex items-center gap-1 shrink-0"> <ReadLaterButton slug={getEntrySlug(entry)} title={entry.data.title} url={Astro.url.pathname} /> <ShareButton title={entry.data.title} url={postUrl} /> </div></div>url 使用 Astro.url.pathname(相对路径 /posts/xxx/)而非完整 URL,保证 Swup 的 navigate() 方法能正确匹配路由。
PostMetadata 加 overflow-hidden 确保窄屏时内部内容被裁剪而不是把按钮挤出容器。
5.2 侧栏
编辑 src/components/widget/SideBar.astro,在 Weather 和 Tag 之间插入 ReadLater widget:
import ReadLater from "./ReadLater.astro";
<Weather class="onload-animation" style="animation-delay: 150ms"></Weather><ReadLater class="onload-animation" style="animation-delay: 200ms"></ReadLater><Tag class="onload-animation" style="animation-delay: 250ms"></Tag><Categories class="onload-animation" style="animation-delay: 300ms"></Categories>侧栏位于 #swup-container 外部(Fuwari 的 grid 布局中独占一列),Swup 的 containers: ["#swup-container", "#toc"] 配置不包含侧栏,因此 Swup 切页时侧栏 DOM 不会被替换,widget 脚本无需重复执行。
六、踩坑记录
克隆体不可见
#content-wrapper 带有 onload-animation 类(opacity: 0; animation: 300ms fade-in-up)。cloneNode(true) 会继承这个类,克隆体追加到 <body> 后动画从头播放,初始 opacity: 0 导致克隆体完全隐形。
修复:在克隆体的 inline style 中设置 opacity:1;animation:none; 覆盖继承的动画。
侧栏跟随掉落
最初克隆整个 #main-grid(含侧栏),导致侧栏跟着一起掉。改为只克隆 #content-wrapper(纯内容列),侧栏不在克隆范围内,始终保持不动。
内容注入闪烁
swupEl.innerHTML = newSwup.innerHTML 替换内容后,新的 #content-wrapper 带有 onload-animation 类,从 opacity: 0 渐显到 1,造成”空白闪烁”。
修复:注入后立即设置 newContentEl.style.opacity = '0',等动画克隆体消失后(reveal())再瞬间设为 1,无渐显过渡。
侧栏点击全量刷新
最初侧栏链接使用 window.location.href = url,导致整页刷新。改用 window.swup.navigate(url) 做 SPA 跳转,只替换 Swup 容器内容,侧栏和其他区域不受影响。
七、文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
src/i18n/i18nKey.ts | 修改 | 新增 4 个枚举值 |
src/i18n/languages/*.ts(9个) | 修改 | 各语言翻译 |
src/global.d.ts | 修改 | ReadLaterStore 类型声明 |
src/components/ReadLaterButton.astro | 新建 | 书签按钮 + 自由落体动画 + ReadLaterStore |
src/components/widget/ReadLater.astro | 新建 | 侧栏 widget + 事件委托 + Swup 钩子 |
src/pages/posts/[...slug].astro | 修改 | 插入 ReadLaterButton |
src/components/widget/SideBar.astro | 修改 | 插入 ReadLater widget |