新手入门:实现聚焦式主题切换动效(Vue3 + Pinia + View Transitions)

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站

文章目录

新手入门:实现聚焦式主题切换动效(Vue3 + Pinia + View Transitions)

一、功能介绍

我们要实现的是「聚焦式主题切换动效」------ 点击主题切换按钮时,以点击位置为中心,像水波纹一样扩散 / 收缩切换亮 / 暗两种主题,同时兼容不支持新 API 的浏览器,确保功能稳定。

核心亮点:

  • 动画跟随点击位置,交互感强
  • 基于原生 View Transitions API,性能优异
  • 用 Pinia 管理主题状态,全局响应
  • 支持主题持久化(刷新不丢失)
  • 完善的降级方案,兼容性好

核心依赖技术

1. 核心 API:View Transitions API(视图过渡 API)
  • 定位:Web 原生 API(Chrome 111+、Edge 111+、Opera 97+ 支持),用于在 DOM 状态变化(如主题切换、页面跳转)时创建 "新旧状态快照" 的过渡动画。
  • 作用:替代传统的 "手动隐藏 / 显示元素""添加过渡类" 等繁琐逻辑,浏览器自动处理快照捕获、动画过渡、快照销毁,简化开发。
2. 核心 CSS 技术:clip-path 裁剪路径
  • 定位:CSS 基础属性,用于 "裁剪" 元素的可见区域,仅显示指定路径内的内容。
  • 作用 :实现 "圆形扩散 / 收缩" 效果的核心 ------ 通过 circle() 函数定义圆形裁剪区域,动态修改圆形的半径和中心点,模拟 "从点击位置扩散 / 收缩" 的视觉效果。
3. 辅助技术:CSS 伪元素(::view-transition-new/::view-transition-old
  • 定位:View Transitions API 自动生成的 "快照伪元素",用于承载新旧状态的 DOM 快照。

  • 作用:

    • ::view-transition-old(root):承载 "旧状态" 的快照(如切换前的亮色主题);
    • ::view-transition-new(root):承载 "新状态" 的快照(如切换后的暗色主题);
    • 开发者通过控制这两个伪元素的层级、透明度、动画,实现复杂过渡效果。

二、前置准备

1. 技术栈要求

  • Vue3(Composition API + <script setup>
  • Pinia(状态管理)
  • CSS 变量(主题配色)
  • 可选:Element Plus(UI 组件)、IconFont(图标)

2. 环境搭建

如果是新项目,先初始化 Vue3 项目并安装依赖:

bash 复制代码
# 创建Vue3项目
npm create vue@latest theme-demo
cd theme-demo
npm install

# 安装必要依赖
npm install pinia  # 状态管理
npm install element-plus  # 可选,UI组件

三、分步实现(新手友好)

第一步:创建主题状态管理(Pinia)

首先创建 Pinia 的主题模块,负责主题的切换、存储、初始化。

  1. src/store/modules/目录下新建theme.ts(如果没有modules文件夹就手动创建):
typescript 复制代码
// src/store/modules/theme.ts
import { defineStore } from 'pinia'

// 定义主题类型:仅支持light/dark两种
type ThemeType = 'light' | 'dark'

// 创建并导出主题Store
export const useThemeStore = defineStore('theme', {
  state: () => ({
    currentTheme: 'light' as ThemeType, // 默认亮色主题
    isChanging: false, // 标记是否正在切换主题(防止重复点击)
    clickPosition: { x: 0, y: 0 }, // 记录点击坐标(用于动画起始位置)
  }),

  actions: {
    // 1. 记录点击位置(点击主题按钮时调用)
    setClickPosition(x: number, y: number) {
      this.clickPosition = { x, y }
    },

    // 2. 核心:切换主题并触发动画
    setTheme(theme: ThemeType) {
      // 如果当前就是目标主题,直接返回(避免重复切换)
      if (this.currentTheme === theme) return

      this.isChanging = true // 标记开始切换

      // 检查浏览器是否支持View Transitions API(现代浏览器支持)
      if (typeof document !== 'undefined' && document.startViewTransition) {
        // 启动原生过渡动画
        const transition = document.startViewTransition(() => {
          // 动画期间执行:更新主题、存储到本地、同步根元素属性
          this.currentTheme = theme
          this.syncThemeToStorage(theme)
          this.updateRootElementTheme(theme)
        })

        // 动画准备就绪后,添加聚焦扩散/收缩效果
        transition.ready.then(() => {
          const { x, y } = this.clickPosition
          // 计算从点击点到屏幕对角的最大距离(确保动画覆盖全屏)
          const radius = Math.hypot(
            Math.max(x, window.innerWidth - x),
            Math.max(y, window.innerHeight - y)
          )

          // 裁剪路径:从0px扩散到最大半径(或反向收缩)
          const clipPath = [
            `circle(0px at ${x}px ${y}px)`,
            `circle(${radius}px at ${x}px ${y}px)`
          ]

          // 暗色主题:新主题扩散;亮色主题:旧主题收缩
          // 变暗时使用扩散动画,变亮时使用收缩动画
          const isDark = theme === 'dark'
          const pseudoElementSelector = isDark
            ? '::view-transition-new(root)' // 暗色主题用新元素扩散
            : '::view-transition-old(root)' // 亮色主题用旧元素收缩

          // 动画关键帧:扩散或收缩
          const keyframes = isDark
            ? [{ clipPath: clipPath[0] }, { clipPath: clipPath[1] }] // 扩散
            : [{ clipPath: clipPath[1] }, { clipPath: clipPath[0] }] // 收缩
          
          // 👇 修改后代码(翻转逻辑)
          // 变暗时使用收缩动画,变亮时使用扩散动画(注意:需要跟css配合才能做到真正的切换)
          // const isDark = theme === 'dark'
          // const pseudoElementSelector = isDark
          //     ? '::view-transition-old(root)'  // 暗色用旧元素收缩
          //     : '::view-transition-new(root)'  // 亮色用新元素扩散

          // const keyframes = isDark
          //     ? [{ clipPath: clipPath[1] }, { clipPath: clipPath[0] }] // 暗色:收缩(最大半径→0)
          //     : [{ clipPath: clipPath[0] }, { clipPath: clipPath[1] }] // 亮色:扩散(0→最大半径)


          // 执行动画
          document.documentElement.animate(keyframes, {
            duration: 600, // 动画时长600ms
            easing: 'cubic-bezier(0.4, 0, 0.2, 1)', // 缓动效果(自然过渡)
            fill: 'both', // 保持动画结束状态
            pseudoElement: pseudoElementSelector // 作用于过渡伪元素
          })
        }).catch(err => {
          console.error('动画准备失败:', err)
        })

        // 动画结束后,重置切换状态
        transition.finished.then(() => {
          this.isChanging = false
        }).catch(err => {
          console.error('动画执行失败:', err)
          this.isChanging = false // 即使失败也重置状态
        })
      } else {
        // 浏览器不支持View Transitions:降级方案(直接切换无动画)
        console.log('浏览器不支持,使用降级方案')
        this.currentTheme = theme
        this.syncThemeToStorage(theme)
        this.updateRootElementTheme(theme)
        this.isChanging = false
      }
    },

    // 3. 辅助:将主题存储到本地(刷新不丢失)
    syncThemeToStorage(theme: ThemeType) {
      try {
        // uni-app用uni.setStorageSync,普通Vue用localStorage
        if (typeof uni !== 'undefined') {
          uni.setStorageSync('app_theme', theme)
        } else {
          localStorage.setItem('app_theme', theme)
        }
      } catch (e) {
        console.error('主题存储失败:', e)
      }
    },

    // 4. 辅助:更新根元素的data-theme属性(用于CSS主题切换)
    updateRootElementTheme(theme: ThemeType) {
      if (typeof document !== 'undefined') {
        document.documentElement.setAttribute('data-theme', theme)
      }
    },

    // 5. 初始化主题(页面加载时调用)
    initTheme() {
      try {
        // 从本地存储读取主题
        let storedTheme: ThemeType = 'light'
        if (typeof uni !== 'undefined') {
          storedTheme = uni.getStorageSync('app_theme') as ThemeType
        } else {
          storedTheme = localStorage.getItem('app_theme') as ThemeType
        }

        // 验证存储的主题是否合法,合法则使用,否则默认亮色
        if (['light', 'dark'].includes(storedTheme)) {
          this.currentTheme = storedTheme
          this.updateRootElementTheme(storedTheme)
        } else {
          this.currentTheme = 'light'
          this.syncThemeToStorage('light')
          this.updateRootElementTheme('light')
        }
      } catch (e) {
        console.error('主题初始化失败:', e)
        this.currentTheme = 'light' // 异常时默认亮色
        this.syncThemeToStorage('light')
        this.updateRootElementTheme('light')
      }
    }
  }
})
  1. 初始化 Pinia:在src/main.ts中引入并使用 Pinia:
typescript 复制代码
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 引入Pinia
import App from './App.vue'
import './styles/theme.css' // 后续会创建的主题样式文件

const app = createApp(App)
app.use(createPinia()) // 使用Pinia
app.mount('#app')

第二步:创建主题样式文件(CSS 变量)

创建全局主题样式,用 CSS 变量定义亮 / 暗两种主题的配色,配合 View Transitions 实现动画。

src/styles/目录下新建theme.css

css 复制代码
/* src/styles/theme.css */
/* 1. 关键:启用View Transitions快照容器(必须) */
:root {
  view-transition-name: root; /* 指定根元素为过渡快照目标 */
}

/* 2. 配置过渡伪元素基础样式(避免动画异常) */
::view-transition-new(root),
::view-transition-old(root) {
  animation: none !important; /* 禁用默认动画,用自定义裁剪 */
  mix-blend-mode: normal; /* 关闭颜色混合,避免错乱 */
  opacity: 1 !important; /* 强制不透明 */
  width: 100vw !important; /* 占满全屏 */
  height: 100vh !important;
  position: fixed !important;
  top: 0 !important;
  left: 0 !important;
}

/* 3. 主题层级控制(确保动画方向正确) */
/* 变暗时使用扩散动画,变亮时使用收缩动画 */
/* 暗色主题:新主题(深底)在上扩散,旧主题(白底)在下 */
:root[data-theme="dark"]::view-transition-new(root) {
  z-index: 100 !important;
  background-color: #121212 !important;
}
:root[data-theme="dark"]::view-transition-old(root) {
  z-index: 99 !important;
  background-color: #ffffff !important;
}

/* 亮色主题:旧主题(深底)在上收缩,新主题(白底)在下 */
:root[data-theme="light"]::view-transition-old(root) {
  z-index: 100 !important;
  background-color: #121212 !important;
}
:root[data-theme="light"]::view-transition-new(root) {
  z-index: 99 !important;
  background-color: #ffffff !important;
}


/* 👇 修改后代码(翻转层级) */
/* 变暗时使用收缩动画,变亮时使用扩散动画 */
/* 暗色主题:旧主题(白底)在上收缩,新主题(深底)在下 */
:root[data-theme="dark"]::view-transition-old(root) {
  z-index: 100 !important; /* 旧主题(亮色)在上,收缩消失 */
  background-color: #ffffff !important;
}
:root[data-theme="dark"]::view-transition-new(root) {
  z-index: 99 !important; /* 新主题(暗色)在下,等待露出 */
  background-color: #121212 !important;
}

/* 亮色主题:新主题(白底)在上扩散,旧主题(深底)在下 */
:root[data-theme="light"]::view-transition-new(root) {
  z-index: 100 !important; /* 新主题(亮色)在上,扩散覆盖 */
  background-color: #ffffff !important;
}
:root[data-theme="light"]::view-transition-old(root) {
  z-index: 99 !important; /* 旧主题(暗色)在下,被覆盖 */
  background-color: #121212 !important;
}


/* 4. 亮色主题配色(默认) */
:root {
  /* 主题色(绿色系) */
  --primary-color: #43de7e;
  --primary-hover: #36eb9d;
  --primary-active: #2fc46d;

  /* 背景色 */
  --bg-main: #ffffff;
  --bg-sidebar: #f0f9f2;
  --bg-menu-hover: #e6f7ed;

  /* 文字色 */
  --text-main: #2d3748;
  --text-secondary: #4a5568;

  /* 顶部栏背景 */
  --header-bg: #f0f9f2;

  /* 覆盖Element Plus默认样式(可选) */
  --el-color-primary: var(--primary-color);
  --el-bg-color: var(--bg-main);
  --el-text-color-primary: var(--text-main);
}

/* 5. 暗色主题配色 */
:root[data-theme="dark"] {
  --primary-color: #43de7e; /* 保持主题色不变,更突出 */
  --primary-hover: #36eb9d;
  --primary-active: #2fc46d;

  --bg-main: #121212;
  --bg-sidebar: #16251f;
  --bg-menu-hover: #23372b;

  --text-main: #f0fdf4;
  --text-secondary: #d1fae5;

  --header-bg: #16251f;

  --el-color-primary: var(--primary-color);
  --el-bg-color: var(--bg-main);
  --el-text-color-primary: var(--text-main);
}

/* 6. 动画过渡(确保主题切换时样式平滑变化) */
:root {
  transition:
    --primary-color 0.3s ease,
    --bg-main 0.3s ease,
    --text-main 0.3s ease;
}

第三步:实现主题切换按钮(页面组件)

在需要添加主题切换的页面(比如首页index.vue)中,添加亮 / 暗切换按钮并绑定事件。

vue 复制代码
<template>
  <!-- 主题切换过渡遮罩(优化视觉效果) -->
  <div class="theme-transition-mask" :class="{ active: isThemeChanging }"></div>
  
  <!-- 页面布局(仅展示核心部分,其他布局可保留) -->
  <div class="header flex items-center justify-between p-4">
    <h1>我的应用</h1>
    <!-- 主题切换按钮区域 -->
    <div class="theme-switcher">
      <!-- 亮/暗色切换按钮 -->
      <i 
        v-if="themeStore.currentTheme === 'light'"
        class="iconfont icon-anse cursor-pointer"
        @click.stop="handleThemeChange($event, 'dark')"
        title="切换暗色主题"
      ></i>
      <i 
        v-else
        class="iconfont icon-a-Frame48 cursor-pointer"
        @click.stop="handleThemeChange($event, 'light')"
        title="切换亮色主题"
      ></i>
    </div>
  </div>
</template>

<script setup lang="ts">
// 1. 引入必要依赖
import { ref, watch } from 'vue'
import { useThemeStore } from '@/store/modules/theme'

// 2. 初始化主题Store
const themeStore = useThemeStore()
// 页面加载时初始化主题(从本地存储读取)
themeStore.initTheme()

// 3. 主题切换相关状态
const isThemeChanging = ref(false) // 控制过渡遮罩显示

// 4. 核心:处理主题切换事件
const handleThemeChange = (event: MouseEvent, theme: 'light' | 'dark') => {
  // 记录点击坐标(用于动画起始位置)
  themeStore.setClickPosition(event.clientX, event.clientY)
  // 触发主题切换(Pinia中的setTheme方法)
  themeStore.setTheme(theme)
}

// 5. 监听主题变化,显示过渡遮罩
watch(
  () => themeStore.currentTheme,
  (newTheme, oldTheme) => {
    if (newTheme !== oldTheme) {
      isThemeChanging.value = true
      // 600ms后隐藏遮罩(与动画时长一致)
      setTimeout(() => {
        isThemeChanging.value = false
      }, 600)
    }
  }
)
</script>

<style scoped>
/* 主题切换过渡遮罩样式 */
.theme-transition-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.1);
  opacity: 0;
  pointer-events: none; /* 不阻止页面交互 */
  transition: opacity 0.6s ease;
  z-index: 9999; /* 确保在最上层 */
}

.theme-transition-mask.active {
  opacity: 1;
}

/* 按钮基础样式(可根据需求调整) */
.iconfont {
  font-size: 20px;
  color: var(--text-secondary);
  transition: color 0.3s ease;
}

.iconfont:hover {
  color: var(--primary-color);
}
</style>

第四步:补充说明(图标与兼容性)

  1. 图标替换 :示例中用了 IconFont 图标(icon-anseicon-a-Frame48),你需要:
    • IconFont选择自己喜欢的亮 / 暗色图标(比如太阳、月亮图标)
    • 下载并引入项目,替换图标类名
  2. 兼容性处理
    • 现代浏览器(Chrome 115+、Edge 115+、Safari 16.4+)支持 View Transitions API,会显示聚焦动画
    • 旧浏览器会自动使用降级方案(直接切换主题,无动画),不影响功能使用
  3. uni-app 适配 :示例中兼容了 uni-app 的存储 API(uni.setStorageSync),如果是普通 Vue 项目,会自动使用localStorage,无需额外修改。

四、核心原理拆解(新手必懂)

1. 状态管理(Pinia)

  • currentTheme:存储当前主题(light/dark)
  • clickPosition:记录点击坐标,让动画从点击点开始
  • isChanging:防止动画期间重复点击

2. 动画核心(View Transitions API)

  • document.startViewTransition():原生 API,会对当前页面拍快照(::view-transition-old),对新状态拍快照(::view-transition-new
  • clip-path: circle():通过圆形裁剪路径实现扩散 / 收缩效果
  • 计算最大半径:确保动画能覆盖整个屏幕

3. 主题切换逻辑

  1. 点击按钮 → 记录点击坐标
  2. 调用setTheme → 标记isChanging: true
  3. 启动 View Transitions → 切换主题并存储到本地
  4. 动画就绪 → 执行聚焦扩散 / 收缩动画
  5. 动画结束 → 重置isChanging: false

五、效果演示

  • 点击亮色主题按钮(太阳图标)→ 以点击点为中心,白色收缩,黑色扩散(切换暗色)
  • 点击暗色主题按钮(月亮图标)→ 以点击点为中心,黑色收缩,白色扩散(切换亮色)

六、常见问题排查

  1. 动画不显示
    • 检查浏览器版本是否支持 View Transitions API
    • 确认theme.css中是否添加了view-transition-name: root
    • 检查控制台是否有报错(比如伪元素样式错误)
  2. 主题切换后样式异常
    • 确保所有页面样式都使用了 CSS 变量(var(--bg-main)var(--text-main)等),而不是固定颜色
    • 检查updateRootElementTheme方法是否正确设置了data-theme属性
  3. 刷新后主题重置
    • 确认initTheme方法在页面加载时被调用(示例中在组件 setup 里调用了themeStore.initTheme()

七、扩展功能(可选)

  1. 自定义动画时长:修改duration: 600(单位 ms),数值越大动画越慢
  2. 动画缓动效果:修改easing属性(比如easelinearcubic-bezier
  3. 主题切换 loading:利用isChanging状态显示 loading 图标,优化等待体验
  4. 系统主题适配:添加监听系统亮暗主题的逻辑,自动同步(可参考window.matchMedia('(prefers-color-scheme: dark)')

通过以上步骤,你就成功实现了仅支持亮 / 暗两种主题的聚焦式切换动效!这个功能不仅交互友好,而且代码结构清晰,新手也能轻松理解和复用~

相关推荐
Howie Zphile2 小时前
NEXTJS/REACT有哪些主流的UI可选
前端·react.js·ui
fruge2 小时前
React Server Components 实战:下一代 SSR 开发指南
前端·javascript·react.js
lichong9512 小时前
harmonyos 大屏设备怎么弹出 u 盘
前端·macos·华为·typescript·android studio·harmonyos·大前端
irises2 小时前
从零实现2D绘图引擎:5.5.简单图表demo
前端·数据可视化
irises2 小时前
从零实现2D绘图引擎:5.鼠标悬停事件
前端·数据可视化
青莲8432 小时前
Android Lifecycle 完全指南:从设计原理到生产实践
android·前端
irises2 小时前
从零实现2D绘图引擎:4.矩形与文本的实现
前端·数据可视化
前端_逍遥生2 小时前
Vue 2 vs React 18 深度对比指南
前端·vue.js·react.js
irises2 小时前
从零实现2D绘图引擎:2.Storage和Painter的实现
前端·数据可视化