likes
comments
collection
share

让IconPark和Vue3灵魂契合(响应式更新)

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

如果你只是听过这个来自字节跳动的图标库,或许你就关掉了这篇文章,但如果你已经用过了,相信本文可以解答你的一个疑惑。

码字不易,点个赞再走吧,最近两篇文章都是收藏比点赞多是怎么回事 =_=!

Icon Park是什么

IconPark,是字节跳动出品的一款图标库,与阿里出品的IconFont相似。

IconPark图标库是一个通过技术驱动矢量图标样式的图标库产品,可以实现根据单一SVG源文件变换出多种主题, 具备丰富的分类、更轻量的代码和更灵活的使用场景;致力于构建高质量、统一化、可定义的图标资源,让大多数设计师都能够选择适合自己的风格图标,并支持把图标源文件导出为React、Vue2、Vue3、SVG多种形式的组件代码,打通 Design to Code 链路,实现产品、研发、设计师一站式对接,使用更高效,具备一项发明专利。

是的,IconPark不仅仅是一套统一规范的SVG图标素材,字节还为其开发了适配各主流前端框架的Npm包,可以方便地使用import语句导入到你的项目中,并直接以组件形式使用。

以Vue3为例,以图标名的大驼峰形式作为组件名引入,在模板中绑定一些props:

<template>
    <Home theme="filled"/>
</template>
<script>
import {Home} from '@icon-park/vue-next';

export default {
    components: {
        Home
    }
}
</script>

一些支持的Props,可以看到,几乎完全就是SVG元素的属性:

属性名称介绍类型默认值
theme图标主题'outline' | 'filled' | 'two-tone' | 'multi-color''outline'
size图标的大小,宽高相同number | string'1em'
spin给图标加旋转效果booleanfalse
fill图标的颜色,不超过4个颜色,默认为当前颜色string | string[]'currentColor'
strokeLinecapsvg元素的stroke-linecap属性'butt' | 'round' | 'square''round'
strokeLinejoinsvg元素的stroke-linejoin属性'miter' | 'round' | 'bevel''round'
strokeWidthsvg元素的stroke-width属性number4

但是我不太喜欢这样的方式引入,组件本身的标签名应当具有逻辑意义,例如<UserList />,一看就知道是一个用户列表,那么它的样式上可能是table或者list,内容应当包含一些用户信息,操作可能包含新建/编辑/删除,并且自闭合意味着没有设计插槽功能。

<Home>可能会让我误以为是首页组件,然而这个标签的意义应当告知开发者:我是一个小图标,我身上的一个属性可以控制我显示什么图案,另外的属性可以控制颜色/大小等。这样才合理。

好在Icon Park提供了这样的引用方式:

<template>
    <IconPark type="AddText" theme="filled"/>
    <IconPark type="add-text" theme="filled"/>
</template>
<script>
import { IconPark } from '@icon-park/vue-next/es/all';

export default {
    components: {
        IconPark
    }
}
</script>

type属性接受一个合法的图标名字,它可以是短杠命名,也可以是大驼峰命名,Icon Park内部会把短杠转换成大驼峰格式(想想是你的话怎么转换?

关于更多IconPark的信息请访问Icon Park官网自行学习。

上手Icon Park

IconPark最大的价值是它有多达2437个风格统一的图标(截止2022.03.05),这对一些没有UI设计师的公司或个人开发者太友好了,终于不用发愁,从各家图标库里找一些符合需求的但风格又明显不一致的图标才能覆盖业务需求。

那,引入项目吧。

出于项目风格的统一性考虑,图标的线条粗细/颜色风格/主题/大小,应当是几乎一致的,这就意味着strokeWidth/fill/theme/size这些prop值应当是相同的。IconPark利用vue3的provide方法实现了全局配置的方法,但我们何尝不能自己封装一个组件,以备之后的不时之需呢?

开干。

新建一个vue文件,使用setup语法糖和typescript编写。

  • dom结构应当非常简单,一个span标签作为容器,允许IconPark填入svg标签;
  • props方面,我们把常用的一些Icon Park属性映射出去,大部分属性都要有个按照项目风格设定的默认值,例如颜色/大小/主题,做好类型校验;
  • 记得引入IconPark需要的CSS文件;
<template>
  <IconPark v-bind="{...$props,...$attrs}"></IconPark>
</template>
<script lang="ts" setup>
  import { IconPark } from '@icon-park/vue-next/es/all';
  import { IconThemeEnum } from '/@/enums/appEnum';
  import { computed, toRefs, ref, watch, render, createVNode, nextTick, onMounted } from 'vue';
  import { propTypes } from '/@/utils/propTypes'import '@icon-park/vue-next/styles/index.css';

  const props = defineProps({
    icon: propTypes.string.isRequired,
    theme: propTypes.oneOf(Object.values(IconThemeEnum)).def(IconThemeEnum.MULLTI),
    size: propTypes.oneOfType([propTypes.string, propTypes.number]).def(20),
    spin: propTypes.bool.def(false),
    color: propTypes
      .oneOfType([propTypes.arrayOf(propTypes.string), propTypes.string])
      .def(['#309cf6', '#118EF8', '#FFF', '#13f19c']),
    park: propTypes.bool.def(false),
  });
</script>
<style lang="less"></style>

这里,我们通过props的默认值实现了全局风格统一,但又可在不同情况下修改这些属性。

就这?

当然不是了,你会发现一个问题,当你的图标名字有变化时:

  • 开发阶段你手动改代码,vite(webpack没试过)热更新后,图标没变化;
  • 业务里会程序修改图标名字,图标没变化;

响应式更新

是的,它没跟着vue的响应式变化而变化。

翻了翻IconPark的源码,发现结构十分简单,首先是2400多个包含了svg信息的代码文件,一个文件输出一个svg标签的VNode,然后有一个IconWrapper方法,创建一个span标签,并把这个svg的VNode添加到span的内部,然后返回一个新的VNode

具体的原因还没看明白(太菜了,如有了解欢迎评论区交流),但是解决方案我在脑海里初具雏形。

利用watch方法,监听prop变化,然后重新创建一个VNode添加到容器中,重新渲染。基本上就是把IconPark的渲染逻辑重写一遍了。

首先翻一翻源码,我发现所有的图标VNode都在一个map文件映射出来了,那就引入它:

import * as IconMap from '@icon-park/vue-next/es/map';

拿到VNode以后就是要把它转换成真实DOM,这里用到Vue提供的两个方法createVNoderendercreateVNode可以把props传给VNoderender方法把VNode转换成真实DOM并渲染到某容器上。这里用到ref捕捉一个DOM节点作为容器即可。

const params = {
      theme: props.theme,
      size: props.size,
      fill: isArray(props.color) ? props.color : [props.color],
      spin: props.spin,
    };
const type = toPascalCase(props.icon);
render(createVNode(IconMap[type], params), elRef.value);

然后放到watch方法的回调里执行即可,看下完整代码吧:

<template>
  <span ref="elRef" :class="$attrs.class"></span>
</template>
<script lang="ts" setup>
  import * as IconMap from '@icon-park/vue-next/es/map';
  import { IconThemeEnum } from '/@/enums/appEnum';
  import { computed, ref, watch, render, createVNode, onMounted } from 'vue';
  import { propTypes } from '/@/utils/propTypes';
  import { isArray } from '/@/utils/is';
  import '@icon-park/vue-next/styles/index.css';

  const props = defineProps({
    icon: propTypes.string.isRequired,
    theme: propTypes.oneOf(Object.values(IconThemeEnum)).def(IconThemeEnum.MULLTI),
    size: propTypes.oneOfType([propTypes.string, propTypes.number]).def(16),
    spin: propTypes.bool.def(false),
    color: propTypes
      .oneOfType([propTypes.arrayOf(propTypes.string), propTypes.string])
      .def(['#309cf6', '#118EF8', '#FFF', '#13f19c']),
    park: propTypes.bool.def(false),
  });
  const color = computed(() => (isArray(props.color) ? props.color[0] : props.color));
  const elRef = ref<Element>();
  function toPascalCase(val:string) {
    return val.replace(/(^\w|-\w)/g, function (c) {
      return c.slice(-1).toUpperCase();
    });
  }
  // 当在primary类型的Button中默认表现为白色而不是主题色
  function setStyle() {
    const parent = elRef.value!.parentElement!.className;
    const inPrimaryBtn = /-btn-primary/i.test(parent);
    if (inPrimaryBtn) {
      return {
        fill: '#fff',
        theme: IconThemeEnum.OUTLINE,
        size: 16,
        class: 'mr-2px',
      };
    }
    const inDefaultBtn = /-btn/i.test(parent);
    if (inDefaultBtn) {
      return {
        theme: IconThemeEnum.OUTLINE,
        size: 16,
        class: 'mr-2px',
      };
    }
    //其他特殊的默认样式也都可以这样做
    return {};
  }
  async function update() {
    const params = {
      theme: props.theme,
      size: props.size,
      fill: isArray(props.color) ? props.color : [props.color],
      spin: props.spin,
      ...setStyle(),
    };
    const type = toPascalCase(props.icon);
    if (!(type in IconMap)) {
      throw new Error(''.concat(type, ' is not a valid icon type name'));
    }
    render(createVNode(IconMap[type], params), elRef.value!);
  }
  watch(() => props, update, { flush: 'post' });

  onMounted(update);
</script>
<style lang="less"></style>

码字不易,点个赞再走吧,最近两篇文章都是收藏比点赞多是怎么回事 =_=!