跳到主要内容

服务端图像生成(Node.js)

🚧 本文介绍的 @ydesign/node 是规划中的包,尚未发布。下文代码按照我们确定的 API 设计草稿来写,发布后的真实接口会保持一致。

想立刻投入生产的朋友,推荐先用云渲染 API —— 底层引擎和 @ydesign/node 完全一致,后续可以随时迁移。


两种服务端渲染方式的对比

Ydesign 提供两条路,你按需要选:

维度☁️ 云渲染 API🏠 @ydesign/node(本文)
部署方式Ydesign 托管,直接调 API你自己的 Node 服务
上手成本5 分钟需搭建环境 + 生产部署
数据出域JSON 上传到 Ydesign完全不出你的内网
资源抓取Ydesign 代拉取(需公网可达)你的服务器直接访问
渲染内核Ydesign 统一维护同一套内核
字体按请求声明 URL可本地注册 / 远程声明
并发扩容按套餐,弹性伸缩自行扩容
成本模型按调用计费只付你自己服务器的钱
适用场景上线快、免运维、批量营销数据敏感、内网、成本敏感

💡 底层内核是同一套@ydesign/node 就是把我们的云渲染服务拆出来、打成可发布包。这意味着:

  • 云渲染和 @ydesign/node 产出的图像素级一致
  • 你可以先用云渲染上线业务,未来想自建时只需替换调用位置,不用改 JSON / 字体

安装(规划中)

# 暂未发布
pnpm add @ydesign/node

# 首次运行会自动下载 Chromium(约 150 MB)
# 已有 Chrome 的机器可以跳过这一步,见下文「自定义可执行路径」

环境要求:

  • Node.js ≥ 18
  • Linux / macOS / Windows 均支持
  • 服务器需要至少 1 GB RAM(高并发推荐 4 GB+)

⚠️ 底层基于 Puppeteer / 无头 Chromium(而不是 node-canvas)。这是我们权衡后的选择 —— 真浏览器内核保证渲染效果和客户端完全一致,不会踩 node-canvas 对 Fabric 特性的各种坑。


最小示例

import fs from 'node:fs/promises';
import { createRenderer } from '@ydesign/node';

async function main() {
// 1) 创建渲染实例(启动一个无头浏览器)
const renderer = await createRenderer();

// 2) 读取一份 store.toJSON() 产出的 JSON
const json = JSON.parse(await fs.readFile('./design.json', 'utf-8'));

// 3) 渲染成图片(默认 PNG)
const png = await renderer.jsonToImage(json);
await fs.writeFile('./out.png', png);

// 4) 渲染成 JPEG(体积更小)
const jpeg = await renderer.jsonToImage(json, {
format: 'jpeg',
quality: 0.9,
multiplier: 2,
});
await fs.writeFile('./out.jpg', jpeg);

// 5) 用完关掉实例,释放浏览器进程
await renderer.close();
}

main();

就这么多。你拿到的 JSON,无论是从前端 store.toJSON() 传过来的、还是从数据库里查出来的模板,都能直接喂进 jsonToImage


API 详解

createRenderer(options?)

启动一个渲染实例(= 启动一个 headless Chromium 进程)。开销较大,建议复用,别每次请求都 new。

const renderer = await createRenderer({
/** 复用已装的 Chrome(跳过 Puppeteer 自带的 Chromium)*/
executablePath: '/usr/bin/google-chrome',

/** 额外的 Chromium 启动参数 */
args: ['--no-sandbox', '--disable-dev-shm-usage'],

/** 并发页面数(内部池化),默认 3 */
concurrency: 5,

/** 单个渲染任务超时,默认 30000ms */
timeout: 30_000,

/** 自定义日志 */
logger: console,
});

renderer.jsonToImage(json, options?)

渲染成图片,返回 Buffer

interface RenderImageOptions {
/** 输出格式,默认 'png' */
format?: 'png' | 'jpeg' | 'webp';

/** JPEG / WebP 质量 0-1,默认 1 */
quality?: number;

/** 输出倍数(高清导出),默认 1。无浏览器上限 */
multiplier?: number;

/** 自定义字体,同 store.fonts 结构 */
fonts?: Array<{ fontFamily: string; url: string }>;

/** 图片 / 字体抓取的代理 */
proxy?: string;

/** 裁剪区域 */
crop?: { left: number; top: number; width: number; height: number };

/** 过滤对象(返回 false 跳过),比如去水印 */
filter?: (obj: { type: string; name?: string; [k: string]: any }) => boolean;
}

renderer.jsonToPDF(json, options?)(规划中 · 矢量优先)

const pdf = await renderer.jsonToPDF(json, {
vector: true, // 矢量模式:文本 / 形状保留为矢量,分辨率无关
pageSize: 'A4', // 或 { width: 1920, height: 1080 }
fonts: [
/* ... */
],
});

矢量 PDF 最大的价值:绕过浏览器 canvas 的像素上限,3m × 5m 的喷绘图也能秒出。详见上一篇大尺寸 & 高清导出

renderer.close()

关闭浏览器进程、释放所有资源。进程退出前务必调用,否则可能留下僵尸 Chromium 进程。


字体处理

Node 端拿不到"用户本地字体",所以所有非系统字体必须显式声明。和云渲染 APIfonts 字段结构完全一致。

方案 A:通过 fonts 参数传 URL(推荐)

const png = await renderer.jsonToImage(json, {
fonts: [
{
fontFamily: 'AlibabaPuHuiTi_2_115_Black',
url: 'https://your-cdn.com/fonts/AlibabaPuHuiTi_2_115_Black.ttf',
},
// ⚠️ 设计稿用到 Bold,也要单独声明 Bold 文件
{
fontFamily: 'AlibabaPuHuiTi_2_115_Black',
url: 'https://your-cdn.com/fonts/AlibabaPuHuiTi_2_115_Bold.ttf',
},
],
});

如果你的前端是用 addGlobalFont({ styles }) 这种规范方式声明的,服务端可以直接这样展开:

// 把 store.fonts 转成 @ydesign/node 接受的扁平数组
function flattenFonts(fonts: Array<any>) {
return fonts.flatMap(f =>
(f.styles || [{ src: f.url }]).map((s: any) => ({
fontFamily: f.fontFamily,
url: (s.src || s.url || '').replace(/^url\(|\)$|['"]/g, ''),
}))
);
}

const png = await renderer.jsonToImage(json, {
fonts: flattenFonts(designJson.fonts || []),
});

方案 B:提前注册本地字体文件

适合字体"就那些、放在服务器磁盘上"的场景,比内网部署常见:

import path from 'node:path';

const renderer = await createRenderer();

// 预注册一批本地字体,所有后续 jsonToImage 都能用
await renderer.registerFonts([
{ fontFamily: 'FiraSans', path: path.resolve('./fonts/FiraSans-Regular.ttf') },
{ fontFamily: 'FiraSans', path: path.resolve('./fonts/FiraSans-Bold.ttf') },
{ fontFamily: 'NotoSansSC', path: path.resolve('./fonts/NotoSansSC.otf') },
]);

const png = await renderer.jsonToImage(json);

方案 C:代理 + CDN(批量调用)

如果你有很多设计稿、字体种类多,但都在同一批 URL:

const renderer = await createRenderer({
// 强制走你的内网镜像缓存
proxy: 'http://cache.internal:3128',
});

字体文件首次从远端抓取后,代理会缓存,后续渲染命中缓存,速度非常快。

📖 字体踩坑的完整排查清单见 字体与文本一致性


图片处理

远程图片抓取

和字体一样,objects[].src 里的远程 URL 都由服务端直接去抓

  • ✅ 必须公网可达(如果走云渲染是 Ydesign 的 IP)
  • ✅ 建议设置合理的 CORS(自建时其实不需要,但内网代理可能会要求)
  • ❌ 不要用需要登录 token 的私有链接
const renderer = await createRenderer({
// 同一个代理可用于图片和字体抓取
proxy: 'http://your-proxy:8080',

// 图片抓取超时,默认 10s
resourceTimeout: 15_000,
});

Base64 图片

JSON 里如果有 data:image/... base64,会内嵌到 Chromium 直接解码,不走网络。速度快但 JSON 体积大,不建议作为日常方案。

私有 OSS / 签名 URL

给图片 URL 加签名是常见做法:

const renderer = await createRenderer({
// 所有抓取加上自定义请求头(服务端认证场景)
extraHeaders: {
Authorization: `Bearer ${process.env.INTERNAL_TOKEN}`,
},
});

生产部署

1. 复用 renderer 实例(关键)

反模式:每次 HTTP 请求都 createRenderer() → close() —— 启动 Chromium 要 1~2 秒,你的接口永远跑不快。

正确模式:进程启动时创建一个全局 renderer,优雅关闭时再 close:

// renderer.ts
import { createRenderer } from '@ydesign/node';

export const renderer = await createRenderer({
concurrency: 5, // 内部 page 池,允许 5 个并发渲染
});

// 进程关闭时清理
process.on('SIGTERM', async () => {
await renderer.close();
process.exit(0);
});
// server.ts
import Fastify from 'fastify';
import { renderer } from './renderer';

const app = Fastify();

app.post('/render', async (req, reply) => {
const { json, multiplier = 1, fonts = [] } = req.body as any;
const png = await renderer.jsonToImage(json, {
format: 'png',
multiplier,
fonts,
});
reply.type('image/png').send(png);
});

app.listen({ port: 3000 });

2. Docker 部署

Chromium 的系统依赖要装齐,下面这份 Dockerfile 是通用模板:

FROM node:20-bookworm-slim

# Chromium 运行时依赖
RUN apt-get update && apt-get install -y \
ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 \
libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 \
libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \
libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \
libxss1 libxtst6 lsb-release wget xdg-utils \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --prod
COPY . .

# 容器里必须关沙箱
ENV PUPPETEER_ARGS="--no-sandbox --disable-dev-shm-usage"

CMD ["node", "server.js"]

代码里对应:

const renderer = await createRenderer({
args: (process.env.PUPPETEER_ARGS ?? '').split(' ').filter(Boolean),
});

3. 内存管理

无头 Chromium 渲染大 canvas 会吃很多内存。生产建议:

  • ✅ 容器给 ≥ 2 GB 内存(大尺寸导出建议 4 GB)
  • --disable-dev-shm-usage 必加(容器里 /dev/shm 默认只有 64M)
  • ✅ 设置 Pod 健康检查,内存超限时自动重启
  • ✅ 长期跑的服务定期 renderer.close() + 重启,避免 Chromium 内存泄漏累积

4. 水平扩容

renderer 实例不共享状态,多副本部署零成本:

# k8s deployment 片段
replicas: 3
resources:
requests: { memory: '2Gi', cpu: '500m' }
limits: { memory: '4Gi', cpu: '2' }

前面挂一层 nginx / k8s service 负载均衡即可。

5. 健康检查

app.get('/health', async (_, reply) => {
try {
// 用一张 100×100 的白图快速确认渲染链路健康
await renderer.jsonToImage({ version: '6.0.0', width: 100, height: 100, objects: [] }, { multiplier: 1 });
reply.send({ ok: true });
} catch (e) {
reply.code(503).send({ ok: false, error: String(e) });
}
});

典型场景

场景 1:批量生成营销图(同一模板 + 动态文案)

import { renderer } from './renderer';

async function batchGenerate(template: any, titles: string[]) {
return Promise.all(
titles.map(async title => {
const json = structuredClone(template);
// 假设第 4 个对象是主标题
json.objects[3].text = title;

const png = await renderer.jsonToImage(json, {
format: 'jpeg',
quality: 0.88,
multiplier: 2,
fonts: /* 统一字体声明 */,
});
await uploadToOSS(png, `marketing/${title}.jpg`);
}),
);
}

await batchGenerate(template, ['春节特惠', '618 狂欢', '双 11 大促']);

场景 2:异步任务队列(BullMQ)

大尺寸渲染可能耗时几秒到几十秒,不适合走同步 HTTP。用队列解耦:

// worker.ts
import { Worker } from 'bullmq';
import { renderer } from './renderer';

new Worker(
'render',
async job => {
const { json, options } = job.data;
const buffer = await renderer.jsonToImage(json, options);
return await uploadToOSS(buffer, `renders/${job.id}.png`);
},
{
connection: { host: 'redis', port: 6379 },
concurrency: 3, // 和 renderer.concurrency 对齐
}
);

场景 3:Webhook 回调

长任务完成后通知业务方:

app.post('/render-async', async (req, reply) => {
const { json, webhookUrl } = req.body as any;
const jobId = crypto.randomUUID();

// 先返回 202,渲染在后台跑
reply.code(202).send({ jobId });

renderer
.jsonToImage(json)
.then(async buffer => {
const url = await uploadToOSS(buffer, `renders/${jobId}.png`);
await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify({ jobId, status: 'done', url }),
});
})
.catch(async e => {
await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify({ jobId, status: 'error', message: String(e) }),
});
});
});

常见问题

Q1:Docker 容器里启动就崩溃

90% 是缺 --no-sandbox。容器里没有完整的用户命名空间,Chromium 的沙箱机制会直接退出。

await createRenderer({
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});

Q2:渲染出来的图字体没对齐

和客户端的排查方式完全一致,按 字体一致性文档 的 6 步走一遍,90% 能定位。

Q3:图片没出来,出来的是 broken 图标

资源抓取失败。常见原因:

  • 图片 URL 404 / 403
  • 图片服务器返回了 HTML 错误页(Content-Type 不是 image/*)
  • 防火墙拦截了服务器出站流量

开启 renderer 的详细日志看请求细节:

const renderer = await createRenderer({
logger: {
info: console.log,
warn: console.warn,
error: console.error,
},
});

Q4:多副本部署下,字体每次都重新下载

给 renderer 配代理,代理做缓存;或者直接用 方案 B 预注册本地字体

Q5:生产跑几小时后越来越慢

Chromium 长时间运行会有内存碎片。简单做法:

  • 每跑 N(如 1000)次 jsonToImageclose() 然后重建 renderer
  • 或者 Pod 配置 livenessProbe + memory.limit,超限自动重启

Q6:我想直接用 polotno-node 先顶着行吗?

理论上可以(都是 Fabric JSON),但是:

  • Ydesign 的 __strokeOptions(图片智能描边)、keyValues(自定义字段)这些扩展字段在 polotno-node 里不会被识别,导出效果会缺失
  • @ydesign/node 的 API 设计和 Ydesign 客户端一致,未来切换零成本

建议的迁移路径:

  1. 现在:用云渲染 API 跑起来(底层同一套内核,效果保证一致)
  2. 未来@ydesign/node 发布后再迁到自建

延伸阅读


进度跟踪

@ydesign/node 的发布进度:

  • M1 · 核心内核剥离:从云渲染服务中拆出独立渲染模块(已完成,目前云渲染 API 已在用)
  • 🚧 M2 · 包封装createRenderer / jsonToImage / jsonToPDF 对外 API
  • M3 · 矢量 PDFvector: true 模式
  • M4 · Docker 官方镜像 + K8s Helm Chart
  • M5 · NPM 正式发布

如果你有具体场景或想参与 Beta 测试,欢迎到 GitHub Discussions 找我们。