【技术改造】前端图标组件库搭建流程

1-1 构建源码bulid.ts

import path from 'node:path';
import chalk from 'chalk';
import consola from 'consola';
import { build, type BuildOptions, type Format } from 'esbuild';
import GlobalsPlugin from 'esbuild-plugin-globals';
import { emptyDir, copy } from 'fs-extra'; // 导入 copy 方法
import vue from 'unplugin-vue/esbuild';
import { version } from '../package.json';
import { pathOutput, pathSrc } from './paths';
 
 
/**
 * 复制组件到 dist 目录
 */
const copyComponents = async () => {
  const componentsSrcDir = path.resolve(pathSrc, 'components');
  const componentsDistDir = path.resolve(pathOutput, 'components');
 
  await emptyDir(componentsDistDir); // 清空目标目录
  await copy(componentsSrcDir, componentsDistDir); // 复制组件
};
 
/**
 * 获取 esbuild 构建配置项
 * @param format 打包格式,分为 esm,iife,cjs
 */
const getBuildOptions = (format: Format) => {
  const options: BuildOptions = {
    entryPoints: [path.resolve(pathSrc, 'index.js')],
    target: 'es2018',
    platform: 'neutral',
    plugins: [
      vue({
        isProduction: true,
        sourceMap: false,
        template: { compilerOptions: { hoistStatic: false } },
      }),
    ],
    bundle: true,
    format,
    minifySyntax: true,
    banner: {
      js: `/*! maomao Icons v${version} */\n`,
    },
    outdir: pathOutput,
  };
  if (format === 'iife') {
    options.plugins!.push(
      GlobalsPlugin({
        vue: 'Vue',
      })
    );
    options.globalName = 'maomaoIcons';
  } else {
    options.external = ['vue'];
  }
 
  return options;
};
 
/**
 * 执行构建
 * @param minify 是否需要压缩
 */
const doBuild = async (minify: boolean = true) => {
  await Promise.all([
    build({
      ...getBuildOptions('esm'),
      entryNames: `[name]${minify ? '.min' : ''}`,
      minify,
    }),
    build({
      ...getBuildOptions('iife'),
      entryNames: `[name].iife${minify ? '.min' : ''}`,
      minify,
    }),
    build({
      ...getBuildOptions('cjs'),
      entryNames: `[name]${minify ? '.min' : ''}`,
      outExtension: { '.js': '.cjs' },
      minify,
    }),
  ]);
};
 
/**
 * 开始构建入口,同时输出 压缩和未压缩 两个版本的结果
 */
const buildBundle = () => {
  return Promise.all([doBuild(), doBuild(false)]);
};
 
consola.log(chalk.blue('开始编译................................'));
consola.log(chalk.blue('清空 dist 目录................................'));
await emptyDir(pathOutput);
await copyComponents(); // 添加复制组件的逻辑
consola.log(chalk.blue('构建中................................'));
await buildBundle();
consola.log(chalk.green('构建完成。'));

1-2 代码生成模块generate.ts

import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import camelcase from 'camelcase';
import chalk from 'chalk';
import consola from 'consola';
import glob from 'fast-glob';
import { emptyDir, ensureDir } from 'fs-extra';
import { format, type BuiltInParserName } from 'prettier';
import { pathComponents, pathSvg } from './paths';
 
 
/**
 * 从文件路径中获取文件名及组件名
 * @param file 文件路径
 * @returns
 */
function getName(file: string) {
  const fileName = path.basename(file).replace('.svg', '');
  const componentName = camelcase(fileName, { pascalCase: true });
  return {
    fileName,
    componentName,
  };
}
 
/**
 * 按照给定解析器格式化代码
 * @param code 待格式化代码
 * @param parser 解析器类型
 * @returns 格式化后的代码
 */
async function formatCode(code: string, parser: BuiltInParserName = 'typescript') {
  return await format(code, {
    parser,
    semi: false,
    trailingComma: 'none',
    singleQuote: true,
  });
}
 
/**
 * 将 file 转换为 vue 组件
 * @param file 待转换的 file 路径
 */
async function transformToVueComponent(file: string) {
  const content = await readFile(file, 'utf-8');
 
  // 使用正则表达式提取 <svg> 标签及其内容
  const svgMatch = content.match(/<svg[^>]*>([\s\S]*?)<\/svg>/);
  const svgContent = svgMatch ? svgMatch[0] : ''; // 如果没有匹配到,则返回空字符串
 
  const { fileName, componentName } = getName(file);
 
  // 获取相对路径
  const relativeDir = path.dirname(path.relative(pathSvg, file));
  const targetDir = path.resolve(pathComponents, relativeDir);
 
  // 确保目标目录存在
  await ensureDir(targetDir);
  const vue = await formatCode(
    `<template>
      <div :style="iconStyle" class="icon"></div>
    </template>
    <script lang="ts" setup>
      import { defineProps,computed } from 'vue';
      import { parse } from 'svg-parser'; // 需要安装并导入svg-parser库
 
      const props = defineProps({
        fillColor: {
          type: String,
          default: 'currentColor',
        },
        size: {
          type: [String, Number],
          default: 32,
        },
        svgContent: {
          type: String,
          default: \`${svgContent}\`,
        },
      });
 
      const iconStyle = computed(() => {
        const parser = new DOMParser();
        const svgDoc = parser.parseFromString(props.svgContent, 'image/svg+xml');
        const svgElement = svgDoc.documentElement;
        const width = svgElement.getAttribute('width') || '100';
        const height = svgElement.getAttribute('height') || '100';
 
        const svgWidth = parseFloat(width);
        const svgHeight = parseFloat(height);
 
        const aspectRatio = svgWidth / svgHeight;
        const size = typeof props.size === 'string' ? parseFloat(props.size) : props.size;
 
        return {
          width: \`\${size}px\`,
          height: \`\${size / aspectRatio}px\`,
          backgroundImage: \`url('data:image/svg+xml;utf8,\${encodeURIComponent(props.svgContent)}')\`,
          backgroundSize: 'contain',
          backgroundRepeat: 'no-repeat',
          display: 'inline-block',
        };
      });
    </script>
    <style scoped>
      .icon {
        /* 额外的样式可以在这里添加 */
      }
    </style>`,
    'vue'
  );
 
  await writeFile(path.resolve(targetDir, `${fileName}.vue`), vue, 'utf-8');
}
 
/**
 * 生成 components 入口文件
 */
const generateEntry = async (files: string[]) => {
  // const elePlusIconsExport = `export * from '@element-plus/icons-vue'`;
  const entries: Record<string, string[]> = {};
 
  for (const file of files) {
    const { fileName, componentName } = getName(file);
    const relativeDir = path.dirname(path.relative(pathSvg, file));
    const entryPath = path.resolve(pathComponents, relativeDir, 'index.js');
 
    // 将每个组件名按目录分类
    if (!entries[relativeDir]) {
      entries[relativeDir] = [];
    }
    entries[relativeDir].push(`export { default as ${componentName} } from './${fileName}.vue'`);
  }
 
  // 为每个目录生成入口文件
  for (const [dir, componentEntries] of Object.entries(entries)) {
    // const code = await formatCode([...componentEntries, elePlusIconsExport].join('\n'));
    const code = await formatCode([...componentEntries].join('\n'));
    await writeFile(path.resolve(pathComponents, dir, 'index.js'), code, 'utf-8');
  }
};
 
/**
 * 获取 svg 文件
 * 这里改为获取多个子目录下的svg文件
 */
function getSvgFiles() {
  return glob('**/*.svg', { cwd: pathSvg, absolute: true });
}
 
consola.log(chalk.blue('开始生成 Vue 图标组件................................'));
await ensureDir(pathComponents);
await emptyDir(pathComponents);
const files = await getSvgFiles();
 
consola.log(chalk.blue('开始生成 Vue 文件................................'));
await Promise.all(files.map((file: string) => transformToVueComponent(file)));
 
consola.log(chalk.blue('开始生成 Vue 组件入口文件................................'));
await generateEntry(files);
consola.log(chalk.green('Vue 图标组件已生成'));

1-3 路径变量 paths.ts

import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'



// 目录相对路径
export const dirsRelativePath = {
root: '..',
src: 'src',
components: 'components',
svg: 'svg',
output: 'dist'
}

// 当前程序执行的目录,即 build
const dir = dirname(fileURLToPath(import.meta.url))

// 根目录
export const pathRoot = resolve(dir, dirsRelativePath.root)
// src 目录
export const pathSrc = resolve(pathRoot, dirsRelativePath.src)
// vue 组件目录
export const pathComponents = resolve(pathSrc, dirsRelativePath.components)
// svg 资源目录
export const pathSvg = resolve(pathRoot, dirsRelativePath.svg)
// 编译输出目录
export const pathOutput = resolve(pathRoot, dirsRelativePath.output)

2-1 构建说明

svg文件夹下是各项目所需要构建的svg图片文件

使用`npm run build`后,src目录下会构建出我们所需要的组件,和入口文件

 举例

project1下的asset-manage.vue,这是生成文件,由文件模板生成

<template>
  <div :style="iconStyle" class="icon"></div>
</template>
<script lang="ts" setup>
import { defineProps, computed } from 'vue'
import { parse } from 'svg-parser' // 需要安装并导入svg-parser库
 
 
const props = defineProps({
  fillColor: {
    type: String,
    default: 'currentColor'
  },
  size: {
    type: [String, Number],
    default: 32
  },
  svgContent: {
    type: String,
    default: `<svg t="1729500214124" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4756" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M540.8 51.008l330.112 277.376 1.28 1.088 117.888 106.624a44.8 44.8 0 1 1-60.16 66.432l-53.12-48v484.16a44.8 44.8 0 0 1-44.8 44.8H192a44.8 44.8 0 0 1-44.8-44.8V454.4l-53.12 48.064a44.8 44.8 0 0 1-60.16-66.432l117.888-106.624 1.28-1.088L483.2 51.008a44.8 44.8 0 0 1 57.6 0zM512 143.808L236.8 375.04v518.848h550.4V375.04L512 143.872z m106.688 216.704a44.8 44.8 0 0 1 34.368 73.472l-45.44 54.528H640a44.8 44.8 0 1 1 0 89.6H556.8v38.4H640a44.8 44.8 0 1 1 0 89.6H556.8v83.2a44.8 44.8 0 1 1-89.6 0v-83.2H384a44.8 44.8 0 1 1 0-89.6h83.2v-38.4H384a44.8 44.8 0 1 1 0-89.6h32.32l-45.44-54.528a44.8 44.8 0 0 1 68.864-57.344L512 463.36l72.256-86.72a44.8 44.8 0 0 1 34.432-16.128z" p-id="4757"></path></svg>`
  }
})
 
const iconStyle = computed(() => {
  const parser = new DOMParser()
  const svgDoc = parser.parseFromString(props.svgContent, 'image/svg+xml')
  const svgElement = svgDoc.documentElement
  const width = svgElement.getAttribute('width') || '100'
  const height = svgElement.getAttribute('height') || '100'
 
  const svgWidth = parseFloat(width)
  const svgHeight = parseFloat(height)
 
  const aspectRatio = svgWidth / svgHeight
  const size =
    typeof props.size === 'string' ? parseFloat(props.size) : props.size
 
  return {
    width: `${size}px`,
    height: `${size / aspectRatio}px`,
    backgroundImage: `url('data:image/svg+xml;utf8,${encodeURIComponent(props.svgContent)}')`,
    backgroundSize: 'contain',
    backgroundRepeat: 'no-repeat',
    display: 'inline-block'
  }
})
</script>
<style scoped>
.icon {
  /* 额外的样式可以在这里添加 */
}
</style>

project1下的index.js

export { default as AssetManage } from './asset-manage.vue'
export { default as AvatarLine } from './avatar-line.vue'
export { default as Baodan12313 } from './baodan12313.vue'
export { default as ExitFullscreen } from './exit-fullscreen.vue'
export { default as PaperFrog } from './paper-frog.vue'
export { default as Registering } from './registering.vue'
export { default as StartFilled } from './start-filled.vue'

2-2 发包说明

重点解释:

此处代码可以根据项目图标目录中的文件,进行针对性导出,实现分包引入的效果,使得引入包大小不会太大。

.package.json
 

{
  "name": "icon-site-group",
  "version": "1.0.2-beta.7",
  "description": "项目分开目录管理(加回导出全部)",
  "private": false,
  "type": "module",
  "keywords": [
    "icons",
    "图标"
  ],
  "license": "ISC",
  "author": "SuperYing",
  "files": [
    "dist"
  ],
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "require": "./dist/index.cjs",
      "import": "./dist/index.js"
    },
    "./project1": "./dist/components/project1/index.js",
    "./project2": "./dist/components/project2/index.js",
    "./project3": "./dist/components/project3/index.js",
    "./*": "./*"
  },
  "sideEffects": false,
  "scripts": {
    "build": "pnpm run build:generate && run-p build:build build:types",
    "build:generate": "tsx build/generate.ts",
    "build:build": "tsx build/build.ts",
    "build:types": "vue-tsc --declaration --emitDeclarationOnly"
  },
  "peerDependencies": {
    "vue": "^3.4.21"
  },
  "dependencies": {
    "pnpm": "^9.12.2"
  },
  "devDependencies": {
    "@babel/types": "^7.22.4",
    "@types/fs-extra": "^11.0.4",
    "@types/node": "^20.11.25",
    "camelcase": "^8.0.0",
    "chalk": "^5.3.0",
    "consola": "^3.2.3",
    "console": "^0.7.2",
    "esbuild": "^0.21.4",
    "esbuild-plugin-globals": "^0.2.0",
    "fast-glob": "^3.3.2",
    "fs-extra": "^11.2.0",
    "npm-run-all": "^4.1.5",
    "prettier": "^3.2.5",
    "tsx": "^4.7.1",
    "typescript": "^5.4.2",
    "unplugin-vue": "^5.0.4",
    "vue": "^3.4.21",
    "vue-tsc": "^2.0.6"
  }
}

3-1 图标库如何使用
 

npm install icon-site-group

单项目情况,需要进行组件注册

在main.js中引入

import * as MaoGroupIcons from 'icon-site-group/project1'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'tailwindcss/tailwind.css'
import { createApp } from 'vue'
import App from './App.vue'
 
 
// console.log(MaoGroupIcons)
 
/* 单项目展示以及调用的情况 */
const app = createApp(App);
Object.entries(MaoGroupIcons).forEach(([name, component]) => {
    app.component(name, component);
});
// console.log(MaoGroupIcons)
app.use(ElementPlus)
    // .use(MaoGroupIcons)
   .mount('#app')

全量引入的情况下
 

import MaoGroupIcons from 'icon-site-group'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'tailwindcss/tailwind.css'
import { createApp } from 'vue'
import App from './App.vue'
 
//此处传入project,用于注册指定项目下的图标组件
const app = createApp(App);
app.use(ElementPlus)
   .use(MaoGroupIcons, { project: ['project1','project2','project3'] })
   .mount('#app')

4-1 打包大小
 

多项目引入占比大小

rollup-plugin-visualizer下的可视化打包页面????stats.html

单项目引入占比大小

rollup-plugin-visualizer下的可视化打包页面????stats.html

5-1 项目目录 

.
├── build
│   ├── build.ts
│   ├── generate.ts
│   └── paths.ts
├── src
│   ├── components
│   │   ├── project1

│   │  │   ├──asset-manage.vue

│   │   ├── project2

│   │  │   ├──baodan.vue

│   │  │   ├──bianji.vue

│   │   └── project3

│   │  │   ├── Check-Circle-Fill.vue

│   └── index.js
├── svg
│   ├── project1
│   │   ├── asset-manage.svg
│   ├── project2
│   │   ├── baodan.svg
│   │   ├── bianji.svg
│   └── project3
│   ├── Check-Circle-Fill.svg

├── tree.md
├── tsconfig.build.json
└── tsconfig.json

上一篇:PVE纵览-从零开始:了解Proxmox Virtual Environment


下一篇:计算机体系结构之多级缓存、缓存miss及缓存hit(二)