全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单
- 1备案通过后的图床重生记:把 CloudFlare-ImgBed 从 Telegram 渠道搬回境内服务器的完整复刻手册
- 2全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单本文
- 3全站视觉移植复刻:把 Firefly 改造成 animal-island-ui 风格的七期工程(含复刻清单与踩坑全录)
- 4导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
- 5博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
- 6Firefly 博客实战:从零搭建任意设备可用的发布台(Vercel + GitHub API)
- 7评论区回复时表情面板被截断:一次 overflow 裁剪排查实录
- 8Firefly 博客实战:给文章页加阅读进度条与圆环百分比
- 9Firefly 博客接入友链朋友圈:从零开始的完整部署教程
- 10从零开始搭建个人图床
- 11文章中添加音乐播放器
- 12基于Firefly主题的列表封面位置改造
- 13基于rehype的博客PDF嵌入组件实现
- 14Twikoo评论系统部署踩坑记录:Vercel域名无法访问的解决之路
- 15这是我的第一篇BLOG
全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单
2026-10-13,本博客完成了一轮「先审计、再动刀」的动效改造:18 个文件修改(+290/−70 行)+ 1 个新依赖(animejs 4.5.0)+ 6 份可执行计划文档,功能零损失。
两条主线:
- 五项动效计划——按「频率/目的/速度/功能」四问门禁筛出的真缺口:浮层开合瞬移、详情模态无退场、公告关闭瞬隐、翻页内容硬切、代码组切标签硬切;
- anime.js 数字滚动——侧栏站点统计从 0 滚到终值,会话内只播一次。
本文写给两类读者:
- 未来的维护者(包括新会话的 AI 助手):读完应知道每个病灶在哪一行、为什么这么修、哪些坑已经踩过(本文记录了 4 个只有实测才会现形的 bug);
- 想复现的访客:文末「复刻清单」从零列出全部步骤,关键代码在正文中可照抄。
一、方法论:先审计,后动刀
这轮改造没有「哪里爽加哪里」,流程是三步:
- 审计出机会表:按四问门禁逐个候选打分——
- 频率:每天上百次的动作(键盘快捷键、核心导航)永远不加动画;偶尔动作(模态、折叠)可加标准动画;罕见动作(首次、庆祝)才轮到「花活」;
- 目的:必须能答出「反馈 / 空间一致性 / 状态指示 / 防止突变 / 解释 / 快乐」之一,「看着酷」不算;
- 速度:UI 单段动画 < 300ms;
- 功能:用户正在读的数据不为风格而动。
- 写成自包含计划:每份计划带改前代码摘录、精确到毫秒和 cubic-bezier 的目标值、边界与验证方法(存于仓库
plans/目录); - 执行 + 审查 + 实测:执行者照计划改,审查者按「never-ship 清单」过一遍(禁
transition: all、禁scale(0)入场、禁 UI 用ease-in、reduced-motion 必须随动画交付……),最后浏览器实测采样曲线。
本轮的审查阶段真的抓出了 4 个 bug——全部集中在 003 号计划,见下文第三节。
统一使用的动效 token(定义在 src/styles/variables.styl,复刻时先建这几个):
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* 颜色类过渡、默认曲线 */--ease-decelerate: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* 进入/落位 */--duration-fast: 100ms;--duration-normal: 150ms; /* 浮层、翻页、切标签都用它 */--duration-medium: 200ms; /* 模态入场、公告折叠 */二、五项动效计划
001 浮层开合瞬移(HIGH)
病灶(改前行号):src/styles/main.css:487 的 .float-panel 基类只有
@apply ... transition-colors duration-150 ...transition-colors 只覆盖颜色族,而关闭态 .float-panel-closed 切的是 opacity 和位移——没有额外工具类的面板(搜索面板 Search.svelte、导航快捷面板 Navbar.astro)开合就是瞬移;另有 4 个面板靠 markup 里的 transition-all 苟活(transition: all 本身是禁项)。
本项目最关键的冷知识:Tailwind v4 把位移编译成了独立的 translate 属性,不是 transform。构建产物实测:
/* .float-panel-closed 的编译结果 */.float-panel-closed { pointer-events: none; --tw-translate-y: calc(var(--spacing, .25rem) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); /* ← 独立属性! */ opacity: 0;}所以 transition 里必须写 translate——写 transform 修了个寂寞。
修复(main.css,改后):
.float-panel { @apply top-21 rounded-(--radius-large) overflow-hidden bg-(--card-bg) shadow-xl border border-black/5 dark:border-white/10; transition: opacity var(--duration-normal) var(--ease-decelerate), translate var(--duration-normal) var(--ease-decelerate), color var(--duration-normal) var(--ease-standard), background-color var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard);}@media (prefers-reduced-motion: reduce) { .float-panel { transition: opacity var(--duration-normal) var(--ease-standard), color var(--duration-normal) var(--ease-standard), background-color var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard); }}同时从 4 个 markup 里删掉 transition-all 这一个词(其余 class 不动),让全站浮层回归同一套机制:
Navbar.astro(music-nav-panel)NavMenuPanel.astro(导航菜单卡片)DisplaySettingsIntegrated.svelteLightDarkSwitch.svelte
运行时验证:getComputedStyle(#search-panel).transitionProperty 应为 opacity, translate, color, background-color, border-color,时长 0.15s;关闭态 translate: 0px -4px。
002 详情模态:补退场、背板补入场
病灶:AnimeDetailModal.svelte / BilibiliDetailModal.svelte(同构双文件)——背板 bg-black/60 随 {#if anime} 瞬现瞬消;卡片入场是 CSS keyframes(内置 ease-out 偏弱),关闭时 Svelte 直接摘 DOM,没有退场。
修复:用 Svelte transition 同时接管进、出(transition 可中断,keyframes 会从零重播),并删掉原 @keyframes animate-in:
<script lang="ts">import { cubicOut } from "svelte/easing";import { fade, fly } from "svelte/transition";</script>
<!-- 背板 --><div use:portal in:fade={{ duration: 200, easing: cubicOut }} out:fade={{ duration: 150, easing: cubicOut }} ...> <!-- 卡片:删掉 class 里的 animate-in,加 --> <div class="... scale-90 sm:scale-100" in:fly={{ y: prefersReducedMotion() ? 0 : 10, duration: 200, easing: cubicOut }} out:fly={{ y: prefersReducedMotion() ? 0 : 10, duration: 150, easing: cubicOut }} >- 入 200ms / 出 150ms:退场快于入场的非对称节奏,均在模态 200–500ms 档与 UI <300ms 红线内;
cubicOut是svelte/easing的具名强 ease-out(JS 消费不了 CSS 的 cubic-bezier 字符串,用框架自带函数而不是手搓近似值);- 卡片上的
scale-90在 Tailwind v4 编译为独立scale属性(实测产物.scale-90{...scale:var(--tw-scale-x) var(--tw-scale-y)}),与 fly 写的transform: translateY()互不打架; prefersReducedMotion()把位移归零、保留淡出。
验证:Esc 关闭后 [role="dialog"] 应滞留在 DOM 里跑完 150ms 退场才被移除(旧实现是瞬间蒸发);打开后 200ms 内立刻 Esc,退场应从中途状态接管而非闪回起点。
003 公告栏关闭——四个只有实测才现形的 bug
病灶:Announcement.astro 的关闭按钮原本是一行 onclick="...style.display='none'",一击瞬隐、侧栏下方组件全体跳位。
布局前提(动手前必须确认):widget-layout 是 SideBar.astro 里 flex flex-col gap-4 容器的直接子项——flex 子项会被 blockify,max-height 才可用;gap-4 = 1rem。
最终方案(三层):
max-height: 实测高度 → 0做折叠(高度动画在「手风琴/折叠」场景是被豁免的);margin-bottom: 0 → -1rem吞掉父容器一个 gap——否则元素收成 0 高后两侧 gap 还在,动画结束display:none那一帧会再跳 16px;- 淡出不能做在根元素上(原因见坑④),做在子元素上。
最终 onclick 全文(可直接照抄):
onclick="var w=this.closest('widget-layout');if(!w||w.dataset.closing)return;if(window.matchMedia('(prefers-reduced-motion: reduce)').matches){w.style.display='none';return}w.dataset.closing='1';w.style.maxHeight=w.offsetHeight+'px';w.style.marginBottom='0';void w.offsetHeight;requestAnimationFrame(function(){w.style.transition='max-height 200ms var(--ease-standard),margin-bottom 200ms var(--ease-standard)';w.style.maxHeight='0';w.style.marginBottom='-1rem';for(var i=0;i<w.children.length;i++){var c=w.children[i];c.style.transition='opacity 200ms var(--ease-standard)';c.style.opacity='0'}});setTimeout(function(){w.style.display='none'},240)"踩坑全录(按发现顺序,每条都是实测抓的):
| # | 症状 | 根因 | 修复 |
|---|---|---|---|
| ① | 点了完全没反应、无报错 | 初版写成 (function(){...})——函数表达式根本没人调用,整个 handler 是空操作 | 去掉包装 |
| ② | 换成 (function(){...})() 后仍没反应 | 立即调用的普通函数内部 this 不是按钮(是 globalThis),this.closest 抛 TypeError | 不包 IIFE,语句直接写在 inline handler 顶层(handler 本身就是函数体,this = 按钮,return/var 合法) |
| ③ | 高度瞬移、不折叠 | 起始值 max-height=Hpx 与 rAF 里的终值被合并进同一次样式计算,浏览器看到的是 none → 0,不可插值 | 起始值写入后 void w.offsetHeight; 强制回流,把起始值钉进上一次计算 |
| ④ | 淡出全程卡在 opacity:1,只在 display:none 后归零 | widget-layout 带 onload-animation(fade-in-up ... forwards)。已完成 CSS 动画的 fill 值优先级高于 inline style;更麻烦的是,Chrome 中由移除动画引起的值变化不触发 transition——在根元素上掐 fill 淡出这条路走不通(先被压死为 1,掐掉后又瞬跳为 0) | 根元素只做 max-height + margin 折叠(keyframes 不碰这两个属性),opacity 淡出施加在 w.children 子元素上(标题与内容块,不受父级 fill 影响) |
实测曲线(每 35ms 采样一次 getComputedStyle):
t=39ms childOp=0.97 maxH=239px mb=-0.4pxt=118ms childOp=0.25 maxH=62px mb=-12pxt=232ms childOp=0 maxH=0 mb=-16pxt=273ms display:none三条属性同步走完 200ms,结束瞬间零跳位。
004 六处翻页桥接
病灶:画廊/动态翻页时 currentPage 一改,{#each} 立即换一批卡片,整块内容瞬移替换。翻页是偶尔操作,值得一段标准动画;只动 opacity,不碰布局。
统一模式(淡出 → 换页 → 淡入,transition 而非 keyframes,快速连点可重定向):
let gridFading = $state(false);
async function goToPage(page: number) { if (page === currentPage) return; // 原有 bounds 检查的文件照旧保留 gridFading = true; await new Promise((r) => setTimeout(r, 150)); currentPage = page; await new Promise((r) => requestAnimationFrame(() => r(null))); gridFading = false;}容器上绑(写在容器 div 上,inline style):
style="opacity: {gridFading ? 0 : 1}; transition: opacity var(--duration-normal) var(--ease-decelerate)"六个文件:
| 文件 | 备注 |
|---|---|
pages/anime/AnimeGrid.svelte | 原 goToPage 无 bounds 检查,加了 page === currentPage 短路 |
pages/bilibili/BilibiliGrid.svelte | 同上 |
pages/vndb/VndbSection.svelte | 保留原 bounds 检查 |
pages/mal/MalSection.svelte | 同上 |
pages/bangumi/BangumiSection.svelte | 同上 |
pages/dynamic/DynamicFeed.svelte | 命令式渲染:换页后 await setTimeout(0) 让 $effect → renderItems 跑完一帧再淡入 |
不做:搜索打字、筛选 chip、排序下拉——键盘驱动的高频路径,加动画只会钝化(门禁第一问直接否决)。
实测曲线:1 → 0.477(60ms 淡出中)→ 换页 → 0.707(淡入中)→ 1。
005 代码组切标签桥接
病灶:TabGroup.svelte 切标签是 block.style.display 硬换,代码块瞬移。代码是用户在读的数据——只做透明度、不做位移。
let tabFading = $state(false);
async function selectTab(i: number) { if (i === active || tabFading) return; // 守卫:连点不叠动画 tabFading = true; await new Promise((r) => setTimeout(r, 150)); active = i; // $effect 随即换 display(此刻容器不可见,硬切被藏住) await new Promise((r) => requestAnimationFrame(() => r(null))); tabFading = false;}按钮 onclick 改走 selectTab(i);容器绑 style="opacity: {tabFading ? 0 : 1}";scoped style 加:
.code-group-blocks { transition: opacity var(--duration-normal) var(--ease-decelerate);}标签下划线/颜色的既有过渡保持不动——指示器即时响应,内容随后桥接,两者分工不冲突。
三、anime.js 数字滚动
选型:为什么整站只用它一处
anime.js 的招牌是 stagger 级联、时间线编排、数字滚动——前两样站内已有 CSS 方案(onload 错峰、Swup 过渡),硬上就是「为个淡入淡出装库」。真正值得的缺口是侧栏 SiteStats.astro 的六个死数字,于是按「最小试水」原则只做这一处。
安装
pnpm add animejs --registry=https://registry.npmmirror.com# 装到的是 animejs 4.5.0(npm 主线就是 v4,没有所谓 v6)v4 API 速查(从 node_modules/animejs/dist/modules/types/index.d.ts 核实,别凭记忆写)
import { animate } from "animejs";
const counter = { v: 0 };animate(counter, { v: end, // 普通 JS 对象补间:参数键名 = 对象属性名 duration: 1000, delay: index * 80, // 每行错峰 ease: "outExpo", // 合法名见 EaseStringParamNames:outQuad/outCubic/outExpo/outBack... onUpdate: () => { el.textContent = Math.round(counter.v).toLocaleString(); }, onComplete: () => { el.textContent = end.toLocaleString(); },});要点:
AnimationParams是Record<string, ...>——属性 tween 与duration/delay/ease/onUpdate/onComplete回调可以混在同一个对象里;ease取值必须是导出的具名字符串或函数,"outExpo"在EaseStringParamNames联合类型里;- 目标是普通对象时走 JS 补间(
JSTargetsParam),不需要 DOM。
SiteStats 实现(三个关键设计)
- 锚点:给统计值 span 统一加
data-stat-value(不蒜子两行是异步来的,不加、不碰)。取值时读textContent当终值——静态值 SSR 已是终值,动态值(运行天数/最后更新)由上方 classic script 先写好(classic script 解析期执行,模块脚本 defer 执行,顺序天然正确)。"今天"这类非数字用/^\d+$/过滤跳过。 - 触发:
IntersectionObserver(threshold 0.3)滚入视口才播,播完disconnect;非数字、end <= 0不播。 - 频率守卫:
sessionStorage键ff-site-stats-counted——真正播过才置位(这样播之前切页还能换页重试);swup 容器含#left-sidebar-dynamic/#right-sidebar-dynamic,切页会重渲染本组件,没有守卫的话每次切页数字都滚一遍就是骚扰。切页重挂靠astro:page-load(不要用swup:contentReplaced——swup 4.x 根本不派发这个旧事件)。prefers-reduced-motion直接置守卫跳过。
两个只有实测才现形的坑
坑①:#site-stats 在 DOM 里有两份(桌面侧栏 + 移动侧栏各一),其中一份在当前断点下是 0×0 的隐藏副本。document.getElementById 永远返回树序第一份——观察了个寂寞。
坑②(阴险):零面积元素会被 IntersectionObserver 特判为 isIntersecting。那个 0×0 的隐藏副本躺在 (0,0),一加载就被判「相交」,抢先把 sessionStorage 守卫置了位——可见副本的回调一看守卫已设,直接自我了断。表现为:守卫显示已播、数值却全程不动。
双保险修复:
// 装观察器前:滤掉零宽根const roots = Array.from(document.querySelectorAll("#site-stats")).filter( (root) => root.dataset.countUpReady !== "1" && root.getBoundingClientRect().width > 0,);
// 回调里:再验一次目标宽度const hit = entries.find( (entry) => entry.isIntersecting && entry.boundingClientRect.width > 0,);if (!hit) return;给测试者的教训:排查过程中用 page.addInitScript 清守卫是永久挂在该 Page 上的——之后每次导航都会先清 key,把「二载不重播」测了个假阳性。换干净标签页重测才拿到真结论。测试钩子本身也会污染被测行为,切记。
双载实测结果(干净标签页)
首载 t=0ms : ["42","4","65","67,866","63","1", "26","1","0","0","0","0"] ← 后半在滚首载收敛 : 两份副本均为 ["42","4","65","67,866","63","1"],守卫="1"二载 800ms 全程: 无归零、无变化(stayFinal=true),守卫="1"(前 6 个是隐藏副本的 SSR 终值、后 6 个是可见副本的计数——隐藏副本全程未被触碰,坑①②修复生效。)
四、验证方法
静态三件套(本仓库约定):
npx biome check <改动文件> # 只读检查,禁止 --write(pnpm lint 会波及十几个无关文件)pnpm type-check # tsc --noEmit,退出码 0# pnpm check 是 astro check,存量基线 17 errors,验证时以「错误数是否仍为 17」为准动态采样法:动画的对错不能靠肉眼定案,用 Playwright page.evaluate 对着 getComputedStyle 按 35–150ms 间隔采样,把曲线打出来对照预期。本文第二节的三条曲线全部来自此法。几个采样技巧:
- 页面动画可能在你开始采样前就跑完了——用
page.addInitScript清状态 +goto后立即采样,或先scrollIntoView再采; - 测「二载不重播」这类守卫逻辑,必须换干净标签页(sessionStorage 按标签隔离,
addInitScript会永久挂在原 Page 上污染后续测试); - console 里本机的图床 DNS 失败(
ERR_NAME_NOT_RESOLVED)与不蒜子/天气接口 429 是环境噪音,与改动无关。
手感复核:DevTools → Animations 面板慢放逐帧;Rendering 面板勾 prefers-reduced-motion 确认「位移归零、淡入保留」。代码只能证明曲线对,手感要活人隔天再看一眼。
五、复刻清单(从零照做)
- 建 token(已有则跳过):在全局样式加
--ease-standard、--ease-decelerate、--duration-fast/normal/medium/slow,值见本文第一节; - 001 浮层:改
.float-panel基类为显式 transition(本文有全文)+ reduce-motion 媒体查询;从 4 个 markup 删transition-all;验证getComputedStyle(#search-panel).transitionProperty含opacity, translate; - 002 模态:两个 DetailModal 改 Svelte
in:/out:fade/fly(入 200 / 出 150,cubicOut),删 keyframes,加prefersReducedMotion()分支; - 003 公告:照抄本文的最终 onclick;确认侧栏容器是
flex ... gap-*且 widget 为直接子项;-1rem要与实际 gap 值一致; - 004 翻页:六文件套用
gridFading模式(本文有骨架);键盘/筛选路径不加; - 005 切标签:
selectTab守卫 + 容器 opacity 绑定 + scoped transition; - anime.js:
pnpm add animejs --registry=https://registry.npmmirror.com;SiteStats 加data-stat-value锚点;照抄三个关键设计(锚点/观察器/守卫)与双保险过滤; - 验证:跑静态三件套 → Playwright 采样 → reduce-motion 档复核;
- 文档化:把计划与实测记录存档(本仓库是
plans/目录),未来的维护者才不用重踩这 4 个 bug。
六、交付物与改动总表
交付物:
plans/——6 份文件:README.md(执行顺序与状态表)+001–005五份自包含计划,每份含改前代码摘录、精确目标值、边界与验证方法,Status 均为 DONE,003 号附三轮审查修正记录;- 本文——
src/content/posts/txt/21文章.md。
改动总表(18 files changed, 290 insertions(+), 70 deletions(-),另加依赖与文章):
| 类别 | 文件 |
|---|---|
| 001 浮层 | src/styles/main.css、Navbar.astro、NavMenuPanel.astro、DisplaySettingsIntegrated.svelte、LightDarkSwitch.svelte |
| 002 模态 | AnimeDetailModal.svelte、BilibiliDetailModal.svelte |
| 003 公告 | Announcement.astro |
| 004 翻页 | AnimeGrid.svelte、BilibiliGrid.svelte、VndbSection.svelte、MalSection.svelte、BangumiSection.svelte、DynamicFeed.svelte |
| 005 标签 | TabGroup.svelte |
| anime.js | package.json、pnpm-lock.yaml、SiteStats.astro |
改前引用的行号(如
main.css:487)以改造前的 commit 为准,改造后会漂移——按「文件 + 代码特征」定位比行号可靠。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!




