「轻松搞懂」$attrs

能拿来做什么

禅语:如果不知道它能拿来做什么?那要“晓得”它来做什么?

简化属性(attribute)传递

当没有v-bind="$attrs"时,若一定要将事件绑定到子组件的非根元素上,你是否会想到这样做:

<!-- Father.vue -->
<template>
  <div>
    <Child class="new-child" @click="handleClick" />
  </div>
</template>

<script>
import Child from "./components/Child.vue";
export default {
  name: "App",
  components: {
    Child,
  },
  methods: {
    handleClick(e) {
      // 查看被绑定的元素是否正确
      console.log(e.currentTarget);
    },
  },
};
</script>
<!-- Child.vue -->
<template>
  <div class="child">
    <!-- 使用.stop 修饰符避免 事件冒泡 触发已经默认绑定到根元素的 click 事件 -->
    <button @click.stop="handleClick">Child btn</button>
  </div>
</template>

<script>
export default {
  methods: {
    handleClick(e) {
      // 触发 click 事件
      this.$emit("click", e);
    },
  },
};
</script>

若要使实现相对“完美”一点:1.不用被隐藏绑定事件在根元素上;2、不用.stop修饰符;可以给子组件添加inheritAttrs: false选项以取消属性的默认绑定:

<!-- Child.vue -->
<template>
  <div class="child">
    <!-- 使用.stop 修饰符避免 事件冒泡 触发已经默认绑定到根元素的 click 事件 -->
    <button @click="handleClick">Child btn</button>
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
  methods: {
    handleClick(e) {
      // 触发 click 事件
      this.$emit("click", e);
    },
  },
};
</script>










 








上述方案似乎是解决了属性绑定的问题,但过程非常繁琐!于是Vue提供了一个简便的属性$attrs来帮助简化这个过程:

注意

依然需要配置inheritAttrs: false选项,因为v-bind="$attrs"和属性的默认绑定是不冲突的。也就是若未配置,除了v-bind处有了绑定,根元素上也有一份本期望不被绑定的绑定。事件会在两个地方各被触发一次!

<template>
  <div class="child">
    <button v-bind="$attrs">Child btn</button>
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
};
</script>


 





 


多级组件间通信

<!-- Grandpa.vue -->
<template>
  <div>
    <Child @change="handleChange" />
  </div>
</template>

<script>
import Child from "./components/Child.vue";
export default {
  name: "App",
  components: {
    Child,
  },
  methods: {
    handleChange(v) {
      console.log("get change", v);
    },
  },
};
</script>
<!-- Father.vue -->
<template>
  <div class="child">
    <GrandSon v-bind="$attrs" />
  </div>
</template>

<script>
import GrandSon from "./GrandSon.vue";
export default {
  inheritAttrs: false,
  components: {
    GrandSon,
  },
};
</script>
<!-- GrandSon.vue -->
<template>
  <div class="grand-son">
    <input
      placeholder="please input value"
      type="text"
      @input="$emit('change', $event.target.value)"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
};
</script>

$attrs可以用来实现如爷孙之间的通信,我认为这算是一个黑魔法!若只是层级结构为爷-父-孙且内容结构简单还好,在更多的层级传递时,还需继续重复v-bind的书写步骤。

不仅如此,你应该也注意到,每个组件的内部,都重复性地添加了inheritAttrs: false配置,由于attributes的默认根节点绑定是隐式的,若这个属性忘记配置或有重复性的事件名在后期维护时被添加,那么会非常容易造成组件间的逻辑变得混乱,最终变成难以维护的情形!

尽量避免使用$attrs来实现组件间通信,你可以使用EventBusProvide\Inject等来实现同等需求!

做了哪些升级

$attrs在Vue3中有一些不兼容的更新:

  1. 移除了$listeners:使用过的肯定知道它通常和$attrs搭配着一起出现。现在它被移除了,并将其功能放入了$attrs中,也就是说现在应从$attrs中获取外部传入的事件函数
  2. Vue2中不能自定义classstyle位置的问题在Vue3中得以实现,现在它们都包含在了$attrs里;

参考资料:移除$listenersopen in new window$attrs包含class & styleopen in new window

非根节点的 attribute 绑定

父组件在使用子组件时传递的所有属性(attribute)默认情况下都绑定在子组件的根节点上:

<!-- father.vue -->
<template>
  <Child class="xxx" />
</template>

此时生成的HTML结果为:

<!-- Child.vue -->
<template>
  <!-- class 会合并 -->
  <div class="child xxx">
    <button>Child btn</button>
  </div>
</template>

提示

子组件的根节点默认拥有一个class的话,传入的class将与其合并,并不会覆盖!

需要注意哪些

<script setup>中使用inheritAttrs

额外添加一个包含inheritAttrs且默认导出的script,若使用了typescript,还需设置lang属性以避免报错:

<script lang="ts">
export default {
  inheritAttrs: false
}
</script>

<script setup lang="ts">
</script>

参考资料:与普通的<script>一起使用open in new window

多节点的attribute绑定问题

Vue3支持自定义组件可以有多个根节点,那这种情况就无法判断传入的属性(attributes)要绑定到哪里,需要自己手动指定绑定位置:

<!-- Father.vue -->
<Child @click="handleChange" />
<!-- Child.vue -->
<template>
  <header>header</header>
  <main v-bind="$attrs">main</main>
  <footer>footer</footer>
</template>



 


未手动指定将报以下警告提醒:

[Vue warn]: Extraneous non-emits event listeners (click) were passed to 
component but could not be automatically inherited because component renders
fragment or text root nodes. If the listener is intended to be a component 
custom event listener only, declare it using the "emits" option. 
at <Child onClick=fn<bound handleChange> > at <App> 

参考资料:多个根节点上的Attribute继承open in new window

自定义组件在emits声明事件

Vue3的设计中,所有的属性传入后都通过$attrs来获取,但有一个例外,在emits选项中声明过的事件将被排除在外,事件可以继续通过$emit('eventName',value)来进行通信!

其实这里涵盖了两个传递流程:

  1. 由外到内:外部属性(attributes)传入子组件;
  2. 由内到外:自定义组件内部定义要和外部通信的事件名并与外部通信;

开发时最好秉承这个思想,最好不要将用于事件通信的名称跟着$attrs到处传递,除非你想使用之前介绍的黑魔法。这就像是组件内部不要修改传入的props,即使你有手段做到这一点!

官方也给到了这份建议:建议定义所有发出的事件,以便更好地记录组件应该如何工作。

看个例子:

<!-- 引用自vue官方 -->
<template>
  <button v-on:click="$emit('click', $event)">OK</button>
</template>
<script>
export default {
  emits: [] // 不声明事件
}
</script>

谨记:所有的属性都会在没有配置inheritAttrs: false选项时被默认绑定根元素。

若未配置inheritAttrsemits未声明的结果就是一次emit('click',$event)产生两次click调用,这是非预期的!

或许你会觉得,配置了inheritAttrs: false不就可以解决这个问题了吗?是的,这可以解决,但违背了上述的思想,大家都本应各司其职,分工协作!下面将通过一个例子来讲解这个过程(这个例子并不完整,需要结合下文setup内获取的attrs非响应式阅读):

<!-- App.vue -->
<template>
  <div>
    <DatePicker
      v-model="show"
      :style="customStyle"
      size="large"
      @mouseover="handleMouseOver"
      @change="handleChange"
    />
  </div>
</template>

<script>
import DatePicker from "./components/DatePicker.vue";
export default {
  name: "App",
  components: {
    DatePicker,
  },
  data() {
    return {
      show: false,
      customStyle: {
        color: "skyblue",
      },
    };
  },
  methods: {
    handleChange(v) {
      console.log("get change", v);
    },
    handleMouseOver() {
      this.customStyle.color = "pink";
    },
  },
};
</script>
<!-- DatePicker.vue -->
<template>
  <div class="date-picker">
    <!-- $attrs 包含了 size、style、@mouseover -->
    <h2 v-bind="$attrs" @click="handClick">toggle popover</h2>
    <!-- 用 size 单独控制 p 样式 -->
    <p :class="[size]">hello world!</p>
    <!-- 注意:如果 Popover 内有一个 change 事件和外部通信,
    那么可以将 $attrs中change结构出来绑定传入 -->
    <Popover v-if="modelValue" />
    <!-- handleChange 中触发与父组件的通信 -->
    <button @click="handleChange">use change</button>
  </div>
</template>

<script>
import Popover from "./Popover.vue";
export default {
  inheritAttrs: false,
  // v-model 的事件也应被声明
  emits: ["change", "update:modelValue"],
  props: {
    modelValue: {
      type: Boolean,
      required: true,
    },
  },
  components: {
    Popover,
  },
  setup(props, context) {
    // attrs可以被解构,用于将不同的属性应用于不同的地方
    // 注意:context就是一个普通对象,不具有响应式
    const { size } = context.attrs;
    return { size };
  },
  methods: {
    handClick() {
      this.$emit("update:modelValue", !this.modelValue);
    },
    handleChange() {
      this.$emit("change", 1000);
    },
  },
};
</script>

<style scoped>
.date-picker p.large {
  font-size: 24px;
}
</style>




 

 













 












 














 



这里的自定义DatePicker组件模拟点击后Popover的显示隐藏以及鼠标划入时的样式更改。代码19行处,将changeupdate:modelValue这些和上一级组件通信的事件进行声明,这样它们就不会跟随$atrrs绑定到其他地方了!

可以在created生命周期和setup函数内将attrs解构获取非响应式的属性,这可将一些静态不变的内容解构出来提供给组件内部不同的元素使用!

参考资料:Vue3 emitsopen in new window

setup内获取的attrs非响应式

$attrs内的属性需要解构拆分,那就把响应式属性通过props传递,原因是$attrs本身是响应式的,但在setup(props,context) => context.attrsuseAttrs中获得的attrs都是普通非响应式对象!

继续结合上文自定义组件在emits声明事件中的代码例子。上例代码行5中,你是否注意到,使用的是$attrs,这会造成将所有的属性(除了emits中声明的和props声明的)绑定于此。除了行7因有需求使用了解构的size,行5却也多余绑定了!那你是否会自然想到size结构后,把剩余属性使用扩展运算符放到一个变量里面,就像这样:

<!-- 简化了部分代码 -->
<template>
  <div class="date-picker">
    <!-- rest 包含了 style、@mouseover,没有了 size -->
    <h2 v-bind="rest" @click="handClick">toggle popover</h2>
  </div>
</template>

<script>
  setup(props, context) {
    // attrs可以被解构,用于将不同的属性应用于不同的地方
    // 注意:context就是一个普通对象,不具有响应式
    const { size, ...rest } = context.attrs;
    return { size, rest };
  },
</script>




 







 



但这样做的结果就是,鼠标移入后,mouseover触发了,但h2样式却没有发生改变!原因就是context不是一个响应式对象,内部属性也不是!于是有了新的stylerest内的style还是旧的值,h2的字体的颜色因此不会发生变化。

那有没有办法处理这个问题?有,$attrs是响应式的,因此可以在onBeforeUpdate中获取其最新值:

<!-- 省略了部分代码 -->
<template>
  <div class="date-picker">
    <!-- rest 包含除了 size 的所有属性: style、@mouseover -->
    <h2 v-bind="rest" @click="handClick">toggle popover</h2>
    <!-- 用 s 单独控制 p 样式 -->
    <p :class="[s]">hello world!</p>
  </div>
</template>
<script>
  setup(props, context) {
    const rest = ref(context.attrs);
    const s = ref(context.attrs.size);
    // 每次更新的时候获取一下最新的 attrs
    onBeforeUpdate(() => {
      const { size, ...rest } = context.attrs;
      rest.value = rest;
      s.value = size;
    });
    return { s, rest };
  },
</script>











 
 
 
 
 
 
 




看完这段代码,是否心中有个想法:“这整太复杂了吧!”。确实是,所以千万不要写这样的代码出来!这也不是最佳解决方案。

响应式的属性用props

响应式的属性用props声明,它会自动从$attrs中剥离,这就像是emits一样。优化后的代码将会是这样:

<!-- 省略了部分代码 -->
<template>
  <div class="date-picker">
    <!-- $attrs 只包含 style、@mouseover -->
    <h2 v-bind="$attrs" @click="handClick">toggle popover</h2>
    <!-- 用 size 单独控制 p 样式 -->
    <p :class="[size]">hello world!</p>
  </div>
</template>
<script>
  emits: ["change", "update:modelValue"],
  props: {
    modelValue: {
      type: Boolean,
      required: true,
    },
    size: {
      type: String,
      default: "normal",
    },
  },
</script>




 

 









 
 
 
 


参考资料:Contextopen in new windowuseAttrsopen in new window示例代码open in new window

总结

看到现在,我们已经知道$attrs的优势在于可以简化属性的绑定,这在创建UI组件时尤为有用(vue文档也用select来举例),我们可以通过$attrs快速将诸如表单元素的clickfoucsblur等事件进行绑定,但对于可能的有响应式的变量属性需通过props来传入(被props声明过的将不再出现在$attrs中)。

因此,你要先确定这个响应式的变量是否需要从$attrs中被拆分,所以你需要明确属性的用途和可能发生的情形!但如果所有的属性都绑定在同一个元素上,那就直接v-bind="$attrs"享受它带来的便捷吧!

最近更新:
Contributors: untilthecore