产品经理指着屏幕说"这条线太硬了,能不能柔和一点",我顺手加了smooth: true,结果曲线像喝多了,最低点直接跑到负值区间,业务方当场问"我们销量怎么可能为负"。这件事之后我把 Vue 里引入 ECharts 画折线图、平滑曲线图,以及平滑背后那套三次贝塞尔的数学公式完整捋了一遍。这篇内容适合三类人:刚上手 Vue 需要做数据可视化的前端、被"平滑曲线看着不对"困扰的开发者、以及想把埋点数据反推成趋势公式做预测的同学。折线图本身很简单,真正花时间的是那些文档里不写的边界情况——容器没高度、打包后布局错位、万级数据卡顿、曲线过冲。下面按我实际踩坑的顺序展开。
1. Vue 项目里引入 ECharts,先把地基打对
1.1 全量引入和按需引入,体积账要算清楚
安装没什么可说的,npm i echarts一条命令。真正值得聊的是引入方式。全量引入就一行:
import * as echarts from 'echarts';方便是方便,但打包后 ECharts 会实打实占掉几百 KB。我实测过一个 Vue3 + Vite 的中后台项目,全量引入后 vendor 包里 ECharts 占约 1MB(未压缩),gzip 之后也在 300KB 上下。如果这个页面只需要折线图和柱状图,这笔开销是浪费。
按需引入的写法是分别引核心和图表类型:
import * as echarts from 'echarts/core'; import { LineChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer ]);这里有个坑我踩过:use()里漏注册组件,图表不会报错,而是静默不显示。表现是"图上有线但没坐标轴"或者"tooltip 死活不出来"。排查方法很简单,把组件名对着官方文档的模块列表一个个核。我现在的习惯是先在开发环境全量引入把功能跑通,确认没问题再改成按需,最后对比一次构建产物体积,确认收益。
还有一个容易忽略的点:echarts/core和echarts两个包同时被引入,会导致核心代码被打进去两份。用import * as echarts from 'echarts/core'之后,就不要再从echarts里引任何东西,包括类型。类型声明也要走echarts/core。
1.2 容器没高度,等于白画
这是我带新人时见到的最高频问题:代码全对,页面空白,控制台没报错。原因几乎都是容器div没有高度。
<!-- 错误示范:高度为 0,canvas 画出来也看不见 --> <div ref="chartRef"></div> <!-- 正确示范 --> <div ref="chartRef" style="width: 100%; height: 360px;"></div>ECharts 初始化时读取容器的clientWidth和clientHeight,如果读到 0,它不会抛异常,只是在 0×0 的画布上画了一遍。所以判断标准很明确:图表不显示时,第一步永远是console.log(el.clientWidth, el.clientHeight)。
宽度撑不满也是同一个道理。父容器用 flex 布局时,子元素默认min-width: auto,内容会把容器撑出预期宽度。解决办法是在图表容器的父级加min-width: 0;,让 flex 允许收缩。
1.3 初始化和销毁必须成对出现
Vue3 组合式 API 里我的标准写法是这样:
import { ref, onMounted, onUnmounted, shallowRef } from 'vue'; import * as echarts from 'echarts/core'; const chartRef = ref(null); const chartInstance = shallowRef(null); onMounted(() => { chartInstance.value = echarts.init(chartRef.value); chartInstance.value.setOption(option); window.addEventListener('resize', handleResize); }); onUnmounted(() => { window.removeEventListener('resize', handleResize); chartInstance.value?.dispose(); chartInstance.value = null; });两个细节值得说明。第一,实例用shallowRef而不是ref。ECharts 实例内部有大量循环引用和 DOM 引用,用ref会被 Vue 深度代理,性能下降不说,某些版本还会报奇怪的错。第二,dispose()之后一定要把变量置空,否则组件卸载后如果有异步回调(比如接口返回慢了)继续调setOption,会报 "Instance is disposed"。
resize监听我更推荐用ResizeObserver替代window.resize,因为侧边栏折叠、Tab 切换这类容器尺寸变化不会触发 window 的 resize 事件:
const ro = new ResizeObserver(() => chartInstance.value?.resize()); ro.observe(chartRef.value);卸载时记得ro.disconnect()。
2. 一条折线图的 option,逐字段拆开看
2.1 xAxis、yAxis、series 这三根柱子
ECharts 的配置项看起来多,本质上就是回答三个问题:横轴是什么、纵轴是什么、数据怎么连。最小可用的折线图配置如下:
const option = { xAxis: { type: 'category', data: ['1月', '2月', '3月', '4月', '5月', '6月'] }, yAxis: { type: 'value' }, series: [ { name: '订单量', type: 'line', data: [820, 932, 901, 934, 1290, 1330] } ] };xAxis.type有两个常见值:category(类目轴,等距排布)和time(时间轴,按真实时间间隔排布)。很多人做日期图表时用了category,结果数据缺了几天,横轴却看不出来——因为类目轴把所有点等距摊开了。只要 x 轴是时间且数据可能不连续,就该用time,并且数据格式写成[timestamp, value]。
yAxis.type常见的还有log(对数轴)。做增长类数据时特别有用,因为线性轴下早期数据会被压成一条贴地的直线。
2.2 数据的三种投喂方式
第一种是series.data直接给数组,最简单。第二种是给对象数组,格式{ value: [x, y], name: '标签' },适合需要在 tooltip 里显示额外信息的场景。第三种是用dataset统一管理,把原始二维表交给 ECharts 自己解析:
const option = { dataset: { source: [ ['月份', '订单量', '退款量'], ['1月', 820, 30], ['2月', 932, 45] ] }, xAxis: { type: 'category' }, yAxis: { type: 'value' }, series: [ { type: 'line', encode: { x: '月份', y: '订单量' } }, { type: 'line', encode: { x: '月份', y: '退款量' } } ] };dataset的好处是数据和样式彻底分离,接口返回的表格结构可以原样丢进去。坏处是排查问题时要多绕一层encode的映射关系。我个人的取舍是:多条系列 + 数据来自后端表格就用 dataset,单条系列或者数据结构特殊(比如带自定义标记点)就直接写 series.data。
2.3 那些影响观感但常被忽略的字段
boundaryGap控制折线两端是否留白。类目轴默认是true,线从第一个类目的中间开始;改成false后线会贴着边界,看起来更"满"。折线图我一般设成false。
axisTick.alignWithLabel和axisLabel.interval决定刻度标签是否和刻度线对齐、是否隔项显示。数据点多的时候标签会挤成一团,interval: 'auto'是默认行为,但如果你想强制每 5 个显示一个,可以写函数:
axisLabel: { interval: (index) => index % 5 === 0 }showSymbol决定是否显示每个数据点的圆点。数据点超过 50 个时建议设为false,只留线;否则满屏圆点,比折线还扎眼。
关于填充渐变,折线下方那块半透明色的做法是:
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(64, 158, 255, 0.45)' }, { offset: 1, color: 'rgba(64, 158, 255, 0.02)' } ]) }注意echarts.graphic在按需引入模式下也要从echarts/core里取,别写成从echarts取,否则又会引入重复的核心包。
2.4 双 Y 轴:yAxisIndex 的配对规则
业务里经常遇到"订单金额(万元)"和"订单数(笔)"画在一张图上,量级差几百倍。这时候单 Y 轴会让小的那条线贴地。解决办法是配两个 yAxis:
yAxis: [ { type: 'value', name: '金额(万元)' }, { type: 'value', name: '笔数', splitLine: { show: false } } ], series: [ { name: '金额', type: 'line', yAxisIndex: 0, data: [...] }, { name: '笔数', type: 'line', yAxisIndex: 1, data: [...] } ]规则很直白:series.yAxisIndex对应yAxis数组的下标。有几个细节要注意:第二个 yAxis 我习惯关掉splitLine,否则两套网格线交叉,图看着很乱;左右两个轴的min、max最好手动指定,不然 ECharts 自动计算的刻度可能让你得出错误结论。还有一点,双轴图有天然的误导性,两条线在某个点"交叉"完全是刻度缩放造成的视觉巧合,做汇报时最好在说明里讲清楚。
3. smooth 到底动了什么:从折角到三次贝塞尔
3.1 折角是从哪来的
先把问题说清楚。折线图的相邻两段是直线,在连接点处左右两段的斜率不一样,也就是一阶导数不连续。视觉上,斜率突变的地方就是尖角。人眼对这个非常敏感,所以数据稍微抖动,图看起来就"毛刺感"很重。
要消除尖角,就得让连接点两侧的切线方向一致,也就是让曲线在该点一阶连续(C1 连续)。工程上最省事的做法是用分段三次曲线去穿过所有数据点——因为三次多项式刚好有四个自由度,可以同时锁定两个端点和两个端点处的切线方向。
3.2 Catmull-Rom 到三次贝塞尔的换算
三次贝塞尔曲线由四个点定义:起点 P1、终点 P2、两个控制点 C1 和 C2。参数方程是:
B(t) = (1-t)³ · P1 + 3(1-t)²t · C1 + 3(1-t)t² · C2 + t³ · P2对它求导,t = 0 处的切向量是3(C1 - P1)。这个式子说明一件事:起点处的切线方向完全由 C1 相对 P1 的位置决定。所以只要保证相邻两段在共享点处算出的切线方向相同,曲线就不会有折角。
Catmull-Rom 样条给出了一个非常经典的取法。对于数据点 P1(它的前一个点是 P0,后一个点是 P2),把这条线段转成三次贝塞尔时,控制点取:
C1 = P1 + (P2 - P0) / 6 C2 = P2 - (P3 - P1) / 6这里的 P3 是 P2 后面的那个点,P0 是 P1 前面的点。看着有点绕,但逻辑很清楚:某点的切线方向用它的前后邻居连线来近似,系数 1/6 是经过验证的经验值,能让曲线在视觉上最贴近"自然过渡"。首段和末段没有前驱或后继点,直接用端点本身做控制点,退化成直线。
把这个公式写成代码,可以直接算出每一段贝塞尔的四个坐标:
function catmullRomToBezier(points, tension = 0.5) { const segs = []; const n = points.length; const k = tension / 3; // tension=0.5 时,等价于系数 1/6 for (let i = 0; i < n - 1; i++) { const p0 = points[i - 1] || points[i]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || points[i + 1]; const c1 = { x: p1.x + (p2.x - p0.x) * k, y: p1.y + (p2.y - p0.y) * k }; const c2 = { x: p2.x - (p3.x - p1.x) * k, y: p2.y - (p3.y - p1.y) * k }; segs.push({ p1, c1, c2, p2 }); } return segs; }拿到segs之后,无论画 SVG 的path还是 Canvas 的bezierCurveTo,都只是拼字符串的事。
3.3 ECharts 的 smooth 是同一族思路
ECharts 内部的平滑实现走的也是"切线方向 + 邻边长度加权"这一类方案,只是具体系数是它自己的实现细节。用几何语言描述的话,可以这样理解:
对内部点 Pi,先算前一段长度 a = |Pi - P(i-1)|、后一段长度 b = |P(i+1) - Pi|,把前后邻居连线方向d = P(i+1) - P(i-1)归一化成单位切向量 t。然后第 i 段的两个控制点按段长比例分摊:
C1 = Pi + t_i · (s · b) C2 = P(i+1) - t_(i+1) · (s · b)其中 s 就是smooth的取值。自定义实现可以这样写:
function smoothControlPoints(points, smooth = 0.3) { const n = points.length; const dist = (a, b) => Math.hypot(b.x - a.x, b.y - a.y); const tan = []; for (let i = 0; i < n; i++) { if (i === 0) { const dx = points[1].x - points[0].x; const dy = points[1].y - points[0].y; const len = Math.hypot(dx, dy) || 1; tan[i] = { x: dx / len, y: dy / len }; } else if (i === n - 1) { const dx = points[n - 1].x - points[n - 2].x; const dy = points[n - 1].y - points[n - 2].y; const len = Math.hypot(dx, dy) || 1; tan[i] = { x: dx / len, y: dy / len }; } else { const dx = points[i + 1].x - points[i - 1].x; const dy = points[i + 1].y - points[i - 1].y; const len = Math.hypot(dx, dy) || 1; tan[i] = { x: dx / len, y: dy / len }; } } const segs = []; for (let i = 0; i < n - 1; i++) { const segLen = dist(points[i], points[i + 1]); const c1 = { x: points[i].x + tan[i].x * segLen * smooth, y: points[i].y + tan[i].y * segLen * smooth }; const c2 = { x: points[i + 1].x - tan[i + 1].x * segLen * smooth, y: points[i + 1].y - tan[i + 1].y * segLen * smooth }; segs.push({ p1: points[i], c1, c2, p2: points[i + 1] }); } return segs; }把结果画到 Canvas 上,跟smooth: 0.3的 ECharts 曲线并列摆放,肉眼几乎分不出差别。这也算是对公式理解的一次验证——能自己写出来,才算真的懂了。
用贝塞尔求值公式可以在任意 t 处取点,用来做曲线上的标记点或者判断曲线是否穿过某个阈值:
function bezierAt(seg, t) { const mt = 1 - t; const a = mt * mt * mt; const b = 3 * mt * mt * t; const c = 3 * mt * t * t; const d = t * t * t; return { x: a * seg.p1.x + b * seg.c1.x + c * seg.c2.x + d * seg.p2.x, y: a * seg.p1.y + b * seg.c1.y + c * seg.c2.y + d * seg.p2.y }; }3.4 过冲:曲线跑到负数是怎么发生的
这就是我开头遇到的那个问题。原因在于,平滑只约束了切线方向,没有约束曲线的取值范围。当两个数据点之间出现一个很陡的起伏,控制点被拉得很远,三次贝塞尔在中间段就可能越出两端点的 y 区间,也就是所谓的过冲(overshoot)。
举个直观的例子:三个点分别是 (0, 100)、(1, 2)、(2, 100)。中间的深坑会让 t 在 0.5 附近把曲线压到比 2 还低,甚至穿过 0。销量、库存、人数这类天然非负的指标,出现负值就非常尴尬。
处理的思路有三条,我一般组合使用:
- 把 smooth 调小。0.2 到 0.4 是大多数场景的安全区间。
smooth: true等价于 0.3,我见过不少人写成smooth: 1,那曲线基本就飞了。 - 手动钳制控制点的 y 值,让它落在相邻两端点 y 的区间内。这是自定义 Canvas 实现时的做法。
- 业务上根本不该平滑的时候就别平滑。比如日活、订单量这种波动本身就是信息,平滑反而是掩盖问题。
// 控制点 y 钳制:避免曲线越过相邻端点的值域 const lo = Math.min(p1.y, p2.y); const hi = Math.max(p1.y, p2.y); c1.y = Math.min(hi, Math.max(lo, c1.y)); c2.y = Math.min(hi, Math.max(lo, c2.y));注意:钳制之后再画,曲线在极值点附近会有一点点"扁"的手感,这是为了保证不过冲必须付出的代价。数据可视化的第一原则是别撒谎,好看要排在准确之后。
4. 反过来:把一串数据点反推成数学公式
4.1 什么场景真的需要"公式化"
平滑是把"点"变成"曲线",严格说只是画法变了,数据本身没变。另一类需求更实在:给定一批采样点,想得到一个函数表达式。常见场景有几种——实验数据要写进报告里需要给出经验公式;做趋势外推,想估算下个季度的量级;把一条曲线压缩成几个参数传给后端,减少传输量。
这类问题的通解是最小二乘拟合。核心思想是:先假设函数形式(多项式、指数、对数),再找一组参数,让所有数据点到曲线的竖直距离平方和最小。平方而不是绝对值,是因为平方可导,能解析求导得到线性方程组。
4.2 多项式拟合的 JS 实现
假设要找 n 次多项式y = c0 + c1·x + c2·x² + … + cn·xⁿ,最小二乘会导出正规方程(AᵀA)c = Aᵀy,其中 A 是范德蒙矩阵。直接构造并求解:
// 高斯消元解线性方程组 function solve(A, b) { const n = b.length; const M = A.map((row, i) => [...row, b[i]]); for (let col = 0; col < n; col++) { let pivot = col; for (let r = col + 1; r < n; r++) { if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r; } [M[col], M[pivot]] = [M[pivot], M[col]]; if (Math.abs(M[col][col]) < 1e-12) continue; for (let r = 0; r < n; r++) { if (r === col) continue; const factor = M[r][col] / M[col][col]; for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c]; } } return M.map((row, i) => row[n] / row[i]); } function polyFit(xs, ys, degree) { const m = degree + 1; const A = Array.from({ length: m }, () => new Array(m).fill(0)); const b = new Array(m).fill(0); for (let i = 0; i < m; i++) { for (let j = 0; j < m; j++) { let s = 0; for (let k = 0; k < xs.length; k++) s += Math.pow(xs[k], i + j); A[i][j] = s; } let t = 0; for (let k = 0; k < xs.length; k++) t += Math.pow(xs[k], i) * ys[k]; b[i] = t; } return solve(A, b); // [c0, c1, ..., cn] }调用polyFit(xs, ys, 2)就得到二次拟合的系数。
这里必须提醒一个实践中的大坑:x 的数值范围一旦偏大(比如时间戳直接当 x),幂次一上去,x^n会变成天文数字,正规方程的条件数爆炸,解出来的系数全是噪声。我踩过这个坑,同一批数据用时间戳直接拟合,三次多项式算出来是一条诡异的振荡线;把 x 归一化成(x - mean) / std,立刻恢复正常。
const mean = xs.reduce((a, b) => a + b, 0) / xs.length; const std = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length) || 1; const nx = xs.map(v => (v - mean) / std);预测时把新的 x 用同样的 mean 和 std 变换过去,再代入系数。
4.3 指数增长和幂律:先变换再拟合
很多业务数据的形态是指数型(用户增长、病毒传播)或幂律型。这类数据直接上多项式会很别扭,要么次数高到过拟合,要么拟合得一塌糊涂。正确做法是先做变换,把非线性变成线性。
指数模型y = a·e^(bx),两边取自然对数得ln y = ln a + b·x。对(x, ln y)做一次线性拟合,得到截距ln a和斜率b,再还原a = e^(截距)。
function expFit(xs, ys) { const lny = ys.map(v => Math.log(Math.max(v, 1e-9))); const [intercept, slope] = polyFit(xs, lny, 1); return { a: Math.exp(intercept), b: slope }; }注意:
Math.log(0)是-Infinity,会直接把整个拟合毁掉。采样值里出现 0 的情况很常见(某天没有订单),所以上面加了Math.max(v, 1e-9)兜底。这个细节几乎每份教程都不提,但线上一定会遇到。
幂律模型y = a·x^b同理,两边取对数后ln y = ln a + b·ln x,对(ln x, ln y)做线性拟合。
4.4 怎么判断拟合得好不好,以及次数怎么选
判断标准是决定系数 R²,计算方式是1 - SS_res / SS_tot,其中SS_res是残差平方和,SS_tot是相对均值的总平方和。R² 越接近 1 拟合越好。
function rSquared(xs, ys, coef) { const predict = x => coef.reduce((sum, c, i) => sum + c * Math.pow(x, i), 0); const mean = ys.reduce((a, b) => a + b, 0) / ys.length; let ssRes = 0, ssTot = 0; for (let i = 0; i < xs.length; i++) { ssRes += (ys[i] - predict(xs[i])) ** 2; ssTot += (ys[i] - mean) ** 2; } return 1 - ssRes / ssTot; }关于次数选择,我的经验是:先用多项式次数 1 和 2 试,R² 提升不明显就说明数据本身关系简单,别硬往上加。次数每加 1,参数就多一个,拟合曲线会越来越贴合训练数据,但在数据区间之外会剧烈发散。经典的例子是 7 个点用 6 次多项式一定完美穿过每一个点,R² = 1,但两点之间的形状完全不受控制——这就是过拟合。
一个更务实的判断方法是留出验证集:把最后 20% 的点不参与拟合,用拟合出的公式去预测它们,看误差。如果训练集 R² 是 0.98 而验证集误差大得离谱,就是过拟合无疑。
把公式画回 ECharts 的做法是:在原有 series 之外再推一条type: 'line',数据为公式算出来的密集采样点,lineStyle设为虚线、showSymbol: false、smooth: false,图例里标成"趋势拟合"。
series: [ { name: '实际', type: 'line', data: raw }, { name: '趋势拟合', type: 'line', showSymbol: false, smooth: false, lineStyle: { type: 'dashed', width: 2 }, data: denseXs.map(x => [x, predict(x)]) } ]5. 那些真正消耗时间的坑
5.1 tooltip 内容太长,一行撑出屏幕
默认 tooltip 是white-space: nowrap,内容长了会横向撑出去,在图边缘直接溢出。做法是两处配合:
tooltip: { trigger: 'axis', confine: true, extraCssText: 'max-width: 300px; white-space: normal; word-break: break-all;', formatter(params) { const list = Array.isArray(params) ? params : [params]; return list .map(p => `${p.marker}${p.seriesName}<br/>${p.name}:${p.value}`) .join('<br/>'); } }confine: true让 tooltip 被限制在图表容器内,靠近边缘时自动换边。white-space: normal配合max-width才会真正换行。自定义formatter返回 HTML 字符串时,<br/>是唯一可靠的换行方式,\n在 HTML 渲染模式下无效。
5.2 打包后布局异常,以及 pxtorem 为什么对 ECharts 失灵
"本地开发好好的,打包上线图表宽度就不对",这个问题我遇到过两次,原因完全不同。
第一次是 CSS 顺序问题。ECharts 的容器宽度来自父级,如果打包后 CSS 压缩合并改变了加载顺序,父级的flex: 1被另一条更高优先级的规则覆盖,容器宽度就变了。解决办法是给图表容器显式设一个min-width,或者用 ResizeObserver 兜住后续变化。
第二次跟postcss-pxtorem有关。这个插件会把 CSS 里的 px 自动转成 rem,但 ECharts 的尺寸是运行时用 JS 算的,走的是 canvas 的 px,插件管不着。听起来没问题,问题出在它把 echarts 源码里内联样式和 className 相关的一部分 px 也转了,导致 tooltip、图例的内部布局错乱。
// postcss.config.js module.exports = { plugins: { 'postcss-pxtorem': { rootValue: 37.5, propList: ['*'], // 关键:排除依赖包里的样式 exclude: /node_modules/ } } };如果用selectorBlackList,可以指定.echarts-前缀来排除 ECharts 注入的样式。另外要清楚一点:根字号变化后,图表容器尺寸变了,必须主动调resize(),ECharts 不会自己感知 rem 变化。
5.3 万级数据点:不开降采样就是卡
我做过一个每 10 秒刷新一次、单条线 2 万个点的监控面板,不优化的话刷新一次页面卡半秒。几个开关效果立竿见影:
series: [{ type: 'line', sampling: 'lttb', // 最大保留三角桶降采样,视觉几乎无损 large: true, largeThreshold: 2000, showSymbol: false, animation: false, lineStyle: { width: 1.5 } }]sampling: 'lttb'是最推荐的,它按面积重要性筛点,能在保留曲线形态的前提下把点降到千级。animation: false一定要关,大数据量下的入场动画纯粹是负担。progressive和progressiveThreshold适合超大数据集分批渲染,但会带来渐进显现的效果,实时大屏上我一般不用。
还有个小技巧:dataZoom配合inside类型能让用户鼠标滚轮缩放,比默认的滑块占用更少的视觉空间。
5.4 定时刷新引起的泄漏
实时曲线最常见的写法是setInterval里调setOption,刷新数据。这里有两个隐患。
一个是定时器没清。组件卸载后定时器还在跑,实例已经dispose()了,setOption每次都抛错。一定要在onUnmounted里clearInterval。
另一个是setOption的合并行为。默认它是合并模式,新传的series会和旧的按索引合并。如果你每次刷新都传不同数量的 series,就会残留幽灵系列。做实时曲线时我建议改成整体替换:
chartInstance.value.setOption(option, { notMerge: true });代价是每次刷新都会重建所有元素,闪烁感稍强。想两全其美的话,把series写成固定长度,只更新 data,这样默认合并模式反而是最平滑的:
chartInstance.value.setOption({ series: [{ data: newData }] });6. 封成一个团队能直接复用的组件
6.1 组合式函数:把初始化那套样板收起来
上面那些初始化、resize、销毁的代码,每个图表写一遍太啰嗦。抽成组合式函数:
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue'; import * as echarts from 'echarts/core'; export function useChart(optionRef, theme = null) { const el = ref(null); const chart = shallowRef(null); let ro = null; const render = () => { if (!chart.value || !optionRef.value) return; chart.value.setOption(optionRef.value, { notMerge: false }); }; onMounted(() => { chart.value = echarts.init(el.value, theme); render(); ro = new ResizeObserver(() => chart.value?.resize()); ro.observe(el.value); }); watch(optionRef, render, { deep: true }); onUnmounted(() => { ro?.disconnect(); chart.value?.dispose(); chart.value = null; }); return { el, chart }; }组件里就只剩配置和数据:
<template> <div ref="el" style="width: 100%; height: 360px;"></div> </template>watch这里有个取舍:deep: true能捕捉到series.data数组的原地修改,但数据量大时深度遍历本身就有开销。如果确定每次都整个替换option,把deep关掉、只监听引用变化更划算。
6.2 主题和暗黑模式切换
主题在init时传入,运行中没法直接换。切换暗黑模式的做法是销毁重建:
function switchTheme(name) { const el = chart.value.getDom(); chart.value.dispose(); chart.value = echarts.init(el, name); chart.value.setOption(optionRef.value, { notMerge: true }); }如果不想重建,另一种做法是定义两套颜色变量,切换时只setOption覆盖颜色相关的部分:
chart.value.setOption({ backgroundColor: isDark ? '#0f1115' : '#ffffff', xAxis: { axisLabel: { color: isDark ? '#9aa4b2' : '#5a6672' } }, yAxis: { splitLine: { lineStyle: { color: isDark ? '#232a35' : '#eef1f5' } } } });这种方式没有重建开销,曲线不会闪一下,我更喜欢。缺点是颜色配置分散在组件里,要靠约定维护。
6.3 几个我固定会加的默认配置
封装组件时,有些默认值我几乎每个项目都要加上,索性固化进去:
const baseOption = { grid: { left: 12, right: 20, top: 32, bottom: 8, containLabel: true }, tooltip: { trigger: 'axis', axisPointer: { type: 'line' } }, legend: { icon: 'roundRect', itemWidth: 10, itemHeight: 10 } };containLabel: true是我最推荐的一个,它能保证坐标轴标签不被裁掉。不加这个,y 轴数值大的时候左边的数字会被切一半。grid的四个方向值也不用调得太精细,containLabel会兜底。
我个人的体会是,ECharts 的配置项确实多,但真正常用的就那么二三十个。折线图这条线上,值得反复琢磨的只有三件事:数据格式和坐标轴类型是否匹配、平滑在数学上做了什么、以及数据量大或结构复杂时的性能开关。把这三点吃透,剩下的基本是查文档的体力活。至于把数据反推成公式,我的建议是把它当成探索工具而不是结论——拟合出来的 R² 再漂亮,它也只是对已有数据的一种解释方式,拿去做外推预测时永远留出安全边际。