一、组件基础
组件是 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 命名组件,原因有三:
- 与 JavaScript 类名/构造函数命名风格一致
- 在模板中直观区分原生 HTML 元素(全小写)与 Vue 组件
- 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 支持事件参数验证。
<!-- SearchInput.vue -->
<template>
<div class="search-box">
<input
:value="modelValue"
@input="onInput"
@keydown.enter="onSearch"
:placeholder="placeholder"
/>
<button @click="onSearch">搜索</button>
</div>
</template>
<script setup>
defineProps({
modelValue: String,
placeholder: { type: String, default: '请输入关键词' }
})
const emit = defineEmits({
// 无验证
'update:modelValue': null,
// 带参数验证
search(payload) {
if (!payload.keyword || payload.keyword.length < 2) {
console.warn('搜索关键词至少需要 2 个字符')
return false
}
return true
}
})
function onInput(e) {
emit('update:modelValue', e.target.value)
}
function onSearch() {
emit('search', { keyword: props.modelValue, timestamp: Date.now() })
}
</script>父组件监听事件:
<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 触发自定义事件,父组件监听事件来获取数据。
<!-- Child.vue -->
<template>
<button @click="sendData">向父组件发送数据</button>
</template>
<script setup>
const emit = defineEmits(['data-sent'])
function sendData() {
const data = { id: Date.now(), text: '来自子组件的数据' }
emit('data-sent', data)
}
</script>
<!-- Parent.vue -->
<template>
<Child @data-sent="handleData" />
<div v-if="receivedData">
收到数据:{{ receivedData }}
</div>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const receivedData = ref(null)
function handleData(data) {
receivedData.value = data
}
</script>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 逐级透传"问题的优雅方案。
基础用法:
<!-- 祖先组件:App.vue -->
<template>
<div id="app">
<h1>主题:{{ theme }}</h1>
<button @click="toggleTheme">切换主题</button>
<Parent />
</div>
</template>
<script setup>
import { ref, provide } from 'vue'
import Parent from './Parent.vue'
const theme = ref('light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 提供响应式数据
provide('theme', theme)
// 提供方法
provide('toggleTheme', toggleTheme)
</script>
<!-- 深层后代:DeepChild.vue -->
<template>
<div :class="theme">
<p>当前主题:{{ theme }}</p>
<button @click="toggleTheme">切换</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数是默认值
const toggleTheme = inject('toggleTheme')
</script>使用 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 可以显式控制暴露内容:
<!-- ChildComponent.vue -->
<template>
<div>
<input ref="inputRef" v-model="text" />
<p>{{ internalData }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
const text = ref('')
const internalData = ref('内部数据')
function someMethod() {
console.log('方法被调用')
}
function focusInput() {
inputRef.value?.focus()
}
const publicCount = ref(42)
// 仅暴露以下内容给父组件
defineExpose({
someMethod,
focusInput,
publicCount
// internalData 不会被暴露
})
</script>非 <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)指定内容插入目标。
<!-- PageLayout.vue -->
<template>
<div class="layout">
<header class="header">
<slot name="header">
<h1>默认标题</h1>
</slot>
</header>
<main class="main">
<slot />
</main>
<aside class="sidebar">
<slot name="sidebar" />
</aside>
<footer class="footer">
<slot name="footer" />
</footer>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-areas:
"header header"
"main sidebar"
"footer footer";
grid-template-columns: 1fr 300px;
gap: 16px;
}
.header { grid-area: header; }
.main { grid-area: main; }
.sidebar{ grid-area: sidebar; }
.footer { grid-area: footer; }
</style>使用具名插槽:
<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.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="row.id">
<td v-for="col in columns" :key="col.key">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row[col.key]"
:rowIndex="rowIndex"
>
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({
columns: Array,
data: Array
})
</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 插槽实战
实战一:可复用列表渲染
<!-- ReusableList.vue -->
<template>
<div class="reusable-list">
<div v-if="loading" class="loading">
<slot name="loading">
<p>加载中...</p>
</slot>
</div>
<div v-else-if="error" class="error">
<slot name="error" :error="error">
<p>加载失败:{{ error.message }}</p>
<button @click="$emit('retry')">重试</button>
</slot>
</div>
<div v-else-if="items.length === 0" class="empty">
<slot name="empty">
<p>暂无数据</p>
</slot>
</div>
<div v-else class="list-container">
<slot name="header" :count="items.length" />
<div v-for="(item, index) in items" :key="item.id" class="list-item">
<slot name="item" :item="item" :index="index" />
</div>
<slot name="footer" :count="items.length" />
</div>
</div>
</template>
<script setup>
defineProps({
items: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
error: { type: [Error, null], default: null }
})
defineEmits(['retry'])
</script>实战二:灵活表格列自定义
<!-- SmartTable.vue -->
<template>
<table class="smart-table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<slot :name="`header-${col.key}`" :column="col">
{{ col.label }}
</slot>
</th>
<th v-if="$slots['actions-header'] || $slots['actions-cell']">
<slot name="actions-header">操作</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="row.id" @click="$emit('row-click', row)">
<td v-for="col in columns" :key="col.key">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row[col.key]"
:rowIndex="rowIndex"
>
{{ formatCell(col, row) }}
</slot>
</td>
<td v-if="$slots['actions-header'] || $slots['actions-cell']">
<slot name="actions-cell" :row="row" :rowIndex="rowIndex" />
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({
columns: { type: Array, required: true },
data: { type: Array, required: true }
})
defineEmits(['row-click'])
function formatCell(column, row) {
const value = row[column.key]
if (column.formatter) return column.formatter(value, row)
if (value === null || value === undefined) return '—'
return String(value)
}
</script>实战三:布局组件
<!-- SplitPane.vue -->
<template>
<div :class="['split-pane', `split-${direction}`]" :style="containerStyle">
<div class="pane pane-left" :style="leftStyle">
<slot name="left" />
</div>
<div
class="divider"
@mousedown="startResize"
:style="dividerStyle"
>
<slot name="divider-handle">⋮</slot>
</div>
<div class="pane pane-right" :style="rightStyle">
<slot name="right" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
direction: { type: String, default: 'horizontal' }, // horizontal | vertical
defaultRatio: { type: Number, default: 0.5 },
minRatio: { type: Number, default: 0.2 },
maxRatio: { type: Number, default: 0.8 }
})
const ratio = ref(props.defaultRatio)
const isResizing = ref(false)
const containerStyle = computed(() => ({
display: 'flex',
flexDirection: props.direction === 'horizontal' ? 'row' : 'column'
}))
const leftStyle = computed(() => ({
flex: `${ratio.value}`,
overflow: 'auto'
}))
const rightStyle = computed(() => ({
flex: `${1 - ratio.value}`,
overflow: 'auto'
}))
const dividerStyle = computed(() => ({
cursor: props.direction === 'horizontal' ? 'col-resize' : 'row-resize',
flex: '0 0 8px',
background: '#eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none'
}))
function startResize(e) {
isResizing.value = true
const startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const startRatio = ratio.value
const container = e.target.parentElement
const containerSize = props.direction === 'horizontal'
? container.offsetWidth
: container.offsetHeight
function onMouseMove(e) {
if (!isResizing.value) return
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const delta = (currentPos - startPos) / containerSize
ratio.value = Math.min(props.maxRatio, Math.max(props.minRatio, startRatio + delta))
}
function onMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>四、动态组件与异步组件
4.1 <component :is="...">
动态组件允许在同一个挂载点动态切换不同组件:
<template>
<div class="tab-container">
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.name"
@click="activeTab = tab.name"
:class="{ active: activeTab === tab.name }"
>
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<!-- 动态切换组件 -->
<component :is="activeComponent" v-bind="componentProps" @close="handleClose" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import TabHome from './TabHome.vue'
import TabSettings from './TabSettings.vue'
import TabProfile from './TabProfile.vue'
const tabs = [
{ name: 'home', label: '首页', component: TabHome },
{ name: 'settings', label: '设置', component: TabSettings },
{ name: 'profile', label: '个人', component: TabProfile }
]
const activeTab = ref('home')
const activeComponent = computed(() => {
const tab = tabs.find(t => t.name === activeTab.value)
return tab ? tab.component : null
})
const componentProps = computed(() => ({
tabName: activeTab.value,
timestamp: Date.now()
}))
function handleClose() {
activeTab.value = 'home'
}
</script>与 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')
)完整配置选项:
<template>
<div>
<AsyncUserProfile :userId="currentUserId" />
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorState from './ErrorState.vue'
const AsyncUserProfile = defineAsyncComponent({
// 工厂函数:返回 Promise
loader: () => import('./UserProfile.vue'),
// 加载过程中显示的组件
loadingComponent: LoadingSpinner,
// 加载失败时显示的组件
errorComponent: ErrorState,
// 延迟显示加载组件的时间(默认 200ms),防止闪烁
delay: 200,
// 超时时间,超时后显示 errorComponent
timeout: 10000,
// 是否支持重试
suspensible: false,
// 加载错误时的回调
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
console.log(`重试第 ${attempts} 次...`)
retry()
} else {
console.error('组件加载失败')
fail()
}
}
})
const currentUserId = ref(1)
</script>路由懒加载——最常见的异步组件场景:
// 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 示例——递归树组件:
// TreeNode.jsx
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'TreeNode',
props: {
node: { type: Object, required: true },
depth: { type: Number, default: 0 }
},
setup(props) {
const expanded = ref(props.depth < 1)
const hasChildren = () =>
props.node.children && props.node.children.length > 0
const toggle = () => {
expanded.value = !expanded.value
}
return () => (
<div class="tree-node" style={{ paddingLeft: `${props.depth * 20}px` }}>
<div class="node-content" onClick={toggle}>
{hasChildren() && (
<span class="toggle-icon">{expanded.value ? '▼' : '▶'}</span>
)}
<span class="node-label">{props.node.label}</span>
{props.node.type === 'file' && (
<span class="file-size">({props.node.size} KB)</span>
)}
</div>
{hasChildren() && expanded.value && (
<div class="children">
{props.node.children.map(child => (
<TreeNode node={child} depth={props.depth + 1} />
))}
</div>
)}
</div>
)
}
})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 发送事件,不含业务逻辑。
<!-- 展示组件:UserListDisplay.vue -->
<template>
<div class="user-list">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id" @click="$emit('select', user)">
{{ user.name }} - {{ user.email }}
</li>
</ul>
</div>
</template>
<script setup>
defineProps({
users: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
defineEmits(['select'])
</script>
<!-- 容器组件:UserListContainer.vue -->
<template>
<UserListDisplay
:users="users"
:loading="loading"
:error="error"
@select="handleSelect"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import UserListDisplay from './UserListDisplay.vue'
import { fetchUsers } from '@/api/users.js'
const users = ref([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
users.value = await fetchUsers()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
function handleSelect(user) {
// 业务逻辑:导航、存储、调用 API 等
console.log('选中用户:', user.id)
}
</script>6.2 渲染组件 vs 逻辑组件
渲染组件负责渲染 UI,逻辑组件封装可复用的逻辑(通常通过组合式函数实现)。
// 逻辑组件:usePagination.js(组合式函数)
import { ref, computed } from 'vue'
export function usePagination(data, pageSize = 10) {
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil(data.value.length / pageSize)
)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize
return data.value.slice(start, start + pageSize)
})
function goToPage(page) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
function nextPage() { goToPage(currentPage.value + 1) }
function prevPage() { goToPage(currentPage.value - 1) }
return {
currentPage,
totalPages,
paginatedData,
goToPage,
nextPage,
prevPage
}
}<!-- 使用逻辑组件的渲染组件 -->
<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>。
<!-- Select.vue(复合组件) -->
<template>
<div class="custom-select" :class="{ open: isOpen }">
<div class="trigger" @click="toggleOpen">
<slot name="trigger" :selected="selectedOption" :placeholder="placeholder">
<span v-if="selectedOption">{{ selectedOption.label }}</span>
<span v-else class="placeholder">{{ placeholder }}</span>
</slot>
<span class="arrow">▼</span>
</div>
<div v-if="isOpen" class="dropdown">
<slot name="options" :select="selectOption" :selectedValue="modelValue">
<div
v-for="option in options"
:key="option.value"
class="option"
:class="{ selected: modelValue === option.value }"
@click="selectOption(option)"
>
<slot name="option" :option="option" :selected="modelValue === option.value">
{{ option.label }}
</slot>
</div>
</slot>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: [String, Number],
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择' }
})
const emit = defineEmits(['update:modelValue', 'change'])
const isOpen = ref(false)
const selectedOption = computed(() =>
props.options.find(o => o.value === props.modelValue)
)
function toggleOpen() { isOpen.value = !isOpen.value }
function selectOption(option) {
emit('update:modelValue', option.value)
emit('change', option)
isOpen.value = false
}
// 点击外部关闭
function handleClickOutside(e) {
if (!e.target.closest('.custom-select')) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script><!-- 使用复合组件 -->
<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 或事件获取状态。
<!-- 受控输入框 -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<!-- 非受控输入框 -->
<template>
<input ref="inputRef" :defaultValue="defaultValue" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
defaultValue: { type: String, default: '' }
})
const inputRef = ref(null)
// 父组件通过 ref 调用此方法获取值
function getValue() {
return inputRef.value?.value || ''
}
function setValue(val) {
if (inputRef.value) inputRef.value.value = val
}
defineExpose({ getValue, setValue })
</script>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(防抖输入)
<!-- directives/debounce.js -->
<template>
<input
:value="modelValue"
@input="debouncedInput"
v-bind="$attrs"
/>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: [String, Number],
delay: { type: Number, default: 300 }
})
const emit = defineEmits(['update:modelValue', 'debounced-input'])
let timer = null
function debouncedInput(e) {
const value = e.target.value
emit('update:modelValue', value)
clearTimeout(timer)
timer = setTimeout(() => {
emit('debounced-input', value)
}, props.delay)
}
// 组件卸载时清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
clearTimeout(timer)
})
</script>指令方式实现 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 实例和用户传入的选项。
// plugins/notification.js
export default {
install(app, options = {}) {
const defaultOptions = {
duration: 3000,
position: 'top-right',
...options
}
// 注入全局方法
app.config.globalProperties.$notify = {
show(message, type = 'info') {
// 创建通知元素
const notification = document.createElement('div')
notification.className = `notification notification-${type}`
notification.textContent = message
notification.style.cssText = `
position: fixed;
${defaultOptions.position.split('-')[0]}: 20px;
${defaultOptions.position.split('-')[1] || 'right'}: 20px;
padding: 12px 20px;
border-radius: 6px;
color: white;
z-index: 9999;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
`
// 根据类型设置背景色
const colors = {
info: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
error: '#f56c6c'
}
notification.style.backgroundColor = colors[type] || colors.info
document.body.appendChild(notification)
// 入场动画
requestAnimationFrame(() => {
notification.style.opacity = '1'
notification.style.transform = 'translateY(0)'
})
// 自动移除
setTimeout(() => {
notification.style.opacity = '0'
notification.style.transform = 'translateY(-20px)'
setTimeout(() => document.body.removeChild(notification), 300)
}, defaultOptions.duration)
},
info(msg) { this.show(msg, 'info') },
success(msg) { this.show(msg, 'success') },
warning(msg) { this.show(msg, 'warning') },
error(msg) { this.show(msg, 'error') }
}
}
}8.2 提供全局方法、指令、组件
// plugins/ui-kit.js
import MyButton from '@/components/MyButton.vue'
import MyModal from '@/components/MyModal.vue'
import MyTooltip from '@/components/MyTooltip.vue'
export default {
install(app, options) {
// 1. 注册全局组件
app.component('MyButton', MyButton)
app.component('MyModal', MyModal)
app.component('MyTooltip', MyTooltip)
// 2. 注册全局指令
app.directive('focus', {
mounted(el) { el.focus() }
})
app.directive('tooltip', {
mounted(el, binding) {
el.title = binding.value
el.style.cursor = 'help'
}
})
// 3. 提供全局方法
app.config.globalProperties.$formatDate = (date, format = 'YYYY-MM-DD') => {
const d = new Date(date)
return format
.replace('YYYY', d.getFullYear())
.replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
.replace('DD', String(d.getDate()).padStart(2, '0'))
}
// 4. 提供全局 mixin(谨慎使用)
app.mixin({
created() {
// 所有组件都会执行
}
})
// 5. provide 全局依赖
app.provide('theme', options.theme || 'light')
app.provide('locale', options.locale || 'zh-CN')
}
}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.mdpackage.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 的组件系统是一个层次丰富、设计优雅的架构体系。深入理解这些机制,将帮助你构建出可维护性高、复用性强、性能优秀的前端应用。