Skip to content
返回

ECharts图表导出功能重构到html2canvas经验分享

下班之后没人亲亲,敲段代码哄自己开心。写个demo修完bug,誓让女神喊你哥哥。

需求很直白:图表卡片上放一个下载按钮,点一下导出 PNG,图里要有标题。


ECharts 自带的 saveAsImage 功能有,但不够

项目里十几种图表,用的都是 ECharts。它自带一个 toolbox,勾上 saveAsImage 就能导出,一行配置的事:

toolbox: {
  feature: {
    saveAsImage: {
    }
  }
}

导出的是 ECharts 画布本身。饼图、折线图、柱状图都行,但问题也很明显——标题不在里面。

我们的标题之前是写在 ECharts 的 option.title 里的,所以导出没问题。但后来我们把标题抽到了 ChartCard(Ant Design 的 a-card 组件)里,和图表画布分开了。title 在 React/Vue 的 DOM 层,ECharts 的 getDataURL() 只抓自己的 canvas,根本看不到外面的东西。

导出的图没标题,这功能等于只做了一半。


使用 html2canvas

项目里本来就有 html2canvas,直接拿来截整个卡片。逻辑也简单:拿 card 的 DOM ref,调 html2canvas(cardRef.$el),导出整张卡。

const canvas = await html2canvas(cardRef.$el, {
  backgroundColor: "#ffffff",
  scale: 2,
});
link.href = canvas.toDataURL("image/png");

标题有了,图表有了,看起来完美。但是结果有个小细节需要注意:「导出的图上有个下载图标」

因为下载按钮本身就画在卡片右上角,html2canvas 什么都给你截进去。用户想要的是一张干净的报表图,不是一张带 UI 控件的截图。


v-if 隐藏按钮 → 踩进布局抖动的坑

最直接的想法:截图前把按钮藏起来,截完再放回去。v-if 一行搞定。

toolbarHidden.value = true;
await nextTick();
const canvas = await html2canvas(cardRef.$el, { ... });
toolbarHidden.value = false;

结果:按钮确实没了,但整个标题栏抖了一下。下载图标消失的瞬间,标题文字往右挪了几像素,截图完成后图标回来,文字又弹回去。

这里暴露了一个被很多人忽视的 CSS 常识:Ant Design Card 的标题和 extra(工具栏区域)是用 flexbox 排的。按钮在 DOM 里,flex 会自动给它分配空间;v-if 把它从 DOM 树里连根拔掉,flex 重新计算,标题区域自然就扩展了。

这就是典型的 CLS(Cumulative Layout Shift) 问题——虽然发生在截图的一瞬间,肉眼仍然能捕捉到。


visibility: hidden — 空间留着,视觉藏起来

解法很简单:别删 DOM,只改可见性。

.is-capturing .ant-card-extra {
  visibility: hidden;
}

visibility: hiddendisplay: none 的核心区别:前者元素还在文档流里,占着原来的位置,只是渲染成透明的。flex 布局不需要重新计算,标题纹丝不动。

而且 html2canvas 对 visibility: hidden 的处理和浏览器一致——不渲染。导出的图里工具栏区域就是一片空白,看不到按钮。

为了让 CSS 类生效再加一帧等待:

capturing.value = true;
await nextTick();
await new Promise(r => requestAnimationFrame(r));
const canvas = await html2canvas(cardRef.$el, { ... });

nextTick 确保 Vue 把类名加到 DOM 上,requestAnimationFrame 确保浏览器完成了一帧渲染。这两个时序缺一不可——少一个就可能截到半透明或者还没隐藏的状态。

到这里功能算完整了,但体验上还差点意思。


从”能跑”到”顺手”

现在的状态:卡片右上角永远挂着一个下载图标。数据监控页面本来信息密度就高,四个饼图并排,每个角上都有一个图标,视觉上很扎眼。

改了一行 CSS:

.ant-card-extra {
  opacity: 0;
  transition: opacity 0.2s ease;
}
.chart-card:hover .ant-card-extra {
  opacity: 1;
}

平时图标完全隐藏,鼠标移到卡片上才淡入。因为用的是 opacity 而不是 display,同样不会有布局位移。过渡的 0.2s 让出现和消失都很自然。

但这引入了一个新问题:截图时如果 toolbar 处于 hover 后的 opacity: 1 状态,html2canvas 会把它截进去。所以截图前仍然要走 visibility: hidden 的逻辑——visibility 优先级高于 opacity,浏览器不会渲染它,html2canvas 也看不到。


几个被这个小功能串起来的知识点

一个导出按钮,从需求到最终实现,使用的及时点包括了:

层面涉及的知识点
EChartsgetDataURL() vs toolbox.saveAsImage,canvas 导出边界
html2canvas动态 import 按需加载,scale: 2 高清导出,backgroundColor 处理透明背景
CSS 布局v-if 导致的 CLS 问题,visibility: hidden vs display: none vs opacity: 0 的渲染差异
浏览器渲染时序nextTickrequestAnimationFrame 的执行顺序,为什么截图需要等一帧
Vue 响应式v-if 的 DOM 重建开销,v-show/CSS 类切换的性能差异
UX 细节hover 交互、opacity 过渡动画、隐式操作 vs 显式操作的设计取舍

没有哪个知识点是”高深的”。就是这些看起来很基础的东西,组合在一起,差一个就会出 bug。

这也是前端有魅力的地方——你不是在写一个导出功能,你是在同时处理渲染引擎、布局算法、异步时序和交互设计,而所有这些都要在一个”下载按钮”上对齐。


Share this post on:

Next Post
Pinia 4.0 全面 ESM-only:前端模块化,终于只剩一条路