在开发中,经常需要对原生 HTML 元素或第三方组件进行二次封装。比如你不想每个地方都重复写 <input class="my-input" placeholder="请输入..."> ,而是希望统一封装一个 <MyInput> 组件,既保留原生 input 的所有能力和事件,又可以加上统一的样式、校验等自定义逻辑。
这种场景的核心需求是:把父组件传进来的多余属性和事件原封不动地“透传”给包裹的底层元素。Vue 提供的 $attrs(以及 Vue 2 时代的 $listeners)就是专门解决这个问题的。
$attrs 里有什么?
$attrs 是一个组件实例上的对象,包含了父组件传递下来,但未在当前组件的 props 或 emits 中声明接收的所有属性和事件。包括:
- 普通的 HTML 属性,如
class、style、id、placeholder、disabled等。 - 自定义的事件监听器,如
@click、@focus、@input等。(在 Vue 3 中,事件监听器也包含在$attrs内) - 非 prop 的自定义数据属性,如
data-*。
Vue 3 与 Vue 2 的重要区别:
在 Vue 2 中,$attrs 不包含事件监听器,事件监听器单独存放在 $listeners 里。你往往需要同时写 v-bind="$attrs" 和 v-on="$listeners"。
Vue 3 做了简化,把所有东西都合并到了 $attrs 中。事件监听器也作为 onXxx 形式的属性存在其中(例如 onClick、onFocus)。
最简单的透传:v-bind="$attrs"
在模板中,可以直接用 v-bind="$attrs" 把当前组件接收到的所有额外属性/事件一次性绑定到内部元素上:
<!-- MyInput.vue —— 一个二次封装的输入框 -->
<template>
<div class="my-input-wrapper">
<label v-if="label">{{ label }}</label>
<!-- 核心透传:将所有外部属性/事件转给原生 input -->
<input v-bind="$attrs" />
</div>
</template>
<script setup>
// 只声明当前组件特有的 props
defineProps({
label: String
})
</script>
父组件使用时,就可以像用普通 <input> 一样用 <MyInput>:
<template>
<MyInput
label="用户名"
v-model="username"
placeholder="请输入用户名"
class="custom-input"
@focus="handleFocus"
/>
</template>
这个时候,placeholder、class、onFocus(即 @focus)以及 v-model 绑定的 onUpdate:modelValue 都不会被 MyInput 的 props 或 emits 声明捕获,它们会全部进入 $attrs,然后通过 v-bind="$attrs" 传递到内部的 <input /> 上。你无需在 MyInput 里为每一个可能用到的原生属性声明 props,这确保了封装的通用性和灵活性。
处理 v-model 透传
上例中 v-model 默认绑定 modelValue 属性和 onUpdate:modelValue 事件。由于我们没有在 MyInput 里声明 modelValue 为 prop,它也会被放入 $attrs 并透传给内部 <input>。但 v-model 需要让内部 input 的输入事件能更新这个值,原生 input 的 v-model 监听的是 input 事件和 value 属性,所以需要确保透传后能正常用。这样做通常已经能够工作,因为 v-model 在 input 上等价于 :value="modelValue" 和 @input="...",透传后内部 input 会收到这些绑定。
但如果你希望对输入值做更精细的控制(比如加一个 trim 处理),可以显式声明 modelValue 为 prop,并手动转发 @update:modelValue:
<template>
<div class="my-input-wrapper">
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
v-bind="$attrs"
/>
</div>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
注意 v-bind="$attrs" 里也可能包含 onUpdate:modelValue,如果你自己手动发射了事件,可能会出现重复调用。因此显式处理 v-model 时,最好将 modelValue 和 onUpdate:modelValue 从 $attrs 中排除,可以使用 computed 过滤,或者在模板中手动绑定除 modelValue 以外的属性。更简单的是,把 v-bind="$attrs" 放在 :value 和 @input 之后,以保持正确的绑定顺序;重复绑定同一个值通常不会有问题,但要注意。
控制透传行为:inheritAttrs
默认情况下,$attrs 中所有的属性会被自动继承到组件的根元素上(即模板最外层的那个元素)。在上面的例子中,<div class="my-input-wrapper"> 是根元素,这意味着如果父组件传了 class="custom-input",它会同时被应用到根 div 和内部 input 上。这通常不是我们想要的效果——我们只想让 input 接收这些属性。
通过设置 inheritAttrs: false 可以禁用自动继承,然后手动用 v-bind="$attrs" 显式选择要绑定的元素:
<template>
<div class="my-input-wrapper">
<input v-bind="$attrs" />
</div>
</template>
<script setup>
defineOptions({
inheritAttrs: false // 禁止自动挂到根 div 上
})
</script>
现在,父组件传的 class、style 等只会被透传到 <input> 上,而不会污染外层的 <div>。在二次封装 UI 组件时,这个设置几乎是必须的。
在 <script setup> 中使用 $attrs
在组合式 API 的 <script setup> 中,可以用 useAttrs() 获取 $attrs 对象:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
// 你可以在这里对 attrs 做过滤、计算等操作
</script>
useAttrs() 返回的是响应式的对象,但需要注意它不是深度响应式的,只跟踪引用变化。你可以在需要时将其转换为普通对象用于逻辑判断。
二次封装组件的典型模式
利用 $attrs 和插槽,可以快速构建“薄封装”组件:
- 禁用自动继承(
inheritAttrs: false)。 - 只声明当前组件特有的 props(如
label、error)。 - 用
v-bind="$attrs"把剩余所有属性/事件透传给目标子组件或原生元素。 - 用
<slot>支持内容分发。
这样封装出的组件既保持了与原组件/原生元素几乎相同的 API,又平添了新的业务特性,极大减少了重复代码和后续维护成本。