组件进阶:依赖注入与模板引用

第 1 节 概述

前几篇讲的 props 和事件,解决的是父子组件之间的通信。

但在真实项目中,组件层级往往很深:

1
2
3
4
App.vue
  └─ Layout.vue
       └─ Header.vue
            └─ UserAvatar.vue(需要用户信息)

如果只用 props,用户信息要从 App 一路传到 UserAvatar。中间组件不关心这个数据,只是帮忙传递。这叫做"prop 逐层传递"。

这一篇讲几个进阶工具:

  • provide / inject:跨层级直接传数据
  • 模板 ref:操作 DOM 或子组件
  • defineExpose:暴露子组件内部
  • v-model:在组件上实现双向绑定

第 2 节 provide / inject

2.1 解决 prop 逐层传递

provide 在祖先组件中提供数据,inject 在后代组件中接收:

1
2
3
4
5
6
7
<!-- App.vue祖先 -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const user = ref({ name: '张三', role: 'admin' })
provide('user', user)
</script>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- UserAvatar.vue深层后代 -->
<script setup lang="ts">
import { inject } from 'vue'

const user = inject('user')
</script>

<template>
  <p>{{ user.name }}</p>
</template>

不管隔了多少层,inject 都能直接拿到数据。

说明

provide 和 inject 之间的组件不需要做任何事,也不需要感知数据传递的存在。

2.2 保持响应式

provide 传递 refreactive 对象时,注入的数据也是响应式的:

1
2
3
4
const count = ref(0)
provide('count', count)

// 后代修改 count,祖先的视图也会更新

但更好的做法是,把修改逻辑放在 provide 的组件里,只暴露操作函数给后代:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script setup lang="ts">
import { provide, ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

provide('count', {
  count: readonly(count), // 防止子组件误修改
  increment,
})
</script>

这样后代可以调用 increment 来修改 count,但不能直接写 count.value = xxx

2.3 使用 Symbol 防止冲突

大型应用中,不同的 provide 可能用了相同的 key。可以用 Symbol 来避免冲突:

1
2
// keys.ts
export const userKey = Symbol('user')
1
2
3
import { userKey } from './keys'
provide(userKey, userData)
inject(userKey)

2.4 app-level provide

如果你的数据整个应用都需要,可以在 main.ts 中注入:

1
2
const app = createApp(App)
app.provide('theme', 'dark')

所有组件都可以直接 inject('theme')

第 3 节 模板 ref

3.1 获取 DOM 元素

有时你需要直接操作 DOM 元素,比如让输入框自动聚焦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

const input = useTemplateRef('my-input')

onMounted(() => {
  input.value?.focus()
})
</script>

<template>
  <input ref="my-input" type="text" />
</template>

ref 属性指定名称,useTemplateRef 用同一个名称获取 DOM 引用。

注意

模板 ref 必须在 onMounted 之后才能访问。在组件刚创建时 DOM 还不存在。

3.2 获取子组件实例

ref 也可以获取子组件实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <ChildComponent ref="child" />
</template>

<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

const child = useTemplateRef('child')

onMounted(() => {
  console.log(child.value) // 子组件实例
})
</script>

3.3 defineExpose

默认情况下,使用 <script setup> 的组件是私有的,父组件通过 ref 获取不到其内部的方法和数据。

defineExpose 显式暴露:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function reset() {
  count.value = 0
}

defineExpose({ count, reset })
</script>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const child = useTemplateRef('child')

function handleReset() {
  child.value?.reset()
}
</script>
说明

defineExpose 应该用在确实需要父组件调用的场景,比如表单组件的校验方法。一般来说,能用 props 和事件解决的,就不用 defineExpose

第 4 节 组件上的 v-model

4.1 语法糖原理

v-model 在组件上是 props + 事件 的语法糖:

1
<ChildComponent v-model="searchText" />

等价于:

1
2
3
4
<ChildComponent
  :modelValue="searchText"
  @update:modelValue="newValue => searchText = newValue"
/>

默认情况下,v-model 传递的 prop 名是 modelValue,更新事件是 update:modelValue

4.2 在子组件中使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function updateValue(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

<template>
  <input :value="modelValue" @input="updateValue" />
</template>

4.3 自定义 v-model 名称

如果要多个双向绑定,可以给 v-model 命名:

1
2
3
4
<ChildComponent
  v-model:title="pageTitle"
  v-model:content="pageContent"
/>

子组件接收:

1
2
3
4
<script setup lang="ts">
defineProps(['title', 'content'])
defineEmits(['update:title', 'update:content'])
</script>

这一篇介绍了三个进阶工具。

但到目前为止,我们一直在讨论一个页面内部的事情。

如果应用有多个页面,怎么在页面之间切换?用户点击导航栏,内容区域怎么变?

这个问题的答案是路由(Router)