记录一次用Vue3二次封装组件(干货)

1,355 阅读6分钟

前言

前段时间,在vue3中实现了对elementplus 中 table的改造,改成了符合业务需求的虚拟表格, 详情见 juejin.cn/post/704666… 为了能让这个功能更具有通用性,决定把这段代码抽离出来,二次封装成一个虚拟表格的组件,这里主要记录在vue3中通过二次封装elementplus的el-table组件的一些问题

开发通用组件一般需要注意的点

开发通用组件是很基础且重要的工作,通用组件必须具备高性能、低耦合的特性, 之前用vue3+web component 进行了对跨UI框架的组件的探索,详情见juejin.cn/post/721884… 不管是设计什么类型的组件,大部分都要考虑以下几个方面:

一、数据从父组件传入

为了解耦,子组件本身就不能生成数据。即使生成了,也只能在组件内部运作,不能传递出去。

父对子传参,就需要用到 props,但是通用组件的的应用场景比较复杂,对 props 传递的参数应该添加一些验证规则

二、在父组件处理事件

在通用组件中,通常会需要有各种事件,

比如复选框的 change 事件,或者组件中某个按钮的 click 事件

这些事件的处理方法应当尽量放到父组件中,通用组件本身只作为一个中转

三、记得留一个 slot

一个通用组件,往往不能够完美的适应所有应用场景

所以在封装组件的时候,只需要完成组件 80% 的功能,剩下的 20% 让父组件通过 solt 解决

在开发过程中,我们往往会碰到一些封装的ui组件,如弹窗往往需要传头部或者尾部来修改默认样式;表格框传一些需要添加的操作按钮。为了适应所有的场景,我们留一个插槽位置是需要的。

四、不要依赖 Vuex或pinia

父子组件之间是通过 props 和 自定义事件 来传参,非父子组件通常会采用 Vuex 传参

但是 Vuex 的设计初衷是用来管理组件状态,虽然可以用来传参,但并不推荐

因为 Vuex 类似于一个全局变量,会一直占用内存

在写入数据庞大的 state 的时候,就会产生内存泄露

五、合理运用 scoped 编写 CSS

在编写组件的时候,可以在 标签中添加 scoped,让标签中的样式只对当前组件生效

但是一味的使用 scoped,肯定会产生大量的重复代码

所以在开发的时候,应该避免在组件中写样式

当全局样式写好之后,再针对每个组件,通过 scoped 属性添加组件样式

二次封装组件一般需要注意的点

Vue中组件是一个独立的实例,每个组件都有共通点,就是:属性事件插槽方法

在日常我们使用第三方组件库的时候,组件库的文档都会说明上面四个特性,组件二次封装也是围绕这四个特性进行的;

实例

把代码抽离出来

image.png

使用ref和$refs获取当前组件实例的区别

refsetup()函数

  • 使用refsetup()函数结合的方式是Vue 3中的新特性。通过在setup()函数中创建ref引用,并将其绑定到DOM元素上,可以在组件内部直接访问和操作该引用。这种方式更加符合Vue 3的响应式数据处理机制,并且在组件内部具有更高的可控性和灵活性。
  • 适用场景:当需要在组件内部进行一系列复杂的操作时,比如触发其他响应式数据的变化、进行数据计算等,使用refsetup()函数的方式更加方便。

this.$refs与模板

  • 使用this.$refs与模板结合的方式是Vue 2中常用的方式。通过在模板中给元素添加ref属性,可以在组件实例中通过this.$refs访问到该引用。这种方式相对简洁,适用于快速获取DOM元素或组件实例,并进行简单的操作。
  • 适用场景:当只需要获取DOM元素或组件实例,并进行一些简单的操作时,比如获取值、调用方法等,使用this.$refs与模板的方式更加方便。

需要注意的是,在Vue 3中,推荐使用refsetup()函数的方式,因为它更符合Vue 3的设计思想,并且提供了更好的可维护性和扩展性。但是,如果你在使用Vue 2或有特定需求,仍然可以使用this.$refs与模板的方式。

继承原组件的 Attributes 属性和Event 事件

如果往新组件传入一些属性,并且想要将这些属性传给 el-table,最简单的方式就是在组件中一个个的去定义 props,然后再传给 el-table,但是这种方法非常麻烦,毕竟 el-table 就有三十几个属性(Attributes

image.png

事件也有十几二十个

image.png

这个时候可以使用 $attrs(属性透传)去解决这个问题,先来看下 Vue 官方文档对 $attrs 的解释:包含了父作用域中不作为组件 props 或自定义事件的 attribute 绑定和事件;当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定,并且可以通过 v-bind="$attrs" 传入内部的 UI 组件中——这在创建高阶的组件时会非常有用。

$attrs的几个注意点

  • 默认情况下,父组件传递的,但没有被子组件解析为 props 的 attributes 绑定会被 “透传”。这意味着当我们有一个单根节点的子组件时,这些绑定会被作为一个常规的 HTML attribute 应用在子组件的根节点元素上,当你编写的组件想要在一个目标元素或其他组件外面包一层时,可能并不期望这样的行为。

我们可以通过设置 inheritAttrsfalse 来禁用这个默认行为。这些 attributes 可以通过 $attrs 这个实例属性来访问,并且可以通过 v-bind 来显式绑定在一个非根节点的元素上。 下面来看一个具体的例子:

父组件:

<template>
    <div>
        <TestCom title="父组件给的标题" aa="我是aa" bb="我是bb"></TestCom>
    </div>
</template>
<script setup lang="ts">
import TestCom from "../../components/TestCom.vue"
</script>

子组件:

<template>
    <div class="root-son">
       <p>我是p标签</p>
       <span>我是span</span>
    </div>
</template>

image.png

因为在默认情况下,父组件的属性会直接渲染在子组件的根节点上,但是有些情况我们希望是渲染在指定的节点上,那怎么处理这问题呢?使用 $attrsinheritAttrs: false 就可以完美的解决这个问题。

<template>
    <div class="root-son">
        <p v-bind="$attrs">我是p标签</p>
        <span>我是span</span>
    </div>
</template>
<script lang="ts">
export default {
    inheritAttrs: false,
}
</script>

image.png

  • $attrs会默认传递所有属性,如果我们不希望透传某些属性比如class, 我们可以通过useAttrs来实现
<MyInput :size="inputSize" :name="userName" :clearable="clearable" ></MyInput>
<template>
  <div class="my-input">
    <el-input v-bind="filteredAttrs"></el-input>
  
    <!-- 如果不希望过滤掉某些属性 可以直接使用 $attrs -->
    <el-input v-bind="$attrs"></el-input>
  </div>
</template>

<script lang="ts" setup>
import {useAttrs,computed,ref } from 'vue'
import { ElInput } from 'element-plus'
defineOptions({
  name: 'MyInput'
})

// 接收 name,其余属性都会被透传给 el-input
defineProps({
  name: String
});

// 如果我们不希望透传某些属性比如class, 我们可以通过useAttrs来实现
const attrs = useAttrs()
const filteredAttrs = computed(() => {
  return { ...attrs, class: undefined };
});

当使用$attrs继承属性时,最好过滤掉class属性。这是因为class属性在Vue中有特殊的处理方式,它会被应用在父组件和子组件的根元素上,而不是作为普通的传递属性。

如果不过滤掉class属性,可能会导致以下问题:

  1. 类名重复:如果父组件和子组件都具有相同的class属性,那么它们会被同时应用在子组件的根元素上,可能导致类名冲突和样式覆盖问题。
  2. 样式继承:如果父组件的class属性包含了一些特定的样式规则,而子组件也继承了这些样式规则,可能会导致意外的样式继承效果,破坏子组件的独立性。
继承原组件的Methods方法

有些时候我们想要使用原组件的一些方法,比如 el-table 提供十几个方法,如何在父组件(也就是封装的组件)中使用这些方法呢?其实可以通过 ref 链式调用,比如 this.$refs.tableRef.$refs.table.clearSort(),但是这样太麻烦了,代码的可读性差;更好的解决方法:将所有的方法暴露出来,供父组件通过 ref 调用!

vue2和vue3继承方法的区别

在 Vue2 中,可以将 el-table 提供方法提取到实例上:

<template>
  <div class="my-table">
    <el-table ref="el-table"></el-table>
  </div>
</template>

<Script>
export default {
  mounted() {
    this.extendMethod()
  },
  methods: {
    extendMethod() {
      const refMethod = Object.entries(this.$refs['el-table'])
      for (const [key, value] of refMethod) {
        if (!(key.includes('$') || key.includes('_'))) {
          this[key] = value
      }
    }
  },
};
</Script>

image.png

<template>
  <MyTable ref="tableRef"></MyTable>
</template>

<Script>
export default {
  mounted() {
    console.log(this.$refs.tableRef.clearSort())
  }
};
</Script>

不过这种方式只能在options api中使用,因为在composition api中是没有this的;

对于setup语法,如果需要使用组件的方法,可以使用getCurrentInstance来获取到组件的实例,然后将方法挂载到exposed上;

在 Vue3 中的使用方法如下:

<template>
  <div class="my-table">
    <el-table ref="table"></el-table>
  </div>
</template>

<script setup>

const instance = getCurrentInstance();
const table = ref();

onMounted(() => { 
    const entries = Object.entries(table.value); 
    for (const [method, fn] of entries) { 
     if(typeof fn === 'function')
        instance.exposed[method] = fn; 
    } 
}); 
//在`setup`语法中如果需要暴露组件的内部方法,需要使用`defineExpose`来暴露;
defineExpose(expose);

<template>
  <MyTable ref="tableRef"></MyInput>
</template>

<script lang="ts" setup>
import { ref,onMounted } from 'vue'

const tableRef = ref()

onMounted(() => {
  console.log(tableRef.value);
  // 调用子组件中table的方法
  tableRef.value.clearSort()
})
</script>
继承原组件的 Slots

插槽也是一样的道理,比如 el-table 有两个倒不多,但是el-input就有4个 Slot,我们不应该在组件中一个个的去手动添加 <slot name="append">,<slot name="empty">,因此需要使用 $slots

image.png

在 Vue3 中,取消了作用域插槽 $scopedSlots,将所有插槽都统一在 $slots 当中:


<template>
  <div class="my-input">
    <el-table>
      <template #[slotName]="slotProps" v-for="(slot, slotName) in $slots" >
          <slot :name="slotName" v-bind="slotProps"></slot>
      </template>
    </el-table>
  </div>
</template>

完整代码

image.png

image.png

image.png

后记(高阶组件)

动态组件
<template>
  <div>
    <button @click="toggleComponent">Toggle Component</button>
    <component :is="currentComponent"></component>
  </div>
</template>

<script setup>
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';


const currentComponent = ref('ComponentA');

 const toggleComponent = () => {
      currentComponent.value = currentComponent.value === 'ComponentA' ? 'ComponentB' : 'ComponentA';
    };
  }
});
</script>
递归组件

之前写过低代码拖拽时的递归组件 见:juejin.cn/post/714787…