
最近在做一个微信小程序原生项目数据报表页里要放折线图、柱状图、饼图还要支持数据更新和导出图片。最开始我走了一堆弯路从 npm 装好 echarts写了个canvas像网页那样 new 一个实例结果一运行直接白屏终端里全是canvas is not defined。后来才搞明白小程序原生环境和浏览器差的不是一星半点——没有 DOM、没有 window连 canvas 都是原生组件层级、生命周期、初始化时机全有自己的规矩。这篇东西就是把我从“能跑”到“跑得稳”的完整过程整理出来重点讲原生小程序如何正确接入 ECharts 图表以及接入之后真正会遇到的细节问题组件初始化、canvas 盖层、多图表实例管理、数据更新、导出图片、性能优化。不管你是第一次在原生小程序里画图表还是已经接上但时不时冒出点怪问题这篇应该都有参考价值。1. 为什么小程序里不能直接把 ECharts 拿来用1.1 浏览器和小程序的运行环境差在哪里在网页里用 ECharts本质上是让它在浏览器的 DOM 树上找一个容器节点测量尺寸、创建 canvas、绑定鼠标和触摸事件然后由 zrender 负责绘制和交互。浏览器里的一切都是 DOMdiv 是 DOMcanvas 也是 DOMECharts 随便折腾。小程序原生环境完全不是这么回事。逻辑层跑在一个独立的 JS 引擎里渲染层是 WebView两层之间靠setData传数据通信。你在 WXML 里写的view、canvas并不是逻辑层能直接操作的 DOM 节点也没有window、document这些全局对象。更麻烦的是小程序里的 canvas 是原生组件它不由 WebView 渲染而是交给客户端底层去绘制。这就导致一个结果ECharts 想在 canvas 上画图但它连“这个 canvas 在哪”都不知道更谈不上监听事件、动态测量尺寸。直接引入echarts.js之后调用echarts.init()它会去找 DOM 节点找到就白屏或者直接抛异常。用一个生活化的类比ECharts 像一个习惯了在实体餐桌上摆盘的厨师浏览器给它一张实体餐桌DOM它可以随便操作。小程序提供的是一间“只能通过小窗口递菜”的隔间没有实体餐桌。适配层的任务就是在隔间里伪造一张让厨师能正常操作的餐桌。1.2 官方适配组件 ec-canvas 到底做了什么官方为小程序场景提供的适配方案核心是一个自定义组件名字叫ec-canvas。它内部做的事情可以简单概括成三件第一创建 echarts 能识别的 canvas 对象。这个对象不一定是你平时wx.createCanvasContext拿到的上下文而是一个被包装过的、具备 echarts 需要的接口的节点。第二测量容器尺寸把宽、高和设备的像素比dpr传给初始化逻辑。第三把 canvas 上的触摸事件touchstart、touchmove、touchend收集起来转交给 echarts 的内部事件系统这样图表才能响应点击、缩放这些操作。使用层面你不需要关心它内部怎么实现。你要做的只是在页面上放一个ec-canvas标签传一个ec属性告诉组件“图表初始化该怎么搞”。要么通过ec.onInit让组件在合适时机自动初始化要么通过ec.lazyLoad: true配合手动调用组件的init()方法控制初始化时机。这两种方式后面都会展开讲。1.3 版本选型和包体积控制别手痒换 echarts.js很多新手一上来就去 ECharts 官网下载最新完整版然后替换掉 adapter 自带的echarts.js这是个挺容易踩的坑。官方小程序适配工程里自带的echarts.js是经过定制构建的只包含常用的图表类型和组件体积比官网完整版小不少。完整版 ECharts 的压缩文件通常接近 1MBgzip 之后也得 300KB 左右在小程序这个环境下已经属于“重磅炸弹”了。以现在的代码包限制主包通常是 2MB 级别总包在 20MB 左右。一个完整 echarts.js 塞进主包很可能直接把主包体积顶爆导致上传失败。我的建议是能用适配工程自带的echarts.js就别换确实需要额外图表类型比如地图、雷达图、漏斗图时走 ECharts 官方的自定义构建流程按需勾选打包别图省事直接上完整版。如果图表页面在分包里尽量让echarts.js跟着分包走别一股脑放进主包。2. 从零到一在原生小程序里跑起第一个柱状图2.1 准备工作基础库版本和文件目录先确认你的项目基础库版本在 2.9.0 以上。这个版本是 Canvas 2D 正式可用的节点它直接决定了同层渲染是否生效也会影响初始化写法和 canvas 层级表现。开发者工具尽量升级到较新版本旧工具对某些 API 的支持会有差异。准备阶段最省事的方式是去官方小程序 ECharts 适配工程里把ec-canvas整个目录复制到项目的components目录下结构大概是├── components │ └── ec-canvas │ ├── ec-canvas.js │ ├── ec-canvas.json │ ├── ec-canvas.wxml │ ├── ec-canvas.wxss │ └── echarts.js └── pages └── chart-demo ├── index.js ├── index.json ├── index.wxml └── index.wxss这里有一个容易忽略的点ec-canvas组件本身在 json 里会声明component: true你把目录原样复制进来就能用不需要额外为它做 npm 构建。原生小程序项目里这种“复制目录”的方式比走 npm 再构建省事得多也少踩版本不兼容的坑。2.2 页面配置json 注册组件wxml 放标签在需要使用图表的页面 json 里把ec-canvas注册为自定义组件{ usingComponents: { ec-canvas: /components/ec-canvas/ec-canvas }, navigationBarTitleText: 数据报表 }路径写法要注意绝对路径以/开头指向项目根目录相对路径也行但复制目录后建议统一用绝对路径避免页面层级变深之后路径失效。然后在 wxml 中放置组件view classchart-box ec-canvas idbar-chart canvas-idbar-chart ec{{ ec }}/ec-canvas /view这里两个 id 各有用途id是给页面逻辑层用的后面用selectComponent(#bar-chart)能拿到组件实例canvas-id是给组件内部 canvas 节点用的同一个页面里多个图表时绝不能重复。ec属性是初始化配置对象后面 js 里会定义。2.3 js 初始化最简单的 onInit 自动模式页面 js 里写初始化逻辑const echarts require(../../components/ec-canvas/echarts); function initChart(canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width: width, height: height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption({ xAxis: { type: category, data: [Mon, Tue, Wed, Thu, Fri] }, yAxis: { type: value }, series: [ { type: bar, data: [120, 200, 150, 80, 170] } ] }); return chart; } Page({ data: { ec: { onInit: initChart } } });这段代码里的几个细节值得说清楚echarts.init(canvas, null, {...})的第一个参数不是 DOM 节点而是组件内部传出来的适配 canvas 对象。必须显式传width、height和devicePixelRatio因为小程序环境里 echarts 没有办法自己去“量”容器的尺寸你不给它就画出来一片空白或者只画一小块。canvas.setChart(chart)这行一定不能漏。它的作用是把 chart 实例注册到 canvas 上组件才能把触摸事件正确转发给 echarts否则图表能画出来但点击 tooltip、切换交互统统不响应。initChart函数最后return chart也很关键。组件拿到返回的 chart 实例后会在内部保存一份引用用于销毁和重新初始化时的清理工作。你如果只init不return图表虽然也能画出来但后续组件实例被wx:if销毁时可能残留事件监听带来一些莫名其妙的报错。2.4 什么时候用 lazyLoad 手动初始化onInit自动模式适合页面一进来就要展示图表、数据已经在本地或可以立即同步渲染的情况。但实际业务里图表数据通常要等接口返回。此时如果用onInit组件 ready 时数据还是空的图表先画一个空壳再等接口回来setOption更新体验不好不说还可能造成首屏闪动。我一般在这种场景用lazyLoad模式Page({ data: { ec: { lazyLoad: true } }, onReady() { this.chartComp this.selectComponent(#bar-chart); }, // 接口回调里调用 handleDataLoaded(optionData) { if (!this.chartComp) return; this.chartComp.init((canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption(optionData); return chart; }); } });selectComponent(#bar-chart)拿到的是 ec-canvas 组件实例init(callback)是组件暴露出来的初始化方法。回调参数里的 canvas、width、height、dpr 和自动模式的initChart参数一样。手动模式的好处是你能精准控制初始化时机——数据一到再创建图表一次成型不闪不跳。2.5 白屏自查清单先别怀疑 ECharts图表白屏是小程序接 ECharts 最常见的现象而且 90% 不是 echarts 本身的问题。我踩过的和见过的原因基本就这几类按顺序排查很快一是容器没高度。ec-canvas默认不会有高度父容器如果只设了宽度没设高度canvas 高度就是 0图表自然看不见。给.chart-box一个明确的高度或者给ec-canvas设置height: 100%并且父容器有确定高度。二是canvas-id重复。同一页面放了多个图表时忘了改canvas-id所有图表都会试图在同一个 canvas 上绘制表现就是后画出来的图表覆盖前面的或者直接报错。三是路径问题。echarts.js在components/ec-canvas目录下页面 js 的require路径是不是写对了usingComponents里组件路径对不对这俩最容易手滑。四是基础库版本太老。Canvas 2D 支持得不好图表在开发者工具里正常、真机白屏这种情况直接把基础库切到 2.9.0 以上再看。3. 翻车记录canvas 盖层、实例管理、数据更新这些真项目里的坑3.1 canvas 把弹窗和下拉框盖住了怎么办小程序里的 canvas 在旧基础库上属于原生组件渲染层级天然高于普通 view。具体表现就是页面上有个筛选按钮点击后弹出一个半透明遮罩层和日期选择框结果弹窗被图表硬生生盖住按钮点了没反应或者弹窗只露出一半。这个现象在小程序接 canvas 的场景里非常普遍老开发者基本都遇到过。解决办法有好几层。如果你的基础库已经支持 Canvas 2D2.9.0 以上ec-canvas 内部通常会走同层渲染的路径普通 view 已经可以和 canvas 共存并覆盖了。此时如果还出现盖层问题先确认项目是不是真的走的新版 canvas而不是在旧模式分支里。如果确认环境没问题但弹窗还是显示异常最简单的兜底方案是弹窗显示前临时隐藏图表容器弹窗关闭后再把图表恢复显示。view classchart-wrapper wx:if{{showChart}} ec-canvas idbar-chart canvas-idbar-chart ec{{ ec }}/ec-canvas /viewtoggleFilter() { this.setData({ showChart: false }); this.setData({ showFilterPanel: true }); }, closeFilter() { this.setData({ showFilterPanel: false }); this.setData({ showChart: true }); // 如果画布被重建了重新走初始化 this.reinitChart(); }用wx:if而不是display:none控制是因为在某些基础库版本里canvas 被display:none后再显示画布内容会丢失或者状态错乱。用wx:if直接销毁重建配合初始化逻辑重新执行表现最稳定。另外还有cover-view/cover-image能覆盖在 canvas 上但它的样式支持非常有限文字、图片、事件处理都别扭只适合做“排行榜置顶”“悬浮按钮”这类极简场景复杂弹窗千万别指望它。3.2 千万别把 chart 实例 setData 进 data这个坑我见过不止一次。有开发者在初始化拿到 chart 后顺手写了句this.setData({ chart: chart })想着后面更新方便。然后页面开始莫名其妙卡顿偶尔还会报Cannot read property setOption of undefined之类的错。原因不复杂setData会把值序列化后通过逻辑层和渲染层之间的桥传输。而一个 echarts 实例内部全是方法、闭包、事件处理器甚至还有对 canvas 节点的引用这些东西根本没法序列化。强行 setData 只会让数据通道被徒增的无效开销占满严重的还会把实例引用弄丢。正确的做法是chart 实例只存在页面实例上不进 data。Page({ data: { ec: { onInit: this.initChart.bind(this) } }, onLoad() { this.chart null; }, initChart(canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); this.chart chart; return chart; }, updateChartData(newData) { if (this.chart) { this.chart.setOption({ series: [{ data: newData }] }); } } });data 里只放ec这种“配置”不放“实例”这是 ECharts 接入小程序的一条铁律。3.3 多图表并存canvas-id 和实例字典的管理数据报表页通常不只一个图表折线、柱状、饼图往往同时在屏幕上。多图表场景下最容易被坑的是canvas-id没有做到全局唯一。一个页面内如果有两个canvas-idtrend第二个组件初始化时大概率会失败或者两个图表互相覆盖。后面那一个还会在控制台报错大意是 canvas 已经存在或者已有实例。这个问题在列表渲染wx:for批量生成图表时尤其隐蔽因为所有项的canvas-id可能都是从同一份模板里带出来的忘了动态拼接。我习惯在每个图表的canvas-id后面拼一个业务 key比如trend-line、trend-bar、trend-pie同时在页面 js 里维护一个this.charts字典Page({ data: { ecList: { line: { lazyLoad: true }, bar: { lazyLoad: true }, pie: { lazyLoad: true } } }, onLoad() { this.charts {}; }, onReady() { this.initChartByName(line, lineOptionBuilder()); this.initChartByName(bar, barOptionBuilder()); this.initChartByName(pie, pieOptionBuilder()); }, initChartByName(name, option) { const comp this.selectComponent(# name); if (!comp) return; if (this.charts[name]) { this.charts[name].dispose(); this.charts[name] null; } comp.init((canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption(option); this.charts[name] chart; return chart; }); } });如果页面里用wx:if切换图表区域记得在图表销毁前先dispose()。原因很简单echarts 实例和 canvas 节点是绑定的旧实例不销毁新实例再 init 同一个 canvas 时会出现“画布已被占用”的状态。清理逻辑虽然代码不多但很容易被漏掉导致页面来回切换几次之后图表就不动了。3.4 setOption 更新数据和 notMerge 的取舍更新图表数据正确姿势是调用chart.setOption而不是重新走一遍echarts.init。setOption默认是 merge 模式也就是说你只传一个新的 series 数据其他配置会保留。这在大部分场景下是合理的比如折线图只是数据变了坐标轴、颜色、图例都保持原样。但有几种情况需要把 merge 模式改成重绘模式一是图表类型发生了变化比如同一个位置从折线切换成柱状图。merge 模式下 echarts 会尝试把旧 series 保留下来很可能出现旧图没清干净、两种图形叠在一起的情况。二是你希望完全重置图表的状态清空用户缩放、数据区域选择等交互痕迹。三是数据结构本身变动比较大merge 反而可能造成配置错乱。此时用chart.setOption(newOption, { notMerge: true, lazyUpdate: true });notMerge: true表示完全替换lazyUpdate: true表示把这次更新合并到下一次渲染帧避免高频更新时反复重绘。另外还有个chart.clear()方法它和dispose()的区别是clear()清空实例里的配置和图形但实例仍然可以用随后还可以继续setOption画新图dispose()则是销毁整个实例之后要重新echarts.init才能画。重建图表时用dispose()更彻底只是刷新数据用setOption就够了。4. 把细节抠完resize、导出图片、体积与性能平衡4.1 窗口尺寸变化时图表不会自己长大开发者工具里拖动窗口大小或者真机上发生横竖屏旋转、平板分屏图表并不会自动铺满新尺寸。这是因为小程序 canvas 的尺寸在初始化时是固定传入 echarts 的没有人通知它“容器变宽了”。要处理 resize可以用wx.onWindowResize监听窗口变化onLoad() { this._handleResize () { const query wx.createSelectorQuery(); query.select(.chart-box).boundingClientRect(rect { if (this.chart rect) { this.chart.resize({ width: rect.width, height: rect.height }); } }).exec(); }; wx.onWindowResize(this._handleResize); }, onUnload() { if (this._handleResize) { wx.offWindowResize(this._handleResize); } }这里注意三点。第一rect.width和rect.height是实际布局尺寸单位是 px不是 rpx。第二resize 之后图表内部会重排但大多数时候你不需要重新setOption它会把旧配置带着新尺寸重绘一遍。第三页面卸载时一定要offWindowResize否则页面销毁后回调还在每次窗口变化都会执行一次无效查询还可能触发对已销毁实例的操作。4.2 把图表导出成图片分享卡片和保存相册的基本操作业务上经常有“把图表分享给别人”或者“长按保存到相册”的需求。做法是先把 canvas 转成临时图片路径再交给wx.previewImage或保存相册接口。这里有一个在新旧基础库上很不一样的坑旧版wx.canvasToTempFilePath依赖canvasId新版 Canvas 2D 需要传canvas节点对象。如果你在 ec-canvas 的初始化回调里没有把 canvas 参数存下来到导出时就会发现自己手里没有可传的节点。所以初始化时顺手保存引用initChart(canvas, width, height, dpr) { this.canvasNode canvas; const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); this.chart chart; return chart; }导出时exportChart() { const canvas this.canvasNode; if (!canvas) return; wx.canvasToTempFilePath({ canvas, destWidth: canvas.width, destHeight: canvas.height, success: (res) { wx.previewImage({ urls: [res.tempFilePath] }); } }); }destWidth和destHeight建议直接用 canvas 的原始像素尺寸否则导出图片可能发虚。追求更高清的话可以乘上wx.getWindowInfo().pixelRatio或者dpr。另外导出前如果图表还处于入场动画中导出结果可能是半成品。稳妥的做法是导出前把动画关掉或者等待几百毫秒再调用导出。我个人习惯用后者视觉上更自然。4.3 大数据量图表性能从配置源头抓起小程序里的 echarts 性能比浏览器里差这点要有预期。原因不复杂浏览器里 echarts 的所有计算和绘制都在同一个环境下完成而小程序逻辑层和渲染层之间有桥接频繁的跨层通信会带来额外开销。所以数据量大的时候优化得从配置源头做起。折线图数据点如果超过几千个加上采样配置能显著减少绘制的点数量series: [ { type: line, sampling: lttb, progressive: 1000, data: largeData } ]sampling: lttb会用一个叫“LTTB”的算法做降采样在保留曲线大致形状的前提下大幅减少点数量。progressive: 1000表示超过 1000 个点时启用渐进式渲染避免一次绘制太多图形导致卡顿。首屏渲染时可以关掉动画chart.setOption(option, { animationDuration: 0 });动画对于数据分析场景是锦上添花不是必需。数据越多越应该优先保证交互的流畅度。还有个隐藏性能点tooltip 的触发方式。默认axis触发在折线图上很灵敏但手指在屏幕上扫过时会触发非常多次 tooltip 更新造成频繁重绘。如果业务不强制要求 tooltip建议配置tooltip: { trigger: item }甚至trigger: none交互开销能下降一个量级。4.4 高级图表的地图数据体积和加载的取舍ECharts 的地图功能在小程序里也能用但成本比普通柱状图高得多。地图需要额外的 GeoJSON 数据这些数据动辄几百 KB对小程序包体是个不小的负担。而且小程序环境里没有全局window不像浏览器那样直接往全局注册地图数据通常需要把 GeoJSON 转成一个可require的 JS 模块再通过echarts.registerMap(mapName, geoJson)注册。这里我的经验是如果只是展示一个省份的下钻、几个区域的热力分布尽量对 GeoJSON 做简化去掉不需要的精度和属性字段如果业务对地图交互要求很高优先评估用自绘 canvas 或者切图方案不要轻易把整份地图数据塞进小程序。5. 选型边界什么时候该用 ECharts什么时候别用5.1 原生方案和其他常见方案的横向对比把小程序里做图表的几类常见方案放一起看会更清楚 ECharts 的位置。方案接入成本包体积图表能力适用场景ECharts ec-canvas中较大需按需裁剪强图表类型和交互丰富折线、柱、饼、散点、雷达、K线等复杂报表H5 页面 web-view低网页开发即可不占小程序包体取决于 H5 用的是什么图表库已有 H5 页面临时嵌进小程序轻量移动端图表库类似 F2 / uCharts 这类方案中较小比 ECharts 弱但常见图够用图表类型固定、数量多、包体积敏感原生 canvas 手写高极小完全可控但开发量随复杂度爆炸极简单的柱状图、迷你走势图web-view 方案看着省事但实际体验比较割裂页面加载白屏时间肉眼可见小程序与 H5 之间通信要借助wx.miniProgram.postMessage这类机制分享、转发时也不好携带图表状态。为了一个图表引入一整个 webview性价比很低我不推荐。5.2 我的实际选择标准接到一个需要图表的需求我的判断流程大概是三步。先看图表种类。如果只需要一两种最基础的图柱状、折线并且交互很简单手指点一下看个数值这种程度那我不会优先选 ECharts。一个轻量图表方案或者自绘 canvas 就够包体积和启动性能都更友好。再看交互深度。需要数据区域缩放dataZoom、多图联动、复杂 tooltip 联动图表这些能力在轻量方案里经常实现得不够好这时候 ECharts 的价值就出来了。ECharts 的 option 体系本身就自带一套完整的交互生态这类需求写起来成本最低。最后看图表在整个产品里的占比。如果只是几个页面里零星出现图表把 echarts.js 放进分包只在对应页面引用问题不大。如果图表是核心功能、页面很多那要考虑的是如何把 echarts.js 这个公共依赖合理地放分包或做预加载避免每次进图表页都去下载一个几百 KB 的文件。5.3 给“还没上车”的人一个参考如果你所在的项目早就用 echarts 写字面报表option 已经积累了一大堆那小程序里继续用这套是顺理成章的至少 option 可以原样复用学习成本为零。如果是全新项目、只有一个简单的统计图需求那就别为了“保险起见”直接把重量级方案引进来。技术选型这事最怕的不是不会选而是不过脑子就选最重的那个。最后分享两个真机调试的经验。第一个开发者工具里一切正常不代表真机没问题Canvas 2D 在旧基础库上会静默失败或表现为白屏建议每次改完都真机预览并打开 vConsole 看有没有初始化报错。第二个遇到白屏或者图表错乱先别怀疑 ECharts 本身按“echarts.js 路径 - usingComponents 注册 - canvas-id 重复 - 容器高度 - 基础库版本”这个顺序排查90% 的问题都能在这个流程里自愈。这两个习惯我保持了很久期间省下过大量查 bug 的时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。