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

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

专栏上篇文章传送门:Web 中的字体和 SVG 图标,你了解多少?

本节涉及的内容源码可在vue-pro-components c4 分支找到,欢迎 star 支持!

前言

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

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

按钮集成图标组件

背景介绍

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

image.png

虽然能实现,但是感觉写起来也挺复杂的,代码量不少,那么能不能简化成这样呢?只要通过一个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,我们发现,这需要将组件注册好,不管是注册到局部还是全局,都可以。

image.png

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

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

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

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

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

封装按钮组件

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

image.png

我们首先要考虑的是: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 的属性是由这些代码构造出来的。

image.png

image.png

我们新建一个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 的属性定义:

image.png

接着我们用一个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>

image.png

可以发现属性很完整了,其中框起来的部分是我们扩展的属性,剩下的都是 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是具备响应式特性的。

image.png

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

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

image.png

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

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

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

image.png

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

image.png

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

image.png

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

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

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

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

image.png

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

image.png

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

引入PlusOutlined图标组件:

image.png

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

image.png

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

[Vue warn]: Failed to resolve component: PlusOutlined

image.png

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

image.png

效果就出来了:

image.png

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

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

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

image.png

图标选择器

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

image.png

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

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

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

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

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

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

image.png

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

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

image.png

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

image.png

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

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

承认不完美

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

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

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

image.png

image.png

image.png

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

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

image.png

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

但我至少是解决了问题。

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

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

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

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

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

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

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

image.png

我们发现,这里面有一些特征可以捕捉到,只要把符合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 属性只需要简单的名称即可,其内部会与前缀拼接。

image.png

根据图标清单实现选择器

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

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

图标选择器.gif

结语

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

© 版权声明
THE END
喜欢就支持一下吧
点赞14 分享
相关推荐
  • 暂无相关文章
  • 评论 抢沙发
    头像
    欢迎您留下宝贵的见解!
    提交
    头像

    昵称

    取消
    昵称表情代码图片

      暂无评论内容