用 React 写数据大屏:为什么我不建议用图表库
去年做过几个数据大屏项目,第一个项目上来就装了 ECharts。做完之后我把整套图表换成了手写 SVG,代码反而更短。
这篇把过程和数字写清楚,也写清楚什么情况下你仍然应该用图表库。
下面这套大屏里的每一个图表 ------ 折线、面积、环形、仪表、雷达、排行、热力、地图 ------ 全部手写 SVG,没有一行 ECharts 代码。

先说差异:大屏的图表和后台的图表不是一回事
后台管理系统的图表是探索工具:数据维度不固定、用户要筛选、要缩放、要 tooltip 跟随、要点击下钻。这种场景图表库是刚需,自己写纯属自找麻烦。
数据大屏的图表是展示品:
- 图表种类固定,通常 8~10 种就够(折线、柱状、环形、仪表、雷达、排行、热力、地图)
- 数据形状固定,就是几个数组
- 没有任何交互 ------ 大屏是挂在墙上的,没人去点它
- 视觉要求高,而且必须和整屏风格严格一致
一句话:要的是「长得对」,不是「什么都能画」。
图表库的强项是前者之外的通用能力,而我需要的是对样式和节奏的绝对控制。这个交换对我来说不划算。
用 ECharts 时遇到的四个具体问题
不是为了否定它 ------ 下面每一条都是我实际踩过的。
1. 主题覆盖的成本是按图表数量翻倍的
科技风大屏的视觉要求:透明背景、极细网格线、暗色坐标轴文字、渐变面积、发光描边。
ECharts 里每一项都要覆盖对应配置,而且每个图表类型都要写一遍 (xAxis.axisLine、splitLine、axisLabel、legend.textStyle、tooltip.backgroundColor...)。三个图表下来,主题配置比业务代码还长。
更麻烦的是:换一版配色,这些覆盖要跟着改一遍。
2. 整屏 scale 之后,图表的重绘时机很难控制
大屏的通用做法是:按 1920×1080 设计,然后整屏 transform: scale() 适配任意屏幕。
问题在于 ECharts 的 resize() 依赖容器像素尺寸 。整屏被 scale 之后,容器像素变小了,但 ECharts 的 canvas 是按缩放前的尺寸绘制的 ------ 结果就是放大后线条发虚 。要么监听缩放重新 resize()(会有一帧卡顿),要么把 devicePixelRatio 手动调大。
手写 SVG 没有这个问题:SVG 是矢量,缩放本来就是它的本职。
3. 动画节奏对不上
大屏的入场动效是统一编排的:标题先出现 → 面板依次浮入 → 数字滚动 → 折线开始描绘。每步间隔 80~120ms,整体 1.5 秒完成。
ECharts 有自己的动画时间线,animationDuration / animationDelay 能调,但和数据滚动、面板浮入不同步。想做「折线画到 60% 时数字刚好滚完」这种效果,只能靠反复试参数。
手写 SVG 之后,用 CSS stroke-dasharray + stroke-dashoffset 做描线,动画时长就是一行 CSS,和其他动效共享同一条时间线。
4. 包体与升级
引入 echarts + 一个边框组件库,gzip 后大概多 150~250KB(按需引入能压到 100KB 上下,但按需引入的配置本身又是一堆代码)。
更烦的是升级:一次大版本升级后,默认的颜色和边框样式变了,大屏整体色调跑偏,排查了半天是库的默认值改了。
手写 SVG:其实只有四个基础件
我把这些抽进了 geometry.ts,97 行,覆盖了后来所有图表的需要。
① 测量容器尺寸
ts
export function useSize<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return undefined;
const update = () => {
setSize((prev) => {
const next = { width: el.clientWidth, height: el.clientHeight };
// 尺寸没变就不 setState,避免 ResizeObserver 抖动
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, ...size };
}
关键点:按真实像素绘制,不做 viewBox 拉伸。因为整块大屏已经统一缩放过了,图表内部再拉伸一次,线宽和字号在任何缩放下都会变 ------ 而大屏上「1px 的线」和「12px 的字」是有设计规范的。
② 平滑折线(Catmull-Rom 转三次贝塞尔)
不用曲线拟合,只要点与点之间好看:
ts
export function smoothPath(points: [number, number][], tension = 0.22): string {
if (points.length < 3) {
return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${p[1]}`).join(' ');
}
let d = `M${points[0][0]},${points[0][1]}`;
for (let i = 0; i < points.length - 1; i += 1) {
const p0 = points[i - 1] || points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] || p2;
// 控制点由相邻两点切线决定,tension 控制弯曲程度
const c1x = p1[0] + (p2[0] - p0[0]) * tension;
const c1y = p1[1] + (p2[1] - p0[1]) * tension;
const c2x = p2[0] - (p3[0] - p1[0]) * tension;
const c2y = p2[1] - (p3[1] - p1[1]) * tension;
d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`;
}
return d;
}
面积图就是在终点补两条线回到底边:
ts
export function areaPath(points: [number, number][], baseline: number, tension = 0.22) {
const line = smoothPath(points, tension);
const last = points[points.length - 1];
return `${line} L${last[0]},${baseline} L${points[0][0]},${baseline} Z`;
}
③ 好看的坐标轴刻度
Math.max(...data) 直接当上限,刻度会是 3712, 7424, 11136 这种丑数字。取整到 1 / 2 / 2.5 / 5 的倍数:
ts
export function niceMax(value: number, divisions = 4): number {
if (value <= 0) return divisions;
const rough = value / divisions;
const mag = 10 ** Math.floor(Math.log10(rough));
const norm = rough / mag;
const step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
return step * mag * divisions;
}
④ 圆弧(环形图、仪表盘共用)
ts
export function polar(cx: number, cy: number, r: number, angleDeg: number): [number, number] {
const rad = ((angleDeg - 90) * Math.PI) / 180; // -90 让 0° 在正上方
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
export function arcPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const [sx, sy] = polar(cx, cy, r, endAngle);
const [ex, ey] = polar(cx, cy, r, startAngle);
const large = endAngle - startAngle <= 180 ? 0 : 1; // 大弧标记
return `M${sx},${sy} A${r},${r} 0 ${large} 0 ${ex},${ey}`;
}
就这四个。环形图、仪表盘、雷达、漏斗,全是在 arcPath 和 smoothPath 上做参数化。
环形图就是若干段 arcPath 拼起来,段与段之间留 2° 的缝:

一个完整的折线图长什么样
精简掉发光滤镜之后,核心就是这样(真实代码,去掉类型声明):
tsx
export function LineChart({ axis, series, ticks = 4, dots = true }) {
const palette = usePalette(); // 调色板由品牌主色推导,见下一节
return (
<ChartFrame>
{({ width, height }) => {
if (width < 40 || height < 40) return null;
const padL = 42, padR = 10, padT = 6, padB = 18;
const innerW = width - padL - padR;
const innerH = height - padT - padB;
const max = niceMax(Math.max(...series.flatMap((s) => s.data), 1), ticks);
const stepX = axis.length > 1 ? innerW / (axis.length - 1) : innerW;
const yOf = (v: number) => padT + innerH - (v / max) * innerH;
const xOf = (i: number) => padL + i * stepX;
return (
<svg width={width} height={height}>
<defs>
{series.map((_, i) => (
<linearGradient key={i} id={`bsFill${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={palette[i % palette.length]} stopOpacity={0.45} />
<stop offset="100%" stopColor={palette[i % palette.length]} stopOpacity={0} />
</linearGradient>
))}
</defs>
{/* 网格 */}
{Array.from({ length: ticks + 1 }, (_, i) => {
const y = padT + (innerH / ticks) * i;
return <line key={i} x1={padL} x2={padL + innerW} y1={y} y2={y}
stroke="currentColor" strokeOpacity={0.08} />;
})}
{series.map((s, si) => {
const pts = s.data.map((v, i) => [xOf(i), yOf(v)] as [number, number]);
const color = palette[si % palette.length];
return (
<g key={s.name}>
<path d={areaPath(pts, padT + innerH)} fill={`url(#bsFill${si})`} />
<path d={smoothPath(pts)} fill="none" stroke={color} strokeWidth={1.6} />
{dots && pts.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={2.4} fill={color} />
))}
</g>
);
})}
</svg>
);
}}
</ChartFrame>
);
}
配色全靠 palette 数组,网格线用 currentColor 继承文字色 ------ 换主题时这里一行都不用改。
这套做法最舒服的一点:配色只改一个值
很多人以为「手写 SVG 就等于颜色全写死」,其实相反:颜色只有一个来源 ------ 品牌主色。
引擎的做法是:拿 enterprise.primary 推导一整套色阶,产出一个 ThemeTokens。真实实现(engine/modules/core/theme.ts,精简后):
ts
export function resolveTheme(config: BigScreenConfig): ThemeTokens {
const brand = config.enterprise;
const derived = defaultTheme(brand); // 主色 → 主色系 + 调色板
// 模板显式写了 theme.* 时覆盖,没写的跟随主色派生
const override = (config.theme || {}) as Partial<ThemeTokens>;
const theme: ThemeTokens = {
...derived,
...override,
primary: override.primary || brand.primary || derived.primary,
palette: override.palette?.length ? override.palette : derived.palette,
// 面板底、边框、标题、文字层级同理
};
return theme;
}
推导出来的东西从两个出口喂给消费方:
ts
// 1) 图表:通过 hook 拿到调色板
const palette = usePalette(); // = theme.palette
// 2) 样式表:转成 CSS 变量
export function themeToCssVars(theme: ThemeTokens): Record<string, string> {
const vars: Record<string, string> = {};
theme.palette.forEach((c, i) => { vars[`--bs-palette-${i}`] = c; });
vars['--bs-primary'] = theme.primary;
vars['--bs-panel-border'] = theme.panelBorder;
// ...
return vars;
}
所以客户换成自己的品牌色时,只改一个 enterprise.primary,折线、面积、环形、图例、面板边框、网格线全部跟着变 ------ 图表代码一行不动。
同一套模板,只把 primary 从 #3b82f6 改成 #f97316:


标题光效、折线、面积渐变、环形、进度条、地图上的城市节点、排名条 ------ 全部跟着换掉了。图表组件的代码一个字都没动。
用 ECharts 的话,这一步要改的是每一处 color 数组、itemStyle、axisLine。
结果数字
| 项目 | 手写 SVG |
|---|---|
| 图表库代码 | 1284 行(含 12 种图表 + 图例 + 坐标轴) |
| 几何工具 | 97 行 |
| 组件总数 | 30 个(图表 + 表格 + 排行 + 事件流 + 地图) |
| 构建产物 | JS 437KB / gzip 149KB(含 React 19) |
| CSS | 13KB / gzip 3.6KB |
149KB 里 React 占了大头,图表部分压缩后不到 30KB。
地图是一类特例:中国和世界地理数据是提前生成的(geo.generated.ts 119KB 源码),只保留大屏需要的省级路径,不需要完整 geoJSON。
什么情况下不要手写
我把话说清楚,避免误导:
该用图表库的场景
- 图表种类会持续增加,或需要用户自己配置(BI 工具、报表平台)
- 需要交互:缩放、brush 选区、tooltip 跟随、点击下钻、联动
- 团队不熟 SVG,维护成本大于收益
- 已经有一套基于图表库的设计规范
适合手写的场景
- 图表种类固定(≤ 12 种),样式高度定制
- 整屏需要统一缩放(
transform: scale方案) - 视觉要求高,动效要和整屏节奏一致
- 愿意为「改一行 CSS 就换主题」付出前期成本
我们做大屏模板,正好全部落在后面这四条里。
代码
这套引擎和两套完整场景(智能制造、能源管控)我开源了,可以直接跑:
https://gitee.com/douyu/bigscreen-starter
bash
npm install
npm run dev
引擎结构在 engine/modules/ 下,图表在 engine/modules/charts/,geometry.ts 就是上面那四个基础件。
采用「评估与学习许可」:学习、评估、非商业试用免费,商业部署需要授权 ------ 原因写在 LICENSE 里了,我就不在这里展开。
更多行业的成品大屏在这里:https://screen.wuxve.top/cases
如果这篇有用,可以点个 Star。有具体的大屏样式想实现又不知道怎么写,可以在评论区描述一下,我尽量给思路。
