阅读视图

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

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 的世界里搭出炫酷的作品吧!✨


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

从“拼字符串”到“魔法响应”:一场数据驱动页面的奇幻进化之旅

引言

你有没有想过,为什么今天的网页能像变魔术一样——点一下按钮,列表自动刷新;输个名字,头像立刻出现?而十几年前,想换个内容却要整个页面“唰”地重载?

这一切的背后,是一场关于数据如何驱动页面的技术革命。今天,我们就穿越三段代码时空,跟随一份用户列表的命运,看看 Web 开发是如何从“手搓 HTML”一步步进化到“声明即渲染”的魔法世界的。


第一章:远古时代 —— 服务端“手工编织”页面(server.js

源代码链接:[vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国")

时间回到 Web 初期,那时没有 Vue,没有 React,甚至连 AJAX 都还没普及。开发者们用最原始的方式:在服务器上把数据和 HTML 混在一起,直接“烤”成完整网页,再扔给浏览器

打开 server.js,你会看到这样一段代码:

const users = [
    {id: 1, name: '张三',email:'zhangsan@qq.com'},
    {id: 2, name: '李四',email:'lisi@qq.com'},
    {id: 3, name: '王五',email:'wangwu@qq.com'},
]

这是一份硬编码的用户名单,就像藏在厨房抽屉里的老式通讯录。

接着,一个叫 generateUsersHtml 的函数登场了:

function generateUsersHtml(users){
    const userRows = users.map(user => `
| ${user.id}|${user.name}|${user.email}|
| ---|---|---|
`).join('');
    return `
Users
| ID|Name|Email|
| ---|---|---|
${userRows}
            
    `
}

注意!这里用的不是标准 HTML 表格,而是 Markdown 表格语法(可能是为了简化演示)。但原理惊人地朴素:用 JavaScript 字符串拼接,把数据“缝”进模板里

当用户访问 /users 时:

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html;charset=utf-8'); 
    const html = generateUsersHtml(users);
    res.end(html);
}

服务器把拼好的“成品网页”通过 res.end() 发出去。浏览器收到后,直接显示——没有请求、没有等待、没有交互,一切都在服务端完成

这就像一位老裁缝,根据你的身材(数据)现场量体裁衣(生成 HTML),然后把做好的衣服(完整页面)递给你。但如果你胖了两斤,他得重新做一件——整个页面刷新!

优点:简单、快速、SEO 友好。
缺点:交互差、前后端耦合、改一点就要重刷。


第二章:工业革命 —— 前后端“分家”,API 登场(index.html + db.json + package.json

随着网站越来越复杂,开发者发现:“让前端和后端各干各的,效率更高!”于是,前后端分离成为新潮流。

完整项目结构及链接:[vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

项目准备

前后端分离需要进行项目初始化

第一步:对负责后端的文件夹 backend 进行初始化,在终端打开该文件夹,执行以下命令

# 初始化项目,生成 package.json(只需执行一次)
npm init -y
 
# 安装 json-server,用于基于 JSON 文件快速创建本地 REST API 服务。
npm i json-server

第二步:在前端文件夹 frontend 中添加文件 index.html ,在后端文件夹 backend 中添加 db.json 文件

第三步:修改 package.json 文件内容

将文件内的JavaScript语句修改为如下内容

  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },

这段代码定义了一个名为 dev 的 npm 脚本,作用是使用 json-server 工具监听(--watch)项目根目录下的 db.json 文件。当运行 npm run dev 时,会启动一个本地 RESTful API 服务器,自动将 db.json 中的数据暴露为 API 接口,并在文件内容变化时实时更新接口数据,常用于前端开发中的模拟后端服务。

静态骨架:index.html 的“空舞台”

看一眼 index.html

User List
Users
| ID|Name|Email|
| ---|---|---|

这简直是个“幽灵页面”——有标题、有表头,但没有一行真实数据!它就像一个空荡荡的剧院舞台,只搭好了布景,就等演员(数据)登场。

数据仓库:db.json 的“假数据库”

真正的数据藏在这里:

{
    &#34;users&#34;: [
        {
            &#34;id&#34;: 1,
            &#34;name&#34;: &#34;张三&#34;,
            &#34;email&#34;: &#34;zhangsan@qq.com&#34;
        },
        {
            &#34;id&#34;: 2,
            &#34;name&#34;: &#34;李四&#34;,
            &#34;email&#34;: &#34;lisi@qq.com&#34;
        }
        ,
        {
            &#34;id&#34;: 3,
            &#34;name&#34;: &#34;王五&#34;,
            &#34;email&#34;: &#34;wangwu@qq.com&#34;    
        }
    ]
}

这是一个纯 JSON 文件,结构清晰,但本身不会动。它需要一个“翻译官”把它变成 API。

自动化 API 工厂:json-server(来自 package.json

package.json

{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },
  &#34;dependencies&#34;: {
    &#34;json-server&#34;: &#34;^1.0.0-beta.3&#34;
  }
}

运行 npm run dev,神奇的事情发生了:json-server 自动监听 db.json,并启动一个本地服务器(默认 http://localhost:3000),将 users 数组暴露为 RESTful 接口:

  • GET /users → 返回全部用户
  • GET /users/1 → 返回 ID 为 1 的用户

现在,前端只需一句 fetch('/users'),就能拿到 JSON 数据!

舞台(index.html)不再自己造演员,而是打电话给经纪公司(API):“请派三位演员上台!” 演员来了,前台 JS 再手动把他们安排到座位上(操作 DOM)。

优点:前后端解耦、接口复用、便于测试。
痛点:前端仍需手动更新 DOM,代码冗长易错——“找到表格 → 清空 → 循环创建行 → 插入单元格……”

于是,人们渴望一种更智能的方式……


第三章:魔法纪元 —— Vue 的“响应式咒语”(App.vue

如果说前后端分离是“分工”,那么 Vue 就是“自动化”。它引入了一个颠覆性理念:你只管描述“页面应该长什么样”,数据变了,页面自动跟着变

项目准备

创建Vue3项目,在终端打开创建项目的文件夹,运行以下命令

npm init vite

回车后输入项目名称 ref-demo,选择 Vue + JavaScript 即可

![](<> "点击并拖拽以移动")

完整项目结构及 App.vue 文件链接:[vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

响应式数据:会“思考”的变量 —— Vue 的魔法心脏

App.vue 中,我们看到这样两行代码:

import { ref } from 'vue';
const users = ref([]);

别小看这短短两行——它们开启了一个自动同步数据与视图的魔法世界

ref([]) 不是普通数组,而是一个“活”的数据容器

乍一看,users 似乎只是个空数组。但其实,ref() 把它包装成了一个响应式引用对象(reactive reference) 。你可以把它想象成一个装着数据的“智能玻璃瓶”:

  • 瓶子里装的是你的真实数据(比如用户列表);
  • 瓶子本身会“监听”:只要有人往里放新东西(修改 .value),它就会立刻广播:“注意!内容变了!”
  • 所有“订阅”了这个瓶子的 UI 元素(比如模板中的 {{user.name}}),都会自动更新自己。

换句话说,users 不再是死气沉沉的变量,而是一个会呼吸、会通知、会联动的活体数据

技术小贴士:在 Vue 3 的 Composition API 中,ref 内部使用了 JavaScript 的 Proxygetter/setter 机制,实现对 .value 访问和赋值的拦截,从而建立“依赖追踪”系统。


生命周期钩子:组件的“出生仪式”

接下来这段代码,是组件的“成人礼”:

onMounted(() => {
  console.log('组件挂载完成,开始从后端获取数据');
  fetch('/users')
    .then(res => res.json())
    .then(data => {
      users.value = data;
      console.log('从后端获取到的数据:', users.value);
    })
})
什么是 onMounted

Vue 组件有自己的“生命周期”:创建 → 挂载 → 更新 → 卸载。
onMounted 就是那个关键的“我已上线”时刻——当组件的 DOM 已经被渲染到页面上,Vue 就会执行这个回调函数。

这就像一个新生儿睁开眼睛的第一秒,立刻说:“世界你好!我要开始干活了!”

为什么在这里发请求?

因为:

  • 页面结构已经存在(模板已解析);
  • 此时发起 fetch('/users'),既能拿到数据,又能确保有地方展示它;
  • 如果在组件还没挂载前就操作 DOM 或赋值,可能会失败或造成内存泄漏。

于是,组件一“睁眼”,就向后端(由 json-server 提供的 /users 接口)发出请求,拿到 JSON 数据后:

users.value = data;

Boom!魔法触发!


重点:这一行代码,如何让页面“活”起来?

当你写下 users.value = data,看似只是赋值,实则引爆了一连串精妙的连锁反应:

  1. Vue 的响应式系统检测到 users 的值发生了变化
    → 因为 users 是用 ref 创建的,任何对 .value 的写入都会被拦截。
  2. 系统立刻找出所有“依赖”这个数据的地方
    → 在编译阶段,Vue 已经悄悄记录下:模板中用了 users 的地方(比如 v-for=&#34;user in users&#34;)都需要被通知。
  3. 重新执行相关的渲染逻辑
    → Vue 并不会重绘整个页面,而是只重新计算“受影响的部分”——也就是用户列表区域。
  4. 生成新的虚拟 DOM(Virtual DOM)
    → Vue 先在内存中构建一个轻量级的 DOM 树副本。
  5. 与旧虚拟 DOM 对比(diff 算法)
    → 找出最小差异:比如新增了三行、删除了零行。
  6. 精准更新真实 DOM
    → 只修改浏览器中真正需要变动的节点,避免不必要的重排重绘。

 整个过程毫秒级完成,用户毫无感知,却看到了最新数据!


声明式模板:所想即所得的 UI 编程 

现在,看看 `` 部分的神奇之处:

<table>
  <tr>
    <th>id</th>
    <th>name</th>
    <th>email</th>
  </tr>
<tbody>
  <tr>
    <td>{{user.id}}</td>
    <td>{{user.name}}</td>
    <td>{{user.email}}</td>
  </tr>
</tbody>
</table>

这里藏着 Vue 两大核心法宝:

{{ }}:插值表达式 —— 数据的“透明窗口”
  • {{user.name}} 不是一段字符串,而是一个动态绑定
  • 它告诉 Vue:“请在这里显示 user.name 的当前值,并且当它变时,自动刷新。”
  • 你不需要手动拼接 HTML,也不用担心 XSS(Vue 默认会转义内容,安全可靠)。
 v-for:列表渲染指令 —— 自动化的“克隆工厂”
  • v-for=&#34;user in users&#34; 是 Vue 的循环指令。
  • 它会遍历 users 数组,为每个 user 自动生成一行 <tr>
  • :key=&#34;user.id&#34; 提供唯一标识,帮助 Vue 高效追踪每个节点的身份(比如移动、删除时保持状态)。

在传统开发中,你要写:

// 找到表格
const tbody = document.querySelector('#user-table tbody');
// 清空
tbody.innerHTML = '';
// 循环创建行
data.forEach(user => {
  const tr = document.createElement('tr');
  tr.innerHTML = `<td>${user.id}</td><td>${user.name}</td>...`;
  tbody.appendChild(tr);
});

而在 Vue 中,你只需说:

“我想显示一个用户列表,每一行包含 id、name 和 email。”

然后 Vue 就默默完成了剩下的所有工作。

这就是声明式编程的魅力:你描述“是什么”,框架负责“怎么做”。

从此,开发者从 DOM 操作的泥潭中解放出来,专注于业务逻辑与用户体验——而这,正是现代前端框架最伟大的馈赠。


终章:三次飞跃,一条主线

时代 核心思想 数据流向 开发体验 用户体验
服务端渲染 “我给你完整的饭” 数据 → 服务端 → 完整 HTML → 浏览器 简单但笨重 刷新卡顿,交互弱
前后端分离 “我给你食材,你自己做” 浏览器 → 请求 API → 获取 JSON → 手动更新 DOM 灵活但繁琐 局部更新,但依赖手动编码
响应式框架 “你告诉我菜谱,我自动做饭” 数据变化 → 自动驱动视图更新 声明式、高效、可维护 流畅、实时、无感

结语:技术的本质是“解放人”

server.js 的字符串拼接,到 App.vue 的响应式绑定,表面看是代码风格的变迁,实则是开发范式的跃迁

从“命令式”(怎么做)走向“声明式”(做什么)

今天的前端开发者,不再需要关心“如何插入一行表格”,而是专注“用户列表应该展示哪些字段”。这种抽象,让我们能更专注于业务逻辑与用户体验。

而这,正是技术进步最美的样子——让复杂消失,让创造浮现

🌟 下次当你看到一个动态刷新的列表时,不妨微笑一下:那背后,是一场跨越二十年的工程智慧结晶。

一键部署!一款开源自托管的照片画廊神器!

大家好,我是 Java陈序员

在这个数字时代,我们的手机和相机里存满了无数珍贵的照片 —— 家人的笑脸、旅行的风景、生活的点滴瞬间。但这些回忆往往被淹没在杂乱的相册里,要么受制于云存储的隐私风险,要么因格式兼容问题难以完整呈现。

这时候,我们可以搭建一个完全属于自己、能按时间和地点梳理回忆的照片画廊。

今天,给大家推荐一款专注于流畅体验的自托管个人画廊神器,支持一键部署!

项目介绍

chronoframe —— 一个丝滑的照片展示和管理应用,支持多种图片格式和大尺寸图片渲染。

功能特色

  • 强大的照片管理:支持通过网页界面轻松管理和浏览照片,并在地图上查看照片拍摄地点
  • 轻量部署体验:基于 Docker 一键部署,无需额外配置数据库(内置 SQLite),几分钟内即可完成私有化部署
  • 多存储后端适配:支持本地文件系统、S3 兼容存储多种存储方式,满足不同场景需求
  • 地图可视化浏览:自动提取照片 GPS 信息,使用 Mapbox 进行地理编码,在地图上展示照片拍摄位置
  • 响应式设计:完美适配桌面端和移动端,支持触摸操作和手势控制,提供原生应用般的体验
  • Live/Motion Photo 支持:完整支持 Apple LivePhoto 格式和 Google 标准的 Motion Photo,自动检测和处理 MOV 视频文件,保留动态照片效果

技术栈:Nuxt4 + TypeScript + TailwindCSS + Drizzle ORM

快速上手

配置信息

创建 .env 文件,下面是使用本地存储的最小示例。

# 管理员邮箱(必须)
CFRAME_ADMIN_EMAIL=
# 管理员用户名(可选,默认 ChronoFrame)
CFRAME_ADMIN_NAME=
# 管理员密码(可选,默认 CF1234@!)
CFRAME_ADMIN_PASSWORD=

# 站点信息(均可选)
NUXT_PUBLIC_APP_TITLE=
NUXT_PUBLIC_APP_SLOGAN=
NUXT_PUBLIC_APP_AUTHOR=
NUXT_PUBLIC_APP_AVATAR_URL=

# 地图提供器 (maplibre/mapbox)
NUXT_PUBLIC_MAP_PROVIDER=maplibre
# 使用 MapLibre 需要 MapTiler 访问令牌
NUXT_PUBLIC_MAP_MAPLIBRE_TOKEN=
# 使用 Mapbox 需要 Mapbox 访问令牌
NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN=

# 存储提供者(local 或 s3 或 openlist)
NUXT_STORAGE_PROVIDER=local
NUXT_PROVIDER_LOCAL_PATH=/app/data/storage

# 会话密码(必须,32 位随机字符串)
NUXT_SESSION_PASSWORD=

# 是否开启允许 IP 直接访问
NUXT_ALLOW_INSECURE_COOKIE=true

如选择使用 S3 存储,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=s3
NUXT_PROVIDER_S3_ENDPOINT=
NUXT_PROVIDER_S3_BUCKET=chronoframe
NUXT_PROVIDER_S3_REGION=auto
NUXT_PROVIDER_S3_ACCESS_KEY_ID=
NUXT_PROVIDER_S3_SECRET_ACCESS_KEY=
NUXT_PROVIDER_S3_PREFIX=photos/
NUXT_PROVIDER_S3_CDN_URL=

若选择使用 OPENLIST,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=openlist
NUXT_PROVIDER_OPENLIST_BASE_URL=https://openlist.example.com
NUXT_PROVIDER_OPENLIST_ROOT_PATH=/115pan/chronoframe
NUXT_PROVIDER_OPENLIST_TOKEN=your-static-token

如果需要集成 Github 登录,需配置 GitHub OAuth 变量:

NUXT_OAUTH_GITHUB_CLIENT_ID=
NUXT_OAUTH_GITHUB_CLIENT_SECRET=

Docker 部署

1、拉取镜像

docker pull ghcr.io/hoshinosuzumi/chronoframe:latest

2、创建挂载目录和配置文件

mkdir -p /data/software/chronoframe/data

cd /data/software/chronoframe

# 配置文件参考前文的配置文件信息
vim .env

3、运行容器

docker run -d \
--name chronoframe \
  -p 3000:3000 \
  -v /data/software/chronoframe/data:/app/data \
  --env-file .env \
  ghcr.io/hoshinosuzumi/chronoframe:latest

Docker Compose 部署

1、创建 docker-compose.yml 文件

services:
  chronoframe:
    image: ghcr.io/hoshinosuzumi/chronoframe:latest
    container_name: chronoframe
    restart: unless-stopped
    ports:
      - '3000:3000'
    volumes:
      - ./data:/app/data
    env_file:
      - .env

2、启动服务

# 启动服务
docker compose up -d

# 查看日志
docker compose logs -f chronoframe

# 停止服务
docker compose down

# 更新到最新版本
docker compose pull
docker compose up -d

反向代理

如需要使用反向代理服务器(如 Nginx 或 Caddy)来处理 HTTPS 和域名解析,可参考如下配置。

server {
    listen 80;
    server_name your-domain.com;
    
    # HTTPS 重定向
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-domain.com;
    
    # SSL 证书配置
    ssl_certificate /path/to/your/certificate.crt;
    ssl_certificate_key /path/to/your/private.key;
    
    # SSL 安全配置
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    
    # 上传大小限制
    client_max_body_size 100M;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        
        # WebSocket 支持
        proxy_set_header Connection "upgrade";
        proxy_set_header Upgrade $http_upgrade;
        
        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
    
    # 静态资源缓存
    location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|ico|woff|woff2|ttf|eot)$ {
        proxy_pass http://localhost:3000;
        expires 1y;
        add_header Cache-Control "public, immutable";
        proxy_set_header Host $host;
    }
}

功能体验

首页

  • 明亮模式

  • 暗黑模式

  • 照片查看

  • 地球仪

  • 相簿

  • 筛选照片

控制台

  • 仪表盘

  • 照片库

  • 上传照片

  • 相簿

  • 队列管理

  • 系统日志

无论是摄影爱好者整理作品,还是个人珍藏生活片段,chronoframe 都能通过简单的部署方式,为你打造一个流畅、安全且充满温度的私人图片画廊。快去部署体验吧~

项目地址:https://github.com/HoshinoSuzumi/chronoframe

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


Vite 到底能干嘛?为什么 Vue3 官方推荐它做工程化工具?

很多朋友在开发项目的时候其实都有用过Vite,也知道它是现代化构建工具,但却不清楚它是怎么用的。

只是知道项目里集成了Vite,开发的时候启动很快,配置文件也很清晰,但很少去了解它是什么,起到了什么作用。

这篇文章我们就来了解一下。

一、Vite 是什么?

它的名字来源于法语单词"vite",意思是"快速",由 Vue.js 作者尤雨溪开发的一款现代化前端构建工具

它的目标很简单:让开发体验更快、更简单

Vite不仅支持Vue,还原生支持React、Svelte、Preact、Lit等主流框架,甚至可以用于纯 HTML + JavaScript 项目。


二、Vite 的核心优势

Vite之所以火,是因为它解决了传统构建工具的几个痛点:

启动极快 开发服务器启动几乎秒开,不打包!

热更新飞快 修改代码后,浏览器只更新改动的部分,毫秒级响应

原生 ES 模块支持 直接利用现代浏览器的 ES Module 能力

零配置上手 创建项目只需一条命令,无需复杂配置

插件生态丰富 兼容 Rollup 插件,生态强大

开箱即用的丰富功能 支持 TypeScript、JSX、CSS 预处理器等,无需复杂配置。


三、与传统工具(如 Webpack)对比

1. 工作方式的根本不同

Webpack:开发时会先打包整个项目(把所有 JS、CSS 合并成 bundle),然后启动服务器。项目越大,打包越慢。

Vite:开发时不打包!直接利用浏览器原生支持的 ES 模块(<script type="module">),按需加载文件。

2. 举个实际例子

假设你有一个简单的项目结构:

src/
├── main.js
└── utils.js

使用 Webpack(传统方式)

// utils.js
export const add = (a, b) => a + b;

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

Webpack 会: 1.把main.jsutils.js打包成一个bundle.js 2.启动本地服务器,返回这个bundle 3.浏览器加载整个bundle

即使你只改了utils.js中的一行,Webpack也要重新分析依赖、重新打包整个应用(虽然有缓存优化,但仍有延迟)。

使用 Vite

Vite 在开发时直接生成这样的 HTML:

<!-- index.html -->
<script type="module" src="/src/main.js"></script>

浏览器会: 1.请求 /src/main.js 2.发现里面 import './utils.js',自动再请求 /src/utils.js 3.按需加载,无需打包!

当你修改utils.js,Vite只告诉浏览器:“喂,utils.js更新了”, 浏览器只重新加载这一个文件,热更新速度接近实时

Vite 在开发阶段用原生 ESM,在生产阶段会用 Rollup 打包,兼顾速度和兼容性。


四、Vite 的工作原理:为什么这么快?

关键就两点:

1. 利用现代浏览器的原生 ES 模块(ESM)

现代浏览器(Chrome、Firefox、Edge 等)早就支持 <script type="module">,可以直接 import/export 模块,无需打包。

Vite 直接把这个能力用起来——开发时不打包,让浏览器自己去加载模块

2. 依赖预构建(Dependency Pre-bundling)

你可能会问:那 node_modules 里的包怎么办?它们很多不是 ESM 格式啊!

Vite会在首次启动时,用 esbuild(超快的 JS 打包器)把 node_modules 里的依赖预构建为 ESM 格式,并缓存起来。

esbuild 是用 Go 写的,比 Webpack 快 10~100 倍!

预构建只做一次,后续开发直接用缓存

这样既保证了兼容性,又不影响开发速度。


五、如何使用 Vite?

1. 创建项目

# 使用 npm
npm create vite@latest my-vue-app -- --template vue

# 使用 yarn
yarn create vite my-react-app --template react

# 使用 pnpm
pnpm create vite my-vanilla-app --template vanilla

2. 项目结构

my-vite-project/
├── index.html
├── package.json
├── vite.config.js
├── public/
│   └── favicon.ico
└── src/
    ├── main.js
    ├── App.vue
    ├── components/
    └── styles/

3. 开发服务器

# 进入项目目录
cd my-vue-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

访问 http://localhost:5173 即可看到你的应用!

在这里插入图片描述

4. 基础配置示例

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

export default defineConfig({
  // 插件配置
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 5173,
    open: true // 自动打开浏览器
  },
  
  // 构建配置
  build: {
    outDir: 'dist',
    sourcemap: true
  },
  
  // 路径别名
  resolve: {
    alias: {
      '@': '/src'
    }
  }
})

5. 完整的Vue组件示例

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的Vite应用</title>
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>欢迎使用 Vite!</h1>
    <p>当前计数: {{ count }}</p>
    <button @click="increment">点击我</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

<style>
.app {
  text-align: center;
  padding: 2rem;
}

button {
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
}
</style>
/* src/style.css */
body {
  margin: 0;
  font-family: Arial, sans-serif;
  background-color: #f5f5f5;
}

* {
  box-sizing: border-box;
}

6. 构建生产版本

# 构建生产版本
npm run build

# 预览生产版本
npm run preview

结语

总的来说,Vite 通过利用现代浏览器的原生能力,在开发阶段省去了打包步骤,大大提升了开发效率。同时,它配置简单、上手容易,还拥有强大的插件生态,非常适合现代前端项目。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

Vue-TodoList 项目详解

Vue 3 Todo List 项目详解

1.图片展示

image.png

2. 核心数据结构

项目使用 ref 定义了核心的响应式数据 todos。数组中的每一项是一个对象,包含三个关键属性:

  • id: 唯一标识符(用于 Diff 算法优化)
  • context: 任务文本内容
  • done: 完成状态 (true/false)
const todos = ref([
  { id: 1, context: '打王者', done: false },
  { id: 2, context: '吃饭', done: true },
  // ...
])

3. 功能实现细节

A. 添加任务 (Add Todo)

逻辑

  1. 双向绑定:使用 v-model 绑定输入框与 newTodoText 变量。
  2. 事件监听:监听键盘的 Enter 键 (@keydown.enter)。
  3. 数据变更:校验非空后,向 todos 数组 push 新对象,并重置输入框。

亮点:使用了 nextId 自增变量,确保每个新任务都有独立的 ID,避免渲染时的 Key 冲突。

B. 列表渲染与性能优化

逻辑:

使用 v-for 指令循环渲染列表。

关键点:

必须绑定 :key="todo.id"。Vue 的虚拟 DOM 机制依赖这个 Key 来进行高效的 Diff 对比。如果数据项顺序改变,Vue 可以直接复用 DOM 元素,而不是销毁重建,从而提升性能。

C. 智能状态计算 (Computed)

项目中大量使用了 computed 计算属性,它的优势在于缓存——只有依赖的数据变化时才会重新计算。

  1. 剩余任务统计 (active):

    实时计算 !todo.done 的数量,用于底部显示 "X items left"。

    const active = computed(() => todos.value.filter(todo => !todo.done).length)
    
  2. 全选/反选 (allDone) - 高级用法:

    这是一个可写计算属性 (Writable Computed),它巧妙地实现了双向逻辑:

    • 读 (Get) :如果所有任务都完成了,全选框自动勾选。
    • 写 (Set) :当你点击全选框时,它触发 set 方法,将所有任务的 done 状态同步为当前全选框的状态。

D. 删除与清理

  • 删除单项:通过 filter 过滤掉指定 ID 的任务。

  • 清除已完成:通过 filter 过滤掉所有 done 为 true 的任务。

    这里的操作都是生成新数组替换旧数组,Vue 的响应式系统会自动检测到引用变化并更新视图。


4. Computed 讲解

计算属性 (Computed Properties):形式上是函数,结果是属性。

核心特性:
  1. 依赖追踪:自动感知它所使用的响应式数据(如 refreactive)。
  2. 缓存机制 (Caching) :这是它与普通函数(Methods)最大的区别。如果依赖的数据没变,多次访问 computed 会直接返回上一次计算的结果,不会重复执行函数体。
高级用法:可写的计算属性(Getter & Setter)

在 Vue 3 中,计算属性(computed)通常默认为“只读”的(即只传入一个 getter 函数)。但在需要实现双向绑定的场景下(例如“全选/反选”复选框),我们需要使用它的高级写法:传入一个包含 getset 的对象。

// 引入 computed
import { computed } from 'vue';

// 定义可写的计算属性
const allDone = computed({
  // getter: 读取值(决定全选框是否勾选)
  // 当依赖的 todos 数据变化时,会自动重新计算
  get() {
    // 逻辑:如果列表不为空,且每一项都已完成 (done === true),则返回 true
    return todos.value.length > 0 && todos.value.every(todo => todo.done)
  },

  // setter: 写入值(当用户点击全选框时触发)
  // val 是用户操作后的新值(true 或 false)
  set(val) {
    // 逻辑:遍历所有 todos,将它们的完成状态强制改为当前全选框的状态
    todos.value.forEach(todo => todo.done = val)
  }
})

在 Vue 中:

  • 使用 Methods (函数) : function getActive() { ... }。每次页面重新渲染(哪怕是无关的 DOM 更新),这个函数都会被执行一遍。如果计算量大,会浪费性能。
  • 使用 Computed (计算属性) : 只有依赖变了才算。对于像“过滤列表”、“遍历大数组”这种操作,computed 是性能优化的关键。

5. 项目源码 (src/App.vue)

<script setup>
  import { ref, computed } from 'vue';
  
  // 1. 响应式数据定义
  const title = ref("todos");
  const newTodoText = ref("");
  const todos = ref([
    { id: 1, context: '打王者', done: false },
    { id: 2, context: '吃饭', done: true }, 
    { id: 3, context: '睡觉', done: false },
    { id: 4, context: '学习Vue', done: false }
  ])

  let nextId = 5;

  // 2. 计算属性:统计未完成数量
  // 优势:computed 有缓存,性能优于 method
  const active = computed(() => {
    return todos.value.filter(todo => !todo.done).length
  })

  // 计算属性:统计已完成数量(用于控制清除按钮显示)
  const completedCount = computed(() => {
    return todos.value.filter(todo => todo.done).length
  })

  // 3. 核心业务:添加任务
  const addTodo = () => {
    const text = newTodoText.value.trim();
    if (!text) return;
    todos.value.push({
      id: nextId++, // 确保 ID 唯一
      context: text,
      done: false
    });
    newTodoText.value = "";
  }

  // 4. 核心业务:全选/反选 (可写计算属性)
  const allDone = computed({
    get() {
      return todos.value.length > 0 && todos.value.every(todo => todo.done)
    },
    set(val) {
      todos.value.forEach(todo => todo.done =val)
    }
  })

  // 5. 核心业务:删除任务
  const removeTodo = (id) => {
    todos.value = todos.value.filter(item => item.id !== id)
  }

  // 6. 核心业务:清除已完成
  const clearCompleted = () => {
    todos.value = todos.value.filter(todo => !todo.done)
  }
</script>

<template>
  <div class="todoapp">
    
    <header class="header">
      <h1>{{ title }}</h1>
      <input 
        class="new-todo" 
        type="text" 
        v-model="newTodoText" 
        @keydown.enter="addTodo" 
        placeholder="What needs to be done?"
        autofocus
      >
    </header>

    <section class="main" v-if="todos.length">
      <input id="toggle-all" class="toggle-all" type="checkbox" v-model="allDone">
      <label for="toggle-all">Mark all as complete</label>

      <ul class="todo-list">
        <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.done }">
          <div class="view">
            <input class="toggle" type="checkbox" v-model="todo.done">
            <label>{{ todo.context }}</label>
            <button class="destroy" @click="removeTodo(todo.id)"></button>
          </div>
        </li>
      </ul>
    </section>

    <footer class="footer" v-if="todos.length">
      <span class="todo-count">
        <strong>{{ active }}</strong> items left
      </span>
      
      <button class="clear-completed" @click="clearCompleted" v-show="completedCount > 0">
        Clear completed
      </button>
    </footer>

  </div>
</template>

Vue中mixin与mixins:全面解析与实战指南

一、引言:为什么需要混入?

在Vue.js开发中,我们经常会遇到多个组件需要共享相同功能或逻辑的情况。例如,多个页面都需要用户认证检查、都需要数据加载状态管理、都需要相同的工具方法等。为了避免代码重复,提高代码的可维护性,Vue提供了混入(Mixin)机制。

今天,我将为你详细解析Vue中mixin和mixins的区别,并通过大量代码示例和流程图帮助你彻底理解这个概念。

二、基础概念解析

1. 什么是mixin?

mixin(混入) 是一个包含可复用组件选项的JavaScript对象。它可以包含组件选项中的任何内容,如data、methods、created、computed等生命周期钩子和属性。

2. 什么是mixins?

mixins 是Vue组件的一个选项,用于接收一个混入对象的数组。它允许组件使用多个mixin的功能。

三、核心区别详解

让我们通过一个对比表格来直观了解二者的区别:

特性 mixin mixins
本质 一个JavaScript对象 Vue组件的选项属性
作用 定义可复用的功能单元 注册和使用mixin
使用方式 被mixins选项引用 组件内部选项
数量 单个 可包含多个mixin

关系流程图

graph TD
    A[mixin定义] -->|混入到| B[Component组件]
    C[另一个mixin定义] -->|混入到| B
    D[更多mixin...] -->|混入到| B
    B --> E[mixins选项<br/>接收mixin数组]

四、代码实战演示

1. 基本mixin定义与使用

创建第一个mixin:

// mixins/loggerMixin.js
export const loggerMixin = {
  data() {
    return {
      logMessages: []
    }
  },
  
  methods: {
    logMessage(message) {
      const timestamp = new Date().toISOString()
      const logEntry = `[${timestamp}] ${message}`
      this.logMessages.push(logEntry)
      console.log(logEntry)
    }
  },
  
  created() {
    this.logMessage('组件/混入已创建')
  }
}

创建第二个mixin:

// mixins/authMixin.js
export const authMixin = {
  data() {
    return {
      currentUser: null,
      isAuthenticated: false
    }
  },
  
  methods: {
    login(user) {
      this.currentUser = user
      this.isAuthenticated = true
      this.$emit('login-success', user)
    },
    
    logout() {
      this.currentUser = null
      this.isAuthenticated = false
      this.$emit('logout')
    }
  },
  
  computed: {
    userRole() {
      return this.currentUser?.role || 'guest'
    }
  }
}

在组件中使用mixins:

<template>
  <div>
    <h1>用户仪表板</h1>
    <div v-if="isAuthenticated">
      <p>欢迎, {{ currentUser.name }} ({{ userRole }})</p>
      <button @click="logout">退出登录</button>
    </div>
    <div v-else>
      <button @click="login({ name: '张三', role: 'admin' })">登录</button>
    </div>
    <div>
      <h3>日志记录:</h3>
      <ul>
        <li v-for="(log, index) in logMessages" :key="index">{{ log }}</li>
      </ul>
    </div>
  </div>
</template>

<script>
import { loggerMixin } from './mixins/loggerMixin'
import { authMixin } from './mixins/authMixin'

export default {
  name: 'UserDashboard',
  
  // mixins选项接收mixin数组
  mixins: [loggerMixin, authMixin],
  
  created() {
    // 合并生命周期钩子
    this.logMessage('用户仪表板组件已创建')
  },
  
  methods: {
    login(user) {
      // 调用mixin的方法
      authMixin.methods.login.call(this, user)
      this.logMessage(`用户 ${user.name} 已登录`)
    }
  }
}
</script>

2. 选项合并策略详解

Vue在处理mixins时遵循特定的合并策略:

// mixins/featureMixin.js
export const featureMixin = {
  data() {
    return {
      message: '来自mixin的消息',
      sharedData: '共享数据'
    }
  },
  
  methods: {
    sayHello() {
      console.log('Hello from mixin!')
    },
    
    commonMethod() {
      console.log('mixin中的方法')
    }
  }
}
<template>
  <div>
    <p>{{ message }}</p>
    <p>{{ componentData }}</p>
    <button @click="sayHello">打招呼</button>
    <button @click="commonMethod">调用方法</button>
  </div>
</template>

<script>
import { featureMixin } from './mixins/featureMixin'

export default {
  mixins: [featureMixin],
  
  data() {
    return {
      message: '来自组件的消息', // 与mixin冲突,组件数据优先
      componentData: '组件特有数据'
    }
  },
  
  methods: {
    // 与mixin中的方法同名,组件方法将覆盖mixin方法
    commonMethod() {
      console.log('组件中的方法')
      // 如果需要调用mixin中的原始方法
      featureMixin.methods.commonMethod.call(this)
    },
    
    componentOnlyMethod() {
      console.log('组件特有方法')
    }
  }
}
</script>

3. 生命周期钩子的合并

生命周期钩子会被合并成数组,mixin的钩子先执行

// mixins/lifecycleMixin.js
export const lifecycleMixin = {
  beforeCreate() {
    console.log('1. mixin的beforeCreate')
  },
  
  created() {
    console.log('2. mixin的created')
  },
  
  mounted() {
    console.log('4. mixin的mounted')
  }
}
<script>
import { lifecycleMixin } from './mixins/lifecycleMixin'

export default {
  mixins: [lifecycleMixin],
  
  beforeCreate() {
    console.log('1. 组件的beforeCreate')
  },
  
  created() {
    console.log('3. 组件的created')
  },
  
  mounted() {
    console.log('5. 组件的mounted')
  }
}
</script>

// 控制台输出顺序:
// 1. mixin的beforeCreate
// 2. 组件的beforeCreate
// 3. mixin的created
// 4. 组件的created
// 5. mixin的mounted
// 6. 组件的mounted

4. 全局混入

除了在组件内使用mixins选项,还可以创建全局mixin:

// main.js或单独的文件中
import Vue from 'vue'

// 全局混入 - 影响所有Vue实例
Vue.mixin({
  data() {
    return {
      globalData: '这是全局数据'
    }
  },
  
  methods: {
    $formatDate(date) {
      return new Date(date).toLocaleDateString()
    }
  },
  
  mounted() {
    console.log('全局mixin的mounted钩子')
  }
})

五、高级用法与最佳实践

1. 可配置的mixin

通过工厂函数创建可配置的mixin:

// mixins/configurableMixin.js
export function createPaginatedMixin(options = {}) {
  const {
    pageSize: defaultPageSize = 10,
    dataKey = 'items'
  } = options
  
  return {
    data() {
      return {
        currentPage: 1,
        pageSize: defaultPageSize,
        totalItems: 0,
        [dataKey]: []
      }
    },
    
    computed: {
      totalPages() {
        return Math.ceil(this.totalItems / this.pageSize)
      },
      
      paginatedData() {
        const start = (this.currentPage - 1) * this.pageSize
        const end = start + this.pageSize
        return this[dataKey].slice(start, end)
      }
    },
    
    methods: {
      goToPage(page) {
        if (page >= 1 && page <= this.totalPages) {
          this.currentPage = page
        }
      },
      
      nextPage() {
        if (this.currentPage < this.totalPages) {
          this.currentPage++
        }
      },
      
      prevPage() {
        if (this.currentPage > 1) {
          this.currentPage--
        }
      }
    }
  }
}
<template>
  <div>
    <h1>用户列表</h1>
    <ul>
      <li v-for="user in paginatedData" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
    
    <div class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
      <span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
    </div>
  </div>
</template>

<script>
import { createPaginatedMixin } from './mixins/configurableMixin'

export default {
  name: 'UserList',
  
  mixins: [createPaginatedMixin({ pageSize: 5, dataKey: 'users' })],
  
  data() {
    return {
      users: [] // 会被mixin处理
    }
  },
  
  async created() {
    // 模拟API调用
    const response = await fetch('/api/users')
    this.users = await response.json()
    this.totalItems = this.users.length
  }
}
</script>

2. 合并策略自定义

// 自定义合并策略
import Vue from 'vue'

// 为特定选项自定义合并策略
Vue.config.optionMergeStrategies.customOption = function(toVal, fromVal) {
  // 返回合并后的值
  return toVal || fromVal
}

// 自定义方法的合并策略:将方法合并到一个数组中
Vue.config.optionMergeStrategies.myMethods = function(toVal, fromVal) {
  if (!toVal) return [fromVal]
  if (!fromVal) return toVal
  return toVal.concat(fromVal)
}

六、mixin与mixins的完整执行流程

sequenceDiagram
    participant G as 全局mixin
    participant M1 as Mixin1
    participant M2 as Mixin2
    participant C as 组件
    participant V as Vue实例
    
    Note over G,M2: 初始化阶段
    G->>M1: 执行全局mixin钩子
    M1->>M2: 执行Mixin1钩子
    M2->>C: 执行Mixin2钩子
    C->>V: 执行组件钩子
    
    Note over G,M2: 数据合并
    V->>V: 合并data选项<br/>(组件优先)
    
    Note over G,M2: 方法合并
    V->>V: 合并methods选项<br/>(组件覆盖mixin)
    
    Note over G,M2: 钩子函数合并
    V->>V: 合并生命周期钩子<br/>(全部执行,mixin先执行)

七、替代方案与Composition API

虽然mixins非常有用,但在大型项目中可能导致一些问题:

  1. 命名冲突
  2. 隐式依赖
  3. 难以追踪功能来源

Vue 3引入了Composition API作为更好的替代方案:

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍: {{ doubleCount }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

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

// 使用Composition API复用逻辑
function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  onMounted(() => {
    console.log('计数器已挂载')
  })
  
  return {
    count,
    doubleCount,
    increment
  }
}

export default {
  setup() {
    // 明确地使用功能,避免命名冲突
    const { count, doubleCount, increment } = useCounter(10)
    
    return {
      count,
      doubleCount,
      increment
    }
  }
}
</script>

八、总结与最佳实践

mixin vs mixins总结:

  • mixin是功能单元,mixins是使用这些功能单元的接口
  • 一个组件可以通过mixins选项使用多个mixin
  • 合并策略:组件选项通常优先于mixin选项
  • 生命周期钩子会合并执行,mixin钩子先于组件钩子

最佳实践:

  1. 命名规范:为mixin使用特定前缀,如mixinwith
  2. 单一职责:每个mixin只关注一个特定功能
  3. 明确文档:记录mixin的依赖和副作用
  4. 避免全局混入:除非确实需要影响所有组件
  5. 考虑Composition API:在Vue 3项目中优先使用

适用场景:

  • 适合使用mixin:简单的工具函数、通用的生命周期逻辑、小型到中型项目
  • 考虑替代方案:复杂的状态管理、大型企业级应用、需要明确依赖关系的场景

希望通过这篇文章,你已经全面理解了Vue中mixin和mixins的区别与用法。在实际开发中,合理使用混入可以显著提高代码复用性和可维护性,但也要注意避免过度使用导致的复杂性问题。

如果你觉得这篇文章有帮助,欢迎分享给更多开发者!

Vue3-全局组件 && 递归组件

1.全局组件

全局组件是在 main.ts 中一次性注册,之后就可以在项目中的任何组件模板内直接使用,无需 import

组件本身就是一个标准的 SFC (单文件组件),使用 <script setup lang="ts"> 编写。

举个栗子,button组件

<template>
  <button class="base-button">
    <slot></slot>
  </button>
</template>

<script setup lang="ts">
// 这里可以定义 props, emits 等
// 例如:
// defineProps<{ type: 'primary' | 'secondary' }>()
</script>

<style scoped>
.base-button {
  padding: 8px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
.base-button:hover {
  background-color: #f0f0f0;
}
</style>

在创建 Vue 实例后、挂载 (.mount()) 之前来注册它。

app.component('组件名', 组件对象)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 1. 导入要全局注册的组件
import BaseCard from './components/BaseCard.vue'
import BaseButton from './components/BaseButton.vue'
import SvgIcon from './components/SvgIcon.vue'

const app = createApp(App)

// 2. 使用 app.component() 进行全局注册
// app.component('组件名', 组件对象)
app.component('BaseCard', BaseCard)
app.component('BaseButton', BaseButton)
app.component('SvgIcon', SvgIcon)

// 3. 挂载应用
app.mount('#app')

注册后,在任何其他组件中都可以直接使用 <BaseButton>,无需导入。

<template>
  <div>
    <h1>欢迎!</h1>
    <BaseButton>点我</BaseButton>
    <BaseCard>
      <SvgIcon name="user" />
      <p>一些内容</p>
    </BaseCard>
  </div>
</template>

<script setup lang="ts">
// 无需 import BaseButton, BaseCard, SvgIcon
</script>
使用场景
  1. 基础 UI 组件: 这是最常见的场景。项目中使用频率极高的组件,例如 Button, Icon, Modal, Card, Input 等。通常会以 Base-App- 作为前缀,以示区分。
  2. UI 库集成: 当使用像 Element Plus, Naive UI 或 Vuetify 这样的库时,它们通常会提供一个 app.use(Library) 的方式,这背后其实就是全局注册了它们所有的组件。
  3. 布局组件: AppHeader, AppFooter, Sidebar 等几乎每个页面都会用到的布局框架。

⚠️ 注意: 全局注册会轻微增加应用的初始加载体积,因为所有全局组件都会被打包到主 chunk 中。因此,请对那些真正常用的组件使用全局注册,避免滥用。


2.递归组件

递归组件是指在其模板中调用自身的组件。

举个栗子:树形菜单

首先,定义数据结构 ( types.ts)

// types.ts
export interface TreeNodeData {
  id: string;
  label: string;
  children?: TreeNodeData[]; // 关键:children 数组的类型是它自身
}

然后,创建递归组件 (TreeNode.vue)

<template>
  <div class="tree-node">
    <div class="node-label">{{ node.label }}</div>
    <!-- 可选链操作符?.:如果 node.children 存在且有值,则渲染子节点列表,否则返回undefined,隐式转换为false -->
    <ul v-if="node.children && node.children.length > 0" class="children-list">
      <li v-for="child in node.children" :key="child.id">
        <!-- 递归 -->
        <TreeNode :node="child" />
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
// 导入我们定义的类型
import type { TreeNodeData } from './types'

// 1. 定义 Props,接收父组件传递的数据
interface Props {
  node: TreeNodeData;
}
defineProps<Props>()

</script>

<style scoped>
.tree-node {
  margin-left: 20px;
}
.node-label {
  font-weight: bold;
}
.children-list {
  list-style-type: none;
  padding-left: 15px;
  border-left: 1px dashed #ccc;
}
</style>

在父组件中导入并渲染“根节点”即可。

<template>
  <div>
    <h1>文件结构树</h1>
    <TreeNode :node="fileTree" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import TreeNode from './components/TreeNode.vue'
import type { TreeNodeData } from './components/types' // 确保路径正确

// 准备一个符合 TreeNodeData 结构的 TS 数据
const fileTree = ref<TreeNodeData>({
  id: 'root',
  label: '项目根目录 (src)',
  children: [
    {
      id: 'c1',
      label: 'components',
      children: [
        { id: 'c1-1', label: 'TreeNode.vue' },
        { id: 'c1-2', label: 'BaseButton.vue' },
      ],
    },
    {
      id: 'c2',
      label: 'views',
      children: [
        { id: 'c2-1', label: 'HomePage.vue' },
      ],
    },
    {
      id: 'c3',
      label: 'App.vue',
      // 这个节点没有 children,递归将在此处停止
    },
  ],
})
</script>
使用场景
  1. 树形结构: 任何需要展示层级关系的数据。例如:文件浏览器、组织架构图、导航菜单(尤其是多级下拉菜单)。
  2. 嵌套评论: 社交媒体或论坛中的评论区,一条评论可以有“回复”(子评论),子评论又可以有回复。
  3. JSON 格式化器: 展示一个 JSON 对象,如果某个值是对象或数组,就递归地调用组件来展示其内部。

参考文章

小满zs 学习Vue3 第十五章(全局组件,局部组件,递归组件)xiaoman.blog.csdn.net/article/det…

❌