父子组件通过 Props 和 Emits 通信很方便,但如果组件层级嵌套很深——比如祖父组件要给曾孙组件传数据,一层层转发 Props 会变成“传递地狱”,中间每一层都要声明自己并不关心的属性。Vue 提供了两种专门解决这类跨层级通信的方案:依赖注入(provide / inject) 和 属性透传($attrs)。
9.2.1 依赖注入:provide 与 inject
不管组件之间隔着多少层,只要祖先组件 provide(提供) 数据,后代组件就可以通过 inject(注入) 直接拿到,完全不需要中间层参与。
基本用法
<!-- 祖父组件 -->
<template>
<div>
<Parent />
</div>
</template>
<script setup>
import { provide } from 'vue'
const theme = 'dark'
provide('theme', theme)
</script>
<!-- 曾孙组件(中间可能隔着多层) -->
<template>
<div :class="theme">当前主题:{{ theme }}</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>
中间无论嵌套了多少层组件,曾孙组件都能直接拿到 theme。provide 的 key 建议使用 Symbol 或者常量 来避免命名冲突,尤其在大型项目中。
默认值与可选注入
如果提供方可能不存在,inject 可以设置默认值:
const theme = inject('theme', 'light') // 默认值 'light'
const theme = inject('theme', () => 'light') // 工厂函数形式(推荐,避免不必要的计算)
响应式处理
上面的例子有一个陷阱:如果提供的是一个普通字符串,后续修改 theme 的值,并不会触发注入方的更新。要想保持响应式,需要提供 响应式数据,或者提供 包含响应式数据的对象。
Vue 3 中推荐的做法是:提供一个 ref 或 reactive 对象,在注入方直接使用,修改时通过提供的方法。
<!-- 祖父组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // 直接提供 ref 本身
// 也可以提供修改的方法
const setTheme = (newVal) => {
theme.value = newVal
}
provide('setTheme', setTheme)
</script>
后代组件注入后,theme 就是一个 ref,可以正常使用 theme.value 读写,并且模板中自动解包:
<template>
<div>
<p>当前主题:{{ theme }}</p>
<button @click="setTheme('light')">切换浅色</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const setTheme = inject('setTheme')
</script>
另一种常见做法是提供 readonly 包装后的 ref,以防后代直接修改:
import { readonly } from 'vue'
provide('theme', readonly(theme))
适用场景
- 全局配置:主题、国际化语言、用户登录信息等。
- 需要被深层子组件消费的数据,且中间层没有使用的必要。
- 比全局状态管理(Pinia)更轻量,但只适合组件树内部共享,不适合跨页面的数据持久化。
注意事项
- 依赖注入会使得组件间的耦合度变高,后代组件需要明确自己依赖了哪个 provide 的 key,这部分关系不像 Props 那样显式。尽量在模块内使用常量管理这些 key,保持可追踪性。
- 如果注入的数据是可变的,尽量由提供方同时暴露修改方法(就像上面
setTheme),遵循“单向数据流”原则。
9.2.2 属性透传:$attrs
$attrs 是 Vue 提供的另一种跨层级通信方式,它主要用于 组件封装 场景:当你写一个包装组件,想自动把父组件传递的所有非 props 属性(包括 class、style 和事件)原封不动地传递给内部的原生元素或子组件。
在 Vue 3 中,$listeners 已经被合并到 $attrs 中,事件监听器会以 onXxx 的格式出现在 $attrs 里,因此透传时一并处理,无需额外操作。
典型场景:封装一个带样式的输入框
<!-- MyInput.vue -->
<template>
<div class="my-input-wrapper">
<input v-bind="$attrs" />
</div>
</template>
使用时,父组件可以像使用原生 <input> 一样给 MyInput 传任何属性和事件:
<template>
<MyInput
type="text"
placeholder="请输入用户名"
class="custom-class"
@focus="handleFocus"
/>
</template>
v-bind="$attrs" 会将 type、placeholder、class 以及 onFocus 全部绑定到内部的 <input> 上。这样封装后的组件对使用者来说就像直接用原生控件一样,无需重新定义一大堆 Props 和 Emits。
控制属性继承
默认情况下,组件的根元素会自动继承父组件传递的、且未在组件 Props 中声明的属性(比如 class、style)。如果不想让这些属性自动应用到根元素(尤其是根元素是容器 <div>,但你想把属性传给深层元素),可以设置 inheritAttrs: false,然后在模板中手动用 $attrs 绑定。
<script setup>
// 选项式:inheritAttrs: false
// 组合式:
defineOptions({ inheritAttrs: false })
</script>
<template>
<div class="wrapper">
<!-- 将 $attrs 显式绑定给目标元素 -->
<input v-bind="$attrs" />
</div>
</template>
获取 $attrs 的具体内容
在 <script setup> 中,可以用 useAttrs() 获得透传属性对象:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.class) // 'custom-class'
console.log(attrs.onFocus) // 父组件绑定的 focus 事件
</script>
适用场景
- 对原生 HTML 元素做轻量封装(按钮、输入框、链接等)。
- 组件库开发:让使用者可以无缝添加原生属性和事件,无需额外学习组件的 Props API。
- 跨层级透传属性:当中间组件只是“传递站”时,用
$attrs直接穿透,中间组件无需声明这些 Props。
总结对比
| 方案 | 适用场景 | 特点 |
|------|----------|------|
| provide / inject | 祖先向深层后代主动提供数据(配置、状态) | 显式指定数据,需要提前约定 key;可结合响应式实现动态更新 |
| $attrs | 封装原生元素或组件,透传所有未声明的属性和事件 | 自动化程度高,适合“透传”而非“定制” |
两种方案并非互斥,在实际开发中常常结合使用:比如用 provide 注入主题配置,同时用 $attrs 透传通用属性给底层 UI 组件。掌握它们,就能优雅地解决绝大多数跨层级通信问题。