likes
comments
collection
share

衍生需求:按钮集成图标组件 & 图标选择器

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

前言

本文是 基于Vite+AntDesignVue打造业务组件库 专栏第 5 篇文章【衍生需求:按钮集成图标组件 & 图标选择器】,聊聊实际业务中与图标组件相关的一些衍生需求,例如:

  • 怎么通过一个简单的icon属性就能在a-button中用上我们的图标组件?
  • 怎么实现一个可视化的图标选择器?

按钮集成图标组件

背景介绍

按钮中搭配图标一起用,是再常见不过的场景了。ant-design-vue 的 Button 组件具备自定义图标的能力,具体是通过icon插槽实现的。

衍生需求:按钮集成图标组件 & 图标选择器

虽然能实现,但是感觉写起来也挺复杂的,代码量不少,那么能不能简化成这样呢?只要通过一个icon属性(而不是插槽)就能把图标展示出来呢?最理想的状态是还能同时支持我们自己的业务图标。

// 比较理想的用法
// 既支持
<a-button type="primary" icon="SearchOutlined">Search</a-button>
<a-button type="primary" icon="location">Search</a-button>

事实上,ant-design-vue没有支持这种能力。

首先,从字符串到组件,是需要一个解析的过程,这对应resolveComponent,简单看下源码,resolveComponent内部会调用resolveAssets,我们发现,这需要将组件注册好,不管是注册到局部还是全局,都可以。

衍生需求:按钮集成图标组件 & 图标选择器

而 ant-design-vue 是一个通用组件库,它提供的图标都是一个个独立的组件,这些组件都在@ant-design/icons-vue这个包里。如果要实现字符串到组件的解析能力,就要求把图标组件都提前注册好,这就违背了按需加载的初衷。

另外, ant-design-vue 也要考虑用户自定义图标的场景,所以综合来看留个插槽算是比较合理的做法。

然而,对业务方来说,通常考虑的是:

  • 大而全:能力丰富,既要有原始组件本身的能力,还能增加一些定制的能力;
  • 用起来方便:提供最简单的用法;
  • 性能过得去:没有明显的性能负担即可。

那么,我们自己来尝试实现一下这些能力。

封装按钮组件

a-button我们也不能改,所以,需要先做一个vp-button,它既有a-button的全部能力,还能支持使用各种图标组件,这样才不至于说封装了一个组件,却牺牲了底层的能力。

衍生需求:按钮集成图标组件 & 图标选择器

我们首先要考虑的是:AButton 本身有很多属性,那么我们怎么让 VpButton 同样支持这些属性呢?

有两条路子可供选择:

  1. 利用v-bind="$attrs"透传属性,但是出于性能考虑,通过$attrs透传的这部分 attributes 不像 props 那样具备响应式特性。
  2. 将 AButton 支持的 props,都列入 VpButton 的 props 中,然后 VpButton 再原样通过属性绑定传递给 AButton,这样就能保证这些 props 的响应式依然有效。

路子1虽然是最简单的,但缺失响应式这一缺点有时候会很致命。

路子2是比较靠谱的,但是使用起来很繁琐,需要将 AButton 支持的属性重复定义在 VpButton 中。此外,一旦粗心就可能会遗漏一些属性,这就会导致功能是有缺失的,那么怎么解决这些问题呢?

我的思路是:

  1. 想办法把 AButton 的 props 定义取出来,与我们要额外扩展的属性做一个合并,统一作为 VpButton 的 props 定义。这样一来,从外部调用者的视角来看,VpButton 支持的属性就是完整的,给人的直观感觉就是:VpButton 是 AButton 的加强版,我可以放心使用。
  2. 在 VpButton 内部需要封装 AButton,同时要从所有 props 中将属于 AButton 的那部分 props 挑选出来,传递给 AButton,这样对 AButton 来说就是无感的,因为我们传给 AButton 的属性是完全符合要求的。

只要我们封装的 VButton 满足了上面这两点,这个组件就是趋近完美的,它向上对调用者提供了更强大的能力,同时向下又包容了 AButton 的能力。

我们来试试看,大致查阅 ant-desigin-vue 的 Button 组件源码,我们可以发现,AButton 的属性是由这些代码构造出来的。

衍生需求:按钮集成图标组件 & 图标选择器

衍生需求:按钮集成图标组件 & 图标选择器

我们新建一个button/props.ts文件,尝试一下下面的代码,看看能不能拿到预期的 AButton 属性定义,如果能成功,那就意味着我们就不必一个一个属性地重复定义了,同时也意味着我们得到了一种扩展属性的基本方法。

import buttonProps from 'ant-design-vue/es/button/buttonTypes'
import { initDefaultProps } from 'ant-design-vue/es/_util/props-util'

const _buttonProps = initDefaultProps(buttonProps(), {
    type: 'default',
})

console.log(_buttonProps)

打印出来发现,这就是我们需要的 AButton 的属性定义:

衍生需求:按钮集成图标组件 & 图标选择器

接着我们用一个enhancedProps来定义需要扩展的属性,这里先给出以下几个属性:

export const enhancedProps = {
    // 对应自定义图标的名称
    ico: {
        type: String,
    },
    // 图标的大小
    icoSize: {
        type: Number,
    },
    // 图标颜色
    icoColor: {
        type: String,
    },
    // 按钮主体颜色,影响边框颜色,背景色
    primaryColor: {
        type: String,
    },
}

ico接收图标名称,是为了避免与AButtonicon插槽冲突。

然后我们把_buttonPropsenhancedProps这两部分组成一个完整的props

export const innerKeys = Object.keys(_buttonProps)
export const enhancedKeys = Object.keys(enhancedProps)

export const props = {
    ..._buttonProps,
    ...enhancedProps,
}

export type VpButtonProps = ExtractPropTypes<typeof props>

衍生需求:按钮集成图标组件 & 图标选择器

可以发现属性很完整了,其中框起来的部分是我们扩展的属性,剩下的都是 AButton 支持的属性。

接下来就是看怎么使用这些属性了,直接上组件主体代码,这里用了 tsx 实现。

import { defineComponent } from 'vue'
import { Button } from 'ant-design-vue'
import IconSvg from '../icon-svg'
import { innerKeys, props as buttonProps } from './props'
import { usePickedProps } from '../hooks/props'

export default defineComponent({
    name: 'VpButton',
    props: buttonProps,
    setup(props, { slots }) {
        // 把属于 AButton 的属性挑选出来,再绑定到 AButton 上
        const innerProps = usePickedProps(props, innerKeys)

        return () => (
            <Button
                {...innerProps.value}
                class="vp-button"
                style={{ backgroundColor: props.primaryColor, borderColor: props.primaryColor }}
                v-slots={{
                    ...slots,
                    default: () => (
                        <>
                            {props.ico && !props.loading ? <IconSvg icon={props.ico} size={props.icoSize} color={props.icoColor} /> : null}
                            {slots?.default?.()}
                        </>
                    ),
                }}
            ></Button>
        )
    },
})

这里用到了一个usePickedProps方法,将 AButton 支持的属性全部挑选出来,然后绑定到 AButton 上(因为 props 中有我们扩展的属性,而这部分不需要传给 AButton)。

usePickedProps的逻辑也不复杂,主要是基于lodash-espick方法进行属性挑选,然后用computed计算属性返回结果,这样才能保证得到的innerProps是具备响应式特性的。

衍生需求:按钮集成图标组件 & 图标选择器

而在图标这块的处理,除了支持通过ico属性直接展示IconSvg图标,我们依然支持通过icon插槽进行自定义的图标展示,这与 AButton 的默认行为是一致的。

当我们在 package playground引入这个 VpButton 组件使用时,会发现报了一个错误Uncaught ReferenceError: React is not defined

衍生需求:按钮集成图标组件 & 图标选择器

这是因为我们的当前环境还不支持jsx,需要引入一个@vitejs/plugin-vue-jsx插件。

// 安装一下 jsx 插件
lerna add @vitejs/plugin-vue-jsx --scope=playground --dev

vite.config.ts增加 jsx 相关配置:

衍生需求:按钮集成图标组件 & 图标选择器

由于 VpButton 内部用到了 AButton 和 IconSvg 这两个组件,而这两个组件也是有定义样式的,所以我们在button/style/index.less中引入一下相关的样式依赖。

衍生需求:按钮集成图标组件 & 图标选择器

接着我们测试一下基本效果,基本上可以满足常见使用场景:

衍生需求:按钮集成图标组件 & 图标选择器

ico属性支持多种图标源可行吗?

那么有没有可能实现上面说的:用一个ico属性,既能支持 ant-design 的内置图标,又能支持由 IconSvg 组件实现的业务图标呢?我们可以尝试做一下看看。

如上文所述,首先需要有一个字符串到组件的解析过程,这需要用到resolveComponent,这部分逻辑可以内置到 VpButton 组件中。与此同时,还需要将 ant-design 的图标注册到组件上下文中,这部分操作放在业务调用方比较合适(这可以支持按需加载),因为我们不可能把所有 ant-design 的图标都注册到 VpButton 组件中,这会让 VpButton 组件变成一个巨型组件。

好,思路清楚后,我们首先实现 VpButton 内部的逻辑。为了减少判断逻辑,我们通过一个icoSource标识图标的来源,默认为"biz",表示展示 IconSvg 支持的业务图标,同时支持"antd",表示展示 ant-design 的图标。

衍生需求:按钮集成图标组件 & 图标选择器

icoSource的值为"antd"时,我们利用 Vue 提供的resolveComponenth进行组件解析和渲染,否则逻辑照旧。

衍生需求:按钮集成图标组件 & 图标选择器

接着我们在App.vue调用一下。

引入PlusOutlined图标组件:

衍生需求:按钮集成图标组件 & 图标选择器

尝试通过icoSourceico属性渲染出图标:

衍生需求:按钮集成图标组件 & 图标选择器

结果发现,resolveComponent还是找不到 PlusOutlined 组件。

[Vue warn]: Failed to resolve component: PlusOutlined

衍生需求:按钮集成图标组件 & 图标选择器

回头看了一下源码resolveComponent的流程,发现它只会在当前组件实例和应用实例中去寻找组件,而resolveComponent是在 Button 组件中使用的,即便我们在App.vue中引入了 PlusOutlined 也是解析不到的,所以只能在应用实例全局注册 PlusOutlined,类似这样:

衍生需求:按钮集成图标组件 & 图标选择器

效果就出来了:

衍生需求:按钮集成图标组件 & 图标选择器

但是这样用起来也是相当繁琐,虽然实现了功能,但还不如直接用icon插槽简单呢,所以这条路基本上可以选择放弃了。

这部分代码可以见这个版本,相关分支上就不保留这部分代码了。

如果与unplugin-vue-components配套使用,其提供的AntDesignVueResolver也支持自动识别并导入@ant-design/icons-vue中的图标,用起来也算方便。

衍生需求:按钮集成图标组件 & 图标选择器

图标选择器

在中后台或者一些低码搭建场景中,很多地方需要动态配置图标,最常见的可能就是给菜单配图标。比较简单的实现方式就是直接用一个文本输入框配置图标名,但是这样并不直观,也容易出错,因为你不确定你输入的图标名是不是对应一个有效的图标。而且这要求操作人员熟知图标的名称,显然不是很方便。

衍生需求:按钮集成图标组件 & 图标选择器

那么能不能提供一个图标选择器进行可视化的配置呢?我们可以来试一试!

要进行图标的选择,首先必须知道有哪些图标,也就是需要有一个图标清单。那么具体怎么做呢?

一个简单粗暴的方法是:项目中维护一个数组,把 icon 名称全部都手动录入。但是这样显得很繁琐,每个业务项目都要手动录,太容易出错了。

另一个方法是:从 iconfont 图标库中寻找有用的信息,基于这些信息编写脚本自动生成一个图标清单。

那么 iconfont 中有哪些我们可以利用的信息呢?

我最开始想的是检查 iconfont 项目调用的接口,从接口中把信息抓出来。确实找到了一个detail.json请求,这里有相关的 icons 数组。

衍生需求:按钮集成图标组件 & 图标选择器

记得前些时间还检查过,iconfont 还没有提供这个 icons 字段,可能最近优化了。

虽然请求是找到了,但是还要考虑调这个请求是不是要验证 token 等身份信息。果不其然,需要验证!

衍生需求:按钮集成图标组件 & 图标选择器

这也就意味着,如果我们想用这个能力,需要打通登录流程,先调登录接口,再调这个 detail.json 的请求。

衍生需求:按钮集成图标组件 & 图标选择器

只要模拟一下这个登录请求即可,看着不复杂,其实做起来不简单,首先要搞清楚 password 的加密策略,还有两个 bx- 开头的字段是怎么得来的,这需要研究一下 iconfont 相关的 js 代码。

而且,我们需要把账号密码存在某个配置文件中,不是很安全,所以也不建议这样做。

承认不完美

要写这么一节,我觉得也是挺逗的。

自古文人相轻,实际上,各行各业都是这样,搞技术的也逃脱不了这种怪圈。

接上面,我的需求是找到图标清单,所以自然是从 iconfont 提供的一些信息中去找,从在线的信息中确实只找到了这些。

衍生需求:按钮集成图标组件 & 图标选择器

衍生需求:按钮集成图标组件 & 图标选择器

衍生需求:按钮集成图标组件 & 图标选择器

以及调用的一些接口信息。

我最大的问题就是我没有去尝试把那个 js 的后缀改为 json 试一试。事实上,cdn 上确实有这个 json 链接。

衍生需求:按钮集成图标组件 & 图标选择器

从这个 json 中取出图标清单就更简单了。但是,我之前没发现,sorry,因此写了后面一节稍微复杂的解法。

但我至少是解决了问题。

于是,某喷子找到了这个喷点,就马上开始了,我让他指条路,不知道触动了他哪条神经。

终于想通了为什么 Uzi 拿不到冠军,尤雨溪在国外才能做出 Vue。

就这样吧,EQ 闪你都不会吗?灯笼不会捡吗?

还是感谢您提供的信息吧,改成从 json 取信息了。

js 链接 + 正则取得图标清单

我们换个思路,既然不想登录,但是又要获得图标清单,那就只能从一些公开的资源上去做文章了。

还好,iconfont 提供的 js 链接是公开的,而且这里面也包含了图标信息。

衍生需求:按钮集成图标组件 & 图标选择器

我们发现,这里面有一些特征可以捕捉到,只要把符合vp-icon-前缀的内容提取出来,就能得到图标清单。

话不多说,直接上代码,主要是一些正则的逻辑:

import fs from "fs"
import axios from "axios"

const SVG_ICON_SCRIPT_URL = "https://at.alicdn.com/t/c/font_3736402_d50r1yq40hw.js"
const SVG_ICON_PREFIX = "vp-icon-"

function getIcons(str) {
    const reg = new RegExp(`id="${SVG_ICON_PREFIX}([^"]+)"`);
    return str.match(/id="([^"]*)"/g).map((item) => item.replace(reg, "$1"));
}

export async function genIconListJson() {
    try {
        const res = await axios.get(SVG_ICON_SCRIPT_URL);
        console.log(res)
        if (res.status === 200) {
            const iconList = getIcons(res.data);
            console.log(iconList);
            fs.writeFile(new URL("../src/assets/json/icons.json", import.meta.url), JSON.stringify(iconList, null, 2), function (err) {
                if (err) {
                    return console.error(err);
                }
                console.log("图标清单写入成功!");
            });
        } else {
            console.error(res.status, res.statusText);
        }
    } catch (err) {
        console.error(err);
    }
}

genIconListJson();

执行脚本后,就能得到一个 json 文件了,这里有全部的图标名称。我们特意去掉了图标前缀,因为 IconSvg 组件的 icon 属性只需要简单的名称即可,其内部会与前缀拼接。

衍生需求:按钮集成图标组件 & 图标选择器

根据图标清单实现选择器

拿到了图标清单,剩下的工作就比较简单了,无非是把图标循环渲染出来,让用户选择即可。同时提供一个搜索功能,方便在图标数量很大时能够通过名字检索。

代码并不复杂,感兴趣的可以 fork 源码看一下。这里展示下图标选择器的使用效果。

结语

本文以实际业务中与图标组件相关的衍生需求为背景,介绍了如何封装一个基础组件,以及如何在封装组件时既能在基础组件之上做扩展,同时又不牺牲掉基础组件的原有能力。总的来说,这不仅仅是在讲解如何开发一个组件,更多的是介绍一种通用的上层组件封装思想,希望对大家有所帮助。如果您对我的专栏感兴趣,欢迎您订阅关注本专栏,接下来可以一同探讨和交流组件库开发过程中遇到的问题。