Vue 组件化开发与插槽机制深入

2024-09-07 前端

一、组件基础

组件是 Vue 应用的核心构建块。一个组件本质是一个拥有预定义选项的 Vue 实例,它将 UI(用户界面)拆分成独立、可复用的部分。本节我们从组件注册、命名、Props、Emits、v-model 等最基础的知识讲起。

1.1 组件注册:全局 vs 局部

全局注册使用 app.component(),注册后可在整个应用任何位置使用,无需重复导入。缺点是无法被 tree-shaking(摇树优化),即使未使用也会打包进最终产物。

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

const app = createApp(App)

// 全局注册
app.component('MyButton', {
  template: '<button class="btn"><slot/></button>'
})

app.component('MyCard', {
  template: '<div class="card"><slot/></div>'
})

app.mount('#app')

局部注册仅在当前组件作用域内可用,需在 <script setup> 中导入后直接使用,无需额外注册步骤:

<!-- ChildComponent.vue -->
<template>
  <div class="child">{{ msg }}</div>
</template>

<script setup>
defineProps({ msg: String })
</script>

<!-- ParentComponent.vue -->
<template>
  <div>
    <ChildComponent msg="Hello" />
  </div>
</template>

<script setup>
import ChildComponent from './ChildComponent.vue'
</script>

在非 <script setup> 模式下,需通过 components 选项显式注册:

import ChildComponent from './ChildComponent.vue'

export default {
  components: { ChildComponent }
}

最佳实践:优先使用局部注册。只有那些真正全局通用的基础组件(如 BaseIcon、BaseButton)才考虑全局注册。

1.2 组件命名规范

Vue 推荐使用 PascalCase 命名组件,原因有三:

  1. 与 JavaScript 类名/构造函数命名风格一致
  2. 在模板中直观区分原生 HTML 元素(全小写)与 Vue 组件
  3. IDE 自动补全友好

PascalCase vs kebab-case 对照表:

定义时 模板中使用
MyComponent <MyComponent> 或 <my-component>
UserProfileCard <UserProfileCard> 或 <user-profile-card>
BaseIconButton <BaseIconButton> 或 <base-icon-button>

Vue 支持两种写法等价转换,但项目中应统一风格。

1.3 Props:类型验证、默认值、必需属性

Props 是父组件向子组件传递数据的通道。Vue 3 提供了完善的运行时类型验证。

基础写法(<script setup>):

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p v-if="email">邮箱:{{ email }}</p>
    <img :src="avatar" alt="avatar" v-if="avatar">
  </div>
</template>

<script setup>
defineProps({
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 18
  },
  email: {
    type: String,
    default: ''
  },
  avatar: {
    type: String,
    default: '/default-avatar.png'
  }
})
</script>

完整类型验证选项:

defineProps({
  // 基础类型检查
  propA: Number,                    // null 和 undefined 会跳过类型检查
  propB: [String, Number],          // 多个类型
  propC: {
    type: String,
    required: true                  // 必需属性
  },
  propD: {
    type: Number,
    default: 100                    // 默认值
  },
  propE: {
    type: Object,
    default: () => ({})             // 对象/数组默认值必须用工厂函数
  },
  propF: {
    validator(value) {              // 自定义验证函数
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  propG: {
    type: Function,
    default: () => console.log('default handler')
  }
})

使用组件时传递 Props:

<template>
  <UserCard
    name="张三"
    :age="25"
    email="zhangsan@example.com"
    :avatar="userAvatar"
    :user="userObj"
    v-bind="userProps"      <!-- 展开对象 -->
  />
</template>

<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'

const userAvatar = ref('/avatars/1.png')
const userObj = ref({ name: '李四', age: 30 })

// 批量传递
const userProps = {
  name: '王五',
  age: 22,
  email: 'wangwu@example.com'
}
</script>

1.4 Emits:声明事件、验证事件参数

子组件通过 emits 声明可触发的事件,Vue 3 支持事件参数验证。

父组件监听事件:

<template>
  <SearchInput
    v-model="keyword"
    @search="handleSearch"
  />
</template>

<script setup>
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'

const keyword = ref('')

function handleSearch(payload) {
  console.log('搜索:', payload.keyword, '时间:', payload.timestamp)
  // 调用 API 执行搜索
}
</script>

1.5 v-model 组件

Vue 3 中,v-model 本质是 :modelValue + @update:modelValue 的语法糖。

基础 v-model 实现:

<!-- CustomInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    class="custom-input"
  />
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

使用:

<template>
  <CustomInput v-model="searchText" />
  <!-- 等价于 -->
  <CustomInput
    :modelValue="searchText"
    @update:modelValue="searchText = $event"
  />
</template>

1.6 多个 v-model 绑定(Vue 3)

Vue 3 允许一个组件绑定多个 v-model,每个绑定有独立的 prop 和 event 名称:

<!-- UserForm.vue -->
<template>
  <div class="user-form">
    <input :value="username" @input="$emit('update:username', $event.target.value)" placeholder="用户名" />
    <input :value="email" @input="$emit('update:email', $event.target.value)" placeholder="邮箱" type="email" />
    <input :value="age" @input="$emit('update:age', Number($event.target.value))" placeholder="年龄" type="number" />
  </div>
</template>

<script setup>
defineProps({
  username: String,
  email: String,
  age: Number
})

defineEmits(['update:username', 'update:email', 'update:age'])
</script>

父组件使用多个 v-model:

<template>
  <UserForm
    v-model:username="formData.username"
    v-model:email="formData.email"
    v-model:age="formData.age"
  />
</template>

<script setup>
import { reactive } from 'vue'
import UserForm from './UserForm.vue'

const formData = reactive({
  username: '',
  email: '',
  age: 18
})
</script>

v-model 修饰符: 自定义修饰符通过 modelModifiers prop 接收:

<!-- CapitalizeInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="handleInput"
  />
</template>

<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function handleInput(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>
<template>
  <CapitalizeInput v-model.capitalize="text" />
</template>

二、组件通信

Vue 提供了多种组件通信方式,适用于不同场景和组件关系层级。理解每种方式的适用场景,是构建可维护、可扩展应用的关键。

2.1 父 → 子:Props

Props 是组件通信最基础、最常用的方式。数据从父组件流向子组件。

<!-- Parent.vue -->
<template>
  <Child :message="msg" :count="count" />
  <button @click="count++">增加</button>
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const msg = ref('来自父组件的消息')
const count = ref(0)
</script>

<!-- Child.vue -->
<template>
  <div>
    <p>{{ message }}</p>
    <p>计数:{{ count }}</p>
  </div>
</template>

<script setup>
defineProps({
  message: String,
  count: Number
})
</script>

特点:

  • 单向数据流,子组件不应直接修改 props
  • TypeScript 友好,支持类型推导
  • 适合所有层级深度

2.2 子 → 父:Emits

子组件通过 $emit 触发自定义事件,父组件监听事件来获取数据。

2.3 兄弟组件:Provide / Inject

Provide/Inject 主要用于跨层级通信,但也可以借助一个共同的父组件实现兄弟通信。

方案一:借助共同父组件(推荐)

<!-- Parent.vue:协调兄弟通信 -->
<template>
  <SiblingA @send-to-b="handleSendToB" />
  <SiblingB :message="siblingMessage" />
</template>

<script setup>
import { ref } from 'vue'
import SiblingA from './SiblingA.vue'
import SiblingB from './SiblingB.vue'

const siblingMessage = ref('')

function handleSendToB(msg) {
  siblingMessage.value = msg
}
</script>

方案二:共享响应式对象

<!-- shared.js -->
import { reactive } from 'vue'
export const sharedState = reactive({
  message: '',
  updateMessage(msg) {
    this.message = msg
  }
})

<!-- SiblingA.vue -->
<template>
  <button @click="send">发送给兄弟</button>
</template>

<script setup>
import { sharedState } from './shared.js'
function send() {
  sharedState.updateMessage('Hello from SiblingA at ' + Date.now())
}
</script>

<!-- SiblingB.vue -->
<template>
  <div>收到:{{ sharedState.message }}</div>
</template>

<script setup>
import { sharedState } from './shared.js'
</script>

2.4 跨多层级:Provide / Inject

Provide/Inject 允许祖先组件向所有子孙组件注入依赖,无需逐层传递 props。这是解决"prop 逐级透传"问题的优雅方案。

基础用法:

使用 Symbol 避免命名冲突:

// injection-keys.js
export const themeKey = Symbol('theme')
export const userKey = Symbol('user')
export const localeKey = Symbol('locale')

// 提供方
import { themeKey } from './injection-keys.js'
provide(themeKey, theme)

// 注入方
import { themeKey } from './injection-keys.js'
const theme = inject(themeKey, 'light')

响应式注入最佳实践: 尽量在 provide 侧保持响应式引用,避免注入方直接修改——应通过 provide 提供的方法来修改:

// ❌ 不推荐:注入方直接修改
provide('count', count)

// ✅ 推荐:仅提供修改方法
provide('count', readonly(count))   // 使用 readonly 保护
provide('increment', increment)

2.5 任意组件:事件总线(mitt)

对于非父子关系的任意组件通信,可以使用事件总线。Vue 3 不再内置事件总线 API,官方推荐使用第三方库 mitt。

npm install mitt
// utils/eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()

// 声明事件类型(TypeScript)
// type Events = { 'user-login': { id: number; name: string }; 'notification': string }
// export const eventBus = mitt<Events>()

发送事件:

<!-- LoginForm.vue -->
<template>
  <button @click="login">登录</button>
</template>

<script setup>
import { eventBus } from '@/utils/eventBus.js'

function login() {
  // 模拟登录
  const user = { id: 1, name: '张三' }
  eventBus.emit('user-login', user)
}
</script>

监听事件:

<!-- UserHeader.vue -->
<template>
  <div v-if="user">
    欢迎,{{ user.name }}
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus.js'

const user = ref(null)

function handleLogin(userData) {
  user.value = userData
}

onMounted(() => {
  eventBus.on('user-login', handleLogin)
})

onUnmounted(() => {
  eventBus.off('user-login', handleLogin)
  // 也可全部移除:eventBus.all.clear()
})
</script>

事件总线的优缺点:

优点 缺点
使用简单,任意组件可通信 事件流难以追踪,不利于调试
不依赖组件层级关系 同名事件易冲突,缺少类型安全
适合简单的中小型应用 大量使用会使代码变得难以维护

建议:优先使用 Provide/Inject 或状态管理库;事件总线仅用于简单的、偶发的跨组件通信场景。

2.6 状态管理:Pinia 简介

对于复杂应用,Pinia 是 Vue 3 官方推荐的状态管理方案。

npm install pinia
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const lastMutation = ref(null)

  // getters
  const doubleCount = computed(() => count.value * 2)

  // actions
  function increment() {
    count.value++
    lastMutation.value = 'increment'
  }

  function decrement() {
    count.value--
    lastMutation.value = 'decrement'
  }

  function reset() {
    count.value = 0
    lastMutation.value = 'reset'
  }

  return { count, doubleCount, lastMutation, increment, decrement, reset }
})

在组件中使用:

<template>
  <div>
    <p>计数:{{ store.count }}</p>
    <p>双倍:{{ store.doubleCount }}</p>
    <p>最后一次操作:{{ store.lastMutation }}</p>
    <button @click="store.increment">+1</button>
    <button @click="store.decrement">-1</button>
    <button @click="store.reset()">重置</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter.js'

const store = useCounterStore()

// 直接解构会丢失响应性,需使用 storeToRefs
// const { count, doubleCount } = store        // ❌ 失去响应性
// const { count, doubleCount } = storeToRefs(store)  // ✅ 保持响应性
</script>

2.7 模板 ref 访问子组件

使用 ref 属性可以直接访问子组件实例:

<template>
  <ChildComponent ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

function callChildMethod() {
  // 访问子组件的属性和方法
  if (childRef.value) {
    console.log(childRef.value.someProperty)
    childRef.value.someMethod()
  }
}
</script>

2.8 Expose 控制子组件暴露的接口

默认情况下,使用 <script setup> 的组件是关闭的——模板 ref 无法访问内部任何绑定。通过 defineExpose 可以显式控制暴露内容:

非 <script setup> 的选项式 API 组件默认暴露所有内容。


三、插槽(Slots)

插槽是 Vue 组件内容分发机制的核心。它让组件模板可以像 HTML 元素一样接收并渲染子内容,是实现灵活、可复用组件的关键武器。

3.1 默认插槽

最简单的插槽形式。子组件用 <slot> 标签定义插槽位置,父组件传入的任何内容都会渲染在该位置。

<!-- BaseCard.vue -->
<template>
  <div class="card">
    <div class="card-body">
      <slot />
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
}
.card-body {
  min-height: 50px;
}
</style>

<!-- 使用 -->
<template>
  <BaseCard>
    <h2>卡片标题</h2>
    <p>这是卡片内容,可以包含任意模板。</p>
  </BaseCard>
</template>

插槽默认内容: 当父组件没有提供内容时,显示默认内容:

<!-- SubmitButton.vue -->
<template>
  <button type="submit" class="btn btn-primary">
    <slot>提交</slot>
    <!-- 如果父组件未提供内容,显示"提交" -->
  </button>
</template>

<!-- 使用 -->
<SubmitButton />                  <!-- 显示"提交" -->
<SubmitButton>保存</SubmitButton>  <!-- 显示"保存" -->

3.2 具名插槽

当需要多个插槽时,使用具名插槽区分不同位置。<slot> 使用 name 属性定义名称,父组件用 v-slot:#name(简写 #name)指定内容插入目标。

使用具名插槽:

<template>
  <PageLayout>
    <template #header>
      <nav>
        <a href="/">首页</a>
        <a href="/about">关于</a>
        <a href="/contact">联系</a>
      </nav>
    </template>

    <!-- 默认插槽(未命名) -->
    <article>
      <h2>文章标题</h2>
      <p>正文内容...</p>
    </article>

    <template #sidebar>
      <ul>
        <li>相关链接 1</li>
        <li>相关链接 2</li>
      </ul>
    </template>

    <template #footer>
      <p>© 2024 版权所有</p>
    </template>
  </PageLayout>
</template>

注意事项:

  • v-slot 只能用在 <template> 标签或组件上
  • 默认插槽的简写是 #default
  • 当同时使用具名插槽和默认插槽时,所有插槽内容必须包裹在 <template> 标签内(除非只有默认插槽)
<!-- ❌ 错误:混用简写和 template -->
<PageLayout>
  <template #header>...</template>
  <p>默认内容</p>         <!-- 如果还有具名插槽,默认插槽也需要 template -->
</PageLayout>

<!-- ✅ 正确 -->
<PageLayout>
  <template #header>...</template>
  <template #default>
    <p>默认内容</p>
  </template>
</PageLayout>

3.3 作用域插槽

默认情况下,插槽内容只能访问父组件作用域的变量。作用域插槽允许子组件向插槽传递数据,让父组件在插槽内容中访问子组件的数据。

<!-- ListView.vue -->
<template>
  <div class="list">
    <div v-for="(item, index) in items" :key="item.id" class="list-item">
      <slot :item="item" :index="index" :isFirst="index === 0" :isLast="index === items.length - 1">
        <!-- 默认渲染方式 -->
        <span>{{ item.label }}</span>
      </slot>
    </div>
  </div>
</template>

<script setup>
defineProps({
  items: {
    type: Array,
    required: true
  }
})
</script>

父组件使用作用域插槽:

<template>
  <ListView :items="users">
    <template #default="{ item, index, isFirst }">
      <div :class="{ highlighted: isFirst }">
        <span class="rank">{{ index + 1 }}.</span>
        <strong>{{ item.name }}</strong>
        <span class="email">({{ item.email }})</span>
        <span v-if="item.role === 'admin'" class="badge">管理员</span>
      </div>
    </template>
  </ListView>
</template>

<script setup>
const users = [
  { id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' },
  { id: 2, name: '李四', email: 'li@example.com', role: 'user' },
  { id: 3, name: '王五', email: 'wang@example.com', role: 'user' }
]
</script>

具名作用域插槽:

使用:

<DataTable :columns="columns" :data="users">
  <template #cell-name="{ row, value }">
    <strong>{{ value }}</strong>
    <span v-if="row.role === 'admin'">⭐</span>
  </template>
  <template #cell-email="{ value }">
    <a :href="`mailto:${value}`">{{ value }}</a>
  </template>
  <template #cell-status="{ value }">
    <span :class="value === 'active' ? 'text-green' : 'text-gray'">
      {{ value === 'active' ? '激活' : '停用' }}
    </span>
  </template>
</DataTable>

3.4 动态插槽名

Vue 3 支持使用动态参数指定插槽名称:

<template>
  <DynamicLayout>
    <template #[dynamicSlotName]>
      <p>动态插槽内容</p>
    </template>
  </DynamicLayout>
</template>

<script setup>
import { ref } from 'vue'
import DynamicLayout from './DynamicLayout.vue'

const dynamicSlotName = ref('sidebar')
// 可根据逻辑动态切换:'header' | 'sidebar' | 'footer'
</script>

实际应用场景:动态表单字段布局

<template>
  <FormBuilder :fields="fields">
    <template v-for="field in fields" :key="field.key" #[`field-${field.key}`]="{ model, errors }">
      <component :is="getFieldComponent(field.type)" v-model="model[field.key]" :field="field" />
      <span v-if="errors[field.key]" class="error">{{ errors[field.key] }}</span>
    </template>
  </FormBuilder>
</template>

3.5 插槽默认内容

如前所述,插槽可定义默认内容,在父组件未提供内容时渲染。

<!-- Alert.vue -->
<template>
  <div :class="['alert', `alert-${type}`]">
    <slot name="icon">
      <span class="default-icon">
        {{ type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️' }}
      </span>
    </slot>
    <slot />
    <slot name="action">
      <button v-if="dismissible" @click="$emit('dismiss')" class="close-btn">✕</button>
    </slot>
  </div>
</template>

<script setup>
defineProps({
  type: { type: String, default: 'info' },
  dismissible: { type: Boolean, default: false }
})

defineEmits(['dismiss'])
</script>

3.6 插槽实战

实战一:可复用列表渲染

实战二:灵活表格列自定义

实战三:布局组件


四、动态组件与异步组件

4.1 <component :is="...">

动态组件允许在同一个挂载点动态切换不同组件:

与 keep-alive 结合使用保持组件状态:

<template>
  <keep-alive :include="['TabHome', 'TabProfile']" :max="3">
    <component :is="activeComponent" />
  </keep-alive>
</template>

4.2 defineAsyncComponent

大型应用中,将组件分割成独立的代码块并按需加载,可以显著减少首屏加载体积。

// 基础用法
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./AsyncComponent.vue')
)

完整配置选项:

路由懒加载——最常见的异步组件场景:

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

const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    // 为路由组件命名的 Webpack 魔法注释
    // component: () => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue')
  }
]

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

4.3 Suspense(Vue 3)

<Suspense> 是 Vue 3 内置的全局组件,用于协调异步依赖的处理。它管理组件树中所有异步操作的加载状态。

<template>
  <Suspense>
    <!-- 默认内容:异步组件就绪后显示 -->
    <AsyncDashboard />

    <!-- fallback:加载中显示 -->
    <template #fallback>
      <div class="skeleton-loader">
        <div class="skeleton-header" />
        <div class="skeleton-body">
          <div class="skeleton-line" v-for="i in 5" :key="i" />
        </div>
      </div>
    </template>
  </Suspense>
</template>

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

const AsyncDashboard = defineAsyncComponent(() => import('./Dashboard.vue'))
</script>

在组合式 API 中使用 <script setup> + await:

<!-- AsyncUserProfile.vue -->
<template>
  <div class="user-profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
  </div>
</template>

<script setup>
// 直接在顶层使用 await——Suspense 会处理加载状态
const response = await fetch(`/api/users/${props.userId}`)
const user = await response.json()

// 如果在 Suspense 外部使用,Vue 会发出警告
</script>

Suspense 事件:

<Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
  <AsyncComponent />
  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>

注意:Suspense 在 Vue 3 中仍处于实验性阶段,API 可能在后续版本变化。生产环境中建议谨慎使用,或配合 defineAsyncComponent 的 loadingComponent/errorComponent 作为替代方案。


五、高阶组件模式

5.1 渲染函数 h()

渲染函数是模板编译的底层 API,提供了完全的 JavaScript 编程能力来创建虚拟 DOM。

import { h, ref } from 'vue'

export default {
  props: {
    level: { type: Number, required: true },
    title: { type: String, required: true }
  },
  setup(props, { slots }) {
    return () => {
      // 动态创建 h1-h6 标签
      return h(
        `h${props.level}`,                    // 标签名
        { class: 'dynamic-heading' },          // props/attributes
        [
          h('a', {                             // 锚点链接
            name: props.title,
            href: `#${props.title}`
          }),
          ' ',
          slots.default ? slots.default() : props.title
        ]
      )
    }
  }
}

渲染函数常用参数:

h('div', { /* props/attrs */ }, [
  // 子节点:字符串或 VNode 数组
  '文本节点',
  h('span', '子元素'),
  h(AnotherComponent, { prop: 'value' })
])

Props 参数详解:

h('button', {
  // 普通 HTML attribute
  id: 'my-btn',
  // class 支持字符串、对象、数组
  class: ['btn', { 'btn-primary': isPrimary }],
  // style 支持字符串、对象、数组
  style: { fontSize: '14px', color: 'blue' },
  // 事件监听
  onClick: handleClick,
  onMouseenter: handleMouseEnter,
  // DOM property(用 _ 前缀区别)
  innerHTML: '<strong>加粗</strong>',
  // 自定义 directive
  directives: [{
    name: 'focus',
    value: true
  }]
})

5.2 JSX 在 Vue 中使用

JSX 提供了更接近模板的语法,但拥有完全的 JavaScript 表达能力。

配置(Vite):

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

export default defineConfig({
  plugins: [vue(), vueJsx()]
})

Vue JSX 基础用法:

// Heading.jsx
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    level: { type: Number, default: 1 },
    align: { type: String, default: 'left' }
  },
  setup(props, { slots }) {
    const Tag = `h${props.level}`

    return () => (
      <Tag style={{ textAlign: props.align }} class="custom-heading">
        {slots.default ? slots.default() : 'Heading'}
      </Tag>
    )
  }
})

JSX vs 模板对比:

特性 模板 JSX
指令 v-if, v-for JS 表达式 (if, map)
插槽 <slot>, <template #name> slots.name()
v-model 语法糖 手动实现或使用 vModel
作用域插槽 <template #name="props"> slots.name(props)
条件渲染 v-if/v-else 三元表达式

复杂 JSX 示例——递归树组件:

5.3 函数式组件

函数式组件是无状态的纯函数,接收 props 并返回 VNode。在 Vue 3 中,函数式组件的性能优势已经不再明显(因为普通组件也已经很快),但它在某些场景下仍然有用。

// FunctionalButton.js
export default function FunctionalButton(props, { slots, emit, attrs }) {
  return (
    <button
      class={['btn', `btn-${props.variant || 'primary'}`]}
      onClick={() => emit('click', props.value)}
      {...attrs}
    >
      {slots.default ? slots.default() : 'Button'}
    </button>
  )
}

// 声明 props
FunctionalButton.props = {
  variant: String,
  value: [String, Number]
}

FunctionalButton.emits = ['click']

在模板中使用:

<template>
  <FunctionalButton variant="danger" :value="itemId" @click="handleDelete">
    删除
  </FunctionalButton>
</template>

<script setup>
import FunctionalButton from './FunctionalButton.js'
</script>

5.4 高阶组件(HOC)模式

高阶组件是一个函数,接收一个组件作为参数,返回一个新的增强组件。这是从 React 借鉴的模式,在 Vue 中可用于横切关注点(如权限校验、日志记录、数据加载等)。

基础 HOC——withLogger:

// withLogger.js
import { defineComponent, h, onMounted, onUnmounted } from 'vue'

export function withLogger(WrappedComponent, componentName) {
  return defineComponent({
    name: `WithLogger(${componentName || WrappedComponent.name})`,

    setup(props, { slots, attrs, emit }) {
      onMounted(() => {
        console.log(`[Logger] ${componentName} 已挂载`, props)
      })

      onUnmounted(() => {
        console.log(`[Logger] ${componentName} 已卸载`)
      })

      return () => h(WrappedComponent, { ...attrs, ...props }, slots)
    }
  })
}

使用:

import UserProfile from './UserProfile.vue'
const UserProfileWithLogger = withLogger(UserProfile, 'UserProfile')

高级 HOC——withPermission:

// withPermission.js
import { defineComponent, h, inject, computed } from 'vue'
import NoPermission from './NoPermission.vue'

export function withPermission(WrappedComponent, requiredPermission) {
  return defineComponent({
    name: `WithPermission(${WrappedComponent.name})`,

    setup(props, { slots, attrs, emit }) {
      const userPermissions = inject('userPermissions', [])

      const hasPermission = computed(() =>
        userPermissions.includes(requiredPermission)
      )

      return () => {
        if (hasPermission.value) {
          return h(WrappedComponent, { ...attrs, ...props }, slots)
        }
        return h(NoPermission, {
          requiredPermission,
          message: `需要"${requiredPermission}"权限才能访问此内容`
        })
      }
    }
  })
}

// 使用
// const AdminPanel = withPermission(AdminPanelBase, 'admin:panel')

HOC 模式与组合式函数对比:

方面 HOC 组合式函数 (Composables)
本质 组件包装(返回新组件) 函数调用(返回状态和方法)
模板影响 新增外层 DOM 无额外 DOM
灵活性 固定 wrapper 层级 自由组合
Props 传透 需手动处理 $attrs 直接使用
ref 穿透 需 inheritAttrs + $attrs 自然支持

推荐:Vue 3 中优先使用组合式函数(Composables)替代传统 HOC 模式,组合式函数更灵活,不引入额外 DOM 层级,且 TypeScript 类型推断更好。


六、组件设计模式

6.1 容器组件 vs 展示组件

这是 Dan Abramov 在 React 社区推广的模式,在 Vue 中同样适用。

容器组件(Container/Smart): 负责数据获取、状态管理、业务逻辑。
展示组件(Presentational/Dumb): 仅通过 props 接收数据,通过 emits 发送事件,不含业务逻辑。

6.2 渲染组件 vs 逻辑组件

渲染组件负责渲染 UI,逻辑组件封装可复用的逻辑(通常通过组合式函数实现)。

<!-- 使用逻辑组件的渲染组件 -->
<template>
  <div>
    <UserListDisplay :users="paginatedData" />

    <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 setup>
const { currentPage, totalPages, paginatedData, nextPage, prevPage } = usePagination(users, 20)
</script>

6.3 复合组件模式

复合组件模式让多个子组件配合工作,共享隐式状态。最典型的例子是 <select> 和 <option>。

<!-- 使用复合组件 -->
<template>
  <Select v-model="selectedCity" :options="cities" placeholder="选择城市">
    <template #option="{ option, selected }">
      <span :class="{ bold: selected }">
        {{ option.label }}
        <span class="code">({{ option.code }})</span>
      </span>
    </template>
  </Select>
</template>

6.4 受控 vs 非受控组件

受控组件: 状态由父组件通过 props 控制,子组件通过 emits 通知变更。
非受控组件: 状态由组件自身管理,父组件通过 ref 或事件获取状态。

6.5 组件的单一职责原则

每个组件应只负责一个明确的功能。判断标准:

"如果一个组件有多个不同的变化原因,就应该拆分。"

❌ 违反单一职责:

<template>
  <div>
    <!-- 用户列表 -->
    <div v-for="user in users" :key="user.id">
      {{ user.name }}
    </div>

    <!-- 搜索过滤器 -->
    <input v-model="searchQuery" placeholder="搜索..." />

    <!-- 分页 -->
    <Pagination :total="total" />

    <!-- 导出按钮 -->
    <button @click="exportCSV">导出 CSV</button>

    <!-- 统计信息 -->
    <div class="stats">
      <p>总数:{{ total }}</p>
      <p>活跃:{{ activeCount }}</p>
    </div>
  </div>
</template>

✅ 符合单一职责:

<!-- UserDashboard.vue(容器组件,负责编排) -->
<template>
  <div>
    <UserSearch @search="handleSearch" />
    <UserTable :users="filteredUsers" @select="handleSelect" />
    <UserPagination :page="page" :total="total" @change="handlePageChange" />
    <UserStats :users="users" />
    <ExportButton :data="filteredUsers" filename="users.csv" />
  </div>
</template>

七、自定义指令

自定义指令是对 DOM 操作的低级封装。Vue 3 中指令的生命周期钩子与组件生命周期对齐。

7.1 局部与全局指令

全局指令:

// main.js
import { createApp } from 'vue'

const app = createApp(App)

app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

app.directive('highlight', {
  mounted(el, binding) {
    el.style.backgroundColor = binding.value || 'yellow'
  },
  updated(el, binding) {
    el.style.backgroundColor = binding.value || 'yellow'
  }
})

局部指令(<script setup>):

<template>
  <input v-focus />
  <p v-highlight="'lightblue'">高亮文本</p>
</template>

<script setup>
const vFocus = {
  mounted(el) {
    el.focus()
  }
}

const vHighlight = {
  mounted(el, binding) {
    el.style.backgroundColor = binding.value
    el.style.padding = '4px'
  },
  updated(el, binding) {
    el.style.backgroundColor = binding.value
  }
}
</script>

局部指令(选项式 API):

export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus()
      }
    }
  }
}

7.2 指令生命周期

Vue 3 指令的钩子函数:

const myDirective = {
  // 当指令第一次绑定到元素上时调用(仅一次)
  created(el, binding, vnode) {
    // binding: value, oldValue, arg, modifiers, instance, dir
    // vnode: 元素的 VNode
  },

  // 元素被挂载到 DOM 时调用
  mounted(el, binding, vnode) {
    // 适合初始化 DOM 操作、事件监听
  },

  // 元素所在组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},

  // 元素所在组件更新后调用,可能触发多次
  updated(el, binding, vnode, prevVnode) {
    // 适合响应式更新 DOM
  },

  // 元素所在组件卸载前调用
  beforeUnmount(el, binding, vnode) {},

  // 元素被卸载时调用(仅一次)
  unmounted(el, binding, vnode) {
    // 适合清理:取消事件监听、定时器等
  }
}

7.3 实战指令

实战一:v-focus(自动聚焦)

const vFocus = {
  mounted(el) {
    // 优先聚焦内部 input/textarea
    const input = el.querySelector('input, textarea, select') || el
    if (input && typeof input.focus === 'function') {
      // 延迟确保渲染完成
      setTimeout(() => input.focus(), 0)
    }
  }
}

实战二:v-click-outside(点击外部)

// directives/clickOutside.js
export const vClickOutside = {
  mounted(el, binding) {
    if (typeof binding.value !== 'function') {
      console.warn('[v-click-outside] 需要绑定一个函数')
      return
    }

    el.__clickOutsideHandler = (event) => {
      if (!el.contains(event.target) && el !== event.target) {
        binding.value(event)
      }
    }

    // 建议使用 mousedown + touchstart 而非 click,避免冒泡问题
    document.addEventListener('mousedown', el.__clickOutsideHandler)
  },

  unmounted(el) {
    document.removeEventListener('mousedown', el.__clickOutsideHandler)
    delete el.__clickOutsideHandler
  }
}

使用:

<template>
  <div v-click-outside="closeDropdown" class="dropdown">
    <button @click="isOpen = !isOpen">切换下拉</button>
    <div v-if="isOpen" class="menu">
      <a href="#">选项 1</a>
      <a href="#">选项 2</a>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { vClickOutside } from '@/directives/clickOutside.js'

const isOpen = ref(false)

function closeDropdown() {
  isOpen.value = false
}
</script>

实战三:v-debounce(防抖输入)

指令方式实现 v-debounce:

// 指令方式
const vDebounce = {
  mounted(el, binding) {
    const delay = Number(binding.arg) || 300
    const handler = binding.value
    let timer

    el.__debounceHandler = (event) => {
      clearTimeout(timer)
      timer = setTimeout(() => handler(event), delay)
    }

    el.addEventListener('input', el.__debounceHandler)
  },

  unmounted(el) {
    clearTimeout(el.__debounceTimer)
    el.removeEventListener('input', el.__debounceHandler)
    delete el.__debounceHandler
  }
}

实战四:v-lazy(图片懒加载)

import { useIntersectionObserver } from '@vueuse/core'

const vLazy = {
  mounted(el, binding) {
    const src = binding.value
    el.dataset.src = src

    // 使用 Intersection Observer
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.src = el.dataset.src
          el.onload = () => el.classList.add('loaded')
          observer.unobserve(el)
        }
      },
      { rootMargin: '100px' }
    )

    observer.observe(el)
    el.__lazyObserver = observer
  },

  unmounted(el) {
    if (el.__lazyObserver) {
      el.__lazyObserver.unobserve(el)
      delete el.__lazyObserver
    }
  }
}

八、插件开发

8.1 插件结构:install() 函数

Vue 插件是一个拥有 install 方法的对象,或是本身作为函数的构造器。install 方法接收 app 实例和用户传入的选项。

8.2 提供全局方法、指令、组件

8.3 发布插件到 npm

插件项目结构:

vue-notification-plugin/
├── src/
│   ├── components/
│   │   └── Notification.vue
│   ├── directives/
│   │   └── index.js
│   ├── composables/
│   │   └── useNotification.js
│   ├── index.js          # 插件入口
│   └── styles/
│       └── notification.css
├── dist/                 # 构建产物
├── package.json
├── vite.config.js
└── README.md

package.json 配置:

{
  "name": "vue-notification-plugin",
  "version": "1.0.0",
  "description": "Vue 3 通知插件",
  "type": "module",
  "main": "./dist/vue-notification-plugin.umd.cjs",
  "module": "./dist/vue-notification-plugin.js",
  "exports": {
    ".": {
      "import": "./dist/vue-notification-plugin.js",
      "require": "./dist/vue-notification-plugin.umd.cjs"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "scripts": {
    "build": "vite build",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "vue": "^3.3.0"
  },
  "keywords": ["vue", "vue3", "notification", "toast"],
  "license": "MIT"
}

Vite 构建配置(库模式):

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

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.js'),
      name: 'VueNotificationPlugin',
      fileName: 'vue-notification-plugin'
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

插件使用文档(README.md 节选):

# Vue Notification Plugin

## 安装

```bash
npm install vue-notification-plugin

使用

import { createApp } from 'vue'
import NotificationPlugin from 'vue-notification-plugin'
import 'vue-notification-plugin/style.css'

const app = createApp(App)
app.use(NotificationPlugin, {
  duration: 3000,
  position: 'top-right'
})
app.mount('#app')

API

// 在任意组件中
this.$notify.info('这是一条信息')
this.$notify.success('操作成功')
this.$notify.warning('请谨慎操作')
this.$notify.error('操作失败')

**发布到 npm:**

```bash
# 登录 npm
npm login

# 构建
npm run build

# 发布
npm publish

# 更新补丁版本
npm version patch
npm publish

# 更新小版本
npm version minor
npm publish

总结

本文从 Vue 组件化开发的八大核心方向进行了系统性的深入讲解:

主题 核心要点
组件基础 Props 类型验证、Emits 声明与验证、多 v-model 绑定、自定义修饰符
组件通信 八种通信方式(Props → Emits → Provide/Inject → Event Bus → Pinia → Template Ref → Expose),各有适用场景
插槽机制 默认插槽、具名插槽、作用域插槽、动态插槽名、默认内容,是实现灵活组件复用的核心武器
动态/异步组件 <component :is> 动态切换、defineAsyncComponent 代码分割、Suspense 异步编排
高阶组件模式 渲染函数 h()、JSX、函数式组件、HOC 模式及与组合式函数的对比取舍
组件设计模式 容器/展示分离、复合组件、受控/非受控、单一职责原则——这些是构建可维护组件架构的指导思想
自定义指令 完整的生命周期钩子、四个实战指令(focus、click-outside、debounce、lazy-load)
插件开发 从 install 函数到注册组件/指令/全局方法,再到发布 npm 包的完整流程

核心推荐:

  • 使用 组合式函数(Composables) 替代大部分 HOC 和 mixin 的场景
  • 使用 TypeScript + <script setup> 获得最佳开发体验
  • 插槽优先:在设计可复用组件时,优先考虑插槽方案而非过多的配置 props
  • 按需加载:大型应用的路由组件、对话框组件等都应使用 defineAsyncComponent 实现懒加载
  • 关注点分离:坚持容器/展示分离,保持组件单一职责

Vue 的组件系统是一个层次丰富、设计优雅的架构体系。深入理解这些机制,将帮助你构建出可维护性高、复用性强、性能优秀的前端应用。