☰
微信小程序 ECharts 实战:ec-canvas 选型、体积裁剪与性能优化
2026/10/1 1:01:58 网站建设 项目流程

在小程序里画一张图表,听起来是件小事,真正动手做过的人都懂,从体积超标、canvas 白屏、tooltip 点不出来,一路能踩到页面退出后内存不释放。我最早接一个业务数据看板需求的时候,想当然地以为把 echarts 引进来直接 setOption 就能出图,结果第一次提交就卡在包体积上,第二次又栽在真机白屏上。这篇就把我在微信小程序里用 echarts 的整套流程完整摊开,从组件选型、体积裁剪、初始化、数据更新,到折线图 x 轴刻度、饼图 labelLine 偏移、tooltip 自动换行这些具体细节,再到性能优化和排错,尽量写细。不管你是刚接触小程序图表的新手,还是已经被 canvas 折腾过几轮的老手,都能从里面抄到能直接用的代码和填过的坑。

1. 为什么要在小程序里折腾 ECharts

1.1 小程序原生画图能力的真实短板

微信小程序自带的 canvas 组件确实能画图,但它本质上是一块画布加一套 2D 绘图 API,你要画一条带坐标轴、图例、tooltip 的折线图,得自己算刻度、排版、处理触摸命中,工作量非常大。做个简单进度环还行,要做数据可视化大屏那种复杂度,纯手写基本等于自己实现半个图表库。而业务侧的需求往往是:x 轴要按时间自适应、鼠标悬浮要看明细、多条系列要能切换显隐、颜色要跟着主题走。这些正是 echarts 的强项。

所以小程序的现实选择通常是三选一:要么用官方维护的 ec-canvas 组件把 echarts 塞进去,要么在 WebView 里跑网页版 echarts,要么干脆在服务端渲染成图片返回。WebView 方案能复用完整的 web 生态,但页面切换卡顿、交互延迟明显,包体也大;服务端渲染图片方案最省事,但完全没有交互,hover、缩放、点选全部没有。真正要交互又要性能,ec-canvas 是当前最稳的路子,它本质是把 echarts 的核心运行在逻辑层和 canvas 之间做了一层桥接,让你在小程序里也能调用 echarts 的 API。

注意:ec-canvas 是社区维护的适配组件,不是 echarts 官方核心包的一部分。它的代码量不大,但版本和 echarts 主版本之间有匹配关系,后面会专门讲。

1.2 三条接入路线的取舍对比

选型这件事不能拍脑袋,我把三条路线按真实项目里最关心的几个维度列出来,你可以直接对着自己的场景挑。

维度ec-canvas 组件WebView 内嵌网页服务端渲染图片
交互能力完整,支持触摸、点选完整,但受 webview 容器限制无
包体积影响中等,取决于 echarts 裁剪较大,需托管网页资源极小
首屏速度快,本地渲染慢,要等 webview 加载取决于网络
开发成本中等,需要处理适配细节低,复用 web低,但后端要配合
动态更新支持 setOption支持每次都要重新请求
离线可用是否否

多数数据看板、报表、运营后台类小程序,直接用 ec-canvas。只有那种页面本身就是一整个复杂 H5、短期内又不想重写的情况,才考虑 WebView。服务端渲染更适合分享卡片、消息推送里的静态缩略图。

1.3 我的选型结论与适用场景

我最后选的是 ec-canvas,理由很直接:小程序的用户对流畅度极其敏感,多等一秒都可能直接退出,本地渲染的首屏优势是决定性的。同时业务上要 hover 看明细、点图例切换系列,这些交互 WebView 能勉强做,但一旦嵌套滚动就会出现手势冲突,体验很差。

不过要清醒认识到它的代价:echarts 完整包接近 1MB,而小程序主包限制是 2MB,你不可能为了一个图表把主包占掉一半。所以真正的难点从来不是"能不能用",而是"怎么把它裁剪到能塞进包里还不丢功能"。这也是后面第 2 章要重点解决的问题。

2. 动手前的准备:ec-canvas 组件与工程结构

2.1 获取并正确放置 ec-canvas 组件

ec-canvas 不是 npm 包,也不能靠 npm install 直接跑通,它是一组需要手动放进项目的组件文件。最稳妥的方式是从开源仓库 echarts-for-weixin 里把ec-canvas整个目录拷出来。这个目录里通常包含四个文件:ec-canvas.js、ec-canvas.json、ec-canvas.wxml、ec-canvas.wxss,还有一个负责新旧 canvas 兼容的wx-canvas.js。

我习惯把它放在项目根目录下的components/ec-canvas/里,而不是放在某个页面的同级目录。原因是图表往往不止一个页面用,放全局方便复用,页面里通过相对路径引用即可。放好之后,组件目录本身不需要额外配置,就是在要用它的页面 json 里注册。

{ "usingComponents": { "ec-canvas": "/components/ec-canvas/ec-canvas" } }

有个细节容易被忽略:ec-canvas.wxml里的 canvas 组件带有canvas-id和id属性,如果你在同一个页面放了两个图表,必须保证它们的canvas-id不同,否则两个图表会抢同一块画布,出现互相覆盖或者只有一个能渲染的诡异现象。我在一个驾驶舱页面同时放了折线和饼图,第一版就是因为 id 重复,导致饼图一直是空白,找了一下午。

2.2 版本匹配的坑:echarts.js 不能随便下

这是新手最容易翻车的点。ec-canvas 适配层需要搭配特定版本的 echarts 源码文件,你从 npm 或者 CDN 上下一个普通版echarts.min.js直接改名丢进去,多半跑不起来,或者能跑但触摸事件失灵。

正确的做法是:从 echarts-for-weixin 仓库里直接拿它自带的echarts.js,这个文件是专门为小程序环境打包过的,内部替换了 canvas 的创建方式和事件绑定逻辑。如果你用的是 echarts 5.x,就要用对应支持 5.x 的 ec-canvas 版本;用 4.x 就用老的适配层。两个大版本之间在渲染器初始化上的差异不小,混用会出现createCanvas is not a function之类的报错。

提示:判断版本是否匹配,最简单的办法是看仓库的 README 或 release 说明,它会明确写清楚当前适配层支持的 echarts 主版本号。不要凭感觉试。

2.3 按需打包:把图表体积从 1MB 砍到 200KB

完整版 echarts 之所以大,是因为它内置了所有图表类型、坐标系、组件和地图。但你一个具体页面可能只需要折线图和饼图。这时候就要用 echarts 官方的在线定制工具,勾选你需要的图表、坐标系、组件,然后下载定制版的压缩包。

我一般会勾这几类:图表类型选折线、柱状、饼图;坐标系选直角坐标和极坐标;组件选 title、tooltip、legend、grid、toolbox 按需;渲染器只留 canvas。这样打出来的文件通常能压到 200KB 以内,运气好甚至不到 150KB。对于包体紧张的项目,这一步是必须做的。

# 是手动放置组件目录,不是 npm 安装 # 项目结构参考 components/ ec-canvas/ ec-canvas.js ec-canvas.json ec-canvas.wxml ec-canvas.wxss wx-canvas.js echarts.js # 替换成定制版 pages/ dashboard/ dashboard.js dashboard.json dashboard.wxml dashboard.wxss

如果定制版还是压不下去,就该考虑分包了。把图表页面连同echarts.js一起放进分包,主包只留入口,这样主包体积不受影响。地图数据、大 JSON 这类东西尤其适合放分包,因为地图 GeoJSON 动辄几百 KB 甚至上兆,塞主包必炸。

3. 第一个折线图:从零跑通的完整代码

3.1 WXML 与 JSON 的配置写法

先看页面结构。canvas 容器必须有一个明确的宽高,否则 echarts 拿到的高度是 0,图根本不会画出来。我一般用一个固定高度的 view 包住 ec-canvas,高度用 rpx 或 px 都行,但要保证实际渲染出来有值。

<!-- dashboard.wxml --> <view class="chart-wrap"> <ec-canvas id="line-chart" canvas-id="line-canvas" ec="{{ ec }}" ></ec-canvas> </view>
/* dashboard.wxss */ .chart-wrap { width: 100%; height: 500rpx; } ec-canvas { width: 100%; height: 100%; }

这里有个新手常犯的错:给.chart-wrap设了padding或者margin,但 ec-canvas 的宽高是按父容器撑的,padding 会导致实际可绘制区域比预期小,坐标轴就贴边甚至被裁掉。解决办法是要么去掉 padding,要么在容器内部再包一层负责间距。

3.2 JS 初始化与 init 回调的完整流程

在页面 js 里,你需要引入组件目录下的 echarts,然后定义一个初始化函数。这个函数的签名是固定的,四个参数分别是 canvas 对象、宽度、高度和像素比。

// dashboard.js import * as echarts from '../../components/ec-canvas/echarts'; function initLineChart(canvas, width, height, dpr) { const chart = echarts.init(canvas, null, { width: width, height: height, devicePixelRatio: dpr }); canvas.setChart(chart); const option = { grid: { left: 40, right: 20, top: 30, bottom: 30 }, xAxis: { type: 'category', data: ['周一', '周二', '周三', '周四', '周五'], axisLabel: { fontSize: 11 } }, yAxis: { type: 'value', axisLabel: { fontSize: 11 } }, series: [{ type: 'line', smooth: true, data: [120, 200, 150, 80, 170], itemStyle: { color: '#3b82f6' } }] }; chart.setOption(option); return chart; } Page({ data: { ec: { onInit: initLineChart } } });

这里的devicePixelRatio非常关键。不传的话,在高分屏上图表会糊成一团,文字边缘发虚。dpr由组件自动读取系统像素比传进来,你只要如实透传给 echarts.init 就行。我见过有人为了省事写死 dpr = 1,结果在 iPhone 上文字全是锯齿,比不画还难看。

另外canvas.setChart(chart)这行也不能省,它负责把 echarts 实例和 WxCanvas 桥接起来,触摸事件、点击命中都依赖它。少了这行,图能显示,但点图例、拖 tooltip 全部失效。

3.3 lazyLoad 与数据更新的正确姿势

当一个页面有多个图表,或者图表要等接口返回数据才渲染时,用 onInit 会有点浪费——组件一挂载就初始化,接口还没回来图就先空跑一次。这时候改用 lazyLoad,等数据到了再手动触发。

Page({ data: { ec: { lazyLoad: true } }, onReady() { this.lineComponent = this.selectComponent('#line-chart'); }, onLoad() { this.fetchData(); }, fetchData() { // 模拟接口返回 const seriesData = [120, 200, 150, 80, 170]; this.lineComponent.init((canvas, width, height, dpr) => { const chart = echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); chart.setOption(this.buildOption(seriesData)); this.lineChart = chart; return chart; }); } });

要养成一个习惯:把图表实例挂在this上。后续更新数据、销毁实例都靠这个引用。更新数据时,别粗暴地重新 init 一遍,那样会反复创建 canvas 上下文,内存蹭蹭涨。正确的是复用实例调 setOption。

updateChart(newData) { if (!this.lineChart) return; this.lineChart.setOption({ series: [{ data: newData }] }); }

多层 series 更新时,如果数据条数变了,建议用notMerge: false默认合并策略,必要时对某个 series 用replaceMerge,避免旧数据残留。

3.4 尺寸获取与适配的边界情况

图表宽度依赖容器实际渲染尺寸。如果容器是用 flex 或者%撑开的,在 onInit 触发时尺寸可能还没最终确定,尤其在低端安卓机上更明显。稳妥做法是把图表所在的 view 设成固定宽高,或者用wx.createSelectorQuery在onReady之后量一次。

还有一个 rem 适配的坑:如果你项目里用了 pxtorem 之类的方案做屏幕适配,注意它对 echarts 里的样式是无效的。echarts 的字体、间距参数走的是 canvas 绘制,不经过 CSS,rem 换算根本触达不到。所以图表的尺寸、字号要么写死 px,要么自己根据系统宽度算比例。我一般会取一次wx.getSystemInfoSync().windowWidth,按 750 基准算出缩放比,再乘到字号上,效果比硬编码好。

4. 常见图表类型的落地细节

4.1 折线图 x 轴刻度与 tooltip 换行

折线图最容易被吐槽的就是 x 轴刻度挤成一团。当类目很多或者标签文字较长时,默认所有刻度都会显示出来,结果就是重叠成一团黑。解决办法有两个:一是用axisLabel.interval控制显示间隔,二是用axisLabel.rotate让文字倾斜。

xAxis: { type: 'category', data: longLabels, axisLabel: { interval: 'auto', rotate: 30, fontSize: 10, formatter: (val) => val.length > 6 ? val.slice(0, 6) + '…' : val } }

interval: 'auto'让 echarts 自己计算能放下多少刻度,这是大多数场景的最优解。如果业务要求每个刻度都必须显示,那就只能靠 rotate 加缩小字号硬扛,或者干脆换成横向柱状图。

tooltip 自动换行是另一个高频问题。小程序里 tooltip 是画在 canvas 上的,没有 DOM,所以你在 web 上常用的extraCssText、appendToBody统统不管用。要实现换行,只能靠 formatter 自己往字符串里塞换行符。

tooltip: { trigger: 'axis', confine: true, textStyle: { fontSize: 11 }, formatter: (params) => { const list = Array.isArray(params) ? params : [params]; const lines = list.map(p => `${p.seriesName}: ${p.value}`); return ['统计明细', '----------'].concat(lines).join('\n'); } }

confine: true也很重要,它会把 tooltip 限制在图表区域内,防止它飘到容器外面被裁掉。如果内容太长,还得自己做手动折行,写一个按字符数切分的小函数,每 N 个字符插一个\n,实测下来比任何花哨配置都可靠。

注意:tooltip 里的颜色、背景想自定义,只能通过 tooltip 自身的backgroundColor、borderColor、textStyle.color来设,不要指望 CSS。

4.2 饼图 labelLine 与标签末端偏移的处理

饼图的标签和引导线是最讲究的。默认情况下,标签末端会有一个小圆点,引导线分两段。很多人遇到的问题是:小圆点和文字对不齐,或者圆点位置偏出去一截。这通常是labelLine的length和length2两个参数没配好。

series: [{ type: 'pie', radius: ['35%', '60%'], avoidLabelOverlap: true, label: { show: true, formatter: '{b}\n{d}%', fontSize: 11, lineHeight: 15 }, labelLine: { length: 12, length2: 10, smooth: false }, labelLayout: { hideOverlap: true }, data: pieData }

length是第一段引导线(从扇区边缘往外),length2是第二段(拐向文字),末端小圆点位于第二段末尾。如果你想让圆点离文字近一点,就把length2调小;想让整体往外扩散,就两个都调大。ECharts 5 里推荐用labelLayout.hideOverlap来自动隐藏重叠标签,比手动算位置省心得多。

还有一个细节:小圆点其实不是独立元素,它是 labelLine 末尾的样式体现,颜色默认跟引导线一致。想改颜色就设labelLine.lineStyle.color。如果发现圆点和文字之间有个奇怪的间隙,大概率是padding或者fontSize造成的视觉错觉,微调 length2 即可。

4.3 雷达图的维度与刻度适配

雷达图在小程序里的用法和 web 几乎一样,唯一的坑是屏幕窄,维度多了之后标签会互相压。核心配置是radar.indicator定义维度和最大值,radar.splitNumber控制网格圈数。

radar: { center: ['50%', '55%'], radius: '60%', splitNumber: 4, indicator: [ { name: '响应速度', max: 100 }, { name: '稳定性', max: 100 }, { name: '易用性', max: 100 }, { name: '扩展性', max: 100 }, { name: '成本', max: 100 } ], axisName: { fontSize: 10, color: '#666' } }

维度超过 6 个时,axisName的字号一定要调小,或者用formatter截断。另外雷达图的系列要设areaStyle才会显得饱满,不然只有几条折线,视觉上很单薄。我会给 areaStyle 加个半透明渐变色,既好看又不会盖住下面的网格。

4.4 地图图表的数据注册与分包存放

地图是小程序图表里最特殊的一类,因为它依赖 GeoJSON 数据。echarts 本身不内置中国地图的几何数据,你得先拿到合规的 GeoJSON 文件,通过echarts.registerMap注册进去,然后才能画。

import * as echarts from '../../components/ec-canvas/echarts'; import chinaGeo from '../../data/china.json'; echarts.registerMap('china', chinaGeo); // series 里这样用 series: [{ type: 'map', map: 'china', roam: true, label: { show: false }, itemStyle: { areaColor: '#eef2ff', borderColor: '#c7d2fe' }, emphasis: { itemStyle: { areaColor: '#a5b4fc' } } }]

最大的现实问题是地图 JSON 体积很大,通常几百 KB。绝对不能放主包。我的做法是:地图相关页面单独分包,把 GeoJSON 和图表逻辑一起放进去,主包只保留入口跳转。这样即使用户从不打开地图页,也不会为主包体积买单。

另外注册地图是全局行为,重复注册会浪费内存,建议放在应用初始化阶段做一次,或者用标志位判断避免重复执行。

5. 常见问题排查与性能优化

5.1 图表不显示、白屏、报错的速查表

这类问题我整理了一张表,基本覆盖了 90% 的翻车场景。遇到问题先对着表排查,比盲目改代码快得多。

现象大概率原因解决办法
图完全空白容器高度为 0给容器设固定高度,别只给百分比
白屏且控制台报 createCanvas 错echarts.js 版本不匹配换成适配层配套的 echarts.js
图上文字发虚、锯齿明显没传 devicePixelRatioinit 时传 dpr
点图例无反应漏了 canvas.setChart初始化后补上这行
两个图只有一个能显示canvas-id 重复每个图表用唯一 id
tooltip 飘到画布外被裁没开 confinetooltip 加 confine: true
页面退出后内存不降没销毁实例onUnload 调 chart.dispose()
地图加载报找不到 map没 registerMap先注册 GeoJSON 再 setOption

5.2 大数据量渲染与内存释放

数据量一大,图表渲染就会卡。折线图几千个点还好,上万点在高分屏上就明显掉帧。这时候有几个手段:一是开large模式,让 echarts 走简化渲染;二是用sampling: 'lttb'做降采样,用更少的点近似描述趋势。

series: [{ type: 'line', large: true, largeThreshold: 2000, sampling: 'lttb', showSymbol: false, data: bigData }]

showSymbol: false也能省不少,数据点多的时候默认给每个点画一个小圆圈,开销很大,趋势图根本不需要显示点。

内存释放是最容易被忽视的。每次 init 都会创建 canvas 上下文和 echarts 实例,如果页面反复进出却不销毁,内存会持续增长,最后触发小程序的内存告警甚至闪退。正确做法是在页面onUnload里销毁实例。

onUnload() { if (this.lineChart) { this.lineChart.dispose(); this.lineChart = null; } }

如果图表在自定义组件里,就在组件的detached生命周期里做同样的清理。我踩过一次坑:一个 tab 切换页面里放了三个图表,来回切十几分钟就卡死,加上 dispose 之后彻底稳定。

5.3 几个反复踩到的心得

第一个心得:不要在onInit里做异步接口请求。onInit 是同步回调,你必须立刻返回 chart 实例,如果里面 await 了接口,返回的就是个 Promise,组件拿不到实例,图直接不出来。要异步就先 lazyLoad,数据到了再 init。

第二个心得:图表的尺寸一旦确定就不要频繁改变。有些需求会根据数据条数动态调高度,如果每帧都改容器高度再重绘,性能会很差。更稳的做法是容器高度固定,用grid内部留白来适配内容密度。

第三个心得:真机调试永远比开发者工具可靠。开发者工具里的 canvas 表现和真机有差异,尤其是 Canvas 2D 模式,很多触摸穿透、渲染错位的问题只在真机上复现。提交前一定用安卓和 iOS 各测一遍。

6. 工程化封装与页面联动扩展

6.1 把重复代码收进一个通用图表组件

一个项目里图表超过三个,就值得封装了。我的做法是写一个base-chart组件,对外暴露一个option属性,内部负责 ec-canvas 的挂载、init、setOption 和 dispose。页面只管传配置,不用每次重复写初始化。

// base-chart.js import * as echarts from '../ec-canvas/echarts'; Component({ properties: { option: { type: Object, value: {} } }, data: { ec: { lazyLoad: true } }, lifetimes: { ready() { this.comp = this.selectComponent('#base-canvas'); this.render(); }, detached() { if (this.chart) { this.chart.dispose(); this.chart = null; } } }, methods: { render() { const option = this.data.option; this.comp.init((canvas, width, height, dpr) => { const chart = echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); chart.setOption(option); this.chart = chart; return chart; }); }, update(newOption) { if (this.chart) this.chart.setOption(newOption); } } });

封装的关键是把 dispose 放进 detached,把 setOption 抽成方法,页面只需要在数据变化时调用this.selectComponent('#chart').update(newOption)。这样一来,图上所有的适配细节都收敛在一处,后续换主题、改配色也只用改一个文件。

6.2 与小程序页面元素的联动

图表从来不是孤立的,它经常要和页面其他元素联动。最常见的两个场景:一是图表切换时同步改导航栏标题,二是点击图表某个数据点跳转详情页。

动态改标题很简单,调用wx.setNavigationBarTitle即可,比如用户切到"月度"就把标题改成"月度趋势"。

switchToMonth() { wx.setNavigationBarTitle({ title: '月度趋势' }); this.selectComponent('#chart').update(monthOption); }

图表点击事件则通过 echarts 的on('click', cb)绑定,回调里能拿到被点数据项的 value 和 name,再wx.navigateTo跳转。要注意事件回调里的 this 指向,提前用箭头函数或者提前缓存指向页面的引用。

chart.on('click', (params) => { wx.navigateTo({ url: `/pages/detail/detail?name=${params.name}` }); });

6.3 从单图到数据大屏的扩展思路

当单张图跑顺了,下一步往往是做整屏的数据大屏。小程序的屏幕尺寸有限,做驾驶舱的思路和 web 不太一样:不要试图塞进所有指标,而是按优先级分层,用卡片式布局,一个屏最多放三到四张图,用户上下滑动查看。配色上建议统一主题色,避免每张图用不同色系导致视觉混乱。

另外,大屏场景对数据刷新频率有要求,一定要用实例复用加 setOption 增量更新,千万别定时器里反复 init。我做过一个每 5 秒刷新的看板,最初就是每次刷新重建图表,半小时后直接卡崩;改成复用实例后,连跑几个小时都稳。图表这块,稳定永远比花哨重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询