2473 字
12 分钟
Fuwari「稍后再读」功能
2026-05-29
Purely By AI

在文章页面添加「稍后再读」书签按钮,点击后文章内容自由落体掉出屏幕、侧栏列表静默刷新,同时支持在侧栏点击已收藏文章直接跳转。纯前端 localStorage 存储,无后端依赖。

IMPORTANT

Fuwari 使用 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() 方法
  • addedAtDate.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.tsen.ts 为例:

zh_CN.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 并行#

动画和首页数据获取同时进行。两个异步任务通过 cloneDonefetchDone 两个布尔标志协调,只有两者都完成时才执行 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, '&lt;') + '</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() 方法能正确匹配路由。

PostMetadataoverflow-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
这篇文章对你有帮助吗?