在做团队协作或者写技术文档的时候,经常需要把某段聊天记录截图保存下来。手动截图不仅麻烦,而且在不同设备上截出来的效果参差不齐。TeamsMock2Image就是为了解决这个问题而生的一个思路:用Node.js在服务端把结构化的消息数据直接绘制成一张统一风格的图片,输出的是PNG格式,清晰度可控,风格可定制。这篇文章就来完整拆解一下它的实现过程。

一、整体架构设计
整个工具的核心链路其实很简单:客户端把消息数据以JSON的形式提交给Node.js服务,服务端解析之后交给绘图模块渲染,最后把生成的图片以Buffer的形式返回。绘图模块是整个项目的重头戏,这里我们选用node-canvas这个库,它是对Cairo的Node绑定,API和浏览器端的Canvas基本一致,上手成本很低。
先看一下消息的数据结构设计。一条消息需要包含发送者信息、时间戳和正文内容,用TypeScript的思维描述大致是这样:
{
"theme": "light",
"messages": [
{
"sender": "张三",
"avatar": "https://ipipp.com/avatar/zhangsan.png",
"time": "2024-05-20 14:32:10",
"text": "今天下午三点开会,记得带上设计稿",
"self": false
},
{
"sender": "我",
"avatar": null,
"time": "2024-05-20 14:33:02",
"text": "收到,设计稿已经整理好了",
"self": true
}
]
}其中self字段用来区分消息是对方发来的还是自己发出的,渲染时决定气泡靠左还是靠右,这一点和Teams的界面习惯保持一致。整体采用Express搭建HTTP服务,路由只暴露一个POST接口,逻辑清晰且便于扩展成批量生成。
二、用Node Canvas绘制消息气泡
绘图部分的核心是坐标计算。我们需要为每个元素预留固定的区域:头像占48像素的圆形区域,用户名和时间戳占一行高度约20像素,剩下的空间留给消息文本。气泡的宽度不能无限拉伸,一般设置为画布宽度减去左右边距,超过这个宽度的文本需要自动换行。
下面是核心渲染函数的简化实现:
const { createCanvas, loadImage, registerFont } = require('canvas');
// 注册中文字体,避免中文乱码
registerFont('./fonts/SourceHanSansCN-Regular.ttf', {
family: 'SansCN'
});
const AVATAR_SIZE = 48;
const PADDING = 16;
async function renderMessage(ctx, msg, y) {
// 绘制头像
if (msg.avatar) {
const img = await loadImage(msg.avatar);
ctx.save();
ctx.beginPath();
ctx.arc(40, y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, 16, y, AVATAR_SIZE, AVATAR_SIZE);
ctx.restore();
}
// 绘制用户名和时间
ctx.fillStyle = '#616161';
ctx.font = '13px SansCN';
ctx.fillText(`${msg.sender} ${msg.time}`, 76, y + 16);
// 文本自动换行
const lines = wrapText(ctx, msg.text, 520);
const bubbleHeight = lines.length * 22 + 20;
// 绘制气泡背景
ctx.fillStyle = msg.self ? '#dcf8c6' : '#f0f0f0';
roundRect(ctx, 76, y + 26, 540, bubbleHeight, 8);
ctx.fill();
// 逐行绘制文本
ctx.fillStyle = '#242424';
ctx.font = '15px SansCN';
lines.forEach((line, i) => {
ctx.fillText(line, 88, y + 48 + i * 22);
});
return y + 26 + bubbleHeight + 20;
}
function wrapText(ctx, text, maxWidth) {
const lines = [];
let current = '';
for (const ch of text) {
if (ctx.measureText(current + ch).width > maxWidth) {
lines.push(current);
current = ch;
} else {
current += ch;
}
}
if (current) lines.push(current);
return lines;
}这段代码有几个细节值得注意。首先是registerFont必须在任何绘制之前调用,否则中文会渲染成方块或者直接报错。其次是wrapText用了逐字符遍历而不是按空格分词,这是为了兼容中文这种没有明显分词边界的语言,英文单词虽然可能被从中间截断,但可以通过补充连字符逻辑来优化。最后头像的圆形裁剪用了clip加restore的组合,这是Canvas的标准做法,记得每次裁剪后一定要恢复上下文状态,否则后续绘制都会被裁剪影响。
三、HTTP接口与返回图片流
渲染逻辑封装好之后,剩下的就是把它们串起来。Express接收请求后,先做参数校验,然后创建一个足够高的画布,逐条渲染消息,最后把Canvas转成PNG的Buffer直接写回响应。画布高度的处理技巧是先计算一遍所有消息占用的总高度,再创建精确尺寸的画布,避免图片底部留出大片空白。
const express = require('express');
const { createCanvas } = require('canvas');
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/api/mock2image', async (req, res) => {
const { messages, theme } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: 'messages不能为空' });
}
// 第一遍计算总高度
const totalHeight = calcTotalHeight(messages);
const canvas = createCanvas(640, totalHeight);
const ctx = canvas.getContext('2d');
// 填充背景色,支持深色主题
ctx.fillStyle = theme === 'dark' ? '#1f1f1f' : '#ffffff';
ctx.fillRect(0, 0, 640, totalHeight);
let y = 20;
for (const msg of messages) {
y = await renderMessage(ctx, msg, y);
}
res.set({
'Content-Type': 'image/png',
'Content-Disposition': 'inline'
});
res.send(canvas.toBuffer('image/png'));
});
app.listen(3000, () => console.log('服务已启动: http://127.0.0.1:3000'));接口返回时设置了Content-Type为image/png,这样前端可以直接把接口地址塞进<img>标签的src属性里展示,也可以用fetch拿到Blob之后下载,两种方式都很方便。深色主题的实现也不复杂,只需要准备两套颜色配置,在渲染前根据主题字段切换气泡背景、文字颜色和次要文字颜色即可。
四、常见坑点与性能优化
实际部署中踩到的坑主要有三类。第一类是字体问题,Linux服务器上默认没有中文字体,必须把ttf文件随项目一起部署并注册,Docker环境下还需要注意字体缓存目录的写入权限。第二类是远程头像加载失败,网络抖动会导致loadImage抛异常,务必要用try-catch包住并提供一个本地默认头像作为兜底,否则一条消息就可能导致整个请求失败。
第三类是性能问题。node-canvas的绘制是同步阻塞的,消息量一大就会卡住事件循环。优化方案有两个方向:一是控制单次请求的消息上限,比如最多200条;二是用worker_threads把渲染任务放到工作线程中执行,主线程只负责接收和返回。此外,如果同一批消息会被反复请求,可以按消息内容的hash值做缓存,命中缓存直接返回之前的Buffer,能省下大量CPU开销。对于高DPI的需求,可以在创建画布时把宽高各乘以2,再通过ctx.scale(2, 2)统一处理,输出的图片在Retina屏幕上依然锐利。
整套方案跑通之后,你会发现所谓消息截图服务,本质上就是一个纯函数:输入结构化数据,输出确定性的图片。这种解耦带来的好处是显而易见的,界面风格想调整时只改绘图参数,业务数据想换来源时只改接口层,两边互不干扰,维护起来非常省心。
Node.jsTeamsMock2Image消息转图片修改时间:2026-09-08 06:12:42