Fuwari 博客主题深度定制记录
本文记录了对 Astro 博客主题 Fuwari 的一系列深度定制,包括友链系统、主页 Hero 横幅、打字机古诗特效等功能的实现过程,以及调试中遇到的各种坑。所有改动均附 diff 代码块。
一、友链系统
1.1 添加友链菜单入口
文件:src/config.ts
navbar: [ LinkPreset.Home, LinkPreset.Archive, { name: '友人帐', url: '/friends/' }, LinkPreset.About,]1.2 创建友链页面
新建 src/pages/friends.astro,包含友链数据、标签筛选 UI 和卡片网格布局:
---import MainGridLayout from "../layouts/MainGridLayout.astro";import FriendCard from "@components/FriendCard.astro";
const friends = [ { name: "fuwari", description: "博客主题", url: "https://github.com/saicaca/fuwari", avatar: "assets/images/demo-avatar.png", tag: "博客" }, // ... 更多友链]const tags = ["全部", ...new Set(friends.map(f => f.tag).filter(Boolean))];---<MainGridLayout title="友人帐" description="友情链接"> <!-- 标签筛选按钮 --> <div class="flex flex-wrap gap-2 px-2"> {tags.map(tag => ( <button class="filter-btn ..." data-tag={tag}>{tag}</button> ))} </div> <!-- 友链卡片网格 --> <div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="friend-grid"> {friends.map(friend => ( <div class="friend-card-wrapper" data-tag={friend.tag}> <FriendCard {...friend} /> </div> ))} </div></MainGridLayout>1.3 新建友链卡片组件
新建 src/components/FriendCard.astro,支持本地图片和远程 URL 头像。
1.4 标签筛选逻辑(Swup 兼容)
WARNING友链页面的
<script>标签位于<MainGridLayout>外部,不在 Swup 的#swup-container内。Swup 替换内容时不会重新执行这些脚本。必须将筛选逻辑移到MainGridLayout.astro的全局脚本中。
文件:src/layouts/MainGridLayout.astro(全局 <script is:inline> 块内新增)
// === Friends page tag filtering ===function setupFilters() { const buttons = document.querySelectorAll('.filter-btn'); const cards = document.querySelectorAll('.friend-card-wrapper'); if (!buttons.length) return;
function filter(selectedTag) { buttons.forEach(b => { b.classList.toggle('active-filter', b.getAttribute('data-tag') === selectedTag); }); cards.forEach(card => { const cardTag = card.getAttribute('data-tag'); card.style.display = (selectedTag === '全部' || cardTag === selectedTag) ? 'block' : 'none'; }); }
buttons.forEach(btn => { btn.addEventListener('click', () => { filter(btn.getAttribute('data-tag')); }); }); filter('全部');}setupFilters();二、主页全屏 Hero 横幅
2.1 调整横幅高度为全屏
文件:src/constants/constants.ts
export const BANNER_HEIGHT = 35;export const BANNER_HEIGHT_EXTEND = 15;export const BANNER_HEIGHT_EXTEND = 73;export const BANNER_HEIGHT_HOME = BANNER_HEIGHT + BANNER_HEIGHT_EXTEND;35vh + 73vh = 108vh,略大于视口高度,保证全屏覆盖。
2.2 添加 Hero 覆盖层(标题 + 打字机 + 箭头)
文件:src/layouts/MainGridLayout.astro(#banner-wrapper 内部新增)
</ImageWrapper> <div class="absolute inset-0 flex flex-col items-center justify-center text-white z-20" id="hero-overlay" style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"> <div class="text-4xl md:text-5xl font-bold mb-4 font-hero"> {siteConfig.title} </div> <div class="text-lg md:text-xl flex items-center font-hero"> <span id="typewriter-text"></span> <span class="animate-pulse ml-1">|</span> </div> <div class="absolute bottom-8 left-1/2 -translate-x-1/2 pointer-events-auto cursor-pointer text-white/80 hover:text-white" onclick="const mg = document.getElementById('main-grid'); if(mg) window.scrollBy({ top: mg.getBoundingClientRect().top, behavior: 'smooth' });"> <Icon name="material-symbols:keyboard-arrow-down-rounded" class="text-5xl animate-bounce" /> </div> </div></div>}2.3 修复向下箭头点击无响应
Hero 区域的箭头位于 #banner-wrapper(z-10),而内容区容器(z-30)虽然视觉上透明,pointer-events-auto 会拦截所有点击。需要将 pointer-events-auto 下移到 #main-grid:
<div class="absolute w-full z-30 pointer-events-none" ...> <div class="relative max-w-[...] mx-auto pointer-events-auto"> <div id="main-grid" class="... mx-auto gap-4 px-0 md:px-4" <div class="relative max-w-[...] mx-auto pointer-events-none"> <div id="main-grid" class="... mx-auto gap-4 px-0 md:px-4 pointer-events-auto"2.4 SPA 导航兼容 CSS
Hero 覆盖层的显隐通过 CSS 控制,确保 Swup 跳转正常切换:
body:not(.lg\:is-home) #hero-overlay { opacity: 0 !important; pointer-events: none !important;}三、打字机古诗特效(Hitokoto API)
3.1 接入一言 API
使用 Hitokoto(一言) API 获取随机古诗词,参数 c=i 仅返回诗词类:
async function fetchNextPhrase() { try { const response = await fetch('https://v1.hitokoto.cn?c=i'); const data = await response.json(); return data.hitokoto; } catch (e) { return "欲买桂花同载酒,终不似,少年游。"; }}3.2 动画参数
| 参数 | 说明 | 当前值 |
|---|---|---|
| 打字速度 | 每个字的打字间隔 | 50ms |
| 删除速度 | 每个字的删除间隔 | 50ms |
| 打完暂停 | 显示完整诗句的停留时间 | 2000ms |
| 删完暂停 | 删除完到开始新诗句的间隔 | 150ms |
3.3 解决异步竞态导致的文字闪烁
打字机效果在页面跳转时会出现文字闪烁。根本原因是 异步竞态条件:
当 initTypewriter() 被调用时,如果上一个实例正停在 await fetch() 上:
clearTimeout()只能清除已排队的定时器,无法终止挂起的 async 函数- 网络请求返回后,旧实例恢复执行并调度新的
setTimeout - 新旧两个
type()链条同时运行,争抢同一个<span>,导致闪烁
解决方案:引入 instanceId 取消令牌:
let typeTimeout = null;let typeInstanceId = 0;
function initTypewriter() { const el = document.getElementById('typewriter-text'); if (!el) return;
if (typeTimeout) clearTimeout(typeTimeout); typeInstanceId++; const myId = typeInstanceId; if (typeTimeout) clearTimeout(typeTimeout);
// ...
async function type() { if (!document.getElementById('typewriter-text')) return; if (myId !== typeInstanceId) return; // 已被新实例取代 if (!document.getElementById('typewriter-text')) return;
if (currentPhrase === "") { await fetchNextPhrase(); currentPhrase = await fetchNextPhrase(); if (myId !== typeInstanceId) return; // await 后再检查 }
// ... 打字逻辑 ...
typeTimeout = setTimeout(type, typeSpeed); typeTimeout = setTimeout(() => { if (myId === typeInstanceId) type(); // 回调中也检查 }, typeSpeed); }}3.4 Swup 事件监听器防重复
使用全局守卫防止 content:replace 被多次注册:
if (!window._swupContentBound) { window._swupContentBound = true; document.addEventListener('swup:content:replace', () => { if (document.getElementById('typewriter-text')) { initTypewriter(); } setupFilters(); });}四、Butterfly 风格视差滚动(探索记录)
4.1 目标
参考 Butterfly 主题 实现首页背景图固定不动、内容从下方滑上覆盖图片的效果。
4.2 源码分析
Butterfly 核心实现仅一行 CSS(source/css/_layout/head.styl):
&.full_page height: $index_top_img_height background-attachment: fixedButterfly 使用 CSS background-image + background-attachment: fixed。Fuwari 使用 <img> 标签,无法直接使用此属性。
4.3 尝试与结论
| 方案 | 原理 | 结果 |
|---|---|---|
position: fixed + lg:is-home | 图片固定,靠 CSS 类切换 | ❌ Swup 的 visit:start 在淡出前就移除类,导致图片弹回产生抖动 |
JS butterfly-fixed 类 + Swup 生命周期 | 在 content:replace(淡出后)才移除固定 | ⚠️ 可行但增加额外复杂度 |
::before 纯色背景遮罩 | #main-grid 上叠加纯色背景覆盖固定图片 | ⚠️ 与 Fuwari 原始过渡动画冲突 |
WARNING结论:Fuwari 的 Swup 过渡系统精心设计了
transition duration-700+lg:is-home类切换的时序配合。position: fixed方案会与这套机制冲突。如需此效果,建议将ImageWrapper组件的<img>替换为 CSSbackground-image,使用原生background-attachment: fixed。
五、自定义字体
.font-hero { font-family: 'SimSun', 'Songti SC', 'STSong', serif;}如需自定义字体文件,放到 public/fonts/ 后:
@font-face { font-family: 'HeroFont'; src: url('/fonts/your-font.ttf') format('truetype'); font-display: swap;}.font-hero { font-family: 'SimSun', 'Songti SC', 'STSong', serif; font-family: 'HeroFont', serif;}六、更换主页背景图
文件:src/config.ts
banner: { enable: true, src: "assets/images/demo-banner.png", src: "https://picture.sanring.sbs/api/random", position: "center",}七、涉及的文件清单
| 文件 | 操作 | 修改内容 |
|---|---|---|
src/config.ts | 修改 | 菜单项、横幅图片配置 |
src/constants/constants.ts | 修改 | BANNER_HEIGHT_EXTEND → 73 |
src/layouts/MainGridLayout.astro | 修改 | Hero 覆盖层、打字机、筛选、pointer-events 修复 |
src/pages/friends.astro | 新建 | 友链页面 |
src/components/FriendCard.astro | 新建 | 友链卡片组件 |
src/content/spec/friends.md | 新建 | 友链页面描述文案 |
八、踩坑总结
Swup SPA 导航下的脚本问题
Fuwari 的 Swup 只替换 #swup-container 内的内容。页面级 <script> 不在容器内,Swup 不会执行。
解决:将脚本移到 MainGridLayout.astro 的全局 <script is:inline> 中,通过 swup:content:replace 事件重新绑定。
WARNING事件监听器重复注册
<script is:inline>每次页面加载都会执行。重复addEventListener导致同一事件被多次监听。解决:使用
window._swupContentBound守卫标志,确保全局只注册一次。
异步函数的竞态条件
clearTimeout() 无法终止正在 await 中挂起的 async 函数。旧实例恢复后会与新实例同时运行,导致 UI 闪烁。
解决:使用递增的 instanceId 作为取消令牌,在 await 前后和 setTimeout 回调内都检查 ID 有效性。
WARNING
position: fixed与 Swup 过渡冲突Swup 在
visit:start(页面淡出前)就移除lg:is-home类。CSS 据此切换position: fixed会导致布局跳动。解决:避免在 Fuwari 中使用
position: fixed控制 banner。如需固定效果应使用background-attachment: fixed。
NOTEpointer-events 层叠遮挡
Fuwari z-index 层级:banner (z-10) → content (z-30)。z-30 层的
pointer-events-auto即使视觉透明也会拦截 z-10 的点击。需将pointer-events-auto精确设置在实际内容元素#main-grid上。
NOTECSS 选择器中的冒号转义
Fuwari 的 body 类名
lg:is-home含冒号,CSS 中须写body.lg\:is-home。漏掉反斜杠选择器静默失效。