服务端批量生成图表和 Dashboard 图片时,最不缺的其实是方案,最缺的往往是“稳定复现”这件事。很多团队最终都遇到过同样的场景:没有浏览器环境、没有 X11、没有中文字体包,却要在凌晨的任务里一次性生成几百张报表图片。如果每次生成的结果会因为运行环境、字体加载顺序甚至随机数不同而发生变化,对账、截图对比、缓存命中都会变得不可控。SlickFast 就是针对这类 No Browser 场景设计的一个确定性图表与 Dashboard 渲染器,输入是 JSON,输出是 SVG 或 PNG。本文围绕它来拆解无浏览器图表渲染的核心链路,包括 JSON 结构怎么设计、渲染管线怎么组织、中文字体和确定性为什么是服务端渲染的命门,以及出现问题后应该按什么顺序排查。
1. 先理解无浏览器渲染要解决哪三类问题
1.1 浏览器渲染在批量生成场景里为什么很难用
浏览器是天然适合展示图表的运行环境,ECharts、Chart.js、D3 这些库都默认挂在一个 DOM 节点上,通过 Canvas 或者 SVG 把图形画出来。但当你需要批量生成图片时,浏览器本身会变成麻烦。
第一问题是资源占用。Chromium 这类完整浏览器实例启动一次需要消耗几百 MB 内存,如果连续启动多个实例,CPU 和内存压力会明显上升。定时任务里一次生成 500 张图,和用户浏览器里打开一个图表页面的负载完全不是一个量级。
第二个问题是渲染结果的稳定性。浏览器页面渲染会受系统字体、屏幕 DPI、GPU 加速、Canvas 抗锯齿策略影响,同一个配置在开发机、CI 环境和生产服务器上可能得到不同像素。对于需要放入邮件、对账单、审核系统中的图片,这种不确定性会导致图片更换时无法做像素级 diff,也无法依赖缓存。
第三问题是环境依赖。没有图形接口的服务器上,如果直接安装无头浏览器,还需要处理沙箱权限、共享库、字体依赖。很多 Docker 镜像为了跑一个图表生成,不得不额外塞入一大堆浏览器运行时依赖,镜像体积和维护成本都会上升。
1.2 无头浏览器截图方案的问题
无头浏览器截图是目前比较流行的做法,用 Playwright 或 Puppeteer 打开页面,等图表渲染完成,然后调用 page.screenshot 输出 PNG。它看起来省事,真正接入后却有不少坑。
首先是等待时序问题。图表库需要加载数据、执行异步布局、完成动画,截图命令如果提前执行,拿到的是加载中的空白页面。常见的做法是 sleep 一段时间,但网络慢或者机器忙时,固定等待时间仍然不稳定。
其次是字体和缩放问题。页面默认字体、是否启用 Web 字体、截图时的 deviceScaleFactor 都会影响最终像素。同样是 1200 宽度的图,在 CI 里可能因为缺字体导致文字变成方块或者错位。
还有一个隐藏问题:无头浏览器生成的 SVG 往往带有页面级样式和脚本相关标签,直接拿去做后续的矢量编辑或打印,需要额外清理。如果业务目标本身就是输出干净的 SVG 文件,无头浏览器方案实际上是绕了一圈。
1.3 SlickFast 这类原生渲染器的定位
SlickFast 对应的思路是:不使用浏览器,不依赖 DOM 和 Canvas,而是直接把 JSON 描述解析成绘图指令,先渲染成结构化 SVG,再把 SVG 栅格化为 PNG。
这样设计有几个直接好处:
- 启动快。原生进程不需要加载浏览器内核,适合大量小任务并发。
- 确定性高。只要输入 JSON、配置字体、版本一致,输出结果应该完全一致,适合做缓存和 diff。
- 产物可控。SVG 中间产物是标准 XML,可以直接用文本工具检查、压缩、嵌入邮件正文,也可以后续转成其它格式。
- 部署轻。服务器上不需要安装浏览器,只需要渲染器自身的运行库和字体。
下面这张表可以比较几种常见方案的核心差异:
| 方案 | 运行依赖 | 输出类型 | 批量生产速度 | 结果确定性 | 部署体积 |
|---|---|---|---|---|---|
| 浏览器图表库 | 浏览器/DOM | Canvas/SVG | 中 | 受环境影响 | 大 |
| 无头浏览器截图 | Chromium | PNG | 慢 | 一般 | 很大 |
| 服务端原生渲染器 | 系统运行库 | SVG/PNG | 快 | 高 | 小 |
| 纯图像库手绘 | 图像库 | PNG | 快 | 高 | 小 |
SlickFast 处于“服务端原生渲染器”这一类。它不是唯一实现,但它的设计目标非常明确:JSON 进,SVG/PNG 出,过程中不允许出现随机元素。
2. 从 JSON 到 SVG 再到 PNG 的完整渲染链路
2.1 渲染链路概览
SlickFast 的整体处理流程可以拆成四段:
- 解析 JSON。读取图表描述文件,校验结构、类型、必需字段,转换成内部的数据模型。
- 布局计算。根据画布宽高、边距、坐标轴范围、图例尺寸,计算每个图形元素的位置和大小。
- 生成 SVG。把数据点、坐标轴线、网格线、文字标签写入 SVG 节点,形成标准矢量图。
- 栅格化。使用渲染器把 SVG 转成 PNG,设置目标宽度、高度和缩放比例。
这里的核心设计是 SVG 作为中间产物。如果你只需要矢量图,渲染到第三步就可以结束;如果需要 PNG,再执行第四步。JSON 到 SVG 这一步是纯文本转换,方便调试,也便于在日志里输出检查。
链路图用文字描述如下:
chart.json -> 解析校验 -> 布局计算 -> SVG 生成 -> PNG 栅格化每一步都是纯函数式流程,相同输入应该产生相同输出。这也是标题中 Deterministic 的直接体现。
2.2 环境准备与前置检查
使用 SlickFast 前,先确认当前环境满足基本要求。具体版本以项目 README 为准,下面列表用于说明检查思路:
- 操作系统:Linux、macOS、Windows 都可以,但生产环境建议使用 Linux 容器。
- 运行库:如果发行包是 Rust 编译的静态二进制,依赖很少;如果是动态链接版本,需要对应系统库。
- 字体目录:至少准备一套中文字体和一套数字/英文等宽字体。
- 命令行环境:能执行基础命令即可,不需要图形界面。
在 Linux 服务器上做一个最小检查:
slickfast --version fc-list | head -n 20第一行确认 CLI 正常启动,第二行确认系统里能看到哪些字体。渲染器通常不会直接从系统字体目录读取,而是通过配置指定字体目录,所以后面配置字体环境时还要单独设置。
如果你使用的是 Docker 部署,镜像里最好统一放置字体文件,并保证字体文件名、路径和配置完全一致。字体缺失是渲染出错的第一大来源,后面排错部分会单独展开。
2.3 最小 JSON 描述文件
下面用一份最小柱状图 JSON 来说明整体输入格式。该结构用于演示核心字段,实际项目要以自己使用的版本和 README 为准。
{ "type": "bar", "width": 800, "height": 480, "margin": { "top": 40, "right": 20, "bottom": 50, "left": 60 }, "title": "月度销售额", "xAxis": { "field": "month", "title": "月份" }, "yAxis": { "field": "amount", "title": "销售额", "startAtZero": true }, "data": [ { "month": "2024-01", "amount": 120 }, { "month": "2024-02", "amount": 180 }, { "month": "2024-03", "amount": 150 }, { "month": "2024-04", "amount": 210 } ], "series": { "color": "#3B82F6" } }这个文件描述了一个宽 800、高 480 的柱状图,横轴读取 data 数组里每项的 month 字段,纵轴读取 amount 字段,标题显示在最上方。
这里有几个关键设计:
- data 是数组,每一项是一个对象,字段名由 xAxis.field 和 yAxis.field 指定。这样做的好处是数据结构统一,后续增加图例、多系列时不需要改变整体格式。
- margin 控制绘图区与画布边界的距离,文字标签不会溢出画布。
- startAtZero 控制纵轴是否强制从 0 开始。对于柱状图推荐 true,因为从非 0 开始会夸大柱形高度差异,容易造成误导。
2.4 执行渲染
保存上述 JSON 为 chart.json,然后执行:
slickfast render ./chart.json --output ./chart.svg --format svg运行后检查输出:
ls -lh chart.svg head -c 300 chart.svg如果一切正常,chart.svg 是标准 SVG 文件,带有<svg>根节点和路径、矩形、文字元素。需要 PNG 时,执行:
slickfast render ./chart.json --output ./chart.png --format png --width 800 --height 480PNG 的宽高参数不一定要与 JSON 里的画布宽高一致,可以等比缩放输出。很多场景里,JSON 里保存的是逻辑坐标,命令行传入的是目标输出尺寸,类似 CSS 像素和物理像素的关系。这个特性对于同一份图表生成不同尺寸的素材非常有用。
3. 图表描述 JSON 的核心字段与设计意图
3.1 画布、边距与布局
一份图表 JSON 的顶层字段通常分为三组:画布信息、结构信息和数据信息。画布信息主要由 width、height、margin 构成。
margin 里的 top、right、bottom、left 四个值决定了绘图区的安全边界。标题、坐标轴标题、刻度标签都在边界区域内绘制,绘图区内的网格线、柱形、折线只能落在 width - left - right 和 height - top - bottom 组成的矩形内部。
合理的 margin 值取决于字体大小和标签长度。比如 y 轴标题“销售额(万元)”比较长,并且刻度值可能达到 4 到 6 位,left 至少需要 60 到 80。如果 left 设置过小,刻度数字会被裁剪,后文排查部分会回到这个问题。
与 layout 相关的还有图例位置。多系列图表建议显式声明 legend 字段,避免自动布局导致图例遮挡数据区域。一个典型配置:
"legend": { "position": "top", "itemGap": 16, "fontSize": 13 }图例位置越早确定,布局计算越稳定。不要把图例位置留在渲染阶段动态猜测,这是很多声明式渲染结果不确定的根源之一。
3.2 坐标轴与比例尺
坐标轴是图表 JSON 最关键的部分。xAxis 和 yAxis 都包含 field 和 title,但语义不同。
对于类目型 x 轴,数据值通常是字符串或时间标识,比例尺按顺序均匀分布。对于连续型 x 轴,可能还需要 min、max、step 字段,让坐标轴能够按固定间隔生成刻度。
y 轴重点在于数值范围计算。常见配置是 startAtZero 和 max:
"yAxis": { "field": "amount", "title": "销售额", "startAtZero": true, "max": 300, "tickCount": 6 }startAtZero 为 true 时,比例尺起点固定为 0,比例尺范围是 0 到 max;max 不设置时,渲染器会取数据最大值并向上取整。这里注意,如果数据存在负数,比例尺范围应同时包含负方向,startAtZero 并不能自动处理负数区间。遇到正负数据同时出现时,建议显式设置 min 和 max,否则比例尺起点由渲染器算法计算,不同版本可能出现差异。
tickCount 指定纵轴刻度数量。tickCount 越大,网格线越密,但标签可能互相覆盖。纵轴刻度标签宽度决定左边距,两者需要提前协调。
3.3 系列、颜色和样式
series 字段定义图形样式。最简单的柱状图只需要一个颜色,但多系列场景需要更完整的描述。
"series": [ { "name": "华东", "field": "east", "color": "#3B82F6" }, { "name": "华南", "field": "south", "color": "#F59E0B" } ]这里与单系列写法的最大区别是:每个系列不再是固定的 y 字段,而是对应 data 对象里的不同字段。比如 data 项:
{ "month": "2024-01", "east": 120, "south": 90 }处理多系列时,图例、柱宽、间距、颜色映射都需要额外计算。为了避免柱形重叠,分组柱状图会按系列数量动态计算每根柱子的宽度。如果同一位置有 2 个系列,每根柱宽应约等于可用宽度的四分之一,两个柱子再分别占据一半位置并保留间隔。
颜色方面,如果系列颜色没有显式给出,建议使用默认调色板,而不是随机生成颜色。随机颜色会破坏确定性。实现中可以在 JSON 的 theme 字段里指定调色板数组:
"theme": { "palette": ["#3B82F6", "#F59E0B", "#10B981", "#EF4444"] }调色板索引、系列顺序、图例顺序都应该是稳定的,才能保证相同 JSON 得到相同图片。
3.4 Dashboard 组合
Dashboard 与单图表的差异在于布局嵌套。SlickFast 的 JSON 描述里,dashboard 类型通常包含 panels 数组,每个 panel 内部又是一份完整图表描述。
{ "type": "dashboard", "title": "运营数据大盘", "width": 1200, "height": 800, "columns": 2, "panels": [ { "title": "月度销售额", "x": 0, "y": 0, "colSpan": 1, "rowSpan": 1, "chart": { "type": "bar", "data": [] } }, { "title": "访问趋势", "x": 1, "y": 0, "colSpan": 1, "rowSpan": 1, "chart": { "type": "line", "data": [] } } ] }这种结构的渲染流程是:先确定 dashboard 整体网格,按 columns 把宽度分成若干列;再按 panels 的 colSpan、rowSpan 计算每个 panel 的矩形区域;最后把每个 panel 的 chart 内容裁剪到对应矩形内。
Dashboard 渲染排错时,最常见的问题是 panel 标题和图表标题重复,导致标题区域占用过多空间。建议 panel 的 title 作为卡片标题,chart 内部不再保留 title,或者通过字段控制关闭内部 title。
4. 确定性渲染为什么是服务端图表的核心约束
4.1 不确定性从哪里来
普通前端图表渲染并不追求确定性,因为用户每次看到页面都可以重新渲染,小幅位移或闪烁通常无感知。但服务端批量生成不同,图片要进入邮件、报表、消息系统,并可能被缓存、对比、审计。如果两天后重跑同样任务,图上柱子的颜色或标题位置变化了,就会导致缓存失效或者人工对比时发现“莫名差异”。
常见不确定性来源有这么几类:
- 随机数。比如随机取颜色、随机打乱数据顺序。
- 哈希和字典序。比如遍历散列结构的字段时,不同语言、不同版本顺序可能不同。
- 字体回退。系统缺失指定字体时,渲染器回退到默认字体,字形宽度变化后导致标题位置偏移。
- 时间相关。比如输出文件名带时间戳,或者图表内显示渲染时间。
- 并发竞争。多个渲染任务共用字体缓存、临时目录时,读取顺序可能改变最终结果。
4.2 文本测量与字体库管理
文本测量是 No Browser 渲染的最大技术难点。浏览器里,SVG 文本可以交给浏览器排版引擎自动布局;脱离浏览器后,渲染器必须自己决定每个文字标签放在哪个 x、y 坐标。
要确定坐标,先要知道文本宽度。渲染器会读取字体文件中的字形度量信息,通过累加每个字符的 advance width 来计算字符串宽度。中文、日文、韩文字符的宽度通常一致,但数字、英文、标点的宽度随字体类型不同而变化。
生产环境里,只安装一种默认字体往往不够。报表通常同时包含中文标题、英文单位、数字数值,如果字体回退策略不稳定,标题文字偏差就会体现在最终图片上。建议把字体目录集中管理:
/opt/fonts/ SourceHanSansCN-Regular.ttf SourceHanSansCN-Bold.ttf Inter-Regular.ttf Inter-Bold.ttf配置文件里指定 fontFamily 映射:
"font": { "defaultFamily": "Source Han Sans CN", "fallbackOrder": ["Source Han Sans CN", "Inter", "sans-serif"] }重点是:提交镜像时把字体文件一起放入,不要在容器启动后依赖 apt-get 在线安装,也不要依赖系统的 fontconfig 动态搜索,否则不同批次容器的字体环境可能不一致。
4.3 哈希、排序和迭代顺序
确定性渲染要求任何内部循环都使用稳定顺序。JSON 解析后的数据通常用数组保存,数组顺序就是用户输入顺序,这点相对安全。但 Series 的颜色、图例项、坐标轴刻度如果被转换成了哈希表,遍历顺序就不稳定。
为了避免这种问题,内部模型建议使用插入序集合或数组,并在处理数据前显式排序。比如月份字段是字符串,如果不排序,可能出现“2024-02”排在“2024-10”前面的情况,因为字符串字典序不对。常见处理是在 JSON 里提供 sortBy 配置:
"xAxis": { "field": "month", "sortBy": "natural" }natural 表示按字符的自然语义排序,实际是按数值或时间顺序。对于月份、日期、序号等字段,推荐使用自定义字段顺序而不是模糊的自然语言排序,因为不同语言环境的自然排序规则可能不同。
4.4 校验确定性的方法
最直接的校验方法是同一份 JSON 连续渲染两次,然后对文件做哈希对比。
slickfast render ./chart.json --output ./a.svg --format svg slickfast render ./chart.json --output ./b.svg --format svg sha256sum a.svg b.svg正常时两个文件哈希一致。如果不一致,说明渲染过程引入了非确定性逻辑。然后使用二分定位:先对比标题、图例、数据区域,逐步缩小差异范围。SVG 是文本格式,可以直接 diff:
diff <(sed 's/[0-9]\{6,\}/N/g' a.svg) <(sed 's/[0-9]\{6,\}/N/g' b.svg)如果使用了随机生成或时间戳,先去除这些动态内容再比较核心布局。对于 PNG,像素级 diff 可以使用比较工具,但更推荐从 SVG 层排查,因为 PNG 的微小像素差异不容易定位原因。
5. 渲染失败时的排查链路
5.1 JSON 解析失败
现象是执行命令后立即报 JSON parse error,可能包含具体行号和列号。
优先做三件事:
- 用 JSON 格式化工具查看原文件,确认引号、逗号、括号是否匹配。
- 检查是否混入了注释。标准 JSON 不支持注释,很多人在配置文件里写
//或#。 - 检查最后一个对象后面是否多加了逗号。
常见问题可以通过格式化命令快速发现:
python3 -m json.tool chart.json如果这条命令报错,先修 JSON 再说。使用 YAML 作为输入源时,则需要确认转换后的字段类型,避免数字被转成字符串。
5.2 中文字体缺失导致方块
现象是 SVG 打开后标题变成空白或方框,PNG 里中文位置出现占位符。
检查方式:
fc-list | grep -i "source han"如果没有找到中文字体,可以使用项目自带的字体目录配置。渲染器内部可能并不依赖系统 fontconfig,而是通过 JSON 里的 fontDir 指定字体目录。设置后重新渲染并确认。
这类问题在 Docker 里尤为常见,因为基础镜像往往只带少量字体。不要只在本地验证,一定要把字体文件和渲染器一起打进镜像,并做一次容器内渲染测试。
5.3 SVG 生成成功,但 PNG 为空白
可能原因有两类。
第一类:SVG 使用了栅格化引擎不支持的 CSS 特性或滤镜。比如依赖 CSS 变量、外部字体链接、复杂 filter 效果,标准 SVG 解析器未必支持。排查方式是把 SVG 里可疑的<filter>、<style>标签移除,再重新转 PNG。
第二类:SVG 内容坐标位于画布之外。比如 margin 配置过大,绘图区尺寸为 0 或负数;或者数据点坐标计算出现 NaN。检查 SVG 文件中 path 元素是否存在度为 NaN 的坐标值。
grep -n "NaN" chart.svg如果输出中包含 NaN,应回到数据源检查字段类型,确认数值列没有空值或者非数字字符串。
5.4 坐标轴标签重叠、数值溢出
柱状图或折线图渲染完成后,如果发现刻度值重叠,多半是布局参数不匹配。
按这个顺序排查:
- 检查纵轴刻度的位数。4 位以上数字需要更大的左边距,否则标签被截断。
- 检查横轴刻度数量。如果 category 数量超过 20,文字标签通常会重叠,需要开启标签旋转或者跳样显示。
- 检查 tickCount。纵轴 tickCount 设置过大,相邻标签间距小于字体高度时就会重叠。
对应解决方式:
"xAxis": { "field": "month", "tickRotation": -30 }"yAxis": { "field": "amount", "tickCount": 5 }旋转后标签宽度会增加,还需要同步检查底部 margin,防止旋转后的文字溢出画布。
5.5 常见问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| JSON 解析报错 | 文件含注释或尾逗号 | python3 -m json.tool | 修正 JSON 格式 |
| 中文显示方块 | 字体缺失 | fc-list 检查字体 | 配置内置字体目录 |
| PNG 空白 | SVG 特性不兼容或坐标越界 | grep NaN | 去除特殊特性,检查边界 |
| 标签重叠 | 刻度数过多或 margin 不足 | 渲染后目测 | 增加 margin 或 tickRotation |
| 两次渲染结果不一致 | 非确定性逻辑 | sha256sum diff | 检查随机数、排序、字体回退 |
| 柱形过宽或过窄 | 系列数导致柱宽计算错误 | 检查 series 数量 | 手动指定 barWidth |
6. 生产环境接入建议与扩展方向
6.1 学习环境与生产环境的差异
学习环境里,最关心的是快速看到一张图。直接跑命令、查看输出文件,不需要考虑字体、缓存、并发等问题。
生产环境的接入要求多出不少:
- 配置外置化。图表模板、字体主题、输出目录都不应硬编码在命令里,建议通过环境变量或中心配置下发。
- 日志与监控。每次渲染任务应记录输入文件、输出文件、耗时、是否命中缓存,便于事后追溯。
- 权限与安全。渲染器如果作为服务对外提供接口,输入 JSON 需要做大小和字段校验,防止恶意构造超大画布或超长文本消耗资源。
- 回滚方案。图表模板会持续演进,线上如果出现生成失败,需要能基于旧模板快速重新生成。
6.2 部署形态与任务管线
有两种常见部署形态。
第一种是 CLI 任务型。由调度系统定时执行命令,输入 JSON 文件,输出到对象存储或文件系统。适合报表、账单、促销图片等离线场景。
第二种是常驻服务型。渲染器作为 HTTP 服务,接收 JSON,返回图片字节。适合在线生成海报、动态分享图、通知配图。
任务管线的设计建议:
数据查询 -> 模板填充 -> JSON 校验 -> 渲染 -> 产物上传 -> 回执通知每一步都应该有可观测点。比如 JSON 校验失败时,应返回字段级的错误信息,而不是只有“渲染失败”。
6.3 可复用的上线前检查清单
参照下面清单逐项确认,能减少大多数渲染事故:
- JSON 文件中是否包含注释或非法字符。
- JSON 数值字段是否都是数字,而不是字符串数字。
- 是否已经配置中文字体目录,并在容器内验证过。
- 是否显式设置了坐标轴的 min、max 和 tickCount。
- 图例位置是否固定,调色板是否显式配置。
- 是否设置了 margin,标题和坐标轴标题是否可能在长文本下溢出。
- 同一份 JSON 是否连续渲染两次得到一致哈希。
- PNG 输出尺寸是否与业务要求一致。
- 生产环境是否具备任务日志、错误告警、输出产物回滚能力。
- 图表模板变更时,是否做过老模板兼容性验证。
6.4 下一步扩展方向
完成了单图表和简单 Dashboard 渲染后,可以继续扩展方向包括:
- 增加更多图表类型,比如饼图、散点图、箱线图、桑基图。
- 增加主题系统,把颜色、字体、间距等统一抽成 JSON 主题。
- 增加模板变量和条件渲染,让同一份 JSON 模板可以根据数据动态展示或者隐藏某些区块。
- 增加输出压缩和缓存指纹,方便 CDN 缓存图片。
- 研究字体子集化,只打包报告中实际用到的字符,减少最终产物体积和运行时字体占用。
对刚接触 No Browser 图表渲染的读者,建议先用最小柱状图跑通 JSON 到 SVG 的链路,再逐步加入坐标轴定制、多系列、Dashboard 布局。渲染器内部一旦出现非预期结果,优先从 JSON 结构、字体环境、坐标范围这三个方向排查,而不是急着改代码。确定性的核心不是某一行代码,而是从输入到输出的每一步都可预测、可检查、可复现。