阅读视图

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

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

vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件

1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components@dlx-ui/utils,目录结构如下:

组件目录

image.png

组件编写

button.vue

<!-- button组件 -->

<template>
  <button class="button" :class="typeClass" @click="handleClick">
    测试按钮
    <slot></slot>
  </button>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  type: {
    type: String,
    default: 'default',
  },
})

const typeClass = ref('')

const handleClick = () => {
  console.log('click')
}
</script>

<style lang="less" scoped>
.button {
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  background: #fff;
  border: 1px solid #dcdfe6;
}
</style>

然后在button/index.ts将其导出

import Button from './button'

export { Button }

export default Button

因为我们后面会有很多组件的,比如 Icon,Upload,Select 等,所以我们需要在components/src/index.ts集中导出所有组件

// components/src/index.ts
export * from './button'

最后在components下的index.ts中,导出所有组件,供其他页面使用

export * from './src/index'

局部引用组件

在play项目中,安装@dlx-ui/components,并且在app.vue中使用

在play目录下执行pnpm add @dlx-ui/components

然后在app.vue中引入button

<template>
  <Button>按钮</Button>
</template>

<script setup lang="ts">
import { Button } from '@dlx-ui/components'
</script>

<style scoped>

</style>

image.png

全局挂载组件

有的时候我们使用组件的时候想要直直接使用 app.use()挂载整个组件库,其实使用 app.use()的时候它会调用传入参数的 install 方法,因此首先我们给每个组件添加一个 install 方法,然后再导出整个组件库,我们将 button/index.ts 改为

import _Button from './button.vue'

import type { App, Plugin } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
  (comp as SFCWithInstall<T>).install = (app: App) => {
    const name = (comp as any).name;
    //注册组件
    app.component(name, comp as SFCWithInstall<T>);
  };
  return comp as SFCWithInstall<T>;
};
export const Button = withInstall(_Button);
export default Button;


components/index.ts修改为

import * as components from "./src/index";
export * from "./src/index";
import { App } from "vue";

export default {
  install: (app: App) => {
    for (let c in components) {
      app.use(components[c]);
    }
  },
};

组件命名

此时我们需要给button.vue一个name:dlx-button好在全局挂载的时候作为组件名使用 在setup语法糖中使用defineOptions

defineOptions({
  name: 'dlx-button',
})

main.ts全局挂载组件库

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import dlxui from '@dlx-ui/components'
const app = createApp(App)
app.use(dlxui)

createApp(App).mount('#app')

在app.vue中引入

<template>
  <dlx-button>全局挂载的按钮</dlx-button>
</template>

<script setup lang="ts"></script>

image.png

vite+ts+monorepo从0搭建vue3组件库(二):项目搭建

安装依赖

在根目录下安装vue和ts 和 less

pnpm的-w 表示在根目录下安装

  pnpm add vue@next typescript less -D -w

初始化ts

跟目录执行 npx tsc --init,生成tsconfig.json,对其做一个更改如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "jsx": "preserve",
    "strict": true,
    "target": "ES2015",
    "module": "ESNext",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "lib": ["esnext", "dom"],
    "types": ["vite/client"]
  }
}

搭建一个基于 vite 的 vue3 项目

创建一个vue3项目,在跟目录下执行以下命令:就创建了play文件夹,一个基于vue+ts+vite的vue3项目

pnpm create vite play --template vue-ts

因为 play 项目需要测试本地的组件库,所以也需要将 play 和我们的组件库关联在一起。修改一下pnpm-workspace.yaml文件

packages:
  - "packages/**"
  - "play"

此时 play 项目便可以安装本地 packages 下的包了

在play下执行pnpm run dev 就能运行play项目了,运行结果:

image.png

我们在根目录运行 play项目里面的dev 脚本

可以使用pnpm -F play dev 指定运行子目录里面的script中的脚本

这个是 pnpm 的能力。

Vue中级冒险:3-4周成为组件沟通大师 🚀

欢迎使用我的小程序👇👇👇👇

small.png


欢迎回到你的Vue学习之旅!如果你已经跨过了基础门槛,那么接下来的3-4周将带你进入一个全新的世界——在这里,组件不再孤立,数据流动如交响乐般和谐,代码组织变得优雅而强大。

📅 第一周:与“时间魔法师”——生命周期成为好友

想象一下,每个Vue组件都像一个小生命,有自己的出生、成长和告别时刻。生命周期钩子就是这些关键时刻的提醒铃铛🔔。

// 以前你可能只认识created和mounted
// 现在来认识整个生命周期家族吧!
export default {
  beforeCreate() { console.log('我即将诞生!') },
  created() { console.log('我出生了!可以访问数据啦') },
  beforeMount() { console.log('准备挂载到DOM树上...') },
  mounted() { console.log('成功安家!现在可以操作DOM了') },
  beforeUpdate() { console.log('数据变了,我要准备更新啦') },
  updated() { console.log('更新完成!界面焕然一新') },
  beforeUnmount() { console.log('我即将离开这个世界...') },
  unmounted() { console.log('再见!清理工作完成') }
}

本周小挑战:写一个组件,在它生命的每个阶段都在控制台留下足迹👣,观察数据变化和DOM更新时的触发顺序。

📅 第二周:掌握组件间的“悄悄话”艺术

组件不会读心术,但它们有6种方式可以交流!让我们把它们想象成住在不同房间的室友:

1. Props:妈妈喊你吃饭式(父→子)

// 爸爸组件大喊:
<ChildComponent :dinner="'红烧肉'" />

// 孩子组件乖乖接收:
props: ['dinner']

2. $emit:孩子有事报告式(子→父)

// 孩子在房间里喊:
this.$emit('hungry', '想吃零食')

// 爸爸在外面监听:
<ChildComponent @hungry="handleHungry" />

3. Refs:直接敲门式

// 获取组件实例,直接调用方法
<ChildComponent ref="child" />
this.$refs.child.doSomething()

4. Event Bus:小区广播式(任意组件间)

// 创建一个中央事件总线
// 组件A:广播消息
eventBus.emit('news', '今天小区停水')

// 组件B:收听广播
eventBus.on('news', (msg) => {
  console.log(msg) // 今天小区停水
})

5. Provide/Inject:家族秘密传承式(跨层级)

// 爷爷辈组件:
provide() {
  return { familySecret: '传家宝的位置' }
}

// 孙子辈组件(跳过爸爸直接获取):
inject: ['familySecret']

6. Vuex/Pinia:社区公告栏式(全局状态)

// 任何组件都可以:
store.commit('setMessage', '社区通知:明天停电')

// 任何组件也都能看到:
store.state.message

本周实践:创建一个“家庭聊天室”应用,使用至少4种通信方式让祖孙三代的组件互相传递消息!

📅 第三、四周:解锁组合式API的“积木魔法”

还记得小时候搭积木的乐趣吗?组合式API让你重新体验这种快乐!

选项式API vs 组合式API

// 以前(选项式) - 像整理抽屉
export default {
  data() { return { count: 0 } },
  methods: { increment() { this.count++ } },
  computed: { doubleCount() { return this.count * 2 } }
}

// 现在(组合式) - 像搭积木
import { ref, computed } from 'vue'

export default {
  setup() {
    // 逻辑1:计数器
    const count = ref(0)
    const increment = () => { count.value++ }
    const doubleCount = computed(() => count.value * 2)
    
    // 逻辑2:用户信息
    const user = ref(null)
    const fetchUser = async () => { /* ... */ }
    
    // 像搭积木一样组合功能
    return { count, increment, doubleCount, user, fetchUser }
  }
}

超能力:自定义组合函数

// 创建一个可复用的“鼠标跟踪器”积木
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  const update = (event) => {
    x.value = event.pageX
    y.value = event.pageY
  }
  
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
  
  return { x, y }
}

// 在任何组件中轻松使用:
const { x, y } = useMouse()
// 看!鼠标坐标自动跟踪!

响应式进阶:reactive vs ref

// ref - 给单个值加响应式外衣
const count = ref(0) // 访问时:count.value

// reactive - 给对象加响应式外衣
const state = reactive({ 
  name: 'Vue', 
  version: 3 
}) // 访问时:state.name

// 小贴士:简单值用ref,复杂对象用reactive

终极挑战:用组合式API重构你之前的一个项目,把相关逻辑抽成自定义组合函数,体验“代码乐高”的快乐!

🎉 庆祝时刻:你已经成为Vue中级开发者!

经过这3-4周的冒险,你已经掌握了:

  • 生命周期管理:像时间旅行者一样掌控组件的一生
  • 6种组件通信:让组件间的对话流畅自然
  • 组合式API:用乐高式思维构建可维护的代码

现在你的Vue技能树已经枝繁叶茂🌳!这些技能不仅在面试中闪闪发光,更能让你在实际项目中游刃有余。

下一步冒险预告:高级路由管理、性能优化、服务端渲染... 但先给自己放个小假,用新技能做个有趣的小项目吧!


分享你的学习成果或遇到的问题,在评论区一起交流成长!你的3周挑战故事是什么? 💬

#Vue #前端开发 #编程学习 #JavaScript #组合式API

Vue3 组件入门:像搭乐高一样玩转前端!

欢迎使用我的小程序👇👇👇👇

small.png


你好呀!如果你刚开始学习 Vue3 组件开发,那你来对地方了!想象一下,组件就像是前端世界的乐高积木——小巧、独立、可重复使用,还能组合成酷炫的东西。让我们花 1-2 周时间,轻松掌握组件开发的三大基石!

🎯 第一周:认识你的“乐高积木”

组件基本结构:Vue 的“基因代码”

每个 Vue 组件都像一个独立的小程序,有自己的模板、逻辑和样式:

<template>
  <!-- 这里是组件的“外貌” -->
  <div class="my-component">
    <h1>{{ title }}</h1>
    <button @click="handleClick">点我!</button>
  </div>
</template>

<script setup>
// 这里是组件的“大脑”
import { ref } from 'vue'

const title = ref('你好,我是组件!')

const handleClick = () => {
  console.log('按钮被点击啦!')
}
</script>

<style scoped>
/* 这里是组件的“穿搭” */
.my-component {
  border: 2px solid #42b983;
  padding: 20px;
  border-radius: 10px;
}
</style>

💡 小贴士<script setup> 是 Vue3 的语法糖,让代码更简洁!scoped 样式确保穿搭只影响自己,不会“撞衫”。

🔄 第二周:让积木“活”起来

Props:组件的“个性定制”

就像给乐高人仔换装一样,Props 让组件可以接收外部数据:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ name }}</h2>
    <p>年龄:{{ age }}</p>
    <p v-if="isVip">⭐ VIP会员</p>
  </div>
</template>

<script setup>
// 定义组件可以接收哪些“定制参数”
const props = defineProps({
  name: {
    type: String,
    required: true  // 这个必须传!
  },
  age: {
    type: Number,
    default: 18     // 不传的话默认18岁
  },
  isVip: Boolean    // 简写形式
})
</script>

使用这个组件时:

<template>
  <UserCard name="小明" :age="20" :is-vip="true" />
  <UserCard name="小红" /> <!-- 小红自动18岁,不是VIP -->
</template>

🎭 有趣比喻:Props 就像点奶茶时的选项——甜度、冰度、加料,同一个奶茶组件,能调出千变万化的味道!

Events:组件的“悄悄话机制”

组件不能总是被动接收,有时也需要主动“说话”:

<!-- Counter.vue -->
<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">归零</button>
  </div>
</template>

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

const emit = defineEmits(['count-change', 'reset-done'])

const count = ref(0)

const increment = () => {
  count.value++
  // 对外“喊话”:计数变化啦!
  emit('count-change', count.value)
}

const reset = () => {
  count.value = 0
  // 喊另一句话:重置完成啦!
  emit('reset-done')
}
</script>

父组件接收“悄悄话”:

<template>
  <Counter 
    @count-change="onCountChange"
    @reset-done="showAlert('已归零!')"
  />
</template>

<script setup>
const onCountChange = (newCount) => {
  console.log(`计数器变成${newCount}了!`)
}

const showAlert = (msg) => {
  alert(msg)
}
</script>

🔊 生动解释:Events 就像组件之间的“对讲机”。子组件按下按钮,父组件就能听到:“嘿!我这里发生事情了!”

插槽:组件的“留白艺术”

有时候,我们想在组件里留一块空地,让使用它的人自由发挥:

<!-- FancyBox.vue -->
<template>
  <div class="fancy-box">
    <div class="header">
      <slot name="header">默认标题</slot>
    </div>
    
    <div class="content">
      <!-- 匿名插槽:不写name的那个 -->
      <slot>默认内容</slot>
    </div>
    
    <div class="footer">
      <slot name="footer"></slot>
      <!-- 如果没提供footer,这里什么都不显示 -->
    </div>
  </div>
</template>

尽情发挥创意:

<template>
  <FancyBox>
    <!-- #header 是 v-slot:header 的简写 -->
    <template #header>
      <h1>🎉 我的个性化标题!</h1>
    </template>
    
    <!-- 这里是匿名插槽的内容 -->
    <p>这是放在主区域的内容...</p>
    <img src="/my-image.jpg" alt="我的图片">
    
    <template #footer>
      <button>确定</button>
      <button>取消</button>
    </template>
  </FancyBox>
</template>

🎨 精妙比喻:插槽就像相框——相框组件提供结构和样式(边框、材质),但你可以在里面放任何照片!

🚀 两周学习路线图

第一周:打好地基

  • 第1-2天:创建你的第一个组件,理解“单文件组件”概念
  • 第3-4天:玩转 Props,尝试各种类型验证
  • 第5-7天:组件通信初体验,父子组件互相“对话”

第二周:进阶组合

  • 第8-10天:掌握具名插槽和作用域插槽
  • 第11-12天:构建一个小项目(如用户卡片集)
  • 第13-14天:重构重复代码为可复用组件

💪 动手挑战!

试着创建一个 MessageBubble 组件:

  1. 通过 type prop 控制样式(成功、警告、错误)
  2. 点击气泡时发射 close 事件
  3. 使用插槽让内容可以包含任何 HTML
  4. 添加一个 icon 插槽,允许自定义图标

🌟 总结

Vue3 组件开发其实就像玩乐高:

  • 基本结构 = 积木的基础形状
  • Props = 给积木涂上不同颜色
  • Events = 积木之间的连接卡扣
  • 插槽 = 预留的特殊接口

记住,最好的学习方式就是动手去做!从今天起,试着把页面上的每个部分都想象成可复用的组件。两周后,你会惊讶地发现,自己已经能用“乐高思维”构建整个应用了!

有什么问题或有趣的组件创意吗?欢迎在评论区分享!一起在 Vue3 的世界里搭出炫酷的作品吧!✨


📅 学习进度提醒:标记你的日历,两周后回来看看自己构建了多少个酷炫组件!

❌