设计 H5 海报/截图生成系统
问题
如何设计一个高质量的 H5 海报/截图生成系统?要求支持营销海报、分享卡片、页面截图保存等场景,兼顾高清适配、跨域处理、文字排版等关键技术挑战,同时提供客户端和服务端两种生成方案。
答案
H5 海报/截图生成系统的核心思路:通过模板引擎定义海报结构,选择合适的渲染方案(html2canvas / dom-to-image / Canvas 手绘 / Puppeteer)将内容渲染为图片,配合跨域代理、高清适配、字体加载等关键技术,最终输出高质量的海报图片供用户保存或分享。根据业务场景选择客户端生成(即时性好、无服务端压力)或服务端生成(一致性强、兼容性好),或两者结合的混合方案。
一、需求分析
功能需求
| 模块 | 功能点 |
|---|---|
| 营销海报 | 活动海报、优惠券分享、邀请裂变海报、节日祝福 |
| 分享卡片 | 文章分享卡片、商品分享图、用户个人名片 |
| 截图保存 | 页面局部截图、聊天记录截图、数据报表截图 |
| 模板系统 | 模板管理、动态数据填充、可视化模板编辑 |
| 二维码合成 | 生成并嵌入二维码/小程序码、支持自定义样式 |
| 导出分享 | 保存到相册、分享到社交平台、复制到剪贴板 |
非功能需求
| 指标 | 目标 |
|---|---|
| 生成速度 | 客户端生成 < 2s,服务端生成 < 5s |
| 图片质量 | 支持 Retina 高清屏,2x/3x 适配 |
| 兼容性 | iOS Safari、Android WebView、微信内置浏览器 |
| 稳定性 | 跨域图片正常渲染,字体正确加载 |
| 可扩展 | 模板可配置、支持多种海报类型快速接入 |
海报生成系统最大的挑战不在于"画出来",而在于一致性和兼容性——不同设备、不同浏览器、不同网络环境下生成的海报要保持一致的高质量。这决定了在方案选型时需要充分考虑各方案的局限性。
二、整体架构
2.1 系统架构全景图
2.2 客户端生成流程
三、技术方案对比
3.1 四种主流方案
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| html2canvas | 克隆 DOM,用 Canvas 重绘 | 接入简单、支持复杂 CSS | CSS 支持不完整、跨域问题 | 页面截图、简单海报 |
| dom-to-image | SVG foreignObject 嵌入 | 还原度高、支持更多 CSS | Safari 兼容差、CORS 限制 | PC 端截图 |
| Canvas 手绘 | Canvas API 逐元素绘制 | 精确控制、无 CSS 兼容问题 | 开发成本高、布局复杂 | 高质量营销海报 |
| Puppeteer | 无头浏览器截图 | 还原度最高、一致性最好 | 需要服务端、生成较慢 | 服务端批量生成 |
- 快速实现 + 页面截图 → html2canvas
- 高质量营销海报 → Canvas 手绘
- 一致性要求高 + 批量生成 → Puppeteer 服务端生成
- PC 端 + 复杂样式 → dom-to-image
3.2 html2canvas 原理详解
html2canvas 的核心原理是 DOM 克隆 + Canvas 重绘,并非真正的"截图",而是"重新画一遍"。
import html2canvas from 'html2canvas';
interface ScreenshotOptions {
element: HTMLElement;
scale?: number;
backgroundColor?: string;
useCORS?: boolean;
}
async function captureScreenshot(options: ScreenshotOptions): Promise<Blob> {
const {
element,
scale = window.devicePixelRatio || 2, // 高清适配
backgroundColor = '#ffffff',
useCORS = true,
} = options;
const canvas = await html2canvas(element, {
scale,
backgroundColor,
useCORS,
allowTaint: false,
// 关键配置:处理滚动偏移
scrollX: -window.scrollX,
scrollY: -window.scrollY,
windowWidth: document.documentElement.offsetWidth,
windowHeight: document.documentElement.offsetHeight,
});
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Canvas toBlob failed'));
},
'image/png',
1.0,
);
});
}
以下 CSS 特性 html2canvas 不支持或支持不完整:
box-shadow的spread参数表现不一致filter(如blur()、grayscale())不支持background-blend-mode不支持-webkit-line-clamp多行省略号不支持writing-mode竖排文字不支持- 伪元素
::before/::after内容有时缺失 - CSS
transform复杂变换可能失真 position: sticky不支持
解决思路:对于不支持的 CSS 属性,可以在截图前用 JS 将其转为 html2canvas 能识别的等效样式(如用真实 DOM 替代伪元素、用 Canvas 滤镜替代 CSS filter 等)。
3.3 dom-to-image 原理
dom-to-image 利用 SVG 的 foreignObject 能力将 HTML 嵌入 SVG,再通过 Image 加载 SVG 并绘制到 Canvas。
import domtoimage from 'dom-to-image';
async function captureDomToImage(element: HTMLElement): Promise<Blob> {
const scale = window.devicePixelRatio || 2;
const width = element.offsetWidth;
const height = element.offsetHeight;
const blob = await domtoimage.toBlob(element, {
width: width * scale,
height: height * scale,
style: {
transform: `scale(${scale})`,
transformOrigin: 'top left',
width: `${width}px`,
height: `${height}px`,
},
cacheBust: true, // 为 URL 添加时间戳,避免缓存导致 CORS 问题
});
return blob;
}
- Safari 兼容性差:Safari 对
foreignObject的支持有限,大尺寸内容可能失败 - CORS 严格:所有外部资源(图片、字体)必须允许跨域或转为内联
- 推荐替代:dom-to-image-more 修复了一些已知问题
四、核心模块设计
4.1 Canvas 手绘方案
Canvas 手绘是生产级海报的首选方案,通过 Canvas API 逐元素绘制,拥有最精确的控制能力。
海报绘制引擎
/** 海报元素类型 */
type PosterElementType = 'image' | 'text' | 'rect' | 'qrcode' | 'line';
/** 基础元素定义 */
interface BaseElement {
type: PosterElementType;
x: number;
y: number;
width: number;
height: number;
opacity?: number;
borderRadius?: number;
}
/** 图片元素 */
interface ImageElement extends BaseElement {
type: 'image';
src: string;
mode: 'cover' | 'contain' | 'fill'; // 裁剪模式
}
/** 文字元素 */
interface TextElement extends BaseElement {
type: 'text';
content: string;
fontSize: number;
fontFamily: string;
fontWeight: 'normal' | 'bold';
color: string;
lineHeight: number;
textAlign: 'left' | 'center' | 'right';
maxLines?: number; // 最大行数,超出省略
}
/** 矩形元素 */
interface RectElement extends BaseElement {
type: 'rect';
backgroundColor: string;
borderColor?: string;
borderWidth?: number;
}
/** 二维码元素 */
interface QRCodeElement extends BaseElement {
type: 'qrcode';
data: string;
foreground?: string;
background?: string;
}
type PosterElement = ImageElement | TextElement | RectElement | QRCodeElement;
/** 海报配置 */
interface PosterConfig {
width: number;
height: number;
backgroundColor: string;
elements: PosterElement[];
scale: number; // 缩放比例,用于高清适配
}
class PosterEngine {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private config: PosterConfig;
constructor(config: PosterConfig) {
this.config = config;
this.canvas = document.createElement('canvas');
// 高清适配:Canvas 实际尺寸 = 显示尺寸 × scale
this.canvas.width = config.width * config.scale;
this.canvas.height = config.height * config.scale;
this.canvas.style.width = `${config.width}px`;
this.canvas.style.height = `${config.height}px`;
const ctx = this.canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context not available');
this.ctx = ctx;
this.ctx.scale(config.scale, config.scale);
}
/** 渲染整张海报 */
async render(): Promise<HTMLCanvasElement> {
// 绘制背景
this.ctx.fillStyle = this.config.backgroundColor;
this.ctx.fillRect(0, 0, this.config.width, this.config.height);
// 按顺序绘制元素(图层叠加)
for (const element of this.config.elements) {
this.ctx.save();
if (element.opacity !== undefined) {
this.ctx.globalAlpha = element.opacity;
}
await this.drawElement(element);
this.ctx.restore();
}
return this.canvas;
}
private async drawElement(element: PosterElement): Promise<void> {
switch (element.type) {
case 'image':
await this.drawImage(element);
break;
case 'text':
this.drawText(element);
break;
case 'rect':
this.drawRect(element);
break;
case 'qrcode':
await this.drawQRCode(element);
break;
}
}
/** 绘制图片(支持圆角裁剪) */
private async drawImage(element: ImageElement): Promise<void> {
const img = await this.loadImage(element.src);
if (element.borderRadius) {
this.clipRoundRect(element.x, element.y, element.width, element.height, element.borderRadius);
}
// 计算 cover/contain 模式下的绘制参数
const drawParams = this.calcDrawParams(
img.width, img.height,
element.width, element.height,
element.mode,
);
this.ctx.drawImage(
img,
drawParams.sx, drawParams.sy, drawParams.sw, drawParams.sh,
element.x + drawParams.dx, element.y + drawParams.dy,
drawParams.dw, drawParams.dh,
);
}
/** 绘制文本(支持自动换行和省略号) */
private drawText(element: TextElement): void {
this.ctx.font = `${element.fontWeight} ${element.fontSize}px ${element.fontFamily}`;
this.ctx.fillStyle = element.color;
this.ctx.textAlign = element.textAlign;
this.ctx.textBaseline = 'top';
const lines = this.wrapText(element.content, element.width);
const maxLines = element.maxLines || lines.length;
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
let text = lines[i];
// 最后一行超出时添加省略号
if (i === maxLines - 1 && lines.length > maxLines) {
text = this.truncateWithEllipsis(text, element.width);
}
const y = element.y + i * element.lineHeight;
this.ctx.fillText(text, element.x, y);
}
}
/** 文字自动换行 */
private wrapText(text: string, maxWidth: number): string[] {
const lines: string[] = [];
let currentLine = '';
for (const char of text) {
const testLine = currentLine + char;
const metrics = this.ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
/** 省略号截断 */
private truncateWithEllipsis(text: string, maxWidth: number): string {
const ellipsis = '...';
const ellipsisWidth = this.ctx.measureText(ellipsis).width;
let truncated = text;
while (this.ctx.measureText(truncated).width + ellipsisWidth > maxWidth && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
return truncated + ellipsis;
}
/** 绘制矩形(支持圆角) */
private drawRect(element: RectElement): void {
if (element.borderRadius) {
this.clipRoundRect(element.x, element.y, element.width, element.height, element.borderRadius);
}
this.ctx.fillStyle = element.backgroundColor;
this.ctx.fillRect(element.x, element.y, element.width, element.height);
if (element.borderColor && element.borderWidth) {
this.ctx.strokeStyle = element.borderColor;
this.ctx.lineWidth = element.borderWidth;
this.ctx.strokeRect(element.x, element.y, element.width, element.height);
}
}
/** 绘制二维码 */
private async drawQRCode(element: QRCodeElement): Promise<void> {
// 使用 qrcode 库生成二维码 Canvas
const QRCode = await import('qrcode');
const qrCanvas = document.createElement('canvas');
await QRCode.toCanvas(qrCanvas, element.data, {
width: element.width * this.config.scale,
margin: 0,
color: {
dark: element.foreground || '#000000',
light: element.background || '#ffffff',
},
});
this.ctx.drawImage(qrCanvas, element.x, element.y, element.width, element.height);
}
/** 圆角裁剪路径 */
private clipRoundRect(x: number, y: number, w: number, h: number, r: number): void {
this.ctx.beginPath();
this.ctx.moveTo(x + r, y);
this.ctx.arcTo(x + w, y, x + w, y + h, r);
this.ctx.arcTo(x + w, y + h, x, y + h, r);
this.ctx.arcTo(x, y + h, x, y, r);
this.ctx.arcTo(x, y, x + w, y, r);
this.ctx.closePath();
this.ctx.clip();
}
/** 计算 cover/contain 绘制参数 */
private calcDrawParams(
srcW: number, srcH: number,
destW: number, destH: number,
mode: 'cover' | 'contain' | 'fill',
) {
if (mode === 'fill') {
return { sx: 0, sy: 0, sw: srcW, sh: srcH, dx: 0, dy: 0, dw: destW, dh: destH };
}
const srcRatio = srcW / srcH;
const destRatio = destW / destH;
if (mode === 'cover') {
if (srcRatio > destRatio) {
const sw = srcH * destRatio;
return { sx: (srcW - sw) / 2, sy: 0, sw, sh: srcH, dx: 0, dy: 0, dw: destW, dh: destH };
} else {
const sh = srcW / destRatio;
return { sx: 0, sy: (srcH - sh) / 2, sw: srcW, sh, dx: 0, dy: 0, dw: destW, dh: destH };
}
}
// contain
if (srcRatio > destRatio) {
const dh = destW / srcRatio;
return { sx: 0, sy: 0, sw: srcW, sh: srcH, dx: 0, dy: (destH - dh) / 2, dw: destW, dh };
} else {
const dw = destH * srcRatio;
return { sx: 0, sy: 0, sw: srcW, sh: srcH, dx: (destW - dw) / 2, dy: 0, dw, dh: destH };
}
}
/** 加载图片 */
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // 跨域图片处理
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
/** 导出为 Blob */
async toBlob(type: string = 'image/png', quality: number = 1.0): Promise<Blob> {
return new Promise((resolve, reject) => {
this.canvas.toBlob(
(blob) => blob ? resolve(blob) : reject(new Error('toBlob failed')),
type,
quality,
);
});
}
}
4.2 跨域图片处理
跨域是海报生成中最常见的"坑"之一。当 Canvas 绘制了跨域图片后,Canvas 会被"污染"(tainted),调用 toBlob() 或 toDataURL() 会抛出安全错误。
三种解决方案
- CORS 配置
- 服务端代理
- Base64 转换
前提:图片服务器支持 CORS 响应头。
function loadCORSImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
// 设置 crossOrigin 让浏览器发送 CORS 请求
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`CORS image load failed: ${url}`));
// 注意:crossOrigin 必须在 src 之前设置
img.src = url;
});
}
服务端需要返回的响应头:
Access-Control-Allow-Origin: *
部分 CDN 在 URL 完全相同时会返回缓存中不带 CORS 头的响应。解决办法是给 URL 添加随机查询参数(cache bust):
const corsUrl = `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`;
当图片服务器不支持 CORS 时,通过自己的服务端代理转发图片请求。
/** 客户端:通过代理加载图片 */
function loadProxyImage(originUrl: string): Promise<HTMLImageElement> {
const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(originUrl)}`;
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Proxy image load failed'));
img.src = proxyUrl;
});
}
/** 服务端(Node.js/Express):代理图片请求 */
import express from 'express';
import fetch from 'node-fetch';
const app = express();
app.get('/api/image-proxy', async (req, res) => {
const { url } = req.query;
if (typeof url !== 'string') {
return res.status(400).send('Missing url parameter');
}
// 安全校验:限制允许的域名
const allowedDomains = ['cdn.example.com', 'img.example.com'];
const parsedUrl = new URL(url);
if (!allowedDomains.includes(parsedUrl.hostname)) {
return res.status(403).send('Domain not allowed');
}
const response = await fetch(url);
const contentType = response.headers.get('content-type') || 'image/png';
res.setHeader('Content-Type', contentType);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=86400');
response.body?.pipe(res);
});
将图片转为 Base64 Data URL,完全绕过跨域限制。
/** 将远程图片转为 Base64 */
async function imageToBase64(url: string): Promise<string> {
// 方式一:通过 fetch 获取并转换(需要 CORS 或代理)
const response = await fetch(url);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/** 使用 Base64 绘制图片 */
async function drawBase64Image(
ctx: CanvasRenderingContext2D,
url: string,
x: number,
y: number,
w: number,
h: number,
): Promise<void> {
const base64 = await imageToBase64(url);
const img = new Image();
img.src = base64; // Base64 不存在跨域问题
await new Promise<void>((resolve) => {
img.onload = () => {
ctx.drawImage(img, x, y, w, h);
resolve();
};
});
}
- 优先 CORS 配置:改服务端响应头即可,零额外开销
- 次选服务端代理:当无法修改第三方 CDN 配置时
- 最后 Base64 转换:适合少量小图片,大图会显著增加内存占用
4.3 高清适配(Retina 屏幕)
移动端设备的 devicePixelRatio(DPR)通常为 2 或 3,如果 Canvas 的实际像素与 CSS 像素 1:1,生成的图片在高清屏上会模糊。
interface RetinaCanvasOptions {
width: number; // CSS 宽度
height: number; // CSS 高度
maxScale?: number; // 最大缩放倍数,防止低端设备内存不足
}
function createRetinaCanvas(options: RetinaCanvasOptions): {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
scale: number;
} {
const { width, height, maxScale = 3 } = options;
// 获取设备像素比,限制最大值
const dpr = Math.min(window.devicePixelRatio || 1, maxScale);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// 设置实际像素尺寸
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
// 设置 CSS 显示尺寸
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 缩放绘图上下文,后续绘制使用 CSS 尺寸坐标即可
ctx.scale(dpr, dpr);
return { canvas, ctx, scale: dpr };
}
- 内存限制:3x 缩放下,750×1334 的海报实际 Canvas 为 2250×4002(约 36MB 内存)。低端设备需降低 DPR
- iOS Canvas 限制:iOS Safari 限制 Canvas 最大面积约 16777216 像素(4096×4096),超出会白屏
- 导出质量:
toBlob导出 JPEG 时建议 quality 设为 0.92,PNG 始终无损
4.4 文字排版
Canvas 原生的文字绘制能力有限,不支持自动换行、富文本等特性,需要手动实现。
字体加载
/** 使用 FontFace API 加载自定义字体 */
async function loadFont(
fontFamily: string,
fontUrl: string,
): Promise<void> {
// 检查字体是否已加载
if (document.fonts.check(`16px "${fontFamily}"`)) {
return;
}
const fontFace = new FontFace(fontFamily, `url(${fontUrl})`);
try {
const loadedFont = await fontFace.load();
document.fonts.add(loadedFont);
// 等待字体在文档中可用
await document.fonts.ready;
console.log(`Font "${fontFamily}" loaded successfully`);
} catch (error) {
console.warn(`Font "${fontFamily}" load failed, using fallback`);
}
}
/** 批量预加载字体 */
async function preloadFonts(
fonts: Array<{ family: string; url: string }>,
): Promise<void> {
await Promise.allSettled(
fonts.map((font) => loadFont(font.family, font.url)),
);
}
高级文字绘制
interface RichTextSegment {
text: string;
fontSize?: number;
fontFamily?: string;
color?: string;
bold?: boolean;
italic?: boolean;
}
interface TextDrawOptions {
x: number;
y: number;
maxWidth: number;
lineHeight: number;
maxLines?: number;
align?: 'left' | 'center' | 'right';
}
class TextRenderer {
private ctx: CanvasRenderingContext2D;
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
/** 绘制多行文字(支持自动换行 + 省略号) */
drawMultilineText(
text: string,
options: TextDrawOptions & { font: string; color: string },
): number {
const { x, y, maxWidth, lineHeight, maxLines, align = 'left', font, color } = options;
this.ctx.font = font;
this.ctx.fillStyle = color;
this.ctx.textBaseline = 'top';
const lines = this.splitLines(text, maxWidth);
const renderLines = maxLines ? lines.slice(0, maxLines) : lines;
renderLines.forEach((line, i) => {
let drawText = line;
// 最后一行省略
if (maxLines && i === maxLines - 1 && lines.length > maxLines) {
drawText = this.ellipsis(line, maxWidth);
}
// 根据对齐方式计算 x 坐标
let drawX = x;
if (align === 'center') {
drawX = x + (maxWidth - this.ctx.measureText(drawText).width) / 2;
} else if (align === 'right') {
drawX = x + maxWidth - this.ctx.measureText(drawText).width;
}
this.ctx.fillText(drawText, drawX, y + i * lineHeight);
});
return renderLines.length * lineHeight;
}
/** 按字符拆分行(中英文混排) */
private splitLines(text: string, maxWidth: number): string[] {
const lines: string[] = [];
const paragraphs = text.split('\n');
for (const paragraph of paragraphs) {
let currentLine = '';
for (const char of paragraph) {
const testLine = currentLine + char;
if (this.ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
}
return lines;
}
private ellipsis(text: string, maxWidth: number): string {
const dots = '...';
const dotsWidth = this.ctx.measureText(dots).width;
let result = text;
while (this.ctx.measureText(result).width + dotsWidth > maxWidth && result.length > 0) {
result = result.slice(0, -1);
}
return result + dots;
}
}
4.5 二维码合成
import QRCode from 'qrcode';
interface QRCodeOptions {
data: string;
size: number;
margin?: number;
foreground?: string;
background?: string;
logo?: string; // 中间 Logo 图片 URL
logoSize?: number; // Logo 尺寸占比(0~0.3)
}
async function generateQRCode(options: QRCodeOptions): Promise<HTMLCanvasElement> {
const {
data,
size,
margin = 1,
foreground = '#000000',
background = '#ffffff',
logo,
logoSize = 0.2,
} = options;
// 生成二维码 Canvas
const qrCanvas = document.createElement('canvas');
await QRCode.toCanvas(qrCanvas, data, {
width: size,
margin,
color: { dark: foreground, light: background },
errorCorrectionLevel: logo ? 'H' : 'M', // 有 Logo 时用高容错率
});
// 如果有 Logo,绘制到二维码中心
if (logo) {
const ctx = qrCanvas.getContext('2d')!;
const img = await loadImage(logo);
const logoActualSize = size * logoSize;
const logoX = (size - logoActualSize) / 2;
const logoY = (size - logoActualSize) / 2;
// 绘制白色背景边框
const padding = 4;
ctx.fillStyle = '#ffffff';
ctx.fillRect(
logoX - padding, logoY - padding,
logoActualSize + padding * 2, logoActualSize + padding * 2,
);
// 绘制 Logo
ctx.drawImage(img, logoX, logoY, logoActualSize, logoActualSize);
}
return qrCanvas;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
4.6 模板引擎
通过 JSON 配置定义海报模板,实现数据与渲染分离。
/** 模板变量 - 支持表达式 */
type TemplateValue = string | number | boolean;
/** 模板数据上下文 */
interface TemplateContext {
[key: string]: TemplateValue | TemplateContext;
}
/** 海报模板定义 */
interface PosterTemplate {
id: string;
name: string;
width: number;
height: number;
backgroundColor: string;
elements: TemplateElement[]; // 元素使用模板变量
fonts: Array<{ family: string; url: string }>;
}
interface TemplateElement {
type: PosterElementType;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown; // 属性值可包含 {{variable}} 占位符
}
class TemplateEngine {
/** 解析模板变量 */
static resolve(template: PosterTemplate, data: TemplateContext): PosterConfig {
const resolvedElements = template.elements.map((element) => {
const resolved: Record<string, unknown> = {};
for (const [key, value] of Object.entries(element)) {
resolved[key] = typeof value === 'string' ? this.interpolate(value, data) : value;
}
return resolved as unknown as PosterElement;
});
return {
width: template.width,
height: template.height,
backgroundColor: template.backgroundColor,
elements: resolvedElements,
scale: window.devicePixelRatio || 2,
};
}
/** 模板字符串插值 */
private static interpolate(template: string, data: TemplateContext): string {
return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, path: string) => {
const value = this.getNestedValue(data, path);
return value !== undefined ? String(value) : '';
});
}
/** 获取嵌套属性值 */
private static getNestedValue(obj: TemplateContext, path: string): TemplateValue | undefined {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current as TemplateValue;
}
}
模板 JSON 示例:
{
"id": "share-card-01",
"name": "文章分享卡片",
"width": 375,
"height": 667,
"backgroundColor": "#ffffff",
"fonts": [
{ "family": "PingFang SC", "url": "/fonts/PingFangSC-Regular.woff2" }
],
"elements": [
{
"type": "image",
"x": 0, "y": 0, "width": 375, "height": 250,
"src": "{{article.coverImage}}",
"mode": "cover"
},
{
"type": "text",
"x": 20, "y": 270, "width": 335, "height": 80,
"content": "{{article.title}}",
"fontSize": 20, "fontWeight": "bold",
"fontFamily": "PingFang SC",
"color": "#333333",
"lineHeight": 30,
"maxLines": 2
},
{
"type": "text",
"x": 20, "y": 370, "width": 335, "height": 120,
"content": "{{article.summary}}",
"fontSize": 14, "fontWeight": "normal",
"fontFamily": "PingFang SC",
"color": "#666666",
"lineHeight": 22,
"maxLines": 4
},
{
"type": "image",
"x": 20, "y": 530, "width": 40, "height": 40,
"src": "{{user.avatar}}",
"mode": "cover",
"borderRadius": 20
},
{
"type": "text",
"x": 70, "y": 538, "width": 200, "height": 24,
"content": "{{user.nickname}}",
"fontSize": 14, "fontWeight": "normal",
"fontFamily": "PingFang SC",
"color": "#999999",
"lineHeight": 24
},
{
"type": "qrcode",
"x": 285, "y": 510, "width": 70, "height": 70,
"data": "{{shareUrl}}"
}
]
}
五、服务端生成方案
5.1 Puppeteer 截图服务
当需要批量生成或高一致性的海报时,服务端 Puppeteer 是最佳选择。
import puppeteer, { Browser, Page } from 'puppeteer';
interface ScreenshotTask {
url: string; // 海报页面 URL
width: number;
height: number;
deviceScaleFactor?: number;
waitForSelector?: string; // 等待某个元素出现
waitForTimeout?: number; // 额外等待时间(ms)
}
class PuppeteerScreenshotService {
private browser: Browser | null = null;
/** 初始化浏览器实例(复用,避免重复启动) */
async init(): Promise<void> {
this.browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--font-render-hinting=none', // 字体渲染一致性
],
});
}
/** 生成截图 */
async screenshot(task: ScreenshotTask): Promise<Buffer> {
if (!this.browser) throw new Error('Browser not initialized');
const page = await this.browser.newPage();
try {
// 设置视口大小和设备像素比
await page.setViewport({
width: task.width,
height: task.height,
deviceScaleFactor: task.deviceScaleFactor || 2,
});
// 加载页面
await page.goto(task.url, {
waitUntil: 'networkidle0', // 等待网络完全空闲
timeout: 30000,
});
// 等待指定元素出现
if (task.waitForSelector) {
await page.waitForSelector(task.waitForSelector, { timeout: 10000 });
}
// 额外等待(字体加载、动画完成等)
if (task.waitForTimeout) {
await new Promise((r) => setTimeout(r, task.waitForTimeout));
}
// 截图
const buffer = await page.screenshot({
type: 'png',
fullPage: false,
clip: { x: 0, y: 0, width: task.width, height: task.height },
});
return buffer as Buffer;
} finally {
await page.close(); // 确保 page 被关闭
}
}
/** 销毁浏览器实例 */
async destroy(): Promise<void> {
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
}
5.2 客户端 vs 服务端生成对比
| 维度 | 客户端生成 | 服务端生成(Puppeteer) |
|---|---|---|
| 生成速度 | 快(< 2s) | 较慢(2~5s) |
| 一致性 | 受设备/浏览器影响 | 环境统一,一致性极高 |
| 兼容性 | 需处理各浏览器差异 | 统一 Chromium 环境 |
| CSS 支持 | html2canvas 有局限 | 完整支持所有 CSS |
| 服务器成本 | 无 | 需要服务器资源(CPU/内存密集) |
| 离线可用 | 是 | 否 |
| 批量生成 | 不适合 | 适合(可排队处理) |
| 用户数据隐私 | 数据在客户端 | 数据需上传服务端 |
- 用户主动触发 + 实时预览 → 客户端 Canvas 手绘
- 批量生成 + 后台任务 → 服务端 Puppeteer
- 高一致性要求 → 服务端 Puppeteer
- 成本敏感 → 客户端生成
- 最佳实践:客户端优先,失败时自动降级到服务端
六、性能优化
6.1 资源预加载
interface PreloadResult {
images: Map<string, HTMLImageElement>;
fonts: Set<string>;
qrcodes: Map<string, HTMLCanvasElement>;
}
class ResourcePreloader {
/** 预加载海报所需的全部资源 */
async preload(config: PosterConfig): Promise<PreloadResult> {
const result: PreloadResult = {
images: new Map(),
fonts: new Set(),
qrcodes: new Map(),
};
// 并行预加载所有资源
const tasks: Promise<void>[] = [];
for (const element of config.elements) {
if (element.type === 'image') {
tasks.push(this.preloadImage(element.src, result.images));
}
if (element.type === 'text') {
tasks.push(this.preloadFont(element.fontFamily, result.fonts));
}
if (element.type === 'qrcode') {
tasks.push(this.preloadQRCode(element, result.qrcodes));
}
}
await Promise.allSettled(tasks); // 使用 allSettled 避免单个失败阻塞全部
return result;
}
private async preloadImage(
src: string,
cache: Map<string, HTMLImageElement>,
): Promise<void> {
if (cache.has(src)) return;
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = src;
});
cache.set(src, img);
}
private async preloadFont(fontFamily: string, cache: Set<string>): Promise<void> {
if (cache.has(fontFamily)) return;
await document.fonts.ready;
cache.add(fontFamily);
}
private async preloadQRCode(
element: QRCodeElement,
cache: Map<string, HTMLCanvasElement>,
): Promise<void> {
if (cache.has(element.data)) return;
const QRCode = await import('qrcode');
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, element.data, { width: element.width * 2 });
cache.set(element.data, canvas);
}
}
6.2 离屏 Canvas 与 Web Worker
/** 使用 OffscreenCanvas 在 Web Worker 中生成海报 */
// --- 主线程 ---
async function generatePosterInWorker(config: PosterConfig): Promise<Blob> {
// 检测 OffscreenCanvas 支持
if (typeof OffscreenCanvas === 'undefined') {
console.warn('OffscreenCanvas not supported, falling back to main thread');
// 回退到主线程方案
const engine = new PosterEngine(config);
await engine.render();
return engine.toBlob();
}
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('./poster-worker.ts', import.meta.url),
{ type: 'module' },
);
worker.onmessage = (e: MessageEvent) => {
if (e.data.type === 'success') {
resolve(e.data.blob as Blob);
} else {
reject(new Error(e.data.error));
}
worker.terminate();
};
worker.postMessage({ config });
});
}
// --- Web Worker 线程 ---
self.onmessage = async (e: MessageEvent) => {
try {
const { config } = e.data;
// 在 Worker 中使用 OffscreenCanvas
const canvas = new OffscreenCanvas(
config.width * config.scale,
config.height * config.scale,
);
const ctx = canvas.getContext('2d')!;
ctx.scale(config.scale, config.scale);
// 绘制背景
ctx.fillStyle = config.backgroundColor;
ctx.fillRect(0, 0, config.width, config.height);
// 绘制各元素...(省略具体绘制逻辑,与主线程类似)
const blob = await canvas.convertToBlob({ type: 'image/png' });
self.postMessage({ type: 'success', blob });
} catch (error) {
self.postMessage({ type: 'error', error: (error as Error).message });
}
};
6.3 性能优化策略总览
| 优化方向 | 具体措施 | 效果 |
|---|---|---|
| 资源预加载 | 图片、字体、二维码并行预加载 | 减少渲染等待时间 50%+ |
| 离屏 Canvas | OffscreenCanvas + Web Worker | 不阻塞主线程,UI 保持流畅 |
| 图片压缩 | 加载时使用适当尺寸的图片(非原图) | 减少加载时间和内存占用 |
| 缓存复用 | 已加载的图片/字体/二维码复用 | 避免重复网络请求 |
| 分层绘制 | 静态背景层与动态内容层分离 | 仅重绘变化部分 |
| Canvas 池化 | 复用 Canvas 元素,避免频繁创建销毁 | 减少 GC 压力 |
| 渐进式渲染 | 先显示低清版本,再替换高清版本 | 提升感知速度 |
| 降级方案 | 检测性能,自动降低 DPR 或切换方案 | 兼容低端设备 |
七、扩展设计
7.1 海报生成 SDK 架构
/** SDK 入口 */
interface PosterSDKOptions {
renderer: 'canvas' | 'html2canvas' | 'server';
serverUrl?: string;
maxScale?: number;
plugins?: PosterPlugin[];
onProgress?: (progress: number) => void;
}
interface PosterPlugin {
name: string;
beforeRender?: (config: PosterConfig) => PosterConfig;
afterRender?: (canvas: HTMLCanvasElement) => Promise<HTMLCanvasElement>;
}
class PosterSDK {
private options: PosterSDKOptions;
private plugins: PosterPlugin[];
constructor(options: PosterSDKOptions) {
this.options = options;
this.plugins = options.plugins || [];
}
async generate(
template: PosterTemplate,
data: TemplateContext,
): Promise<Blob> {
// 1. 解析模板
let config = TemplateEngine.resolve(template, data);
// 2. 执行插件前置钩子
for (const plugin of this.plugins) {
if (plugin.beforeRender) {
config = plugin.beforeRender(config);
}
}
this.options.onProgress?.(0.1);
// 3. 预加载资源
const preloader = new ResourcePreloader();
await preloader.preload(config);
this.options.onProgress?.(0.5);
// 4. 渲染
const engine = new PosterEngine(config);
let canvas = await engine.render();
this.options.onProgress?.(0.8);
// 5. 执行插件后置钩子
for (const plugin of this.plugins) {
if (plugin.afterRender) {
canvas = await plugin.afterRender(canvas);
}
}
// 6. 导出
const blob = await engine.toBlob();
this.options.onProgress?.(1.0);
return blob;
}
}
7.2 保存和分享
/** 保存图片到本地 */
function downloadImage(blob: Blob, filename: string = 'poster.png'): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // 释放内存
}
/** 复制图片到剪贴板 */
async function copyImageToClipboard(blob: Blob): Promise<void> {
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
console.log('Image copied to clipboard');
} catch (error) {
console.error('Clipboard write failed:', error);
// 降级:打开新窗口展示图片
const url = URL.createObjectURL(blob);
window.open(url);
}
}
/** 微信/移动端长按保存引导 */
function showSaveGuide(blob: Blob, container: HTMLElement): void {
const url = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = url;
img.style.cssText = 'width: 100%; display: block;';
// 移动端:展示图片让用户长按保存
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); display: flex;
flex-direction: column; align-items: center;
justify-content: center; z-index: 9999;
`;
const tip = document.createElement('p');
tip.textContent = '长按图片保存到相册';
tip.style.cssText = 'color: #fff; margin-top: 16px; font-size: 14px;';
overlay.appendChild(img);
overlay.appendChild(tip);
overlay.addEventListener('click', () => {
overlay.remove();
URL.revokeObjectURL(url);
});
container.appendChild(overlay);
}
常见面试问题
Q1: html2canvas 的原理是什么?有哪些局限性?
答案:
html2canvas 的原理是 DOM 克隆 + Canvas 重绘,核心流程如下:
- 克隆 DOM 树:深度克隆目标 DOM 节点及其子节点
- 解析计算样式:通过
getComputedStyle获取每个节点的最终 CSS 属性 - 递归遍历节点:按 DOM 树顺序依次处理每个节点
- Canvas 逐元素绘制:将每个节点的样式转化为 Canvas 绑定指令(
fillRect、drawImage、fillText等) - 输出 Canvas:最终得到一个包含渲染结果的 Canvas 元素
关键点:html2canvas 不是真正的"屏幕截图",而是用 Canvas API "重新画了一遍",因此它只能渲染自己"理解"的 CSS 属性。
主要局限性:
| 局限 | 说明 | 解决方案 |
|---|---|---|
| CSS 支持不完整 | 不支持 filter、backdrop-filter、mix-blend-mode 等 | 替换为等效的简单样式 |
| 伪元素渲染不稳定 | ::before/::after 内容可能丢失 | 用真实 DOM 替代伪元素 |
| 跨域图片 | Canvas 被"污染"后无法导出 | CORS 配置或代理 |
transform 变换 | 复杂 3D 变换可能失真 | 简化为 2D 变换 |
| 滚动区域 | 被滚动隐藏的内容不会绘制 | 截图前先设置 overflow: visible |
position: sticky | 不支持 | 截图前临时改为 position: relative |
| SVG 渲染 | 部分 SVG 特性不支持 | 先将 SVG 转为图片 |
Q2: 海报生成中如何处理跨域图片?
答案:
跨域图片会导致 Canvas "被污染"(tainted canvas),调用 toDataURL() 或 toBlob() 时抛出 SecurityError。三种解决方案:
方案一:CORS 配置(首选)
const img = new Image();
img.crossOrigin = 'anonymous'; // 必须在设置 src 之前
img.src = 'https://cdn.example.com/image.jpg';
服务端需返回 Access-Control-Allow-Origin 响应头。
方案二:服务端代理
当无法修改第三方 CDN 的 CORS 配置时,通过自己的服务端转发请求:
// 前端请求自己的代理接口
const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(originUrl)}`;
代理服务需注意安全校验(限制域名白名单、限流等)。
方案三:Base64 转换
通过 fetch 获取图片后转为 Base64 Data URL:
const response = await fetch(proxyUrl);
const blob = await response.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
CORS 配置 > 服务端代理 > Base64 转换。CORS 配置无额外开销,Base64 会增加约 33% 的数据体积且占用更多内存。
Q3: 高清屏(Retina)适配的原理和方案是什么?
答案:
原理:高清屏的 devicePixelRatio(DPR)> 1,意味着 1 个 CSS 像素由多个物理像素渲染。如果 Canvas 的像素与 CSS 像素 1:1,在高清屏上等于放大显示,导致模糊。
适配方案:
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
// 1. Canvas 实际像素 = CSS 尺寸 × DPR
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
// 2. CSS 显示尺寸不变
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
// 3. 缩放绘图上下文,后续使用 CSS 坐标绘制
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
关键注意事项:
| 问题 | 说明 |
|---|---|
| 内存占用 | DPR=3 时,内存是 1x 的 9 倍。建议限制最大 DPR 为 3 |
| iOS 限制 | iOS Safari Canvas 面积上限约 16,777,216 像素,超出会白屏 |
| 文字清晰度 | Canvas 文字在高 DPR 下仍可能比 DOM 文字略模糊,可适当增大字号 |
| 导出尺寸 | 导出的图片为高清尺寸(如 2x),注意提示用户或在分享时压缩 |
Q4: 服务端生成和客户端生成如何选择?
答案:
决策流程:
核心考量维度:
| 维度 | 选客户端 | 选服务端 |
|---|---|---|
| 生成频率 | 用户主动触发,频率低 | 后台批量生成,频率高 |
| 一致性 | 允许设备间略有差异 | 必须完全一致(如电商活动) |
| 实时性 | 需要即时生成和预览 | 可异步排队处理 |
| CSS 复杂度 | 简单布局可控 | 复杂 CSS 动画、滤镜 |
| 成本 | 零服务器成本 | 需要 Puppeteer 集群 |
| 隐私 | 用户数据不出端 | 数据需上传服务端 |
| 离线 | 支持离线生成 | 必须联网 |
最佳实践——混合方案:
async function generatePoster(config: PosterConfig): Promise<Blob> {
try {
// 优先尝试客户端生成
return await clientGenerate(config);
} catch (error) {
console.warn('Client generate failed, fallback to server:', error);
// 客户端失败时降级到服务端
return await serverGenerate(config);
}
}
- 优先客户端生成:成本低、速度快、用户体验好
- 服务端兜底:客户端生成失败(内存不足、浏览器不支持)时自动降级
- 批量任务走服务端:定时任务、后台生成统一走 Puppeteer 集群
相关链接
- html2canvas 官方文档
- dom-to-image GitHub
- Puppeteer 官方文档
- Canvas API - MDN
- OffscreenCanvas - MDN
- FontFace API - MDN
- devicePixelRatio - MDN
- qrcode npm
- 设计在线图片编辑器 - Canvas 渲染引擎设计
- 设计图片处理 CDN 服务 - 图片处理与 CDN 缓存
- 设计大文件上传系统 - 文件处理与上传
- Web Worker 优化 - Worker 线程池与 Transferable Objects
- 图片优化 - WebP/AVIF、懒加载、CDN 处理