7.3 KiB
图表导出为图片:实现说明
记录
smart-fin-chart的图片下载是如何实现的、命名从哪来、以及一个重要的对照结论: 同一段下载代码,在真实浏览器顶层页面能下载,在被 sandbox 限制的 iframe 里会被静默拦截。
一、两条导出路径
项目里有两种导出方式,都落在浏览器原生能力上,没有任何第三方库(没有 file-saver、没有 html2canvas)。
路径 A:单张图表导出
| 环节 | 位置 |
|---|---|
| 按钮 | js/ui.js:197 —— 卡片右上角 导出 PNG |
| 取图 | js/main.js:265 exportPNG() —— instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }) |
| 下载 | 同一个函数里 anchor.href = dataURL → anchor.download = '${filename}.png' → anchor.click() |
关键点:
- 直接用 ECharts 实例自带的
getDataURL(),等于把画布按 2 倍像素密度重新导出一遍,不截屏、不重绘,所以导出结果和处理中的图完全一致,且天然是高清的(pixelRatio: 2)。 backgroundColor: '#fff'是必须的 —— 否则透明底在公众号里会变黑/变灰。
路径 B:多张合并成长图
入口是卡片上的 ➕ 加入合并(js/ui.js:191),形成一个「合并队列」,然后走三步:
-
入队即存快照(
js/main.js:282 toggleCart)cart.set(chart.instanceId, { instanceId, title, filename: chart.filename || chart.title, dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }), });存的是入队那一刻的 dataURL,不是 ECharts 实例引用。这样即使用户随后切换视图(全部/年度/半年)、改了参数、甚至窗口 resize,队列里的图也不会变。
captureSnapshot()(js/main.js:275)负责在重渲染后刷新快照,保证「所见即所得」。 -
Canvas 纵向拼接(
js/merge.js:23 mergeImages)- 宽度取各图最大值,宽度不一致的等比缩放对齐;
- 高度 = 各图高度之和 +
gap × (张数 - 1),gap = 96(@2x 下视觉 48px); - 先铺白底,再逐张
ctx.drawImage()。 - 为什么要纵向拼:公众号在手机上以竖版窗口预览,横版单图显示过小;两张拼成 2400×~2800 接近满屏。
-
Blob 下载(
js/merge.js:48 downloadCanvas)canvas.toBlob((blob) => { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename.endsWith('.png') ? filename : `${filename}.png`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); }, 'image/png');代码注释里写明了理由:Blob URL 比超长 dataURL 下载更稳 —— 合并长图的 dataURL 字符串可能到几 MB 量级,塞进
href在部分浏览器上会失败;转成 Blob 再由浏览器托管,稳定得多。用完 10 秒后释放,避免内存泄漏。注意:单图路径用的是 dataURL,合并路径用的是 Blob URL —— 这不是不一致,是刻意分工(单图 dataURL 只有几百 KB,够用且更简单)。
合并预览弹窗
showMergePreview()(js/ui.js:276)只是在 canvas 上直接显示 + 挂两个下载按钮,不产生第二份数据:entry.canvas 就是拼好的那块 canvas,点下载才走 toBlob。
- 配对模式:队列两两合并(
buildCompositions(),js/main.js:324),奇数张时最后一张单独导出并提示; - 「全部下载」用
setTimeout(..., index * 350)错峰触发(js/main.js:353)—— 连续同步触发多个下载会被浏览器判定为「自动批量下载」而拦截。
二、文件名从哪来
统一来自 manifest.json 的 filename 字段,回退到 chart.title。
- 挂载点:
js/ui.js:198→onExport(chart.instanceId, chart.filename || chart.title) manifest.json里共 85 处filename,例如:{ "key": "ppmt-profit", "title": "盈利能力", ..., "filename": "泡泡玛特盈利能力" }- 也就是说展示名和下载名是两套:卡片标题是短的(「盈利能力」),下载名是完整的(「泡泡玛特盈利能力」)。这样屏幕上不啰嗦,存下来的一堆图又不会重名。
- 合并导出时用
clipName()截断到 24 字,多张用+连接(js/main.js:319, 338)。
三、对照结论:为什么同一段代码在别处就下载不了
这个项目能下载,根本原因是它跑在真实浏览器的顶层页面(start.command → http://localhost:8899/ → open "$URL"),不在任何 iframe 沙箱里。
对照实验(无头 Chromium + CDP 显式 Browser.setDownloadBehavior: allow,两组跑完全同一段下载代码):
| 场景 | 结果 | CDP 事件 |
|---|---|---|
| 顶层页面 | ✅ 文件落盘 | Browser.downloadWillBegin → downloadProgress ×6 |
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals" 的 iframe |
❌ 未落盘 | 连 downloadWillBegin 都没有 |
第二条是要害:沙箱拦截时不报错、不弹提示、不发事件 —— 下载请求在发起前就被丢弃。页面上看不出任何异常,控制台也没有告警,所以表现成「按钮点了没反应」,而且回溯起来会误以为是代码写错了。
结论:
- 沙箱 iframe 缺
allow-downloads是唯一原因,和toBlob/createObjectURL/a.click()的写法无关; - 补救方向是让下载动作离开沙箱:宿主允许
allow-popups-to-escape-sandbox时,从 iframe 里window.open()出一个弹窗(弹窗不受沙箱约束),在弹窗里挂<a download>触发下载。 - 注意
window.open()必须有真实用户手势(真实点击),脚本内直接调用会被弹窗拦截器挡掉。
四、可复用的最小实现
// canvas → PNG 下载(Blob URL 版本,适合大图)
function downloadCanvas(canvas, filename) {
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.png') ? filename : `${filename}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}, 'image/png');
}
// ECharts 实例 → PNG 下载(高清,dataURL 版本)
function downloadChart(chartInstance, filename) {
const a = document.createElement('a');
a.href = chartInstance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
a.download = `${filename}.png`;
a.click();
}
前提:这段代码必须在非沙箱的顶层页面里执行。若运行在 iframe 中,先确认宿主给了 allow-downloads,否则要走弹窗逃逸。
五、相关文件索引
| 文件 | 职责 |
|---|---|
js/merge.js |
mergeImages() 纵向拼接、downloadCanvas() Blob 下载 |
js/main.js |
exportPNG() 单图导出、toggleCart() 队列快照、buildCompositions() 配对、openMergePreview() |
js/ui.js |
renderCards() 挂导出按钮、showMergePreview() 预览弹窗 |
manifest.json |
filename 字段(下载命名,85 处) |
css/style.css |
.export-button / .cart-* / .modal-* / .preview-* 样式 |