likes
comments
collection
share

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

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

前言

本文部分灵感来自vbenjs-admin

svg icon 拥有很多优秀的特性,在我司的项目中也大量使用了这种icon方案,本文就特地将我在使用中的一些经验分享给大家,希望对大家有所帮助。

安装 vite-plugin-svg-icons

pnpm add vite-plugin-svg-icons fast-glob -D

安装 fast-glob 的原因是 vite-plugin-svg-icons依赖了它。

然后配置vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
import path from "path";

export default defineConfig({
  plugins: [
    vue(),
    createSvgIconsPlugin({
      // 指定目录
      iconDirs: [path.resolve(process.cwd(), "src/icons")],
      // 使用svg图标的格式
      symbolId: "icon-[dir]-[name]",
    }),
  ],
});

然后需要在src目录下创建icons目录,我们在项目中使用的svg 图标都放在这里面,这里我们放三个图标 home.svgaddress.svgsetting.svg 的图标进去。

然后在入口处引入插件

// main.ts
import "virtual:svg-icons-register";

然后我们在模板中就可以引用了

<template>
  <div>
    <svg>
      <use xlink:href="#icon-home"></use>
    </svg>
  </div>
</template>

到这里可以看到 svg-icon 已经成功渲染出来了:

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

封装 SvgIcon 组件

目前我们使用的方式还比较原始,为了方便使用,我们将他们封装起来。

svg/components 目录下面创建一个文件SvgIcon.vue

<template>
  <svg
    :style="{ width: sizeRef + 'px', height: sizeRef + 'px' }"
    class="svg-icon-wrapper"
  >
    <use :xlink:href="prefix + name" :fill="colorRef"></use>
  </svg>
</template>

<script setup lang="ts">
import { computed } from "vue";

defineOptions({
  name: "SvgIcon",
});

export type IconSize = "default" | "small" | "large";

export type IconColor =
  | "primary"
  | "default"
  | "success"
  | "warn"
  | "error"
  | (string & {});

const props = withDefaults(
  defineProps<{
    /** icon 的前缀 */
    prefix?: string;
    /** icon 名称 */
    name: string;
    /** icon 的颜色 */
    color?: IconColor;
    /** icon 的尺寸 */
    size?: IconSize | number;
  }>(),
  {
    prefix: "#icon-",
    size: "default",
    color: "default",
  }
);

const sizeMap: Record<IconSize, number> = {
  default: 16,
  small: 12,
  large: 24,
};

const colorMap: Record<IconColor, string> = {
  primary: "#409EFF",
  success: "#67C23A",
  error: "#bb1b1b",
  warn: "#F56C6C",
  default: "#333333",
};

const colorRef = computed(() => {
  return colorMap[props.color] || props.color;
});

const sizeRef = computed(() => {
  if (typeof props.size === "string") {
    return sizeMap[props.size];
  }
  return props.size;
});
</script>

<style>
.svg-icon-wrapper {
  display: inline-block;
}
</style>

这个组件的封装了一些常用功能,并且设置了一些内置的值,让我们可以更好地统一写法,比如 sizecolor, 这里需要提一句的是color 的类型定义:

export type IconColor =  "primary" | "default" | "success" | "warn" | "error" | (string & {});

这里的意思这 color 有几个预设值,但是也接受其它string 类型的值,这里大家可能会问为什么不写 string,答案是这样写可以有类型提示,看图:

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

这里是一个小技巧,感兴趣的同学可以查一下 typescript 的相关资料。

然后再同级目录下创建一个 index.ts ,创建一个插件,将组件导出去

// src/components/SvgIcon/index.ts

import type { App } from "vue";
import SvgIcon from "./SvgIcon.vue";

/**
 * 让 SvgIcon 具有类型提示
 */
declare module "vue" {
  export interface GlobalComponents {
    SvgIcon: typeof SvgIcon;
  }
}

const SvgIconPlugin = (app: App) => {
  app.component(SvgIcon.name, SvgIcon);
};

export { SvgIcon, SvgIconPlugin };

这里需要注意,由于我们要将组件注册在全局,所以这里要拓展一下类型,即:

/** * 让 SvgIcon 具有类型提示 */ 
declare module "vue" { 
   export interface GlobalComponents { 
      SvgIcon: typeof SvgIcon; 
   } 
}

然后在入口文件引用

import { SvgIconPlugin } from "./components/SvgIcon";

createApp(App).use(SvgIconPlugin).mount("#app");

我们在 App.vue 文件中使用一下:

<template>
  <div class="wrapper">
    <SvgIcon name="home" color="primary" size="large"></SvgIcon>
    <SvgIcon name="setting" color="success" size="small"></SvgIcon>
    <SvgIcon name="address" color="error" :size="30"></SvgIcon>
  </div>
</template>

下面是效果:

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

进一步使用会发现 ts 提示也很友好

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

到这里,我们就已经编写了一个很好用的 SvgIcon 组件了。

编写自定义 vite 插件,增强 name 字段类型提示

上面的代码其实已经比较优雅了,但是还是有可以进一步优化的点,那就是 name 字段的类型提示,我们可以看到,由于 name 的类型签名是string,导致我们没法得到输入的建议;

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

我们希望它能提示我们输入home setting address,然而现实却不尽如人意。 对于一个成熟的typescript爱好者而言,这是万万不可的,那么有没有办法去优化这一块呢?

答案是有的,由于src/icons 下面的目录都是些静态文件,我们可以通过文件系统读取到这些文件,并且把他们写到一个 d.ts 文件中,并且把它暴露到全局,这样一来我们就可以用到它了。

vite 插件开发不是本文的重点,笔者也正在学习这一块,所以这里大家直接参考我的代码就可以了。

我们在根目录下创建一个 vite/vite-plugin-icon-dts.ts 文件,

把这个文件放在 tsconfig.jsoninclude选项中(有可能你的项目已经按照一些规则包含了这个文件,所以这里不是必要的);

然后在这个文件中编写插件代码:

先安装一个依赖

pnpm add fs-extra -D

// vite/vite-plugin-icon-dts.ts
import fs from "fs-extra";
import glob from "fast-glob";
import path from "path";

const PLUGIN_NAME = "vite-plugin-icon-dts";

const error = (...args: any[]) => {
  console.error(`${PLUGIN_NAME}: `, ...args);
};

function debounce(fn: () => void, wait: number) {
  let timer: any = null;
  return function () {
    if (timer !== null) {
      clearTimeout(timer);
    }
    timer = setTimeout(fn, wait);
  };
}

// glob 默认只支持 / 作为路径分隔符,windows 下会出现问题
const normalizePath = (path: string) => path.replace(/\\/g, "/");

interface IconDtsOptions {
  /** 监听的目录 */
  directory: string;
  /** 输出的dts文件 */
  dts: string;
  /** 监听变化的延迟时间 */
  delay: number;
  /** 接口名称 */
  interfaceName: string;
}

const defualtOptions: IconDtsOptions = {
  directory: "src/icons/",
  dts: "icons-dts.d.ts",
  delay: 200,
  interfaceName: "ISvgIconPath",
};

export const iconDts = (options: Partial<IconDtsOptions> = {}) => {
  const finalOptions: IconDtsOptions = Object.assign(
    {},
    defualtOptions,
    options
  );
  const { delay, interfaceName } = finalOptions;
  let { directory, dts } = finalOptions;
  directory = normalizePath(directory);
  dts = normalizePath(dts);

  return {
    name: PLUGIN_NAME,
    configureServer: async () => {
      if (!fs.existsSync(directory)) {
        error(`directory ${directory} not exist, please check`);
        return;
      }

      const update = () => {
        let assets: any = glob.sync(`${directory}/**/*.svg`, {});
        assets = assets.map((i: string) => i.replace(directory, ""));
        assets = assets.map((i: string) => i.replace(".svg", ""));
        assets = assets.map((i: string) => i.replace("/", "-"));

        let output = `/* prettier-ignore-start */\n/* tslint:disable */\n/* eslint-disable */\ninterface ${interfaceName} {\n`;
        for (let i = 0; i < assets.length; i++) {
          const pic = assets[i];
          output += `  '${pic}': string;\n`;
        }
        output += `}\n/* prettier-ignore-end */\n`;

        const base = path.dirname(dts);
        fs.ensureDirSync(base);
        fs.writeFileSync(dts, output);
      };

      const debounceLogic = debounce(update, delay);
      // 监听到文件变化,就重新写一遍
      fs.watch(directory, { recursive: true }, () => {
        debounceLogic();
      });
      update();
    },
  };
};

核心思路就是在 vite 启动的时候生成 dts 文件,后续再监听该目录,持续更新生成最新的代码。

不出意外的话,你会得到一个文件icons-dts.d.ts 文件

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

然后我们把这个文件同样加入到 tsconfig.jsoninclude选项中。

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

这样一来,我们就可以在 ts 的类型空间中,得到一个 ISvgIconPath 的类型,接下来我们去处理我们 SvgIcon文件。

export type IconName = keyof ISvgIconPath | (string & {});

{
    ...
    /** icon 名称 */
    name: IconName;
    ...
}

然后我们看看效果

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

在有文件夹嵌套的情况也能正常工作

[Vue3 + ts 干货👍] 手把手教你封装一个 SvgIcon 组件

完美!!!

总结

本文手把手带大家封装了一个实用的 SvgIcon 组件,能满足基础的功能,而且类型提示也足够智能,在这个基础上可以加以拓展,封装出更好用的组件,非常具有学习的意义,希望对大家有所帮助。

感兴趣的同学可以点击这里获取到完整的代码,

欢迎大家关注我的其它文章,后续会持续更新 vue3 +ts 的相关知识

码字不易,如果觉得有用的话,点个👍👍👍再走吧!