全站动效记录 01:动效地图、首访加载和一个 H
这次先把全站动效分层,再处理首访加载。入口动画只负责第一眼,不能拖慢后面的阅读和点击。
给网站加动效,最容易做过头。每个地方都动一点,最后页面会像在等自己表演完。
这次先定了一个简单规则:动效可以帮内容出现,但不能让人等。首访加载可以有一点仪式感,其它地方都要短、快、可跳过。
现在的动效地图
项目里的动效大致分成几层:
| 文件 | 负责什么 | 覆盖范围 |
|---|---|---|
HomeEntrance.tsx | 首访加载、首页准备状态 | 只在首页第一屏前出现 |
Header.tsx | 顶部导航入场、当前导航高亮 | 全站 header |
Hero.tsx | 欢迎语、书法图、名片和下滑箭头 | 首页第一屏 |
HomeReveal.tsx | 滚动到可见区域后上浮出现 | 首页第二屏及之后 |
PageTransition.tsx | 路由边界 | 现在只保留结构,不做视觉动画 |
RouteLoading.tsx | loading fallback | 只给读屏器提示,不显示加载层 |
PageEnter.tsx | 二级页面本地入场 | 博客、摄影、书法、关于 |
PhotoModal.tsx / CalligraphyModal.tsx | 弹窗进入退出 | 摄影、书法详情 |
TableOfContents.tsx | 目录当前项滑动 | 博客详情页 |
BackToTop.tsx / ScrollChrome.tsx | 返回顶部和滚动提示 | 长页面 |
globals.css | 打字、唱片文字滚动、统一字体和 reduced motion | 全站 CSS 层 |
这样分层之后,问题会清楚很多。首访加载不该管二级页面,页面切换也不该重新播放首页入口动画。
首访加载只做一次
入口动画在 HomeEntrance.tsx。它做三件事:
- 显示一个居中的哥特体
H。 - 下方显示当前年月日时间和
We Are the World。 - 结束后再放出首页内容和 header。
时间来自浏览器里的 Intl.DateTimeFormat:
return `${parts.year}.${parts.month}.${parts.day} ${parts.hour}:${parts.minute}`;这个文本不是直接淡入,而是用 CSS 做从左到右的打字效果:
.home-intro-type {
clip-path: inset(0 100% 0 0);
animation: home-intro-type 0.82s steps(35, end) 0.12s both;
}这里没有真的一个字一个字改 React state。文本本身只渲染一次,动画只改变可见区域。这样不会出现“小字先出来、消失、又出来”的闪动。
状态比时间更重要
早期问题不是动画写得不够多,而是顺序不清楚。首页内容、header、加载层同时开始跑,就会看到背景图和 header 抢先露出来。
现在 HomeEntrance 用了几个明确阶段:
type IntroPhase = "checking" | "intro" | "exiting" | "done";checking 阶段先判断要不要跳过。第一次访问进入 intro。时间到了进入 exiting。遮罩真正退场后,才把首页标记为 ready。
这个 ready 状态通过 context 传给 header 和首页内容。也就是说,不是每个组件自己猜什么时候该出现,而是入口动画统一发出信号。
为什么需要两层记录
首访动画用 sessionStorage 记录是否播放过:
const INTRO_STORAGE_KEY = "huzejun.homeIntroPlayed";但只靠 sessionStorage 还不够。从其它页面返回首页时,React 组件可能重新挂载。如果判断时机不对,加载层会闪一下。
所以又加了一层运行时变量:
let introCompletedInRuntime = false;它不负责跨刷新保存,只负责当前这次打开网站期间的页面切换。刷新页面看 sessionStorage,站内来回跳转看运行时状态。
小结
首访加载不是全站 loading。它只是一道入口门帘。
这个边界定清楚之后,后面的动效就不需要互相抢时间:入口动画结束,首页第一屏入场;首页准备好,滚动区再观察;二级页面自己做轻量入场,不再借全局遮罩。

