Vue组件系统
目录
组件基础
组件概念
组件是可复用的Vue实例,每个组件都有自己的模板、数据、方法、计算属性等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌─────────────────────────────────────┐ │ Vue组件 │ ├─────────────────────────────────────┤ │ ┌─────────────────────────────────┐│ │ │ Template (HTML模板) ││ │ └─────────────────────────────────┘│ │ ┌─────────────────────────────────┐│ │ │ Script (逻辑) ││ │ │ - data ││ │ │ - methods ││ │ │ - computed ││ │ │ - watch ││ │ └─────────────────────────────────┘│ │ ┌─────────────────────────────────┐│ │ │ Style (CSS样式) ││ │ └─────────────────────────────────┘│ └─────────────────────────────────────┘
|
组件注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| Vue.component('my-component', { template: '<div>全局组件</div>', data() { return { message: 'Hello' } } })
import { createApp } from 'vue'
const app = createApp({}) app.component('MyComponent', { template: '<div>全局组件</div>' }) app.mount('#app')
<script> import ChildComponent from './ChildComponent.vue'
export default { components: { ChildComponent } } </script>
|
组件命名规范
| 方式 |
示例 |
说明 |
| kebab-case |
my-component |
HTML中推荐 |
| PascalCase |
MyComponent |
JS中推荐 |
| 浏览器兼容 |
my-component |
自动转换 |
1 2 3 4 5 6 7 8
| <my-component></my-component> <MyComponent></MyComponent>
<script type="text/x-template"> <MyComponent></MyComponent> </script>
|
组件使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <!-- MyButton.vue --> <template> <button class="btn" @click="handleClick"> {{ text }} </button> </template>
<script> export default { name: 'MyButton', props: { text: { type: String, default: '按钮' } }, methods: { handleClick() { this.$emit('click') } } } </script>
<style scoped> .btn { padding: 8px 16px; background: #409eff; color: white; border: none; border-radius: 4px; } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <template> <div> <my-button text="提交" @click="handleSubmit"></my-button> </div> </template>
<script> import MyButton from './MyButton.vue'
export default { components: { MyButton }, methods: { handleSubmit() { console.log('提交成功') } } } </script>
|
组件通信
父子组件通信
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌─────────────────────────────────────────────────────┐ │ 父子组件通信 │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ Parent │ props │ Child │ │ │ │ │ ───────> │ │ │ │ │ message │ │ message │ │ │ │ "Hello" │ │ props │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ Parent │ <─────── │ Child │ │ │ │ │ $emit │ │ │ │ │ handle │ events │ $emit() │ │ │ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────┘
|
Props向下传递
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!-- Parent.vue --> <template> <child-component :message="message" :count="count" :user="user" title="标题" ></child-component> </template>
<script> export default { data() { return { message: 'Hello', count: 0, user: { name: '张三', age: 18 } } } } </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <!-- Child.vue --> <template> <div> <p>{{ message }}</p> <p>{{ count }}</p> <p>{{ user.name }}</p> </div> </template>
<script> export default { // Vue2 props: { message: String, count: { type: Number, required: true, default: 0 }, user: { type: Object, default() { return { name: '', age: 0 } } }, title: { type: String, default: '默认标题' } }
// Vue3 - 使用defineProps // props: { // message: String, // count: Number // } } </script>
|
Props类型验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| props: { message: String, count: Number, isValid: Boolean, ids: Array, config: Object }
props: { value: [String, Number] }
props: { requiredProp: { type: String, required: true } }
props: { defaultProp: { type: String, default: '默认值' } }
props: { list: { type: Array, default() { return [] } }, config: { type: Object, default() { return {} } } }
props: { level: { type: Number, validator(value) { return [1, 2, 3].includes(value) } } }
|
$emit向上传递
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <!-- Child.vue --> <template> <div> <button @click="handleClick">点击</button> <input v-model="inputValue" @input="handleInput"> </div> </template>
<script> export default { data() { return { inputValue: '' } }, methods: { handleClick() { this.$emit('custom-event', '参数1', '参数2') }, handleInput() { this.$emit('update:value', this.inputValue) } } } </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <!-- Parent.vue --> <template> <child-component @custom-event="handleEvent" @update:value="handleUpdate" ></child-component> </template>
<script> export default { methods: { handleEvent(arg1, arg2) { console.log('接收到:', arg1, arg2) }, handleUpdate(value) { console.log('更新值:', value) } } } </script>
|
v-model实现双向绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!-- Parent.vue --> <template> <!-- 默认情况下,v-model使用value prop和input事件 --> <custom-input v-model="searchText"></custom-input>
<!-- 等价于 --> <custom-input :value="searchText" @input="searchText = $event" ></custom-input> </template>
<script> export default { data() { return { searchText: '' } } } </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!-- Child.vue - 自定义v-model --> <template> <input :value="value" @input="$emit('input', $event.target.value)" > </template>
<script> export default { props: { value: String } } </script>
|
双向绑定多个值
1 2 3 4 5 6 7 8 9
| <!-- Parent.vue --> <template> <!-- Vue2.5+ 支持多个v-model --> <user-form v-model:name="user.name" v-model:email="user.email" v-model:age="user.age" ></user-form> </template>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <!-- Child.vue --> <template> <div> <input :value="name" @input="$emit('update:name', $event.target.value)"> <input :value="email" @input="$emit('update:email', $event.target.value)"> <input :value="age" @input="$emit('update:age', $event.target.value)"> </div> </template>
<script> export default { props: { name: String, email: String, age: Number } } </script>
|
兄弟组件通信
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import Vue from 'vue' export const eventBus = new Vue()
import { eventBus } from './eventBus'
export default { methods: { sendMessage() { eventBus.$emit('message', 'Hello') } } }
import { eventBus } from './eventBus'
export default { mounted() { eventBus.$on('message', (msg) => { console.log('收到:', msg) }) }, beforeDestroy() { eventBus.$off('message') } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import mitt from 'mitt'
export const emitter = mitt()
import { emitter } from './eventBus'
emitter.emit('message', 'Hello')
import { emitter } from './eventBus'
emitter.on('message', (msg) => { console.log('收到:', msg) })
emitter.off('message', handler)
|
跨级组件通信
provide/inject
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <!-- Ancestor.vue --> <template> <parent-component></parent-component> </template>
<script> export default { // Vue2 provide() { return { theme: this.theme, user: this.user } }, data() { return { theme: 'dark', user: { name: '张三' } } }
// Vue3 // import { provide, ref } from 'vue' // setup() { // const theme = ref('dark') // provide('theme', theme) // } } </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!-- GrandChild.vue --> <template> <div :class="theme"> <p>主题: {{ theme }}</p> <p>用户: {{ user.name }}</p> </div> </template>
<script> export default { // Vue2 inject: ['theme', 'user']
// Vue3 // import { inject } from 'vue' // setup() { // const theme = inject('theme') // const user = inject('user') // return { theme, user } // } } </script>
|
provide/inject响应式
1 2 3 4 5 6 7 8 9 10 11 12 13
| provide() { return { theme: this.theme, user: this.user } }
inject: ['theme', 'user']
this.user.name = '李四'
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { provide, ref, reactive } from 'vue'
setup() { const theme = ref('dark') const user = reactive({ name: '张三' })
provide('theme', theme) provide('user', user)
function updateTheme() { theme.value = 'light' }
return { theme, user } }
import { inject } from 'vue'
setup() { const theme = inject('theme') const user = inject('user')
return { theme, user } }
|
插槽
插槽基础
1 2 3 4 5 6
| <!-- Child.vue --> <template> <div class="container"> <slot>默认内容</slot> </div> </template>
|
1 2 3 4 5 6
| <!-- Parent.vue --> <template> <child-component> <p>插槽内容</p> </child-component> </template>
|
具名插槽
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <!-- Child.vue --> <template> <div class="layout"> <header> <slot name="header"></slot> </header> <main> <slot></slot> <!-- 默认插槽 --> </main> <footer> <slot name="footer"></slot> </footer> </div> </template>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!-- Parent.vue --> <template> <child-component> <template #header> <h1>标题</h1> </template>
<template #default> <p>主要内容</p> </template>
<template #footer> <p>页脚</p> </template>
<!-- Vue2写法 --> <!-- <template slot="header"> --> <!-- <h1>标题</h1> --> <!-- </template> --> </child-component> </template>
|
作用域插槽
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!-- Child.vue - 数据在子组件 --> <template> <ul> <li v-for="item in items" :key="item.id" > <slot :item="item" :index="index"></slot> </li> </ul> </template>
<script> export default { data() { return { items: [ { id: 1, name: '苹果', price: 5 }, { id: 2, name: '香蕉', price: 3 } ] } } } </script>
|
1 2 3 4 5 6 7 8
| <!-- Parent.vue - 插槽内容由父组件决定 --> <template> <child-component> <template #default="{ item, index }"> <span>{{ index }}: {{ item.name }} - ¥{{ item.price }}</span> </template> </child-component> </template>
|
动态插槽名
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <!-- Vue3 --> <template> <child-component> <template #[dynamicSlotName]> 动态插槽内容 </template> </child-component> </template>
<script> export default { data() { return { dynamicSlotName: 'header' } } } </script>
|
插槽最佳实践
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!-- 组件设计 - 插槽模式 --> <!-- BaseModal.vue --> <template> <div class="modal"> <div class="modal-header"> <slot name="header"> <h3>默认标题</h3> </slot> <button class="close" @click="$emit('close')">×</button> </div> <div class="modal-body"> <slot></slot> </div> <div class="modal-footer"> <slot name="footer"> <button @click="$emit('close')">取消</button> <button @click="$emit('confirm')">确定</button> </slot> </div> </div> </template>
|
动态组件
component + is
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| <template> <div> <button v-for="tab in tabs" :key="tab" :class="{ active: currentTab === tab }" @click="currentTab = tab" > {{ tab }} </button>
<!-- 动态组件 --> <component :is="currentComponent"></component> </div> </template>
<script> import Home from './Home.vue' import About from './About.vue' import Contact from './Contact.vue'
export default { data() { return { currentTab: 'Home', tabs: ['Home', 'About', 'Contact'] } }, computed: { currentComponent() { const map = { 'Home': Home, 'About': About, 'Contact': Contact } return map[this.currentTab] } } } </script>
|
keep-alive缓存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <!-- 缓存非活动组件实例 --> <keep-alive> <component :is="currentComponent"></component> </keep-alive>
<!-- 缓存特定组件 --> <keep-alive include="Home,About"> <component :is="currentComponent"></component> </keep-alive>
<!-- 排除不缓存的组件 --> <keep-alive exclude="Contact"> <component :is="currentComponent"></component> </keep-alive>
<!-- 最大缓存数量 --> <keep-alive :max="10"> <component :is="currentComponent"></component> </keep-alive>
|
keep-alive生命周期
1 2 3 4 5 6 7 8 9 10 11
| export default { activated() { console.log('组件被激活') },
deactivated() { console.log('组件被缓存') } }
|
组件生命周期
完整生命周期
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| ┌─────────────────────────────────────────────────────────────┐ │ Vue2 生命周期 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ beforeCreate ──────┐ │ │ │ │ │ │ ▼ │ 实例初始化 │ │ created ───────────┘ │ │ │ │ ▼ │ beforeMount ──────┐ │ │ │ │ 模板编译 │ │ ▼ │ │ mounted ───────────┘ │ │ │ │ ▼ │ beforeUpdate ─────┐ │ │ │ DOM更新 │ │ ▼ │ │ updated ───────────┘ │ │ │ │ ▼ │ beforeDestroy ────┐ │ │ │ 组件销毁 │ │ ▼ │ │ destroyed ─────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ │ Vue3 生命周期 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ setup() ───────────────────────────────────────────────── │ │ │ │ │ ▼ │ │ onBeforeMount ─────┐ │ │ │ │ 模板编译 │ │ ▼ │ │ onMounted ──────────┘ │ │ │ │ │ ▼ │ │ onBeforeUpdate ────┐ │ │ │ │ DOM更新 │ │ ▼ │ │ onUpdated ──────────┘ │ │ │ │ │ ▼ │ │ onBeforeUnmount ────┐ │ │ │ 组件卸载 │ │ ▼ │ │ onUnmounted ────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
|
生命周期对比
| Vue2 |
Vue3 |
说明 |
| beforeCreate |
setup |
实例创建前,data/methods不可用 |
| created |
setup |
实例创建后,data/methods可用 |
| beforeMount |
onBeforeMount |
挂载前,模板编译完成 |
| mounted |
onMounted |
挂载后,DOM已渲染 |
| beforeUpdate |
onBeforeUpdate |
更新前,数据变化 |
| updated |
onUpdated |
更新后,DOM已更新 |
| beforeDestroy |
onBeforeUnmount |
销毁前,清理定时器/事件 |
| destroyed |
onUnmounted |
销毁后,组件实例已销毁 |
实际应用场景
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| export default { beforeCreate() { this.globalSettings = window.SETTINGS },
created() { this.fetchUserData()
this.timer = setInterval(() => {}, 1000)
window.addEventListener('resize', this.handleResize) },
mounted() { this.initChart()
this.$refs.chart.init() },
beforeDestroy() { clearInterval(this.timer)
window.removeEventListener('resize', this.handleResize)
this.cancelRequest() } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { onMounted, onBeforeUnmount, ref } from 'vue'
export default { setup() { const chartRef = ref(null)
onMounted(() => { chartRef.value.init() })
onBeforeUnmount(() => { chartRef.value.destroy() })
return { chartRef } } }
|
组件高级特性
递归组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <!-- TreeItem.vue - 递归组件 --> <template> <div class="tree-item"> <div @click="toggle"> <span v-if="hasChildren">▼</span> <span>{{ node.label }}</span> </div> <div v-if="isExpanded && hasChildren" class="children"> <tree-item v-for="child in node.children" :key="child.id" :node="child" ></tree-item> </div> </div> </template>
<script> export default { name: 'TreeItem', props: { node: { type: Object, required: true } }, data() { return { isExpanded: false } }, computed: { hasChildren() { return this.node.children && this.node.children.length > 0 } }, methods: { toggle() { if (this.hasChildren) { this.isExpanded = !this.isExpanded } } } } </script>
|
异步组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| const AsyncComponent = () => ({ component: import('./AsyncComponent.vue'), loading: LoadingComponent, error: ErrorComponent, delay: 200, timeout: 3000 })
export default { components: { AsyncComponent } }
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent({ loader: () => import('./AsyncComponent.vue'), loadingComponent: LoadingComponent, errorComponent: ErrorComponent, delay: 200, timeout: 3000, onError(error, retry, fail, attempts) { if (attempts < 3) { retry() } else { fail() } } })
|
依赖注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import { provide, inject, ref } from 'vue'
export default { setup() { const theme = ref('dark')
provide('theme', theme)
function updateTheme() { theme.value = 'light' }
return { theme, updateTheme } } }
export default { setup() { const theme = inject('theme')
const localTheme = inject('theme', 'default-theme')
return { theme, localTheme } } }
|
$refs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <!-- Parent.vue --> <template> <div> <!-- DOM元素引用 --> <input ref="inputRef" type="text">
<!-- 组件引用 --> <child-component ref="childRef"></child-component>
<button @click="focusInput">聚焦输入框</button> <button @click="callChildMethod">调用子组件方法</button> </div> </template>
<script> export default { methods: { focusInput() { this.$refs.inputRef.focus() }, callChildMethod() { this.$refs.childRef.childMethod() } } } </script>
|
$attrs与inheritAttrs
1 2 3 4 5 6 7 8 9 10
| <!-- Parent.vue --> <template> <child-component id="custom-id" class="custom-class" data-type="info" @click="handleClick" custom-prop="value" ></child-component> </template>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <!-- Child.vue --> <template> <!-- 使用$attrs渲染根元素 --> <div v-bind="$attrs"> <p>内容</p> </div>
<!-- 不使用$attrs --> <div> <p>内容</p> </div> </template>
<script> export default { // 默认行为 - $attrs会继承到根元素 // inheritAttrs: true
// 禁用继承 - $attrs不会自动添加到根元素 inheritAttrs: false,
mounted() { // $attrs包含所有非props属性 console.log(this.$attrs) // { // id: 'custom-id', // class: 'custom-class', // 'data-type': 'info', // custom-prop: 'value' // } } } </script>
|
总结
组件通信模式
| 模式 |
适用场景 |
Vue2 |
Vue3 |
| Props/$emit |
父子通信 |
✓ |
✓ |
| v-model |
双向绑定 |
✓ |
✓ |
| $refs |
父访问子 |
✓ |
✓ |
| eventBus |
兄弟/跨级 |
✓ |
✓ |
| provide/inject |
跨级通信 |
✓ |
✓ |
| Pinia/Vuex |
全局状态 |
✓ |
✓ |
组件设计原则
- 单一职责 - 每个组件只负责一个功能
- props接口 - 使用props定义组件的输入
- 事件接口 - 使用$emit定义组件的输出
- 插槽封装 - 使用插槽提高组件灵活性
- 命名规范 - 使用PascalCase或kebab-case
相关链接
- Vue基础入门
- [Vue3新特性与Composition API](Vue3新特性与Composition API.md)
- Vue路由