我为 Memos 做了一个图片渲染服务
趁着春节休假我给自己的 memos 系统补了一块一直想做的能力:把一条 memo 分享成一张可传播的卡片图。这件事看起来像是“截图”,但真正做起来我才发现,它本质上不是一个图片处理问题,而是一个系统设计问题。
我做下来的体会可以总结成一句话:不是在后端“画”一张图,而是在后端养了一台可控的浏览器,让它在正确的时机按下快门。

为什么要单独做一个渲染服务
从实现需求的角度,最直接的方案当然是把图片生成逻辑塞进主后端里,但我最后没有这么做。原因不是“不能做”,而是职责会变得很混乱。memos 后端擅长的是memo数据、权限、API等管理,而“把一个复杂页面稳定渲染成图”这件事,天然更像浏览器运行时的问题:
- 字体是否加载完成
- 图片是否加载完成
- 主题和语言是否正确
- 阴影、圆角、背景渐变是否和前端一致
- 在高分屏下导出后是否仍然清晰
这些问题,如果在后端重新实现一套模板引擎和排版逻辑,等于把前端再写一遍。维护成本会很快失控。所以我最后做了一个内部服务 memos-images-rendering,专门负责一件事:
- 接受渲染请求
- 打开 Memos Web 的分享页
- 等页面准备完成
- 截图并返回 PNG
主后端负责鉴权和发令,渲染服务负责执行。边界一下就清楚了。
重要的前提:需要特别说明的一点是我有一台闲置的阿里云99一年的ECS,可以作为memos image rendering服务的环境,如果单为这个服务特意准备VPS/ECS就有点“为了瓶醋包了顿饺子”的味道了。
我为什么选择“浏览器渲染”,而不是“模板渲染”
如果只是输出一张图,很多人第一反应会是 Canvas、SVG、甚至服务端模板拼图。前一版本的share memo as image就是html2canvas的实现,但是在不同的环境(如iOS、macOS、Linux等)图片显示就会不一致。方案都能做但我最终还是选了 Playwright,原因很现实:
- 前端已经有现成的分享页面样式
- 主题(light/dark)和 locale 参数已经在 Web 层存在
- 富文本、图片、emoji、CJK 字体渲染都交给浏览器
- 样式改动后,渲染结果天然跟着前端走
也就是说,我复用的是“最终呈现结果”,而不是去复用一堆中间数据结构。这在工程上很重要。因为用户看到的从来不是 DTO,也不是 Markdown AST,用户看到的是最后那张图。
真正的难点不是截图,而是“什么时候截图”
做这种服务最容易低估的一点是:截图动作很简单,截图时机很难。如果你太早截图,常见问题会立刻出现:
- 字体还没加载完成,文本换行错位
- 图片还在加载,卡片出现空白块
- 阴影和渐变还没稳定,导出的图看起来像半成品
很多人会用 networkidle,但线上页面经常有长连接、埋点、异步请求,这个信号并不可靠。所以我在这个服务里用的是一个更“业务化”的约定:
- 分享页加载完成后,由前端显式设置
window.__MEMO_SHARE_READY__ = true - 渲染服务等待这个全局标记,再截图
这个设计有点像前后端之间的握手协议。 浏览器知道“DOM 出来了”,但只有业务页面自己知道“这张卡现在可以拍了”。这也是我这次实现里最关键的一条经验:渲染服务不应该猜页面状态,而应该和页面约定状态。
两种模式:在“稳定输出”和“视觉效果”之间做了分层
为了兼顾不同分享场景,我做了两种渲染模式:
1. fixed 模式:要尺寸确定,就给你尺寸确定
这个模式会截图整个分享画布,然后把结果缩放到目标宽高(例如 2400x1350)。适合场景:
- 社交平台封面图
- 需要固定比例(例如
16:9) - 需要稳定落地到某个模板位
优点是稳,结果尺寸完全可控。
2. auto 模式:围绕卡片智能裁切,保留一点“呼吸感”
这个模式会围绕 .share-memo-card 自动裁切,而不是死板地截满画布。我做了几个细节处理:
- 给卡片周围保留背景 bleed
- 给阴影额外留安全边距
- 最终裁切区域会和
.share-memo-canvas相交,防止越界
这样导出的图不会显得“贴边”,卡片视觉上更像一张真正的分享卡。当然,自动裁切一定会有失败边界(例如选择器缺失、布局异常)。 所以我给它做了兜底:一旦 auto crop 失败,自动回退到画布截图。
这背后的思路很简单:好看是加分项,稳定返回结果是底线。
性能优化不是先上分布式,而是先把单机跑顺
这种服务如果按“每次请求都启动浏览器”的方式写,基本很快就会卡住。所以我做了几层非常务实的优化:
- 复用单个浏览器实例
- 复用共享 browser context(缓存可复用)
- 每次请求只新建 page,结束后关闭 page
- 用队列限制并发(
MAX_CONCURRENCY) - 支持按空闲时间、渲染次数、存活时长回收浏览器
这几条加起来的效果,是把“浏览器启动成本”从每次请求里挪走,只在必要时付一次。另外还有一个经常被忽略的细节:清晰度。我这边默认用较高 DPR 渲染,再用 sharp 做高质量缩放。 这会让最终 PNG 在文字和细线条上更稳,不容易出现“能看,但发出去有点糊”的情况。换句话说,我不是只追求“生成成功”,而是追求“发出去像成品”。
可观测性:我希望看到它慢在哪里,而不是只知道它失败了
图片渲染服务很容易变成一个黑盒:请求进来,等几秒,成了或炸了。为了避免这个问题,我给它加了几类观测信息(可按环境开关):
- 分阶段 timing 日志(
goto、wait ready、截图、裁切、resize) - 请求级
request id - 可选响应头返回 timing 信息
- 页面
console error/request failed日志(用于排查前端资源问题)
这类服务一旦线上出问题,最怕的不是错误本身,而是“没有上下文”。 能看到每一阶段耗时,你才能判断问题在网络、页面、字体、图片,还是浏览器进程本身。
安全边界:这个服务只负责渲染,不负责做判断
这个服务是 internal-only 的,我没有把它设计成公开接口。完整链路里,鉴权责任在 memos 侧:
- 后端生成短时效 share JWT
- 渲染服务带着 token 打开分享页
- 分享页/相关 API 完成验证
- 渲染服务只拿最终页面做截图
也就是说,渲染服务不做业务权限判断,它只执行“拍照”动作。这是我这次实现里另一个很明确的选择:让权限留在权限系统里,让渲染留在渲染系统里。
这次实现让我重新确认的一件事
我以前会把这类需求归类为“媒体能力”或者“图片处理”,但这次做完后我更愿意把它叫作:“前端呈现的后端化执行”。它不是在服务端重新发明 UI,而是把浏览器变成一个受控运行时,把页面变成一个可验证的渲染契约。当你接受这个视角之后,很多设计决策都会变得自然:
- 为什么要等
__MEMO_SHARE_READY__ - 为什么要复用 browser/context
- 为什么要做
auto与fixed双模式 - 为什么 auto crop 失败要回退
- 为什么要把它做成内部服务,而不是暴露公网
这些不是“优化点”,而是这个系统能长期稳定运行的前提。
后续我还想做的事
目前版本已经能稳定支撑分享图生成,但还有一些值得继续打磨的方向:
- 渲染结果缓存(相同 memo + theme + size 命中缓存)
- 更细粒度的失败分类与重试策略
- 可视化调试模式(导出裁切框信息)
- 批量渲染/异步任务模式(适合预生成封面)
- 更多模板(不是只有“截图卡片”,而是“设计化卡片”)
如果你也在做类似的“网页转图片”服务,我的建议是先别急着上复杂架构,先把这三件事做对:
- 渲染时机要有业务握手
- 失败路径要可回退
- 浏览器生命周期要可控
剩下的扩展,都会容易很多。
总结这次实践,让我对Vibe Coding有了新的认识,对于一个Linux OS系统安全背景的人来讲,前端、浏览器这些永远在我的技能点之外的,但是Vibe Coding增强了我的技能树,所以不要把核心价值押注在补足模型能力缺口上,而是增强复杂系统的编排能力。
复杂系统的编排能力,包括数据孤岛、组织阻力、权限、习惯成本,这些是模型能力再强也吃不掉的,因为它们不是智能问题,是人和组织的问题。
但“编排能力”离不开专业领域知识,模型降低的是通用知识的门槛,但在医疗、法律、金融等领域,真正的专业判断力并不会被抹平。
