前端知库

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" 的按钮,父组件这样用它:

vue
<!-- 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 被绑到了哪个元素:

vue
<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,运行时会发出警告:

vue
<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:

vue
<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 里声明它,透传就不会发生了。