很多人第一次在小程序里接 echarts,都会经历同一个心理过程:照着官网示例把一段 option 贴进项目里,开发者工具里跑得挺顺畅,真机一打开——要么白屏一片,要么图被压成一条扁扁的线,要么手指划上去完全没反应,tooltip 死活不出来。然后把代码翻来覆去看三遍,发现语法没问题、数据没问题、模拟器也没报错,就是不对。
问题不在你的 option 写得差,而在于小程序的运行环境和浏览器完全是两套东西。小程序没有 DOM,没有document,没有window,甚至页面渲染和 JS 执行都不在同一个线程里。ECharts 默认是给浏览器写的,它依赖document.createElement('canvas')去拿画布、依赖getComputedStyle去量字体、依赖真实 DOM 事件去接收鼠标悬停。这套东西在小程序里一个都不成立。
所以小程序的 ECharts 走的是一条专门的路线:官方维护的echarts-for-weixin仓库,核心是一个叫ec-canvas的自定义组件。它干的事情本质上就一件——把原生canvas组件包装成 ECharts 认识的"类 DOM"画布,并把小程序的触摸事件转译成 ECharts 能理解的事件对象。
这篇内容面向三类人:刚接手小程序数据可视化需求的前端、被图表真机问题折磨过的开发者、以及需要在小程序里做数据大屏或报表页的团队。我会把从目录搭建、自定义构建、真机适配、到饼图雷达图地图逐个攻破的过程全部拆开讲,包括那些官方文档里不会写、但我自己踩过的坑。看完之后你应该能独立把一套完整的图表页跑通,并且知道每一个参数为什么这么设。
1. 小程序里的 ECharts 和网页版根本不是一个东西
1.1 双线程架构决定了图表不能"自动"响应布局
浏览器的逻辑是:CSS 把容器撑到多大,canvas 就多大,ECharts 通过clientWidth一读就知道。小程序的逻辑是:渲染层负责把canvas组件摆到屏幕上,逻辑层负责跑 JS。这两层之间靠消息通信,逻辑层拿不到实时的布局信息,只能靠组件在特定生命周期把尺寸"推"过来。
这就解释了一个非常高频的困惑:为什么给容器写了width: 100%; height: 300rpx;,图表还是不出来?因为ec-canvas内部的onInit回调是靠createSelectorQuery去量真实尺寸的。如果父容器是height: 100%但它的父级没有确定高度,量出来的就是 0,echarts.init拿到 0 宽 0 高,画出来自然是一片空白。
注意:
ec-canvas所在的每一层父容器,只要有百分比高度,就必须有一条链路上存在确定的高度值(px、rpx 或者 flex 分配后的实际高度)。这是排查白屏问题的第一顺位。
1.2 ec-canvas 到底替你做了哪些脏活
把ec-canvas目录打开,你会看到这么几个文件:
| 文件 | 作用 |
|---|---|
ec-canvas.js | 组件主逻辑,负责初始化画布、派发触摸事件、调用 onInit |
ec-canvas.wxml | 内部就是一个<canvas>节点,支持type="2d" |
ec-canvas.wxss | 给 canvas 设了 100% 宽高,所以外层必须给高度 |
wx-canvas.js | 兼容旧版 canvas 的适配层 |
echarts.js | 定制过的 ECharts 构建产物 |
关键在最后那个echarts.js。它不是你在 npm 上npm install echarts拿到的那个版本,而是一份针对小程序裁剪过的构建:去掉了 DOM 相关的模块,保留了 CanvasRenderer,并且把一些依赖浏览器 API 的路径做了兼容处理。
我见过有人为了减小体积,直接把 npm 上的echarts.min.js改个名字丢进去替换,结果图表能出来,但 tooltip 一碰就报错,或者地图注册不上。原因就是那份包里的环境探测逻辑假设了浏览器环境。
我的建议很直接:先用官方仓库里那份echarts.js把功能跑通,确认所有图表类型都能正常工作之后,再考虑做体积优化。顺序反了,你会在排错的时候分不清是业务代码的问题还是构建产物的问题。
1.3 先想清楚:哪些场景其实不需要 ECharts
说句得罪人的话,不是所有小程序图表都值得上 ECharts。一个只有三个数字的仪表盘、一条固定不变的趋势线、一个静态的环形进度,用canvas手绘几十行代码就够了,或者干脆用 CSS 画。
ECharts 的价值在于坐标系、多系列叠加、交互联动、以及复杂布局的自动计算。当你需要双 Y 轴、需要 dataZoom 拖动区间、需要雷达图、需要堆叠柱状图加折线混排的时候,ECharts 才真正划算。因为这两个原因:
- 体积代价实打实。完整构建压缩后接近 1MB,对主包 2MB 的红线来说是一半空间,这个账必须算。
- 初始化有成本。每个图表实例都会创建 zrender 实例、监听事件、跑动画,页面上堆五六个图表,中低端安卓机上首屏会明显卡顿。
如果你只是要在商品详情页放一个小趋势图,认真评估一下手绘方案的性价比,不丢人。
2. 环境搭建:把 echarts-for-weixin 塞进项目里的正确姿势
2.1 目录放置与组件注册
标准的做法是:在项目根目录(或者分包根目录)建一个ec-canvas文件夹,把仓库里的五个文件整个放进去,一个都不能少。然后在使用它的页面 json 里注册:
{ "usingComponents": { "ec-canvas": "/components/ec-canvas/ec-canvas" } }路径建议用绝对路径(以/开头)。相对路径在分包情况下极易写错,尤其是ec-canvas放在主包、页面在分包的时候,../../数着数着就多了一层。
页面 wxml 里这样用:
<view class="chart-box"> <ec-canvas id="sales-chart" canvas-id="sales-canvas" ec="{{ ec }}" ></ec-canvas> </view>id是给selectComponent用的,canvas-id在旧版 canvas 模式下用于获取上下文,两个都写上最保险。
对应的 wxss:
.chart-box { width: 100%; height: 500rpx; } .ec-canvas { width: 100%; height: 100%; }提示:
ec-canvas组件内部已经把 canvas 设成 100% 宽高了,你不需要也不能直接给 canvas 节点写样式。要给高度就给外层chart-box。
2.2 自定义构建压体积:勾选清单比操作步骤重要
官方仓库那份echarts.js是"大而全"的,因为作者要保证各种图表类型开箱可用。但你的项目可能只用柱状图和折线图,那完全可以自己构建一份。
操作路径是从 ECharts 官网进入在线定制页面,按需勾选。这里有个容易被忽略的点:在线定制工具给出的默认包是浏览器版本,它包含了 DOM 相关的适配代码。所以更稳的路子是——用官方仓库的echarts.js作为基线,对照体积分析工具找出占体积最大的模块,然后只在你确实需要精简的时候才走定制路线,并且定制完成后必须回归测试 tooltip、legend、地图注册这三块最容易出问题的功能。
勾选的时候,图表类型按需选,但下面这些必须保留:
- CanvasRenderer:小程序里只能用 canvas 渲染,SVG 渲染器直接删掉。
- Grid / Axis:直角坐标系的基础,柱状折线全靠它。
- Tooltip:不勾这个,你的 tooltip 配置写了也白写。
- Legend:多系列图表基本都要图例。
像Graph、TreeMap、Sunburst这种冷门图表,除非明确要上,否则一律不勾。
2.3 主包还是分包:2MB 红线下的取舍
目前小程序主包上限 2MB,整个项目所有分包合计上限 20MB,单个分包上限也是 2MB。ECharts 加上你的业务代码,主包很容易顶到天花板。
我的实际做法是:把ec-canvas和所有图表页面放进同一个分包。这样主包只保留首页和基础框架,图表页作为一个独立的加载单元。配置大概是:
{ "subPackages": [ { "root": "packageChart", "pages": [ "pages/report/report", "pages/dashboard/dashboard" ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["packageChart"] } } }preloadRule是关键。分包默认是用户跳过去才开始下载,会有明显的等待。配置了预加载之后,用户停留在首页的时候分包就在后台悄悄下好了,点进去几乎是瞬间打开。这个细节在图表页这种"点进去就要看数据"的场景里,体验差别非常明显。
如果你的ec-canvas需要在多个分包之间复用,那就放主包,然后接受 1MB 左右的体积占用——这时候自定义构建就不是可选项,是必需品了。
3. 第一条折线图跑通:从 ec.onInit 到 setOption 的完整链路
3.1 onInit 回调里的三个参数别浪费
最基础也是最经典的初始化写法是这样的:
import * as echarts from '../../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(getOption()); return chart; } Page({ data: { ec: { onInit: initChart } } });这里有三个细节值得单独说。
第一,devicePixelRatio必须传。不传的话,在 iPhone 这类高像素密度设备上,图表会糊得像糊了一层毛玻璃。因为 canvas 的物理像素数需要按 dpr 放大,ECharts 才能画出锐利的线条。ec-canvas已经把 dpr 算好了递给你,不用就浪费了。
第二,canvas.setChart(chart)这一行不能省。它是把 ECharts 实例反向绑定到 canvas 上的关键,省掉之后触摸事件无法正确路由,表现出来就是"图表能看不能碰"。
第三,onInit只会执行一次,返回的 chart 实例必须自己存起来。很多人的写法是this.chart = initChart(...),但onInit是框架回调,你拿不到返回值,正确做法是在回调内部this.chart = chart——注意此时this的指向问题,建议把 init 函数写在 Page 对象的方法里,或者用闭包把实例挂到一个共享变量上。
3.2 数据更新:绝对不要重新 init
新手最容易犯的错是:数据变了,就把ec.onInit换成一个新函数,指望重新渲染。结果图表闪烁、内存泄漏、触摸事件错乱三连。
正确的姿势只有一条:保持 chart 实例不变,只调setOption。
Page({ data: { ec: { onInit: null }, chart: null }, onLoad() { const self = this; this.setData({ ec: { onInit: function (canvas, width, height, dpr) { const chart = echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption(buildOption(self.data.rawList)); self.chart = chart; return chart; } } }); }, refresh(newList) { if (!this.chart) return; this.chart.setOption(buildOption(newList), false); } });setOption的第二个参数notMerge要慎重。传false(默认)表示增量合并,适合只更新 series 的 data;传true表示完全替换,适合图表结构发生变化的场景(比如从柱状图切成折线图)。我踩过的坑是:切换图表类型时用了增量合并,结果旧的 series 残留在图上,画出来一个柱状折线叠在一起的怪物。
3.3 lazyLoad 模式什么时候该打开
ec.onInit的问题是:页面一渲染,所有图表同时初始化。如果一个页面有四个图表,首屏会明显顿一下。
lazyLoad模式把初始化时机交给你控制:
Page({ data: { ec: { lazyLoad: true } }, onReady() { this.selectComponent('#sales-chart').init((canvas, width, height, dpr) => { const chart = echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption(getOption()); this.chart = chart; return chart; }); } });什么时候值得开?两种情况:一是页面上图表数量超过两个;二是图表在首屏之下,用户需要滚动才能看到。第二种情况配合IntersectionObserver做懒加载,效果最好——滚到可视区域再初始化。
但要注意,lazyLoad模式下同一个组件调用两次init会出问题。如果你确实需要重新初始化(比如换了图表类型),先chart.dispose()再重新init。
4. 真机上才暴露的问题:尺寸、像素比与懒加载
4.1 canvas type="2d" 与同层渲染的边界
ec-canvas内部用的是<canvas type="2d">。这个"2d"不是指绘图上下文类型,而是微信提供的新版 canvas 接口。它比旧版有几个明显优势:支持同层渲染(不再永远浮在所有元素之上)、性能更好、支持requestAnimationFrame。
但同层渲染有前提。iOS 上需要基础库版本支持,安卓上部分老机型也有兼容问题。所以你会遇到一个经典现象:开发者工具里弹窗能正常盖在图表上,真机上一弹窗就被图表挡住一半。
遇到这种情况,三条路:
- 把弹窗换成
cover-view体系(麻烦,但兼容性最好)。 - 检查基础库版本,把最低版本卡到支持同层渲染的档位。
- 把图表放进自己的层级容器,弹窗用页面级遮罩而不是局部定位元素。
我个人偏好第三条,改动成本最低,而且视觉上更符合用户预期。
4.2 尺寸计算里 rpx 和 px 的坑
ec-canvas递过来的width和height是px,不是 rpx。这一点在写自适应逻辑时必须记住。
大部分人不需要关心这个,因为尺寸是从真实布局量出来的。但有两种场景必须自己算:
一种是图表内部元素(比如自定义的 label 字号)要跟随屏幕宽度缩放。这时候你要手动把 rpx 转成 px,公式是rpx * (屏幕宽度 / 750),屏幕宽度通过wx.getSystemInfoSync().windowWidth拿。
另一种是从 H5 项目迁移过来的人。H5 里常用pxtorem或postcss-px-to-viewport做全站适配,想着小程序里也能照搬——抱歉,ECharts 画在 canvas 上,所有 CSS 单位换算对它完全无效。你写在 option 里的fontSize: 12就是 12 个逻辑像素,跟 rem、vw、rpx 一个都不沾边。想让字号自适应,只能在 JS 里算好再塞进 option。这也是很多从 Vue H5 项目迁过来的同学调试半天找不出原因的地方。
4.3 tab 切换和页面返回后的图表空白
这个坑我踩过两次,每次都要重新查一遍。
现象是:页面 A 有一个图,用户切到 tab B 再切回来,图表消失了。原因是页面被隐藏时,canvas 的绘制内容在某些机型上会被回收,而 chart 实例以为自己还在正常状态。
解决方案是在onShow里检查并重绘:
onShow() { if (this.chart) { this.chart.resize(); this.chart.setOption(buildOption(this.data.rawList), true); } }resize()负责重新测量尺寸,setOption带true负责强制重绘。两个都要,只做一个有时候不生效。
顺带一提,如果你在同一个页面用 tab 切换不同图表,标题栏文字也要跟着变,用wx.setNavigationBarTitle({ title: '销售趋势' })就够了,别忘了在onShow里恢复成默认标题,否则用户返回其他页面时会看到残留的文字。
5. 图表类型逐个攻破:饼图、雷达图、地图
5.1 饼图标签重叠与末尾元素的偏移
饼图在小程序上是投诉率最高的图表类型,因为屏幕窄,标签极容易互相压。
ECharts 5.3 之后引入了一组专门解决这个问题的配置,我建议直接用:
series: [{ type: 'pie', radius: ['35%', '62%'], center: ['50%', '48%'], avoidLabelOverlap: true, label: { show: true, alignTo: 'edge', edgeDistance: 8, minMargin: 6, bleedMargin: 6, formatter: '{b}\n{d}%' }, labelLine: { length: 8, length2: 12, smooth: false, maxSurfaceAngle: 70 }, data: pieData }]逐个解释为什么这么设。
alignTo: 'edge'是把所有标签对齐到画布左右边缘,而不是沿着扇形半径方向散开。这一条是解决重叠的核心开关,比手动调labelLine.length有效十倍。
minMargin控制标签之间的最小垂直间距,bleedMargin控制标签距离画布边缘的最小距离。窄屏上把这两个值调小一点,能让更多标签排得下。
maxSurfaceAngle限制引导线第二段的倾斜角度,防止在顶部和底部出现那种几乎水平的横线,视觉上很丑。
至于"引导线末尾小圆点位置偏移",本质上是labelLine两段长度和标签文本宽度三者不匹配导致的。length是从扇形出来的第一段(不带角度),length2是拐弯之后的第二段,如果你在标签里用了 rich 文本拼接自定义符号(比如一个圆点加数字),那这个圆点会被算进文本宽度里,alignTo: 'edge'对齐的时候就偏了。处理办法是给 rich 里的小圆点单独设置padding和lineHeight,或者干脆不用额外符号,用labelLine本身来表达指向。
上线前一定要在最小屏(比如 iPhone SE 尺寸)和最大屏各看一遍,饼图是最吃屏幕宽度的图表类型。
5.2 雷达图在小屏上的收缩策略
雷达图的坑少一些,但有两个参数值得注意:
radar: { center: ['50%', '52%'], radius: '62%', splitNumber: 4, axisName: { fontSize: 10, color: '#666', padding: [2, 4] } }radius用百分比不用具体像素,这是必须的。splitNumber从默认的 5 降到 4,是因为小屏上网格太密会让数据线看起来糊成一团。
axisName.fontSize设 10 是经验值。雷达图的轴名称通常比较长,长度超过四五个字就必然会重叠,所以更根本的解法是在数据层面把维度名称缩短——"移动端访问量"改成"移动端",能省一半空间。
5.3 中国地图:registerMap 与本地 JSON 的硬约束
地图是这套体系里最特殊的一块。因为 ECharts 5 已经不再内置任何地图数据了,你必须自己注册。
import * as echarts from '../../ec-canvas/echarts'; import chinaJson from '../../ec-canvas/map/china.json'; echarts.registerMap('china', chinaJson); // option 里 series: [{ type: 'map', map: 'china', roam: true, label: { show: false }, emphasis: { label: { show: true }, itemStyle: { areaColor: '#f5a623' } }, data: provinceData }]这里有一个很多人踩过的坑:registerMap必须在setOption之前执行,而且必须只执行一次。如果放在onInit回调里面,页面有多张地图的话就会重复注册,轻则浪费内存,重则报错。
另外,小程序不能像网页那样用fetch去远程拉 JSON 文件,所以地图数据必须放在本地,用import或require引进来,这又会吃掉一部分包体积。一个省级地图的 JSON 通常在几百 KB 量级,全国地图更大,放分包几乎是必须的。
注意:地图数据来源要合规、可追溯,不要随手在网上找一个来源不明的 JSON 文件就用。上线前务必确认所用地图数据的合法性,以及是否有对应的审图号要求。这件事在项目排期里要提前问清楚,不要等到提审前一天才发现。
还有一个容易忽略的点:roam: true开启缩放拖动之后,会和页面的纵向滚动打架。用户想上下滑页面,结果把地图拖走了。折中方案是默认关掉roam,在地图角上放一个"放大查看"的按钮,点开之后进全屏再开启交互。
6. Tooltip、坐标轴刻度这些细节为什么在真机上失效
6.1 tooltip 是画在 canvas 上的,不是 HTML
这一点必须先建立认知:小程序 ECharts 的 tooltip 是 zrender 用 canvas 画出来的矩形和文字,不是 DOM 元素。所以:
extraCssText里的 CSS 全部无效。borderRadius、backgroundColor这类属性可以用,但它们是 ECharts 自己的绘制参数,不是 CSS。- rich 富文本在 tooltip 里支持得很有限,别指望
{a|文本}这种语法。
那自动换行怎么办?最稳的方案是在formatter里手动折行:
tooltip: { trigger: 'axis', confine: true, formatter: function (params) { const lines = params.map(function (p) { return p.seriesName + ': ' + p.value; }); return lines.join('\n'); } }用\n拼接,ECharts 会按行渲染,这就是小程序里最可靠的"自动换行"。如果你非要按宽度折行,只能在 formatter 里手动算字符数——中文按 1 个字、英文数字按 0.5 个字估算,超过阈值就插\n。这个方法不优雅,但实测有效。
confine: true强烈建议加上。它保证 tooltip 不会超出画布边界,在小屏上尤其重要,不加的话鼠标(手指)靠近边缘时 tooltip 会被裁掉一半。
6.2 x 轴刻度拥挤的处理顺序
刻度问题的处理有个顺序,按这个顺序调效率最高:
| 优先级 | 配置 | 适用场景 |
|---|---|---|
| 1 | axisLabel.hideOverlap: true | 标签自动隐藏重叠项,最省事 |
| 2 | axisLabel.interval: 'auto' | 让 ECharts 自己决定隔几个显示 |
| 3 | axisLabel.rotate: 45 | 日期类长标签,倾斜之后能排下更多 |
| 4 | axisLabel.formatter | 自定义截断,比如只显示月日 |
| 5 | dataZoom | 数据点超过 30 个时的正解 |
前三条基本上能解决 80% 的拥挤问题。但如果数据点超过 30 个,无论怎么调标签都会变成一坨黑线,这时候唯一正确的做法是上dataZoom:
dataZoom: [{ type: 'inside', start: 60, end: 100 }]type: 'inside'表示用手指在图上滑动来平移,不需要额外的滑块组件,在小屏上体验最自然。默认显示最后 40% 的数据,用户想看更早的自己划。
6.3 数据大屏在小程序里的适配思路
有人问过能不能在小程序里做数据可视化大屏。能,但要想清楚定位。
大屏的本质是"信息密度高、展示为主、交互为辅"。小程序屏幕就那么大,硬塞十个图表的结果是每个都看不清。我的建议是拆成两层:首屏放三个核心指标卡片加一个主趋势图,其余图表往下滚。滚动区域用scroll-view,但要给图表区域加catchtouchmove的阻断处理,否则图表内部的手势会和滚动冲突。
另外大屏配色不要照搬网页版的深色科技风。小程序整体是浅色背景,黑色底的图表嵌进去非常突兀。用浅色底、加粗的数值、克制的强调色,视觉效果反而更专业。
7. 性能与体积的最后一道关
7.1 动画和数据的取舍
图表动画在网页上很讨喜,在小程序里是性能杀手,尤其是低端安卓机。上线版本建议这样配:
animation: false, animationDuration: 300, animationEasing: 'cubicOut'只在数据首次加载时保留短动画,后续的setOption全部关掉动画。做法是把animation放在 option 里,首次构建时设true,刷新时用单独的 option 对象把animation设成false。
数据量大的时候还要开抽样:
series: [{ type: 'line', sampling: 'lttb', showSymbol: false, data: bigData }]sampling: 'lttb'是大数据量折线的标配,它会在保持视觉形状的前提下抽掉大量点位,一千个点能压到一百多个,渲染速度快一个数量级。showSymbol: false也很重要,一千个点每个都画个小圆点,光绘制符号就能把帧率拖垮。
7.2 多图表同页的资源复用
一个页面上有多个图表时,能复用的一定要复用:
- option 的公共部分抽成函数。坐标轴、网格、tooltip 的样式基本一致,抽出来之后每个图表只传 series 和个性化配置,代码量和维护成本都能降下来。
- 颜色主题统一注册。用
echarts.registerTheme注册一次主题,init的时候传进去,避免每个图表重复写颜色。 - 提前算好的数据不要在 setOption 里做。排序、筛选、单位换算这些放在 JS 里算完再塞进去,
setOption只负责渲染。
7.3 上线前的自查清单
真机测完这一遍,基本能避开九成的线上问题:
- 主包体积是否接近 2MB 红线,图表代码是否已放分包。
- 最小屏和最大屏各看一遍,饼图标签有没有重叠。
- 切 tab 再切回来,图表是否还在。
- 弱网环境下首次进入图表页,有没有加载态提示。
- 深色模式下文字颜色是否还能看清(如果项目支持深色模式)。
- 弹窗、悬浮按钮能否正常盖在图表上。
- 地图数据来源是否合规,审图号要求是否已确认。
- iOS 和安卓各测一台低端机,观察首屏渲染耗时。
我在实际项目里的体会是,ECharts 在小程序里最难的部分从来不是写 option,而是搞清楚"哪些是浏览器思维、哪些是小程序思维"。当你意识到 canvas 是一块孤岛、尺寸靠推不靠读、样式靠 JS 不靠 CSS 之后,剩下的事情基本都是查文档和调参数的体力活。最后分享一个小技巧:把每个图表的 option 单独放到一个 JS 文件里导出一个函数,参数是数据,返回是 option。这样调试的时候可以直接在函数里加console.log,改了立刻能看到效果,比在页面里翻半天找配置快得多。