React 与 View Transitions API:在 React 路由切换中实现原生级别的跨页面视图平滑过渡动画

各位前端同仁,大家下午好。

我是你们今天的讲师。把你们的笔记本电脑都合上,哪怕是为了看这个,也请合上。我们要聊的是一件让无数 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 节点被移除,新节点被插入。浏览器对此无能为力,它只能重绘。

于是,我们这些前端工程师就开始了“手工补完计划”:

  1. CSS 隐藏旧组件,然后 setTimeout
  2. 渲染新组件,然后让新组件慢慢显示。
  3. 手动计算滚动位置(别忘了!),否则用户会掉到页面最底下。
  4. 处理状态保持(比如选中的 Tab)。

这简直是噩梦。而且,这种手工方式有一个致命的缺陷:它无法处理布局动画。你无法在旧按钮消失和新按钮出现之间建立视觉联系,因为它们根本不在同一个 DOM 树里。

我们想要的是什么呢?我们想要的是 Native。就像你从 A 页面滑动到 B 页面,那是 iOS 原生应用的质感。现在,浏览器终于要把这个能力开放给 Web 了。


第二章:View Transitions API 是什么鬼?

简单来说,View Transitions API 是一个让浏览器在页面状态变化时,自动创建“视图过渡”的机制。

当你调用 document.startViewTransition() 时,浏览器会做两件事:

  1. 快照:把当前页面的当前状态拍一张“照片”。
  2. 更新:执行你的路由逻辑(更新 DOM)。
  3. 合成:浏览器把“照片”和“新状态”合成一张 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。它就在那里,静静地等着你去唤醒它。

祝大家编码愉快,动画丝滑!

(讲师退场,掌声雷动)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注