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


## 概述

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

但在真实项目中，组件层级往往很深：

```
App.vue
  └─ Layout.vue
       └─ Header.vue
            └─ UserAvatar.vue（需要用户信息）
```

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

这一篇讲几个进阶工具：

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

## provide / inject

### 解决 prop 逐层传递

`provide` 在祖先组件中提供数据，`inject` 在后代组件中接收：

```vue
<!-- App.vue（祖先） -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const user = ref({ name: '张三', role: 'admin' })
provide('user', user)
</script>
```

```vue
<!-- UserAvatar.vue（深层后代） -->
<script setup lang="ts">
import { inject } from 'vue'

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

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

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

> [!info] 说明
> provide 和 inject 之间的组件不需要做任何事，也不需要感知数据传递的存在。

### 保持响应式

`provide` 传递 `ref` 或 `reactive` 对象时，注入的数据也是响应式的：

```typescript
const count = ref(0)
provide('count', count)

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

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

```vue
<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`。

### 使用 Symbol 防止冲突

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

```typescript
// keys.ts
export const userKey = Symbol('user')
```

```typescript
import { userKey } from './keys'
provide(userKey, userData)
inject(userKey)
```

### app-level provide

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

```typescript
const app = createApp(App)
app.provide('theme', 'dark')
```

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

## 模板 ref

### 获取 DOM 元素

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

```vue
<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 引用。

> [!warning] 注意
> 模板 ref 必须在 `onMounted` 之后才能访问。在组件刚创建时 DOM 还不存在。

### 获取子组件实例

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

```vue
<template>
  <ChildComponent ref="child" />
</template>

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

const child = useTemplateRef('child')

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

### defineExpose

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

用 `defineExpose` 显式暴露：

```vue
<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function reset() {
  count.value = 0
}

defineExpose({ count, reset })
</script>
```

```vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const child = useTemplateRef('child')

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

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

## 组件上的 v-model

### 语法糖原理

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

```vue
<ChildComponent v-model="searchText" />
```

等价于：

```vue
<ChildComponent
  :modelValue="searchText"
  @update:modelValue="newValue => searchText = newValue"
/>
```

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

### 在子组件中使用

```vue
<!-- 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>
```

### 自定义 v-model 名称

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

```vue
<ChildComponent
  v-model:title="pageTitle"
  v-model:content="pageContent"
/>
```

子组件接收：

```vue
<script setup lang="ts">
defineProps(['title', 'content'])
defineEmits(['update:title', 'update:content'])
</script>
```

---

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

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

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

这个问题的答案是**路由（Router）**。


---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/%E7%BB%84%E4%BB%B6%E8%BF%9B%E9%98%B6%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E4%B8%8E%E6%A8%A1%E6%9D%BF%E5%BC%95%E7%94%A8/  

