SVG 图标系统工程
图标设计原则(统一画布、currentColor、像素网格)、sprite 实现与尺寸颜色变体、命名规范与构建链路。
前置知识
建议先阅读以下内容再进入本文:
前置知识:symbol / use 复用机制(《SVG 符号与复用》,120-SVGSymbolReuse)与 CSS 变量穿透(《SVG CSS 样式化》,130-SVGCSSStyling)。
学习目标:掌握 currentColor 主题化与像素网格对齐两条图标设计铁律;会搭一套从源文件到 sprite 的图标工程链路(含构建脚本与 TS 声明);知道图标按钮、状态图标这类工程组件如何承接无障碍属性(属性语义本身见《SVG 可访问性》)。
无障碍属性的判定规则(装饰/语义/交互三分法、ARIA 速查、键盘导航、reduced-motion、对比度)已独立成篇,见 SVG 可访问性;本文专注图标作为工程资产的另一半:怎么画、怎么管、怎么建。
1. 为什么用 SVG 图标
| 维度 | SVG 图标 | 字体图标(如 Font Awesome) | PNG 图标 |
|---|---|---|---|
| 缩放 | 无损 | 无损 | 锯齿 |
| 颜色 | CSS 控制 | CSS 控制(有限) | 固定 |
| 可访问性 | 原生支持 | 一般 | 需 alt |
| 文件体积 | 小(单图标) | 中(整包) | 大 |
| 动画 | 支持 | 有限 | 不支持 |
| 语义化 | DOM 节点 | 字符 | 图片 |
SVG 是现代 Web 图标的首选方案。
2. 图标设计原则
2.1 统一画布
所有图标使用相同 viewBox(通常 24×24):
html
<symbol id="icon-home" viewBox="0 0 24 24">...</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">...</symbol>2.2 描边一致
html
<symbol id="icon-home" viewBox="0 0 24 24">
<path
d="..."
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</symbol>统一描边宽度、端点、拐角,保持视觉一致性。
2.3 使用 currentColor
html
<!-- 错误:硬编码颜色 -->
<symbol id="icon-home">
<path fill="#4f5bd5" />
</symbol>
<!-- 正确:使用 currentColor -->
<symbol id="icon-home">
<path fill="currentColor" />
</symbol>currentColor 让图标颜色继承父元素 color,实现主题化。
2.4 对齐像素网格
html
<!-- 1:1 渲染时锐利:1px 描边落在半像素坐标(与像素边界重合) -->
<path d="M 0.5 0.5 L 10.5 0.5" stroke-width="1" />
<!-- 2px 及以上描边、填充形状边缘:用整数坐标 -->
<path d="M 0 0 L 10 0" stroke-width="2" />细描边图标需关注像素网格:1px 描边在 1:1 渲染下落在半像素坐标最锐利,填充边缘则优先整数坐标(原理见《SVG 坐标系统与 viewBox》的抗锯齿一节)。图标常以 16/24/32 等多种尺寸渲染,最终以目标尺寸的实际显示效果为准。
3. 图标系统实现
3.1 Sprite 模式
html
<!-- icons.svg 隐藏文件 -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 24 24">
<path
d="M3 12 L12 3 L21 12 M5 10 V21 H19 V10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<line
x1="16"
y1="16"
x2="21"
y2="21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</symbol>
<symbol id="icon-user" viewBox="0 0 24 24">
<circle cx="12" cy="8" r="4" fill="currentColor" />
<path
d="M4 20 C4 16 8 14 12 14 C16 14 20 16 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</symbol>
</svg>3.2 使用图标
html
<svg class="icon" aria-hidden="true">
<use href="#icon-home" />
</svg>调用点默认按装饰图标处理(aria-hidden);需要独立表意的图标如何改属性,见 SVG 可访问性 的三分法。
css
.icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}3.3 尺寸变体
css
.icon-sm {
width: 16px;
height: 16px;
}
.icon-md {
width: 24px;
height: 24px;
}
.icon-lg {
width: 32px;
height: 32px;
}
.icon-xl {
width: 48px;
height: 48px;
}html
<svg class="icon icon-sm"><use href="#icon-home" /></svg>
<svg class="icon icon-lg"><use href="#icon-home" /></svg>3.4 颜色变体
css
.icon-primary {
color: #4f5bd5;
}
.icon-success {
color: #00b894;
}
.icon-danger {
color: #d63031;
}
.icon-warning {
color: #f9a825;
}html
<button class="btn">
<svg class="icon icon-danger"><use href="#icon-delete" /></svg>
删除
</button>4. 图标按钮组件
html
<button class="btn-icon btn-icon-danger" aria-label="删除项目">
<svg class="icon" aria-hidden="true" viewBox="0 0 24 24">
<path
d="M3 6 H21 M8 6 V4 H16 V6 M6 6 L7 20 H17 L18 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<style>
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.btn-icon:hover {
background: rgba(0, 0, 0, 0.05);
}
.btn-icon:focus-visible {
outline: 2px solid #4f5bd5;
outline-offset: 2px;
}
.btn-icon-danger {
color: #d63031;
}
.btn-icon-danger:hover {
background: rgba(214, 48, 49, 0.1);
}
.icon {
width: 20px;
height: 20px;
}
</style>5. 动态图标
5.1 加载状态
html
<svg class="icon icon-spin" viewBox="0 0 24 24" aria-label="加载中" role="img">
<path
d="M12 2 A10 10 0 0 1 22 12"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<style>
.icon-spin {
animation: spin 1s linear infinite;
transform-origin: center;
transform-box: fill-box;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>5.2 状态切换
html
<button class="btn-toggle" aria-pressed="false" id="like-btn">
<svg class="icon" viewBox="0 0 24 24">
<path
class="heart-outline"
d="M12 21 L4 13 C2 11 2 8 4 6 C6 4 9 4 12 7 C15 4 18 4 20 6 C22 8 22 11 20 13 Z"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<path
class="heart-fill"
d="M12 21 L4 13 C2 11 2 8 4 6 C6 4 9 4 12 7 C15 4 18 4 20 6 C22 8 22 11 20 13 Z"
fill="currentColor"
/>
</svg>
</button>
<style>
.btn-toggle .heart-fill {
display: none;
}
.btn-toggle[aria-pressed='true'] .heart-outline {
display: none;
}
.btn-toggle[aria-pressed='true'] .heart-fill {
display: block;
}
.btn-toggle[aria-pressed='true'] {
color: #d63031;
}
</style>
<script>
const btn = document.getElementById('like-btn');
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', !pressed);
});
</script>aria-pressed 表示按钮按下状态,配合 CSS 切换图标。
6. 图标命名规范
plaintext
icon-{category}-{name}| 命名 | 含义 |
|---|---|
icon-action-home | 操作类 - 首页 |
icon-action-search | 操作类 - 搜索 |
icon-media-play | 媒体类 - 播放 |
icon-media-pause | 媒体类 - 暂停 |
icon-status-success | 状态类 - 成功 |
icon-status-error | 状态类 - 错误 |
icon-nav-menu | 导航类 - 菜单 |
icon-nav-close | 导航类 - 关闭 |
7. 图标集管理
7.1 目录结构
plaintext
src/
assets/
icons/
action/
home.svg
search.svg
media/
play.svg
pause.svg
status/
success.svg
error.svg
sprite/
icons.svg # 构建生成的 sprite
icons.ts # TypeScript 声明7.2 构建脚本
javascript
// scripts/build-icons.js
const fs = require('fs');
const path = require('path');
const { optimize } = require('svgo');
const iconsDir = path.join(__dirname, '../src/assets/icons');
const outputPath = path.join(__dirname, '../src/sprite/icons.svg');
function buildSprite() {
const symbols = [];
function walk(dir) {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walk(fullPath);
} else if (item.endsWith('.svg')) {
const name = path.basename(item, '.svg');
const content = fs.readFileSync(fullPath, 'utf8');
const optimized = optimize(content, {
plugins: [{ name: 'preset-default' }, { name: 'removeDimensions' }],
}).data;
// 提取内容并转为 symbol
const inner = optimized.replace(/<svg[^>]*>|<\/svg>/g, '');
symbols.push(`<symbol id="icon-${name}" viewBox="0 0 24 24">${inner}</symbol>`);
}
}
}
walk(iconsDir);
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>`;
fs.writeFileSync(outputPath, sprite);
console.log(`Built ${symbols.length} icons`);
}
buildSprite();8. 实战:完整的图标按钮系统
html
<!DOCTYPE html>
<html>
<head>
<style>
.icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn:hover {
background: #f5f5f5;
border-color: #ccc;
}
.btn:focus-visible {
outline: 2px solid #4f5bd5;
outline-offset: 2px;
}
.btn-primary {
background: #4f5bd5;
border-color: #4f5bd5;
color: #fff;
}
.btn-primary:hover {
background: #3a47b8;
}
.btn-danger {
color: #d63031;
border-color: #d63031;
}
.btn-danger:hover {
background: #fbe9e7;
}
.btn-icon-only {
padding: 8px;
}
</style>
</head>
<body>
<svg style="display:none">
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 5 V19 M5 12 H19" stroke-linecap="round" />
</symbol>
<symbol id="icon-trash" viewBox="0 0 24 24">
<path
d="M3 6 H21 M8 6 V4 H16 V6 M6 6 L7 20 H17 L18 6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M5 12 L10 17 L19 8" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
</svg>
<button class="btn btn-primary">
<svg class="icon" aria-hidden="true"><use href="#icon-plus" /></svg>
新建项目
</button>
<button class="btn btn-danger btn-icon-only" aria-label="删除">
<svg class="icon" aria-hidden="true"><use href="#icon-trash" /></svg>
</button>
<button class="btn" aria-pressed="false" id="check-btn">
<svg class="icon" aria-hidden="true"><use href="#icon-check" /></svg>
标记完成
</button>
<script>
document.getElementById('check-btn').addEventListener('click', function () {
const pressed = this.getAttribute('aria-pressed') === 'true';
this.setAttribute('aria-pressed', !pressed);
});
</script>
</body>
</html>小结
初学者要点:
- 图标工程的两条设计铁律:换色靠
currentColor(颜色继承父元素),换尺寸靠 CSS 尺寸类(viewBox 坐标不动),两者都不需要为每个变体重画图标。 - sprite +
<use>是图标工程的标准交付形态:一份 sprite 文件、若干调用点,构建脚本把源 SVG 批量转 symbol。 - 命名即文档:
icon-{category}-{name}的三段命名让几百枚图标在代码里可搜索、可审计。
进阶注意:
- SVGO 的
cleanupIds会砍掉 sprite 里被<use>引用的 id——sprite 构建链路必须关闭该插件。 - 图标按钮与状态图标是无障碍属性的主要落点:命名放外层控件、状态走 aria-pressed 的完整语义,见《SVG 可访问性》;工程侧只要保证组件模板预留这些属性位。