Swipe2Image 要解决的核心问题是:把用户在触屏上的滑动行为转换成标准图片,便于后续做轨迹比对、人机识别或调试。这个需求常见于滑动验证码、手势密码可视化、用户行为分析等场景。一次完整的转换需要经过三个环节,前端采样获得带时间戳的坐标序列,网络层传输结构化数据,服务端绘制并输出 SVG 或 PNG。Node.js 非常适合承接这个任务,它可以通过 HTTP 直接处理 JSON 请求,生成 SVG 文本无须依赖浏览器,而且 Buffer 输出能无缝对接各类存储和 CDN。本文不依赖重型图像库,优先使用 SVG 作为中间格式,再按需转换为 PNG,让实现过程更具可控性。

一、前端采集:Swipe手势的事件模型与抽稀
Swipe 手势的原始数据来自触摸屏或鼠标拖动。现代浏览器推荐使用 Pointer Events 统一处理鼠标、触摸和触控笔输入,而不是分别监听 touchmove 和 mousemove。pointerdown、pointermove、pointerup 三个事件可以覆盖一次完整滑动。移动端浏览器中,pointermove 的触发频率受设备刷新率影响,通常每秒 60 次左右,如果直接把所有点原样上传,数据量并不大,但很多点之间的位移不足 1 像素,属于无效冗余。因此采集阶段需要做最小距离抽稀,只有当当前点与上一个落盘点的欧氏距离大于阈值时才记录。
除了坐标,还应该记录时间戳和压力值。时间戳用于计算滑动速度,压力值可以区分手指接触和笔尖轻触。结束点要单独标记,方便服务端判断轨迹是否完整。另一个容易被忽略的字段是视口尺寸,因为同一段轨迹在不同屏幕分辨率上的坐标完全不同。前端把window.innerWidth和window.innerHeight一并上传,服务端才能做归一化,输出统一尺寸的图片。
下面这段代码演示了如何采集并发送手势数据。它用最小距离 2 像素作为抽稀条件,同时保留首尾两个端点,避免短距离滑动变成空轨迹。
var tracking = false;
var points = [];
var startInfo = null;
function reset() {
tracking = false;
points = [];
startInfo = null;
}
window.addEventListener('pointerdown', function (e) {
tracking = true;
startInfo = { x: e.clientX, y: e.clientY, t: Date.now() };
points.push({ x: e.clientX, y: e.clientY, p: e.pressure || 0.5, dt: 0 });
});
window.addEventListener('pointermove', function (e) {
if (!tracking) return;
var now = Date.now();
var last = points[points.length - 1];
var dx = e.clientX - last.x;
var dy = e.clientY - last.y;
if (dx * dx + dy * dy > 4) {
points.push({
x: e.clientX,
y: e.clientY,
p: e.pressure || 0.5,
dt: now - startInfo.t
});
}
});
window.addEventListener('pointerup', function (e) {
if (!tracking) return;
points.push({
x: e.clientX,
y: e.clientY,
p: e.pressure || 0.5,
dt: Date.now() - startInfo.t,
end: true
});
var payload = {
width: window.innerWidth,
height: window.innerHeight,
points: points
};
fetch('/api/swipe2image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (res) {
return res.blob();
}).then(function (blob) {
var url = URL.createObjectURL(blob);
var img = new Image();
img.src = url;
document.body.appendChild(img);
});
reset();
});
window.addEventListener('pointercancel', reset);
二、服务端绘制:从点序列到SVG路径
Node.js 端接收到手势点序列后,第一步不是画图,而是校验和归一化。校验主要检查点数量是否足够、坐标是否为有限数值、时间戳是否单调递增。归一化则是把客户端坐标映射到目标图片的坐标系中。比如前端窗口宽度是 390,而目标图片宽度是 360,就需要把原始 x 坐标按照比例缩放。更稳妥的做法是计算所有点的最小外接矩形,再等比缩放到目标画布内,同时保留一定内边距,避免轨迹贴边。
使用 SVG 作为中间格式有几方面好处。SVG 文本极易拼接,不需要安装 native 绘图库;它可以在浏览器中直接预览,也方便用工具转换成 PNG。构建 SVG 时,核心是 <path> 标签的 d 属性。简单方案是把所有点用直线段连接,形成一条折线。如果希望轨迹更接近真实滑动,可以对折线做一次移动平均平滑,再用三次贝塞尔曲线细分。
下面的代码先对原始点做归一化,然后生成一个带背景、起点和终点的 SVG 字符串。路径采用直线连接,适合快速验证流程是否跑通。
const http = require('http');
const { URL } = require('url');
function normalizePoints(rawPoints, maxWidth, maxHeight) {
if (!Array.isArray(rawPoints) || rawPoints.length < 2) {
throw new Error('轨迹点数量不足');
}
const xs = rawPoints.map(function (p) { return p.x; });
const ys = rawPoints.map(function (p) { return p.y; });
const maxX = Math.max.apply(null, xs);
const minX = Math.min.apply(null, xs);
const maxY = Math.max.apply(null, ys);
const minY = Math.min.apply(null, ys);
const rangeX = (maxX - minX) || 1;
const rangeY = (maxY - minY) || 1;
const scale = Math.min(maxWidth / rangeX, maxHeight / rangeY, 2.5);
return rawPoints.map(function (p) {
return {
x: (p.x - minX) * scale + 20,
y: (p.y - minY) * scale + 20,
p: p.p || 0.5,
dt: p.dt || 0
};
});
}
function smoothPath(points) {
if (points.length < 3) return points;
const smoothed = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
smoothed.push({
x: (prev.x + curr.x * 2 + next.x) / 4,
y: (prev.y + curr.y * 2 + next.y) / 4,
p: curr.p,
dt: curr.dt
});
}
smoothed.push(points[points.length - 1]);
return smoothed;
}
function buildSvg(points, width, height) {
const safePoints = smoothPath(points);
let d = 'M ' + safePoints[0].x.toFixed(2) + ' ' + safePoints[0].y.toFixed(2);
for (let i = 1; i < safePoints.length; i++) {
d += ' L ' + safePoints[i].x.toFixed(2) + ' ' + safePoints[i].y.toFixed(2);
}
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">'
+ '<rect width="100%" height="100%" fill="#f5f7fa"/>'
+ '<path d="' + d + '" fill="none" stroke="#2f80ed" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>'
+ '<circle cx="' + safePoints[0].x + '" cy="' + safePoints[0].y + '" r="10" fill="#27ae60"/>'
+ '<circle cx="' + safePoints[safePoints.length - 1].x + '" cy="' + safePoints[safePoints.length - 1].y + '" r="10" fill="#eb5757"/>'
+ '</svg>';
}
const server = http.createServer(function (req, res) {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/api/swipe2image' && req.method === 'POST') {
let body = '';
req.on('data', function (chunk) { body += chunk; });
req.on('end', function () {
try {
const data = JSON.parse(body);
const points = normalizePoints(data.points, data.width || 360, data.height || 160);
const svg = buildSvg(points, data.width || 360, data.height || 160);
res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
res.end(svg);
} catch (err) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(err.message);
}
});
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(3000);
如果想直接输出 PNG,可以引入 @resvg/resvg-js 模块。这个模块基于 Rust 实现,性能比纯 JavaScript 方案高不少,而且安装时提供预编译二进制,不需要本地额外配置 Rust 工具链。转换函数非常简洁。
const { Resvg } = require('@resvg/resvg-js');
function svgToPng(svg, width) {
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: width }
});
return resvg.render().asPng();
}
三、Swipe2Image在滑动验证码中的应用
滑动验证码通常包含一张背景图和一个可拖动的拼图块。服务端生成验证图片时,既要提供缺口位置,又要记录真实用户的滑动轨迹。Swipe2Image 服务可以把轨迹叠加到一张独立的透明图上,生成轨迹分析图;也可以把轨迹和拼图缺口合成到同一张图片里,方便人工审核或模型训练。
如果只是生成轨迹图,可以使用前面返回的简单 SVG。如果希望模拟验证码风格,可以增加干扰线、渐变背景和缺口标记。缺口位置一般由业务系统随机生成,不能由前端传入,否则用户可以伪造一个恰好对齐缺口的轨迹。服务端用随机数决定缺口 x 坐标,再把这个 x 坐标与图片一起返回给前端,前端只负责展示,校验由服务端完成。
下面的函数在基础 SVG 上增加了随机干扰线和白色缺口块。实际使用时,缺口块的位置应该保存到会话中,后续验证阶段需要比对用户滑动结束位置是否落在缺口范围内。
function buildCaptchaSvg(points, width, height) {
const base = buildSvg(points, width, height);
const gapX = width * 0.7;
const gapY = height * 0.45;
let noise = '';
for (let i = 0; i < 6; i++) {
noise += '<line x1="' + Math.random() * width + '" y1="' + Math.random() * height
+ '" x2="' + Math.random() * width + '" y2="' + Math.random() * height
+ '" stroke="#333" stroke-width="1" opacity="0.25"/>';
}
return base.replace('</svg>',
'<rect x="' + gapX + '" y="' + gapY + '" width="42" height="42" rx="8" fill="#ffffff" stroke="#bdbdbd" stroke-width="2"/>'
+ noise
+ '</svg>');
}
输出格式方面,SVG 适合开发调试,因为它是文本格式,可以直接查看节点数量。生产环境建议输出 PNG,PNG 不会被浏览器缓存成动态文本,也不容易被用户直接修改路径。把 PNG Buffer 写入对象存储或返回给前端,都可以复用同一套代码。
四、并发与安全:让Swipe2Image服务稳定运行
图片生成服务最容易被攻击的点是超长或畸形数据。如果攻击者提交包含几万个点的 JSON,服务端在归一化和路径拼接时会消耗大量 CPU,甚至导致内存溢出。因此必须在进入绘图逻辑前做严格限制。建议单次请求最多接受 200 个轨迹点,每个坐标的绝对值不超过画布尺寸的 4 倍,时间戳必须是有限数字,压力值限制在 0 到 1 之间。任何字段不符合要求都直接返回 400,而不是尝试修复后继续处理。
性能方面,当 QPS 升高时,SVG 字符串拼接通常不是瓶颈,PNG 转换才是。可以把 resvg.render() 放到 worker_threads 中执行,避免阻塞主事件循环。如果同一轨迹重复请求很多,还可以增加一层以轨迹哈希为 key 的内存缓存,缓存有效期设置为 10 秒,能显著降低重复计算。对于滑动验证码场景,缺口图片可以预先批量生成并缓存,只有轨迹部分需要实时计算。
安全上还要注意 SVG 注入。虽然当前代码的坐标都是数字,但如果未来允许传入图片背景颜色或文字标签,必须过滤单引号、双引号和尖括号,或者在生成 SVG 时只接受白名单枚举值。另外,不要信任前端传来的宽度和高度。服务端应该维护一套允许的尺寸列表,例如 360×160、320×140,超出范围的直接使用默认值,防止攻击者生成超大画布占用内存。
整体来看,Node.js 实现 Swipe2Image 并不复杂。前端负责采集和抽稀,服务端负责归一化、平滑、SVG 生成与 PNG 转换,再加上严格的参数校验和缓存策略,就能得到一个可扩展的轨迹图片生成服务。这个服务既可以独立部署,也可以作为滑动验证码系统的一个内部模块,为行为分析和人机识别提供标准化的图像输出。