Vue 的 $attrs、inheritAttrs 和透传属性是怎样工作的?
传给组件、却没有被声明成 props 或 emits 的属性和 v-on 监听器叫透传属性。组件只有一个根元素时,它们会自动加到根元素上,class 和 style 会和根元素原有的值合并。设置 inheritAttrs 为 false 可以关掉自动透传,再用 v-bind="$attrs" 绑到想要的元素上。多根节点组件不会自动透传,没有显式绑定时会有警告。
简答
封装一个
<MyButton>,使用的人写了<MyButton class="large" @click="onClick" />,可组件既没有声明class这个 prop,也没有声明click事件。最后渲染出来的<button>上却有class="large",点击也能触发onClick。这些没被声明的属性和监听器叫透传属性。组件只有一个根元素时,Vue 会把透传属性自动加到根元素上。
class和style会和根元素自己写的值合并,监听器如果根元素上也绑了同一个事件,两个都会触发。声明过的 props 和 emits 已经被组件“消费”掉,不在透传范围里。自动加在根元素上不一定合适。比如按钮外面包了一层
div做样式,属性应该落到里面的button上。这时设置inheritAttrs: false关掉自动透传,再在button上写v-bind="$attrs"。Vue 3.3 起可以在<script setup>里用defineOptions设置。要注意两点。多根节点组件不会自动透传,没有显式绑定
$attrs时运行时会警告。另外在 JavaScript 里用useAttrs()拿到的对象总是最新的,但不是响应式的,不能用watch监听它。
透传属性会落到哪里
假设 <MyButton> 的模板只有一个带 class="btn" 的按钮,父组件这样用它:
<!-- MyButton.vue -->
<template>
<button class="btn">点击</button>
</template>
<!-- 父组件 -->
<MyButton class="large" @click="onClick" />
渲染出来的 DOM 是 <button class="btn large">点击</button>。为什么 class 不是被覆盖,而是变成两个?因为 class 和 style 会和根元素已有的值合并,其他属性才是直接加上去。@click 也透传到了原生 button 上,点击时会调用父组件的 onClick;如果 button 自己也写了 @click,两个监听器都会执行。
什么属性不会透传?文档的说法是,声明成 props 的属性、声明在 emits 里的事件监听器,都已经被组件消费了,不会再往下传。所以想让组件自己处理 click,就在 emits 里声明它;想让它原样落到元素上,就不声明。props 应该怎样声明,见《为什么 Vue 提倡 props 单向数据流?》。
组件嵌套时属性怎么传
如果 <MyButton> 的根节点不是原生元素,而是另一个组件 <BaseButton>,透传属性会继续交给 <BaseButton>。传下去的是 <MyButton> 没消费掉的那部分,<BaseButton> 如果声明了其中某个 prop,就会把它当 prop 收下。
为什么要关掉自动透传
为了样式,按钮外面常常要套一层 div。这时属性自动落在 div 上就不对了,disabled、aria-label 应该在 button 上。下面要看 $attrs 被绑到了哪个元素:
<script setup>
defineOptions({
inheritAttrs: false,
});
</script>
<template>
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">点击</button>
</div>
</template>
inheritAttrs: false 让 Vue 不再往根元素上加,$attrs 里装着所有透传属性,包括 class、style、id 和 onClick 这样的监听器。v-bind 不带参数时会把一个对象的所有属性都绑到元素上,所以它们全部落到了 button 上。defineOptions 是 Vue 3.3 加入的;更早的版本要另写一个普通 <script> 块,在里面 export default { inheritAttrs: false }。
$attrs 里的属性名长什么样
和 props 不同,透传属性在 JavaScript 里保留原来的写法,不会转成驼峰。所以 foo-bar 要用 $attrs['foo-bar'] 读。@click 这样的监听器在对象里是一个函数,名字是 $attrs.onClick。
多根节点组件为什么会警告
单根组件的透传目标很明确,多根组件就不知道该放在哪个元素上。所以 Vue 不会自动透传,如果也没有显式绑定 $attrs,运行时会发出警告:
<template>
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
</template>
在其中一个根节点上写 v-bind="$attrs",告诉 Vue 透传属性放在这里,警告就没有了。
在 JavaScript 里读透传属性
<script setup> 里用 useAttrs(),不用 <script setup> 时用 setup(props, ctx) 的 ctx.attrs,选项式 API 里是 this.$attrs:
<script setup>
import { useAttrs } from 'vue';
const attrs = useAttrs();
</script>
这个对象总是反映最新的透传属性,但出于性能考虑,它不是响应式的,不能用 watch 监听它的变化。文档给了两个替代办法:需要响应式就把它声明成 prop;或者用 onUpdated(),在每次更新后拿最新的 attrs 做副作用。
常见追问
Vue 2 的 $listeners 去哪了?
Vue 3 把它合进了
$attrs。监听器在$attrs里以onXxx的形式出现,比如onClick,所以v-bind="$attrs"会把属性和监听器一起传下去。
inheritAttrs: false 会影响 class 和 style 吗?
会。在 Vue 3 里,
class和style也属于$attrs,关掉自动透传后它们不会再加到根元素上,要靠v-bind="$attrs"自己绑定。
为什么监听器被触发了两次?
常见原因是组件没在
emits里声明这个事件,它就成了透传监听器,被加到了根元素上;组件内部又在点击时emit了一次同名事件,父组件的处理函数就执行了两次。在emits里声明它,透传就不会发生了。