下班之后没人亲亲,敲段代码哄自己开心。写个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: hidden 和 display: 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 也看不到。
几个被这个小功能串起来的知识点
一个导出按钮,从需求到最终实现,使用的及时点包括了:
| 层面 | 涉及的知识点 |
|---|---|
| ECharts | getDataURL() vs toolbox.saveAsImage,canvas 导出边界 |
| html2canvas | 动态 import 按需加载,scale: 2 高清导出,backgroundColor 处理透明背景 |
| CSS 布局 | v-if 导致的 CLS 问题,visibility: hidden vs display: none vs opacity: 0 的渲染差异 |
| 浏览器渲染时序 | nextTick → requestAnimationFrame 的执行顺序,为什么截图需要等一帧 |
| Vue 响应式 | v-if 的 DOM 重建开销,v-show/CSS 类切换的性能差异 |
| UX 细节 | hover 交互、opacity 过渡动画、隐式操作 vs 显式操作的设计取舍 |
没有哪个知识点是”高深的”。就是这些看起来很基础的东西,组合在一起,差一个就会出 bug。
这也是前端有魅力的地方——你不是在写一个导出功能,你是在同时处理渲染引擎、布局算法、异步时序和交互设计,而所有这些都要在一个”下载按钮”上对齐。