likes
comments
collection
share

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

作者站长头像
站长
· 阅读数 52

前言

我们经过前面的基础探索,从本章开始,我们将进入具体组件的实现原理探索。首先探索的是表单相关的组件,因为表单是我们使用得最多的组件了,所以我们很有必要了解它的实现原理。

我们首先进行 Button 组件的实现,Button 组件是基于原生的 HTML 的 <button> 标签实现的。在前后端未分离时代,button 标签往往是跟表单一起使用的,因为 button 标签上有很多跟表单进行组合使用的属性。在前后端分离的现在,肯定有很多不符合前后端分离使用习惯的属性,所以需要进行符合前后端分离使用习惯的特性进行封装 Button 组件。

在实现 Button 组件的过程中,我们将穿插讲解 Button 组件所涉及的一些 Vue3 知识。比如,编译宏相关的 API:defineProps、defineEmits 和 defineExpose,还有 useSlots 和 useAttrs,以及隔代组件之间如何通过 provide 和 inject 进行通讯,以及 toRef 和 toRefs 的实现原理。

HTML5 button 标签原生特性

提交表单:submit

在 HTML 中,表单是由 <form> 标签 来表示的,再配合一些信息输入标签,例如 <input> 标签 ,这样就可以搜集用户输入的信息了。在用户填完资料之后,可以通过设置 <button> 标签 的 type 属性为 submit,让用户点击 <button> 标签 进行提交资料。

<form>
  <input type="text" name="username" />
  <button type="submit">提交</button>
</form>

我们在浏览器上进行验证一下。

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们可以看到在填完相关信息,点击提交按钮之后,在地址栏和网络请求中都出现了我们填写的相关信息,这其实是 form 表单的默认提交方式 GET 请求的表现形式,当然还可以设置其他提交方式,比如 POST 方式,在前后端不分离时代,就是这样提交表单信息的。

虽然在前后端分离的今天,我们不再使用这种方式进行提交数据,但这种提交方式有一个好处,就是浏览器会在将请求发送给服务器之前触发 submit 事件。这样,我们就有机会验证表单数据,并根据验证结果决定是否允许提交表单数据。如果我们阻止这个事件的默认行为就可以取消表单提交。

在前后端分离的项目,我们还是比较少使用到这个属性的,但我们伟大的 Vue 框架是一款渐进式的框架,在一些还没进行前后端分离的项目也可以进行使用 Vue,同样也可以使用 Element UI,那么就有可能使用到这个属性了。

重置表单:reset

重置表单这个功能我们还是经常需要使用到的,比如我们设置一个有很多条件的搜索表单的功能时,我们通常就需要给用户一个重置表单的功能,以获得更好的用户体验。在原生 HTML 标签中我们只要将 button 标签的 type 的属性值设置为 reset 则可以创建一个重置按钮。

<form>
  <input type="text" name="username" />
  <button type="reset">重置</button>
  <button type="submit">提交</button>
</form>

在重置表单的时候,如果字段的初始值为空,则恢复为空,如果字段有默认值,则恢复为默认值。

disabled 属性

HTML 中的 input 标签、button 标签、option 标签等表单标签都具有一个 disabled 属性。当赋予该属性时该标签将变得不可交互。

为什么需要 button 标签?

其实上述的提交表单、重置表单以及 disabled 属性实现的不可交互功能,也是可以直接通过原生 JavaScript 来实现相关的功能。那么为什么还需要 button 标签呢?这是因为 button 标签,超链接 a 标签,input 标签等原生可以通过鼠标进行交互的标签,也都可以通过键盘进行交互。

我们通过下面的一段代码进行验证。

我们把上述代码通过浏览器打开之后,我们不使用鼠标进行操作,而是通过键盘进行操作。

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们发现第一个普通的 DIV 标签,虽然我们通过 CSS 样式使其变的像按钮,可以点击,并且也通过 JavaScript 实现了相关点击事件,但当我们通过键盘进行操作的时候,普通 DIV 标签却无法通过键盘进行操作。所以通过此实验我们可以发现 button 等原生标签可以与键盘进行交互,有着其他标签不可比拟的优势。所以我们在封装现代的组件当中我们同样要保留这些优势。所以即便可以通过其他标签和原生 JavaScript 实现按钮的相关的功能,但我们依然使用 button 原生标签进行封装。毕竟有很多人并不十分喜欢使用鼠标,而是希望通过键盘就能完成相关的操作,所以我们封装的组件也应该支持此类功能,满足此类用户的需求。

如果普通标签也想通过键盘访问到则需要通过设置 tabindex 属性,指示其元素是否可以聚焦。此外 button 标签可以被屏幕阅读器识别,而普通标签模拟按钮则需要设置元素的 ARIA role。

<span tabindex="0" role="button">按钮</span>

所以如果使用普通标签进行封装 Button 按钮组件的话,则需要设置很多其他代码,这是无法接受的。

自动聚焦 autofocus 属性

我们通常是给 input 标签设置 autofocus 属性,当页面加载时 input 元素应该自动获得焦点,这样用户就可以立即开始输入。同样在 button 标签上设置该属性的时候,用户就可以通过回车键立即进行响应绑定在该 button 标签上的事件了。这个也属于键盘交互部分的功能了。

此外一个页面上只允许有一个表单元素拥有 autofocus 属性。如果有多个元素拥有 autofocus 属性,则第一个元素会自动获得焦点。

同样 autofocus 属性是普通显示类的标签是没有的,比如 div、span、p、h1 等标签。

小结:

原生 button 标签还很多功能属性,我们这里就不一一进行探索介绍了。而且很多属性功能在前后分离的现代,已经不适合事宜了。比如 formaction、formmethod、formtarget 等属性。

通过上文的分析我们知道了为什么我们的 Button 组件为什么要使用 button 原生标签,同时还应该实现原生 button 标签的 type 属性的 button, submit, reset 属性值,此外还有 autofocus 和 disabled 属性。

基础 Button 组件实现

根据前面的文章的接受我们按以下目录结构创建我们的 Button 组件目录。

├── packages
│   ├── components
│   │   ├── button
│   │   │   ├── __tests__       # 测试目录
│   │   │   ├── src             # 组件入口目录
│   │   │   │   ├── button.ts     # 组件属性与 TS 类型
│   │   │   │   └── button.vue    # 组件模板内容
│   │   │   ├── style           # 组件样式目录
│   │   │   └── index.ts        # 组件入口文件
│   │   └── package.json

我们首先在基础版本 Button 组件中实现前面提到的原生 button 标签的 type 属性的 button, submit, reset 属性值,此外还有 autofocus 和 disabled 的原生属性。这些是 HTML button 标签原生部分的功能,我们要进行继承实现的。

此外使用  typeplainround  和  circle  来定义按钮的样式,这部分功能,我们也在基础部分实现,以及组件大小。

那么根据上述功能要求,我们就可以在 button.ts 中定义以下代码。

import type { ExtractPropTypes, PropType } from 'vue'
import type Button from './button.vue'

// 点击事件
export const buttonEmits = {
  click: (evt: MouseEvent) => evt instanceof MouseEvent,
}

export type ButtonEmits = typeof buttonEmits
// 使用 type 来定义按钮的样式
export type ButtonType =
  | ''
  | 'default'
  | 'primary'
  | 'success'
  | 'warning'
  | 'info'
  | 'danger'
  | 'text'
// type 原始类型
export type ButtonNativeType = 'button' | 'submit' | 'reset'
// size 组件大小
export type ButtonSize = '' | 'default' | 'small' | 'large'
// 定义 props
export const buttonProps = {
  size: String as PropType<ButtonSize>,
  type: {
    type: String as PropType<ButtonType>,
  },
  nativeType: {
    type: String as PropType<ButtonNativeType>,
    default: 'button',
  },
  disabled: Boolean,
  autofocus: Boolean,
  round: Boolean,
  plain: Boolean,
  circle: Boolean,
} as const
// Props 类型
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
// Button 组件实例类型
export type ButtonInstance = InstanceType<typeof Button>

接着我们定义 button.vue 文件的基础代码。

<template>
  <button
    ref="_ref"
    :class="[
      ns.b(),
      ns.m(type),
      ns.m(size),
      ns.is('disabled', disabled),
      ns.is('plain', plain),
      ns.is('round', round),
      ns.is('circle', circle),
    ]"
    :disabled="disabled"
    :autofocus="autofocus"
    :type="nativeType"
    @click="handleClick"
  >
    <slot />
  </button>
</template>
<script lang="ts" setup>
  import { ref } from "vue";
  import { useNamespace } from "@cobyte-ui/hooks";
  import { buttonEmits, buttonProps } from "./button";
  // 定义组件名称
  defineOptions({
    name: "ElButton",
  });
  // 定义 Props
  defineProps(buttonProps);
  // 定义 emit
  const emit = defineEmits(buttonEmits);
  // classname 的 BEM 命名
  const ns = useNamespace("button");
  // 按钮 html 元素
  const _ref = ref<HTMLButtonElement>();
  // 点击事件函数
  const handleClick = (evt: MouseEvent) => {
    emit("click", evt);
  };

  // 组件暴露自己的属性以及方法,去供外部使用
  defineExpose({
    ref: _ref,
  });
</script>

接着我们需要在 button/index.ts 目录文件中定义 Button 组件的出口内容。

import { withInstall, withNoopInstall } from "@element-plus/utils";
import Button from "./src/button.vue";
import ButtonGroup from "./src/button-group.vue";

export const ElButton = withInstall(Button, {
  ButtonGroup,
});
export const ElButtonGroup = withNoopInstall(ButtonGroup);
export default ElButton;

export * from "./src/button";

同样关于本部分更多的基础逻辑讲解,可以看看本专栏前面的文章。

defineProps、defineEmits 和 defineExpose

defineProps、defineEmits 和 defineExpose 是 Vue3 提供在 script setup 模式中的编译宏方法。

defineProps

我们知道 Vue 中设计了 props 特性,它允许组件的使用者在外部传递 props,然后组件内部就可以根据这些 props 实现各种各样的功能了。同时 props 也是父子组件之间通讯的其中一种方式。在传统的 option 方式声明 组件的时候,可以在 option 的选项中直接定义相关的 props 即可,但在 script setup 模式则需要通过 defineProps 这个编译宏 API 来进行声明了。

以我们的 Button 组件为例,Button 组件为子组件。那么我们父组件中调用 Button 组件的时候,想给 Button 组件传递一些值。比如上面讲到的 type 属性和 disabled 属性。

父组件调用 Button 组件的时候,可以给它传递一些 props 数据。

<el-button type="danger" disabled>Danger</el-button>

子组件,也就是 Button 组件通过 defineProps 定义 type 和 disabled 属性。

<script lang="ts" setup>
  import { PropType } from "vue";
  const props = defineProps({
    type: {
      nativeType: String as PropType<"button" | "submit" | "reset">,
      required: false,
      default: "button",
    },
    disabled: {
      type: Boolean,
      required: false,
    },
  });
</script>

这是使用 defineProps 的其中的一种方式,这种方式跟使用 option 方式编写组件时定义 props 的方式很像,如果想要支持 TypeScript 类型验证,那需要借助 Vue3 中提供的 PropType。我们在 Button 组件中就是使用这种方式,其中还把 defineProps 需要定义的 props 定义独立到单独的文件中进行统一管理。

还可以通过 TypeScript 的专有声明进行声明 props。

<script lang="ts" setup>
  interface Props {
    nativeType: "button" | "submit" | "reset";
    disabled: boolen;
  }
  const props = defineProps<Props>();
</script>

通过这种声明,是没有默认值的。如果想要有默认值则需要使用 withDefaults 编译宏命令。

<script lang="ts" setup>
  interface Props {
    nativeType: "button" | "submit" | "reset";
    disabled: boolen;
  }
  const props = withDefaults(defineProps<Props>(), {
    nativeType: "button",
    disabled: false,
  });
</script>

值得注意的是 defineProps 只能在 setup 中使用,且只能在 setup 的顶层使用,不能在局部作用域使用。

<script lang="ts" setup>
const handlerClick = () => {
  const props = defineProps({
    disabled: boolen,
  });
}
</script>

如果写在局部,报错:Uncaught ReferenceError: defineProps is not defined。

同时 defineProps 还有其他需要注意的点。defineProps 只能在 script setup 中使用,它不需要显式导入即可使用,不可以访问 script setup 中定义的其他变量。

defineEmits

相比较于 Vue2,在 Vue3 中要向组件中传递自定义事件,在组件内部再通过 emit 进行发射时,都需要在 emits 选项中进行显式声明。这也是 父子组件通讯方式之一。

<script lang="ts">
import { defineComponent } from 'vue'    
export default defineComponent({
    name: 'ElButton',
    props: {
        // ...
    },
    emits: ['click'],
  	setup(props, { emit }) {
        // 点击事件函数
        const handleClick = (evt: MouseEvent) => {
          emit('click', evt)
        }
    }
})
</script>

在 script setup 模式则需要通过 defineEmits 这个编译宏 API 来进行声明了。defineEmits 是在 script setup 模式中用于组件通信中子组件向父组件进行传值的 API,用其来声明 emits,其接收内容和通过 defineComponent 方式声明组件中 emits 选项一致。

父组件调用 Button 组件的时候,给它传递一些自定义事件。

<template>
	<el-button @handle="handleClick">按钮</el-button>
</template>
<script setup lang="ts">
    const handleClick = (e: Event) => {
      console.log(e)
    }
</script>

在子组件也就是 Button 组件中使用 defineEmits 来声明 emits。

<template>
	<button @click="handleClick">按钮</button>
</template>
<script setup lang="ts">
    // 定义 emit
	const emit = defineEmits({
        handle: (evt: MouseEvent) => evt instanceof MouseEvent,
    })
    // 点击事件函数
    const handleClick = (evt: MouseEvent) => {
      emit('handle', evt)
    }
</script>

defineEmits 的接收内容还可以是一个数组。

const emit = defineEmits(['handle'])
// 点击事件函数
const handleClick = (evt: MouseEvent) => {
    emit('handle', evt)
}

同样跟 defineProps 一样,还可以使用 TypeScript 专有的声明方式进行声明。

const emit = defineEmits<{(e: 'handle', evt: MouseEvent): void}>()
// 点击事件函数
const handleClick = (evt: MouseEvent) => {
    emit('handle', evt)
}

defineEmits 也同样需要注意,只能在 script setup 中使用,它不需要显式导入即可使用,必须在 script setup 的顶层使用,不可以在 script setup 的局部作用域中使用。

在局部作用域中使用 defineEmits 将会报错。

// 点击事件函数
const handleClick = (evt: MouseEvent) => {
  const emit = defineEmits(buttonEmits)
  emit('click', evt)
}

我们看到将会报错。

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

defineExpose

在通过 defineComponent 方式声明组件,如果要让父组件访问子组件中的变量和方法,则需要在 setup 方法中将需要访问的变量和方法返回出去即可。这也是父子组件通讯方式的一种。

<script lang="ts">
import { defineComponent, ref } from 'vue'    
export default defineComponent({
    name: 'ElButton',
    props: {
        // ...
    },
  	setup(props, { emit }) {
        // 给父组件访问的变量
        const name = ref('给父组件访问的变量')
        // 给父组件访问的方法
        const handleChild = () => {
          	console.log('给父组件访问的方法')
        }
        // 将需要访问的变量和方法返回出去
        return {
           name,
           handleChild
        }
    }
})
</script>

在父组件中就可以通过 ref 去获取子组件的相关内容了。

<template>
  <div>
    <el-button ref="_ref">按钮</el-button>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const _ref = ref()
console.log(_ref)
</script>

访问的打印结果:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

在 script setup 模式中则需要通过 defineExpose 编译宏 API 进行暴露组件自己的属性以及方法,去供外部使用。

<template>
  <button>
    <slot />
  </button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
// 给父组件访问的变量
const name = ref('给父组件访问的变量')
// 给父组件访问的方法
const handleChild = () => {
  console.log('给父组件访问的方法')
}
defineExpose({
  name,
  handleChild,
})
</script>

访问的打印结果:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们发现打印的结果跟上面的还是有点不一样的,name 值还进行了一层 ref 的包裹,但总的来说在父组件是可以访问到的了。

测试基础 Button 组件

接着我们在 play 项目中引用我们编写好的 Button 组件进行测试。

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们前面已经配置好基础框架代码了,我们现在只需要进行画红线的代码配置即可。

接着我们对我们前面写的基础功能进行测试,测试代码如下:

<el-button>按钮</el-button>
<el-button type="primary">Primary</el-button>
<el-button type="success" plain>Success</el-button>
<el-button type="info" round>Info</el-button>
<el-button type="warning" circle>Warning</el-button>
<el-button type="danger" disabled>Danger</el-button>
<el-button size="large">按钮</el-button>
<el-button size="small">按钮</el-button>

测试渲染结果

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

由于还没写样式,所以真实页面效果是 button 的原生模样,我们主要看相关 classname 的生成是否正确。

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们在本章节中将不会讲解 Button 组件样式的实现,具体 Button 组件样式的实现,将放在下一篇文章中进行详细讲解,标题暂定为《SCSS 样式架构的实践》。

在 Button 组件的实现中下面这一块代码是进行生成组件 classname 的方法。

<template>
  <button
    :class="[
      ns.b(),
      ns.m(type),
      ns.m(size),
      ns.is('disabled', disabled),
      ns.is('plain', plain),
      ns.is('round', round),
      ns.is('circle', circle),
    ]"
  >
    <slot />
  </button>
</template>

其中 BEM 规范下 classname 的命名格式为:

block-name__<element-name>--<modifier-name>_<modifier_value>
复制代码
  • 所有实体的命名均使用小写字母,复合词使用连字符 “-” 连接。
  • Block 与 Element 之间使用双下画线 “__” 连接。
  • Mofifier 与 Block/Element 使用双连接符 “--” 连接。
  • modifier-name 和 modifier_value 之间使用单下画线 “_” 连接。

在声明 ns 实例的时候 const ns = useNamespace('button') 就通过字符串参数 'button' 表明这个模块的 classname 的开头是 el-button。如果存在 Block 前缀,也就是 Block 里面还有 Block,例如:el-form 下面还有一个 el-form-item。

使用 ns.b() 则表明没有前缀将生成 el-button classname。如果有前缀,例如 ns.b('item') 那么将生成 el-button-item

ns.m() 则是根据参数创建块前缀修改器,例如 ns.m('primary') 将会生成 el-button--primary

ns.is() 则是根据参数创建动作状态的 classname,例如 ns.is('disabled', disabled),如果第二个参数 disabled 为真则创建 is_disabled ,如果为假则什么也不创建。

最后我们测试一下点击事件:

<template>
  <el-button size="small" @click="handlerClick">按钮</el-button>
</template>
<script setup lang="ts">
  const handlerClick = (e: Event) => {
    console.log(e);
  };
</script>

测试结果:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

正确打印了点击事件。

最后我们测试 Button 组件中通过 defineExpose 暴露给外部组件使用的 ref。

<template>
  <button
    ref="_ref"
  >
    <slot />
  </button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
// 按钮 html 元素
const _ref = ref<HTMLButtonElement>()
// 组件暴露自己的属性以及方法,去供外部使用
defineExpose({
  ref: _ref,
})
</script>

通过删减代码之后,我们可以看到此 ref 是定义在 HTML 的原生 button 标签上的,所以此 ref 应该是原生 DOM 的引用。

<template>
  <div>
    <el-button ref="buttonRef" size="small">按钮</el-button>
  </div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'

const buttonRef = ref()
nextTick(() => {
  console.log('buttonRef', buttonRef.value.ref)
})
</script>

我们知道 setup 方法是在组件实例化之后运行的,此时 DOM 还没渲染,所以如果在此时获取 DOM 内容是获取不到的,所以我们通过 nextTick 方法来获取 DOM 内容。因为 nextTick hook 本质是通过异步任务来执行的一个原理。

打印内容如下:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

至此,Button 组件的基础内容我们就进行实现和验证完成了。

图标按钮实现

通过 props 的 icon 属性实现

图标按钮是通过使用 icon 属性来为按钮添加图标的。 所以我们先在 button/src/button.ts 文件中定义 Button 组件 props 中添加的 icon 属性。

// 定义 props
export const buttonProps = {
  icon: {
    type: [Object, String] as PropType<object | string>,
    default: '',
  }
} as const

接着我们在 button/src/button.vue 中添加以下内容。

<template>
  <button>
    <el-icon v-if="icon">
      <component :is="icon" />
    </el-icon>
    <slot />
  </button>
</template>

在 Element Plus 的官方 Icon 组件库中有很多已经设置好的 icon,我们可以直接拿来使用,我们要使用则先要安装 @element-plus/icons-vue 包。

pnpm install @element-plus/icons-vue -w

接着我们进行测试。

<template>
  <div>
    <el-button type="primary" :icon="Edit" />
  </div>
</template>
<script setup lang="ts">
import { Edit } from '@element-plus/icons-vue'
</script>

测试结果:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

成功渲染。

通过插槽实现

其实图标组件还可以通过插槽的方式进行使用。

<el-button>
    <template #icon><Edit /></template>
</el-button>

这样我们的 Button 组件的 template 也需要作出相应的修改。

<el-icon v-if="icon || $slots.icon">
    <component :is="icon" v-if="icon" />
    <slot v-else name="icon" />
</el-icon>

这里我们看到有一个 $slots 的变量,接下来我们介绍一下相关知识。

useSlots 和 useAttrs

我们从上一小节可以在模板中看到有一个 $slots 的变量。在模板中我们可以通过 $slots 访问父组件中插槽传递的虚拟 Dom 对象 ,同时还可以通过 $attrs 访问父组件中非 props 的传递到子组件的属性,包括 class 和 style 属性。

在模板中我们可以通过 $slots$attrs 访问 slots 和 attrs。在 script 标签中我们则可以通过 useSlots 和 useAttrs 两个 Vue3 提供 Hook 函数来访问。

<script setup>
import { useSlots, useAttrs } from 'vue'

const slots = useSlots()
const attrs = useAttrs()
</script>

加载状态按钮的实现

通过设置 loading 属性为 true 来显示加载中状态,也可以使用 loading 插槽或 loadingIcon属性自定义 loading 图标 。所以我们先在 button/src/button.ts 文件中定义 Button 组件 props 中添加的 loading 和 loadingIcon 属性,其中 loadingIcon 的默认值我们使用 @element-plus/icons-vue 图标库中的 Loading 图标。

import { Loading } from '@element-plus/icons-vue'
// 定义 props
export const buttonProps = {
  loading: Boolean,
  loadingIcon: {
    type: [Object, String] as PropType<object | string>,
    default: () => Loading,
  },
} as const

接着我们在 button/src/button.vue 中添加图标部分的内容以及修改原来 Icon 图标的内容。

<template>
  <button>
    <template v-if="loading">
      <slot v-if="$slots.loading" name="loading" />
      <el-icon v-else :class="ns.is('loading')">
        <component :is="loadingIcon" />
      </el-icon>
    </template>
    <el-icon v-else-if="icon || $slots.icon">
      <component :is="icon" v-if="icon" />
      <slot v-else name="icon" />
    </el-icon>
    <slot />
  </button>
</template>

从上面代码我们可以看到,如果是 loading 状态,优先显示 loading 的内容,同时 loading 插槽优先级高于 loadingIcon 属性。

ButtonGroup 的实现

按钮组的功能比较简单,就是通过按钮组来控制一组按钮的大小和类型,但其中的实现技术还是值得学习一下,可谓由浅入深。

按钮组提供两个属性。

  • size 用于控制该按钮组内按钮的大小
  • type 用于控制该按钮组内按钮的类型

我们先在 button/src 目录下创建 button-group.ts 文件,然后创建内容如下:

import { buttonProps } from './button'
import type { ExtractPropTypes } from 'vue'
import type buttonGroup from './button-group.vue'
// 因为 size 和 type 属性和 Button 中的属性是一样的,所以可以进行复用
export const buttonGroupProps = {
  size: buttonProps.size,
  type: buttonProps.type,
} as const

export type ButtonGroupProps = ExtractPropTypes<typeof buttonGroupProps>
export type ButtonGroupInstance = InstanceType<typeof buttonGroup>

因为 size 和 type 属性和 Button 中的属性是一样的,所以可以进行复用。

接着我们在 button/src 目录下创建 button-group.vue 文件,然后创建内容如下:

<template>
  <div :class="`${ns.b('group')}`">
    <slot />
  </div>
</template>
<script lang="ts" setup>
import { useNamespace } from '@cobyte-ui/hooks'
import { buttonGroupProps } from './button-group'

defineOptions({
  name: 'ElButtonGroup',
})
const ns = useNamespace('button')
</script>

classname 是通过 ns.b('group') 生成的,而在 Button 组件中是通过 ns.b() 生成的,命名空间通过 useNamespace('button') 的调用方式可以知道都是 button ,通过前面的章节我们可以知道 BEM 的命名原理 ns.b() 中参数就是 Block 前缀,默认是 Block,也就是没有参数的时候,也就是 el-button,那么加上一个 Block 前缀就是 el-button-group

provide 和 inject

按钮组相当于一个壳,通过插槽自定义按钮组内容,然后通过这个壳控制里面的所有按钮的 size 和 type 属性。但这里按钮组组件和按钮组件不一定是父子组件关系,也有可能是多层级嵌套的组件,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦。这时我们就要引出组件间通讯的一种重要方式,也就是通过 provide 和 inject

由于 provide 是通过 provide('key', val) 这样方式进行定义提供数据的,也就是 key - value 的形式,这样 key 如果只是通过字符串的话,难免会有重复,所以 Vue 官方建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

因为有很多组件都可以使用到 provide 和 inject 进行通讯,所以就会有很多的 key 需要创建,同时将来其他开发者使用我们的组件库的时候,也有可能需要引用我们的组件相关的 key 进行 inject 获取相关数据进行功能开发。基于这些需求,我们把这些 key 统一配置到一个独立的包项目中。

我们在根目录的 packages 文件夹下创建一个 tokens 的文件,然后初始化一个 package.json 文件,内容如下:

{
  "name": "@cobyte-ui/tokens",
  "version": "0.0.1",
  "license": "MIT",
  "main": "index.ts",
  "module": "index.ts",
  "unpkg": "index.js",
  "jsdelivr": "index.js",
  "peerDependencies": {
    "vue": "^3.2.0"
  },
  "types": "index.d.js"
}

我们创建了一个新的包,我们需要通过 pnpm 安装到根目录中。

pnpm install @cobyte-ui/tokens -w

然后在 tokens 文件夹下创建一个 button.ts 文件专门配置关于 Button 组件 provide 和 inject 需要用到的 key。内容如下:

import type { InjectionKey } from 'vue'
import type { ButtonProps } from '@cobyte-ui/components/button'

export interface ButtonGroupContext {
  size?: ButtonProps['size']
  type?: ButtonProps['type']
}

export const buttonGroupContextKey: InjectionKey<ButtonGroupContext> = Symbol(
  'buttonGroupContextKey'
)

然后我们还需要在 tokens 文件夹下创建一个 index.ts 文件用于导出组件库各种组件的 provide 和 inject 需要用到的 key。

packages/tokens/index.ts 的内容:

export * from './button'

接着我们需要在 button-group.vue 文件中添加以下内容:

<template>
  <div :class="`${ns.b('group')}`">
    <slot />
  </div>
</template>
<script lang="ts" setup>
import { provide, reactive, toRef } from 'vue'
import { buttonGroupContextKey } from '@cobyte-ui/tokens'
import { buttonGroupProps } from './button-group'

const props = defineProps(buttonGroupProps)
provide(
  buttonGroupContextKey,
  reactive({
    size: toRef(props, 'size'),
    type: toRef(props, 'type'),
  })
)
</script>

我们通过上述代码可以知道我们向按钮组组件的后代组件提供了一个响应式对象,内容就是 props 中的 size 和 type 属性。这里我们可以看到是通过 toRef 来读取 props 中的属性的,接下来就让我们好了解一下这个 toRef 以及和它功能相同的 toRefs API 吧。

toRef 和 toRefs

我们在上面的按钮组组件中向后代组件提供的依赖注入,创建一个响应式对象,读取 props 属性时是通过 toRef 来读取的。如果我们不通过 toRef 来读取,而是直接 props.sizeprops.type 进行读取,那么将来 props.size 或者 props.type 的值发生改变,都不会引起这个依赖注入中创建的响应式对象的变化响应。因为通过 props.sizeprops.type 进行读取赋值的时候,已经失去了跟原来的属性的联系了。

reactive({
    size: props.size,
    type: props.type,
}

这是属于响应式丢失的问题。那么什么是响应式丢失呢?我们在通过以下方式编写组件的时候,需要把响应式数据暴露出去,这样才能在模板中进行读取。

<script>
import { defineComponent } from 'vue'    
export default defineComponent({
  	setup() {
        const obj = reactive({name: 'cobyte'age: 18})
        // 将需要访问的变量和方法暴露出去
        return {
           obj
        }
    }
})
</script>

这样我们就可以在模板中访问暴露出来的相关数据了。

<template>
<p>{{obj.name}}{{obj.age}}</p>
</template>

但这样要写多一个 obj 才能读取,那么能不能直接 {{name}}{{age}} 这样就可以读取呢?是可以的,我们将 obj 进行解构就可以了。

return {
    ...obj
}

但这样会导致一个新的问题,响应式会丢失。当我们修改响应式数据的时候,不会触发重新渲染。为什么会导致响应式丢失呢?这是由展开运算符 (...) 导致的。实际上,上面的代码等价于下面的代码:

return {
    name: 'cobyte',
    age: 18
}

其实这就变成了一个普通对象,所以不具有响应式能力。这样我们就能理解按钮组组件中通过以下方式创建提供依赖注入会失去响应式了。

reactive({
    size: props.size,
    type: props.type,
}

所以为了解决这个问题,Vue3 提供了 toRef。toRef 函数接收两个参数,第一个参数是一个响应式对象,第二个参数是响应式对象的一个键。

return {
    name: toRef(obj, 'name'),
    age: toRef(obj, 'age')
}

但这样如果响应式数据 obj 的键非常多,这样进行转换还是十分繁琐的,所以 Vue3 又提供了一个批量转 ref 的函数 toRefs。

return {
    ...toRefs(obj)
}

toRef 和 toRefs 的实现原理

toRef 的实现原理其实很简单,就是利用了 Ref 的实现原理。我们知道 reactive 是通过 Proxy API 实现的,而 Proxy API 只能对引用对象实现代理,无法对原始值进行代理,所以 Vue3 实现了一个 Ref 的 API 来实现对原始值的代理。Ref 的实现原理是通过属性访问器来实现的,这也是为什么 Ref 需要通过 .value 来访问的缘故。

简单来说 Ref 的响应式原理就是当访问属性访问器 value 的时候建立响应式依赖,当设置属性访问器 value 的时候触发响应式依赖。那么 toRef 其实就相当于创建了一个 Ref 对象,然后在 getter 中读取响应式属性,从而建立响应依赖,在 setter 中设置响应式属性的值,从而触发依赖副作用函数重新执行。

以下便是 Vue3 toRef 实现的相关源码。

class ObjectRefImpl {
  public __v_isRef = true
  constructor(private _object, private _key) {}
  get value() {
    // 在 getter 中读取响应式属性,从而建立响应依赖
    return this._object[this._key]
  }
  set value(newVal) {
    // 在 setter 中设置响应式属性的值,从而触发依赖副作用函数重新执行
    this._object[this._key] = newVal
  }
}

export function toRef(object,key){
  // 如果是 Ref 对象那么就直接读取,不是则创新一个 Ref 对象
  return isRef(object[key])
    ? object[key]
    : new ObjectRefImpl(object, key)
}

如果是 Ref 对象那么就直接读取,不是则创新一个 Ref 对象。

toRefs 则是循环对象的属性通过 toRef 进行读取。

export function toRefs(object){
  // 判断是否是数组
  const ret = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = toRef(object, key)
  }
  return ret
}

ButtonGroup 的完善

我们在 button-group 组件上通过 provide API 统一提供了 size 和 type,所以需要在后代按钮组件中通过 inject API 接收 size 和 type,然后进行相关的功能实现。

我们对 button.vue 进行以下修改。

<template>
  <button
    :class="[
      ns.m(_type),
      ns.m(_size),
    ]"
  >
	<slot />
  </button>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue'
import { buttonGroupContextKey } from '@cobyte-ui/tokens'

// 定义 Props
const props = defineProps(buttonProps)
// 使用 inject 取出祖先组件提供的依赖
const buttonGroupContext = inject(buttonGroupContextKey, undefined)
// 使用 computed 进行缓存计算
const _size = computed(() => props.type || buttonGroupContext?.size)
const _type = computed(() => props.type || buttonGroupContext?.type || '')
</script>

因为我们需要对多个响应式数据进行计算取值,所以我们使用 computed 进行缓存计算。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行传给 computed 的副作用函数并将其结果作为返回值返回。并将执行结果缓存起来,当副作用函数中的响应式数据没有发生改变的时,读取 value 的值时返回的都是缓存的结果,并不会重新执行副作用函数。

ButtonGroup 的安装与导出

我们已经把 ButtonGroup 组件实现了,我们知道每个组件都需要设置一个 install 方法以便在全量导入组件库的时候通过插件进行安装。

所以我们在 button 组件目录的入口文件 index.ts 中添加 ButtonGroup 的代码。

import { withInstall } from '@cobyte-ui/utils'
import Button from './src/button.vue'
+ import ButtonGroup from './src/button-group.vue'

// 通过 withInstall 方法给 Button 添加了一个 Vue3 插件所需的 install 方法
const ElButton = withInstall(Button)
+ // 通过 withInstall 方法给 ButtonGroup 添加了一个 Vue3 插件所需的 install 方法
+ export const ElButtonGroup = withInstall(ButtonGroup)
// 可以通过 app.use 来使用,也可以通过 import 方式单独使用
export default ElButton
export * from './src/button'

然后在模拟组件库的代码中添加 ButtonGroup 的代码。

- import ElButton from '@cobyte-ui/components/button'
+ import ElButton, { ElButtonGroup } from '@cobyte-ui/components/button'
import '@cobyte-ui/theme-chalk/src/index.scss'
import App from './src/App.vue'

// 组件库
- const components = [ElIcon, ElButton]
+ const components = [ElIcon, ElButton, ElButtonGroup]

然后我们进行测试渲染。

<template>
  <div>
    <el-button-group type="success" size="large">
      <el-button>按钮1</el-button>
      <el-button>按钮2</el-button>
    </el-button-group>
  </div>
</template>

渲染结果如下:

Element Plus 组件库相关技术揭秘:9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

我们可以看到如期渲染出我们需要的按钮组 class — el-button-group ,我们通过按钮组的 props 传递的两个参数 type='success'size='large' 也成功渲染出 el-button--successel-button--large

至此我们的 Button 组件的基本功能就算是完成了,但我们还需要继续重构代码使得我们的代码逻辑更合理,更利于日后的拓展。

我们在上述代码中,我们是需要在组件库里面导出 ElButtonGroup 的,而 ElButtonGroup 和 Button 组件是同一个组别的组件,能不能同一个组件的组件只需要导出一个基础组件即可,这样当我们组件库拥有几十个组件的时候,我们则可以少写很多代码,而且只有一个出入口,也方便日后维护与拓展。

基于此我们希望只导出一个基础组件即可完成相关组件安装。

+ import ElButton from '@cobyte-ui/components/button'
- import ElButton, { ElButtonGroup } from '@cobyte-ui/components/button'
import '@cobyte-ui/theme-chalk/src/index.scss'
import App from './src/App.vue'

// 组件库
+ const components = [ElIcon, ElButton]
- const components = [ElIcon, ElButton, ElButtonGroup]

也就是我们把代码还原回去。

我们希望拓展原来给组件添加一个 Vue3 插件所需的 install 方法的 withInstall 函数。让 withInstall 函数的第二个参数接收同一组别的除了基础组件的其他组件,在将来安装的时候,一起进行安装。

import { withInstall } from '@cobyte-ui/utils'
import Button from './src/button.vue'
import ButtonGroup from './src/button-group.vue'

// 通过 withInstall 方法给 Button 添加了一个 Vue3 插件所需的 install 方法
- const ElButton = withInstall(Button)
+ const ElButton = withInstall(Button, { ButtonGroup })
- // 通过 withInstall 方法给 ButtonGroup 添加了一个 Vue3 插件所需的 install 方法
- export const ElButtonGroup = withInstall(ButtonGroup)
// 可以通过 app.use 来使用,也可以通过 import 方式单独使用
export default ElButton
export * from './src/button'

我们需要把原来的 withInstall 函数进行迭代。

- export const withInstall = <T>(comp: T) => {
-   ;(comp as SFCWithInstall<T>).install = function (app) {
-     const { name } = comp as unknown as { name: string }
-     app.component(name, comp as SFCWithInstall<T>)
-   }
-   return comp as SFCWithInstall<T>
- }
+ // Record 后面的泛型就是对象键和值的类型
+ export const withInstall = <T, E extends Record<string, any>>(
+  main: T,
+  extra?: E
+ ) => {
+  ;(main as SFCWithInstall<T>).install = (app): void => {
+    for (const comp of [main, ...Object.values(extra ?? {})]) {
+      app.component(comp.name, comp)
+    }
+  }

+  if (extra) {
+    for (const [key, comp] of Object.entries(extra)) {
+      // 将来可以通过基础组件访问同组别的其他组件,例如:ElButton.ButtonGroup 同样可减少用户需要手动引用的代码量
+      ;(main as any)[key] = comp
+    }
+  }
+  return main as SFCWithInstall<T> & E
+ }

我们把基础组件 main 和除基础组件以外的同一组别的组件 extra,通过 Object.values 方法组成在同一个数组里面,然后通过 for of 进行迭代数组中的每一项组件,然后进行安装。

通过 Object.entries 方法除基础组件以外的同一组别的组件 extra 转换成键值对的数组,再通过 for of 进行迭代数组中的每一项,把同一组别的组件赋值到基础组件上。这样将来可以通过基础组件访问同组别的其他组件,例如:ElButton.ButtonGroup 同样可减少用户需要手动引用的代码量。

这里我们需要了解一下 Object 函数的内置方法 Object.valuesObject.entries

  • Object.values() 方法返回一个数组,包含了给定对象自身的(不含继承的) 、可枚举的所有属性的值,但不包括 Symbol 值属性的值。
  • Object.entries 方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值对的数组。

最后我们还需要把导出 ButtonGroup 组件,因为用户有可能进行手动引用。

// 导出 ButtonGroup 组件
export const ElButtonGroup = withNoopInstall(ButtonGroup)

我们需要创建多一个 withNoopInstall 函数为以后类似 ButtonGroup 的组件添加类型。

import { NOOP } from '@vue/shared'
export const withNoopInstall = <T>(component: T) => {
  // NOOP 的类型其实就是 () => void 表示不返回任何内容的函数
  ;(component as SFCWithInstall<T>).install = NOOP

  return component as SFCWithInstall<T>
}

注意 NOOP 是 Vue3 的 @vue/shared 包中的一个空函数。具体内容如下:

export const NOOP = () => {}

因为 ButtonGroup 组件已经在安装基础组件的时候一起进行安装了,所以在 withNoopInstall 函数中不需要再进行安装,但又因为我们使用同一个类型 SFCWithInstall,而类型 SFCWithInstall 要求必要要有一个 install 方法,所以我们需要手动实现一个空函数。我们的组件库是依赖于 Vue3 的,在 Vue3 中已经提供这样的方法给我们了,我们则直接拿来用即可,以达到减少代码量的目的。

总结

通过上述文章,我们基本把 Button 组件除了样式之外的功能都实现了,样式部分将在下一篇文章中进行讲解实现。

通过 Button 组件的实现,我们学习了 Vue3 的相关知识,比如父子组件之间的通讯方式,可以通过 props,可以通过 emit,还可以通过插槽,隔代组件之间则可以通过 provide 和 inject。

还学习了 Vue3 编译宏相关的 API:defineProps、defineEmits 和 defineExpose。

以及 toRef 和 toRefs 的实现原理。

还知道了在 Element Plus 中可以通过基础组件访问同组别的其他组件,例如:ElButton.ButtonGroup,这样可以减少手动引用的代码量。

欢迎关注本专栏,了解更多 Element Plus 组件库知识

本专栏文章:

转载自:https://juejin.cn/post/7179257832632483896
评论
请登录