徽章(Badge)是那些出现在README顶部的小图标,例如build passing、version v1.2.0,它们通常由shields.io这类服务动态生成。但当你需要在内网环境部署,或者想完全控制徽章的样式与数据来源时,自己实现一个徽章生成API就成了必须掌握的技能。本文将用Node.js实现一个完整的Badging API服务,支持从URL参数生成SVG徽章,并可转换为PNG图片返回。

一、理解徽章的SVG结构原理
一个标准的shields风格徽章本质上是一段极简的SVG代码,由圆角矩形背景和文字两部分组成。矩形通过rx属性实现左侧半圆角效果,文字则使用<text>元素配合等宽或指定字体渲染。理解这个结构后,你会发现所谓徽章服务,核心逻辑就是「参数拼字符串」。
一个最基础的徽章SVG如下所示,其中左侧灰色部分是标签,右侧绿色部分是值,两段矩形宽度需要根据文字长度动态计算:
<svg xmlns="http://www.w3.org/2000/svg" width="110" height="20">
<rect rx="3" width="50" height="20" fill="#555"/>
<rect rx="3" x="47" width="63" height="20" fill="#4c1"/>
<text x="25" y="14" fill="#fff" font-size="11"
font-family="Verdana,sans-serif" text-anchor="middle">build</text>
<text x="78" y="14" fill="#fff" font-size="11"
font-family="Verdana,sans-serif" text-anchor="middle">passing</text>
<//svg>注意第二个<rect>的x属性比第一个矩形的宽度小3像素,这是为了让右侧只保留半圆角,形成经典的胶囊拼接效果。文字的text-anchor="middle"配合水平中心点坐标,可以保证文字在各自区块内居中。
二、实现动态宽度计算与SVG生成器
文字宽度无法在服务端精确测量,通用做法是采用近似估算公式:每个字符按字体大小乘以一个系数计算。对于Verdana字体,经验系数大约是6.5(针对11px字号),再加上左右各5像素的内边距。虽然不是像素级精确,但对于ASCII字符已经足够接近真实效果。
下面是核心生成函数的完整实现,它接收标签、值和颜色三个参数,返回SVG字符串:
// badge.js
function escapeXml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// 按字符类型估算文字宽度,中文等全角字符占更大空间
function estimateTextWidth(text, fontSize = 11) {
let width = 0;
for (const ch of String(text)) {
// 全角字符或CJK文字宽度约为字号本身
width += /[\u2E80-\u9FFF\uFF00-\uFFEF]/.test(ch) ? fontSize : fontSize * 0.6;
}
return Math.ceil(width);
}
function generateBadge(label, value, color = '#4c1') {
const fontSize = 11;
const padding = 6;
const height = 20;
const labelWidth = estimateTextWidth(label) + padding * 2;
const valueWidth = estimateTextWidth(value) + padding * 2;
const totalWidth = labelWidth + valueWidth;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}">
<rect rx="3" width="${labelWidth}" height="${height}" fill="#555"/>
<rect rx="3" x="${labelWidth - 3}" width="${valueWidth + 3}" height="${height}" fill="${color}"/>
<text x="${labelWidth / 2}" y="14" fill="#fff" font-size="${fontSize}"
font-family="Verdana,DejaVu Sans,sans-serif" text-anchor="middle">${escapeXml(label)}</text>
<text x="${labelWidth + valueWidth / 2}" y="14" fill="#fff" font-size="${fontSize}"
font-family="Verdana,DejaVu Sans,sans-serif" text-anchor="middle">${escapeXml(value)}</text>
</svg>`;
}
module.exports = { generateBadge };这里有两个容易踩的坑需要特别强调。第一,escapeXml函数不能省略,否则用户传入带尖括号的参数会直接破坏SVG结构,甚至造成注入攻击。第二,估算中文宽度时要单独处理,CJK字符在11px字号下宽度接近11像素而不是6.6像素,如果不区分处理,中文徽章会出现文字溢出的问题。
三、用sharp实现SVG转PNG输出
SVG在浏览器中可以直接显示,但很多场景(如邮件、办公文档、IM消息卡片)只支持位图格式,因此需要服务端将SVG渲染为PNG。sharp是目前Node.js生态中性能最好的图像处理库,底层基于libvips,渲染一张小徽章只需几毫秒。
先安装依赖,然后编写转换逻辑:
npm install sharp
const sharp = require('sharp');
async function svgToPng(svgString, scale = 2) {
const svgBuffer = Buffer.from(svgString);
const image = sharp(svgBuffer, { density: 72 * scale });
return image.png().toBuffer();
}
// 用法示例
(async () => {
const svg = generateBadge('coverage', '96%');
const pngBuffer = await svgToPng(svg, 2);
require('fs').writeFileSync('badge.png', pngBuffer);
})();density参数是清晰度的关键。SVG默认按72 DPI渲染,在小尺寸徽章上输出会很模糊,将其放大2到3倍可以得到高清的Retina效果图片,同时文件体积仍然很小(通常在3KB以内)。需要注意的是sharp在Linux服务器上要求glibc版本较新,如果部署在老旧的CentOS容器中,建议改用官方提供的alpine预编译版本。
四、搭建HTTP接口并支持格式协商
有了生成器,接下来用原生http模块(或Express)暴露接口。设计URL格式为/badge/:label-:value.:ext,其中.ext决定输出SVG还是PNG。同时提供color查询参数覆盖默认颜色。
const http = require('http');
const { URL } = require('url');
const { generateBadge } = require('./badge');
const { svgToPng } = require('./converter');
const cache = new Map(); // 简单内存缓存
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const match = url.pathname.match(/^\/badge\/(.+)-([^/]+)\.(svg|png)$/);
if (!match) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('Not Found');
}
const [, label, value, ext] = match;
const color = url.searchParams.get('color') || '#4c1';
const cacheKey = `${label}|${value}|${color}|${ext}`;
try {
const svg = generateBadge(decodeURIComponent(label), decodeURIComponent(value), color);
if (ext === 'svg') {
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600'
});
return res.end(svg);
}
// PNG模式,优先读缓存
if (!cache.has(cacheKey)) {
cache.set(cacheKey, await svgToPng(svg));
if (cache.size > 1000) cache.delete(cache.keys().next().value);
}
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600'
});
res.end(cache.get(cacheKey));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Error');
}
});
server.listen(3000, () => console.log('badge service on http://localhost:3000'));启动服务后,访问http://localhost:3000/badge/build-passing.svg即可得到SVG徽章,将后缀换成png则返回位图版本。HTTP缓存头同样重要,因为徽章内容通常变化不频繁,设置max-age可以显著降低服务压力。
五、进阶优化与扩展方向
基础版本可以满足大多数需求,但生产环境还可以从以下几个方向增强。首先是样式扩展:支持flat-square(无圆角)、plastic(带立体阴影)等风格,只需调整矩形和增加渐变定义即可。其次是数据源集成:接口不只是静态参数,可以接收回调URL,在生成徽章前先拉取真实的构建状态或版本号,这样就变成了一个真正的动态徽章服务。
性能层面,由于徽章的组合数量有限,强烈建议引入LRU缓存或Redis,缓存转换后的PNG二进制数据。实测sharp渲染虽有优化,但高并发下CPU占用仍不可忽视,命中缓存后响应时间可以从15毫秒降到1毫秒以内。此外,如果部署为Serverless函数,可以将sharp换成@resvg/resvg-js,它对无服务器环境的冷启动更友好。
最后别忘了安全与稳定性:对label和value设置最大长度限制(如100字符),对color参数做白名单或正则校验(只允许十六进制色值),避免恶意构造的超长SVG拖垮渲染进程。通过这些措施,一个健壮的私有化Badging API就完整落地了,你可以放心在内网文档、内部平台和CI通知中使用它,不再依赖任何外部服务。