Element Plus 源码:Icon 组件最详细实现

1,132 阅读6分钟

一、前言

Element Plus 组件最简单入手的就是 icon 组件,所以先从 icon 组件来大概了解整个项目处理组件的基本原理,由浅入深,能让你了解到ELement Plus 的 bem 命名规范themechalk 文件中的 scss ,组件如何定义类型和基本实现思路以及部分在 icon 组件中使用到的 hooks 和 utils 中的方法。

二、Icon 组件的基本功能

一)、Icon 组件的目录结构

我们可以先从 ELement Plus 中 icon 组件的实现的目录结构入手,其他复杂组件的目录结构跟此类似,方便我们更改去理解。

  |—— packages
  |  |—— components
  |  |    |—— icon
  |  |    |  |—— __tests__            //测试目录
  |  |    |  |—— src                  // 组件的入口目录
  |  |    |  |    |——icon.ts          // 组件属性和 ts 类型
  |  |    |  |    |——icon.vue         // 组件模板内容
  |  |    |  |—— style                // 组件样式目录
  |  |    |  |—— index.ts             // 组件的入口文件
  |  |    |  |—— package.json

Element Plus 是基于 Vue3 搭建的,这方便我们利用 Vue3 的新特性来编写组件,通过把组件中的属性值和属性类型抽离出来,方便其他组件复用 从 声明组件的类型,大大的降低了耦合度,scr/*.ts中声明组件的属性类型和声明一些实现组件的其他类型,将类型全部导出,方便其他组件使用其类型。

二)、Icon组件属性prop

1、Icon 组件的功能分析

封装一个全局组件首先需要清楚这个组件要实现什么功能,因为 Vue3 中父组件可以通过 props 向子组件传递数据, 我们使用这个封装组件需要明确 props 的类型和 props 的值。

Element Plus 中的 icon 组件 props 设计比较简单,只有 sizecolor ,如果我们需要添加更多有特性的功能也可以参考这两个属性的实现思路,我们可以定义更多功能的 icon 组件。

属性名属性说明属性类型默认值
sizesvg 图标大小,size 代表的是宽高大小number,string数字和字符串数字直接转换 ${size}px, string 是直接带有px的字符串或%继承字体大小
colorsvg 图标的填充色pick<CSSProperties,'color'>继承颜色

Element Plus 组件的 icon 组件实现了我们可以通过 props 来向子组件传值,修改组件的尺寸和背景颜色,清晰了 icon 组件的功能需求,接下来便可以大刀阔斧的干一场了。首先在 components/icon/src目录下创建 icon 组件的类型声明 icon.ts 文件。

export const iconProps = {
  size: [Number, String], //size 值类型可以是带有px和百分号的字符串,和数字
  color: String,
} as const

2、Icon 组件的 props 类型推断

as const是 TS 语法的常量断言,可以使这个常量标记成一个不可修改的值,但是现在这个 iconProps 类型不是我们最终想要的类型,我们最终想要的是,size 为 String 或者 Number , color String 类型。而这个类型是它类型的构造函数类型,所以我们需要将它转化一下。

在 Vue3 中提供了一个可以将接收的类型转换成对应所需要类型: ExtractPropTypes ,它把构造函数的类型转换成想要的类型,比如 StringConstructor 转换成 string。

import { ExtractPropTypes } from 'vue'

export const iconProps = {
  size: [Number, String],
  color: String,
} as const

export type IconProps = ExtractPropTypes<typeof iconProps>

但是这个转换的类型不会把 size 的类型转换出来,需要强制的去声明 size 的类型。

强制声明一个属性值的类型,我们可以封用到的是 Vue3 中提供的 PropType ,它可以将类型转换成设置的类型,Element Plus 将这个类型函数封装在 utils/vue/props/runtime里。为了方便引入这个函数的简化路径。

可以在 utils/vue/props/index.ts中将 runtime 文件中所有的函数导出, utils/vue/index.ts 可以将 props 文件导出 ,utils 根目录下中的 index.ts 文件将 vue文件里的函数导出。其他文件也可以参照这种格式暴露出去。这样做的好处是可以在任何想引入 uitls文件中的方法都只需要通过 import xx from '@fz-mini/utils' ,不需要写后面的后缀名,这个 @fz-mini/utils' 是模块名也是根路径下的 package.json 中导入的包名。

import type {PropType} from 'vue'
export const defineProps=<T>(val:any):PropType<T>=>val
export * from './runtime'
export * from './props'
export * from './vue'

3、Icon props 具体实现

最后 icon 组件的类型声明可以写成以下代码。导出 icon 组件的类型和导出实例类型。

import { definePropType } from '@fz-mini/utils'
import type { ExtractPropTypes } from 'vue'
import type Icon from './icon.vue'
export const iconProps = {
  /**
   * @description icon 尺寸大小
   */
  size: {
    type: definePropType<string | number>([String, Number]),
  },
  /**
   * @description icon 颜色 svg的填充色
   */
  color: {
    type: String,
  },
} as const
//icon props类型
export type IconProps = ExtractPropTypes<typeof iconProps>
//icon组件 实例类型
export type IconInstance = InstanceType<typeof Icon>

三)、Icon 组件的 Vue 模板(BEM)

1、CSS 设计模式( BEM )

在写 Vue 模板的功能前,我需要先分析 Element Plus 中 BEM 的命名规范,主要是给 Vue 模板文件提供规则化类名,整个组件的 CSS 变量名和命名规范保持一致。这个是一种 CSS 命名方法论,即 Block (块)、Element (元素)和 Modifier (修改器)的简称。

具体实现,Element Plus 源码中实现了可以通过依赖注入 namespace 的值来更改 全局 CSS 变量的 namespace 。但我们目前先实现一个默认的 namespace 值,后面写全局配置组件再详细介绍。

import { ref } from 'vue'
const statePrefix = 'is-'
//规则化生成class类名
const _bem = (
  namespace: string,
  block: string,
  blockSuffix: string,
  element: string,
  modifier: string,
) => {
  let cls = `${namespace}-${block}`
  if (blockSuffix) {
    cls += `-${blockSuffix}`
  }
  if (element) {
    cls += `__${element}`
  }
  if (modifier) {
    cls += `--${modifier}`
  }
  return cls
}
export const useNamespace = (block: string) => {
  const namespace = ref('fz')
  const b = (blockSuffix = '') =>
    _bem(namespace.value, block, blockSuffix, '', '')
  const e = (element?: string) => {
    element ? _bem(namespace.value, block, '', element, '') : ''
  }
  const m = (modifier?: string) => {
    modifier ? _bem(namespace.value, block, '', '', modifier) : ''
  }
  const be = (blockSuffix?: string, element?: string) => {
    blockSuffix && element
      ? _bem(namespace.value, block, blockSuffix, element, '')
      : ''
  }
  const bm = (blockSuffix?: string, modifier?: string) => {
    blockSuffix && modifier
      ? _bem(namespace.value, block, blockSuffix, '', modifier)
      : ''
  }
  const em = (element?: string, modifier?: string) => {
    element && modifier
      ? _bem(namespace.value, block, '', element, modifier)
      : ''
  }
  const bem = (blockSuffix?: string, element?: string, modifier?: string) => {
    blockSuffix && element && modifier
      ? _bem(namespace.value, block, blockSuffix, element, modifier)
      : ''
  }
  const is: {
    (name: string, state: boolean | undefined): string
    (name: string): string
  } = (name: string, ...args: [boolean | undefined] | []) => {
    const state = args.length >= 1 ? args[0]! : true
    return name && state ? `${statePrefix}${name}` : ''
  }
  return {
    b,
    e,
    m,
    be,
    em,
    bm,
    bem,
    is,
  }
}

根据返回的这几个函数我依次解释它们返回的字符串。const ns=useNamespace('icon')首先引入 hooks , hooks 传入的参数值最好是组件名,也可以随意定义。

  • ns.b() :生成 fz-icon字符串。
  • ns.e('content'):生成 fz-icon__content字符串。
  • ns.m('primary'):生成 fz-icon--primary字符串。
  • ns.is('center'):生成 is-center字符串。
  • ns.be('title','content'):生成 fz-icon-title__content字符串。
  • ns.em('conent','primary'):生成 fz-icon-content--primary字符串。
  • ns.bm('title','primary'):生成 fz-icon-title--primary字符串。
  • ns.bem('title','content','primary'):生成 fz-icon-title__content--primary字符串。

2、Icon 中 Vue 模板实现

Icon 组件主要是利用插槽来接收父组件传来的字体图标 svg ,通过 props 的值来更改 svg 的尺寸和填充色。

<template>
  <i :class="ns.b()">
    <slot />
  </i>
</template>

<script setup lang="ts">
  import { useNamespace } from '@fz-mini/hooks'
  const ns = useNamespace('icon')
</script>

我们已经在 icon.ts文件中定义好了 icon 组件的 props ,现在可以使用 defineProps编译宏命令来在模板中进行声明 props ,声明的 props 会自动暴露给模板。

import { iconProps } from './icon'
const props = defineProps(iconProps)

由于 props 中定义了 size 和 color 两个属性,组件需要对这两个属性值进行处理,把它们处理好的结果绑定到 style 中,以下我们来实现这个逻辑。

const style = computed<CSSProperties>(() => {
  const { size, color } = props
  if (!size && !color) return {}
  return {
    fontSize: isUndefined(size) ? undefined : addUnit(size),
    '--color': color,
  }
})

可能有人这里就会疑惑了,addUnit函数用来干什么的,为什么是 --color:color 不是 color:color addUnit 函数用来处理 size 传的值不管是数字、数字字符串还是字符串都转换成对应的格式。

import { isNumber, isString, isStringNumber } from '../types'

//生成带有px单位的字符串
export const addUnit = (value: string | number, defaultUnit = 'px') => {
  if (!value) return
  if (isNumber(value) || isStringNumber(value)) {
    return `${value}${defaultUnit}`
  } else if (isString(value)) {
    return value
  }
}

--color:color这是使用 CSS 的变量,声明 CSS 的变量在变量名前面添加两根连词线(--),使用它的原因是因为 Icon 图标可能是一个 svg 的图标,也可以是一个字体类型的图标, 字体类型的图标可以直接修改 color 来设置背景颜色,而 svg 的背景颜色需要修改 fill 来修改背景色。将 fill 属性值改成 currentColor ,就可以通过继承父元素的 color 来改变颜色。例如在 CSS 中定义 color:var(--color) , --color:red ,相当于 color:red

最后,Icon 组件的 Vue 模板需要声明组件的名字,我们需要动态的设置组件的名称,需要使用 Vue3 中的 defineOptions 来定义组件名称(只支持 Vue3.3 及以上版本),详细配置可以参照 Vue3 文档cn.vuejs.org/api/sfc-scr…。以下是 Vue 模板的全部代码。

<template>
  <i :class="ns.b()" :style="style" v-bind="$attrs">
    <slot />
  </i>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useNamespace } from '@fz-mini/hooks'
import { addUnit, isUndefined } from '@fz-mini/utils'
import { iconProps } from './icon'
import type { CSSProperties } from 'vue'
defineOptions({
  name: 'FzIcon',
})
const props = defineProps(iconProps)
const ns = useNamespace('icon')
const style = computed<CSSProperties>(() => {
  const { size, color } = props
  if (!size && !color) return {}
  return {
    fontSize: isUndefined(size) ? undefined : addUnit(size),
    '--color': color,
  }
})
</script>

3、Icon 组件的注册

组件注册一般有两种方式,一种是在日常开发中常用的局部注册,通过直接导入组件在 .vue 文件中,还有一种就是全局注册,全局注册在组件库中使用是尤为必要的,能减少很多多余的代码。接下来我会着重介绍组件库中如何全局注册组件。

Element Plus 全局注册组件的设计思路:把实现的组件通过 install 的方式注册成一个插件,把安装的插件进行放入一个组件插件数组中,依次遍历数组,通过 app.use() 来依次使用已经安装的插件。执行每一个组件的 install() 方法,方法里进行组件的全局注册,这样组件库里面的每个组件都将被注册到全局组件中去了。

既然是全局组件都需要使用这种实现思路,Element Plus 将它封装成一个 withInstall 方法。接下来分析这个 withInstall 方法。Element Plus 将它封装在 packages\utils/vue/install.ts 目录下。

import type { Plugin } from 'vue'

export type SFCWithInstall<T> = T & Plugin
import type { SFCWithInstall } from './typescript'

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 ?? {})]) { 
      //因为在组件动态声明了组件名,所以这个可以获取到传入的组件的name
      app.component(comp.name, comp)
    }
  }

  if (extra) {
    for (const [key, comp] of Object.entries(extra)) {
      ;(main as any)[key] = comp
    }
  }
  return main as SFCWithInstall<T> & E
}

main 这个参数传组件main 类型强制推断成插件类型,判断 extra 是否有值 ,有就给传入的组件添加上,它用来给组件添加额外属性或方法,扩展组件功能,并将 mainextra 的属性值合并一起全局注册组件。返回类型为 SFCWithInstall<T>的插件类型的插件。

封装 withInstall 方法后,我们需要在 icon/index.ts将这个 icon 组件注册成一个插件并暴露出去。

import { withInstall } from '@fz-mini/utils'
import Icon from './src/icon.vue'

export const FzIcon = withInstall(Icon)
export default FzIcon
export * from './src/icon'

想要测试组件是否有效,可以再根目录下的 play 文件夹下将注册好的插件在 main.ts 中通过 use 方法遍历使用,然后再使用平常组件一样使用开发好的组件。

在根目录下下载 xicon icon组件库,下载其他的也可以,ELement Plus 是自己的 icon 组件库。在使用的组件中通过插槽的方式写入组件里。

<script setup lang="ts">
  import { Add } from '@vicons/ionicons5'
</script>

<template>
  <fz-icon>
    <Add />
  </fz-icon>
</template>

<style scoped></style>

四)、Icon 组件的 SCSS 实现

Element Plus 使用 SCSS 封装 CSS 变量,Element Plus 组件中使用大量 SCSS 的 mixinfunction 封装组件的类名和处理变量值。解释button 组件源码时再着重分析 Element Plus 中的 SCSS 源码,目前我们会简单封装一个符合 Icon 组件功能的 CSS 全局变量。

Element Plus 组件的 SCSS 核心逻辑在 theme-chalk 文件夹下。接下来会实现 icon 组件的 SCSS。

1、theme-chalk/src/mixins/config.scss

全局需要使用的 SCSS 常量。

$namespace: 'fz' !default;
$common-separator: '-' !default;
$miniui-separator: '__' !default;
$modifier-separator: '--' !default;
$stateSuffix: 'is-' !default;

2、theme-chalk/src/mixins/mixins.scss

定义块级类名,在 icon 组件中只引用 BEM 中的块级名,与之对应 SCSS 变量中也需要声明这个类名变量, Element Plus 这个文件夹的源码有很多其他封装的 mixin ,这些等后面涉及到了在一一解释。现在我只解释 mixin b() mixin when(),至于不知道 mixin 和 SCSS 的用法可以参考 SCSS基本使用 进行学习。

  • @content 是一个占位符,它允许在调用 mixin 时将样式块传递进来,类似于组件插槽
  • @at-root 是一个 Sass 指令,用来控制生成的 CSS 规则的嵌套层级。它会将其内部的内容提升到顶层,而不是嵌套在父选择器中。这样可以确保生成的样式规则直接作用于顶层元素,而不是嵌套的子元素 。
@forward 'config'; //导出 config
@use 'config' as *;
// 生成 BEM 命名格式的块 @include b (icon) => .fz-icon{ }
@mixin b($block) {
  $B: $namespace + $common-separator + $block !global;
  .#{$B} {
    @content;
  }
}

// @inclue when (loading) => .fz-icon.is-loading{ }
@mixin when($state) {
  @at-root {
    &.#{$state-prefix + $state} {
      @content;
    }
  }
}

3、Icon 组件的 SCSS 样式实现

实现组件加载动画,背景色填充更改,尺寸大小更改。

@use 'mixins/mixins.scss' as *;

.#{$namespace}-icon--right {
  margin-right: 5px;
}
.#{$namespace}-icon--left {
  margin-left: 5px;
}
.#{$namespace}-icon-loading {
  animation: rotating 2s linear infinite;
}
@keyframes rotating {
  0% {
    transform: rotateZ(0deg);
  }
  100% {
    transform: rotateZ(360deg);
  }
}
@include b(icon) {
  --color: inherit;
  height: 1em;
  width: 1em;
  line-height: 1em;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
  fill: currentColor;
  color: var(--color);
  font-size: inherit;
  @include when(loading) {
    animation: rotating 2s linear infinite;
  }
  svg {
    width: 1em;
    height: 1em;
  }
}

三、总结

本章内容比较长,详细的介绍了从零搭建一个 icon 组件的步骤,愿诸君越来越好,一起进步。

  • icon 组件目录介绍
  • icon 组件 props 分析和类型解析
  • icon vue 模板功能实现、全局注册组件
  • icon 组件库下载
  • icon 组件的 SCSS 源码分析和样式实现