场景与接口设计
当需要按需下发可定制的图标时,可以通过统一的HTTP接口,根据查询参数返回定制过的SVG。例如:
- 示例:
https://api.com/icon?icon=home&color=red&size=24&strokeWidth=2&filled=true
- 返回类型:
Content-Type: image/svg+xml; charset=utf-8
- 常见参数:
icon
:图标名称,必须来自白名单,例如home
、user
color
:图标主色。建议仅支持十六进制或有限白名单色值size
:像素尺寸,控制width/height
(建议范围 12-256)strokeWidth
:线宽(仅对描边型图标生效)filled
:是否使用填充版本
核心处理流程
- 解析查询参数并做严格校验(白名单/范围/默认值)
- 读取或拼装对应
SVG模板
- 按参数对
fill
、stroke
、width/height
、stroke-width
等属性进行应用 - 进行安全清洗(移除脚本、事件属性等不安全片段;或仅使用内置模板)
- 设置响应头(
Content-Type
、缓存头、CORS) - 输出SVG字符串
参数校验与白名单建议
icon
:必须是白名单枚举值,防止任意文件读取color
:- 建议只接受
#RGB
/#RRGGBB
的十六进制或一小部分命名色(如black/white/red
) - 超出约束时回退到
currentColor
或默认色
- 建议只接受
size
:范围约束,例如12 ≤ size ≤ 256
,默认24
strokeWidth
:范围约束,例如0 ≤ strokeWidth ≤ 8
,默认2
filled
:布尔值,默认true
实现方式对比
- 字符串模板替换:简单高效,基于受控的模板字符串进行
replace
- 解析DOM后修改:用
cheerio
/xmldom
修改节点属性,控制更精细 - 预渲染与运行时:
- 预渲染:生成不同参数组合的静态产物(适合组合少、访问量极高)
- 运行时:根据参数即时生成(弹性强,更通用)
基于 Express 的最小可用实现
javascript
// server.js (CommonJS)
const express = require('express');
const crypto = require('crypto');
const app = express();
const port = 3000;
// 受控的图标模板(示例:home与user)。实际项目可拆分多个文件集中管理
const ICON_TEMPLATES = {
home: {
filled: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>',
outline: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12l9-9 9 9"/><path d="M9 21V9h6v12"/></svg>'
},
user: {
filled: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5zm0 2c-4.418 0-8 2.239-8 5v1h16v-1c0-2.761-3.582-5-8-5z"/></svg>',
outline: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="7" r="4"/><path d="M20 21v-1c0-3.866-3.582-7-8-7s-8 3.134-8 7v1"/></svg>'
}
};
const NAMED_COLORS = new Set(['black', 'white', 'red', 'green', 'blue', 'gray']);
function sanitizeColor(input) {
if (!input) return 'currentColor';
const hexOk = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexOk.test(input)) return input;
if (NAMED_COLORS.has(input.toLowerCase())) return input.toLowerCase();
return 'currentColor';
}
function clamp(value, min, max, fallback) {
const n = Number(value);
if (Number.isFinite(n)) return Math.min(Math.max(n, min), max);
return fallback;
}
function buildSvg({ icon, color, size, strokeWidth, filled }) {
const group = ICON_TEMPLATES[icon];
const template = filled ? group.filled : group.outline;
const safeColor = sanitizeColor(color);
const safeSize = clamp(size, 12, 256, 24);
const safeStrokeWidth = clamp(strokeWidth, 0, 8, 2);
// 将模板中的颜色与线宽占位符替换为最终值
let svg = template
.replace(/currentColor/g, safeColor)
.replace(/stroke-width="[^"]*"/g, `stroke-width="${safeStrokeWidth}"`);
// 为根节点补充尺寸(注意:保持viewBox原样)
svg = svg.replace(
/<svg(\s+[^>]*)?>/,
match => match.replace('>', ` width="${safeSize}" height="${safeSize}">`)
);
return svg;
}
app.get('/icon', (req, res) => {
const icon = String(req.query.icon || 'home');
const color = req.query.color ? String(req.query.color) : undefined;
const size = req.query.size ? Number(req.query.size) : undefined;
const strokeWidth = req.query.strokeWidth ? Number(req.query.strokeWidth) : undefined;
const filled = req.query.filled ? String(req.query.filled).toLowerCase() !== 'false' : true;
if (!ICON_TEMPLATES[icon]) {
return res.status(400).json({ error: 'icon 不在白名单中' });
}
const svg = buildSvg({ icon, color, size, strokeWidth, filled });
// 简单ETag:参数 + 模板哈希
const etag = 'W/"' + crypto.createHash('sha1').update(svg).digest('hex') + '"';
if (req.headers['if-none-match'] === etag) {
res.status(304).end();
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('ETag', etag);
res.send(svg);
});
app.listen(port, () => {
console.log(`icon api listening on http://localhost:${port}`);
});
- 访问示例:
http://localhost:3000/icon?icon=home&color=%23ff5722&size=32
http://localhost:3000/icon?icon=user&filled=false&strokeWidth=1&color=blue
响应头与缓存策略
Content-Type: image/svg+xml; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
:强缓存一年,内容随查询参数变化ETag/If-None-Match
:基于SVG内容生成弱ETag,命中则返回304
- CDN缓存:将
icon/color/size/strokeWidth/filled
纳入缓存key,或统一追加版本号v
安全注意事项
- 严格的
icon
白名单,拒绝任意路径或用户上传SVG直读 - 颜色仅允许十六进制或有限命名色,拒绝
url()
、var()
等复杂表达式 - 使用内置模板字符串,不接受用户传入的SVG片段
- 如需解析并修改外部SVG,务必:
- 删除
<script>
、on*
事件属性、foreignObject
等潜在危险节点 - 删除外链引用(如
xlink:href
、外部url()
)
- 删除
前端使用方式
html
<!-- 直接作为图片使用 -->
<img src="https://api.com/icon?icon=home&color=%23ff5722&size=32" alt="home"/>
<!-- CSS背景图(注意:部分场景下内联SVG更灵活) -->
<div style="width:32px;height:32px;background:url('https://api.com/icon?icon=home&color=%23ff5722&size=32') no-repeat center/contain"></div>
调试与测试
bash
# 本地调试(Express示例)
curl -i "http://localhost:3000/icon?icon=home&color=%23ff5722&size=32"
# 命中304
curl -i -H "If-None-Match: W/\"<your-etag>\"" "http://localhost:3000/icon?icon=home&color=%23ff5722&size=32"