React与ECharts自定义系列实战:从零封装温度计图表组件
1. 为什么温度计这种小图表反而值得单独封装温度计组件在前端可视化需求里属于那种看起来不起眼、真做起来坑不少的类型。很多人的第一反应是ECharts 里没有温度计图表类型那我用个仪表盘或者进度条凑合一下不就行了结果做出来的东西要么刻度对不上要么液柱高度和温度值之间的映射关系全靠硬编码改一个量程就得重调一遍。我在几个数据看板类项目里都遇到过类似需求——设备温度监控、冷链运输状态、机房环境监测场景不同但核心诉求高度一致给定一个温度值和一个量程范围用直观的液柱高度表达出来同时刻度、颜色、数值标注都要准确。ECharts 本身没有原生的温度计图表类型但它提供的自定义系列custom series能力足够让我们从零画出一个温度计。相比用仪表盘gauge改造自定义系列的好处是渲染逻辑完全可控液柱的圆角、球泡的弧度、刻度的疏密都能按设计稿精确还原。而相比纯 SVG 或 Canvas 手写ECharts 帮我们处理了坐标系映射、动画过渡、响应式重绘这些脏活我们只需要关注怎么把温度值翻译成图形这一件事。这篇文章面向的是已经会用 React 和 ECharts 做基础图表、但没怎么碰过自定义系列的开发者。我会从坐标系设计讲起把液柱高度计算、球泡绘制、刻度生成、颜色分段这几个核心环节拆开揉碎再给出一个可以直接复用的 React 组件封装方案。中间会穿插我在实际项目里踩过的坑比如量程切换时动画错乱、负温度显示异常、容器尺寸变化导致球泡变形这些问题以及对应的解决思路。提示本文假设你使用的是 ECharts 5.x 版本React 16.8 以上需要用到 Hooks。如果你还在用 ECharts 4.x自定义系列的 API 有较大差异建议先升级。2. 温度计的坐标系设计把温度值映射到像素高度2.1 为什么不能直接用直角坐标系ECharts 的直角坐标系grid默认是给折线图、柱状图用的它的 y 轴是从下往上递增的数值轴。温度计看起来好像也能用——把液柱当成一个柱子y 轴设成温度范围不就行了我一开始就是这么想的但很快发现两个问题。第一个问题是球泡区域无法用直角坐标系表达。温度计底部的球泡是一个圆形它和上方的液柱是连通的液柱的宽度远小于球泡直径。在直角坐标系里一个柱子的宽度是固定的你没法让同一个数据项在底部突然变宽成一个圆。第二个问题是刻度的位置。温度计的刻度通常只画在液柱右侧而且刻度线长度、标签对齐方式都和标准坐标轴不一样用 axis 配置很难精确控制。所以正确的做法是放弃直角坐标系使用自定义系列在一个自定义的坐标系里手动计算每个图形元素的像素位置。ECharts 的自定义系列允许你通过renderItem函数返回任意图形元素矩形、圆、路径、文本等每个元素的坐标由你自己算。这就给了我们完全的自由度。2.2 量程与像素的线性映射温度计的核心映射关系很简单温度值T在量程[Tmin, Tmax]内对应液柱高度H在像素范围[0, Hmax]内。线性映射公式H (T - Tmin) / (Tmax - Tmin) * Hmax其中Hmax是液柱的最大可绘制高度它等于容器总高度减去球泡直径再减去顶部留白。这里有个容易忽略的细节球泡的中心点位置。球泡不是贴在容器最底部的它通常有一个固定的半径R圆心在(cx, height - R - padding)处。液柱的底部应该从球泡圆心开始往上延伸而不是从球泡底部开始否则液柱和球泡之间会出现一段空白。我在第一次实现时就犯了这个问题液柱从球泡底部开始画结果温度低的时候液柱和球泡之间断开了看起来像两个独立的图形。正确的做法是让液柱的矩形底部与球泡圆心对齐这样无论温度多低液柱和球泡始终是连通的。2.3 负温度的处理如果量程包含负温度比如 -20°C 到 50°C映射公式本身没问题但视觉上需要额外处理。零度线应该有一个明显的标记而且液柱在零度以下时颜色通常要区分开比如用蓝色表示低温。我的做法是在renderItem里判断当前温度是否低于零度如果是液柱颜色切换到冷色系同时在零度位置画一条虚线作为参考线。还有一个边界情况当温度恰好等于Tmin时液柱高度为 0此时球泡里应该仍然有颜色填充表示温度计还在工作而不是完全空白。我通常会给液柱设一个最小高度比如 2px保证视觉上不会消失。3. 用 renderItem 画出液柱、球泡和刻度3.1 renderItem 的返回值结构ECharts 自定义系列的renderItem函数接收params和api两个参数返回一个或多个图形元素定义。每个元素定义的基本结构是{ type: rect, // 图形类型rect, circle, path, text 等 shape: { ... }, // 图形的几何属性 style: { ... }, // 样式填充色、描边、透明度等 position: [x, y], // 可选图形的位置偏移 z: 10 // 层级数值越大越靠上 }params里包含当前数据项的信息最重要的是params.dataIndex和params.data。api提供了api.getWidth()和api.getHeight()来获取容器尺寸以及api.getZr()获取底层 zrender 实例。3.2 液柱矩形的绘制液柱是一个圆角矩形宽度固定比如 20px高度由温度映射计算得出。关键代码如下const liquidHeight (value - min) / (max - min) * maxLiquidHeight; const liquidRect { type: rect, shape: { x: cx - liquidWidth / 2, y: bulbCenterY - liquidHeight, width: liquidWidth, height: liquidHeight, r: liquidWidth / 2 // 圆角半径 }, style: { fill: liquidColor, shadowBlur: 8, shadowColor: rgba(0,0,0,0.15) }, z: 5 };注意y坐标是bulbCenterY - liquidHeight因为 ECharts 的坐标原点在左上角y 轴向下递增。液柱的顶部在bulbCenterY - liquidHeight底部在bulbCenterY这样就和球泡圆心对齐了。3.3 球泡的绘制与层级关系球泡是一个圆形圆心在(cx, bulbCenterY)半径R。它需要画在液柱下面z 值更小这样液柱的底部会被球泡遮住看起来是连通的。但球泡本身需要有一个描边表示玻璃外壳。所以实际上要画两个圆一个填充圆底色和一个描边圆边框。const bulbFill { type: circle, shape: { cx, cy: bulbCenterY, r: bulbRadius }, style: { fill: bulbColor }, z: 3 }; const bulbStroke { type: circle, shape: { cx, cy: bulbCenterY, r: bulbRadius }, style: { fill: transparent, stroke: #ccc, lineWidth: 2 }, z: 6 };液柱的 z 值设为 5介于球泡填充3和球泡描边6之间这样液柱会盖住球泡填充但被球泡描边盖住视觉上液柱像是从球泡里长出来的。3.4 刻度的动态生成刻度线是温度计可读性的关键。我的做法是根据量程范围自动决定刻度间隔如果量程跨度小于 20每 2 度一个刻度20 到 50 之间每 5 度一个刻度大于 50每 10 度一个刻度。这样既能保证刻度不过密也不会太稀疏。每个刻度由一条短横线和一段文字组成。横线从液柱右侧边缘开始向右延伸 8px文字在横线右侧再偏移 4px 处垂直居中对齐。文字内容就是温度值加上单位符号。const ticks []; for (let t min; t max; t step) { const y bulbCenterY - (t - min) / (max - min) * maxLiquidHeight; ticks.push({ type: line, shape: { x1: cx liquidWidth / 2 4, y1: y, x2: cx liquidWidth / 2 12, y2: y }, style: { stroke: #999, lineWidth: 1 }, z: 4 }); ticks.push({ type: text, style: { text: ${t}°, x: cx liquidWidth / 2 16, y: y, fill: #666, fontSize: 11, textVerticalAlign: middle }, z: 4 }); }这里有个细节text元素的x和y是文本的锚点位置默认锚点在文本左上角。要让文字垂直居中于刻度线需要设置textVerticalAlign: middle。水平方向我通常用左对齐这样所有刻度文字的左边缘对齐看起来整齐。4. React 组件封装从配置到渲染的完整链路4.1 组件 Props 设计一个可复用的温度计组件应该暴露哪些 props我根据实际项目经验总结了这几个核心配置Prop 名类型默认值说明valuenumber25当前温度值minnumber-20量程下限maxnumber50量程上限unitstring°C温度单位widthnumber120组件宽度pxheightnumber300组件高度pxsegmentsarray[]颜色分段配置showTicksbooleantrue是否显示刻度animatedbooleantrue是否启用动画segments是一个数组每项包含from、to、color三个字段用来定义不同温度区间的液柱颜色。比如[{ from: -20, to: 0, color: #4A90D9 }, { from: 0, to: 30, color: #5CB85C }, { from: 30, to: 50, color: #D9534F }]。如果segments为空则使用单一默认颜色。4.2 用 useRef 管理 ECharts 实例在 React 里集成 ECharts 的标准做法是用useRef保存 DOM 容器和 ECharts 实例用useEffect在挂载时初始化、在卸载时销毁。关键代码如下import React, { useRef, useEffect } from react; import * as echarts from echarts; function Thermometer({ value, min, max, unit, width, height, segments, showTicks, animated }) { const containerRef useRef(null); const chartRef useRef(null); useEffect(() { if (!containerRef.current) return; chartRef.current echarts.init(containerRef.current); return () { chartRef.current.dispose(); chartRef.current null; }; }, []); useEffect(() { if (!chartRef.current) return; const option buildOption({ value, min, max, unit, segments, showTicks, animated }); chartRef.current.setOption(option, { notMerge: true }); }, [value, min, max, unit, segments, showTicks, animated]); return div ref{containerRef} style{{ width, height }} /; }注意第二个useEffect里用了notMerge: true。这是因为温度计的配置项里包含大量动态生成的图形元素如果合并更新旧的刻度线和液柱可能不会被正确清除导致重影。用notMerge每次全量替换配置虽然性能开销略大但能保证渲染结果正确。对于温度计这种更新频率不高的组件通常几秒一次这点开销完全可以接受。4.3 容器尺寸变化的处理ECharts 实例初始化时会读取容器的实际尺寸。如果容器尺寸在初始化之后发生变化比如父级布局调整、窗口缩放图表不会自动重绘液柱和球泡的位置就会错乱。解决办法是监听容器尺寸变化调用chartRef.current.resize()。我通常用ResizeObserver来监听容器尺寸useEffect(() { if (!containerRef.current || !chartRef.current) return; const observer new ResizeObserver(() { chartRef.current?.resize(); }); observer.observe(containerRef.current); return () observer.disconnect(); }, []);这里有个坑ResizeObserver的回调触发频率很高如果每次触发都调用resize()在快速拖拽窗口时可能导致性能问题。我的做法是加一个简单的防抖比如 100ms 内只执行一次。另外resize()之后需要重新setOption因为自定义系列的图形位置是基于旧尺寸计算的不重新计算的话液柱高度不会跟着变。4.4 动画过渡的实现ECharts 自定义系列默认不支持数据更新时的过渡动画因为每个图形元素都是你手动创建的ECharts 不知道它们之间的对应关系。要实现液柱高度平滑过渡有两个方案。方案一是用 ECharts 的animation配置配合transition。在renderItem返回的元素里加上transition: [shape]然后在setOption时用replaceMerge策略。这个方案在 ECharts 5.x 里支持得比较好但需要保证每次生成的元素顺序一致。方案二是手动用requestAnimationFrame做数值插值。在组件内部维护一个displayValue状态当valueprop 变化时用 rAF 在 300ms 内从旧值渐变到新值每次插值后重新setOption。这个方案更可控但代码量稍大。我一般用方案一因为更简洁。如果遇到动画不生效的情况检查一下renderItem返回的元素是否每次都有相同的type和shape结构以及setOption时是否用了notMerge: false动画需要合并更新才能识别出同一个元素。5. 那些让我加班到深夜的坑5.1 量程切换时液柱跳变项目里有个需求是允许用户切换温度计的量程比如从 -20~50 切到 0~100。我一开始直接在setOption里改了min和max结果液柱高度瞬间跳变动画完全乱套。原因是自定义系列的图形位置是在renderItem里根据当前min/max实时计算的量程一变所有刻度和液柱位置都变了ECharts 的过渡动画无法正确插值。解决办法是量程切换时不走动画直接全量重绘。在setOption时传{ notMerge: true, silent: true }并且把animation设为false。等重绘完成后再恢复动画配置。虽然视觉上少了一个过渡效果但至少不会出现错乱。5.2 负温度时球泡颜色不更新前面提到负温度时液柱颜色要切换成冷色。我一开始只在液柱的style.fill里做了判断但忘了球泡的填充色也需要跟着变。结果温度降到零下时液柱变蓝了球泡还是原来的暖色看起来非常割裂。后来在renderItem里统一计算当前温度对应的颜色液柱和球泡共用同一个颜色值问题解决。5.3 容器宽度太小时刻度文字被截断温度计组件如果放在一个窄容器里比如宽度只有 80px刻度文字可能会超出容器边界被裁掉。ECharts 默认会裁剪超出容器的图形。我的处理方式是当容器宽度小于某个阈值比如 100px时自动隐藏刻度文字只保留刻度线或者把刻度文字放到液柱左侧。这个逻辑可以在buildOption里根据width参数动态判断。5.4 多个温度计实例的内存泄漏一个页面上如果同时渲染多个温度计比如监控大屏上有 20 个设备每个实例都会创建一个 ECharts 实例和对应的 zrender 画布。如果组件卸载时没有正确dispose这些画布会一直占用内存。我在useEffect的清理函数里调用了dispose但有一次因为条件判断写错导致chartRef.current为 null 时跳过了清理。后来改成无论实例是否存在都执行dispose加空值判断问题解决。注意ECharts 实例的dispose和 DOM 元素的移除是两回事。即使 React 已经移除了容器 DOMECharts 实例可能仍然持有对画布的引用。务必在组件卸载时显式调用dispose。6. 让温度计更好用的几个进阶技巧6.1 用 graphic 组件叠加装饰元素ECharts 的graphic组件可以在图表上层叠加任意图形适合画温度计的玻璃管高光、顶部装饰帽这些纯装饰性元素。比如在液柱左侧画一条半透明白色竖线模拟玻璃反光graphic: [{ type: rect, left: cx - liquidWidth / 2 3, top: 20, shape: { width: 3, height: maxLiquidHeight - 10 }, style: { fill: rgba(255,255,255,0.4) }, z: 7 }]这些装饰元素不参与数据映射用graphic管理比塞进renderItem里更清晰。6.2 支持自定义刻度格式化有些场景下刻度需要显示小数比如精密温度计或者需要加单位后缀。我在组件里加了一个tickFormatterprop类型是函数接收温度值返回显示文本。默认实现是(v) ${v}${unit}。这样调用方可以自由控制刻度显示格式比如(v) v.toFixed(1)。6.3 用 CSS 变量控制主题色如果项目支持暗色模式温度计的颜色需要跟着切换。我的做法是把液柱颜色、刻度颜色、背景色都抽成 CSS 变量在renderItem里通过getComputedStyle读取。这样切换主题时只需要改 CSS 变量ECharts 配置不用动。不过要注意getComputedStyle在renderItem里频繁调用有性能开销最好在组件层面读取一次后缓存起来。6.4 温度变化时的脉冲提示当温度超过某个阈值时让球泡有一个脉冲发光效果能起到警示作用。实现方式是在renderItem里给球泡加一个shadowBlur然后用setInterval动态调整shadowBlur的值配合setOption更新。或者更优雅的方式是用 ECharts 的animation配置但自定义系列的动画支持有限我最后还是用了定时器方案每 500ms 切换一次阴影大小视觉上就是呼吸灯效果。7. 一个完整的可运行示例把上面的内容串起来下面是一个精简但完整的温度计组件实现。你可以直接复制到项目里改改颜色和尺寸就能用。import React, { useRef, useEffect, useCallback } from react; import * as echarts from echarts; const DEFAULT_SEGMENTS [ { from: -50, to: 0, color: #4A90D9 }, { from: 0, to: 30, color: #5CB85C }, { from: 30, to: 100, color: #D9534F } ]; function getColorForValue(value, segments) { const seg segments.find(s value s.from value s.to); return seg ? seg.color : segments[segments.length - 1]?.color || #5CB85C; } function buildOption({ value, min, max, unit, segments, showTicks, width, height }) { const padding 20; const bulbRadius Math.min(width * 0.18, 22); const liquidWidth bulbRadius * 0.9; const cx width / 2; const bulbCenterY height - padding - bulbRadius; const maxLiquidHeight bulbCenterY - padding - 10; const liquidHeight Math.max(2, (value - min) / (max - min) * maxLiquidHeight); const liquidColor getColorForValue(value, segments); const renderItem (params, api) { const elements []; // 球泡填充 elements.push({ type: circle, shape: { cx, cy: bulbCenterY, r: bulbRadius }, style: { fill: liquidColor, opacity: 0.9 }, z: 3 }); // 液柱 elements.push({ type: rect, shape: { x: cx - liquidWidth / 2, y: bulbCenterY - liquidHeight, width: liquidWidth, height: liquidHeight, r: liquidWidth / 2 }, style: { fill: liquidColor, opacity: 0.9 }, z: 5 }); // 球泡描边 elements.push({ type: circle, shape: { cx, cy: bulbCenterY, r: bulbRadius }, style: { fill: transparent, stroke: #bbb, lineWidth: 2 }, z: 6 }); // 刻度 if (showTicks) { const range max - min; const step range 20 ? 2 : range 50 ? 5 : 10; for (let t min; t max; t step) { const y bulbCenterY - (t - min) / range * maxLiquidHeight; elements.push({ type: line, shape: { x1: cx liquidWidth / 2 4, y1: y, x2: cx liquidWidth / 2 12, y2: y }, style: { stroke: #aaa, lineWidth: 1 }, z: 4 }); if (width 100) { elements.push({ type: text, style: { text: ${t}${unit}, x: cx liquidWidth / 2 16, y: y, fill: #888, fontSize: 11, textVerticalAlign: middle }, z: 4 }); } } } return elements; }; return { animation: true, series: [{ type: custom, renderItem, data: [value], clip: false }], xAxis: { show: false, min: 0, max: width }, yAxis: { show: false, min: 0, max: height } }; } export default function Thermometer({ value 25, min -20, max 50, unit °C, width 120, height 300, segments DEFAULT_SEGMENTS, showTicks true }) { const containerRef useRef(null); const chartRef useRef(null); useEffect(() { if (!containerRef.current) return; chartRef.current echarts.init(containerRef.current); const observer new ResizeObserver(() { chartRef.current?.resize(); }); observer.observe(containerRef.current); return () { observer.disconnect(); chartRef.current?.dispose(); chartRef.current null; }; }, []); useEffect(() { if (!chartRef.current) return; const option buildOption({ value, min, max, unit, segments, showTicks, width, height }); chartRef.current.setOption(option, { notMerge: true }); }, [value, min, max, unit, segments, showTicks, width, height]); return div ref{containerRef} style{{ width, height }} /; }这个组件在 React 18 和 ECharts 5.4 下实测可用。如果你用的是 TypeScript把 props 和renderItem的参数补上类型定义即可ECharts 官方提供了CustomSeriesRenderItem类型。8. 关于性能与复用的几点个人体会温度计组件本身的计算量很小性能瓶颈通常不在渲染上而在频繁的 setOption 调用。如果你的温度数据是每秒推送一次每次推送都触发setOption在低端设备上可能会有卡顿感。我的优化策略是如果新旧温度值的差异小于 0.1 度就跳过这次更新或者用requestAnimationFrame把更新节流到每帧最多一次。另一个体会是不要过度封装。我见过有的项目把温度计封装成一个通用仪表组件支持温度计、压力表、转速表三种模式结果配置项爆炸维护成本极高。温度计就是温度计它的视觉语义很明确单独封装一个组件保持 API 简洁比做成万能组件要划算得多。最后如果你需要在服务端渲染SSR场景下使用这个组件记得把 ECharts 的初始化放到useEffect里本文的做法已经满足并且确保echarts.init不会在服务端执行。如果构建时报错可以用动态import()延迟加载 ECharts 模块。