用 React 写数据大屏:为什么我不建议用图表库

用 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。有具体的大屏样式想实现又不知道怎么写,可以在评论区描述一下,我尽量给思路。

相关推荐
行者全栈架构师2 小时前
【鸿蒙心迹】让老照片变清晰:HarmonyOS 7.0 图像超分相册工程化落地与 5 个踩坑解法
架构·前端框架·开源
欣欣之王来了2 小时前
原始HTML插值:v-html指令的使用与安全风险
前端·学习·架构·项目·vue3教程
IT_陈寒2 小时前
Redis的LRU淘汰策略坑了我一整天
前端·人工智能·后端
计算机魔术师3 小时前
砍半价格、参数再涨,国产旗舰追到 Opus 鼻尖了
前端
特立独行的猫A3 小时前
10 MB 的桌面 AI 客户端:用仓颉 + 系统 WebView 替代 Electron
前端·harmonyos
修炼的dance4 小时前
白底图存JPG,sharp开mozjpeg背景杂点少近三成
前端
anOnion4 小时前
构建无障碍组件之Grid Pattern
前端·html·交互设计
乔旭伟4 小时前
Vben3 中后台表格封装实战:useVbenVxeGrid 的全局收敛与七个坑
前端·anti-design-vue·1024程序员节
鶴哥只手遮天4 小时前
jQuery 4.0 源码架构与工程实践-第 10 章 尺寸、几何与显隐
前端·javascript·jquery