视频加载失败

全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单

3739 字
19 分钟
全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单

全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单#

2026-10-13,本博客完成了一轮「先审计、再动刀」的动效改造:18 个文件修改(+290/−70 行)+ 1 个新依赖(animejs 4.5.0)+ 6 份可执行计划文档,功能零损失。

两条主线:

  1. 五项动效计划——按「频率/目的/速度/功能」四问门禁筛出的真缺口:浮层开合瞬移、详情模态无退场、公告关闭瞬隐、翻页内容硬切、代码组切标签硬切;
  2. anime.js 数字滚动——侧栏站点统计从 0 滚到终值,会话内只播一次。

本文写给两类读者:

  • 未来的维护者(包括新会话的 AI 助手):读完应知道每个病灶在哪一行、为什么这么修、哪些坑已经踩过(本文记录了 4 个只有实测才会现形的 bug);
  • 想复现的访客:文末「复刻清单」从零列出全部步骤,关键代码在正文中可照抄。

一、方法论:先审计,后动刀#

这轮改造没有「哪里爽加哪里」,流程是三步:

  1. 审计出机会表:按四问门禁逐个候选打分——
    • 频率:每天上百次的动作(键盘快捷键、核心导航)永远不加动画;偶尔动作(模态、折叠)可加标准动画;罕见动作(首次、庆祝)才轮到「花活」;
    • 目的:必须能答出「反馈 / 空间一致性 / 状态指示 / 防止突变 / 解释 / 快乐」之一,「看着酷」不算;
    • 速度:UI 单段动画 < 300ms;
    • 功能:用户正在读的数据不为风格而动。
  2. 写成自包含计划:每份计划带改前代码摘录、精确到毫秒和 cubic-bezier 的目标值、边界与验证方法(存于仓库 plans/ 目录);
  3. 执行 + 审查 + 实测:执行者照计划改,审查者按「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.svelte
  • LightDarkSwitch.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。

最终方案(三层):

  1. max-height: 实测高度 → 0 做折叠(高度动画在「手风琴/折叠」场景是被豁免的);
  2. margin-bottom: 0 → -1rem 吞掉父容器一个 gap——否则元素收成 0 高后两侧 gap 还在,动画结束 display:none 那一帧会再跳 16px;
  3. 淡出不能做在根元素上(原因见坑④),做在子元素上。

最终 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.4px
t=118ms childOp=0.25 maxH=62px mb=-12px
t=232ms childOp=0 maxH=0 mb=-16px
t=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 的六个死数字,于是按「最小试水」原则只做这一处。

安装#

Terminal window
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 实现(三个关键设计)#

  1. 锚点:给统计值 span 统一加 data-stat-value(不蒜子两行是异步来的,不加、不碰)。取值时读 textContent 当终值——静态值 SSR 已是终值,动态值(运行天数/最后更新)由上方 classic script 先写好(classic script 解析期执行,模块脚本 defer 执行,顺序天然正确)。"今天" 这类非数字用 /^\d+$/ 过滤跳过。
  2. 触发:IntersectionObserver(threshold 0.3)滚入视口才播,播完 disconnect;非数字、end <= 0 不播。
  3. 频率守卫: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 个是可见副本的计数——隐藏副本全程未被触碰,坑①②修复生效。)


四、验证方法#

静态三件套(本仓库约定):

Terminal window
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 确认「位移归零、淡入保留」。代码只能证明曲线对,手感要活人隔天再看一眼。


五、复刻清单(从零照做)#

  1. 建 token(已有则跳过):在全局样式加 --ease-standard、--ease-decelerate、--duration-fast/normal/medium/slow,值见本文第一节;
  2. 001 浮层:改 .float-panel 基类为显式 transition(本文有全文)+ reduce-motion 媒体查询;从 4 个 markup 删 transition-all;验证 getComputedStyle(#search-panel).transitionProperty 含 opacity, translate;
  3. 002 模态:两个 DetailModal 改 Svelte in:/out: fade/fly(入 200 / 出 150,cubicOut),删 keyframes,加 prefersReducedMotion() 分支;
  4. 003 公告:照抄本文的最终 onclick;确认侧栏容器是 flex ... gap-* 且 widget 为直接子项;-1rem 要与实际 gap 值一致;
  5. 004 翻页:六文件套用 gridFading 模式(本文有骨架);键盘/筛选路径不加;
  6. 005 切标签:selectTab 守卫 + 容器 opacity 绑定 + scoped transition;
  7. anime.js:pnpm add animejs --registry=https://registry.npmmirror.com;SiteStats 加 data-stat-value 锚点;照抄三个关键设计(锚点/观察器/守卫)与双保险过滤;
  8. 验证:跑静态三件套 → Playwright 采样 → reduce-motion 档复核;
  9. 文档化:把计划与实测记录存档(本仓库是 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.jspackage.json、pnpm-lock.yaml、SiteStats.astro

改前引用的行号(如 main.css:487)以改造前的 commit 为准,改造后会漂移——按「文件 + 代码特征」定位比行号可靠。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

全站动效补完与 anime.js 数字滚动:五项动效计划的落地、四轮实测踩坑与复刻清单
https://azuma.mstzuomu.space/posts/azuma.zuomu-blog-21/
作者
左沐
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
全站视觉移植复刻:把 Firefly 改造成 animal-island-ui 风格的七期工程(含复刻清单与踩坑全录)
技术分享完整记录如何把一个 Astro + Svelte 博客移植成 animal-island-ui 的治愈系海岛风格:决策依据(为什么不引库、不重写)、规范获取方式、七期改造的逐文件实录与精确色值、Tailwind 层序覆盖等关键机制、Nunito 字体接入、验证三件套,附他人可照做的复刻清单与未尽事项
2
导航栏 UI 改造全记录:卡片边框、悬浮胶囊、三段收拢与 PC 收拢态快捷面板(附完整复现指南)
技术分享一次连续迭代的完整复盘——从卡片边框体系、悬浮胶囊导航,到三段结构拆分、下滑收拢动画,再到 PC 收拢态汉堡的控件快捷面板与"行点击原位弹卡"的两处根因修复,追加 PC 搜索控件图标化与收拢动画卡顿的逐帧诊断修复。每个批次改了哪些文件、为什么这么改、踩了哪些坑(CSS 层序、正圆公式、包含块、Vite 缓存、Playwright headless 动画冻结),附 git 重放与手工重做两条复现路径及验收清单
3
基于Firefly主题的列表封面位置改造
技术分享不想让封面挤在侧边?Firefly封面置顶记录
4
博客性能优化全记录:Lighthouse 报告拆解到帧率根因排查(A–F 六项实战)
技术分享从两份 Lighthouse 对比报告出发,完整记录六项性能优化的排查方法、根因定位与修复代码:字体子集化、封面图尺寸 API、CSS 内联、强制重排归零、以及"只有 Chrome 掉帧"的浏览器设置级根因,附全部可复现命令与工具脚本
5
Firefly 博客实战:给文章页加阅读进度条与圆环百分比
技术分享从设计思路到逐文件代码,手把手为 Firefly/Astro 博客实现纯客户端阅读进度功能:顶部细条、右下角与悬浮按钮同款的圆环百分比,零网络请求,兼容 Swup 切页
随机文章随机推荐
Profile Image of the Author
陌殊途左沐
热爱是拯救无趣人生的唯一途径
公告
欢迎来到我的博客!这里是左沐的个人空间,分享我的学习、生活和兴趣爱好。希望你能在这里找到有趣的内容,请不要对我的喜好做出评价哦!请勿使用公网访问本站。
分类
访客信息
加载中...
标签
最新动态
站点统计
文章
30
分类
2
标签
52
总字数
58,096
运行时长
0 天
最后活动
0 天前
总浏览量
-
访客数
-
文章目录