阅读视图

发现新文章,点击刷新页面。

Vue3 服务端渲染 (SSR) 深度解析:从原理到实践的完整指南

摘要

服务端渲染 (SSR) 是现代 Web 应用提升性能、SEO 和用户体验的关键技术。Vue3 提供了全新的服务端渲染架构,具有更好的性能、更简洁的 API 和更完善的 TypeScript 支持。本文将深入探讨 Vue3 SSR 的工作原理、核心概念、实现方案,通过详细的代码示例、架构图和流程图,帮助你全面掌握 Vue3 服务端渲染的完整知识体系。


一、 什么是服务端渲染?为什么需要它?

1.1 客户端渲染 (CSR) 的问题

在传统的 Vue 单页面应用 (SPA) 中:

<!DOCTYPE html>
<html>
<head>
    <title>Vue App</title>
</head>
<body>
    <div id="app"></div>
    <!-- 初始 HTML 是空的 -->
    <script src="app.js"></script>
</body>
</html>

CSR 的工作流程:

  1. 浏览器请求 HTML
  2. 服务器返回空的 HTML 模板
  3. 浏览器下载 JavaScript 文件
  4. Vue 应用初始化,渲染页面
  5. 用户看到内容

存在的问题:

  • SEO 不友好:搜索引擎爬虫难以抓取动态内容
  • 首屏加载慢:用户需要等待所有 JS 加载执行完才能看到内容
  • 白屏时间长:特别是网络条件差的情况下

1.2 服务端渲染 (SSR) 的优势

SSR 的工作流程:

  1. 浏览器请求 HTML
  2. 服务器执行 Vue 应用,生成完整的 HTML
  3. 浏览器立即显示渲染好的内容
  4. Vue 应用在客户端"激活"(Hydrate),变成可交互的 SPA

核心优势:

  • 更好的 SEO:搜索引擎可以直接抓取完整的 HTML 内容
  • 更快的首屏加载:用户立即看到内容,无需等待 JS 下载执行
  • 更好的用户体验:减少白屏时间,特别是对于慢网络用户
  • 社交分享友好:社交媒体爬虫可以正确获取页面元信息

二、 Vue3 SSR 核心架构与工作原理

2.1 Vue3 SSR 整体架构

流程图:Vue3 SSR 完整工作流程

flowchart TD
    A[用户访问URL] --> B[服务器接收请求]
    B --> C[创建Vue应用实例]
    C --> D[路由匹配]
    D --> E[数据预取<br>asyncData/pinia]
    E --> F[渲染HTML字符串]
    F --> G[注入状态到HTML]
    G --> H[返回完整HTML给浏览器]
    
    H --> I[浏览器显示静态内容]
    I --> J[加载客户端JS]
    J --> K[Hydration激活]
    K --> L[变成可交互SPA]
    L --> M[后续路由切换为CSR]

2.2 同构应用 (Isomorphic Application)

Vue3 SSR 的核心概念是"同构" - 同一套代码在服务器和客户端都能运行。

// 同构组件 - 在服务器和客户端都能运行
export default {
  setup() {
    // 这个组件在两个环境都能执行
    const data = ref('Hello SSR')
    return { data }
  }
}

关键挑战:

  1. 环境差异:Node.js vs 浏览器环境
  2. 生命周期:服务器没有 DOM,只有部分生命周期
  3. 数据状态:服务器预取的数据需要传递到客户端
  4. 路由匹配:服务器需要根据 URL 匹配对应组件

三、 Vue3 SSR 核心 API 解析

3.1 renderToString - 核心渲染函数

import { renderToString } from 'vue/server-renderer'
import { createApp } from './app.js'

// 服务器渲染入口
async function renderApp(url) {
  // 1. 创建 Vue 应用实例
  const { app, router } = createApp()
  
  // 2. 设置服务器端路由
  router.push(url)
  await router.isReady()
  
  // 3. 渲染为 HTML 字符串
  const html = await renderToString(app)
  
  // 4. 返回完整的 HTML
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue3 SSR App</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
  `
}

3.2 createSSRApp - 创建 SSR 应用

// app.js - 同构应用创建
import { createSSRApp } from 'vue'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './routes'

// 导出一个工厂函数,为每个请求创建新的应用实例
export function createApp() {
  const app = createSSRApp(App)
  
  // 根据环境使用不同的 history
  const router = createRouter({
    history: import.meta.env.SSR 
      ? createMemoryHistory()  // 服务器用 memory history
      : createWebHistory(),    // 客户端用 web history
    routes
  })
  
  app.use(router)
  return { app, router }
}

3.3 useSSRContext - 服务器上下文

import { useSSRContext } from 'vue'

// 在组件中访问 SSR 上下文
export default {
  setup() {
    if (import.meta.env.SSR) {
      // 只在服务器端执行
      const ctx = useSSRContext()
      ctx.title = '动态标题'
    }
  }
}

四、 完整 Vue3 SSR 项目实战

让我们构建一个完整的 Vue3 SSR 项目来演示所有概念。

4.1 项目结构

vue3-ssr-project/
├── src/
│   ├── client/          # 客户端入口
│   │   └── entry-client.js
│   ├── server/          # 服务器入口
│   │   └── entry-server.js
│   ├── components/      # 共享组件
│   │   ├── Layout.vue
│   │   └── PostList.vue
│   ├── router/          # 路由配置
│   │   └── index.js
│   ├── stores/          # 状态管理
│   │   └── postStore.js
│   └── App.vue
├── index.html           # HTML 模板
├── server.js           # Express 服务器
└── vite.config.js      # Vite 配置

4.2 共享应用创建 (app.js)

// src/app.js
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import { createPinia } from 'pinia'
import App from './App.vue'

// 导出一个工厂函数,为每个请求创建新的应用实例
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  const pinia = createPinia()
  
  app.use(router)
  app.use(pinia)
  
  return { app, router, pinia }
}

4.3 路由配置

// src/router/index.js
import { createRouter as _createRouter, createMemoryHistory, createWebHistory } from 'vue-router'

// 路由配置
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../components/Home.vue'),
    meta: {
      ssr: true // 标记需要 SSR
    }
  },
  {
    path: '/posts',
    name: 'Posts',
    component: () => import('../components/PostList.vue'),
    meta: {
      ssr: true,
      preload: true // 需要数据预取
    }
  },
  {
    path: '/posts/:id',
    name: 'PostDetail',
    component: () => import('../components/PostDetail.vue'),
    meta: {
      ssr: true,
      preload: true
    }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../components/About.vue'),
    meta: {
      ssr: false // 不需要 SSR
    }
  }
]

export function createRouter() {
  return _createRouter({
    history: import.meta.env.SSR 
      ? createMemoryHistory() 
      : createWebHistory(),
    routes
  })
}

4.4 Pinia 状态管理

// src/stores/postStore.js
import { defineStore } from 'pinia'

// 模拟 API 调用
const fetchPosts = async () => {
  await new Promise(resolve => setTimeout(resolve, 100))
  return [
    { id: 1, title: 'Vue3 SSR 入门指南', content: '学习 Vue3 服务端渲染...', views: 152 },
    { id: 2, title: 'Pinia 状态管理', content: 'Vue3 推荐的状态管理方案...', views: 98 },
    { id: 3, title: 'Vite 构建工具', content: '下一代前端构建工具...', views: 76 }
  ]
}

const fetchPostDetail = async (id) => {
  await new Promise(resolve => setTimeout(resolve, 50))
  const posts = await fetchPosts()
  return posts.find(post => post.id === parseInt(id))
}

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: [],
    currentPost: null,
    loading: false
  }),
  
  actions: {
    async loadPosts() {
      this.loading = true
      try {
        this.posts = await fetchPosts()
      } finally {
        this.loading = false
      }
    },
    
    async loadPostDetail(id) {
      this.loading = true
      try {
        this.currentPost = await fetchPostDetail(id)
      } finally {
        this.loading = false
      }
    },
    
    // 服务器端数据预取
    async serverInit(route) {
      if (route.name === 'Posts') {
        await this.loadPosts()
      } else if (route.name === 'PostDetail') {
        await this.loadPostDetail(route.params.id)
      }
    }
  }
})

4.5 服务器入口

// src/server/entry-server.js
import { renderToString } from 'vue/server-renderer'
import { createApp } from '../app.js'
import { usePostStore } from '../stores/postStore'

export async function render(url) {
  const { app, router, pinia } = createApp()
  
  // 设置服务器端路由位置
  router.push(url)
  
  // 等待路由准备完成
  await router.isReady()
  
  // 获取匹配的路由
  const matchedComponents = router.currentRoute.value.matched
  const route = router.currentRoute.value
  
  // 数据预取 - 执行组件的 asyncData 或 store 的 serverInit
  const postStore = usePostStore(pinia)
  await postStore.serverInit(route)
  
  // 获取需要预取数据的组件
  const componentsWithPreload = matchedComponents.map(component => 
    component.components?.default || component
  ).filter(component => component.asyncData)
  
  // 执行组件的 asyncData 方法
  const preloadPromises = componentsWithPreload.map(component => 
    component.asyncData({
      store: pinia,
      route: router.currentRoute.value
    })
  )
  
  await Promise.all(preloadPromises)
  
  // 渲染应用为 HTML 字符串
  const ctx = {}
  const html = await renderToString(app, ctx)
  
  // 获取 Pinia 状态,用于客户端注水
  const state = JSON.stringify(pinia.state.value)
  
  return { html, state }
}

4.6 客户端入口

// src/client/entry-client.js
import { createApp } from '../app.js'
import { usePostStore } from '../stores/postStore'

const { app, router, pinia } = createApp()

// 恢复服务器状态
if (window.__PINIA_STATE__) {
  pinia.state.value = window.__PINIA_STATE__
}

// 等待路由准备完成
router.isReady().then(() => {
  // 挂载应用
  app.mount('#app')
  
  console.log('客户端激活完成')
})

// 客户端特定逻辑
if (!import.meta.env.SSR) {
  // 添加客户端特定的事件监听等
  const postStore = usePostStore()
  
  // 监听路由变化,在客户端获取数据
  router.beforeEach((to, from, next) => {
    if (to.meta.preload && !postStore.posts.length) {
      postStore.serverInit(to).then(next)
    } else {
      next()
    }
  })
}

4.7 Vue 组件示例

App.vue - 根组件

<template>
  <div id="app">
    <Layout>
      <RouterView />
    </Layout>
  </div>
</template>

<script setup>
import Layout from './components/Layout.vue'
</script>

Layout.vue - 布局组件

<template>
  <div class="layout">
    <header class="header">
      <nav class="nav">
        <RouterLink to="/" class="nav-link">首页</RouterLink>
        <RouterLink to="/posts" class="nav-link">文章列表</RouterLink>
        <RouterLink to="/about" class="nav-link">关于</RouterLink>
      </nav>
    </header>
    
    <main class="main">
      <slot />
    </main>
    
    <footer class="footer">
      <p>&copy; 2024 Vue3 SSR 演示</p>
    </footer>
  </div>
</template>

<script setup>
import { RouterLink } from 'vue-router'
</script>

<style scoped>
.layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.header {
  background: #2c3e50;
  padding: 1rem 0;
}

.nav {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 2rem;
  display: flex;
  gap: 2rem;
}

.nav-link {
  color: white;
  text-decoration: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  transition: background 0.3s;
}

.nav-link:hover,
.nav-link.router-link-active {
  background: #34495e;
}

.main {
  flex: 1;
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  width: 100%;
}

.footer {
  background: #ecf0f1;
  padding: 1rem;
  text-align: center;
  color: #7f8c8d;
}
</style>

PostList.vue - 文章列表组件

<template>
  <div class="post-list">
    <h1>文章列表</h1>
    
    <div v-if="postStore.loading" class="loading">
      加载中...
    </div>
    
    <div v-else class="posts">
      <article 
        v-for="post in postStore.posts" 
        :key="post.id"
        class="post-card"
      >
        <h2>
          <RouterLink :to="`/posts/${post.id}`" class="post-link">
            {{ post.title }}
          </RouterLink>
        </h2>
        <p class="post-content">{{ post.content }}</p>
        <div class="post-meta">
          <span>浏览量: {{ post.views }}</span>
        </div>
      </article>
    </div>
  </div>
</template>

<script setup>
import { usePostStore } from '../stores/postStore'
import { onServerPrefetch, onMounted } from 'vue'

const postStore = usePostStore()

// 服务器端数据预取
onServerPrefetch(async () => {
  await postStore.loadPosts()
})

// 客户端数据获取(如果服务器没有预取)
onMounted(async () => {
  if (postStore.posts.length === 0) {
    await postStore.loadPosts()
  }
})

// 传统 asyncData 方式(可选)
export const asyncData = async ({ store, route }) => {
  const postStore = usePostStore(store)
  await postStore.loadPosts()
}
</script>

<style scoped>
.post-list {
  max-width: 800px;
  margin: 0 auto;
}

.loading {
  text-align: center;
  padding: 2rem;
  color: #7f8c8d;
}

.posts {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.post-card {
  border: 1px solid #e1e8ed;
  border-radius: 8px;
  padding: 1.5rem;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  transition: transform 0.2s, box-shadow 0.2s;
}

.post-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}

.post-link {
  color: #2c3e50;
  text-decoration: none;
}

.post-link:hover {
  color: #42b883;
  text-decoration: underline;
}

.post-content {
  color: #5a6c7d;
  line-height: 1.6;
  margin: 1rem 0;
}

.post-meta {
  border-top: 1px solid #e1e8ed;
  padding-top: 1rem;
  color: #7f8c8d;
  font-size: 0.9rem;
}
</style>

4.8 Express 服务器

// server.js
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
import { render } from './dist/server/entry-server.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

// 静态文件服务
app.use('/assets', express.static(resolve(__dirname, './dist/client/assets')))

// SSR 路由处理
app.get('*', async (req, res) => {
  try {
    const { html, state } = await render(req.url)
    
    const template = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue3 SSR 演示</title>
    <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
    <div id="app">${html}</div>
    <script>
      // 将服务器状态传递到客户端
      window.__PINIA_STATE__ = ${state}
    </script>
    <script type="module" src="/assets/entry-client.js"></script>
</body>
</html>`
    
    res.status(200).set({ 'Content-Type': 'text/html' }).end(template)
  } catch (error) {
    console.error('SSR 渲染错误:', error)
    res.status(500).end('服务器内部错误')
  }
})

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`🚀 服务器运行在 http://localhost:${PORT}`)
})

4.9 Vite 构建配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  
  build: {
    rollupOptions: {
      input: {
        client: resolve(__dirname, 'src/client/entry-client.js'),
        server: resolve(__dirname, 'src/server/entry-server.js')
      },
      output: {
        format: 'esm',
        entryFileNames: (chunk) => {
          return chunk.name === 'server' ? 'server/[name].js' : 'client/assets/[name]-[hash].js'
        }
      }
    }
  },
  
  ssr: {
    noExternal: ['pinia']
  }
})

五、 数据预取与状态同步

5.1 数据预取策略

流程图:数据预取与状态同步流程

flowchart TD
    A[用户请求] --> B[服务器路由匹配]
    B --> C[识别需要预取的组件]
    C --> D[执行asyncData/store预取]
    D --> E[所有数据预取完成]
    E --> F[渲染HTML]
    F --> G[序列化状态到window]
    G --> H[返回HTML+状态]
    
    H --> I[浏览器渲染]
    I --> J[客户端激活]
    J --> K[反序列化状态]
    K --> L[Hydration完成]

5.2 多种数据预取方式

方式一:使用 onServerPrefetch

<template>
  <div>
    <h1>用户资料</h1>
    <div v-if="user">{{ user.name }}</div>
  </div>
</template>

<script setup>
import { ref, onServerPrefetch } from 'vue'

const user = ref(null)

const fetchUserData = async () => {
  // 模拟 API 调用
  await new Promise(resolve => setTimeout(resolve, 100))
  user.value = { name: '张三', id: 1, email: 'zhangsan@example.com' }
}

// 服务器端预取
onServerPrefetch(async () => {
  await fetchUserData()
})

// 客户端获取(如果服务器没有预取)
import { onMounted } from 'vue'
onMounted(async () => {
  if (!user.value) {
    await fetchUserData()
  }
})
</script>

方式二:使用 Store 统一管理

// stores/userStore.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false
  }),
  
  actions: {
    async fetchUser(id) {
      this.loading = true
      try {
        // 实际项目中这里调用 API
        await new Promise(resolve => setTimeout(resolve, 100))
        this.user = { id, name: '用户' + id, email: `user${id}@example.com` }
      } finally {
        this.loading = false
      }
    },
    
    // 服务器端初始化
    async serverInit(route) {
      if (route.name === 'UserProfile' && route.params.id) {
        await this.fetchUser(route.params.id)
      }
    }
  }
})

六、 性能优化与最佳实践

6.1 缓存策略

// server.js - 添加缓存
import lru-cache from 'lru-cache'

const ssrCache = new lru-cache({
  max: 100, // 缓存100个页面
  ttl: 1000 * 60 * 15 // 15分钟
})

app.get('*', async (req, res) => {
  // 检查缓存
  const cacheKey = req.url
  if (ssrCache.has(cacheKey)) {
    console.log('使用缓存:', cacheKey)
    return res.send(ssrCache.get(cacheKey))
  }
  
  try {
    const { html, state } = await render(req.url)
    const template = generateTemplate(html, state)
    
    // 缓存结果(排除需要动态内容的页面)
    if (!req.url.includes('/admin') && !req.url.includes('/user')) {
      ssrCache.set(cacheKey, template)
    }
    
    res.send(template)
  } catch (error) {
    // 错误处理
  }
})

6.2 流式渲染

// 流式渲染示例
import { renderToNodeStream } from 'vue/server-renderer'

app.get('*', async (req, res) => {
  const { app, router } = createApp()
  
  router.push(req.url)
  await router.isReady()
  
  res.write(`
    <!DOCTYPE html>
    <html>
      <head><title>Vue3 SSR</title></head>
      <body><div id="app">
  `)
  
  const stream = renderToNodeStream(app)
  stream.pipe(res, { end: false })
  
  stream.on('end', () => {
    res.write(`</div><script src="/client.js"></script></body></html>`)
    res.end()
  })
})

6.3 错误处理

// 错误边界组件
const ErrorBoundary = {
  setup(props, { slots }) {
    const error = ref(null)
    
    onErrorCaptured((err) => {
      error.value = err
      return false // 阻止错误继续传播
    })
    
    return () => error.value 
      ? h('div', { class: 'error' }, '组件渲染错误')
      : slots.default?.()
  }
}

七、 Nuxt.js 3 - 更简单的 SSR 方案

对于大多数项目,推荐使用 Nuxt.js 3,它基于 Vue3 提供了开箱即用的 SSR 支持。

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
  modules: ['@pinia/nuxt'],
  runtimeConfig: {
    public: {
      apiBase: process.env.API_BASE || '/api'
    }
  }
})
<!-- pages/index.vue -->
<template>
  <div>
    <h1>Nuxt 3 SSR</h1>
    <div>{{ data }}</div>
  </div>
</template>

<script setup>
// 自动处理 SSR 数据获取
const { data } = await useFetch('/api/posts')
</script>

八、 总结

8.1 Vue3 SSR 核心优势

  1. 更好的性能:首屏加载速度快,减少白屏时间
  2. SEO 友好:搜索引擎可以直接索引内容
  3. 同构开发:一套代码,两端运行
  4. 现代化 API:更好的 TypeScript 支持和组合式 API 集成

8.2 适用场景

  • 内容型网站:博客、新闻、电商等需要 SEO 的场景
  • 企业官网:需要快速首屏加载和良好 SEO
  • 社交应用:需要社交媒体分享预览
  • 需要性能优化的 SPA

8.3 注意事项

  1. 服务器负载:SSR 会增加服务器 CPU 和内存消耗
  2. 开发复杂度:需要处理环境差异和状态同步
  3. 缓存策略:需要合理设计缓存机制
  4. 错误处理:需要完善的错误边界和降级方案

Vue3 的服务端渲染为现代 Web 应用提供了强大的能力,合理使用可以显著提升用户体验和应用性能。希望本文能帮助你全面掌握 Vue3 SSR 的核心概念和实践技巧!


如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。在这里插入图片描述

Vue3 事件修饰符深度解析:从基础到高级应用的完整指南

摘要

事件修饰符是 Vue.js 中一个强大而优雅的特性,它允许我们以声明式的方式处理 DOM 事件细节。Vue3 在保留所有 Vue2 事件修饰符的基础上,还引入了一些新的修饰符。本文将深入探讨所有事件修饰符的工作原理、使用场景和最佳实践,通过详细的代码示例、执行流程分析和实际应用案例,帮助你彻底掌握 Vue3 事件修饰符的完整知识体系。


一、 什么是事件修饰符?为什么需要它?

1.1 传统事件处理的问题

在原生 JavaScript 中处理事件时,我们经常需要编写重复的样板代码:

// 原生 JavaScript 事件处理
element.addEventListener('click', function(event) {
  // 阻止默认行为
  event.preventDefault()
  
  // 停止事件传播
  event.stopPropagation()
  
  // 执行业务逻辑
  handleClick()
})

传统方式的问题:

  • 代码冗余:每个事件处理函数都需要重复调用 preventDefault()stopPropagation()
  • 关注点混合:事件处理逻辑与 DOM 操作细节混合在一起
  • 可读性差:代码意图不够清晰明确
  • 维护困难:修改事件行为需要深入函数内部

1.2 Vue 事件修饰符的解决方案

Vue 的事件修饰符提供了一种声明式的解决方案:

<template>
  <!-- 使用事件修饰符 -->
  <a @click.prevent.stop="handleClick" href="/about">关于我们</a>
</template>

事件修饰符的优势:

  • 代码简洁:以声明式的方式表达事件行为
  • 关注点分离:业务逻辑与 DOM 细节分离
  • 可读性强:代码意图一目了然
  • 维护方便:修改事件行为只需改动模板

二、 Vue3 事件修饰符完整列表

2.1 事件修饰符分类总览

类别 修饰符 说明 Vue2 Vue3
事件传播 .stop 阻止事件冒泡
.capture 使用捕获模式
.self 仅当事件源是自身时触发
.once 只触发一次
.passive 不阻止默认行为
默认行为 .prevent 阻止默认行为
按键修饰 .enter Enter 键
.tab Tab 键
.delete 删除键
.esc Esc 键
.space 空格键
.up 上箭头
.down 下箭头
.left 左箭头
.right 右箭头
系统修饰 .ctrl Ctrl 键
.alt Alt 键
.shift Shift 键
.meta Meta 键
.exact 精确匹配系统修饰符
鼠标修饰 .left 鼠标左键
.right 鼠标右键
.middle 鼠标中键
Vue3 新增 .vue 自定义事件专用

三、 事件传播修饰符详解

3.1 事件传播的基本概念

流程图:DOM 事件传播机制

flowchart TD
    A[事件发生] --> B[捕获阶段 Capture Phase]
    B --> C[从window向下传递到目标]
    C --> D[目标阶段 Target Phase]
    D --> E[到达事件目标元素]
    E --> F[冒泡阶段 Bubble Phase]
    F --> G[从目标向上传递到window]
    
    B --> H[.capture 在此阶段触发]
    D --> I[.self 检查事件源]
    F --> J[.stop 阻止继续冒泡]

3.2 .stop - 阻止事件冒泡

<template>
  <div class="stop-modifier-demo">
    <h2>.stop 修饰符 - 阻止事件冒泡</h2>
    
    <div class="demo-area">
      <!-- 外层容器 -->
      <div class="outer-box" @click="handleOuterClick">
        <p>外层容器 (点击我会触发)</p>
        
        <!-- 内层容器 - 不使用 .stop -->
        <div class="inner-box" @click="handleInnerClick">
          <p>内层容器 - 无 .stop (点击我会触发内外两层)</p>
        </div>
        
        <!-- 内层容器 - 使用 .stop -->
        <div class="inner-box stop-demo" @click.stop="handleInnerClickStop">
          <p>内层容器 - 有 .stop (点击我只触发内层)</p>
        </div>
      </div>
    </div>

    <div class="event-logs">
      <h3>事件触发日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 10) {
    logs.value.pop()
  }
}

const handleOuterClick = (event) => {
  addLog('🟢 外层容器被点击')
  console.log('外层点击事件:', event)
}

const handleInnerClick = (event) => {
  addLog('🔵 内层容器被点击 (无.stop - 会冒泡)')
  console.log('内层点击事件 (无.stop):', event)
}

const handleInnerClickStop = (event) => {
  addLog('🔴 内层容器被点击 (有.stop - 阻止冒泡)')
  console.log('内层点击事件 (有.stop):', event)
}
</script>

<style scoped>
.stop-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.demo-area {
  margin: 20px 0;
}

.outer-box {
  padding: 30px;
  background: #e3f2fd;
  border: 2px solid #2196f3;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.3s;
}

.outer-box:hover {
  background: #bbdefb;
}

.outer-box p {
  margin: 0 0 15px 0;
  font-weight: bold;
  color: #1976d2;
}

.inner-box {
  padding: 20px;
  margin: 15px 0;
  background: #f3e5f5;
  border: 2px solid #9c27b0;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.3s;
}

.inner-box:hover {
  background: #e1bee7;
}

.inner-box p {
  margin: 0;
  color: #7b1fa2;
}

.stop-demo {
  background: #fff3e0;
  border-color: #ff9800;
}

.stop-demo p {
  color: #ef6c00;
}

.event-logs {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

.event-logs h3 {
  margin: 0 0 15px 0;
  color: #333;
}

.log-item {
  padding: 8px 12px;
  margin: 5px 0;
  background: white;
  border-radius: 4px;
  border-left: 4px solid #4caf50;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

3.3 .capture - 使用事件捕获模式

<template>
  <div class="capture-modifier-demo">
    <h2>.capture 修饰符 - 事件捕获模式</h2>
    
    <div class="demo-area">
      <!-- 捕获阶段触发 -->
      <div class="capture-box" @click.capture="handleCaptureClick">
        <p>捕获阶段容器 (使用 .capture)</p>
        
        <div class="target-box" @click="handleTargetClick">
          <p>目标元素 (正常冒泡阶段)</p>
        </div>
      </div>
    </div>

    <div class="explanation">
      <h3>执行顺序说明:</h3>
      <ol>
        <li>点击目标元素时,首先触发 <strong>.capture</strong> 阶段的事件</li>
        <li>然后触发目标元素自身的事件</li>
        <li>最后是冒泡阶段的事件 (本例中没有)</li>
      </ol>
    </div>

    <div class="event-logs">
      <h3>事件触发顺序:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
}

const handleCaptureClick = () => {
  addLog('1️⃣ 捕获阶段: 外层容器 (.capture)')
}

const handleTargetClick = () => {
  addLog('2️⃣ 目标阶段: 内层元素 (正常)')
}
</script>

<style scoped>
.capture-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.capture-box {
  padding: 30px;
  background: #fff3e0;
  border: 2px dashed #ff9800;
  border-radius: 8px;
}

.capture-box p {
  margin: 0 0 15px 0;
  color: #ef6c00;
  font-weight: bold;
}

.target-box {
  padding: 20px;
  background: #e8f5e8;
  border: 2px solid #4caf50;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.3s;
}

.target-box:hover {
  background: #c8e6c9;
}

.target-box p {
  margin: 0;
  color: #2e7d32;
}

.explanation {
  margin: 20px 0;
  padding: 20px;
  background: #e3f2fd;
  border-radius: 8px;
}

.explanation h3 {
  margin: 0 0 10px 0;
  color: #1976d2;
}

.explanation ol {
  margin: 0;
  color: #333;
}

.log-item {
  padding: 8px 12px;
  margin: 5px 0;
  background: white;
  border-radius: 4px;
  border-left: 4px solid #ff9800;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

3.4 .self - 仅当事件源是自身时触发

<template>
  <div class="self-modifier-demo">
    <h2>.self 修饰符 - 仅自身触发</h2>
    
    <div class="demo-area">
      <!-- 不使用 .self -->
      <div class="container" @click="handleContainerClick">
        <p>普通容器 (点击子元素也会触发)</p>
        <button class="child-btn">子元素按钮</button>
      </div>
      
      <!-- 使用 .self -->
      <div class="container self-demo" @click.self="handleContainerSelfClick">
        <p>.self 容器 (只有点击容器本身才触发)</p>
        <button class="child-btn">子元素按钮</button>
      </div>
    </div>

    <div class="event-logs">
      <h3>事件触发日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 8) {
    logs.value.pop()
  }
}

const handleContainerClick = (event) => {
  addLog(`🔵 容器被点击 (target: ${event.target.tagName})`)
}

const handleContainerSelfClick = (event) => {
  addLog(`🔴 .self 容器被点击 (只有点击容器本身才触发)`)
}
</script>

<style scoped>
.self-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.demo-area {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin: 20px 0;
}

.container {
  padding: 25px;
  border: 2px solid #666;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.3s;
  min-height: 120px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.container:hover {
  background: #f5f5f5;
}

.container p {
  margin: 0 0 15px 0;
  font-weight: bold;
  text-align: center;
}

.self-demo {
  border-color: #e91e63;
  background: #fce4ec;
}

.self-demo:hover {
  background: #f8bbd9;
}

.child-btn {
  padding: 8px 16px;
  background: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.child-btn:hover {
  background: #1976d2;
}

.event-logs {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}
</style>

3.5 .once - 只触发一次

<template>
  <div class="once-modifier-demo">
    <h2>.once 修饰符 - 只触发一次</h2>
    
    <div class="demo-area">
      <div class="button-group">
        <button @click="handleNormalClick" class="btn">
          普通按钮 (可重复点击)
        </button>
        <button @click.once="handleOnceClick" class="btn once-btn">
          .once 按钮 (只触发一次)
        </button>
      </div>
      
      <div class="counter-display">
        <div class="counter">
          <span>普通点击: </span>
          <strong>{{ normalCount }}</strong>
        </div>
        <div class="counter">
          <span>Once 点击: </span>
          <strong>{{ onceCount }}</strong>
        </div>
      </div>
    </div>

    <div class="event-logs">
      <h3>事件触发日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const normalCount = ref(0)
const onceCount = ref(0)
const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 6) {
    logs.value.pop()
  }
}

const handleNormalClick = () => {
  normalCount.value++
  addLog(`🔵 普通按钮点击: ${normalCount.value}`)
}

const handleOnceClick = () => {
  onceCount.value++
  addLog(`🔴 ONCE 按钮点击: ${onceCount.value} (只会显示一次!)`)
}
</script>

<style scoped>
.once-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.button-group {
  display: flex;
  gap: 20px;
  justify-content: center;
  margin: 30px 0;
}

.btn {
  padding: 12px 24px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
  transition: all 0.3s;
}

.btn:first-child {
  background: #2196f3;
  color: white;
}

.btn:first-child:hover {
  background: #1976d2;
  transform: translateY(-2px);
}

.once-btn {
  background: #ff9800;
  color: white;
}

.once-btn:hover {
  background: #f57c00;
  transform: translateY(-2px);
}

.counter-display {
  display: flex;
  justify-content: center;
  gap: 40px;
  margin: 20px 0;
}

.counter {
  padding: 15px 25px;
  background: white;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  text-align: center;
  min-width: 150px;
}

.counter span {
  display: block;
  color: #666;
  margin-bottom: 5px;
}

.counter strong {
  font-size: 24px;
  color: #333;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: white;
  border-radius: 4px;
  border-left: 4px solid #2196f3;
  font-family: 'Courier New', monospace;
}

.log-item:contains('ONCE') {
  border-left-color: #ff9800;
}
</style>

3.6 .passive - 不阻止默认行为

<template>
  <div class="passive-modifier-demo">
    <h2>.passive 修饰符 - 不阻止默认行为</h2>
    
    <div class="demo-area">
      <div class="scroll-container">
        <div class="scroll-content">
          <div v-for="n in 50" :key="n" class="scroll-item">
            项目 {{ n }}
          </div>
        </div>
      </div>
      
      <div class="control-info">
        <p>尝试滚动上面的区域,观察控制台输出:</p>
        <ul>
          <li>使用 <code>.passive</code> 的事件处理函数不会调用 <code>preventDefault()</code></li>
          <li>这可以提升滚动性能,特别是移动端</li>
        </ul>
      </div>
    </div>

    <div class="performance-metrics">
      <h3>性能指标:</h3>
      <div class="metrics">
        <div class="metric">
          <span>滚动事件触发次数:</span>
          <strong>{{ scrollCount }}</strong>
        </div>
        <div class="metric">
          <span>阻塞时间:</span>
          <strong>{{ blockTime }}ms</strong>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const scrollCount = ref(0)
const blockTime = ref(0)

onMounted(() => {
  const scrollContainer = document.querySelector('.scroll-container')
  
  // 模拟阻塞操作
  const heavyOperation = () => {
    const start = performance.now()
    let result = 0
    for (let i = 0; i < 1000000; i++) {
      result += Math.random()
    }
    const end = performance.now()
    blockTime.value = (end - start).toFixed(2)
    return result
  }
  
  // 添加 passive 事件监听器
  scrollContainer.addEventListener('scroll', (event) => {
    scrollCount.value++
    heavyOperation()
    console.log('passive 滚动事件 - 不会阻止默认行为')
  }, { passive: true })
})
</script>

<style scoped>
.passive-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.scroll-container {
  height: 200px;
  border: 2px solid #ddd;
  border-radius: 8px;
  overflow-y: scroll;
  margin: 20px 0;
}

.scroll-content {
  padding: 10px;
}

.scroll-item {
  padding: 15px;
  margin: 5px 0;
  background: #f5f5f5;
  border-radius: 4px;
  border-left: 4px solid #4caf50;
}

.control-info {
  padding: 20px;
  background: #e3f2fd;
  border-radius: 8px;
  margin: 20px 0;
}

.control-info p {
  margin: 0 0 10px 0;
  font-weight: bold;
}

.control-info ul {
  margin: 0;
  color: #333;
}

.control-info code {
  background: #fff;
  padding: 2px 6px;
  border-radius: 3px;
  font-family: 'Courier New', monospace;
}

.performance-metrics {
  padding: 20px;
  background: #fff3e0;
  border-radius: 8px;
}

.performance-metrics h3 {
  margin: 0 0 15px 0;
  color: #e65100;
}

.metrics {
  display: flex;
  gap: 30px;
}

.metric {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.metric span {
  color: #666;
  margin-bottom: 5px;
}

.metric strong {
  font-size: 24px;
  color: #e65100;
}
</style>

四、 默认行为修饰符

4.1 .prevent - 阻止默认行为

<template>
  <div class="prevent-modifier-demo">
    <h2>.prevent 修饰符 - 阻止默认行为</h2>
    
    <div class="demo-area">
      <div class="form-group">
        <h3>表单提交示例</h3>
        <form @submit="handleFormSubmit" class="prevent-form">
          <input v-model="username" placeholder="请输入用户名" class="form-input" />
          <button type="submit" class="btn">普通提交</button>
          <button type="submit" @click.prevent="handlePreventSubmit" class="btn prevent-btn">
            使用 .prevent
          </button>
        </form>
      </div>
      
      <div class="link-group">
        <h3>链接点击示例</h3>
        <a href="https://vuejs.org" @click="handleLinkClick" class="link">
          普通链接 (会跳转)
        </a>
        <a href="https://vuejs.org" @click.prevent="handlePreventLinkClick" class="link prevent-link">
          使用 .prevent (不会跳转)
        </a>
      </div>
    </div>

    <div class="event-logs">
      <h3>事件触发日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 8) {
    logs.value.pop()
  }
}

const handleFormSubmit = (event) => {
  addLog('📝 表单提交 (页面会刷新)')
  // 这里可以添加表单验证等逻辑
}

const handlePreventSubmit = () => {
  addLog('🛑 表单提交 (使用 .prevent,页面不会刷新)')
  // 在这里处理 AJAX 提交等逻辑
  if (username.value) {
    addLog(`✅ 提交用户名: ${username.value}`)
  }
}

const handleLinkClick = () => {
  addLog('🔗 链接点击 (会跳转到 Vue.js 官网)')
}

const handlePreventLinkClick = () => {
  addLog('🚫 链接点击 (使用 .prevent,不会跳转)')
  // 可以在这里处理路由跳转等逻辑
}
</script>

<style scoped>
.prevent-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.form-group, .link-group {
  margin: 30px 0;
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
}

.form-group h3, .link-group h3 {
  margin: 0 0 15px 0;
  color: #333;
}

.prevent-form {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
}

.form-input {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  flex: 1;
  min-width: 200px;
}

.btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.btn:first-of-type {
  background: #2196f3;
  color: white;
}

.prevent-btn {
  background: #ff5722;
  color: white;
}

.btn:hover {
  opacity: 0.9;
}

.link-group {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.link {
  padding: 12px 20px;
  background: #e3f2fd;
  border: 1px solid #2196f3;
  border-radius: 4px;
  text-decoration: none;
  color: #1976d2;
  text-align: center;
  transition: background 0.3s;
}

.prevent-link {
  background: #ffebee;
  border-color: #f44336;
  color: #d32f2f;
}

.link:hover {
  background: #bbdefb;
}

.prevent-link:hover {
  background: #ffcdd2;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: white;
  border-radius: 4px;
  border-left: 4px solid #2196f3;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

五、 按键修饰符详解

5.1 常用按键修饰符

<template>
  <div class="key-modifier-demo">
    <h2>按键修饰符 - 键盘事件处理</h2>
    
    <div class="demo-area">
      <div class="input-group">
        <h3>输入框按键事件</h3>
        <input 
          v-model="inputText"
          @keyup.enter="handleEnter"
          @keyup.tab="handleTab"
          @keyup.delete="handleDelete"
          @keyup.esc="handleEsc"
          @keyup.space="handleSpace"
          placeholder="尝试按 Enter、Tab、Delete、Esc、Space 键"
          class="key-input"
        />
      </div>
      
      <div class="arrow-group">
        <h3>方向键控制</h3>
        <div class="arrow-controls">
          <div class="arrow-row">
            <button @keyup.up="handleUp" class="arrow-btn up">↑</button>
          </div>
          <div class="arrow-row">
            <button @keyup.left="handleLeft" class="arrow-btn left">←</button>
            <button @keyup.down="handleDown" class="arrow-btn down">↓</button>
            <button @keyup.right="handleRight" class="arrow-btn right">→</button>
          </div>
        </div>
        <p>点击按钮后按方向键测试</p>
      </div>
    </div>

    <div class="key-logs">
      <h3>按键事件日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const inputText = ref('')
const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 10) {
    logs.value.pop()
  }
}

const handleEnter = (event) => {
  addLog('↵ Enter 键被按下')
  if (inputText.value.trim()) {
    addLog(`💾 保存内容: "${inputText.value}"`)
    inputText.value = ''
  }
}

const handleTab = () => {
  addLog('↹ Tab 键被按下')
}

const handleDelete = () => {
  addLog('⌫ Delete 键被按下')
}

const handleEsc = () => {
  addLog('⎋ Esc 键被按下 - 取消操作')
  inputText.value = ''
}

const handleSpace = () => {
  addLog('␣ Space 键被按下')
}

const handleUp = () => {
  addLog('↑ 上方向键 - 向上移动')
}

const handleDown = () => {
  addLog('↓ 下方向键 - 向下移动')
}

const handleLeft = () => {
  addLog('← 左方向键 - 向左移动')
}

const handleRight = () => {
  addLog('→ 右方向键 - 向右移动')
}
</script>

<style scoped>
.key-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.input-group, .arrow-group {
  margin: 30px 0;
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
}

.input-group h3, .arrow-group h3 {
  margin: 0 0 15px 0;
  color: #333;
}

.key-input {
  width: 100%;
  padding: 12px;
  border: 2px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
  transition: border-color 0.3s;
}

.key-input:focus {
  outline: none;
  border-color: #2196f3;
}

.arrow-controls {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.arrow-row {
  display: flex;
  gap: 10px;
  justify-content: center;
}

.arrow-btn {
  width: 60px;
  height: 60px;
  border: 2px solid #666;
  border-radius: 8px;
  background: white;
  font-size: 20px;
  cursor: pointer;
  transition: all 0.3s;
  display: flex;
  align-items: center;
  justify-content: center;
}

.arrow-btn:focus {
  outline: none;
  background: #e3f2fd;
  border-color: #2196f3;
}

.arrow-btn:hover {
  transform: scale(1.1);
}

.up { border-color: #4caf50; color: #4caf50; }
.down { border-color: #2196f3; color: #2196f3; }
.left { border-color: #ff9800; color: #ff9800; }
.right { border-color: #9c27b0; color: #9c27b0; }

.arrow-group p {
  text-align: center;
  margin: 15px 0 0 0;
  color: #666;
  font-style: italic;
}

.key-logs {
  margin-top: 30px;
  padding: 20px;
  background: #2c3e50;
  border-radius: 8px;
  color: white;
}

.key-logs h3 {
  margin: 0 0 15px 0;
  color: #42b883;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: #34495e;
  border-radius: 4px;
  border-left: 4px solid #42b883;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

5.2 系统修饰符

<template>
  <div class="system-modifier-demo">
    <h2>系统修饰符 - 组合键处理</h2>
    
    <div class="demo-area">
      <div class="modifier-group">
        <h3>系统修饰符测试</h3>
        <div class="key-combinations">
          <div class="key-item" @click.ctrl="handleCtrlClick">
            Ctrl + 点击
          </div>
          <div class="key-item" @click.alt="handleAltClick">
            Alt + 点击
          </div>
          <div class="key-item" @click.shift="handleShiftClick">
            Shift + 点击
          </div>
          <div class="key-item" @click.meta="handleMetaClick">
            Meta (Cmd) + 点击
          </div>
        </div>
      </div>
      
      <div class="exact-modifier">
        <h3>.exact 修饰符 - 精确匹配</h3>
        <div class="exact-combinations">
          <button @click="handleAnyClick" class="exact-btn">
            任意点击
          </button>
          <button @click.ctrl="handleCtrlOnlyClick" class="exact-btn">
            Ctrl + 点击
          </button>
          <button @click.ctrl.exact="handleExactCtrlClick" class="exact-btn exact">
            .exact Ctrl (仅 Ctrl)
          </button>
        </div>
      </div>
    </div>

    <div class="system-logs">
      <h3>系统修饰符事件日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 8) {
    logs.value.pop()
  }
}

const handleCtrlClick = () => {
  addLog('🎛️ Ctrl + 点击')
}

const handleAltClick = () => {
  addLog('⎇ Alt + 点击')
}

const handleShiftClick = () => {
  addLog('⇧ Shift + 点击')
}

const handleMetaClick = () => {
  addLog('⌘ Meta (Cmd) + 点击')
}

const handleAnyClick = () => {
  addLog('🔄 任意点击 (无修饰符)')
}

const handleCtrlOnlyClick = () => {
  addLog('🎛️ Ctrl + 点击 (可能包含其他修饰符)')
}

const handleExactCtrlClick = () => {
  addLog('🎛️ .exact Ctrl + 点击 (仅 Ctrl,无其他修饰符)')
}
</script>

<style scoped>
.system-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.modifier-group, .exact-modifier {
  margin: 30px 0;
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
}

.modifier-group h3, .exact-modifier h3 {
  margin: 0 0 15px 0;
  color: #333;
}

.key-combinations {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 15px;
}

.key-item {
  padding: 20px;
  background: white;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
  font-weight: bold;
}

.key-item:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.key-item:nth-child(1) { border-color: #2196f3; color: #2196f3; }
.key-item:nth-child(2) { border-color: #ff9800; color: #ff9800; }
.key-item:nth-child(3) { border-color: #4caf50; color: #4caf50; }
.key-item:nth-child(4) { border-color: #9c27b0; color: #9c27b0; }

.exact-combinations {
  display: flex;
  gap: 15px;
  flex-wrap: wrap;
}

.exact-btn {
  padding: 12px 20px;
  border: 2px solid #666;
  border-radius: 6px;
  background: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.exact-btn:hover {
  background: #f5f5f5;
}

.exact-btn.exact {
  border-color: #e91e63;
  color: #e91e63;
  font-weight: bold;
}

.system-logs {
  margin-top: 30px;
  padding: 20px;
  background: #2c3e50;
  border-radius: 8px;
  color: white;
}

.system-logs h3 {
  margin: 0 0 15px 0;
  color: #42b883;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: #34495e;
  border-radius: 4px;
  border-left: 4px solid #42b883;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

六、 鼠标按键修饰符

6.1 鼠标按键修饰符使用

<template>
  <div class="mouse-modifier-demo">
    <h2>鼠标按键修饰符</h2>
    
    <div class="demo-area">
      <div class="mouse-test-area">
        <div 
          class="click-zone"
          @click.left="handleLeftClick"
          @click.middle="handleMiddleClick"
          @click.right="handleRightClick"
        >
          <p>在此区域测试鼠标按键:</p>
          <ul>
            <li>左键点击 - 正常点击</li>
            <li>中键点击 - 鼠标滚轮点击</li>
            <li>右键点击 - 弹出上下文菜单</li>
          </ul>
        </div>
      </div>
      
      <div class="context-menu-info">
        <p><strong>注意:</strong>右键点击时,使用 <code>.prevent</code> 可以阻止浏览器默认的上下文菜单:</p>
        <div 
          class="prevent-context-zone"
          @click.right.prevent="handlePreventRightClick"
        >
          右键点击这里不会显示浏览器菜单
        </div>
      </div>
    </div>

    <div class="mouse-logs">
      <h3>鼠标事件日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 6) {
    logs.value.pop()
  }
}

const handleLeftClick = () => {
  addLog('🖱️ 鼠标左键点击')
}

const handleMiddleClick = () => {
  addLog('🎯 鼠标中键点击')
}

const handleRightClick = (event) => {
  addLog('📋 鼠标右键点击 (会显示浏览器上下文菜单)')
}

const handlePreventRightClick = () => {
  addLog('🚫 鼠标右键点击 (使用 .prevent,不显示浏览器菜单)')
  // 可以在这里显示自定义上下文菜单
}
</script>

<style scoped>
.mouse-modifier-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.mouse-test-area {
  margin: 30px 0;
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
}

.click-zone {
  padding: 40px;
  background: white;
  border: 3px dashed #2196f3;
  border-radius: 8px;
  text-align: center;
  cursor: pointer;
  transition: background 0.3s;
}

.click-zone:hover {
  background: #e3f2fd;
}

.click-zone p {
  margin: 0 0 15px 0;
  font-weight: bold;
  color: #1976d2;
}

.click-zone ul {
  text-align: left;
  display: inline-block;
  margin: 0;
  color: #333;
}

.click-zone li {
  margin: 8px 0;
}

.context-menu-info {
  margin: 30px 0;
  padding: 25px;
  background: #fff3e0;
  border-radius: 8px;
}

.context-menu-info p {
  margin: 0 0 15px 0;
  color: #e65100;
}

.prevent-context-zone {
  padding: 20px;
  background: #ffebee;
  border: 2px solid #f44336;
  border-radius: 6px;
  text-align: center;
  cursor: pointer;
  font-weight: bold;
  color: #d32f2f;
}

.mouse-logs {
  margin-top: 30px;
  padding: 20px;
  background: #2c3e50;
  border-radius: 8px;
  color: white;
}

.mouse-logs h3 {
  margin: 0 0 15px 0;
  color: #42b883;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: #34495e;
  border-radius: 4px;
  border-left: 4px solid #42b883;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

七、 事件修饰符的组合使用

7.1 修饰符链式调用

<template>
  <div class="combined-modifier-demo">
    <h2>事件修饰符组合使用</h2>
    
    <div class="demo-area">
      <div class="combination-examples">
        <div class="example">
          <h3>1. 阻止冒泡 + 阻止默认行为</h3>
          <a 
            href="#"
            @click.prevent.stop="handlePreventStop"
            class="combined-link"
          >
            @click.prevent.stop
          </a>
          <p>既阻止链接跳转,又阻止事件冒泡</p>
        </div>
        
        <div class="example">
          <h3>2. 捕获阶段 + 只触发一次</h3>
          <div 
            @click.capture.once="handleCaptureOnce"
            class="capture-once-box"
          >
            @click.capture.once
            <button>内部按钮</button>
          </div>
          <p>在捕获阶段触发,且只触发一次</p>
        </div>
        
        <div class="example">
          <h3>3. 精确组合键 + 阻止默认</h3>
          <button 
            @keydown.ctrl.exact.prevent="handleExactCtrlPrevent"
            class="exact-ctrl-btn"
          >
            聚焦后按 Ctrl (精确)
          </button>
          <p>精确匹配 Ctrl 键,阻止默认行为</p>
        </div>
        
        <div class="example">
          <h3>4. 自身检查 + 阻止冒泡</h3>
          <div 
            @click.self.stop="handleSelfStop"
            class="self-stop-box"
          >
            @click.self.stop
            <button>点击按钮不会触发</button>
          </div>
          <p>只有点击容器本身才触发,并阻止冒泡</p>
        </div>
      </div>
    </div>

    <div class="combination-logs">
      <h3>组合修饰符事件日志:</h3>
      <div v-for="(log, index) in logs" :key="index" class="log-item">
        {{ log }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const logs = ref([])

const addLog = (message) => {
  const timestamp = new Date().toLocaleTimeString()
  logs.value.unshift(`[${timestamp}] ${message}`)
  if (logs.value.length > 8) {
    logs.value.pop()
  }
}

const handlePreventStop = () => {
  addLog('🔗 prevent.stop: 阻止跳转和冒泡')
}

const handleCaptureOnce = () => {
  addLog('🎯 capture.once: 捕获阶段触发,只触发一次')
}

const handleExactCtrlPrevent = (event) => {
  addLog('⌨️ ctrl.exact.prevent: 精确 Ctrl,阻止默认行为')
  event.preventDefault()
}

const handleSelfStop = () => {
  addLog('🎯 self.stop: 仅自身触发,阻止冒泡')
}
</script>

<style scoped>
.combined-modifier-demo {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.combination-examples {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
  margin: 30px 0;
}

.example {
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #e9ecef;
}

.example h3 {
  margin: 0 0 15px 0;
  color: #333;
  font-size: 16px;
}

.example p {
  margin: 10px 0 0 0;
  color: #666;
  font-size: 14px;
  font-style: italic;
}

.combined-link {
  display: block;
  padding: 12px;
  background: #e3f2fd;
  border: 2px solid #2196f3;
  border-radius: 6px;
  text-decoration: none;
  color: #1976d2;
  text-align: center;
  font-weight: bold;
  transition: background 0.3s;
}

.combined-link:hover {
  background: #bbdefb;
}

.capture-once-box {
  padding: 20px;
  background: #fff3e0;
  border: 2px solid #ff9800;
  border-radius: 6px;
  text-align: center;
  cursor: pointer;
  font-weight: bold;
  color: #e65100;
}

.capture-once-box button {
  margin-top: 10px;
  padding: 8px 16px;
  background: #ff9800;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.exact-ctrl-btn {
  width: 100%;
  padding: 12px;
  background: #fce4ec;
  border: 2px solid #e91e63;
  border-radius: 6px;
  color: #c2185b;
  font-weight: bold;
  cursor: pointer;
  transition: background 0.3s;
}

.exact-ctrl-btn:focus {
  outline: none;
  background: #f8bbd9;
}

.self-stop-box {
  padding: 20px;
  background: #e8f5e8;
  border: 2px solid #4caf50;
  border-radius: 6px;
  text-align: center;
  cursor: pointer;
  font-weight: bold;
  color: #2e7d32;
}

.self-stop-box button {
  margin-top: 10px;
  padding: 8px 16px;
  background: #4caf50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.combination-logs {
  margin-top: 30px;
  padding: 20px;
  background: #2c3e50;
  border-radius: 8px;
  color: white;
}

.combination-logs h3 {
  margin: 0 0 15px 0;
  color: #42b883;
}

.log-item {
  padding: 10px 15px;
  margin: 5px 0;
  background: #34495e;
  border-radius: 4px;
  border-left: 4px solid #42b883;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

八、 最佳实践和注意事项

8.1 事件修饰符最佳实践

<template>
  <div class="best-practices-demo">
    <h2>事件修饰符最佳实践</h2>
    
    <div class="practices">
      <div class="practice-item good">
        <h3>✅ 推荐做法</h3>
        <div class="code-example">
          <pre><code>&lt;!-- 清晰的修饰符顺序 --&gt;
&lt;form @submit.prevent.stop="handleSubmit"&gt;
&lt;!-- 适当的修饰符组合 --&gt;
&lt;a @click.prevent="handleLinkClick"&gt;
&lt;!-- 使用 .exact 精确控制 --&gt;
&lt;button @keyup.ctrl.exact="handleExactCtrl"&gt;</code></pre>
        </div>
      </div>
      
      <div class="practice-item bad">
        <h3>❌ 避免做法</h3>
        <div class="code-example">
          <pre><code>&lt;!-- 过度使用修饰符 --&gt;
&lt;button @click.prevent.stop.self="handleClick"&gt;
&lt;!-- 混淆的修饰符顺序 --&gt;
&lt;form @submit.stop.prevent="handleSubmit"&gt;
&lt;!-- 不必要的修饰符 --&gt;
&lt;div @click.self.prevent="handleClick"&gt;</code></pre>
        </div>
      </div>
    </div>

    <div class="performance-tips">
      <h3>性能提示</h3>
      <ul>
        <li>使用 <code>.passive</code> 改善滚动性能,特别是移动端</li>
        <li>避免在频繁触发的事件上使用复杂的修饰符组合</li>
        <li>使用 <code>.once</code> 清理不需要持续监听的事件</li>
        <li>合理使用 <code>.prevent</code> 避免不必要的默认行为阻止</li>
      </ul>
    </div>

    <div class="accessibility-considerations">
      <h3>可访问性考虑</h3>
      <ul>
        <li>确保键盘导航支持所有交互功能</li>
        <li>使用适当的 ARIA 标签描述交互行为</li>
        <li>测试屏幕阅读器兼容性</li>
        <li>提供键盘快捷键的视觉提示</li>
      </ul>
    </div>
  </div>
</template>

<script setup>
// 最佳实践示例代码
const handleSubmit = () => {
  console.log('表单提交处理')
}

const handleLinkClick = () => {
  console.log('链接点击处理')
}

const handleExactCtrl = () => {
  console.log('精确 Ctrl 键处理')
}
</script>

<style scoped>
.best-practices-demo {
  padding: 20px;
  max-width: 900px;
  margin: 0 auto;
}

.practices {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 30px;
  margin: 30px 0;
}

.practice-item {
  padding: 25px;
  border-radius: 8px;
}

.practice-item.good {
  background: #e8f5e8;
  border: 2px solid #4caf50;
}

.practice-item.bad {
  background: #ffebee;
  border: 2px solid #f44336;
}

.practice-item h3 {
  margin: 0 0 15px 0;
  color: inherit;
}

.code-example {
  background: white;
  border-radius: 6px;
  padding: 15px;
  overflow-x: auto;
}

.code-example pre {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.4;
}

.performance-tips, .accessibility-considerations {
  margin: 30px 0;
  padding: 25px;
  background: #e3f2fd;
  border-radius: 8px;
}

.performance-tips h3, .accessibility-considerations h3 {
  margin: 0 0 15px 0;
  color: #1976d2;
}

.performance-tips ul, .accessibility-considerations ul {
  margin: 0;
  color: #333;
}

.performance-tips li, .accessibility-considerations li {
  margin: 8px 0;
}

.performance-tips code, .accessibility-considerations code {
  background: #fff;
  padding: 2px 6px;
  border-radius: 3px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
}
</style>

九、 总结

9.1 事件修饰符核心价值

  1. 声明式编程:以声明的方式表达事件行为意图
  2. 代码简洁:减少样板代码,提高开发效率
  3. 可读性强:代码意图一目了然
  4. 维护方便:修改事件行为只需改动模板

9.2 事件修饰符分类总结

类别 主要修饰符 使用场景
事件传播 .stop .capture .self 控制事件传播流程
默认行为 .prevent .passive 管理浏览器默认行为
按键处理 .enter .tab .esc 键盘交互处理
系统修饰 .ctrl .alt .shift .meta 组合键操作
精确控制 .exact 精确匹配修饰符
鼠标按键 .left .right .middle 区分鼠标按键
次数控制 .once 一次性事件处理

9.3 最佳实践要点

  1. 合理排序:按照 .capture.once.passive.prevent.stop.self 的顺序
  2. 适度使用:避免过度复杂的修饰符组合
  3. 性能考虑:在频繁事件上使用 .passive
  4. 可访问性:确保键盘导航支持所有功能

Vue3 的事件修饰符提供了一种优雅而强大的方式来处理 DOM 事件细节,通过合理使用这些修饰符,可以编写出更加简洁、可读和可维护的 Vue 代码。


如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。在这里插入图片描述

Vue3 组件懒加载深度解析:从原理到极致优化的完整指南

摘要

组件懒加载是现代前端性能优化的核心技术,Vue3 提供了多种强大的懒加载方案。本文将深入探讨 Vue3 中组件懒加载的实现原理、使用场景、性能优化策略,通过详细的代码示例、执行流程分析和实际项目案例,帮助你全面掌握 Vue3 组件懒加载的完整知识体系。


一、 什么是组件懒加载?为什么需要它?

1.1 传统组件加载的问题

在传统的 Vue 应用中,所有组件通常被打包到一个 JavaScript 文件中:

// 传统同步导入方式
import Home from './components/Home.vue'
import About from './components/About.vue'
import Contact from './components/Contact.vue'

const app = createApp({
  components: {
    Home,
    About,
    Contact
  }
})

传统方式的问题:

  • 首屏加载缓慢:用户需要下载整个应用代码才能看到首屏内容
  • 资源浪费:用户可能永远不会访问某些页面,但依然加载了对应组件
  • 用户体验差:特别是对于移动端用户和网络条件较差的场景
  • 缓存效率低:整个应用打包成一个文件,任何改动都会使缓存失效

1.2 组件懒加载的解决方案

懒加载(Lazy Loading)也称为代码分割(Code Splitting),它允许我们将代码分割成多个 chunk,只在需要时加载:

// 懒加载方式
const Home = () => import('./components/Home.vue')
const About = () => import('./components/About.vue')
const Contact = () => import('./components/Contact.vue')

懒加载的优势:

  • 更快的首屏加载:只加载当前页面需要的代码
  • 按需加载:根据用户操作动态加载组件
  • 更好的缓存:独立的 chunk 可以独立缓存
  • 优化用户体验:减少初始加载时间

二、 Vue3 组件懒加载核心概念

2.1 懒加载的工作原理

流程图:组件懒加载完整工作流程

flowchart TD
    A[用户访问应用] --> B[加载主包 main.js]
    B --> C[渲染首屏内容]
    C --> D{用户触发懒加载?}
    
    D -- 路由切换 --> E[加载对应路由组件]
    D -- 条件渲染 --> F[加载条件组件]
    D -- 用户交互 --> G[加载交互组件]
    
    E --> H[显示加载状态]
    F --> H
    G --> H
    
    H --> I[网络请求对应chunk]
    I --> J{加载成功?}
    J -- 是 --> K[渲染懒加载组件]
    J -- 否 --> L[显示错误状态]
    
    K --> M[组件激活使用]
    L --> N[提供重试机制]

2.2 懒加载的核心概念

  • 代码分割:将代码拆分成多个小块(chunks)
  • 动态导入:使用 import() 函数在运行时加载模块
  • 组件工厂:返回 Promise 的函数,解析为组件定义
  • 加载状态:在组件加载期间显示的回退内容
  • 错误处理:加载失败时的降级方案

三、 Vue3 组件懒加载基础实现

3.1 使用 defineAsyncComponent 实现懒加载

Vue3 提供了 defineAsyncComponent 函数来创建异步组件:

<template>
  <div class="basic-lazy-demo">
    <h2>基础懒加载示例</h2>
    
    <div class="controls">
      <button @click="showLazyComponent = !showLazyComponent" class="btn-primary">
        {{ showLazyComponent ? '隐藏' : '显示' }} 懒加载组件
      </button>
    </div>

    <div class="component-area">
      <!-- 同步加载的组件 -->
      <div v-if="!showLazyComponent" class="sync-component">
        <h3>同步加载的组件</h3>
        <p>这个组件在主包中,立即可用</p>
      </div>

      <!-- 懒加载的组件 -->
      <Suspense v-else>
        <template #default>
          <LazyBasicComponent />
        </template>
        <template #fallback>
          <div class="loading-state">
            <div class="spinner"></div>
            <p>懒加载组件加载中...</p>
          </div>
        </template>
      </Suspense>
    </div>

    <div class="bundle-info">
      <h3>打包信息分析</h3>
      <div class="info-grid">
        <div class="info-item">
          <span>主包大小:</span>
          <strong>~15KB</strong>
        </div>
        <div class="info-item">
          <span>懒加载组件大小:</span>
          <strong>~8KB (单独chunk)</strong>
        </div>
        <div class="info-item">
          <span>加载方式:</span>
          <strong>按需加载</strong>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const showLazyComponent = ref(false)

// 使用 defineAsyncComponent 定义懒加载组件
const LazyBasicComponent = defineAsyncComponent(() => 
  import('./components/LazyBasicComponent.vue')
)
</script>

<style scoped>
.basic-lazy-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
  font-family: Arial, sans-serif;
}

.controls {
  margin: 20px 0;
  text-align: center;
}

.btn-primary {
  padding: 12px 24px;
  background: #42b883;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
  transition: background 0.3s;
}

.btn-primary:hover {
  background: #369870;
}

.component-area {
  margin: 30px 0;
  min-height: 200px;
}

.sync-component {
  padding: 30px;
  background: #e3f2fd;
  border: 2px solid #2196f3;
  border-radius: 8px;
  text-align: center;
}

.sync-component h3 {
  margin: 0 0 15px 0;
  color: #1976d2;
}

.loading-state {
  padding: 40px;
  background: #fff3e0;
  border: 2px dashed #ff9800;
  border-radius: 8px;
  text-align: center;
  color: #e65100;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #ff9800;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 16px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.bundle-info {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

.bundle-info h3 {
  margin: 0 0 15px 0;
  color: #333;
}

.info-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
}

.info-item {
  display: flex;
  justify-content: space-between;
  padding: 12px;
  background: white;
  border-radius: 6px;
  border-left: 4px solid #42b883;
}

.info-item span {
  color: #666;
}

.info-item strong {
  color: #2c3e50;
}
</style>

LazyBasicComponent.vue

<template>
  <div class="lazy-basic-component">
    <h3>🚀 懒加载组件已加载!</h3>
    <div class="component-content">
      <p>这个组件是通过懒加载方式动态加载的</p>
      <div class="features">
        <div class="feature">
          <span class="icon">📦</span>
          <span>独立 chunk</span>
        </div>
        <div class="feature">
          <span class="icon">⚡</span>
          <span>按需加载</span>
        </div>
        <div class="feature">
          <span class="icon">🎯</span>
          <span>性能优化</span>
        </div>
      </div>
      <p class="load-time">组件加载时间: {{ loadTime }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const loadTime = ref('')

onMounted(() => {
  loadTime.value = new Date().toLocaleTimeString()
  console.log('LazyBasicComponent 已挂载')
})
</script>

<style scoped>
.lazy-basic-component {
  padding: 30px;
  background: #e8f5e8;
  border: 2px solid #4caf50;
  border-radius: 8px;
  text-align: center;
}

.lazy-basic-component h3 {
  margin: 0 0 20px 0;
  color: #2e7d32;
  font-size: 24px;
}

.component-content {
  max-width: 400px;
  margin: 0 auto;
}

.features {
  display: flex;
  justify-content: space-around;
  margin: 25px 0;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.feature {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

.feature .icon {
  font-size: 24px;
}

.feature span:last-child {
  font-size: 14px;
  color: #666;
}

.load-time {
  margin: 20px 0 0 0;
  padding: 10px;
  background: #2c3e50;
  color: white;
  border-radius: 4px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

3.2 路由级别的懒加载

在实际项目中,路由级别的懒加载是最常见的应用场景:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')  // 懒加载首页
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue') // 懒加载关于页
  },
  {
    path: '/products',
    name: 'Products',
    component: () => import('@/views/Products.vue') // 懒加载产品页
  },
  {
    path: '/contact',
    name: 'Contact',
    component: () => import('@/views/Contact.vue') // 懒加载联系页
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

带加载状态的路由懒加载:

<template>
  <div class="route-lazy-demo">
    <h2>路由级别懒加载示例</h2>
    
    <nav class="nav-tabs">
      <router-link 
        v-for="tab in tabs" 
        :key="tab.path"
        :to="tab.path"
        class="nav-tab"
        active-class="active"
      >
        {{ tab.name }}
      </router-link>
    </nav>

    <div class="route-content">
      <RouterView v-slot="{ Component }">
        <Suspense>
          <template #default>
            <component :is="Component" />
          </template>
          <template #fallback>
            <div class="route-loading">
              <div class="loading-content">
                <div class="spinner large"></div>
                <p>页面加载中...</p>
                <div class="loading-dots">
                  <span></span>
                  <span></span>
                  <span></span>
                </div>
              </div>
            </div>
          </template>
        </Suspense>
      </RouterView>
    </div>

    <div class="route-info">
      <h3>路由懒加载信息</h3>
      <div class="chunk-status">
        <div 
          v-for="chunk in chunkStatus" 
          :key="chunk.name"
          class="chunk-item"
          :class="chunk.status"
        >
          <span class="chunk-name">{{ chunk.name }}</span>
          <span class="chunk-status">{{ chunk.status }}</span>
          <span class="chunk-size">{{ chunk.size }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

const tabs = [
  { path: '/', name: '首页' },
  { path: '/about', name: '关于我们' },
  { path: '/products', name: '产品服务' },
  { path: '/contact', name: '联系我们' }
]

const chunkStatus = ref([
  { name: 'home', status: 'loaded', size: '15KB' },
  { name: 'about', status: 'pending', size: '12KB' },
  { name: 'products', status: 'pending', size: '25KB' },
  { name: 'contact', status: 'pending', size: '8KB' }
])

// 监听路由变化,模拟 chunk 加载状态
watch(() => route.name, (newRouteName) => {
  const chunkName = newRouteName.toLowerCase()
  chunkStatus.value.forEach(chunk => {
    if (chunk.name === chunkName) {
      chunk.status = 'loaded'
    }
  })
})
</script>

<style scoped>
.route-lazy-demo {
  max-width: 1000px;
  margin: 0 auto;
  padding: 20px;
}

.nav-tabs {
  display: flex;
  background: #f8f9fa;
  border-radius: 8px;
  padding: 5px;
  margin: 20px 0;
}

.nav-tab {
  flex: 1;
  padding: 12px 20px;
  text-align: center;
  text-decoration: none;
  color: #666;
  border-radius: 6px;
  transition: all 0.3s;
}

.nav-tab:hover {
  background: #e9ecef;
  color: #333;
}

.nav-tab.active {
  background: #42b883;
  color: white;
}

.route-content {
  min-height: 400px;
  margin: 30px 0;
}

.route-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 300px;
  background: #f8f9fa;
  border-radius: 8px;
  border: 2px dashed #dee2e6;
}

.loading-content {
  text-align: center;
  color: #666;
}

.spinner.large {
  width: 60px;
  height: 60px;
  border: 6px solid #f3f3f3;
  border-top: 6px solid #42b883;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

.loading-dots {
  display: flex;
  justify-content: center;
  gap: 4px;
  margin-top: 15px;
}

.loading-dots span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #42b883;
  animation: bounce 1.4s infinite ease-in-out;
}

.loading-dots span:nth-child(1) { animation-delay: -0.32s; }
.loading-dots span:nth-child(2) { animation-delay: -0.16s; }

@keyframes bounce {
  0%, 80%, 100% {
    transform: scale(0);
  }
  40% {
    transform: scale(1);
  }
}

.route-info {
  margin-top: 30px;
  padding: 20px;
  background: #2c3e50;
  border-radius: 8px;
  color: white;
}

.route-info h3 {
  margin: 0 0 15px 0;
  color: #42b883;
}

.chunk-status {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.chunk-item {
  display: flex;
  justify-content: space-between;
  padding: 12px 15px;
  background: #34495e;
  border-radius: 6px;
  transition: all 0.3s;
}

.chunk-item.loaded {
  border-left: 4px solid #27ae60;
}

.chunk-item.pending {
  border-left: 4px solid #f39c12;
  opacity: 0.7;
}

.chunk-name {
  font-weight: bold;
  color: #ecf0f1;
}

.chunk-status {
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: bold;
  text-transform: uppercase;
}

.chunk-item.loaded .chunk-status {
  background: #27ae60;
  color: white;
}

.chunk-item.pending .chunk-status {
  background: #f39c12;
  color: white;
}

.chunk-size {
  color: #bdc3c7;
  font-family: 'Courier New', monospace;
}
</style>

四、 高级懒加载配置与优化

4.1 完整的异步组件配置

Vue3 的 defineAsyncComponent 支持完整的配置选项:

<template>
  <div class="advanced-lazy-demo">
    <h2>高级懒加载配置</h2>
    
    <div class="controls">
      <button @click="loadComponent('success')" class="btn-success">
        加载成功组件
      </button>
      <button @click="loadComponent('error')" class="btn-error">
        加载错误组件
      </button>
      <button @click="loadComponent('timeout')" class="btn-warning">
        加载超时组件
      </button>
      <button @click="loadComponent('delay')" class="btn-info">
        加载延迟组件
      </button>
    </div>

    <div class="component-area">
      <AdvancedAsyncComponent 
        v-if="currentComponent"
        :key="componentKey"
      />
    </div>

    <div class="config-info">
      <h3>异步组件配置说明</h3>
      <div class="config-grid">
        <div class="config-item">
          <h4>loader</h4>
          <p>组件加载函数,返回 Promise</p>
        </div>
        <div class="config-item">
          <h4>loadingComponent</h4>
          <p>加载过程中显示的组件</p>
        </div>
        <div class="config-item">
          <h4>errorComponent</h4>
          <p>加载失败时显示的组件</p>
        </div>
        <div class="config-item">
          <h4>delay</h4>
          <p>延迟显示加载状态(避免闪烁)</p>
        </div>
        <div class="config-item">
          <h4>timeout</h4>
          <p>加载超时时间</p>
        </div>
        <div class="config-item">
          <h4>onError</h4>
          <p>错误处理回调函数</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue'
import LoadingSpinner from './components/LoadingSpinner.vue'
import ErrorDisplay from './components/ErrorDisplay.vue'

const currentComponent = ref(null)
const componentKey = ref(0)

// 模拟不同加载场景的组件
const componentConfigs = {
  success: () => import('./components/SuccessComponent.vue'),
  error: () => Promise.reject(new Error('模拟加载错误')),
  timeout: () => new Promise(() => {}), // 永远不会 resolve
  delay: () => new Promise(resolve => {
    setTimeout(() => {
      resolve(import('./components/DelayedComponent.vue'))
    }, 3000)
  })
}

// 高级异步组件配置
const AdvancedAsyncComponent = defineAsyncComponent({
  // 加载器函数
  loader: () => currentComponent.value?.loader() || Promise.reject(new Error('未选择组件')),
  
  // 加载中显示的组件
  loadingComponent: LoadingSpinner,
  
  // 加载失败显示的组件
  errorComponent: ErrorDisplay,
  
  // 延迟显示加载状态(避免闪烁)
  delay: 200,
  
  // 超时时间(毫秒)
  timeout: 5000,
  
  // 错误处理函数
  onError: (error, retry, fail, attempts) => {
    console.error(`组件加载失败 (尝试次数: ${attempts}):`, error)
    
    // 最多重试 3 次
    if (attempts <= 3) {
      console.log(`第 ${attempts} 次重试...`)
      retry()
    } else {
      fail()
    }
  },
  
  // 可挂起(Suspense 相关)
  suspensible: false
})

const loadComponent = (type) => {
  currentComponent.value = {
    loader: componentConfigs[type],
    type: type
  }
  componentKey.value++ // 强制重新创建组件
}
</script>

<style scoped>
.advanced-lazy-demo {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.controls {
  display: flex;
  gap: 15px;
  justify-content: center;
  margin: 30px 0;
  flex-wrap: wrap;
}

.btn-success { background: #27ae60; }
.btn-error { background: #e74c3c; }
.btn-warning { background: #f39c12; }
.btn-info { background: #3498db; }

.btn-success, .btn-error, .btn-warning, .btn-info {
  color: white;
  border: none;
  padding: 12px 20px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.btn-success:hover { background: #229954; }
.btn-error:hover { background: #c0392b; }
.btn-warning:hover { background: #e67e22; }
.btn-info:hover { background: #2980b9; }

.component-area {
  min-height: 300px;
  margin: 30px 0;
  border: 2px dashed #ddd;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.config-info {
  margin-top: 40px;
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
}

.config-info h3 {
  margin: 0 0 20px 0;
  color: #2c3e50;
  text-align: center;
}

.config-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
}

.config-item {
  padding: 20px;
  background: white;
  border-radius: 8px;
  border-left: 4px solid #42b883;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.config-item h4 {
  margin: 0 0 10px 0;
  color: #42b883;
  font-size: 16px;
}

.config-item p {
  margin: 0;
  color: #666;
  line-height: 1.5;
}
</style>

LoadingSpinner.vue

<template>
  <div class="loading-spinner">
    <div class="spinner-container">
      <div class="spinner"></div>
      <p>组件加载中...</p>
      <div class="progress">
        <div class="progress-bar" :style="progressStyle"></div>
      </div>
      <p class="hint">这通常很快,请耐心等待</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const progress = ref(0)
let progressInterval

onMounted(() => {
  progressInterval = setInterval(() => {
    progress.value = Math.min(progress.value + Math.random() * 10, 90)
  }, 200)
})

onUnmounted(() => {
  clearInterval(progressInterval)
})

const progressStyle = {
  width: `${progress.value}%`
}
</script>

<style scoped>
.loading-spinner {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px;
  text-align: center;
}

.spinner-container {
  max-width: 300px;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #42b883;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.progress {
  width: 100%;
  height: 6px;
  background: #f0f0f0;
  border-radius: 3px;
  margin: 15px 0;
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  background: linear-gradient(90deg, #42b883, #369870);
  border-radius: 3px;
  transition: width 0.3s ease;
}

.hint {
  font-size: 12px;
  color: #999;
  margin: 10px 0 0 0;
}
</style>

ErrorDisplay.vue

<template>
  <div class="error-display">
    <div class="error-container">
      <div class="error-icon">❌</div>
      <h3>组件加载失败</h3>
      <p class="error-message">{{ error?.message || '未知错误' }}</p>
      <div class="error-actions">
        <button @click="retry" class="retry-btn">
          🔄 重试加载
        </button>
        <button @click="reset" class="reset-btn">
          🏠 返回首页
        </button>
      </div>
      <p class="error-hint">如果问题持续存在,请联系技术支持</p>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  error: {
    type: Error,
    default: null
  }
})

const emit = defineEmits(['retry'])

const retry = () => {
  emit('retry')
}

const reset = () => {
  window.location.href = '/'
}
</script>

<style scoped>
.error-display {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px;
  text-align: center;
}

.error-container {
  max-width: 400px;
  padding: 30px;
  background: #fff5f5;
  border: 2px solid #fed7d7;
  border-radius: 8px;
}

.error-icon {
  font-size: 48px;
  margin-bottom: 20px;
}

.error-container h3 {
  margin: 0 0 15px 0;
  color: #e53e3e;
}

.error-message {
  color: #718096;
  margin-bottom: 20px;
  padding: 10px;
  background: white;
  border-radius: 4px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}

.error-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-bottom: 15px;
}

.retry-btn, .reset-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.retry-btn {
  background: #4299e1;
  color: white;
}

.retry-btn:hover {
  background: #3182ce;
}

.reset-btn {
  background: #e2e8f0;
  color: #4a5568;
}

.reset-btn:hover {
  background: #cbd5e0;
}

.error-hint {
  font-size: 12px;
  color: #a0aec0;
  margin: 0;
}
</style>

4.2 条件懒加载与预加载

<template>
  <div class="conditional-lazy-demo">
    <h2>条件懒加载与预加载策略</h2>
    
    <div class="strategies">
      <div class="strategy">
        <h3>1. 条件懒加载</h3>
        <div class="demo-section">
          <label class="toggle-label">
            <input type="checkbox" v-model="enableHeavyComponent">
            启用重型组件
          </label>
          <div class="component-container">
            <HeavyComponent v-if="enableHeavyComponent" />
          </div>
        </div>
      </div>

      <div class="strategy">
        <h3>2. 预加载策略</h3>
        <div class="demo-section">
          <div class="preload-buttons">
            <button @click="preloadComponent('chart')" class="preload-btn">
              预加载图表组件
            </button>
            <button @click="preloadComponent('editor')" class="preload-btn">
              预加载编辑器
            </button>
          </div>
          <div class="preload-status">
            <div 
              v-for="item in preloadStatus" 
              :key="item.name"
              class="status-item"
              :class="item.status"
            >
              <span>{{ item.name }}</span>
              <span class="status-dot"></span>
            </div>
          </div>
        </div>
      </div>

      <div class="strategy">
        <h3>3. 可见时加载</h3>
        <div class="demo-section">
          <div class="scroll-container">
            <div 
              v-for="n in 10" 
              :key="n"
              class="scroll-item"
            >
              <p>内容区块 {{ n }}</p>
              <LazyWhenVisible v-if="n === 5" />
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, defineAsyncComponent, onMounted } from 'vue'

// 1. 条件懒加载
const enableHeavyComponent = ref(false)
const HeavyComponent = defineAsyncComponent(() => 
  import('./components/HeavyComponent.vue')
)

// 2. 预加载策略
const preloadStatus = reactive([
  { name: '图表组件', status: 'pending' },
  { name: '编辑器组件', status: 'pending' }
])

const preloadedComponents = {}

const preloadComponent = async (type) => {
  const index = preloadStatus.findIndex(item => item.name.includes(type))
  if (index === -1) return

  preloadStatus[index].status = 'loading'
  
  try {
    if (type === 'chart') {
      preloadedComponents.chart = await import('./components/ChartComponent.vue')
    } else if (type === 'editor') {
      preloadedComponents.editor = await import('./components/EditorComponent.vue')
    }
    
    preloadStatus[index].status = 'loaded'
    console.log(`${type} 组件预加载完成`)
  } catch (error) {
    preloadStatus[index].status = 'error'
    console.error(`${type} 组件预加载失败:`, error)
  }
}

// 3. 可见时加载
const LazyWhenVisible = defineAsyncComponent(() => 
  import('./components/LazyWhenVisible.vue')
)

// 模拟预加载
onMounted(() => {
  // 空闲时预加载可能用到的组件
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      preloadComponent('chart')
    })
  }
})
</script>

<style scoped>
.conditional-lazy-demo {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.strategies {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
  gap: 30px;
  margin: 30px 0;
}

.strategy {
  padding: 25px;
  background: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #e9ecef;
}

.strategy h3 {
  margin: 0 0 20px 0;
  color: #2c3e50;
  font-size: 18px;
}

.demo-section {
  min-height: 200px;
}

.toggle-label {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 15px;
  cursor: pointer;
  font-weight: bold;
  color: #333;
}

.component-container {
  min-height: 150px;
  border: 2px dashed #ddd;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.preload-buttons {
  display: flex;
  gap: 10px;
  margin-bottom: 15px;
}

.preload-btn {
  padding: 10px 16px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.3s;
}

.preload-btn:hover {
  background: #2980b9;
}

.preload-status {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.status-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 12px;
  background: white;
  border-radius: 4px;
  border-left: 4px solid #bdc3c7;
}

.status-item.pending {
  border-left-color: #f39c12;
}

.status-item.loading {
  border-left-color: #3498db;
}

.status-item.loaded {
  border-left-color: #27ae60;
}

.status-item.error {
  border-left-color: #e74c3c;
}

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #bdc3c7;
}

.status-item.pending .status-dot { background: #f39c12; }
.status-item.loading .status-dot { 
  background: #3498db;
  animation: pulse 1.5s infinite;
}
.status-item.loaded .status-dot { background: #27ae60; }
.status-item.error .status-dot { background: #e74c3c; }

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.scroll-container {
  height: 300px;
  overflow-y: auto;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 10px;
}

.scroll-item {
  padding: 20px;
  margin: 10px 0;
  background: white;
  border-radius: 4px;
  border: 1px solid #f0f0f0;
  min-height: 80px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.scroll-item p {
  margin: 0;
  color: #666;
}
</style>

五、 性能优化与最佳实践

5.1 Webpack 打包优化配置

// vue.config.js
const { defineConfig } = require('@vue/cli-service')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = defineConfig({
  transpileDependencies: true,
  
  configureWebpack: {
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          // 第三方库单独打包
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            priority: 20,
            chunks: 'all'
          },
          // Vue 相关库单独打包
          vue: {
            test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
            name: 'vue-vendors',
            priority: 30,
            chunks: 'all'
          },
          // 公共代码提取
          common: {
            name: 'common',
            minChunks: 2,
            priority: 10,
            chunks: 'all'
          }
        }
      }
    },
    plugins: [
      // 打包分析工具(开发时使用)
      process.env.NODE_ENV === 'development' && 
      new BundleAnalyzerPlugin({
        analyzerMode: 'server',
        openAnalyzer: false
      })
    ].filter(Boolean)
  },
  
  chainWebpack: config => {
    // 预加载配置
    config.plugin('preload').tap(options => {
      options[0] = {
        rel: 'preload',
        as(entry) {
          if (/\.css$/.test(entry)) return 'style'
          if (/\.(woff|woff2)$/.test(entry)) return 'font'
          return 'script'
        },
        include: 'initial',
        fileBlacklist: [/\.map$/, /hot-update\.js$/]
      }
      return options
    })
    
    //  prefetch 配置
    config.plugin('prefetch').tap(options => {
      options[0] = {
        rel: 'prefetch',
        include: 'asyncChunks'
      }
      return options
    })
  }
})

5.2 性能监控与错误追踪

<template>
  <div class="performance-monitor">
    <h2>懒加载性能监控</h2>
    
    <div class="metrics-dashboard">
      <div class="metric-cards">
        <div class="metric-card">
          <div class="metric-value">{{ metrics.totalLoads }}</div>
          <div class="metric-label">总加载次数</div>
        </div>
        <div class="metric-card">
          <div class="metric-value">{{ metrics.averageLoadTime }}ms</div>
          <div class="metric-label">平均加载时间</div>
        </div>
        <div class="metric-card">
          <div class="metric-value">{{ metrics.successRate }}%</div>
          <div class="metric-label">成功率</div>
        </div>
        <div class="metric-card">
          <div class="metric-value">{{ metrics.cacheHits }}</div>
          <div class="metric-label">缓存命中</div>
        </div>
      </div>

      <div class="load-timeline">
        <h3>组件加载时间线</h3>
        <div class="timeline">
          <div 
            v-for="event in loadEvents" 
            :key="event.id"
            class="timeline-event"
            :class="event.status"
          >
            <div class="event-time">{{ event.timestamp }}</div>
            <div class="event-name">{{ event.name }}</div>
            <div class="event-duration">{{ event.duration }}ms</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'

const metrics = reactive({
  totalLoads: 0,
  averageLoadTime: 0,
  successRate: 100,
  cacheHits: 0
})

const loadEvents = ref([])

// 监控组件加载性能
const monitorComponentLoad = (componentName) => {
  const startTime = performance.now()
  const eventId = Date.now()
  
  const loadEvent = {
    id: eventId,
    name: componentName,
    timestamp: new Date().toLocaleTimeString(),
    status: 'loading',
    duration: 0
  }
  
  loadEvents.value.unshift(loadEvent)
  if (loadEvents.value.length > 10) {
    loadEvents.value.pop()
  }
  
  metrics.totalLoads++
  
  return {
    success: () => {
      const endTime = performance.now()
      const duration = endTime - startTime
      
      loadEvent.status = 'success'
      loadEvent.duration = Math.round(duration)
      
      // 更新平均加载时间
      const totalTime = metrics.averageLoadTime * (metrics.totalLoads - 1) + duration
      metrics.averageLoadTime = Math.round(totalTime / metrics.totalLoads)
    },
    error: () => {
      const endTime = performance.now()
      const duration = endTime - startTime
      
      loadEvent.status = 'error'
      loadEvent.duration = Math.round(duration)
      
      // 更新成功率
      const successCount = Math.floor(metrics.totalLoads * (metrics.successRate / 100))
      metrics.successRate = Math.round((successCount / metrics.totalLoads) * 100)
    },
    cacheHit: () => {
      metrics.cacheHits++
    }
  }
}

// 示例:监控组件加载
const loadMonitoredComponent = async (componentName) => {
  const monitor = monitorComponentLoad(componentName)
  
  try {
    // 模拟组件加载
    await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 500))
    
    // 检查是否缓存命中
    if (Math.random() > 0.7) {
      monitor.cacheHit()
    }
    
    monitor.success()
    return true
  } catch (error) {
    monitor.error()
    return false
  }
}

// 模拟一些加载事件
onMounted(async () => {
  const components = ['首页', '用户面板', '设置页面', '数据分析', '文档查看']
  
  for (const component of components) {
    await loadMonitoredComponent(component)
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
})
</script>

<style scoped>
.performance-monitor {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.metrics-dashboard {
  margin: 30px 0;
}

.metric-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}

.metric-card {
  padding: 25px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  text-align: center;
  border-top: 4px solid #42b883;
}

.metric-value {
  font-size: 32px;
  font-weight: bold;
  color: #2c3e50;
  margin-bottom: 8px;
}

.metric-label {
  color: #7f8c8d;
  font-size: 14px;
}

.load-timeline {
  background: white;
  border-radius: 8px;
  padding: 25px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.load-timeline h3 {
  margin: 0 0 20px 0;
  color: #2c3e50;
}

.timeline {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.timeline-event {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 15px;
  border-radius: 6px;
  border-left: 4px solid #bdc3c7;
  transition: all 0.3s;
}

.timeline-event.loading {
  border-left-color: #3498db;
  background: #ebf5fb;
}

.timeline-event.success {
  border-left-color: #27ae60;
  background: #eafaf1;
}

.timeline-event.error {
  border-left-color: #e74c3c;
  background: #fdedec;
}

.event-time {
  font-size: 12px;
  color: #7f8c8d;
  min-width: 80px;
}

.event-name {
  flex: 1;
  font-weight: 500;
  color: #2c3e50;
}

.event-duration {
  font-family: 'Courier New', monospace;
  font-weight: bold;
  color: #34495e;
  min-width: 60px;
  text-align: right;
}
</style>

六、 实际项目中的应用场景

6.1 大型管理系统的懒加载策略

// src/utils/lazyLoading.js
export const createLazyComponent = (loader, options = {}) => {
  const defaultOptions = {
    loadingComponent: () => import('@/components/Loading/LoadingState.vue'),
    errorComponent: () => import('@/components/Error/ErrorState.vue'),
    delay: 200,
    timeout: 10000,
    retryAttempts: 3
  }
  
  return defineAsyncComponent({
    loader,
    ...defaultOptions,
    ...options
  })
}

// 业务组件懒加载
export const LazyUserManagement = createLazyComponent(
  () => import('@/views/UserManagement.vue'),
  { timeout: 15000 }
)

export const LazyDataAnalytics = createLazyComponent(
  () => import('@/views/DataAnalytics.vue')
)

export const LazyReportGenerator = createLazyComponent(
  () => import('@/views/ReportGenerator.vue')
)

// 功能模块懒加载
export const LazyRichEditor = createLazyComponent(
  () => import('@/components/Editors/RichEditor.vue')
)

export const LazyChartLibrary = createLazyComponent(
  () => import('@/components/Charts/ChartLibrary.vue')
)

// 预加载策略
export const preloadCriticalComponents = () => {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      // 预加载关键组件
      import('@/views/Dashboard.vue')
      import('@/components/Common/SearchBox.vue')
    })
  }
}

// 路由级别的分组懒加载
export const createRouteGroup = (groupName) => {
  return {
    user: () => import(/* webpackChunkName: "user-group" */ `@/views/${groupName}/User.vue`),
    profile: () => import(/* webpackChunkName: "user-group" */ `@/views/${groupName}/Profile.vue`),
    settings: () => import(/* webpackChunkName: "user-group" */ `@/views/${groupName}/Settings.vue`)
  }
}

6.2 基于用户行为的智能预加载

<template>
  <div class="smart-preload-demo">
    <h2>智能预加载策略</h2>
    
    <div class="user-journey">
      <div class="journey-step" @mouseenter="preloadStep('products')">
        <h3>1. 浏览产品</h3>
        <p>鼠标悬停预加载产品详情</p>
      </div>
      
      <div class="journey-step" @click="preloadStep('checkout')">
        <h3>2. 加入购物车</h3>
        <p>点击预加载结算页面</p>
      </div>
      
      <div class="journey-step" @touchstart="preloadStep('payment')">
        <h3>3. 结算支付</h3>
        <p>触摸预加载支付组件</p>
      </div>
    </div>

    <div class="preload-strategies">
      <h3>预加载策略状态</h3>
      <div class="strategy-grid">
        <div 
          v-for="strategy in strategies" 
          :key="strategy.name"
          class="strategy-item"
          :class="strategy.status"
        >
          <div class="strategy-icon">{{ strategy.icon }}</div>
          <div class="strategy-info">
            <div class="strategy-name">{{ strategy.name }}</div>
            <div class="strategy-desc">{{ strategy.description }}</div>
          </div>
          <div class="strategy-status">{{ strategy.status }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'

const strategies = reactive([
  {
    name: '悬停预加载',
    description: '鼠标悬停时预加载目标组件',
    icon: '🖱️',
    status: '等待触发',
    trigger: 'mouseenter'
  },
  {
    name: '点击预加载',
    description: '用户点击时预加载下一页面',
    icon: '👆',
    status: '等待触发',
    trigger: 'click'
  },
  {
    name: '触摸预加载',
    description: '移动端触摸时预加载',
    icon: '📱',
    status: '等待触发',
    trigger: 'touchstart'
  },
  {
    name: '空闲预加载',
    description: '浏览器空闲时预加载',
    icon: '💤',
    status: '等待触发',
    trigger: 'idle'
  }
])

const preloadedComponents = new Set()

const preloadStep = async (step) => {
  const strategy = strategies.find(s => s.trigger === step)
  if (strategy && strategy.status === '等待触发') {
    strategy.status = '加载中...'
    
    try {
      // 模拟组件预加载
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      strategy.status = '已加载'
      preloadedComponents.add(step)
      console.log(`✅ ${step} 组件预加载完成`)
    } catch (error) {
      strategy.status = '加载失败'
      console.error(`❌ ${step} 组件预加载失败:`, error)
    }
  }
}

// 空闲时预加载
onMounted(() => {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      const idleStrategy = strategies.find(s => s.trigger === 'idle')
      if (idleStrategy) {
        idleStrategy.status = '已加载'
        preloadedComponents.add('common')
        console.log('🕒 空闲时预加载完成')
      }
    })
  }
})
</script>

<style scoped>
.smart-preload-demo {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.user-journey {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
  margin: 30px 0;
}

.journey-step {
  padding: 30px;
  background: white;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
}

.journey-step:hover {
  border-color: #42b883;
  transform: translateY(-2px);
  box-shadow: 0 4px 15px rgba(66, 184, 131, 0.2);
}

.journey-step h3 {
  margin: 0 0 10px 0;
  color: #2c3e50;
}

.journey-step p {
  margin: 0;
  color: #7f8c8d;
  font-size: 14px;
}

.preload-strategies {
  margin-top: 40px;
}

.preload-strategies h3 {
  margin: 0 0 20px 0;
  color: #2c3e50;
}

.strategy-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 15px;
}

.strategy-item {
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  border-left: 4px solid #bdc3c7;
  transition: all 0.3s;
}

.strategy-item.等待触发 {
  border-left-color: #f39c12;
}

.strategy-item.加载中 {
  border-left-color: #3498db;
}

.strategy-item.已加载 {
  border-left-color: #27ae60;
}

.strategy-item.加载失败 {
  border-left-color: #e74c3c;
}

.strategy-icon {
  font-size: 24px;
}

.strategy-info {
  flex: 1;
}

.strategy-name {
  font-weight: bold;
  color: #2c3e50;
  margin-bottom: 4px;
}

.strategy-desc {
  font-size: 12px;
  color: #7f8c8d;
}

.strategy-status {
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: bold;
  text-transform: uppercase;
}

.strategy-item.等待触发 .strategy-status {
  background: #fff3cd;
  color: #856404;
}

.strategy-item.加载中 .strategy-status {
  background: #d1ecf1;
  color: #0c5460;
}

.strategy-item.已加载 .strategy-status {
  background: #d4edda;
  color: #155724;
}

.strategy-item.加载失败 .strategy-status {
  background: #f8d7da;
  color: #721c24;
}
</style>

七、 总结

7.1 Vue3 组件懒加载的核心价值

  1. 性能优化:显著减少首屏加载时间,提升用户体验
  2. 资源效率:按需加载,避免资源浪费
  3. 缓存优化:独立的 chunk 可以更好地利用浏览器缓存
  4. 用户体验:合理的加载状态和错误处理提升用户满意度

7.2 懒加载实现方式总结

方式 适用场景 优点 缺点
defineAsyncComponent 条件渲染组件 配置灵活,错误处理完善 需要手动管理加载状态
路由懒加载 页面级组件 天然的业务分割,实现简单 页面切换可能有延迟
Suspense + 异步组件 需要加载状态的场景 声明式,代码简洁 需要 Vue3 支持
动态 import() 模块级懒加载 标准语法,通用性强 需要配合构建工具

7.3 性能优化最佳实践

  1. 合理分割代码:按照业务模块和功能进行代码分割
  2. 预加载策略:根据用户行为预测并预加载可能需要的组件
  3. 加载状态管理:提供友好的加载反馈和错误处理
  4. 缓存策略:利用浏览器缓存和 Service Worker
  5. 监控分析:持续监控加载性能,优化分割策略

7.4 注意事项

  • 避免过度分割:太多的 chunk 会增加 HTTP 请求开销
  • 错误处理:必须处理加载失败的情况
  • 测试覆盖:确保懒加载组件在各种网络条件下的表现
  • SEO 考虑:服务端渲染时需要考虑懒加载组件的处理

Vue3 的组件懒加载为现代前端应用提供了强大的性能优化手段。通过合理运用各种懒加载策略,可以显著提升应用性能,改善用户体验。


如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。在这里插入图片描述

🎉 React 的 JSX 语法与组件思想:开启你的前端‘搭积木’之旅(深度对比 Vue 哲学)

嘿,未来的前端大神们!👋 欢迎来到 React 的世界!如果你正在寻找一个现代、高效、充满乐趣的前端框架,那么恭喜你,你找对地方了!

在 React 中,有两个核心概念你必须掌握:JSX 语法组件化思想。它们不仅是 React 的“招牌”,也是你实现炫酷用户界面的“魔法棒”。

别担心,React 并没有传说中那么“高冷”,只要理解了它的核心哲学,特别是通过与 Vue 等框架的对比,你就会发现这是一个充满乐趣的“搭积木”游戏!

本文将结合一个仿掘金首页的示例,带你一起深入理解 JSX 和组件的魅力,并穿插讲解 React 这种**“All in JavaScript”的纯粹思想与 Vue 的“关注点分离”**哲学有何不同。


🚀 一、现代前端框架的魅力与 React 的“激进”哲学

在深入 JSX 之前,我们先来聊聊 Vue、React 等现代前端框架的共同特点和它们之间的根本区别:

1. 现代框架的共同点

  • 响应式(Reactive):数据(State/状态)发生变化时,UI 界面会自动、高效地同步更新。
  • 数据绑定(Data Binding): 建立数据和 DOM 之间的连接。
  • 组件化(Component-Based):组件为基本开发单位,像搭积木一样来组合页面。

2. React 与 Vue 的哲学差异:关注点如何分离?

尽管都推崇组件化,但在如何组织一个组件内部的职责上,React 和 Vue 采取了截然不同的路径:

特性 React 哲学 (激进) Vue 哲学 (渐进)
关注点分离 技术融合: 关注一个功能/组件的完整性。将模板(UI)、逻辑、样式都写在 JavaScript 文件内。 技术分离: 关注 HTML/CSS/JS 三种技术的传统分工。在一个 .vue 文件中用 <template><script><style> 分块。
模板语法 JSX (XML in JS): 在 JavaScript 中写类似 HTML 的结构。 HTML 扩展:<template> 块中使用标准的 HTML,通过 v-bindv-on 等指令扩展其能力。
入门门槛 相对较高,需要先适应在 JS 中写 UI 的 JSX 语法。 相对较低,接近传统前端开发习惯(HTML 模板)。

🌟 React 的“激进”之处:

React 认为,一个组件的 UI 描述(JSX)、逻辑(JS 代码)和样式(可选的 CSS 模块化)应该紧密地封装在一起,形成一个功能完整的单元。它更推崇**“关注点分离”而非“技术分离”**。实现这一点的核心,就是 JSX


✨ 二、JSX 语法:XML in JavaScript 的魔法

1. 什么是 JSX?(React 的模板语法)

在 React 的世界里,你不再需要一个单独的模板文件来描述 UI。你直接在 JavaScript 代码里写类似 HTML 的结构!这种在 JavaScript 中书写 XML/HTML 结构的语法扩展,就是 JSX (JavaScript XML)

核心定义: JSX 是 React 中用于描述用户界面语法拓展。它本质上是 XML 的一个特定应用,将 UI 描述(原本的 HTML 职责)和逻辑控制(原本的 JS 职责)完美地融合在一起。

在我们的示例代码中,以下两行代码完美地诠释了 JSX 作为语法糖(Syntactic Sugar)的作用:

JavaScript

// 语法糖:用类似 HTML 的 JSX 结构来描述 UI,可读性极高
const element = <h2>JSX 是 React 中用于描述用户界面的语法拓展</h2>;

// 原始写法:使用 React.createElement API,繁琐且可读性差
import { createElement } from 'react'; // 引入 createElement
const element2 = createElement("h2", null, "JSX 是 React 中用于描述用户界面的语法拓展");

结论: JSX 是为了简化模板开发提升代码可读性而生的。在底层,它会被像 Babel 这样的工具编译成 React.createElement() 函数调用,最终创建出 React 元素(Elements)。

2. JSX 与 Vue 模板的对比

特性 React (JSX) Vue (Template)
数据展示 使用 {变量/表达式} 使用 {{ 变量/表达式 }} (双花括号)
类名属性 必须使用 className 使用标准的 class 属性
条件渲染 使用 JavaScript 表达式{condition ? <A/> : <B/>}{condition && <A/>} 使用 特殊指令<p v-if="condition">A</p><p v-else>B</p>
列表渲染 使用 JavaScript 数组方法{list.map((item) => <li key={item.id}>{item.title}</li>)} 使用 特殊指令<li v-for="item in list" :key="item.id">{{ item.title }}</li>

对比总结: Vue 倾向于在 HTML 模板中引入 新的语法和指令(如 v-if, v-for)来实现逻辑控制;而 React (JSX) 则直接利用原生 JavaScript 的强大表现力(如三元运算符、.map() 方法)来实现模板逻辑。这要求开发者对 JavaScript 更加熟悉。

示例:在 JSX 中嵌入 JavaScript 表达式

你可以在 JSX 中使用单花括号 {} 来嵌入任何有效的 JavaScript 表达式

JavaScript

// 在 <span> 标签内嵌入 getname 变量的值
<h1>Hello <span className="title">{getname}!</span></h1>

// 列表渲染:利用 JS 的 .map() 方法和三元表达式进行条件渲染
{
    // 如果 gettodos.length > 0 为真,则渲染 ul 列表,否则渲染 <p>
    gettodos.length > 0 ? (<ul>
        {/* 原生JS react 能不用新语法,就不用。我们直接使用原生 map */}
        {gettodos.map((item) => {
            return (
                // 迭代生成的元素必须要有 key 属性,帮助 React 识别哪些项发生了变化
                <li key={item.id}>
                    {item.title}
                </li>
            )
        })}
    </ul>) : (
        <p>暂无待办事项</p>
    )
}

🏗️ 三、组件化:从“砖头沙子”到“根组件”

1. 以组件为基本单位(组件是函数)

在 React 中,你工作的基本单位不再是孤立的 HTML 元素,而是组件(Component)

简单回答: 返回 JSX 的函数就是组件。

例如,我们的掘金首页示例:

JavaScript

// 函数名(约定以大写字母开头)就是组件名
function JuejinHeader() {
  return (
    // jsx 最外层只能有一个元素
    <div>
      <header>
        <h1>JueJin首页</h1>
      </header>
    </div>
  )
}

// ... 其他子组件
  • App根组件,它渲染了整个页面。
  • 组件树: 这种结构清晰地展示了组件树如何取代传统的 DOM 树,成为我们审查和组织网页结构的主要方式。

2. 根组件的挂载:一切的起点

无论是 React 还是 Vue,应用都需要一个挂载点,即在 HTML 页面中找到一个元素,将整个组件应用渲染进去。React 的现代挂载方式清晰地体现了其纯粹的组件思想

JavaScript

// index.js 或 main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

// 挂载根组件
// react 一开始就是组件思想,非常的纯粹
const root = document.getElementById('root'); // 1. 找到 HTML 中的挂载点 <div id="root"></div>
createRoot(root).render( // 2. 创建 React 根容器,并调用 render 方法渲染
  <StrictMode>
    <App /> {/* 3. 将根组件 <App /> 渲染到根容器中 */}
  </StrictMode>,
)

代码分析:

  1. createRoot(element):这是 React 18+ 的新 API,用于创建 React 应用程序的根容器,它支持并发渲染(Concurrent Mode)等新特性。
  2. App /> 这就是根组件。可以看到,整个应用的起点就是组件App 组件返回的 JSX 结构,将从这里开始,被 React 渲染成最终的 DOM 结构。
  3. <StrictMode> 严格模式,它不会渲染可见的 UI,但会为它内部的组件(包括 App)启用额外的检查和警告,帮助我们在开发过程中及早发现潜在问题。

🧠 四、组件的“数据之魂”:useState 详解

仅仅会写 UI 描述(JSX)是不够的,一个组件必须能够管理自己的数据状态和业务逻辑。在函数式组件中,我们通过 React Hooks 来实现这些功能,其中最基础的就是 useState

1. useState 的作用与结构

useState Hook 的作用就是为函数组件添加“状态”(State)管理的能力。它接收一个参数(状态的初始值),并返回一个包含两个元素的数组

JavaScript

import { useState } from 'react';

function App() {
    // [当前状态值, 更新状态值的函数] = useState(初始值);
    const [getname, setName] = useState("vue"); 
    const [gettodos, setTodos] = useState([
        // ... 待办事项初始数据
    ]);
    const [isloggedIn, setIsLoggedIn] = useState(true);
    // ...
}

① 数组的第一个元素:状态值(如 getname

它是当前时刻的状态值,用于在 JSX 中展示。

② 数组的第二个元素:更新状态的函数(如 setName

这是一个专用的函数,用于更新状态值

⚠️ 关键点:

  1. 不可直接修改状态值:你必须调用更新函数,例如 setName("react")
  2. 触发重新渲染:当调用这个更新函数时,React 会检测到状态变化,重新执行组件函数(即 App 函数),从而实现响应式更新 UI。

2. useState 与 Vue 响应式数据的对比

特性 React (useState) Vue (Composition API / Setup)
数据定义 const [value, setValue] = useState(initial) const value = ref(initial)
数据访问 直接使用 value 需要使用 value.value (在 <script> 块中)
数据更新 调用专用的更新函数:setValue(newValue) 直接赋值:value.value = newValue
更新机制 组件重新执行 (Re-render) :更新状态后,React 重新渲染整个函数组件。 精确追踪 (Proxy) :Vue 通过 Proxy 机制精确追踪哪些地方使用了数据,只重新渲染使用该数据的部分 DOM。

对比总结: Vueref/reactive劫持数据,更新数据就是直接赋值;而 ReactuseState函数调用,更新状态就是调用一个 setter 函数,触发组件重新渲染。这是两者响应式机制的根本区别。

示例:通过事件更新状态

JavaScript

const toggleLogin = () => {
    // 调用 setIsLoggedIn,传入新值(当前值的反面),触发 App 组件重新渲染
    setIsLoggedIn(!isloggedIn);
}

return (
    <>
      {/* 状态依赖 UI 自动更新 */}
      {isloggedIn?<p>已登录</p>:<p>未登录</p>}
      <button onClick={toggleLogin}> {/* onClick 绑定 JS 函数 */}
          {isloggedIn?"退出登录":"登录"}
      </button>
    </>
)

💻 五、JSX 的底层真相与 createElement 的角色

1. JSX 是什么?

JSX 仅仅是语法糖。它需要被编译(通常由 Babel 完成)才能被浏览器识别。

2. createElement 做了什么?

JSX 的编译目标就是 React.createElement() 函数。

  • JSX 形式:

    JavaScript

    const element = <h2>JSX 是 React 中用于描述用户界面的语法拓展</h2>
    
  • 编译后的原始 React API 形式:

    JavaScript

    // createElement(type, props, ...children)
    const element2 = createElement("h2", null, "JSX 是 React 中用于描述用户界面的语法拓展");
    

createElement 函数的职责是创建出一个 React 元素(React Element) ,这是一个轻量级的 JavaScript 对象,它描述了你希望在屏幕上看到什么。它是 React 应用的骨架,是对真实 DOM 元素用户自定义组件的一种抽象描述。React 拿到这个描述对象后,会根据它来构建和维护虚拟 DOM(Virtual DOM) ,最终高效地将其同步到浏览器的真实 DOM 上。


🔍 六、实战总结:纯粹的组件思想

通过掘金首页的示例,我们再次明确了 React 的开发模式:

  • 核心思想: 一个组件就是 JSX (UI) + 逻辑 (Hooks/状态/事件) 的完整封装。
  • 开发模式: 组件是由 js/css/html 组合起来,完成一个相对独立的功能。我们像搭积木一样将它们组装起来,从根组件 <App /> 开始,由 React 负责最终的渲染。

React 的这种纯粹的组件和 JavaScript 驱动的思想,使得它在处理大型、复杂应用时,逻辑清晰、边界分明。虽然它要求开发者对 JavaScript 更加依赖,但它带来的强大灵活性和可维护性是其最大的优势。


结语

恭喜你,现在你已经掌握了 React 开发的两大核心武器:JSX 语法组件化思想,并且理解了它与 Vue 哲学的主要区别!

  1. JSX 让你能够在 JavaScript 中优雅地描述 UI。
  2. 组件 让你以模块化、可复用的方式构建应用,从根组件开始。
  3. useState 为你的函数组件注入了响应式的数据活力。
  4. React 与 Vue 的对比让你更深刻地理解了框架背后的设计哲学。

React 的旅程才刚刚开始,接下来还有 useEffectuseContext 等更多强大的 Hooks 等待你去探索。记住,多写多练,将每一个 UI 模块都看作一个独立的组件,你很快就能成为一名优秀的 React 开发者!

vscode编写vue代码的时候一聚焦就代码块变白?怎么回事如何解决

起因

今天打开vscode编写vue代码的时候 发现我的vscode出现了一个小问题,就是我在 template、script、style里面编写代码的时候,我的vscode会将这些结构体全部变成透明色,这样非常的麻烦也不利于我查看编写的代码,这样写起来也非常的难受

04e1eb05-5c04-43f6-a4f7-16033b06aa9c.jpg

解决方法

原因是编辑器默认的折叠策略或 Vue 插件的配置导致<template><script>等块被自动折叠。可以通过以下步骤解决:

1. 调整 VSCode 全局折叠设置

打开设置(快捷键 Ctrl+,),搜索并修改以下配置:

  • 关闭自动折叠总开关:将 Editor: Folding 取消勾选(或在settings.json中添加 "editor.folding": false)。
  • 或修改折叠策略:将 Editor: Folding Strategy 设为 manual(仅允许手动折叠)。

2. 针对 Vue 文件的插件配置

如果使用 Volar(Vue3 官方插件)或 Vetur,需调整插件的折叠配置:

  • Vetur:在设置中搜索 vue.enableFolding,将其设为 false
  • Volar:确保 editor.foldingStrategy 设为 auto,同时避免其他插件(如旧版 Vetur)冲突。

3. 临时展开所有代码

若需快速恢复当前文件的显示,可使用快捷键:

  • 展开所有代码:Ctrl+K Ctrl+0(Windows/Linux)或 Cmd+K Cmd+0(Mac)。

修改后,Vue 文件的<template><script><style>块就不会在聚焦时自动折叠了。

Vue3项目集成monaco-editor实现浏览器IDE代码编辑功能

前言

相信大家在做一些低代码平台的项目时,都会涉及到一些在线IDE代码编辑的功能吧,比如通过在线代码编辑后实现在线运行代码效果.

本篇给大家分享一下作者个人在开发低代码平台时如何实现如下图所示的vscode在线代码IDE编辑功能的吧

image.png

一、安装相关插件

pnpm add monaco-editor 
pnpm add monaco-editor-vue3

因为是在Vue3项目中所以这里直接使用 monaco-editor-vue3 这个插件会更加便捷

二、新增一个monaco.ts 配置文件(这个很重要)

在安装完插件后其实我们这样直接在页面中引入就可以使用了,但是这个时候页面其实会有报错的,大概就是提示你monaco-editor 相关配置没有处理

<template>
  <div style="height: 400px; width: 800px">
    <CodeEditor
      v-model:value="code"
      language="javascript"
      theme="vs-dark"
      :height="600"
      :options="editorOptions"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { CodeEditor } from 'monaco-editor-vue3';

const code = ref(`function hello() {
console.log('Hello, Monaco Editor!');
}`);

const editorOptions = {
  fontSize: 14,
  minimap: { enabled: false },
  automaticLayout: true,
};
</script>

这时候我们需要创建一个 monaco.ts 文件并添加以下配置内容

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import * as monaco from 'monaco-editor';

declare global {
  interface Window {
    MonacoEnvironment?: {
      getWorker: (moduleId: string, label: string) => Worker;
    };
  }
}

(self as Window).MonacoEnvironment = {
  getWorker(_: string, label: string) {
    if (label === 'json') {
      return new jsonWorker();
    }
    if (label === 'css' || label === 'scss' || label === 'less') {
      return new cssWorker();
    }
    if (label === 'html' || label === 'handlebars' || label === 'razor') {
      return new htmlWorker();
    }
    if (label === 'typescript' || label === 'javascript') {
      return new tsWorker();
    }
    return new editorWorker();
  },
};
 

同时在 main.ts 中引入 monaco.ts

import App from './App.vue';
import '@/utils/monaco'

type AppInstance = AppType<Element>;

const app: AppInstance = createApp(App);
app.mount('#app');

界面:ok配置成功后界面内容大概就是这样

image.png

自定义主题

如果你觉得编辑器默认的主题样式不太好看也可以自定义主题样式,这里简单的配置一下

依旧在monaco.ts中添加代码

// 定义符合项目系统的自定义主题
const customTheme: monaco.editor.IStandaloneThemeData = {
  base: 'vs-dark', // 基于官方暗色主题
  inherit: true, // 继承默认语法高亮规则
  rules: [
    { token: 'comment', foreground: '617b91', fontStyle: 'italic' }, // 注释呈现斜体灰蓝
    { token: 'keyword', foreground: 'c5cceb', fontStyle: 'bold' }, // 关键字加粗淡紫
    { token: 'string', foreground: 'a9b1d6' }, // 字符串淡蓝
    { token: 'number', foreground: 'c5cceb' }, // 数字淡紫
    { token: 'operator', foreground: 'c7cacf' }, // 运算符浅灰
    { token: 'delimiter', foreground: 'c7cacf' }, // 分隔符浅灰
    { token: 'type', foreground: 'c5cceb' }, // 类型标识淡紫
    { token: 'class', foreground: 'c5cceb' }, // 类名淡紫
    { token: 'function', foreground: 'a9b1d6' }, // 函数名淡蓝
    { token: 'variable', foreground: 'c5cceb' }, // 变量名淡紫
  ],
  colors: {
    'editor.background': '#252837', // 编辑器背景
    'editor.foreground': '#c5cceb', // 默认前景文字
    'editor.lineHighlightBackground': '#29344c', // 当前行高亮背景
    'editor.inactiveSelectionBackground': 'rgba(69, 137, 255, 0.15)', // 未激活选区背景
    'editorCursor.foreground': '#c5cceb', // 光标颜色
    'editorWhitespace.foreground': '#535f79', // 空白字符提示色
    'editorIndentGuide.background': '#535f79', // 缩进指示线
    'editorIndentGuide.activeBackground': '#a9b1d6', // 活动缩进指示线
    'editorLineNumber.foreground': '#617b91', // 行号默认颜色
    'editorLineNumber.activeForeground': '#c5cceb', // 当前行号颜色
    'editorGutter.background': '#252837', // 行号区域背景
    'editorWidget.background': '#29344c', // 弹出组件背景
    'editorWidget.border': '#535f79', // 弹出组件边框
    'editorSuggestWidget.background': '#29344c', // 智能提示背景
    'editorSuggestWidget.border': '#535f79', // 智能提示边框
  },
};

// 注册自定义主题
monaco.editor.defineTheme('custom-dark', customTheme);

界面效果:

image.png

monaco.ts 完整的配置

import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import * as monaco from 'monaco-editor';
declare global {
  interface Window {
    MonacoEnvironment?: {
      getWorker: (moduleId: string, label: string) => Worker;
    };
  }
}
(self as Window).MonacoEnvironment = {
  getWorker(_: string, label: string) {
    if (label === 'json') {
      return new jsonWorker();
    }
    if (label === 'css' || label === 'scss' || label === 'less') {
      return new cssWorker();
    }
    if (label === 'html' || label === 'handlebars' || label === 'razor') {
      return new htmlWorker();
    }
    if (label === 'typescript' || label === 'javascript') {
      return new tsWorker();
    }
    return new editorWorker();
  },
};
// 定义符合项目系统的自定义主题
const customTheme: monaco.editor.IStandaloneThemeData = {
  base: 'vs-dark', // 基于官方暗色主题
  inherit: true, // 继承默认语法高亮规则
  rules: [
    { token: 'comment', foreground: '617b91', fontStyle: 'italic' }, // 注释呈现斜体灰蓝
    { token: 'keyword', foreground: 'c5cceb', fontStyle: 'bold' }, // 关键字加粗淡紫
    { token: 'string', foreground: 'a9b1d6' }, // 字符串淡蓝
    { token: 'number', foreground: 'c5cceb' }, // 数字淡紫
    { token: 'operator', foreground: 'c7cacf' }, // 运算符浅灰
    { token: 'delimiter', foreground: 'c7cacf' }, // 分隔符浅灰
    { token: 'type', foreground: 'c5cceb' }, // 类型标识淡紫
    { token: 'class', foreground: 'c5cceb' }, // 类名淡紫
    { token: 'function', foreground: 'a9b1d6' }, // 函数名淡蓝
    { token: 'variable', foreground: 'c5cceb' }, // 变量名淡紫
  ],
  colors: {
    'editor.background': '#252837', // 编辑器背景
    'editor.foreground': '#c5cceb', // 默认前景文字
    'editor.lineHighlightBackground': '#29344c', // 当前行高亮背景
    'editor.inactiveSelectionBackground': 'rgba(69, 137, 255, 0.15)', // 未激活选区背景
    'editorCursor.foreground': '#c5cceb', // 光标颜色
    'editorWhitespace.foreground': '#535f79', // 空白字符提示色
    'editorIndentGuide.background': '#535f79', // 缩进指示线
    'editorIndentGuide.activeBackground': '#a9b1d6', // 活动缩进指示线
    'editorLineNumber.foreground': '#617b91', // 行号默认颜色
    'editorLineNumber.activeForeground': '#c5cceb', // 当前行号颜色
    'editorGutter.background': '#252837', // 行号区域背景
    'editorWidget.background': '#29344c', // 弹出组件背景
    'editorWidget.border': '#535f79', // 弹出组件边框
    'editorSuggestWidget.background': '#29344c', // 智能提示背景
    'editorSuggestWidget.border': '#535f79', // 智能提示边框
  },
};
// 注册自定义主题
monaco.editor.defineTheme('custom-dark', customTheme);

总结

以上就是作者个人在Vue3项目中集成 monaco.editor 的过程

总体来说也是非常的简单

大概就是分三步流程实现

1.安装 monaco-editormonaco-editor-vue3 插件

2.新增和引入 monaco.ts 文件

3.在页面中使用 CodeEditor

github.com/microsoft/m…

github.com/bazingaedwa…

Vue组件缓存终极指南:keep-alive原理与动态更新实战

一、为什么需要组件缓存?

在Vue单页应用开发中,我们经常会遇到这样的场景:用户在数据筛选页面设置了复杂的查询条件,然后进入详情页查看,当返回时希望之前的筛选条件还能保留。如果每次切换路由都重新渲染组件,会导致用户体验下降、数据丢失、性能损耗等问题。

组件缓存的核心价值:

    1. 保持组件状态,避免重复渲染
    1. 提升应用性能,减少不必要的DOM操作
    1. 改善用户体验,维持用户操作上下文

二、Vue的缓存神器:keep-alive

2.1 keep-alive基础用法

<template>
  <div id="app">
    <!-- 基本用法 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 结合router-view -->
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive"></router-view>
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive"></router-view>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent'UserList'
    }
  }
}
</script>

2.2 keep-alive的生命周期变化

当组件被缓存时,正常的生命周期会发生变化:

<script>
export default {
  name'UserList',
  
  // 正常生命周期(未缓存时)
  created() {
    console.log('组件创建')
    this.loadData()
  },
  
  mounted() {
    console.log('组件挂载')
  },
  
  destroyed() {
    console.log('组件销毁')
  },
  
  // 缓存特有生命周期
  activated() {
    console.log('组件被激活(进入缓存组件)')
    this.refreshData() // 重新获取数据
  },
  
  deactivated() {
    console.log('组件被停用(离开缓存组件)')
    this.saveState() // 保存当前状态
  }
}
</script>

生命周期流程图:

首次进入组件:
created → mounted → activated

离开缓存组件:
deactivated

再次进入缓存组件:
activated(跳过created和mounted)

组件被销毁:
deactivated → destroyed(如果完全销毁)

三、高级缓存策略

3.1 条件缓存与排除缓存

<template>
  <div>
    <!-- 缓存特定组件 -->
    <keep-alive :include="cachedComponents" :exclude="excludedComponents" :max="5">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 只缓存这些组件(基于组件name)
      cachedComponents: ['UserList''ProductList''OrderList'],
      
      // 不缓存这些组件
      excludedComponents: ['Login''Register']
    }
  }
}
</script>

3.2 动态路由缓存方案

// router/index.js
const routes = [
  {
    path'/user/list',
    name'UserList',
    component() => import('@/views/UserList.vue'),
    meta: {
      title'用户列表',
      keepAlivetrue// 需要缓存
      isRefreshtrue  // 是否需要刷新
    }
  },
  {
    path'/user/detail/:id',
    name'UserDetail',
    component() => import('@/views/UserDetail.vue'),
    meta: {
      title'用户详情',
      keepAlivefalse // 不需要缓存
    }
  }
]

// App.vue
<template>
  <div id="app">
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive"></router-view>
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive"></router-view>
  </div>
</template>

四、缓存后的数据更新策略

4.1 方案一:使用activated钩子

<script>
export default {
  name'ProductList',
  data() {
    return {
      products: [],
      filterParams: {
        category'',
        priceRange: [01000],
        sortBy'createdAt'
      },
      lastUpdateTimenull
    }
  },
  
  activated() {
    // 检查是否需要刷新数据(比如超过5分钟)
    const now = new Date().getTime()
    if (!this.lastUpdateTime || (now - this.lastUpdateTime) > 5 * 60 * 1000) {
      this.refreshData()
    } else {
      // 使用缓存数据,但更新一些实时性要求高的内容
      this.updateRealTimeData()
    }
  },
  
  methods: {
    async refreshData() {
      try {
        const response = await this.$api.getProducts(this.filterParams)
        this.products = response.data
        this.lastUpdateTime = new Date().getTime()
      } catch (error) {
        console.error('数据刷新失败:', error)
      }
    },
    
    updateRealTimeData() {
      // 只更新库存、价格等实时数据
      this.products.forEach(async (product) => {
        const stockInfo = await this.$api.getProductStock(product.id)
        product.stock = stockInfo.quantity
        product.price = stockInfo.price
      })
    }
  }
}
</script>

4.2 方案二:事件总线更新

// utils/eventBus.js
import Vue from 'vue'
export default new Vue()

// ProductList.vue(缓存组件)
<script>
import eventBus from '@/utils/eventBus'

export default {
  created() {
    // 监听数据更新事件
    eventBus.$on('refresh-product-list'(params) => {
      if (this.filterParams.category !== params.category) {
        this.filterParams = { ...params }
        this.refreshData()
      }
    })
    
    // 监听强制刷新事件
    eventBus.$on('force-refresh'() => {
      this.refreshData()
    })
  },
  
  deactivated() {
    // 离开时移除事件监听,避免内存泄漏
    eventBus.$off('refresh-product-list')
    eventBus.$off('force-refresh')
  },
  
  methods: {
    handleSearch(params) {
      // 触发搜索时,通知其他组件
      eventBus.$emit('search-params-changed', params)
    }
  }
}
</script>

4.3 方案三:Vuex状态管理 + 监听

// store/modules/product.js
export default {
  state: {
    list: [],
    filterParams: {},
    lastFetchTimenull
  },
  
  mutations: {
    SET_PRODUCT_LIST(state, products) {
      state.list = products
      state.lastFetchTime = new Date().getTime()
    },
    
    UPDATE_FILTER_PARAMS(state, params) {
      state.filterParams = { ...state.filterParams, ...params }
    }
  },
  
  actions: {
    async fetchProducts({ commit, state }, forceRefresh = false) {
      // 如果不是强制刷新且数据在有效期内,则使用缓存
      const now = new Date().getTime()
      if (!forceRefresh && state.lastFetchTime && 
          (now - state.lastFetchTime) < 10 * 60 * 1000) {
        return
      }
      
      const response = await api.getProducts(state.filterParams)
      commit('SET_PRODUCT_LIST', response.data)
    }
  }
}

// ProductList.vue
<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('product', ['list''filterParams'])
  },
  
  activated() {
    // 监听Vuex状态变化
    this.unwatch = this.$store.watch(
      (state) => state.product.filterParams,
      (newParams, oldParams) => {
        if (JSON.stringify(newParams) !== JSON.stringify(oldParams)) {
          this.fetchProducts()
        }
      }
    )
    
    // 检查是否需要更新
    this.checkAndUpdate()
  },
  
  deactivated() {
    // 取消监听
    if (this.unwatch) {
      this.unwatch()
    }
  },
  
  methods: {
    ...mapActions('product', ['fetchProducts']),
    
    checkAndUpdate() {
      const lastFetchTime = this.$store.state.product.lastFetchTime
      const now = new Date().getTime()
      
      if (!lastFetchTime || (now - lastFetchTime) > 10 * 60 * 1000) {
        this.fetchProducts()
      }
    },
    
    handleFilterChange(params) {
      this.$store.commit('product/UPDATE_FILTER_PARAMS', params)
    }
  }
}
</script>

五、实战:动态缓存管理

5.1 缓存管理器实现

<!-- components/CacheManager.vue -->
<template>
  <div class="cache-manager">
    <keep-alive :include="dynamicInclude">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

<script>
export default {
  name'CacheManager',
  
  data() {
    return {
      cachedViews: [], // 缓存的组件名列表
      maxCacheCount10 // 最大缓存数量
    }
  },
  
  computed: {
    dynamicInclude() {
      return this.cachedViews
    }
  },
  
  created() {
    this.initCache()
    
    // 监听路由变化
    this.$watch(
      () => this.$route,
      (to, from) => {
        this.addCache(to)
        this.manageCacheSize()
      },
      { immediatetrue }
    )
  },
  
  methods: {
    initCache() {
      // 从localStorage恢复缓存设置
      const savedCache = localStorage.getItem('vue-cache-views')
      if (savedCache) {
        this.cachedViews = JSON.parse(savedCache)
      }
    },
    
    addCache(route) {
      if (route.meta && route.meta.keepAlive && route.name) {
        const cacheName = this.getCacheName(route)
        
        if (!this.cachedViews.includes(cacheName)) {
          this.cachedViews.push(cacheName)
          this.saveCacheToStorage()
        }
      }
    },
    
    removeCache(routeName) {
      const index = this.cachedViews.indexOf(routeName)
      if (index > -1) {
        this.cachedViews.splice(index, 1)
        this.saveCacheToStorage()
      }
    },
    
    clearCache() {
      this.cachedViews = []
      this.saveCacheToStorage()
    },
    
    refreshCache(routeName) {
      // 刷新特定缓存
      this.removeCache(routeName)
      setTimeout(() => {
        this.addCache({ name: routeName, meta: { keepAlivetrue } })
      }, 0)
    },
    
    manageCacheSize() {
      // LRU(最近最少使用)缓存策略
      if (this.cachedViews.length > this.maxCacheCount) {
        this.cachedViews.shift() // 移除最旧的缓存
        this.saveCacheToStorage()
      }
    },
    
    getCacheName(route) {
      // 为动态路由生成唯一的缓存key
      if (route.params && route.params.id) {
        return `${route.name}-${route.params.id}`
      }
      return route.name
    },
    
    saveCacheToStorage() {
      localStorage.setItem('vue-cache-views'JSON.stringify(this.cachedViews))
    }
  }
}
</script>

5.2 缓存状态指示器

<!-- components/CacheIndicator.vue -->
<template>
  <div class="cache-indicator" v-if="showIndicator">
    <div class="cache-status">
      <span class="cache-icon">💾</span>
      <span class="cache-text">数据已缓存 {{ cacheTime }}</span>
      <button @click="refreshData" class="refresh-btn">刷新</button>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    componentName: {
      typeString,
      requiredtrue
    }
  },
  
  data() {
    return {
      lastUpdatenull,
      showIndicatorfalse,
      updateIntervalnull
    }
  },
  
  computed: {
    cacheTime() {
      if (!this.lastUpdatereturn ''
      
      const now = new Date()
      const diff = Math.floor((now - this.lastUpdate) / 1000)
      
      if (diff < 60) {
        return `${diff}秒前`
      } else if (diff < 3600) {
        return `${Math.floor(diff / 60)}分钟前`
      } else {
        return `${Math.floor(diff / 3600)}小时前`
      }
    }
  },
  
  activated() {
    this.loadCacheTime()
    this.showIndicator = true
    this.startTimer()
  },
  
  deactivated() {
    this.showIndicator = false
    this.stopTimer()
  },
  
  methods: {
    loadCacheTime() {
      const cacheData = localStorage.getItem(`cache-${this.componentName}`)
      if (cacheData) {
        this.lastUpdate = new Date(JSON.parse(cacheData).timestamp)
      } else {
        this.lastUpdate = new Date()
        this.saveCacheTime()
      }
    },
    
    saveCacheTime() {
      const cacheData = {
        timestampnew Date().toISOString(),
        componentthis.componentName
      }
      localStorage.setItem(`cache-${this.componentName}`JSON.stringify(cacheData))
      this.lastUpdate = new Date()
    },
    
    refreshData() {
      this.$emit('refresh')
      this.saveCacheTime()
    },
    
    startTimer() {
      this.updateInterval = setInterval(() => {
        // 更新显示时间
      }, 60000// 每分钟更新一次显示
    },
    
    stopTimer() {
      if (this.updateInterval) {
        clearInterval(this.updateInterval)
      }
    }
  }
}
</script>

<style scoped>
.cache-indicator {
  position: fixed;
  bottom20px;
  right20px;
  backgroundrgba(0000.8);
  color: white;
  padding10px 15px;
  border-radius20px;
  font-size14px;
  z-index9999;
}

.cache-status {
  display: flex;
  align-items: center;
  gap8px;
}

.refresh-btn {
  background#4CAF50;
  color: white;
  border: none;
  padding4px 12px;
  border-radius4px;
  cursor: pointer;
  font-size12px;
}

.refresh-btn:hover {
  background#45a049;
}
</style>

六、性能优化与注意事项

6.1 内存管理建议

// 监控缓存组件数量
Vue.mixin({
  activated() {
    if (window.keepAliveInstances) {
      window.keepAliveInstances.add(this)
      console.log(`当前缓存组件数量: ${window.keepAliveInstances.size}`)
    }
  },
  
  deactivated() {
    if (window.keepAliveInstances) {
      window.keepAliveInstances.delete(this)
    }
  }
})

// 应用初始化时
window.keepAliveInstances = new Set()

6.2 缓存策略选择指南

场景 推荐方案 说明
列表页 → 详情页 → 返回列表 keep-alive + activated刷新 保持列表状态,返回时可选刷新
多标签页管理 动态include + LRU策略 避免内存泄漏,自动清理
实时数据展示 Vuex + 短时间缓存 保证数据实时性
复杂表单填写 keep-alive + 本地存储备份 防止数据丢失

6.3 常见问题与解决方案

问题1:缓存组件数据不更新

// 解决方案:强制刷新特定组件
this.$nextTick(() => {
  const cache = this.$vnode.parent.componentInstance.cache
  const keys = this.$vnode.parent.componentInstance.keys
  
  if (cache && keys) {
    const key = this.$vnode.key
    if (key != null) {
      delete cache[key]
      const index = keys.indexOf(key)
      if (index > -1) {
        keys.splice(index, 1)
      }
    }
  }
})

问题2:滚动位置保持

// 在路由配置中
{
  path'/list',
  componentListPage,
  meta: {
    keepAlivetrue,
    scrollToTopfalse // 不滚动到顶部
  }
}

// 在组件中
deactivated() {
  // 保存滚动位置
  this.scrollTop = document.documentElement.scrollTop || document.body.scrollTop
},

activated() {
  // 恢复滚动位置
  if (this.scrollTop) {
    window.scrollTo(0this.scrollTop)
  }
}

七、总结

Vue组件缓存是提升应用性能和用户体验的重要手段,但需要合理使用。关键点总结:

  1. 1. 合理选择缓存策略:根据业务场景选择适当的缓存方案
  2. 2. 注意内存管理:使用max属性限制缓存数量,实现LRU策略
  3. 3. 数据更新要灵活:结合activated钩子、事件总线、Vuex等多种方式
  4. 4. 监控缓存状态:实现缓存指示器,让用户了解数据状态
  5. 5. 提供刷新机制:始终给用户手动刷新的选择权

正确使用keep-alive和相关缓存技术,可以让你的Vue应用既保持流畅的用户体验,又能保证数据的准确性和实时性。记住,缓存不是目的,而是提升用户体验的手段,要根据实际业务需求灵活运用。

希望这篇详细的指南能帮助你在实际项目中更好地应用Vue组件缓存技术!

企业级 Vue 3 项目图标系统重构实践:从多源混乱到单一数据源

日期: 2025-12-12
技术栈: Vue 3 + TypeScript + iconfont

前言

在大型前端项目中,图标管理是一个看似简单却容易失控的问题。随着业务迭代,往往会出现多套图标方案并存的情况:有人用 SVG 文件,有人用 iconfont,有人直接用图片……这不仅增加了维护成本,还容易导致图标风格不统一、打包体积膨胀等问题。

本文将分享我们在 CMC Link IBS Web 项目中进行的一次图标系统重构实践,核心目标是:统一图标来源,降低维护成本,提升开发体验

一、问题诊断:混乱的图标现状

1.1 现状分析

重构前,项目中存在两套并行的图标方案:

图标来源
├── iconfont(阿里图标库)
│   ├── iconfont.cssFont Class 模式
│   └── iconfont.js      → Symbol 模式(支持彩色)
│
└── 本地 SVG 图标
    └── src/assets/icons/ → 150+ 个 SVG 文件
        └── vite-plugin-svg-spritemap 处理

1.2 痛点总结

问题 影响
双重维护 新增图标需要决定放哪里,老员工用 SVG,新员工用 iconfont
处理逻辑复杂 SVG 需要 SVGO 插件处理 fill/stroke/width/height 属性
彩色图标识别困难 需要通过文件名约定(c- 前缀)或内容分析来判断
构建依赖 额外引入 @spiriit/vite-plugin-svg-spritemap 依赖
心智负担 开发者需要了解两套方案的差异和适用场景

1.3 核心矛盾

开发效率 vs 技术债务
    ↓
每次新增图标都在累积技术债
    ↓
维护成本随项目规模线性增长

二、方案设计:单一数据源架构

2.1 设计原则

  1. 单一数据源:所有图标统一从 iconfont 获取
  2. 向后兼容:现有代码无需修改即可工作
  3. 渐进迁移:支持新旧写法并存,逐步过渡
  4. 开发体验优先:新增图标流程简化

2.2 架构设计

┌─────────────────────────────────────────────────┐
│                  使用层                          │
│  <SvgIcon name="search" />  (旧代码,无需修改)    │
│  <CmcIcon name="icon-search" /> (新代码)         │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              SvgIcon (兼容层)                    │
│  - 接收旧的 name/icon 属性                       │
│  - 通过映射表转换为 iconfont 名称                 │
│  - 自动判断彩色/单色                             │
│  - 内部渲染 CmcIcon                             │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              CmcIcon (核心组件)                  │
│  - Font Class 模式(单色,可改颜色)              │
│  - Symbol 模式(彩色,保留原色)                  │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│           iconfont 资源 (单一数据源)             │
│  - iconfont.css (Font Class)                    │
│  - iconfont.js (Symbol)                         │
└─────────────────────────────────────────────────┘

2.3 关键设计决策

决策1:为什么选择 iconfont 作为单一数据源?

方案 优势 劣势
本地 SVG 完全可控、离线可用 需要构建处理、维护成本高
iconfont 在线管理、团队协作、支持彩色 依赖外部服务

选择 iconfont 的原因:

  • 已有成熟的图标库(400+ 图标)
  • 支持 Symbol 模式(彩色图标)
  • 团队协作友好(设计师可直接上传)
  • 无需额外构建插件

决策2:兼容层设计

不破坏现有代码是重构的底线。通过代理模式,让旧的 SvgIcon 组件内部调用新的 CmcIcon

<!-- SvgIcon.vue - 兼容层 -->
<script setup lang="ts">
import CmcIcon from '../CmcIcon/CmcIcon.vue'
import { getIconfontName, isColorfulIcon } from '../CmcIcon/icon-mapping'

// ... props 定义

const iconfontName = computed(() => getIconfontName(rawIconName.value))
const colorful = computed(() => isColorfulIcon(rawIconName.value))
</script>

<template>
  <CmcIcon
    :name="iconfontName"
    :size="size"
    :color="color"
    :colorful="colorful"
  />
</template>

决策3:映射表策略

对于名称不一致的情况,通过映射表解决:

// icon-mapping.ts
export const SVG_TO_ICONFONT_MAP: Record<string, string> = {
  'dingcangicon': 'icon-menu-dingcang',
  'billoflading': 'icon-menu-tidan',
  // ...
}

export const COLORFUL_ICONS = new Set([
  'menu-chukou',
  'menu-jinkou',
  'USD', 'CNY', 'EUR',
  // ...
])

三、实现细节

3.1 CmcIcon 核心组件

<script lang="ts" setup>
interface Props {
  name: string           // 图标名称(需带 icon- 前缀)
  size?: number | string // 尺寸,默认 16px
  color?: string         // 颜色(仅单色有效)
  colorful?: boolean     // 是否为彩色图标
}

const props = withDefaults(defineProps<Props>(), {
  size: 16,
  color: 'currentColor',
  colorful: false,
})
</script>

<template>
  <!-- 彩色图标:Symbol 模式 -->
  <svg v-if="colorful" class="cmc-icon" :style="{ width: sizeValue, height: sizeValue }">
    <use :xlink:href="`#${iconName}`" />
  </svg>

  <!-- 单色图标:Font Class 模式 -->
  <i v-else class="cmc-icon iconfont-cmc" :class="iconName" :style="{ fontSize: sizeValue, color }" />
</template>

3.2 iconfont 的两种模式

Font Class 模式(单色图标):

  • 通过 CSS 类名引用图标
  • 支持 color 属性动态改变颜色
  • 文件:iconfont.css

Symbol 模式(彩色图标):

  • 通过 SVG <use> 引用
  • 保留图标原始颜色
  • 文件:iconfont.js
<!-- 单色:可通过 color 控制颜色 -->
<CmcIcon name="icon-search" color="red" />

<!-- 彩色:保留原始多色 -->
<CmcIcon name="icon-menu-chukou" colorful />

3.3 清理冗余代码

移除了不再需要的构建配置:

// build/plugins.ts
- import VitePluginSVGSpritemap from '@spiriit/vite-plugin-svg-spritemap'

export function createVitePlugins() {
  return [
    // ...其他插件
-   createSvgIconsPlugin(),  // 移除 SVG 处理插件
  ]
}

- // 移除 138 行 SVG 处理代码
- function createSvgIconsPlugin() { ... }
- function processMonoIcon() { ... }
- function processRootIcon() { ... }
- function traverseSvgNodes() { ... }

四、收益分析

4.1 量化收益

指标 重构前 重构后 变化
图标来源 2 套 1 套 -50%
构建依赖 +1 0 -100%
plugins.ts 代码行数 241 103 -57%
新增图标步骤 5 步 3 步 -40%

4.2 定性收益

  1. 降低心智负担:开发者只需了解一套方案
  2. 简化新增流程:上传 iconfont → 更新资源 → 使用
  3. 减少构建时间:移除 SVGO 处理环节
  4. 代码更简洁:核心组件 < 100 行

4.3 新增图标流程对比

重构前(SVG 方案):

  1. 获取 SVG 文件
  2. 判断是单色还是彩色
  3. 如果单色,手动处理 fill/stroke 属性
  4. 放入对应目录(mono/ 或 colorful/)
  5. 使用 <SvgIcon name="xxx" />

重构后(iconfont 方案):

  1. 上传到 iconfont 项目
  2. 下载更新资源文件
  3. 使用 <CmcIcon name="icon-xxx" />

五、经验总结

5.1 重构原则

  1. 向后兼容是底线:通过兼容层保证现有代码正常工作
  2. 渐进式迁移:新代码用新方案,旧代码按需迁移
  3. 单一数据源:避免多源并存的混乱
  4. 简化优于完美:够用就好,不过度设计

5.2 技术选型思考

选择 iconfont 而非自建 SVG 方案的核心原因:

  • 团队协作:设计师可直接在 iconfont 管理图标
  • 成本效益:利用现有成熟方案,避免重复造轮子
  • 彩色支持:Symbol 模式原生支持多色图标

5.3 适用场景

本方案适合:

  • 已在使用 iconfont 的项目
  • 团队规模中等以上,需要设计师协作
  • 图标更新频繁的业务系统

不太适合:

  • 对离线可用性要求极高的场景
  • 图标需要复杂动画的场景
  • 完全私有化部署、无法访问外网的环境

六、后续优化方向

  1. 自动化更新:编写脚本自动从 iconfont 拉取最新资源
  2. 类型安全:生成图标名称的 TypeScript 类型定义
  3. 按需加载:对于大型图标库,考虑按需加载策略
  4. 文档自动化:从 iconfont.json 自动生成图标文档

结语

图标系统看似是个小问题,但在大型项目中却能显著影响开发效率和代码质量。这次重构的核心思路是:识别技术债务 → 设计兼容方案 → 统一数据源 → 渐进式迁移

希望本文的实践经验能为你的项目提供一些参考。记住,最好的架构不是最复杂的,而是最适合团队的。


本文基于公司项目的真实重构实践整理,如有问题欢迎讨论。

Vue 3 组件开发最佳实践:可复用组件设计模式

Vue 3 组件开发最佳实践:可复用组件设计模式

前言

组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。

组件设计基本原则

1. 单一职责原则

每个组件应该只负责一个明确的功能,避免功能过于复杂。

2. 开放封闭原则

组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。

3. 可组合性

组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。

基础组件设计模式

1. Props 透传模式

<!-- BaseButton.vue -->
<template>
  <button 
    :class="buttonClasses"
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  block: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const buttonClasses = computed(() => [
  'btn',
  `btn--${props.variant}`,
  `btn--${props.size}`,
  {
    'btn--block': props.block,
    'btn--disabled': props.disabled
  }
])

const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event)
  }
}

// 允许父组件访问子组件实例
defineExpose({
  focus: () => {
    // 实现焦点管理
  }
})
</script>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease;
  text-decoration: none;
}

.btn--primary {
  background-color: #42b883;
  color: white;
}

.btn--secondary {
  background-color: #6c757d;
  color: white;
}

.btn--danger {
  background-color: #dc3545;
  color: white;
}

.btn--ghost {
  background-color: transparent;
  color: #42b883;
  border: 1px solid #42b883;
}

.btn--small {
  padding: 4px 8px;
  font-size: 12px;
}

.btn--medium {
  padding: 8px 16px;
  font-size: 14px;
}

.btn--large {
  padding: 12px 24px;
  font-size: 16px;
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn:hover:not(.btn--disabled) {
  opacity: 0.8;
  transform: translateY(-1px);
}
</style>

2. 插槽分发模式

<!-- Card.vue -->
<template>
  <div class="card" :class="cardClasses">
    <!-- 默认插槽 -->
    <div v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <h3 class="card__title">{{ title }}</h3>
      </slot>
    </div>
  
    <!-- 内容插槽 -->
    <div class="card__body">
      <slot />
    </div>
  
    <!-- 底部插槽 -->
    <div v-if="$slots.footer" class="card__footer">
      <slot name="footer" />
    </div>
  
    <!-- 操作区域插槽 -->
    <div v-if="$slots.actions" class="card__actions">
      <slot name="actions" />
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  bordered: {
    type: Boolean,
    default: true
  },
  shadow: {
    type: Boolean,
    default: false
  },
  hoverable: {
    type: Boolean,
    default: false
  }
})

const cardClasses = computed(() => ({
  'card--bordered': props.bordered,
  'card--shadow': props.shadow,
  'card--hoverable': props.hoverable
}))
</script>

<style scoped>
.card {
  background: #fff;
  border-radius: 8px;
}

.card--bordered {
  border: 1px solid #e5e5e5;
}

.card--shadow {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card--hoverable:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.card__header {
  padding: 16px 24px;
  border-bottom: 1px solid #f0f0f0;
}

.card__title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.card__body {
  padding: 24px;
}

.card__footer {
  padding: 16px 24px;
  border-top: 1px solid #f0f0f0;
}

.card__actions {
  padding: 16px 24px;
  text-align: right;
}
</style>

使用示例:

<template>
  <Card title="用户信息" bordered hoverable>
    <template #header>
      <div class="custom-header">
        <h3>用户详情</h3>
        <BaseButton size="small" variant="ghost">编辑</BaseButton>
      </div>
    </template>
  
    <p>这里是卡片内容</p>
  
    <template #footer>
      <div class="card-footer">
        <span>创建时间: 2023-01-01</span>
      </div>
    </template>
  
    <template #actions>
      <BaseButton variant="primary">保存</BaseButton>
      <BaseButton variant="ghost">取消</BaseButton>
    </template>
  </Card>
</template>

高级组件设计模式

1. Renderless 组件模式

Renderless 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:

<!-- FetchData.vue -->
<template>
  <slot 
    :loading="loading"
    :data="data"
    :error="error"
    :refetch="fetchData"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  url: {
    type: String,
    required: true
  },
  immediate: {
    type: Boolean,
    default: true
  }
})

const loading = ref(false)
const data = ref(null)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  error.value = null

  try {
    const response = await fetch(props.url)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    data.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  if (props.immediate) {
    fetchData()
  }
})

defineExpose({
  fetchData
})
</script>

使用示例:

<template>
  <FetchData url="/api/users" v-slot="{ loading, data, error, refetch }">
    <div class="user-list">
      <div v-if="loading">加载中...</div>
      <div v-else-if="error">错误: {{ error }}</div>
    
      <template v-else>
        <div v-for="user in data" :key="user.id" class="user-item">
          {{ user.name }}
        </div>
      
        <button @click="refetch">刷新</button>
      </template>
    </div>
  </FetchData>
</template>

2. Compound Components 模式

复合组件模式允许相关组件协同工作,共享状态和配置:

<!-- Tabs.vue -->
<template>
  <div class="tabs">
    <div class="tabs__nav" role="tablist">
      <slot name="nav" :active-key="activeKey" :change-tab="changeTab" />
    </div>
    <div class="tabs__content">
      <slot :active-key="activeKey" />
    </div>
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const activeKey = ref(props.modelValue)

const changeTab = (key) => {
  activeKey.value = key
  emit('update:modelValue', key)
}

// 提供给子组件使用的上下文
provide('tabs-context', {
  activeKey,
  changeTab
})
</script>

<style scoped>
.tabs {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
}

.tabs__nav {
  display: flex;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.tabs__content {
  padding: 24px;
}
</style>
<!-- TabNav.vue -->
<template>
  <div class="tab-nav">
    <slot />
  </div>
</template>

<style scoped>
.tab-nav {
  display: flex;
}
</style>
<!-- TabNavItem.vue -->
<template>
  <button
    :class="classes"
    :aria-selected="isActive"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)

const classes = computed(() => [
  'tab-nav-item',
  {
    'tab-nav-item--active': isActive.value,
    'tab-nav-item--disabled': props.disabled
  }
])

const handleClick = () => {
  if (!props.disabled) {
    tabsContext.changeTab(props.tabKey)
  }
}
</script>

<style scoped>
.tab-nav-item {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  transition: all 0.2s ease;
}

.tab-nav-item:hover:not(.tab-nav-item--disabled) {
  color: #42b883;
  background-color: rgba(66, 184, 131, 0.1);
}

.tab-nav-item--active {
  color: #42b883;
  font-weight: 600;
  background-color: #fff;
  border-bottom: 2px solid #42b883;
}

.tab-nav-item--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
<!-- TabPanel.vue -->
<template>
  <div v-show="isActive" class="tab-panel" role="tabpanel">
    <slot />
  </div>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
</script>

<style scoped>
.tab-panel {
  outline: none;
}
</style>

使用示例:

<template>
  <Tabs v-model="activeTab">
    <template #nav="{ activeKey, changeTab }">
      <TabNavItem tab-key="profile">个人信息</TabNavItem>
      <TabNavItem tab-key="settings">设置</TabNavItem>
      <TabNavItem tab-key="security" disabled>安全</TabNavItem>
    </template>
  
    <TabPanel tab-key="profile">
      <p>这是个人信息面板</p>
    </TabPanel>
  
    <TabPanel tab-key="settings">
      <p>这是设置面板</p>
    </TabPanel>
  
    <TabPanel tab-key="security">
      <p>这是安全面板</p>
    </TabPanel>
  </Tabs>
</template>

<script setup>
import { ref } from 'vue'

const activeTab = ref('profile')
</script>

3. Higher-Order Component (HOC) 模式

虽然 Vue 更推荐使用 Composition API,但在某些场景下 HOC 仍然有用:

// withLoading.js
import { h, ref, onMounted } from 'vue'

export function withLoading(WrappedComponent, loadingMessage = '加载中...') {
  return {
    name: `WithLoading(${WrappedComponent.name || 'Component'})`,
    inheritAttrs: false,
    props: WrappedComponent.props,
    emits: WrappedComponent.emits,
    setup(props, { attrs, slots, emit }) {
      const isLoading = ref(true)
    
      onMounted(() => {
        // 模拟异步操作
        setTimeout(() => {
          isLoading.value = false
        }, 1000)
      })
    
      return () => {
        if (isLoading.value) {
          return h('div', { class: 'loading-wrapper' }, loadingMessage)
        }
      
        return h(WrappedComponent, {
          ...props,
          ...attrs,
          on: Object.keys(emit).reduce((acc, key) => {
            acc[key] = (...args) => emit(key, ...args)
            return acc
          }, {})
        }, slots)
      }
    }
  }
}

4. State Reducer 模式

借鉴 React 的理念,通过 reducer 函数管理复杂状态:

<!-- Toggle.vue -->
<template>
  <div class="toggle">
    <slot 
      :on="on"
      :toggle="toggle"
      :set-on="setOn"
      :set-off="setOff"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  reducer: {
    type: Function,
    default: null
  }
})

const emit = defineEmits(['update:modelValue'])

const internalOn = ref(props.modelValue)

const getState = () => ({
  on: internalOn.value
})

const dispatch = (action) => {
  const changes = props.reducer 
    ? props.reducer(getState(), action)
    : defaultReducer(getState(), action)
  
  if (changes.on !== undefined) {
    internalOn.value = changes.on
    emit('update:modelValue', changes.on)
  }
}

const defaultReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      throw new Error(`Unknown action type: ${action.type}`)
  }
}

const toggle = () => dispatch({ type: 'toggle' })
const setOn = () => dispatch({ type: 'setOn' })
const setOff = () => dispatch({ type: 'setOff' })

defineExpose({
  toggle,
  setOn,
  setOff
})
</script>

使用示例:

<template>
  <Toggle :reducer="toggleReducer" v-slot="{ on, toggle, setOn, setOff }">
    <div class="toggle-demo">
      <p>状态: {{ on ? '开启' : '关闭' }}</p>
      <BaseButton @click="toggle">切换</BaseButton>
      <BaseButton @click="setOn">开启</BaseButton>
      <BaseButton @click="setOff">关闭</BaseButton>
    </div>
  </Toggle>
</template>

<script setup>
const toggleReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      // 添加日志记录
      console.log('Toggle state changed:', !state.on)
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      return state
  }
}
</script>

组件通信最佳实践

1. Provide/Inject 模式

// theme.js
import { ref, readonly, computed } from 'vue'

const themeSymbol = Symbol('theme')

export function createThemeStore() {
  const currentTheme = ref('light')

  const themes = {
    light: {
      primary: '#42b883',
      background: '#ffffff',
      text: '#333333'
    },
    dark: {
      primary: '#42b883',
      background: '#1a1a1a',
      text: '#ffffff'
    }
  }

  const toggleTheme = () => {
    currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
  }

  const themeConfig = computed(() => themes[currentTheme.value])

  return {
    currentTheme: readonly(currentTheme),
    themeConfig,
    toggleTheme
  }
}

export function provideTheme(themeStore) {
  provide(themeSymbol, themeStore)
}

export function useTheme() {
  const themeStore = inject(themeSymbol)
  if (!themeStore) {
    throw new Error('useTheme must be used within provideTheme')
  }
  return themeStore
}

2. Event Bus 替代方案

使用 mitt 库替代传统的事件总线:

// eventBus.js
import mitt from 'mitt'

export const eventBus = mitt()

// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)

性能优化策略

1. 组件懒加载

// router/index.js
const routes = [
  {
    path: '/heavy-component',
    component: () => import('@/components/HeavyComponent.vue')
  }
]

// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/charts/HeavyChart.vue')
)

2. 虚拟滚动

<!-- VirtualList.vue -->
<template>
  <div 
    ref="containerRef" 
    class="virtual-list"
    @scroll="handleScroll"
  >
    <div :style="{ height: totalHeight + 'px' }" class="virtual-list__spacer">
      <div 
        :style="{ transform: `translateY(${offsetY}px)` }"
        class="virtual-list__content"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :style="{ height: itemHeight + 'px' }"
          class="virtual-list__item"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  bufferSize: {
    type: Number,
    default: 5
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  const containerHeight = containerRef.value?.clientHeight || 0
  return Math.min(
    props.items.length - 1,
    Math.floor((scrollTop.value + containerHeight) / props.itemHeight) + props.bufferSize
  )
})

const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1)
})

const offsetY = computed(() => {
  return startIndex.value * props.itemHeight
})

const handleScroll = () => {
  scrollTop.value = containerRef.value.scrollTop
}

onMounted(() => {
  // 初始化滚动监听
})

onUnmounted(() => {
  // 清理资源
})
</script>

<style scoped>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #e5e5e5;
}

.virtual-list__spacer {
  position: relative;
}

.virtual-list__content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list__item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
}
</style>

测试友好的组件设计

1. 明确的 Props 定义

// Button.test.js
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

describe('BaseButton', () => {
  test('renders slot content', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    expect(wrapper.text()).toContain('Click me')
  })

  test('emits click event when clicked', async () => {
    const wrapper = mount(BaseButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })

  test('applies correct CSS classes based on props', () => {
    const wrapper = mount(BaseButton, {
      props: {
        variant: 'primary',
        size: 'large'
      }
    })
    expect(wrapper.classes()).toContain('btn--primary')
    expect(wrapper.classes()).toContain('btn--large')
  })
})

2. 可访问性考虑

<!-- AccessibleModal.vue -->
<template>
  <teleport to="body">
    <div 
      v-if="visible"
      ref="modalRef"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-describedby="descriptionId"
      class="modal"
      @keydown.esc="close"
    >
      <div class="modal__overlay" @click="close"></div>
      <div class="modal__content" ref="contentRef">
        <div class="modal__header">
          <h2 :id="titleId" class="modal__title">{{ title }}</h2>
          <button 
            type="button"
            class="modal__close"
            @click="close"
            aria-label="关闭对话框"
          >
            ×
          </button>
        </div>
      
        <div :id="descriptionId" class="modal__body">
          <slot />
        </div>
      
        <div v-if="$slots.footer" class="modal__footer">
          <slot name="footer" />
        </div>
      </div>
    </div>
  </teleport>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['update:visible', 'close'])

const modalRef = ref(null)
const contentRef = ref(null)
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const descriptionId = `modal-desc-${Math.random().toString(36).substr(2, 9)}`

const close = () => {
  emit('update:visible', false)
  emit('close')
}

watch(() => props.visible, async (newVal) => {
  if (newVal) {
    await nextTick()
    // 自动聚焦到模态框
    contentRef.value?.focus()
  }
})
</script>

结语

Vue 3 组件开发的最佳实践涉及多个方面,从基础的 Props 和插槽使用,到高级的设计模式如 Renderless 组件和 Compound Components,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:

  1. 保持组件简洁:每个组件专注于单一功能
  2. 提供良好的 API:清晰的 Props 定义和事件接口
  3. 重视可访问性:确保所有用户都能正常使用组件
  4. 考虑性能影响:特别是在处理大量数据或复杂交互时
  5. 便于测试:设计易于测试的组件接口

通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。

Vue 3 动画效果实现:Transition和TransitionGroup详解

Vue 3 动画效果实现:Transition和TransitionGroup详解

前言

在现代Web应用中,流畅的动画效果不仅能提升用户体验,还能有效传达界面状态变化的信息。Vue 3 提供了强大的过渡和动画系统,通过 <transition><transition-group> 组件,开发者可以轻松地为元素的进入、离开和列表变化添加动画效果。本文将深入探讨这两个组件的使用方法和高级技巧。

Transition 组件基础

基本用法

<transition> 组件用于包装单个元素或组件,在插入、更新或移除时应用过渡效果。

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    <transition name="fade">
      <p v-if="show">Hello Vue 3!</p>
    </transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Vue 3 为进入/离开过渡提供了6个CSS类名:

  1. v-enter-from:进入过渡的开始状态
  2. v-enter-active:进入过渡生效时的状态
  3. v-enter-to:进入过渡的结束状态
  4. v-leave-from:离开过渡的开始状态
  5. v-leave-active:离开过渡生效时的状态
  6. v-leave-to:离开过渡的结束状态

注意:在 Vue 3 中,类名前缀从 v-enter 改为 v-enter-from,其他类名也相应调整。

JavaScript 钩子函数

除了CSS过渡,还可以使用JavaScript钩子来控制动画:

<template>
  <transition
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
  >
    <div v-if="show" class="box">Animated Box</div>
  </transition>
</template>

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)

const beforeEnter = (el) => {
  el.style.opacity = 0
  el.style.transform = 'scale(0)'
}

const enter = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 1,
    scale: 1,
    onComplete: done
  })
}

const afterEnter = (el) => {
  console.log('进入完成')
}

const beforeLeave = (el) => {
  el.style.transformOrigin = 'center'
}

const leave = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 0,
    scale: 0,
    onComplete: done
  })
}

const afterLeave = (el) => {
  console.log('离开完成')
}
</script>

常见动画效果实现

1. 淡入淡出效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Fade</button>
    <transition name="fade">
      <div v-if="show" class="content">Fade Effect Content</div>
    </transition>
  </div>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease-in-out;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2. 滑动效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Slide</button>
    <transition name="slide">
      <div v-if="show" class="content">Slide Effect Content</div>
    </transition>
  </div>
</template>

<style>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
  max-height: 200px;
  overflow: hidden;
}

.slide-enter-from,
.slide-leave-to {
  max-height: 0;
  opacity: 0;
  transform: translateY(-20px);
}
</style>

3. 弹跳效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Bounce</button>
    <transition name="bounce">
      <div v-if="show" class="content">Bounce Effect Content</div>
    </transition>
  </div>
</template>

<style>
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
    opacity: 0;
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

4. 翻转效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Flip</button>
    <transition name="flip">
      <div v-if="show" class="content flip-content">Flip Effect Content</div>
    </transition>
  </div>
</template>

<style>
.flip-enter-active {
  animation: flip-in 0.6s ease forwards;
}

.flip-leave-active {
  animation: flip-out 0.6s ease forwards;
}

@keyframes flip-in {
  0% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
  40% {
    transform: perspective(400px) rotateY(-10deg);
  }
  70% {
    transform: perspective(400px) rotateY(10deg);
  }
  100% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
}

@keyframes flip-out {
  0% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
  100% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
}
</style>

TransitionGroup 组件详解

基本列表动画

<transition-group> 用于为列表中的元素添加进入/离开过渡效果:

<template>
  <div class="list-demo">
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
  
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  const index = Math.floor(Math.random() * (items.length + 1))
  items.splice(index, 0, {
    id: nextId++,
    text: `新项目 ${nextId - 1}`
  })
}

const removeItem = () => {
  if (items.length > 0) {
    const index = Math.floor(Math.random() * items.length)
    items.splice(index, 1)
  }
}
</script>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-move {
  transition: transform 0.5s ease;
}

.list-item {
  padding: 10px;
  margin: 5px 0;
  background-color: #f0f0f0;
  border-radius: 4px;
}
</style>

列表排序动画

<template>
  <div class="shuffle-demo">
    <button @click="shuffle">随机排序</button>
    <button @click="add">添加</button>
    <button @click="remove">删除</button>
  
    <transition-group name="shuffle" tag="div" class="grid">
      <div 
        v-for="item in items" 
        :key="item.id" 
        class="grid-item"
        @click="removeItem(item)"
      >
        {{ item.number }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, number: 1 },
  { id: 2, number: 2 },
  { id: 3, number: 3 },
  { id: 4, number: 4 },
  { id: 5, number: 5 }
])

const shuffle = () => {
  // Fisher-Yates 洗牌算法
  for (let i = items.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [items[i], items[j]] = [items[j], items[i]]
  }
}

const add = () => {
  const newNumber = items.length > 0 ? Math.max(...items.map(i => i.number)) + 1 : 1
  items.push({
    id: Date.now(),
    number: newNumber
  })
}

const remove = () => {
  if (items.length > 0) {
    items.pop()
  }
}

const removeItem = (item) => {
  const index = items.indexOf(item)
  if (index > -1) {
    items.splice(index, 1)
  }
}
</script>

<style>
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}

.grid-item {
  width: 60px;
  height: 60px;
  background-color: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: pointer;
  font-weight: bold;
  user-select: none;
}

.shuffle-enter-active,
.shuffle-leave-active {
  transition: all 0.5s ease;
}

.shuffle-enter-from {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-leave-to {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-move {
  transition: transform 0.5s ease;
}
</style>

高级动画技巧

1. FLIP 技术实现平滑动画

FLIP (First, Last, Invert, Play) 是一种优化动画性能的技术:

<template>
  <div class="flip-demo">
    <button @click="filterItems">筛选奇数</button>
    <button @click="resetFilter">重置</button>
  
    <transition-group 
      name="flip-list" 
      tag="div" 
      class="flip-container"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div 
        v-for="item in filteredItems" 
        :key="item.id" 
        class="flip-item"
      >
        {{ item.value }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const items = ref(Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  value: i + 1
})))

const filterOdd = ref(false)

const filteredItems = computed(() => {
  return filterOdd.value 
    ? items.value.filter(item => item.value % 2 === 1)
    : items.value
})

const filterItems = () => {
  filterOdd.value = true
}

const resetFilter = () => {
  filterOdd.value = false
}

const positions = new Map()

const beforeEnter = (el) => {
  el.style.opacity = '0'
  el.style.transform = 'scale(0.8)'
}

const enter = (el, done) => {
  // 获取最终位置
  const end = el.getBoundingClientRect()
  const start = positions.get(el)

  if (start) {
    // 计算位置差
    const dx = start.left - end.left
    const dy = start.top - end.top
    const ds = start.width / end.width
  
    // 反向变换
    el.style.transform = `translate(${dx}px, ${dy}px) scale(${ds})`
  
    // 强制重绘
    el.offsetHeight
  
    // 执行动画
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
  
    setTimeout(done, 300)
  } else {
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
    setTimeout(done, 300)
  }
}

const leave = (el, done) => {
  // 记录初始位置
  positions.set(el, el.getBoundingClientRect())
  el.style.position = 'absolute'
  done()
}
</script>

<style>
.flip-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
  gap: 10px;
  position: relative;
  min-height: 200px;
}

.flip-item {
  background-color: #3498db;
  color: white;
  padding: 20px;
  text-align: center;
  border-radius: 8px;
  font-weight: bold;
}

.flip-list-enter-active,
.flip-list-leave-active {
  transition: all 0.3s ease;
}

.flip-list-enter-from,
.flip-list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

.flip-list-move {
  transition: transform 0.3s ease;
}
</style>

2. 交错动画

<template>
  <div class="stagger-demo">
    <button @click="loadItems">加载项目</button>
    <button @click="clearItems">清空</button>
  
    <transition-group 
      name="staggered-fade" 
      tag="ul" 
      class="staggered-list"
    >
      <li 
        v-for="(item, index) in items" 
        :key="item.id"
        :data-index="index"
        class="staggered-item"
      >
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([])

const loadItems = () => {
  items.value = Array.from({ length: 10 }, (_, i) => ({
    id: Date.now() + i,
    text: `项目 ${i + 1}`
  }))
}

const clearItems = () => {
  items.value = []
}
</script>

<style>
.staggered-list {
  list-style: none;
  padding: 0;
}

.staggered-item {
  padding: 15px;
  margin: 5px 0;
  background-color: #e74c3c;
  color: white;
  border-radius: 6px;
  opacity: 0;
}

/* 进入动画 */
.staggered-fade-enter-active {
  transition: all 0.3s ease;
}

.staggered-fade-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

/* 离开动画 */
.staggered-fade-leave-active {
  transition: all 0.3s ease;
  position: absolute;
}

.staggered-fade-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 移动动画 */
.staggered-fade-move {
  transition: transform 0.3s ease;
}

/* 交错延迟 */
.staggered-item:nth-child(1) { transition-delay: 0.05s; }
.staggered-item:nth-child(2) { transition-delay: 0.1s; }
.staggered-item:nth-child(3) { transition-delay: 0.15s; }
.staggered-item:nth-child(4) { transition-delay: 0.2s; }
.staggered-item:nth-child(5) { transition-delay: 0.25s; }
.staggered-item:nth-child(6) { transition-delay: 0.3s; }
.staggered-item:nth-child(7) { transition-delay: 0.35s; }
.staggered-item:nth-child(8) { transition-delay: 0.4s; }
.staggered-item:nth-child(9) { transition-delay: 0.45s; }
.staggered-item:nth-child(10) { transition-delay: 0.5s; }
</style>

3. 页面切换动画

<!-- App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
      <router-link to="/contact">联系</router-link>
    </nav>
  
    <router-view v-slot="{ Component }">
      <transition name="page" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

<style>
.page-enter-active,
.page-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  top: 60px;
  left: 0;
  right: 0;
}

.page-enter-from {
  opacity: 0;
  transform: translateX(30px);
}

.page-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

nav {
  padding: 20px;
  background-color: #f8f9fa;
}

nav a {
  margin-right: 20px;
  text-decoration: none;
  color: #333;
}

nav a.router-link-active {
  color: #42b883;
  font-weight: bold;
}
</style>

性能优化建议

1. 使用 transform 和 opacity

优先使用 transformopacity 属性,因为它们不会触发重排:

/* 推荐 */
.good-animation {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* 避免 */
.bad-animation {
  transition: left 0.3s ease, top 0.3s ease;
}

2. 合理使用 will-change

对于复杂的动画,可以提前告知浏览器优化:

.animated-element {
  will-change: transform, opacity;
}

3. 避免阻塞主线程

对于复杂动画,考虑使用 Web Workers 或 requestAnimationFrame:

const animateElement = (element, duration) => {
  const startTime = performance.now()

  const animate = (currentTime) => {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
  
    // 更新元素样式
    element.style.transform = `translateX(${progress * 100}px)`
  
    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  requestAnimationFrame(animate)
}

结语

Vue 3 的过渡和动画系统为我们提供了强大而灵活的工具来创建丰富的用户界面体验。通过合理运用 <transition><transition-group> 组件,结合 CSS3 动画和 JavaScript 控制,我们能够实现从简单到复杂的各种动画效果。

关键要点总结:

  1. 理解过渡类名机制:掌握6个核心类名的作用时机
  2. 善用 JavaScript 钩子:实现更复杂的自定义动画逻辑
  3. 列表动画的重要性:使用 <transition-group> 处理动态列表
  4. 性能优化意识:选择合适的 CSS 属性和动画技术
  5. 用户体验考量:动画应该增强而不是阻碍用户操作

在实际项目中,建议根据具体需求选择合适的动画方案,并始终考虑性能影响。适度的动画能够显著提升用户体验,但过度或不当的动画反而会适得其反。希望本文能够帮助你在 Vue 3 项目中更好地实现和控制动画效果。

别再用mixin了!Vue3自定义Hooks让逻辑复用爽到飞起

前言

随着 Vue 3 的普及,Composition API 成为了构建复杂应用的主流方式。相比 Options API,Composition API 提供了更好的逻辑组织和复用能力。而自定义 Hooks 正是这一能力的核心体现,它让我们能够将业务逻辑抽象成可复用的函数,极大地提升了代码的可维护性和开发效率。

什么是自定义 Hooks?

自定义 Hooks 是基于 Composition API 封装的可复用逻辑函数。它们通常以 use 开头命名,返回响应式数据、方法或计算属性。通过自定义 Hooks,我们可以将组件中的逻辑抽离出来,在多个组件间共享。

基本结构

// useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

实战案例:常用自定义 Hooks

1. 网络请求 Hook

// useApi.js
import { ref, onMounted } from 'vue'
import axios from 'axios'

export function useApi(url, options = {}) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async (params = {}) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await axios.get(url, { ...options, params })
      data.value = response.data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (options.immediate !== false) {
      fetchData()
    }
  })

  return {
    data,
    loading,
    error,
    fetchData
  }
}

使用示例:

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="item in data" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    <button @click="fetchData">刷新</button>
  </div>
</template>

<script setup>
import { useApi } from '@/hooks/useApi'

const { data, loading, error, fetchData } = useApi('/api/users')
</script>

2. 表单验证 Hook

// useForm.js
import { reactive, computed } from 'vue'

export function useForm(initialValues, rules) {
  const formData = reactive({ ...initialValues })
  const errors = reactive({})

  const validateField = (field) => {
    const value = formData[field]
    const fieldRules = rules[field] || []
  
    for (const rule of fieldRules) {
      if (!rule.validator(value, formData)) {
        errors[field] = rule.message
        return false
      }
    }
  
    delete errors[field]
    return true
  }

  const validateAll = () => {
    let isValid = true
    Object.keys(rules).forEach(field => {
      if (!validateField(field)) {
        isValid = false
      }
    })
    return isValid
  }

  const resetForm = () => {
    Object.assign(formData, initialValues)
    Object.keys(errors).forEach(key => {
      delete errors[key]
    })
  }

  const isDirty = computed(() => {
    return JSON.stringify(formData) !== JSON.stringify(initialValues)
  })

  return {
    formData,
    errors,
    validateField,
    validateAll,
    resetForm,
    isDirty
  }
}

使用示例:

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="formData.username" 
        @blur="() => validateField('username')"
        placeholder="用户名"
      />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
  
    <div>
      <input 
        v-model="formData.email" 
        @blur="() => validateField('email')"
        placeholder="邮箱"
      />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
  
    <button type="submit" :disabled="!isDirty">提交</button>
    <button type="button" @click="resetForm">重置</button>
  </form>
</template>

<script setup>
import { useForm } from '@/hooks/useForm'

const { formData, errors, validateField, validateAll, resetForm, isDirty } = useForm(
  { username: '', email: '' },
  {
    username: [
      {
        validator: (value) => value.length >= 3,
        message: '用户名至少3个字符'
      }
    ],
    email: [
      {
        validator: (value) => /\S+@\S+\.\S+/.test(value),
        message: '请输入有效的邮箱地址'
      }
    ]
  }
)

const handleSubmit = () => {
  if (validateAll()) {
    console.log('表单验证通过:', formData)
  }
}
</script>

3. 防抖节流 Hook

// useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeoutId = null

  watch(value, (newValue) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

// useThrottle.js
export function useThrottle(value, delay = 300) {
  const throttledValue = ref(value.value)
  let lastTime = 0

  watch(value, (newValue) => {
    const now = Date.now()
    if (now - lastTime >= delay) {
      throttledValue.value = newValue
      lastTime = now
    }
  })

  return throttledValue
}

4. 本地存储 Hook

// useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key)
  const value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)

  watch(value, (newValue) => {
    if (newValue === null) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  }, { deep: true })

  const remove = () => {
    value.value = null
  }

  return [value, remove]
}

高级技巧与最佳实践

1. Hook 组合

// useUserManagement.js
import { useApi } from './useApi'
import { useLocalStorage } from './useLocalStorage'

export function useUserManagement() {
  const [currentUser, removeCurrentUser] = useLocalStorage('currentUser', null)
  const { data: users, loading, error, fetchData } = useApi('/api/users')

  const login = async (credentials) => {
    // 登录逻辑
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const userData = await response.json()
    currentUser.value = userData
  }

  const logout = () => {
    removeCurrentUser()
    // 其他登出逻辑
  }

  return {
    currentUser,
    users,
    loading,
    error,
    login,
    logout,
    refreshUsers: fetchData
  }
}

2. 错误处理

// useAsync.js
import { ref, onMounted } from 'vue'

export function useAsync(asyncFunction, immediate = true) {
  const result = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const execute = async (...args) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await asyncFunction(...args)
      result.value = response
      return response
    } catch (err) {
      error.value = err
      throw err
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (immediate) {
      execute()
    }
  })

  return {
    result,
    loading,
    error,
    execute
  }
}

3. 类型安全(TypeScript)

// useCounter.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  doubleCount: ComputedRef<number>
}

export function useCounter(initialValue: number = 0): UseCounterReturn {
  const count = ref(initialValue)

  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

设计原则与注意事项

1. 单一职责原则

每个 Hook 应该只负责一个特定的功能领域,保持功能单一且专注。

2. 命名规范

  • 使用 use 前缀
  • 名称清晰表达 Hook 的用途
  • 避免过于通用的名称

3. 返回值设计

  • 返回对象而非数组(便于解构时命名)
  • 保持返回值的一致性
  • 考虑添加辅助方法

4. 性能优化

  • 合理使用 watchcomputed
  • 避免不必要的重新计算
  • 及时清理副作用

结语

自定义 Hooks 是 Vue 3 Composition API 生态中的重要组成部分,它不仅解决了逻辑复用的问题,更提供了一种更加灵活和可组合的开发模式。通过合理地设计和使用自定义 Hooks,我们可以:

  1. 提升代码复用性:将通用逻辑抽象成独立模块
  2. 改善代码组织:让组件更加关注视图逻辑
  3. 增强可测试性:独立的逻辑更容易进行单元测试
  4. 提高开发效率:减少重复代码编写

在实际项目中,建议根据业务需求逐步积累和优化自定义 Hooks,建立属于团队的 Hooks 库,这将是提升前端开发质量和效率的重要手段。

记住,好的自定义 Hooks 不仅要解决当前问题,更要具备良好的扩展性和可维护性。随着经验的积累,你会发现自己能够创造出越来越优雅和实用的自定义 Hooks。

拒绝做 DOM 的“搬运工”:从 Vanilla JS 到 Vue 3 响应式思维的进化

在前端开发的漫长演进中,我们经常听到“数据驱动”这个词。但对于很多习惯了 jQuery 或者原生 JavaScript(Vanilla JS)的开发者来说,从“操作 DOM”到“操作数据”的思维转变,往往比学习新语法更难。

今天,我们将通过重构一个经典的 Todos 任务清单应用,来深度剖析 Vue 3 Composition API 是如何解放我们的双手,让我们专注于业务逻辑而非繁琐的页面渲染。

1. 痛点回顾:原生 JS 的“命令式”困境

在没有框架的时代,写一个简单的输入框回显功能,我们通常需要经历这几个步骤:寻找元素 -> 监听事件 -> 获取值 -> 修改 DOM。

让我们看看这个基于原生 JS 的实现片段:

// 先找到DOM元素, 命令式的, 机械的
const app = document.getElementById('app');
const todoInput = document.getElementById('todo-input');

todoInput.addEventListener('change', function(event) {
    const todo = event.target.value.trim();
    if (!todo) return;
    // 手动操作 DOM 更新
    app.innerHTML = todo; 
})

这种代码被称为命令式编程(Imperative Programming) 。正如在代码注释中所写,这是一种“机械”的过程。我们需要关注每一个步骤的实现细节。而且,频繁地操作 DOM 性能是低下的,因为这涉及到了 JS 引擎(V8)与渲染引擎之间的跨界通信。

随着应用变得复杂,大量的 getElementByIdinnerHTML 会让代码变成难以维护的“意大利面条”。

2. Vue 3 的破局:响应式数据与声明式渲染

Vue 的核心在于声明式编程(Declarative Programming) 。你只需要告诉 Vue “想要什么结果”,中间的 DOM 更新过程由 Vue 替你完成。

在 Vue 3 中,我们利用 setup 函数和 Composition API(组合式 API)来组织逻辑。

2.1 核心概念:ref 与数据驱动

App.vue 中,我们不再去查询 DOM 元素,而是定义了响应式数据

import { ref, computed } from 'vue'

// 响应式数据
const title = ref("");
const todos = ref([
  { id: 1, title: '睡觉', done: true },
  { id: 2, title: '吃饭', done: false }
]);

这里体现了 Vue 开发的核心思路: “不再需要思考页面的元素怎么操作,而是要思考数据是怎么变化的”

2.2 指令:连接数据与视图的桥梁

有了数据,我们通过 Vue 的指令将数据绑定到模板上:

  • 双向绑定 (v-model)<input type="text" v-model="title">。当用户输入时,title 变量自动更新;反之亦然。这比手动写 addEventListener 优雅得多。
  • 列表渲染 (v-for)<li v-for="todo in todos" :key="todo.id">。Vue 会根据 todos 数组的变化,智能地添加、删除或更新 <li> 元素。注意这里 :key 的使用,它是 Vue 识别节点的唯一标识,对性能至关重要。
  • 样式绑定 (:class)<span :class="{done: todo.done}">。我们不再需要手动 classList.add('done'),只需改变数据 todo.done,样式就会自动生效。

2.3 智能的条件渲染:v-if 与 v-else 的排他性逻辑

在实际应用中,用户体验细节至关重要。例如,当任务列表被清空时,我们不应该留给用户一片空白,而应该展示“暂无任务”的提示。在原生 JS 中,这通常需要我们在每次添加或删除操作后,手动检查数组长度并切换 DOM 的 display 属性。

而在 Vue 中,我们可以通过 v-ifv-else 指令,像写 if-else 代码块一样在模板中轻松处理这种逻辑分支:

<ul v-if="todos.length">
  <li v-for="todo in todos" :key="todo.id">
    ...
  </li>
</ul>
<div v-else>
  <span>暂无任务</span>
</div>

代码深度解析:

  1. 真实 DOM 的销毁与重建v-if 是真正的条件渲染。当 todos.length 为 0 时,Vue 不仅仅是隐藏了 <ul>(像 CSS 的 display: none 那样),而是直接从 DOM 中移除了整个列表元素。这意味着此时 DOM 中只有 <div>暂无任务</div>,减少了页面的 DOM 节点数量。
  2. 响应式切换:一旦我们向 todos 数组 push 了一条新数据,todos.length 变为 1。Vue 的响应式系统会立即感知,销毁 v-else 元素,并重新创建并插入 <ul> 列表。
  3. 逻辑互斥v-else 必须紧跟在 v-if 元素之后,它们构成了一个封闭的逻辑组,保证了同一时间页面上只会存在其中一种状态。

通过这两个指令,我们不仅实现了界面的动态交互,更重要的是,我们将“列表为空时显示什么”的业务逻辑直接通过模板表达了出来,不仅代码量减少了,意图也更加清晰。

3. 深度解析:Computed 计算属性 vs. 模板逻辑

在开发中,我们经常需要根据现有的数据计算出新的状态,比如统计“剩余未完成任务数”。

3.1 为什么要用 Computed?

初学者可能会直接在模板里写逻辑:

{{ todos.filter(todo => !todo.done).length }}

虽然这也能工作,但 Vue 官方更推荐使用 Computed(计算属性)

// 创建一个响应式的计算属性
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

computed 的四大优势:

  1. 性能优化(带缓存) :这是最大的区别。模板内的表达式在每次组件重渲染时都会重新执行。而 computed 只有在它依赖的数据(这里是 todos)发生变化时才会重新计算。如果 todos 没变,多次访问 active 会直接返回缓存值。
  2. 可读性:将复杂的逻辑从 HTML 模板中剥离到 JS 中,让模板保持干净、语义化。
  3. 可复用性active 可以在模板中多处使用,也可以在 JS 逻辑中被引用。
  4. 调试与测试:单独测试一个 JS 函数远比测试模板中的一段逻辑要容易。

3.2 进阶技巧:Computed 的 Get 与 Set

计算属性通常是只读的,但 Vue 也允许我们定义 set 方法,这在处理“全选/全不选”功能时非常强大。

看看这段精妙的代码:

const allDone = computed({
  // 读取值:判断是否所有任务都已完成
  get() {
    return todos.value.every(todo => todo.done)
  },
  // 设置值:当点击全选框时,将所有任务状态同步修改
  set(value) {
    todos.value.forEach(todo => todo.done = value)
  }
})

在模板中,我们只需绑定 <input type="checkbox" v-model="allDone">

  • 当用户点击复选框,Vue 调用 set(value),我们遍历数组更新所有 todo.done
  • 当所有子任务被手动勾选,get() 返回 true,全选框自动被勾选。

这种双向的逻辑联动,如果用原生 JS 实现,需要编写大量的事件监听和状态判断代码,而在 Vue 中,它被封装成了一个优雅的属性。

4. 总结:Vue 开发方式的哲学

demo.htmlApp.vue,我们经历的不仅仅是语法的改变,更是思维模式的重构:

  • Focus on Business:我们不再是浏览器的“建筑工人”(搬运 DOM),而是“设计师”(定义数据状态)。
  • Composition APIsetuprefcomputed 让我们能够更灵活地组合逻辑,比 Vue 2 的 Options API 更利于代码复用和类型推断。
  • Best Practices:永远不要在模板中写复杂的逻辑,善用 computed 缓存机制。

Vue 3 通过响应式系统,替我们处理了脏活累活(DOM 更新),让我们能将精力集中在真正有价值的业务逻辑上。对于想要构建复杂交互系统(如粒子特效、数据可视化)的开发者来说,掌握这种“数据驱动”的思维是迈向高阶开发的第一步。

5.附录:完整App.vue代码

<template>
   <div>
    <h2>{{ title }}</h2>
    <input type="text" v-model="title" @keydown.enter="addTodo">
    <ul v-if="todos.length">
      <li v-for="todo in todos" :key="todo.id">
        <input type="checkbox" v-model="todo.done">
        <span :class="{done: todo.done}">{{ todo.title }}</span>
      </li>
    </ul>
    <div v-else>
      <span>暂无任务</span>
    </div>
    <div>
      全选<input type="checkbox" v-model="allDone">
      {{ active }}
      /
      {{ todos.length }}
    </div>
   </div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 响应式数据
const title = ref("");
const todos = ref([
  {
    id: 1,
    title: '睡觉',
    done: true
  },
  {
    id: 2,
    title: '吃饭',
    done: false
  }
]);

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

const addTodo = () => {
  if(!title.value) return;
  todos.value.push({
    id: todos.value.length + 1,
    title: title.value,
    done: false
  });
  title.value = '';
}
const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => todo.done = value)
  }
})
</script>
<style>
  .done {
    color: gray;
    text-decoration: line-through;
  }
</style>

Vue 任务清单开发:数据驱动 vs 传统 DOM 操作

Vue 任务清单开发:数据驱动 vs 传统 DOM 操作

在前端开发中,任务清单是一个常见的案例,通过这个案例我们可以清晰对比传统 DOM 操作与 Vue 数据驱动开发的差异。本文将结合具体代码,解析 Vue 的核心思想和常用 API。

传统开发方式的局限

传统 JavaScript 开发中,我们需要手动操作 DOM 元素来实现功能。以下代码为例:

<h2 id="app"></h2>
<input type="text" id="todo-input">
<script>
    // 传统方式需要先获取DOM元素
    const app = document.getElementById('app');
    const todoInput = document.getElementById('todo-input');
    
    // 手动绑定事件并操作DOM
    todoInput.addEventListener('change',function(event) {
        const todo = event.target.value.trim();
        if(!todo){
            console.log('请输入任务');
            return ;
        }else{
            // 直接修改DOM内容
            app.innerHTML = todo;
        }
    })
</script>

这种方式的特点是:

  • 需要手动获取 DOM 元素
  • 命令式地操作 DOM 进行更新
  • 业务逻辑与 DOM 操作混杂
  • 随着功能复杂,代码会变得难以维护

Vue 的数据驱动开发理念

Vue 采用了完全不同的思路:开发者只需关注数据本身,而非 DOM 操作。以任务清单为例:

<template>
  <div>
    <!-- 数据绑定 -->
    <h2>{{ title }}</h2>
    <!-- 双向数据绑定 -->
    <input type="text" v-model="title" @keydown.enter="addTodo">
    
    <!-- 条件渲染 -->
    <ul v-if="todos.length">
      <!-- 循环渲染 -->
      <li v-for="todo in todos" :key="todo.id">
        <input type="checkbox" v-model="todo.done">
        <span :class="{done: todo.done}">{{ todo.title }}</span>
      </li>
    </ul>
    <div v-else>暂无计划</div>
  </div>
</template>

Vue 的核心思想是:不再关心页面元素如何操作,只关注数据如何变化。当数据发生改变时,Vue 会自动更新 DOM,开发者无需手动操作。

Vue 常用 API 解析

  1. v-model 双向数据绑定

    <input type="text" v-model="title">
    

    实现表单输入与数据的双向绑定,输入框的变化会自动更新数据,数据的变化也会自动反映到输入框。

  2. v-for 循环渲染

    <li v-for="todo in todos" :key="todo.id">
    

    基于数组渲染列表,:key用于标识每个元素的唯一性,提高渲染性能。

  3. v-if/v-else 条件渲染

    <ul v-if="todos.length">
      ...
    </ul>
    <div v-else>暂无计划</div>
    

    根据条件动态渲染不同的内容,当todos数组为空时显示 "暂无计划"。

  4. :class 动态类绑定

    <span :class="{done: todo.done}">{{ todo.title }}</span>
    

    todo.donetrue时,自动为元素添加done类,实现完成状态的样式变化。

  5. @事件监听

    <input type="text" @keydown.enter="addTodo">
    

    监听键盘回车事件,触发addTodo方法,@v-bind:的缩写。

  6. computed 计算属性

    // 计算未完成的任务数量
    const active = computed(() => {
      return todos.value.filter(todo => !todo.done).length
    })
    
    // 全选功能的实现
    const allDone = computed({
      get(){
        return todos.value.every(todo => todo.done)
      },
      set(val){
        todos.value.forEach(todo => todo.done = val)
      }
    })
    

    计算属性具有缓存特性,只有依赖的数据变化时才会重新计算,相比方法调用更节省性能。全选功能展示了计算属性的高级用法,通过getset实现双向绑定。

  7. ref 响应式数据

    import { ref } from 'vue'
    const title = ref("");
    const todos = ref([...])
    

    创建响应式数据,当这些数据变化时,Vue 会自动更新相关的 DOM。

总结

Vue 通过数据驱动的方式,极大简化了前端开发流程:

  • 开发者可以专注于业务逻辑和数据处理
  • 减少了大量手动 DOM 操作的代码
  • 提供了简洁直观的 API,降低学习成本
  • 内置的性能优化(如计算属性缓存)让应用运行更高效

Vue 组件解耦实践:用回调函数模式替代枚举类型传递

Vue 组件解耦实践:用回调函数模式替代枚举类型传递

前言

在 Vue 组件开发中,父子组件通信是一个常见场景。当子组件需要触发父组件的某个操作,而父组件又需要根据触发来源执行不同逻辑时,很容易写出耦合度较高的代码。本文通过一个真实的登录模块重构案例,介绍如何使用回调函数模式来解耦组件。

问题场景

业务背景

在登录页面中,验证码登录组件有两个操作入口:

  • 点击"获取验证码"按钮

  • 点击"登录"按钮

两个操作都需要检查用户是否同意服务协议。如果未同意,需要弹出协议确认弹窗。用户确认后,根据触发来源执行不同的后续操作。

原有实现

// codeLogin.enum.ts - 子组件定义枚举
export const CodeLoginEnum = {
  CODE_BTN: 'code-btn',    // 获取验证码按钮
  LOGIN_BTN: 'login-btn'   // 登录按钮
} as const;

// codeLogin.vue - 子组件
const getCode = () => {
  if (!isAgree.value) {
    emit('changeCodeAgreeDisplayType', CodeLoginEnum.CODE_BTN);  // 告诉父组件是哪个按钮
    emit('toggleAgreeDialog', true);
    return;
  }
  // ...
}

// login.vue - 父组件
const handleAgreementConfirm = () => {
  if (codeAgreeDisplayType.value === CodeLoginEnum.LOGIN_BTN) {
    // 登录按钮触发的,需要校验验证码
    if (!verifyKey.value) {
      ElMessage.warning('请先获取验证码');
      return;
    }
  }
  codeLoginInstance.value?.doGetCode();
}

问题分析

  1. 父组件依赖子组件内部细节:父组件需要导入并理解 CodeLoginEnum

  2. 违反开闭原则:子组件新增按钮时,父组件也需要修改

  3. 职责不清:子组件的业务逻辑分散在父子两个组件中

  4. 可测试性差:父组件的逻辑依赖子组件的枚举定义

解决方案:回调函数模式

核心思想

子组件不告诉父组件"我是谁",而是告诉父组件"确认后请通知我"

将"后续要执行的操作"封装为回调函数,保存在子组件内部。父组件只需要在适当时机通知子组件执行即可。

重构后的实现

// codeLogin.vue - 子组件
type PendingCallback = (() => void) | null;
const pendingCallback = ref<PendingCallback>(null);

const getCode = () => {
  if (!isAgree.value) {
    // 保存回调:协议确认后执行获取验证码
    pendingCallback.value = () => {
      executeGetCode();
    };
    emit('toggleAgreeDialog', true);
    return;
  }
  executeGetCode();
}

const codeLogin = () => {
  if (!isAgree.value) {
    // 保存回调:协议确认后执行登录
    pendingCallback.value = () => {
      emit('codeLogin', mobileValue.value, areaCodeValue.value, verifyCodeArg.value);
    };
    emit('toggleAgreeDialog', true);
    return;
  }
  emit('codeLogin', mobileValue.value, areaCodeValue.value, verifyCodeArg.value);
}

// 供父组件调用
const onAgreementConfirmed = () => {
  pendingCallback.value?.();
  pendingCallback.value = null;
}

defineExpose({ onAgreementConfirmed });
// login.vue - 父组件
const handleAgreementConfirm = () => {
  toggleIsAgree(true);
  toggleAgreeDialog(false);

  if (isAccount()) {
    doLoginFn(loginTempData);
  } else {
    // 简单通知子组件执行回调,无需知道具体是什么操作
    codeLoginInstance.value?.onAgreementConfirmed();
  }
}

数据流对比

重构前:

┌─────────┐  发送按钮类型   ┌─────────┐  根据类型判断   ┌─────────┐
│ 子组件  │ ─────────────→ │ 父组件  │ ─────────────→ │ 子组件  │
└─────────┘                └─────────┘                └─────────┘

重构后:

┌─────────┐  保存回调      ┌─────────┐  通知执行       ┌─────────┐
│ 子组件  │ ─────────────→ │ 父组件  │ ─────────────→ │ 子组件  │
└─────────┘  请求显示弹窗   └─────────┘  onConfirmed   └─────────┘
            (不传类型)                   (不传参数)

方案对比

维度 枚举类型传递 回调函数模式
耦合度 高,父组件依赖子组件枚举 低,父组件只调用方法
扩展性 差,新增类型需改两处 好,只改子组件
职责划分 模糊,逻辑分散 清晰,子组件自治
代码量 需要枚举文件 无额外文件
可测试性 差,依赖外部枚举 好,逻辑内聚

适用场景

回调函数模式适用于以下场景:

  1. 异步确认流程:如本文的协议确认、二次确认弹窗等

  2. 多入口单出口:多个触发点,但后续处理由同一个组件负责

  3. 子组件业务自治:子组件的业务逻辑不应该泄露给父组件

注意事项

  1. 回调清理:执行完回调后记得置空,避免重复执行

  2. 错误处理:回调执行可能失败,需要考虑异常情况

  3. 状态同步:确保回调执行时,相关状态(如 isAgree)已更新

总结

组件解耦的核心原则是让每个组件只关心自己的职责。当发现父组件需要了解子组件的内部实现细节时,就是重构的信号。

回调函数模式是一种简单有效的解耦手段,它将"做什么"的决策权留给子组件,父组件只负责"何时做"的协调。这种控制反转的思想,在很多设计模式中都有体现,值得在日常开发中灵活运用。

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

大家好,今天用一个最经典的 Todos 应用,来带大家彻底搞清楚:

「为什么我们不再手动操作 DOM?Vue 到底替我们做了什么?」

很多初学者看完 Vue 文档后,会觉得「好像很简单啊」,但真正自己写的时候,又会不自觉地回到原来的命令式写法:

document.getElementById('app').innerHTML = xxx

这篇文章将通过一个逐步演进的过程,让你从「机械式 DOM 操作」进化到「数据驱动」的现代 Vue3 开发思维,彻底领悟响应式编程的魅力。

一、原生 JS 写 Todos:痛并痛苦着

先来看看传统写法(很多人还在这么写):

<h2 id="app"></h2>
<input type="text" id="todo-input">

<script>
  const app = document.getElementById('app');
  const todoInput = document.getElementById('todo-input');
  
  todoInput.addEventListener('change', function(event) {
    const todo = event.target.value.trim();
    if (!todo) return;
    app.innerHTML = todo; // 只能显示最后一个!
  })
</script>

这代码能跑,但问题一大堆:

  • 只能显示一条任务(innerHTML 被覆盖)
  • 要实现多条任务、删除、完成状态……需要写几百行 DOM 操作
  • 一旦需求变动,改起来就是灾难

这就是典型的命令式编程:我们的大脑一直在想「我要先找到哪个元素,然后怎么改它」。

而 Vue 的核心思想是:别管 DOM,你只管数据就行。

二、Vue3 + Composition API 完整实现

03998dfb2be956b19c909a672ec27e78.jpg

<!-- App.vue -->
<script setup>
import { ref, computed } from 'vue'

// 1. 响应式数据(重点!)
const title = ref('') // 输入框内容
const todos = ref([
  { id: 1, title: '吃饭', done: false },
  { id: 2, title: '睡觉', done: true }
])

// 2. 计算属性:统计未完成任务数量(带缓存!)
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 3. 添加任务
const addTodo = () => {
  if (!title.value.trim()) return
  
  todos.value.push({
    id: Date.now(), // 推荐用时间戳,比 Math.random() 更可靠
    title: title.value.trim(),
    done: false
  })
  title.value = '' // 清空输入框
}

// 4. 高级技巧:全选/全不选(computed 的 getter + setter)
const allDone = computed({
  get() {
    if (todos.value.length === 0) return false
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value
    })
  }
})
</script>

<template>
  <div class="todos">
    <h2>我的任务清单</h2>
    
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="今天要做什么?按回车添加"
      class="input"
    />

    <!-- 任务列表 -->
    <ul v-if="todos.length" class="todo-list">
      <li v-for="todo in todos" :key="todo.id" class="todo-item">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    
    <div v-else class="empty">
      🎉 暂无任务,休息一下吧~
    </div>

    <!-- 统计 + 全选 -->
    <div class="footer">
      <label>
        <input type="checkbox" v-model="allDone">
        全选
      </label>
      <span>未完成:{{ active }} / 总数:{{ todos.length }}</span>
    </div>
  </div>
</template>

<style scoped>
.done{
  color: gray;
  text-decoration: line-through;
}
</style>

三、核心知识点深度拆解(建议反复看)

1. ref() 是如何做到响应式的?

const title = ref('')

这句话背后发生了什么?

  • Vue 在内部为 title 创建了一个响应式对象
  • 真正的数据存在 title.value 中
  • 当你读取 title.value 时,Vue 会记录「当前组件依赖了这个数据」
  • 当你修改 title.value 时,Vue 知道「哪些组件需要重新渲染」,自动更新 DOM

这就叫「依赖收集 + 自动更新」,你完全不用管 DOM!

2. 为什么 computed 比普通函数香?

// 普通函数写法(每次都会计算!)
const activeCount = () => todos.value.filter(...).length

// computed 写法(只有依赖变化才重新计算)
const active = computed(() => todos.value.filter(...).length)

性能差异巨大!当你有 1000 条任务时,普通函数会在每次渲染都执行 1000 次过滤,而 computed 可能只执行一次。

3. computed 的 getter + setter 神技(90%的人不知道)

const allDone = computed({
  get() {
    // 如果todos为空,返回false
    if (todos.value.length === 0) return false;
    // 如果所有todo都完成,返回true
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    // 设置所有todo的done状态
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
})

这才是真正的「双向计算属性」!点击全选框时,v-model 会自动调用 setter,把所有任务的 done 状态同步修改。

4. v-for 一定要写 :key!不然会出大问题

<li v-for="todo in todos" :key="todo.id">

不写 key 的后果:

  • Vue 无法准确判断哪条数据变了,会导致整张列表重绘
  • 输入框焦点丢失、动画错乱、状态错位

推荐 key 使用:

id: Date.now() + Math.random() // 更稳妥
// 或使用 uuid 库

5. v-model 本质是 :value + @input 的语法糖

Vue 的双向绑定(v-model) = 数据 → 视图 的绑定 + 视图 → 数据的绑定

它让「数据」和「表单元素的值」始终保持同步,你改数据,界面自动更新;你改输入框,数据也自动更新。

<input v-model="title">
<!-- 等价于 -->
<input :value="title" @input="title = $event.target.value">

拆解一下:

方向 对应指令 作用
数据 → 视图 :value="msg" 把 msg 的值渲染到 input 上
视图 → 数据 @input="msg = $event.target.value" 用户输入时,把值重新赋值给 msg

而 @keydown.enter 是 Vue 提供的键位修饰符,超级好用:

@keydown.enter="addTodo"
@keydown.ctrl.enter="addTodo"
@click.prevent="submit" <!-- 阻止默认行为 -->

四、常见坑位避雷指南(血泪经验)

场景 错误写法 正确写法 说明
添加任务后输入框不清空 没重置 title.value title.value = '' v-model 是双向绑定,必须手动清空
全选状态不同步 用普通变量控制 用 computed({get,set}) 普通变量无法响应所有任务的变化
key 使用 index :key="index" :key="todo.id" index 会导致状态错乱
id 使用 Math.random() id: Math.random() id: Date.now() 可能重复,尤其快速添加时
computed 忘记 .value return todos.filter(...) return todos.value.filter(...) script setup 中 ref 要加 .value

五、细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

computed 是如何做到「又快又省」的?

一句话结论:
computed 只有在它的「依赖」真正发生变化时,才会重新计算一次,其他所有时间直接返回缓存结果。

这才是它比普通方法快 10~100 倍的根本原因!

一、最直观的对比实验
<script setup>
import { ref, computed } from 'vue'

const a = ref(1)
const b = ref(10)

// 场景1:普通方法(每次渲染都重新算)
const sum1 = () => {
  console.log('普通方法被调用了') 
  return a.value + b.value
}

// 场景2:computed(只有依赖变了才算)
const sum2 = computed(() => {
  console.log('computed 被调用了')
  return a.value + b.value
})
</script>

<template>
  <p>普通方法:{{ sum1() }}</p>
  <p>computed:{{ sum2 }}</p>
  <button @click="a++">a + 1</button>
  <button @click="b++">b + 1</button>
</template>

你会看到:

操作 普通方法打印几次 computed 打印几次
页面首次渲染 1 次 1 次
点击 a++ 再次打印 再次打印
点击 b++ 再次打印 再次打印
页面任意地方触发渲染(比如父组件更新) 又打印! 不打印!(直接用缓存)

这就是「缓存」带来的性能飞跃!

Vue 内部到底是怎么实现这个缓存的?(底层逻辑)

Vue 用了一个经典的「脏检查 + 依赖收集」机制(Vue3 用 Proxy 更优雅,但原理一致):

步骤 发生了什么
1. 创建 computed Vue 创建一个「计算属性对象」,里面有个 value(缓存值)和 dirty(是否脏)标志」
2. 第一次读取 computed 执行计算函数 → 同时收集所有用到的响应式数据(a、b、todos.length 等)作为依赖
3. 把依赖和这个 computed 关联起来 a.effect.deps.push(computed)
4. 依赖变化时 Vue 把这个 computed 的 dirty 标志设为 true(表示缓存失效了)
5. 下一次读取时 发现 dirty = true → 重新执行计算函数 → 更新缓存 → dirty = false
6. 之后再读取 dirty = false → 直接返回缓存值,不执行函数

图解:

首次读取 computed
     ↓
执行计算函数 → 依赖收集(记录依赖了 a 和 b)
     ↓
把结果缓存起来,dirty = false

a.value = 999(依赖变化)
     ↓
Vue 自动把所有依赖了 a 的 computed 的 dirty 设为 true

下次读取 computed
     ↓
发现 dirty = true → 重新计算 → 更新缓存 → dirty = false
哪些情况会打破缓存?(常见坑)
情况 是否重新计算 说明
依赖的 ref/reactive 变了 正常触发
依赖的普通变量(let num = 1) 不是响应式的!永远只算一次(大坑!)
依赖了 props props 也是响应式的
依赖了 store.state(Pinia/Vuex) store 是响应式的
依赖了 route.params $route 是响应式的(Vue Router 注入)
依赖了 window.innerWidth 不是响应式!要配合 watchEffectScope 手动处理
实战避雷清单
错误写法 正确写法 后果
computed(() => Date.now()) 改成普通方法或用 ref(new Date()) + watch 每一次读取都重新计算,缓存失效
computed(() => Math.random()) 同上 永远不缓存,性能灾难
computed(() => props.list.length) 完全正确 推荐写法
computed(() => JSON.parse(JSON.stringify(todos.value))) 不要这么做,深拷贝太重 浪费性能
六、一句话记住

computed 的高性能秘诀只有 8 个字:
「依赖不变,绝不重新计算」

现在你再也不用担心「用 computed 会不会影响性能」了,反而应该大胆用!
因为它比你手写任何缓存逻辑都要聪明、都要快!

六、总结:从「操作 DOM」到「操作数据」的思维跃迁

传统 JS 思维 Vue 响应式思维
先找元素 → 再改 innerHTML 只改数据 → Vue 自动更新 DOM
手动 addEventListener 用 v-model / @event 声明式绑定
手动计算未完成数量 用 computed 自动计算 + 缓存
全选要遍历 DOM 用 computed setter 一行搞定

当你真正理解了「数据驱动视图」后,你会发现:

写 Vue 代码不再是「怎么操作页面」,而是「数据怎么变化。

这才是现代前端开发的正确姿势!

vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库

打包配置

vite 专门提供了库模式的打包方式,配置其实非常简单,首先全局安装 vite 以及@vitejs/plugin-vue

   pnpm add vite @vitejs/plugin-vue -D -w

在components下新建vite.config.ts。我们需要让打包后的结构和我们开发的结构一致,如下配置我们将打包后的文件放入dlx-ui 目录下,因为后续发布组件库的名字就是 dlx-ui,当然这个命名大家可以随意.具体代码在下方

然后在 components/package.json 添加打包命令scripts

 "scripts": {
    "build": "vite build"
  },

声明文件

到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话我们就失去了用 ts 开发组件库的意义了。所以我们需要在打包的库里加入声明文件(.d.ts)。

全局安装vite-plugin-dts

pnpm add vite-plugin-dts -D -w

在vite.config.ts中引入,完整的配置文件如下:

// components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
  plugins: [
    vue(),
    dts({
      entryRoot: './src',
      outDir: ['../dlx-ui/es/src', '../dlx-ui/lib/src'],
      //指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
      tsconfigPath: '../../tsconfig.json',
    }),
  ],
  build: {
    //打包文件目录
    outDir: 'es',
    emptyOutDir: true,
    //压缩
    //minify: false,
    rollupOptions: {
      //忽略打包vue文件
      external: ['vue'],
      input: ['index.ts'],
      output: [
        {
          //打包格式
          format: 'es',
          //打包后文件名
          entryFileNames: '[name].mjs',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/es',
        },
        {
          //打包格式
          format: 'cjs',
          //打包后文件名
          entryFileNames: '[name].js',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/lib',
        },
      ],
    },
    lib: {
      entry: './index.ts',
    },
  },
})

执行pnpm run build打包,出现了我们需要的声明的文件

image.png

可以看到打包时打包了2种模式,一种是es模式,一种是cjs模式,当用户引入组件库时使用哪种呢?我们可以修改/components/package.json的代码:

  • main: 指向 lib/index.js,这是 CommonJS 模块的入口文件。Node.js 环境和不支持 ES 模块的工具会使用这个文件。
  • module: 指向 es/index.mjs,这是 ES 模块的入口文件。现代前端工具(如 Vite)会优先使用这个文件。
  "main": "lib/index.js", // CommonJS 入口文件
  "module": "es/index.mjs", // ES 模块入口文件

但是此时的所有样式文件还是会统一打包到 style.css 中,还是不能进行样式的按需加载,所以接下来我们将让 vite 不打包样式文件,样式文件后续单独进行打包。后面我们要做的则是让样式文件也支持按需引入,敬请期待。

vite+ts+monorepo从0搭建vue3组件库(四):button组件开发

组件属性

button组件接收以下属性

  • type 类型
  • size 尺寸
  • plain 朴素按钮
  • round 圆角按钮
  • circle 圆形按钮
  • loading 加载
  • disabled禁用
  • text 文字

button组件全部代码如下:

// button.vue
<template>
  <button
    class="dlx-button"
    :class="[
      buttonSize ? `dlx-button--${buttonSize}` : '',
      buttonType ? `dlx-button--${buttonType}` : '',
      {
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle,
        'is-disabled': disabled,
        'is-loading': loading,
        'is-text': text,
        'is-link': link,
      },
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="dlx-button__loading">
      <span class="dlx-button__loading-spinner"></span>
    </span>
    <span class="dlx-button__content">
      <slot></slot>
    </span>
  </button>
</template>

<script lang="ts" setup>
import { computed } from 'vue'

defineOptions({
  name: 'DlxButton',
})

const props = defineProps({
  // 按钮类型
  type: {
    type: String,
    values: ['primary', 'success', 'warning', 'danger', 'info'],
    default: '',
  },
  // 按钮尺寸
  size: {
    type: String,
    values: ['large', 'small'],
    default: '',
  },
  // 是否为朴素按钮
  plain: {
    type: Boolean,
    default: false,
  },
  // 是否为圆角按钮
  round: {
    type: Boolean,
    default: false,
  },
  // 是否为圆形按钮
  circle: {
    type: Boolean,
    default: false,
  },
  // 是否为加载中状态
  loading: {
    type: Boolean,
    default: false,
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false,
  },
  // 是否为文字按钮
  text: {
    type: Boolean,
    default: false,
  },
  // 是否为链接按钮
  link: {
    type: Boolean,
    default: false,
  },
})

const buttonSize = computed(() => props.size)
const buttonType = computed(() => props.type)

const handleClick = (evt: MouseEvent) => {
  if (props.disabled || props.loading) return
  emit('click', evt)
}

const emit = defineEmits(['click'])
</script>

<style lang="less" scoped>
.dlx-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  line-height: 1;
  height: 32px;
  white-space: nowrap;
  cursor: pointer;
  color: #606266;
  text-align: center;
  box-sizing: border-box;
  outline: none;
  transition: 0.1s;
  font-weight: 500;
  padding: 8px 15px;
  font-size: 14px;
  border-radius: 4px;
  background-color: #fff;
  border: 1px solid #dcdfe6;

  &:hover,
  &:focus {
    color: #409eff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }

  &:active {
    color: #3a8ee6;
    border-color: #3a8ee6;
    outline: none;
  }

  // 主要按钮
  &--primary {
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;

    &:hover,
    &:focus {
      background: #66b1ff;
      border-color: #66b1ff;
      color: #fff;
    }

    &:active {
      background: #3a8ee6;
      border-color: #3a8ee6;
      color: #fff;
    }
  }

  // 成功按钮
  &--success {
    color: #fff;
    background-color: #67c23a;
    border-color: #67c23a;

    &:hover,
    &:focus {
      background: #85ce61;
      border-color: #85ce61;
      color: #fff;
    }

    &:active {
      background: #5daf34;
      border-color: #5daf34;
      color: #fff;
    }
  }

  // 警告按钮
  &--warning {
    color: #fff;
    background-color: #e6a23c;
    border-color: #e6a23c;

    &:hover,
    &:focus {
      background: #ebb563;
      border-color: #ebb563;
      color: #fff;
    }

    &:active {
      background: #cf9236;
      border-color: #cf9236;
      color: #fff;
    }
  }

  // 危险按钮
  &--danger {
    color: #fff;
    background-color: #f56c6c;
    border-color: #f56c6c;

    &:hover,
    &:focus {
      background: #f78989;
      border-color: #f78989;
      color: #fff;
    }

    &:active {
      background: #dd6161;
      border-color: #dd6161;
      color: #fff;
    }
  }

  // 信息按钮
  &--info {
    color: #fff;
    background-color: #909399;
    border-color: #909399;

    &:hover,
    &:focus {
      background: #a6a9ad;
      border-color: #a6a9ad;
      color: #fff;
    }

    &:active {
      background: #82848a;
      border-color: #82848a;
      color: #fff;
    }
  }

  // 大尺寸
  &--large {
    height: 40px;
    padding: 12px 19px;
    font-size: 14px;
    border-radius: 4px;
  }

  // 小尺寸
  &--small {
    height: 24px;
    padding: 5px 11px;
    font-size: 12px;
    border-radius: 3px;
  }

  // 朴素按钮
  &.is-plain {
    background: #fff;

    // 不同类型按钮的默认状态
    &.dlx-button--primary {
      color: #409eff;
      border-color: #409eff;
    }

    &.dlx-button--success {
      color: #67c23a;
      border-color: #67c23a;
    }

    &.dlx-button--warning {
      color: #e6a23c;
      border-color: #e6a23c;
    }

    &.dlx-button--danger {
      color: #f56c6c;
      border-color: #f56c6c;
    }

    &.dlx-button--info {
      color: #909399;
      border-color: #909399;
    }

    &:hover,
    &:focus {
      background: #ecf5ff;
      border-color: #409eff;
      color: #409eff;
    }

    &:active {
      background: #ecf5ff;
      border-color: #3a8ee6;
      color: #3a8ee6;
    }

    // 为不同类型的朴素按钮添加对应的悬浮状态
    &.dlx-button--primary {
      &:hover,
      &:focus {
        background: #ecf5ff;
        border-color: #409eff;
        color: #409eff;
      }
      &:active {
        border-color: #3a8ee6;
        color: #3a8ee6;
      }
    }

    &.dlx-button--success {
      &:hover,
      &:focus {
        background: #f0f9eb;
        border-color: #67c23a;
        color: #67c23a;
      }
      &:active {
        border-color: #5daf34;
        color: #5daf34;
      }
    }

    &.dlx-button--warning {
      &:hover,
      &:focus {
        background: #fdf6ec;
        border-color: #e6a23c;
        color: #e6a23c;
      }
      &:active {
        border-color: #cf9236;
        color: #cf9236;
      }
    }

    &.dlx-button--danger {
      &:hover,
      &:focus {
        background: #fef0f0;
        border-color: #f56c6c;
        color: #f56c6c;
      }
      &:active {
        border-color: #dd6161;
        color: #dd6161;
      }
    }

    &.dlx-button--info {
      &:hover,
      &:focus {
        background: #f4f4f5;
        border-color: #909399;
        color: #909399;
      }
      &:active {
        border-color: #82848a;
        color: #82848a;
      }
    }
  }

  // 圆角按钮
  &.is-round {
    border-radius: 20px;
  }

  // 圆形按钮
  &.is-circle {
    border-radius: 50%;
    padding: 8px;
  }

  // 文字按钮
  &.is-text {
    border-color: transparent;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:not(.is-disabled) {
      // 默认文字按钮
      color: #409eff;

      &:hover,
      &:focus {
        color: #66b1ff;
        background-color: transparent;
        border-color: transparent;
      }

      &:active {
        color: #3a8ee6;
      }

      // 不同类型的文字按钮颜色
      &.dlx-button--primary {
        color: #409eff;
        &:hover,
        &:focus {
          color: #66b1ff;
        }
        &:active {
          color: #3a8ee6;
        }
      }

      &.dlx-button--success {
        color: #67c23a;
        &:hover,
        &:focus {
          color: #85ce61;
        }
        &:active {
          color: #5daf34;
        }
      }

      &.dlx-button--warning {
        color: #e6a23c;
        &:hover,
        &:focus {
          color: #ebb563;
        }
        &:active {
          color: #cf9236;
        }
      }

      &.dlx-button--danger {
        color: #f56c6c;
        &:hover,
        &:focus {
          color: #f78989;
        }
        &:active {
          color: #dd6161;
        }
      }

      &.dlx-button--info {
        color: #909399;
        &:hover,
        &:focus {
          color: #a6a9ad;
        }
        &:active {
          color: #82848a;
        }
      }
    }

    // 文字按钮的禁用状态
    &.is-disabled {
      color: #c0c4cc;
    }
  }

  // 链接按钮
  &.is-link {
    border-color: transparent;
    color: #409eff;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:hover,
    &:focus {
      color: #66b1ff;
    }

    &:active {
      color: #3a8ee6;
    }
  }

  // 禁用状态
  &.is-disabled {
    &,
    &:hover,
    &:focus,
    &:active {
      cursor: not-allowed;

      // 普通按钮的禁用样式
      &:not(.is-text):not(.is-link) {
        background-color: #fff;
        border-color: #dcdfe6;
        color: #c0c4cc;

        // 有颜色的按钮的禁用样式
        &.dlx-button--primary {
          background-color: #a0cfff;
          border-color: #a0cfff;
          color: #fff;
        }

        &.dlx-button--success {
          background-color: #b3e19d;
          border-color: #b3e19d;
          color: #fff;
        }

        &.dlx-button--warning {
          background-color: #f3d19e;
          border-color: #f3d19e;
          color: #fff;
        }

        &.dlx-button--danger {
          background-color: #fab6b6;
          border-color: #fab6b6;
          color: #fff;
        }

        &.dlx-button--info {
          background-color: #c8c9cc;
          border-color: #c8c9cc;
          color: #fff;
        }
      }
    }
  }

  // 有颜色的按钮禁用状态 - 直接选择器
  &.is-disabled.dlx-button--primary {
    background-color: #a0cfff;
    border-color: #a0cfff;
    color: #fff;
  }

  &.is-disabled.dlx-button--success {
    background-color: #b3e19d;
    border-color: #b3e19d;
    color: #fff;
  }

  &.is-disabled.dlx-button--warning {
    background-color: #f3d19e;
    border-color: #f3d19e;
    color: #fff;
  }

  &.is-disabled.dlx-button--danger {
    background-color: #fab6b6;
    border-color: #fab6b6;
    color: #fff;
  }

  &.is-disabled.dlx-button--info {
    background-color: #c8c9cc;
    border-color: #c8c9cc;
    color: #fff;
  }

  // 文字按钮禁用状态
  &.is-disabled.is-text {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 链接按钮禁用状态
  &.is-disabled.is-link {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 加载状态
  &.is-loading {
    position: relative;
    pointer-events: none;

    &:before {
      pointer-events: none;
      content: '';
      position: absolute;
      left: -1px;
      top: -1px;
      right: -1px;
      bottom: -1px;
      border-radius: inherit;
      background-color: rgba(255, 255, 255, 0.35);
    }
  }

  .dlx-button__loading {
    display: inline-flex;
    align-items: center;
    margin-right: 4px;
  }

  .dlx-button__loading-spinner {
    display: inline-block;
    width: 14px;
    height: 14px;
    border: 2px solid #fff;
    border-radius: 50%;
    border-top-color: transparent;
    animation: button-loading 1s infinite linear;
  }
}

@keyframes button-loading {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

引用

在play的src下新建example,存放各个组件的代码,先在play下安装vue-router

pnpm i vue-router

目录结构如下

image.png

app.vue如下:

<template>
  <div class="app-container">
    <div class="sidebar">
      <h2 class="sidebar-title">组件列表</h2>
      <ul class="menu-list">
        <li
          v-for="item in menuItems"
          :key="item.path"
          :class="{ active: currentPath === item.path }"
          @click="handleMenuClick(item.path)"
        >
          {{ item.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <router-view></router-view>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const currentPath = ref('/button')

const menuItems = [
  { name: 'Button 按钮', path: '/button' },
  // 后续添加其他组件...
]

const handleMenuClick = (path: string) => {
  currentPath.value = path
  router.push(path)
}
</script>

<style scoped>
.app-container {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 240px;
  background-color: #f5f7fa;
  border-right: 1px solid #e4e7ed;
  padding: 20px 0;
}

.sidebar-title {
  padding: 0 20px;
  margin: 0 0 20px;
  font-size: 18px;
  color: #303133;
}

.menu-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu-list li {
  padding: 12px 20px;
  cursor: pointer;
  color: #303133;
  font-size: 14px;
  transition: all 0.3s;
}

.menu-list li:hover {
  color: #409eff;
  background-color: #ecf5ff;
}

.menu-list li.active {
  color: #409eff;
  background-color: #ecf5ff;
}

.content {
  flex: 1;
  padding: 20px;
}
</style>

router/index.ts如下:

import { createRouter, createWebHistory } from 'vue-router'
import ButtonExample from '../example/button.vue'

const routes = [
  {
    path: '/',
    redirect: '/button',
  },
  {
    path: '/button',
    component: ButtonExample,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

play下执行pnpm run dev

运行效果:

image.png

❌