做接口联调的时候,经常碰到这样的需求:后端给出一份JSON格式的Mock数据,前端或产品同学希望直接看到这张数据渲染出来的图表、名片或报表截图,方便在群里讨论。手动打开浏览器截图效率低,而且每次数据一变就得重来。更好的做法是搭一个Mock2Image服务:客户端POST一段JSON,服务端渲染成PNG返回。本文就用Node.js实现这样一个服务,并且部署到Railway上,整个过程不需要自己管理服务器。

整体架构设计:为什么选择无头浏览器渲染
把数据变成图片,常见有两条路。第一条是纯服务端绘制,比如用node-canvas或者sharp直接画像素,优点是轻量,缺点是排版能力极弱,写一个稍微复杂点的报表布局要算大量坐标,维护成本很高。第二条路是准备一个HTML模板,把Mock数据注入进去,再用无头浏览器加载页面并截图。这样模板可以直接复用前端的CSS排版能力,图表可以引入ECharts这类库,效果和真实页面完全一致。
权衡之后我选择了第二条路。核心链路是:Express接收请求,校验并解析JSON,根据参数选择对应的HTML模板,把数据以安全的方式注入模板,然后启动Puppeteer无头Chrome加载这个页面,等待渲染完成后对指定区域截图,最后把PNG二进制流写回响应。整个流程对调用方来说就是一个普通的HTTP接口,返回值就是图片文件。
架构上还有一个关键决策:Puppeteer实例不应该每次请求都新建。启动一个Chrome进程通常需要几百毫秒甚至更久,高并发下会把CPU打满。正确做法是启动时初始化一个浏览器实例,之后每个请求只创建轻量的page对象,用完即销毁。这样单次截图耗时可以从秒级降到100毫秒左右。
核心代码实现:从JSON到PNG
先初始化项目并安装依赖:
npm init -y npm install express puppeteer
下面是服务的完整入口代码。模板页面放在templates目录下,通过查询参数name指定使用哪个模板,请求体中的data字段就是Mock数据:
const express = require("express");
const puppeteer = require("puppeteer");
const app = express();
app.use(express.json({ limit: "2mb" }));
let browser;
// 服务启动时初始化一次浏览器,避免每次请求冷启动
async function initBrowser() {
browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage"
]
});
}
// 把数据注入HTML模板,这里用占位符替换,避免直接拼接引发注入问题
function renderTemplate(template, data) {
return template.replace(/\/\*__DATA__\*\//g, JSON.stringify(data));
}
app.post("/api/render", async (req, res) => {
const { name, selector, width, height } = req.body;
if (!name || !req.body.data) {
return res.status(400).json({ error: "缺少name或data参数" });
}
const page = await browser.newPage();
try {
await page.setViewport({ width: width || 800, height: height || 600 });
await page.goto(`file://${__dirname}/templates/${name}.html`, {
waitUntil: "networkidle0"
});
// 将Mock数据传入页面上下文,触发页面内的渲染逻辑
await page.evaluate((d) => window.renderData(d), req.body.data);
await page.waitForSelector("#output");
const target = selector || "#output";
const el = await page.$(target);
const buffer = await el.screenshot({ type: "png" });
res.set("Content-Type", "image/png");
res.set("Content-Disposition", "inline; filename=mock.png");
res.send(buffer);
} catch (err) {
console.error(err);
res.status(500).json({ error: "渲染失败" });
} finally {
await page.close();
}
});
const PORT = process.env.PORT || 3000;
initBrowser().then(() => {
app.listen(PORT, () => console.log("Mock2Image 服务已启动, 端口 " + PORT));
});配合的模板文件很简单,只需要暴露一个全局的renderData函数,把数据渲染进DOM。以一个数据名片为例:
<!DOCTYPE html>
<html>
<head>
<style>
.card { padding: 24px; font-family: sans-serif; border-radius: 12px;
background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.15); }
.name { font-size: 22px; font-weight: bold; }
.role { color: #666; margin-top: 4px; }
</style>
</head>
<body>
<div id="output" class="card"></div>
<script>
window.renderData = function (data) {
document.getElementById("output").innerHTML =
'<div class="name">' + data.name + '</div>' +
'<div class="role">' + data.role + '</div>';
};
</script>
</body>
</html>本地用curl测试一下就能拿到图片:
curl -X POST http://localhost:3000/api/render \
-H "Content-Type: application/json" \
-d '{"name":"card","data":{"name":"张三","role":"前端工程师"}}' \
--output card.pngRailway部署流程与配置要点
Railway对Node.js项目的支持非常友好,只要仓库里有package.json,它就能自动识别并构建。部署有两种方式:一是把代码推到GitHub后在Railway控制台关联仓库,每次push自动触发部署;二是本地安装Railway CLI后执行railway up直接上传。对个人工具类项目,CLI方式最省事。
需要注意几个部署细节。第一,Railway会通过环境变量PORT注入监听端口,代码里必须用process.env.PORT,硬编码3000会导致服务探测失败。第二,Railway的运行环境是基于Nix的容器,安装Puppeteer时建议在package.json中加上如下配置,确保能下载到可用的Chromium:
{
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
}
}
# CLI部署三步走 npm i -g @railway/cli railway login railway up
第三,Puppeteer在容器里运行必须带上--no-sandbox参数,因为容器内默认没有沙箱权限,缺了这行Chrome会直接启动失败。第四,Railway免费额度有内存限制,Chrome本身就是内存大户,建议控制并发数量,可以在服务里加一个简单的信号量限制同时打开的page数量,超出的请求排队处理,避免OOM被平台重启。
一个可参考的并发控制实现:
const MAX_CONCURRENT = 3;
let running = 0;
const queue = [];
async function withSlot(fn) {
if (running >= MAX_CONCURRENT) {
await new Promise((r) => queue.push(r));
}
running++;
try {
return await fn();
} finally {
running--;
if (queue.length) queue.shift()();
}
}把渲染逻辑包在withSlot里执行,就能保证内存不会因为突发流量而失控。部署完成后Railway会分配一个公网域名,前端调用时直接把这个域名当图片接口用,也可以配合npm上的file-type等库在客户端做后处理。这样一套Mock2Image服务,从本地开发到线上可用,整个过程基本零运维成本,非常适合团队内部工具或个人效率场景。