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
// 全局注册 - Vue2
Vue.component('my-component', {
template: '<div>全局组件</div>',
data() {
return {
message: 'Hello'
}
}
})

// 全局注册 - Vue3
import { createApp } from 'vue'

const app = createApp({})
app.component('MyComponent', {
template: '<div>全局组件</div>'
})
app.mount('#app')

// 局部注册
// Parent.vue
<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>

<!-- 在字符串模板中推荐PascalCase -->
<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
// eventBus.js - Vue2
import Vue from 'vue'
export const eventBus = new Vue()

// ComponentA.vue
import { eventBus } from './eventBus'

export default {
methods: {
sendMessage() {
eventBus.$emit('message', 'Hello')
}
}
}

// ComponentB.vue
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
// Vue3 - 使用mitt库
import mitt from 'mitt'

export const emitter = mitt()

// ComponentA.vue
import { emitter } from './eventBus'

emitter.emit('message', 'Hello')

// ComponentB.vue
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
// Vue2 - 需要传入对象
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
// Vue3 - 使用ref/reactive
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 {
// Vue2
beforeCreate() {
// 1. 初始化非响应式数据
this.globalSettings = window.SETTINGS
},

created() {
// 2. 发起API请求
this.fetchUserData()

// 3. 设置定时器
this.timer = setInterval(() => {}, 1000)

// 4. 绑定事件
window.addEventListener('resize', this.handleResize)
},

mounted() {
// 5. DOM操作
this.initChart()

// 6. 第三方库初始化
this.$refs.chart.init()
},

beforeDestroy() {
// 7. 清理定时器
clearInterval(this.timer)

// 8. 移除事件监听
window.removeEventListener('resize', this.handleResize)

// 9. 取消未完成的请求
this.cancelRequest()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Vue3
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
// Vue2 - 工厂函数
const AsyncComponent = () => ({
component: import('./AsyncComponent.vue'),
loading: LoadingComponent,
error: ErrorComponent,
delay: 200,
timeout: 3000
})

export default {
components: {
AsyncComponent
}
}

// Vue3
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
// Vue3 Composition API
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 全局状态

组件设计原则

  1. 单一职责 - 每个组件只负责一个功能
  2. props接口 - 使用props定义组件的输入
  3. 事件接口 - 使用$emit定义组件的输出
  4. 插槽封装 - 使用插槽提高组件灵活性
  5. 命名规范 - 使用PascalCase或kebab-case

相关链接