先把结论放在前面:在 uniapp 的 Vue 页面里用 echarts 绘制堆叠柱状图,真正卡住你的通常不是 echarts 的配置项,而是“图表到底在哪里初始化、容器为什么是 0 高度、换了端为什么就白屏”这一串环境问题。堆叠柱状图本身倒很简单,series.stack一句话的事,难点全在 uniapp 的跨端渲染机制上。
这篇文章不是什么官方文档复述,而是我在实际业务页面里跑通这套东西之后的一次完整梳理。适合两类人看:一是用过 echarts 但第一次在 uniapp 里接图表的开发者,二是需要在 H5 和 App 端同时输出可视化页面的同学。我会把依赖安装、初始化时机、堆叠配置、动态更新、多端坑点一次讲清楚,最后给一份可以直接复制到项目里的完整页面代码。
1. 先想清楚:在 uniapp 里选哪种方式接 echarts
1.1 echarts 渲染依赖 DOM/canvas,而 uniapp 各端差异巨大
echarts 常规使用方式是通过一个 DOM 节点初始化:
const chart = echarts.init(document.getElementById('chart'));初始化之后,echarts 会在该节点内部创建 canvas 或 svg 并进行绘制。这套逻辑在浏览器环境里没有任何问题,但 uniapp 是跨端框架,同一套 Vue 代码要编译到 H5、App、小程序等多个平台,这些平台的渲染环境完全不是一回事。
- H5 端就是浏览器,DOM 天然可用,常规初始化方式没问题。
- App 端如果用的是 Vue 页面(而不是 nvue),页面实际跑在 WebView 里,也有 DOM 环境,但节点获取的时机、ref 指向的行为和浏览器略有差异。
- 小程序端没有 DOM 概念,只有
canvas节点,echarts.init(dom)这套逻辑直接失效。
所以这里要有一个基本认知:不存在一套代码不加修改就能在所有端把 echarts 跑起来。你可以通过条件编译把不同端的初始化逻辑分开写,但前提是先搞清楚每条路的原理。
1.2 三种主流接入方案的取舍
我在项目里对比过几种常见接入方式,简单整理一下:
| 方案 | 适用端 | 初始化方式 | 优点 | 缺点 |
|---|---|---|---|---|
| npm 引入 echarts,直接绑 DOM 节点 | H5、App Vue 页面 | echarts.init(this.$refs.chart) | 写法最接近 echarts 官网示例,调试方便 | 小程序端不可用 |
| renderjs 方式 | App、H5 | 在<script lang="renderjs">中操作视图层 DOM | 图表运行在视图层,和逻辑层交互更流畅 | 不能编译到小程序,理解成本略高 |
| echarts-for-weixin 或封装组件 | 微信小程序 | 通过 canvas 2d 节点初始化 | 能在小程序端跑起来 | 初始化流程、事件绑定都和常规 echarts 不同 |
我的选型结论是:如果主要目标是 H5 和 App 端,直接用第一种方案,也就是 npm 引入 echarts,在 Vue 页面的onReady生命周期里初始化。原因很简单,代码最好维护、报错最好排查、和 echarts 官方示例最接近。
如果业务必须覆盖微信小程序端,再单独给小程序页面写一套 canvas 适配逻辑,或者直接用封装好的图表组件。业务上各端可以有不同的技术实现,不需要强行一套代码走天下。
1.3 图表代码到底跑在哪个层,决定了你怎么调 bug
在 uniapp 里经常有人遇到“H5 好好的,真机上就废了”的情况。理解这个问题,要稍微提一下 uniapp 的逻辑层和视图层。
在 App 端和小程序端,业务逻辑和 dom 渲染并不是同一层,逻辑层通过消息通知视图层更新界面。当你在逻辑层拿到一个 ref 或者发起一次 DOM 查询时,拿到的东西可能只是视图层节点的描述,不一定是可操作的 DOM 对象。
Vue 页面在 App 端跑在 WebView 中,情况比小程序好很多,但依然不要在onLoad里急着初始化图表。onLoad只代表页面逻辑层准备好了,不代表视图层已经渲染完成,这个时候拿到的容器宽度往往是 0。这也是本文后续所有代码都坚持在onReady里初始化的原因。
2. 基础环境:安装依赖、放置容器、保证宽高
2.1 安装 echarts 依赖与导入方式
项目根目录执行:
npm install echarts --saveH5 和 App 端都可以直接用 npm 包。导入方式有两种,一种是全量导入:
import * as echarts from 'echarts';另一种是按需导入,可以明显减小打包体积:
import { use } from 'echarts/core'; import { BarChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; use([BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); import * as echarts from 'echarts/core';按需导入的写法在 H5 和 App 端都好使。如果你的项目里只用到柱状图这一种图表,强烈建议按需导入,全量 echarts 的包体积确实不小。
注意:如果后续要跑微信小程序端,npm 包在微信小程序里需要通过小程序的 npm 构建流程才能用,而且初始化方式不是
echarts.init(dom),这个问题我会在第 5 章单独说。
2.2 容器节点和宽高的坑
图表的容器必须是一个有明确宽高的元素。很多第一次在 uniapp 里接 echarts 的人,页面写出来了,console 也不报错,但图表就是白屏,十有八九是容器的宽高为 0。
<template> <view class="page"> <view class="chart-box"> <view ref="stackChart" class="chart" id="stackChart"></view> </view> </view> </template> <style scoped> .page { width: 100%; height: 100vh; display: flex; flex-direction: column; } .chart-box { width: 100%; height: 400rpx; padding: 20rpx; box-sizing: border-box; } .chart { width: 100%; height: 100%; } </style>这里有几个细节值得解释一下。
- 容器要放在一个有确定高度的父级里,否则你用
height: 100%可能依然算不出高度。我给图表外层套了一个固定400rpx的chart-box,这是最省事且最稳定的做法。 rpx是 uniapp 的响应式单位,但 echarts 内部绘制用的是 px 逻辑。这没关系,echarts 会自动根据容器尺寸换算,你不需要手动把 rpx 转 px。- 如果图表需要自适应屏幕宽度,
width: 100%就够了,但父级千万不要加overflow: hidden,有些安卓 WebView 在容器尺寸变化后,canvas 重绘会异常。
2.3 初始化时机:为什么推荐 onReady 而不是 mounted
在 uniapp 的页面生命周期里,mounted是组件挂载完成时触发,onReady是页面初次渲染完成时触发。从实际效果看,onReady更稳妥。
我在项目里测试过,同样一段初始化代码放在mounted里,H5 端偶尔能成功,但有时拿到的容器宽度是 0;放到onReady里,基本不会出现这个问题。原因就是onReady触发时,页面的布局已经完成,容器有了真实尺寸。
export default { data() { return { chart: null }; }, onReady() { this.initChart(); }, methods: { initChart() { if (!this.$refs.stackChart) return; this.chart = echarts.init(this.$refs.stackChart); this.chart.setOption(this.buildOption()); } } };这里我判断了一下this.$refs.stackChart是否存在,防止某些极端时序下节点还没渲染出来。如果 ref 拿不到,也可以退一步用document.getElementById('stackChart'),App 端 Vue 页面里可用,但更推荐把初始化动作放到onReady里来规避这个问题。
2.4 一个能跑通的最小示例
到这里,其实已经能画出一个最基础的堆叠柱状图了。完整流程是:写容器、装依赖、onReady里 init、setOption。
buildOption() { return { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: ['基础量', '增量'] }, xAxis: { type: 'category', data: ['一月', '二月', '三月', '四月'] }, yAxis: { type: 'value' }, series: [ { name: '基础量', type: 'bar', stack: 'total', data: [320, 332, 301, 334] }, { name: '增量', type: 'bar', stack: 'total', data: [120, 132, 101, 134] } ] }; }先别急着往下看,把这套代码放进页面里跑一遍。跑通了,说明你的环境没问题,后面堆叠配置的细节才有意义。
3. 堆叠柱状图的配置拆解:理解 stack 才能画对
3.1 series.stack 字段的工作原理
堆叠柱状图的唯一关键点,就是series里每个柱状系列要有相同的stack值。
{ name: '基础量', type: 'bar', stack: 'total', data: [320, 332, 301, 334] }, { name: '增量', type: 'bar', stack: 'total', data: [120, 132, 101, 134] }当多个 series 的stack字符串相同时,echarts 会把它们画在同一根柱子上。第一段是基础量的值,第二段从基础量的顶部继续向上累加,最终这根柱子的总高度是两者之和。
这里有个容易误解的地方:stack不是“合并数据”,而是“共享同一根柱子的绘制空间”。它是按 series 在数组中的顺序一层一层往上叠的,先声明的系列绘制在底部,后声明的系列绘制在上层。所以如果你希望“基础量”在下面,“增量”在上面,series 的数组顺序就不能乱。
我习惯用一个生活化的类比:这就像把几本书叠在同一格子,第一本放最下面,第二本放第一本上面,第三本再压上去。格子还是同一个,但每本书的厚度各不相同。
3.2 一份可直接套用的完整配置
下面这份配置,是业务页面里最常见的堆叠柱状图形态,我把常用参数全部标出来了。
buildOption() { return { color: ['#4A90E2', '#50E3C2', '#F5A623', '#D0021B'], tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: ['线上销售', '线下销售', '分销渠道'], top: 10 }, grid: { left: '3%', right: '4%', bottom: '3%', top: 60, containLabel: true }, xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'], axisLabel: { interval: 0 } }, yAxis: { type: 'value', name: '销量' }, series: [ { name: '线上销售', type: 'bar', stack: 'sales', barMaxWidth: 40, data: [120, 200, 150, 80] }, { name: '线下销售', type: 'bar', stack: 'sales', data: [50, 70, 90, 130] }, { name: '分销渠道', type: 'bar', stack: 'sales', data: [30, 40, 60, 20] } ] }; }参数按功能划分说明一下:
color数组控制各系列的颜色顺序,对应 legend 的显示顺序。tooltip.trigger用axis,鼠标或触摸悬停在某个 x 轴分类上时,会一次性展示该分类下所有堆叠系列的数据;axisPointer.type: 'shadow'会显示一个阴影背景,视觉上更自然。grid控制图表绘制区域,containLabel: true很关键,它保证 y 轴的文字标签不会被裁掉。barMaxWidth限制柱子最大宽度。移动端屏幕窄,不限制宽度的话,柱子可能宽得很难看。stack三个系列的取值都是'sales',所以他们会堆叠在一起。
配完这个,堆叠柱状图就已经完整了。你可以在series数组里加更多系列,只要stack一致,就会继续往上叠。
3.3 分组堆叠:两组数据在一个图表里并存
还有一种常见场景是:同一个图表里有两组不同的堆叠柱,比如 A 产品和 B 产品,各自有线上、线下两个渠道。这时不能所有系列都用同一个stack,否则 A 和 B 会叠成一根柱子。
正确做法是给不同分组设置不同的stack值:
series: [ { name: 'A-线上', type: 'bar', stack: 'groupA', data: [30, 50, 40] }, { name: 'A-线下', type: 'bar', stack: 'groupA', data: [20, 30, 25] }, { name: 'B-线上', type: 'bar', stack: 'groupB', data: [45, 55, 60] }, { name: 'B-线下', type: 'bar', stack: 'groupB', data: [15, 25, 30] } ]效果是:A 的两段数据叠成一根柱子,B 的两段数据叠成旁边一根柱子,两组柱子并排显示。这里的核心就一句话:stack 值相同则叠加,stack 值不同则各画各的。
3.4 堆叠图的进阶增强:总计、百分比、圆角
基础堆叠配好之后,业务上还会冒出三个高频需求。
第一个是显示每根柱子的总计值。echarts 没有提供“总计”这种内置系列,但可以在最上层的系列上开启 label,用 formatter 把所有系列同一下标的数据加起来:
{ name: '分销渠道', type: 'bar', stack: 'sales', data: [30, 40, 60, 20], label: { show: true, position: 'top', formatter: (params) => { const index = params.dataIndex; const total = [120, 200, 150, 80][index] + [50, 70, 90, 130][index] + [30, 40, 60, 20][index]; return total; } } }实际项目中建议把 series 数据抽成变量,这样 formatter 里可以直接汇总,而不是像我示例里这样硬编码。
第二个是百分比堆叠。需求是“每个分类下各渠道的占比”。处理方式是在 setOption 之前先把数据转成百分比:
const rawData = [ [120, 50, 30], [200, 70, 40], [150, 90, 60], [80, 130, 20] ]; const percentData = rawData.map(group => { const sum = group.reduce((a, b) => a + b, 0); return group.map(v => (sum ? +(v / sum * 100).toFixed(2) : 0)); });然后把 yAxis 的最大值固定为 100,数值格式化加一个%后缀:
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }series 的 data 换成percentData的对应行即可。注意除零判断,所有值都是 0 时,直接把百分比置 0,否则会算出 NaN。
第三个是柱子的圆角。普通柱状图可以给itemStyle.borderRadius设置圆角,但堆叠图要小心:如果每一层都设置四角圆角,柱子中间会出现弧形接缝,很丑。
建议只给最上层系列设置顶部圆角,例如后声明的系列在最上层,给它设置:
itemStyle: { borderRadius: [6, 6, 0, 0] }如果最上层的某一项数据为 0,圆角会显得很突兀,这时可以用函数形式:
itemStyle: { borderRadius: (params) => params.value === 0 ? 0 : [6, 6, 0, 0] }4. 动态数据更新:setOption 的正确打开方式
4.1 接口数据回流后的局部更新
图表初始化之后,大部分场景不是一次性画完,而是等待接口数据返回后再渲染,或者在不同筛选条件之间切换。这时候不需要重新 init,直接调用同一个实例的setOption方法即可。
updateChart(data) { this.chart.setOption({ xAxis: { data: data.categories }, series: data.series }); }setOption默认是增量合并模式,你只传变化的部分,echarts 会自动把剩余配置保留下来。也就是说,之前设置的tooltip、legend、grid这些配置不会被冲掉。
这里有一个新手容易踩的坑:series 更新时,如果不传 name,echarts 是按 series 数组的索引去匹配的。如果你的新旧 series 顺序不一致,更新后图表里的图例和数据就对不上了。所以我建议在 series 里始终带上name,让 echarts 通过名称匹配,而不是依赖顺序。
4.2 notMerge 与 lazyUpdate 的取舍
setOption的第二个参数是一个配置对象,有两个字段在实战中很重要:
this.chart.setOption(newOption, { notMerge: false, lazyUpdate: false });notMerge: true表示完全替换配置,而不是增量合并。适合“整个图表结构都变了”的场景,比如从堆叠柱状图切换成折线图。代价是 echarts 需要重建内部组件,性能开销更大,而且之前一些组件状态会被重置。lazyUpdate: true表示延迟更新,如果在一次事件循环里连续调用多次setOption,echarts 会合并成一次渲染。适合数据频繁刷新的看板页面。
我的建议是:普通接口数据更新用默认值就够了,一堆参数不用管。只有当你发现页面高频刷新、图表明显卡顿时,再去考虑lazyUpdate和进一步优化。
4.3 轮询刷新时的防抖与清理
有些实时监控页面需要每隔几秒拉一次数据。要特别注意定时器的清理,否则页面切走了定时器还在跑,轻则浪费资源,重则报错。
data() { return { chart: null, timer: null }; }, methods: { startPolling() { if (this.timer) return; this.timer = setInterval(() => { this.fetchData(); }, 30000); }, stopPolling() { if (this.timer) { clearInterval(this.timer); this.timer = null; } }, async fetchData() { const result = await this.getChartData(); this.updateChart(result); } }, onUnload() { this.stopPolling(); }如果你的接口返回特别快,但页面渲染跟不上,可以在 fetchData 里加一层节流控制,或者利用lazyUpdate让 echarts 自己合并短时间内的多次更新。
4.4 页面切换与实例销毁
页面卸载时,最好把 echarts 实例销毁掉,否则在单页应用或者频繁跳转的页面里,canvas 节点被移除但实例还在,容易出现内存泄漏和告警。
onUnload() { if (this.chart) { this.chart.dispose(); this.chart = null; } this.stopPolling(); }这里有一个容易忽视的场景:tabBar 页面切换时页面并不会销毁。如果你的图表在 tabBar 页面上,切走再切回来,实例还在,不需要重新 init,但可能需要调用resize()让图表适配新的屏幕尺寸。如果此时你又执行了一遍initChart(),控制台会报There is a chart instance already initialized on the dom。为了避免这种情况,init 前先做一次判断:
if (echarts.getInstanceByDom(this.$refs.stackChart)) return;5. 我在多端实跑时踩过的坑
5.1 白屏问题的完整排查链路
遇到白屏,按这个顺序排查,基本都能定位。
第一步,先确认节点存在。在onReady里打印一下:
onReady() { console.log(this.$refs.stackChart); this.initChart(); }如果打印出来是undefined,说明节点还没渲染好。尤其检查一下是不是在onLoad里初始化的,换成onReady通常能解决。
第二步,确认容器有宽高。用 uniapp 的createSelectorQuery看一下:
uni.createSelectorQuery() .select('.chart') .boundingClientRect(res => { console.log(res.width, res.height); }) .exec();如果宽高是 0,去看父级样式。最常见的两个原因:父级没有显式高度,或者<view>被display: none了。还有一种是页面整体高度设置成了100%,但 page 根节点本身没有高度,链条断了。
第三步,确认 echarts.init 没报错。白屏但 console 有红字,通常是初始化时传入的节点不是合法 DOM。H5 端如果this.$refs拿到的是 uniapp 包装后的节点,可以用document.getElementById兜底。
第四步,确认 setOption 的数据没把图表搞崩。比如 series 里某项 data 是undefined,echarts 不会报 JS 错误,但画出来就是空的。
5.2 小程序端为什么不能直接用 echarts.init
这是跨端最常踩的坑。微信小程序端没有完整的 DOM API,canvas 只是一个节点,echarts 常规的初始化方式这里完全失效。如果你在小程序页面里写echarts.init(this.$refs.stackChart),通常会报各种节点类型错误。
小程序端的适配思路是:拿到 canvas 节点之后,直接传给 echarts 作为初始化参数。以 echarts-for-weixin 用到的 canvas 2d 方式为例,核心流程是这样:
<canvas type="2d" id="stackChart" class="chart"></canvas>onReady() { const query = uni.createSelectorQuery().in(this); query .select('#stackChart') .fields({ node: true, size: true }) .exec(res => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const chart = echarts.init(canvas, null, { width: res[0].width, height: res[0].height }); chart.setOption(this.buildOption()); }); }注意 canvas 必须加type="2d",uni.createSelectorQuery().in(this)里的in(this)在自定义组件场景下尤其重要,不要漏。
如果你用的是 uniapp 编译到微信小程序,更省事的做法是直接用插件市场里的图表组件,内部已经把 canvas 适配封装好了。但这也意味着你失去了 echarts 原生的灵活性。我的建议是:小程序端单独评估,不要为了“一套代码”强行打通所有端。
5.3 真机上报错:图表实例已经存在
真机调试时经常遇到这个报错:There is a chart instance already initialized on the dom。
原因很简单:某个生命周期里图表被初始化了两次。常见触发场景有两个:一是onReady和mounted都写了初始化逻辑;二是同一个容器节点被多个页面的代码复用缓存。
解决方式前面已经提过,init 之前检查实例:
const dom = this.$refs.stackChart; if (!dom) return; const existing = echarts.getInstanceByDom(dom); if (existing) { this.chart = existing; return; } this.chart = echarts.init(dom);这样可以保证同一个节点不会被重复初始化,同时也不影响已有的实例继续使用。
5.4 体积优化:全量引入看起来没啥,首包很疼
全量引入 echarts,压缩后体积在一兆左右,对 H5 页面来说,这个体积会直接拖慢首屏加载。移动端网络环境不稳定时体验更明显。
按需引入之后,只画柱状图的页面包体积能压缩到原来的一半甚至更少。前面第 2 章的按需引入代码适合直接抄用。记住一个原则:用到哪个图就注册哪个图,用到哪个组件就注册哪个组件。
如果项目里同时需要折线图、柱状图、饼图,比如做一个数据看板,可以这样注册:
use([ BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer ]);这样就只打包了这几类图表相关代码,其他 echarts 模块不会进入产物。
6. 完整示例:可直接复制到项目里的页面代码
下面的代码是一个完整的单文件页面示例。包含初始化、基础堆叠柱状图、动态数据更新三个部分。你把它复制到一个 Vue 页面里,onReady 后就能直接看到效果。
<template> <view class="page"> <view class="chart-box"> <view ref="stackChart" class="chart" id="stackChart"></view> </view> <button class="update-btn" @click="handleUpdate">更新数据</button> </view> </template> <script> import { use } from 'echarts/core'; import { BarChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; use([BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); import * as echarts from 'echarts/core'; export default { data() { return { chart: null, defaultSeries: [ { name: '线上销售', type: 'bar', stack: 'sales', barMaxWidth: 40, data: [120, 200, 150, 80] }, { name: '线下销售', type: 'bar', stack: 'sales', data: [50, 70, 90, 130] }, { name: '分销渠道', type: 'bar', stack: 'sales', data: [30, 40, 60, 20] } ] }; }, onReady() { this.initChart(); }, onUnload() { if (this.chart) { this.chart.dispose(); this.chart = null; } }, methods: { initChart() { const dom = this.$refs.stackChart; if (!dom) return; const exist = echarts.getInstanceByDom(dom); if (exist) { this.chart = exist; } else { this.chart = echarts.init(dom); } this.chart.setOption(this.buildOption()); }, buildOption() { return { color: ['#4A90E2', '#50E3C2', '#F5A623'], tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: ['线上销售', '线下销售', '分销渠道'], top: 10 }, grid: { left: '3%', right: '4%', bottom: '3%', top: 60, containLabel: true }, xAxis: { type: 'category', data: ['Q1', 'Q2', 'Q3', 'Q4'] }, yAxis: { type: 'value', name: '销量' }, series: this.defaultSeries }; }, handleUpdate() { const newData = [ [180, 90, 50], [220, 110, 30], [170, 70, 80], [90, 160, 40] ]; this.chart.setOption({ series: this.defaultSeries.map((series, index) => ({ ...series, data: newData[index] })) }); } } }; </script> <style scoped> .page { width: 100%; height: 100vh; display: flex; flex-direction: column; padding: 20rpx; box-sizing: border-box; background-color: #f7f8fa; } .chart-box { width: 100%; height: 420rpx; background-color: #ffffff; border-radius: 16rpx; padding: 10rpx; box-sizing: border-box; } .chart { width: 100%; height: 100%; } .update-btn { margin-top: 40rpx; } </style>如果你用的是 Vue 3 的<script setup>语法,把this.$refs.stackChart换成模板 ref 变量即可,其它逻辑不需要改动。
最后再分享一个我自己的习惯:永远把“初始化图表”和“更新图表”拆成两个函数。初始化里只做echarts.init和第一次setOption,更新函数里只做增量setOption。这样做的好处是,后续接接口、做筛选、做联动的时候,你会发现自己根本不需要碰初始化逻辑,只需在更新函数里处理数据映射就够了。我第一次做看板页面时把这两件事写在一起,后面每次调整数据逻辑都得小心翼翼,拆开之后整个世界都清爽了。