各位前端同仁,大家下午好。
我是你们今天的讲师。把你们的笔记本电脑都合上,哪怕是为了看这个,也请合上。我们要聊的是一件让无数 React 开发者夜不能寐,却又在每次刷新页面后看着那生硬的闪烁感到绝望的事情——页面切换动画。
我知道你们在想什么。你们在想:“嘿,我用了 react-router-dom,我用了 framer-motion,我甚至还在 useEffect 里写了 setTimeout 来模拟淡入淡出。这还不够吗?”
不够。绝对不够。
如果我说,在 2024 年,我们还在用 setTimeout 来做路由切换动画,就像是在法拉利引擎盖上贴纸一样——技术上可行,但品味极差,而且浪费了引擎的潜力。
今天,我们要聊的是浏览器最前沿的武器,那个直接插进 CPU 里就能跑的 API——View Transitions API。这不是什么第三方库,不是什么 CSS hack,这是浏览器原生为你准备的“魔法”。我们将用这个 API,在 React 路由切换中,实现那种丝般顺滑、原生级别的跨页面视图过渡。
准备好了吗?让我们把那些乱七八糟的 useEffect 和 CSS keyframes 全都扔进垃圾桶。
第一章:为什么我们总是失败?
在深入代码之前,我们先来吐槽一下现状。你们有没有遇到过这种情况:
用户点击“关于我们”,页面瞬间清空,紧接着旧内容像鬼魂一样淡出,新内容像僵尸一样淡入。整个过程就像是一个没有感情的机器人在读秒。
为什么?因为 React 是单页应用(SPA),路由切换本质上是销毁旧组件,渲染新组件。在这个过程中,DOM 节点被移除,新节点被插入。浏览器对此无能为力,它只能重绘。
于是,我们这些前端工程师就开始了“手工补完计划”:
- CSS 隐藏旧组件,然后
setTimeout。 - 渲染新组件,然后让新组件慢慢显示。
- 手动计算滚动位置(别忘了!),否则用户会掉到页面最底下。
- 处理状态保持(比如选中的 Tab)。
这简直是噩梦。而且,这种手工方式有一个致命的缺陷:它无法处理布局动画。你无法在旧按钮消失和新按钮出现之间建立视觉联系,因为它们根本不在同一个 DOM 树里。
我们想要的是什么呢?我们想要的是 Native。就像你从 A 页面滑动到 B 页面,那是 iOS 原生应用的质感。现在,浏览器终于要把这个能力开放给 Web 了。
第二章:View Transitions API 是什么鬼?
简单来说,View Transitions API 是一个让浏览器在页面状态变化时,自动创建“视图过渡”的机制。
当你调用 document.startViewTransition() 时,浏览器会做两件事:
- 快照:把当前页面的当前状态拍一张“照片”。
- 更新:执行你的路由逻辑(更新 DOM)。
- 合成:浏览器把“照片”和“新状态”合成一张 GIF,然后把这个 GIF 播放给你看。
这听起来像 CSS 动画,但实际上,它是浏览器的渲染引擎在干活。它允许你控制过渡的流程,同时让 CSS 来控制过渡的样式。
最棒的是什么?它不需要任何第三方库。不需要 Framer Motion,不需要 GSAP。你只需要 React 和浏览器原生 API。
第三章:Hello World —— 最基础的 React 路由切换
让我们先从一个最简单的例子开始。假设我们有一个简单的路由配置,点击链接时触发过渡。
注意,这里我们不使用 react-router 的 <Link> 组件的默认行为(即 navigate),而是要用 startViewTransition 包裹导航逻辑。
// App.js
import { useState } from 'react';
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
// 模拟两个页面组件
const Home = () => {
return (
<div style={{ padding: '20px', background: '#f0f0f0', minHeight: '100vh' }}>
<h1>我是首页</h1>
<p>点击下面的链接,体验原生级别的切换。</p>
<button onClick={() => navigate('/about')}>去关于我们</button>
</div>
);
};
const About = () => {
return (
<div style={{ padding: '20px', background: '#e0e0e0', minHeight: '100vh' }}>
<h1>我是关于页</h1>
<p>现在,点击返回。</p>
<button onClick={() => navigate('/')}>返回首页</button>
</div>
);
};
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const handleNavigate = (to) => {
// 关键点:使用 startViewTransition 包装导航逻辑
if (document.startViewTransition) {
document.startViewTransition(() => {
navigate(to);
});
} else {
// 降级处理:如果不支持,直接导航
navigate(to);
}
};
return (
<Routes location={location}>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
);
}
看懂了吗? 这就是魔法发生的地方。document.startViewTransition 接收一个回调函数,这个回调函数里执行的是正常的 navigate。
如果你在 Chrome 里运行这段代码,你会发现页面并没有生硬地刷新,而是有一个淡入淡出的效果。这就是浏览器默认的视图过渡。它默认给整个根节点(<body> 或最外层容器)添加了一个过渡效果。
但是,这还不够酷。默认的过渡效果通常只是简单的淡入淡出,我们能不能做得更高级一点?
第四章:布局动画 —— 这才是真正的“专家级”
这是 View Transitions API 最强的地方。它允许你控制 DOM 元素的过渡。
想象一下,你有一个导航栏,在首页显示“首页”,在关于页显示“关于我们”。在传统的 SPA 中,这两个按钮会完全消失,然后新按钮出现。
但在 View Transitions API 中,如果旧按钮和新按钮在 DOM 结构中是兄弟节点(或者有相同的 ID),浏览器会自动把它们关联起来,并播放一个布局动画。
怎么做?只需要给它们起个名字。
// App.js (修改版)
import { useState } from 'react';
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
const Navbar = ({ title }) => {
return (
<nav style={{ padding: '20px', background: '#333', color: '#fff' }}>
<h2>{title}</h2>
</nav>
);
};
const Home = () => {
return (
<div>
<Navbar title="首页" />
<div style={{ padding: '20px' }}>
<p>这是首页内容...</p>
</div>
</div>
);
};
const About = () => {
return (
<div>
<Navbar title="关于我们" />
<div style={{ padding: '20px' }}>
<p>这是关于页内容...</p>
</div>
</div>
);
};
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const handleNavigate = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => {
navigate(to);
});
} else {
navigate(to);
}
};
return (
<Routes location={location}>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
);
}
等等,代码没变。为什么会有动画?因为我们在 React 中,通常会在组件内部使用 useEffect 来动态设置 CSS 属性。
我们需要给导航栏的标题加上 view-transition-name。
// 修改 Navbar 组件
const Navbar = ({ title }) => {
const navRef = React.useRef(null);
React.useEffect(() => {
if (navRef.current) {
// 给导航栏元素起个名字
navRef.current.style.viewTransitionName = 'nav-transition';
}
}, [title]);
return (
<nav ref={navRef} style={{ padding: '20px', background: '#333', color: '#fff' }}>
<h2>{title}</h2>
</nav>
);
};
现在,我们需要在 CSS 里定义这个名字对应的动画规则。这是最有趣的部分。
/* App.css */
/* 定义视图过渡组。'nav-transition' 是我们在 JS 里起的名字 */
::view-transition-group(nav-transition) {
animation-duration: 0.5s;
}
/* 定义旧视图(旧标题)的动画 */
::view-transition-old(nav-transition) {
/* 假设我们要做一个简单的缩放淡出 */
animation: fade-out 0.5s ease-in forwards;
}
/* 定义新视图(新标题)的动画 */
::view-transition-new(nav-transition) {
/* 假设我们要做一个简单的缩放淡入 */
animation: fade-in 0.5s ease-out forwards;
}
@keyframes fade-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.9); }
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
效果:
当你从首页跳转到关于页时,你会发现那个“首页”并没有直接消失,而是缩小、变淡,同时“关于我们”从同一个位置缩小、变淡地出现。这就是布局动画。它保留了视觉上下文的连续性,让用户感觉页面是在“变形”,而不是“重置”。
第五章:进阶挑战 —— 动态视图名称
上面的例子中,标题是固定的。但在真实的应用中,你可能有一个列表,列表里的每一项都有不同的 ID。
比如,你有一个博客列表,点击“文章 A”,进入文章详情页。列表中的“文章 A”卡片和详情页中的标题卡片,应该建立过渡连接。
这需要动态的 view-transition-name。
// DetailPage.js
const DetailPage = ({ postId, title }) => {
const titleRef = React.useRef(null);
React.useEffect(() => {
if (titleRef.current) {
// 关键:使用唯一的 ID 作为名字
titleRef.current.style.viewTransitionName = `post-${postId}`;
}
}, [postId, title]);
return (
<div>
<h1 ref={titleRef}>{title}</h1>
<p>文章详情内容...</p>
</div>
);
};
// ListPage.js
const PostItem = ({ post }) => {
return (
<div onClick={() => navigate(`/post/${post.id}`)}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
);
};
现在,你需要在 CSS 里定义针对不同 ID 的样式。你可以使用 CSS 变量或者 view-transition-name 的属性选择器。
/* 定义一个通用的动画规则 */
::view-transition-group(*-transition) {
animation-duration: 0.5s;
}
::view-transition-old(*-transition) {
animation: slide-out 0.5s ease-in forwards;
}
::view-transition-new(*-transition) {
animation: slide-in 0.5s ease-out forwards;
}
@keyframes slide-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-50px); }
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(50px); }
to { opacity: 1; transform: translateX(0); }
}
这里用了一个通配符 *-transition 来匹配所有以 -transition 结尾的名字。这样,你就不需要为每一篇文章写一遍 CSS 了。这展示了 View Transitions API 的强大灵活性。
第六章:React Router 的“水合”噩梦
好了,现在我们已经掌握了基础和进阶。但如果你在 React 中使用 SSR(服务端渲染),比如 Next.js,事情会变得有点复杂。
为什么?因为 React 的 Hydration 机制。当服务器返回 HTML 时,浏览器需要把 HTML 和客户端的 JavaScript 重新匹配。如果你在 useEffect 里操作 DOM 样式,可能会在 Hydration 阶段导致错误。
场景:
服务器渲染的 HTML 里,<h1> 标签还没有 view-transition-name 属性。
客户端 JS 执行了 useEffect,给 <h1> 加上了属性。
浏览器检测到不匹配,报错。
解决方案:
我们需要确保在 Hydration 阶段不要去操作 DOM。我们可以使用一个检查来绕过这个问题,或者更简单的方法,使用 useRef 来存储状态,只在客户端挂载后应用样式。
// DetailPage.js (SSR 友好版)
const DetailPage = ({ postId, title }) => {
const titleRef = React.useRef(null);
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => {
setIsMounted(true);
if (titleRef.current) {
titleRef.current.style.viewTransitionName = `post-${postId}`;
}
}, [postId, title]);
// 只有在客户端挂载后才渲染带样式的 DOM
// 或者,我们可以直接在 render 中设置 style,但要注意 SSR 的空白值问题
return (
<div>
{/* 这里有个技巧:直接在 JSX 中设置 style,React 会处理 Hydration */}
<h1
ref={titleRef}
style={isMounted ? { viewTransitionName: `post-${postId}` } : {}}
>
{title}
</h1>
<p>文章详情内容...</p>
</div>
);
};
另一种更优雅的方式是使用 useNavigationType 来判断是第一次渲染还是路由切换。
const useViewTransitionName = (name) => {
const location = useLocation();
const navigationType = useNavigationType();
// 只有在 'POP' (后退) 或 'PUSH' (前进) 时才应用动画
// 或者只在页面加载完成后应用
return location.state?.viewTransition ? name : null;
};
第七章:滚动位置 —— 那个总是被遗忘的 Bug
这是最经典、最令人抓狂的 Bug。
当你使用 startViewTransition 时,浏览器会自动管理滚动位置。它会自动滚动到页面顶部。如果你在旧页面滚动到了 50% 的位置,跳转到新页面时,页面会瞬间跳回顶部。
为什么? 因为浏览器认为页面“重置”了。
如何修复?
在 startViewTransition 的回调函数里,手动滚动到旧位置。
const handleNavigate = (to) => {
if (document.startViewTransition) {
// 1. 保存当前的滚动位置
const scrollX = window.scrollX;
const scrollY = window.scrollY;
document.startViewTransition(() => {
// 2. 在视图过渡开始前,先执行导航,并恢复滚动
navigate(to);
window.scrollTo(scrollX, scrollY);
});
} else {
navigate(to);
}
};
等等,这里有个陷阱!
如果你使用了布局动画,比如上面的导航栏过渡,浏览器可能会根据新的 DOM 结构重新计算布局。如果你在导航之前就滚动到了旧位置,新页面的布局可能会发生偏移,导致滚动位置不准确。
高级修复方案:
我们需要在过渡开始前,先计算出旧位置的偏移量,然后在过渡结束后,将页面滚动到新的正确位置。
const handleNavigate = (to) => {
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
navigate(to);
});
// 监听过渡结束事件
transition.finished.then(() => {
// 简单的防抖,确保滚动位置正确
setTimeout(() => {
window.scrollTo(0, 0); // 或者 window.scrollTo(window.scrollX, window.scrollY);
}, 50);
});
} else {
navigate(to);
}
};
第八章:实战演练 —— 一个完整的路由包装器
让我们把这些知识整合起来。我们需要一个包装器,它不仅处理路由切换,还处理滚动位置,还处理布局动画。
我给你们写一个稍微复杂一点的组件,你可以直接复制到你的项目中。
// ViewTransitionRouter.js
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { useNavigationType } from 'react-router-dom';
export const ViewTransitionRouter = ({ children, routes }) => {
const location = useLocation();
const navigate = useNavigate();
const navigationType = useNavigationType();
// 我们需要在这个组件内部处理路由逻辑
// 这样我们才能在 navigate 周围包裹 startViewTransition
const handleNavigate = (path) => {
if (document.startViewTransition) {
// 保存滚动位置
const scrollX = window.scrollX;
const scrollY = window.scrollY;
document.startViewTransition(() => {
navigate(path);
// 注意:这里先导航,再滚动。如果使用了布局动画,可能需要微调
window.scrollTo(scrollX, scrollY);
});
} else {
navigate(path);
}
};
// 渲染路由表
return (
<Routes location={location}>
{routes.map((route, index) => (
<Route
key={index}
path={route.path}
element={route.element}
/>
))}
</Routes>
);
};
使用方式:
// App.js
import { ViewTransitionRouter } from './ViewTransitionRouter';
import Home from './Home';
import About from './About';
function App() {
return (
<ViewTransitionRouter
routes={[
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
]}
/>
);
}
第九章:CSS 的艺术 —— 掌控每一帧
现在,让我们谈谈 CSS。View Transitions API 生成了大量的伪元素。如果你能理解这些伪元素,你就掌握了动画的生杀大权。
::view-transition-old(root): 整个页面的旧视图。::view-transition-new(root): 整个页面的新视图。::view-transition-group(name): 对应特定名字的元素的动画组。::view-transition-image-paint(name): 对应特定名字的元素的图像绘制(高级用法)。
你可以用 CSS 控制整个过渡的流程。
例子:一个“电影转场”效果
/* 整个页面黑屏,然后淡入 */
::view-transition-old(root) {
opacity: 1;
}
::view-transition-new(root) {
animation: 0.5s ease-out fade-in;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* 元素特定的动画 */
::view-transition-group(section) {
animation-duration: 0.3s;
}
/* 元素飞入 */
::view-transition-new(section) {
animation: 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) slide-in-right;
}
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
通过这种方式,你可以创造出非常复杂的转场效果,比如页面从右侧滑入,旧页面从左侧滑出,同时中间的元素保持不动。
第十章:性能与兼容性 —— 现实的考量
虽然 View Transitions API 很棒,但我们不能忽视现实。
兼容性:
目前,这个 API 主要在 Chrome 111+, Android 111+ 上支持良好。Safari 和 Firefox 仍在开发中。如果你需要支持 IE 或旧版 Safari,你必须提供一个降级方案(比如我们之前写的 else { navigate(to); })。
性能:
这个 API 是基于 GPU 合成的,所以性能非常好。但是,如果你在过渡过程中执行了大量的 JavaScript 计算,会导致掉帧。记得把过渡期间的重计算逻辑移到 startViewTransition 的回调之外。
内存:
每次过渡都会创建新的伪元素。如果你在一个页面里使用了大量的 view-transition-name,可能会导致 DOM 节点增多。但在现代浏览器中,这通常不是问题。
结语:拥抱原生
好了,各位。今天我们聊了很多。
我们从 React Router 的 useEffect 陷阱出发,一路走到了 View Transitions API 的魔法世界。我们学会了如何给 DOM 元素起名字,如何定义 CSS 动画,如何处理 SSR 和滚动位置。
View Transitions API 不仅仅是一个动画库,它代表了 Web 开发的一个新方向:让浏览器来做它最擅长的事情——渲染。
我们不再需要手动计算 transform,不再需要手动管理 z-index,不再需要为了一个简单的页面切换而写几百行代码。我们只需要告诉浏览器:“嘿,我要切换视图了,帮我渲染一下。”
这感觉就像是从手工修车变成了开自动驾驶汽车。你依然可以掌控方向,但引擎已经替你处理了所有的颠簸和摩擦。
所以,下一次当你准备写那个 setTimeout 来做淡入淡出的时候,请停下来,深吸一口气,想一想 View Transitions API。它就在那里,静静地等着你去唤醒它。
祝大家编码愉快,动画丝滑!
(讲师退场,掌声雷动)