注册
web

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

在开发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 的浏览器仍能正常切换。

作者:张海潮
来源:juejin.cn/post/7546326670648328219

0 个评论

要回复文章请先登录注册