一篇文章学会-图标组件库的搭建
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