阅读视图

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

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。

❌