普通视图

发现新文章,点击刷新页面。
昨天以前首页

面试官:手写一个深色模式切换过渡动画

作者 张海潮
2025年9月5日 09:55

在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。

读完本文,你将掌握:

  1. 使用View Transitions API实现流畅的主题切换动画
  2. 理解深色模式切换的核心原理与实现细节
  3. 能够将这套方案应用到实际项目中,提升用户体验
image.png

前言

在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。

随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:

  • 了解 document.startViewTransition 的工作原理
  • 学会用 clipPath + animate 控制圆形扩散动画

核心铺垫:我们需要解决什么问题?

在设计方案前,先明确 3 个核心目标:

  1. 流畅过渡:避免普通 transition 的“整体闪烁”,实现局部扩散过渡。
  2. 交互感强:以用户点击位置为动画圆心,符合直觉。
  3. 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。

为此,我们需要用到几个关键技术点:

  • View Transitions API:提供 document.startViewTransition,可以对 DOM 状态切换设置过渡动画。
  • clip-path:通过 circle(r at x y) 定义动画圆形,从 0px 扩展到最大半径。
  • computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。
  • .animate:使用 document.documentElement.animate 精确控制过渡过程。

Math.hypot:计算平面上点到原点的距离

Math.hypot()是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。

image.png

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:

斜边计算

Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离

image.png

clip-path

recording.gif

clip-path是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。

<basic-shape>一种形状,其大小和位置由 <geometry-box> 的值定义。如果没有指定 <geometry-box>,则将使用 border-box 用为参考框。取值可为以下值中的任意一个:

  • inset()

    定义一个 inset 矩形。

  • circle()

    定义一个圆形(使用一个半径和一个圆心位置)。

  • ellipse()

    定义一个椭圆(使用两个半径和一个圆心位置)。

  • polygon()

    定义一个多边形(使用一个 SVG 填充规则和一组顶点)。

  • path()

    定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。

这里使用circle()来实现效果

该函数接受以下参数:

  • 半径:定义圆形的大小(0px到计算的最大半径)
  • at关键词:分隔半径和中心点位置
  • 中心点位置:使用x y坐标指定圆形中心

startViewTransition:浏览器视图转换API

基本概念

document.startViewTransition()是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。

生命周期与关键事件

  1. 调用startViewTransition:浏览器准备开始视图转换
  2. 执行回调函数:DOM状态更新
  3. transition.ready事件:视图转换准备就绪,可以应用动画
  4. 视图转换完成:动画结束,新状态成为稳定状态

浏览器兼容性处理

在实际应用中,我们需要检查浏览器是否支持此API:

const isAppearanceTransition =
    document.startViewTransition &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
    // 不支持View Transitions API时的降级处理
    isDark.value = !isDark.value;
    setupThemeClass(isDark.value);
    return;
}

这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。

核心实现:从逻辑到代码

graph TD

    A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
    B -- 否 --> C[直接切换主题变量<br/>无动画效果]
    B -- 是 --> D[获取点击坐标X,Y]
    D --> E[计算覆盖全屏的最大半径]
    E --> F[启动视图转换]
    F --> G[执行回调函数<br/>更新isDark状态]
    G --> H[设置HTML的dark class<br/>更新CSS变量]
    H --> I[等待DOM更新完成<br/>nextTick]
    I --> J[视图转换准备就绪]
    J --> K[应用clipPath动画<br/>从点击点向外扩散]
    K --> L[动画完成<br/>主题切换完成]
    
    style B fill:#f9f,stroke:#333,stroke-width:2px
    style K fill:#9cf,stroke:#333,stroke-width:2px
  1. 用户交互:用户点击切换按钮,触发主题切换流程

  2. 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API

  3. 降级处理:在不支持API的浏览器中直接切换主题

  4. 动画核心逻辑

    • 获取点击位置作为动画起点
    • 计算覆盖全屏的最大半径
    • 启动视图转换过程
  5. 状态更新:实际执行主题状态更新和CSS类设置

  6. 动画触发:在视图转换准备就绪后,应用clipPath动画效果

  7. 完成:动画结束,新主题状态稳定

步骤 1:封装主题切换

    function setupThemeClass(isDark) {
      document.documentElement.classList.toggle("dark", isDark);
      localStorage.setItem("theme", isDark ? "dark" : "light");
    }

作用:控制 html.dark 类名,完成主题切换。


步骤 2:计算扩散最大半径

    function computeMaxRadius(x, y) {
      const maxX = Math.max(x, window.innerWidth - x);
      const maxY = Math.max(y, window.innerHeight - y);
      return Math.hypot(maxX, maxY); // √(maxX² + maxY²)
    }
    

作用:确保无论点击哪里,扩散圆都能覆盖屏幕。


步骤 3:触发 View Transition

    function onToggleClick(event) {
      const isSupported =
        document.startViewTransition &&
        !window.matchMedia("(prefers-reduced-motion: reduce)").matches;

      if (!isSupported) {
        // 回退方案:直接切换
        isDark.value = !isDark.value;
        setupThemeClass(isDark.value);
        return;
      }

      const x = event.clientX;
      const y = event.clientY;
      const endRadius = computeMaxRadius(x, y);

      // 开启视图过渡
      const transition = document.startViewTransition(async () => {
        isDark.value = !isDark.value;
        setupThemeClass(isDark.value);
        await nextTick(); // 等 Vue DOM 更新
      });

      transition.ready.then(() => {
        const clipPath = [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ];

        document.documentElement.animate(
          {
            clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
          },
          {
            duration: 450,
            easing: "ease-in",
            pseudoElement: isDark.value
              ? "::view-transition-old(root)"
              : "::view-transition-new(root)",
          }
        );
      });
    }

要点:

*startViewTransition 接收一个回调函数,里面执行 DOM 更新(切换主题)。

*transition.ready.then(...) 可以在 DOM 更新后定义动画效果。

*clipPath 数组定义了从 小圆 → 大圆 的扩散过程。

*pseudoElement 控制是对 新视图 还是 旧视图 应用动画。


步骤 4:覆盖默认过渡样式


    ::view-transition-new(root),
    ::view-transition-old(root) {
      animation: none;
      mix-blend-mode: normal;
    }

    ::view-transition-old(root) {
      z-index: 1;
    }

    ::view-transition-new(root) {
      z-index: 2147483646;
    }

    html.dark::view-transition-old(root) {
      z-index: 2147483646;
    }

    html.dark::view-transition-new(root) {
      z-index: 1;
    }

作用:取消默认动画,手动用 clipPath 控制。通过 z-index 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。


效果演示

recording.gif

运行后:

  • 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。
  • 若浏览器不支持 View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。

完整demo


延伸与避坑

  1. 兼容性问题

    • View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。
    • 可加上 isSupported 判断,优雅降级。
  2. 性能优化

    • 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。
    • clip-path 本身是 GPU 加速属性,性能较好。
  3. 扩展思路

    • 除了圆形扩散,还可以用 polygon() 实现“百叶窗切换”或“对角线切换”。
    • 可以结合 路由切换 做“页面级过渡动画”。

总结

本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:

  • startViewTransition:声明 DOM 状态切换的动画上下文。
  • clipPath + animate:控制过渡动画形状与过程。
  • computeMaxRadius:计算圆形覆盖全屏的半径。
  • 优雅降级:确保不支持 API 的浏览器仍能正常切换。
❌
❌