ENGINEERING NOTES · 工程笔记

用 Playwright 做"设计图自动生成"服务:把重复劳动交给浏览器

先说结论:给每个商户人工出小程序页面设计图这件事,商户一上量就是不可能完成的体力活。我们把它做成了服务:HTML 模板注入商户数据,交给 Playwright 驱动的无头 Chromium 渲染,整页截图直接出图。这套东西的难点不在 API 调用,而在串行队列、硬超时、内存账这些工程纪律。本文是完整复盘,代码均为脱敏伪代码。

一、背景:设计图是人肉体力活

我们做多租户小程序 SaaS,每个商户拿到的都是带自己品牌的独立小程序。签约后、开工前,商户最关心的一件事是"我的店做出来长什么样"。早期这套"效果图"是人工出的。

把人工出图的账摊开算:打开设计工具加载模板约 40 秒;替换品牌名、主色辅色并核对一遍约 2 分钟;换菜品图、调分类结构约 3 分钟;导出 PNG 再逐张检查约 1 分钟——单页稳定在 7 分钟上下,一家店按 9 个页面算是 63 分钟。再算上商户来回确认、改版重导,一家店要吃掉会出图的同事大半天。

真正把问题钉死的是某个周三:当天集中签约 11 家,11 × 9 = 99 张图,按 7 分钟一张是 693 分钟——将近 12 个小时的纯出图时间。全组会出图的就两个人,其它活全停也得干满一天;加上确认与改版,图到第三天才齐。商户群里第二天就来了一句:"效果图好了吗?老板想先发个朋友圈。"出图速度第一次成了交付瓶颈,"设计图"也第一次从设计问题变成了工程问题。

回头看,这活儿没有任何创造性:改的全是数据,动的是同一套模板。而"模板 + 数据 → 出图",恰好是浏览器最擅长的事。方案自然浮现:让无头浏览器替人"截屏"。流水线跑通后,单任务渲染加截图 8 秒上下,99 张排进队列 15 分钟内跑完,高峰期一夜几百张也不需要有人守着——同一道题的另一份答案。

二、架构:模板、数据、无头浏览器

整体链路一句话讲完:商户配置 → 模板注入 → Chromium 渲染 → 截图 → 对象存储

内容谁维护
模板层每种页面类型一个 HTML 模板(首页、点单、券页……)随设计规范迭代,版本走 Git
数据层每家商户一份 JSON:品牌名、色板(主色/辅色)、菜品图、分类结构商户配置,不是代码
渲染层Node + Playwright 驱动 Chromium,移动端视口渲染,整页截图渲染服务,无人值守

新商户接入 = 新增一份配置,模板零改动。两层之间定死 schema:字段缺失当场报错,不让一张"缺 logo 的图"流到商户面前。模板资源与商户图片一律走白名单域名,渲染时其它请求全部 abort——既是安全边界,也让 networkidle 的等待不被无关请求拖长。

三、工程要点:纪律比技巧多

1. 串行队列。 单容器同一时刻只跑一个 Chromium。理由很朴素:一个 Chromium 渲染峰值要吃几百 MB 内存,串行让峰值可预测、不会 OOM;渲染是批量任务不是实时接口,排队延迟完全可接受。

队列没有引入消息中间件,直接用数据库一张任务表实现,核心是一个状态列加一个心跳时间戳:

-- 脱敏伪代码:队列表核心列
job_id | template_name | merchant_data | status | retry_count | locked_at
-- status: pending / running / done / failed

worker 主循环每轮只取一条:一条带 FOR UPDATE SKIP LOCKED 的 UPDATE,把最老的一条 pending 置为 running 并写入 locked_atSKIP LOCKED 保证将来多副本也互不抢同一条;locked_at 当心跳用——worker 崩了任务会卡在 running,看门狗把超过 5 分钟没心跳的任务重置回 pending,等于数据库层面的兜底超时。取到任务后循环里绝不再取第二条:单并发是渲染服务自己立的规矩,靠代码自觉,不靠数据库强制。

2. 模板与数据分离,版本可追溯。 模板改版不碰商户数据,商户换图不碰模板,出问题好定位。产物文件名里记录所用模板版本号,设计规范升级后,任何一张旧图都能回答"出自哪版模板"。

3. 失败重试与硬超时。 参数逻辑摆出来:正常任务 8 秒内完成,硬超时给 30 秒,约 4 倍余量——卡死任务宁可错杀,不能让它占着队列;超时后连浏览器带进程树一起杀。失败退避重试 3 次,间隔 5 秒、30 秒、2 分钟,防止外部图片源抖动时连环撞墙;3 次都败标记 failed 交人工。渲染幂等:同一任务重跑产出同名文件直接覆盖,重试不需要清理逻辑。

4. 产物命名与清理。 命名带确定性:商户/模板/版本.png;产物全部进对象存储、不落本地盘,容器随时可弃;定时任务清掉超期旧版本。

四、伸缩的诚实账

并发上限不靠猜,靠算。单实例峰值一步步推:Node 主进程常驻约 150 MB,Chromium 空载约 250 MB,长页面渲染时图片解码是大头、瞬时再加 300 MB 上下——峰值合计约 700 MB。容器限额 1 GB,余量三成,结论就是单并发。想在单机塞两个 Chromium?理论峰值 1.4 GB 直接越过限额,OOM 会把正在渲染的那个任务一起带走——批量场景下"慢而稳"完胜"快而崩"。

吞吐的账同样直白:单副本 8 秒一张,一小时 450 张。日常一天新签 20 家共 180 张图,单副本 25 分钟消化完;假设活动高峰要求一小时出 2000 张,2000 ÷ 450 ≈ 4.4,上取整开 5 个副本,每个副本内部依旧串行。副本数 = 目标吞吐 / 单副本吞吐,小学数学,不需要玄学。

五、伪代码:渲染 → 截图流水线

// 脱敏伪代码:单 worker 串行消费渲染队列(Node + Playwright)
async function renderOne(job) {
  const browser = await chromium.launch({
    args: ['--no-sandbox', '--disable-dev-shm-usage'],  // 容器内标配
  });
  try {
    const page = await browser.newPage({
      viewport: { width: 375, height: 812 },
      deviceScaleFactor: 2,                             // 2 倍图,出图清晰
    });
    await page.route('**/*', (route) =>                 // 请求白名单:只放行模板资源与商户图片域
      ALLOWED_HOSTS.has(new URL(route.request().url()).host)
        ? route.continue() : route.abort());

    const html = renderTemplate(job.templateName, job.merchantData); // 注入品牌名/色板/菜品图
    await page.setContent(html, { waitUntil: 'networkidle', timeout: 20_000 });

    await page.evaluate(() => document.fonts.ready);    // 字体就绪再截
    await page.evaluate(scrollThrough);                 // 分步滚到底,触发懒加载
    await page.waitForTimeout(300);                     // 留解码余量

    const shot = await page.screenshot({ fullPage: true });
    await storage.put(objectKey(job), shot);            // 商户/模板/版本.png
  } finally {
    await browser.close();                              // 无论成败都回收,防僵尸进程
  }
}

// 队列主循环:硬超时 + 退避重试,始终单并发
const BACKOFF_MS = [5_000, 30_000, 120_000];
while (true) {
  const job = await queue.take();                       // 取最老一条 pending 并置 running
  for (let i = 0; i < 3; i++) {
    try { await withTimeout(renderOne(job), 30_000); break; }
    catch (e) { await sleep(BACKOFF_MS[i]); }           // 5s/30s/2min,末次失败落 failed
  }
}

每个任务起一个全新浏览器是刻意为之:实例级隔离,内存不跨任务累积,慢一点换的是省心。

六、踩坑清单

  1. 中文全是方块。 现象:第一版截图里中文全渲染成"口口口",英文和数字正常。定位:本地跑同一份代码完全没事,差异只能出在镜像——基础镜像没装 CJK 字体;装完字体后偶发仍方块,再查是 web 字体异步加载,截图抢在字体就绪之前。修法:镜像里装中文字体(如思源黑体),截图前 await page.evaluate(() => document.fonts.ready),两道都上才算修完。
  1. 容器里 Chromium 起不来。 现象:本地一切正常,进了容器启动即崩。定位:容器日志是共享库缺失的加载错误——精简基础镜像砍掉了 Chromium 的运行时依赖;另一路症状是能启动但渲染中途崩,根因在容器默认 /dev/shm 只有 64 MB。修法:换 Playwright 官方镜像,或执行其依赖安装命令补齐;启动参数固定带 --no-sandbox--disable-dev-shm-usage
  1. 懒加载没触发就截图。 现象:整页截图首屏正常,往下全是灰色占位块。定位:模板图片走"滚动进可视区才加载"的策略,无头模式里没人滚动,触发条件永远不满足。修法:截图前用脚本分步滚到底部,每步稍作停留让图片请求发出,等解码完成再补 300 ms 余量;更彻底的做法是模板加"渲染模式"参数直接关掉懒加载——出图场景本来就不需要它。

三句话总结

  1. 设计图自动生成的本质是"模板 + 商户数据 + 无头浏览器整页截图",Playwright 把工程量压到一天级别。
  2. 稳定来自纪律而非技巧:容器内单实例串行、硬超时加幂等重试、产物确定性命名,渲染服务才能无人值守。
  3. 伸缩别玄学:峰值内存估算定单机并发,吞吐不够加副本——批量渲染拼的是不挂,不是单机多开。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询