React+ECharts+Ant Design 构建数据看板:封装、性能优化与权限路由实战

发布时间:2026/10/20 17:10:09
React+ECharts+Ant Design 构建数据看板:封装、性能优化与权限路由实战 我之前接了一个内部数据平台的前端改造需求项目代号叫 rea。这个名字其实是个巧合技术栈里 React、ECharts、Ant Design 三个词的首字母拼起来正好是 rea读起来像 real 少了个 l也像 react 的前半截。做数据看板的朋友应该秒懂这套组合几乎就是中后台可视化项目的标配。这篇文章不打算讲那种“从入门到放弃”的空话而是直接把我在 rea 项目从需求拆解到落地过程中真正用到的东西倒出来为什么选这套技术栈、图表组件怎么封装才不踩坑、大数据量图表怎么优化、权限路由怎么接以及那些我花了一晚上才排查明白的诡异问题。适合正准备搭数据平台、或者已经在维护中后台项目、想优化图表性能和工程结构的开发者参考。看完之后你可以直接照着里面的思路和代码改到自己项目里。1. 项目代号与整体设计思路1.1 这个项目到底要解决什么问题需求最初的来源很朴素业务数据分散在好几个后台系统里运营看转化要开 A 系统技术看接口耗时要开 B 系统管理层要的日报还得靠人去 Excel 里手工汇总。每次做数据复盘光收集数据就要半天更别说跨系统比对。所以团队决定自建一个统一的数据工作台把关键指标、趋势分析、异常告警和导出报表放到同一个界面里。刚开始也考虑过直接用开源 BI 工具但试了一圈发现几个绕不过去的坎一是权限模型太死我们内部按角色和数据域双重控制开源工具配置起来很费劲二是看板需要深层定制不只是拖拽图表还要跟内部多个业务系统做数据回链三是报表的时效性要求高部分页面需要秒级刷新开源 BI 在这种场景下往往要自己做很多二次开发。最后结论很明确自研一个轻量级数据工作台只保留必要的看板和查询能力把定制能力握在自己手里。rea 这个名字就是在技术选型时定下来的。前端主框架选 React图表用 ECharts组件库用 Ant Design缩写正好是 rea。项目定位上它不是一个大而全的数据中台而是一个“足够快、足够简单、权限清晰”的分析入口。对开发团队来说这意味着代码量可控、迭代效率高、新人上手快对使用者来说核心诉求就是打开页面能快速看到数据能下钻能导出别卡顿。1.2 技术选型背后的取舍逻辑技术选型没有绝对的对错关键看团队情况和使用场景。当时我们做了一个简单的对比核心结论可以整理成下面这张表。环节最终选择备选项选择原因UI 框架React 18Vue 3团队已有 React 项目积累迁移成本低相关生态和招聘资源更成熟构建工具Vite 5Webpack 5冷启动快HMR 反应迅速配置简单生产构建稳定性在成熟项目中已经足够图表库ECharts 5AntV G2ECharts 的 API 设计更贴合业务快速迭代已有大量配置案例可查组件库Ant Design 5Semi Design组件丰富、文档齐全和 React 生态衔接顺滑主题定制基于 design token暗色模式支持很完善状态管理zustandRedux Toolkit代码量少不需要额外 Provider适合中后台这种以请求驱动为主的数据流这里重点说说图表库和状态管理的选择。ECharts 5 吸引我的不是图表种类多而是它的 dataset 和渐进渲染能力。dataset 可以把数据和配置分离换数据源不需要重写整个 option渐进渲染在处理上万点数据的折线图时能明显降低首帧卡顿感。相比起来AntV G2 的统计语法确实更强大但团队的学习曲线更陡而且 ECharts 的生态案例实在太多了遇到问题几乎都能搜到现成方案。状态管理我们没选 Redux Toolkit。中后台项目的状态大部分是服务端数据真正需要共享的客户端状态无非是用户信息、权限列表、主题配置、当前筛选条件。zustand 用起来就像写一个普通 hook不需要定义 action、reducer 那一整套模板。调试的时候也简单直接通过 store 的 getState 就能拿到当前值。还有一点容易被忽略React 18 的并发渲染特性对图表库有潜在影响。图表实例不属于 React 的渲染树如果在 render 过程中同步操作 ECharts 实例有可能和并发更新冲突。所以 rea 项目里所有图表组件的实例创建和销毁都放在 useEffect 里不让 React 生命周期直接管理对外部库的同步操作。1.3 模块划分与目录结构项目初期最容易犯的错误是上来就按文件类型分目录比如 components、pages、utils 一坨一坨堆在一起。等业务复杂之后改一个页面功能要同时动四五个目录很痛苦。rea 项目改用业务模块优先的目录结构每个模块自治内部再按职责细分。src/ ├─ modules/ │ ├─ dashboard/ │ │ ├─ components/ │ │ ├─ hooks/ │ │ ├─ services/ │ │ ├─ store.ts │ │ └─ index.tsx │ ├─ report/ │ ├─ monitor/ │ └─ system/ ├─ shared/ │ ├─ components/ # 跨模块的通用组件 │ ├─ hooks/ │ ├─ services/ # 统一请求实例、错误码定义 │ └─ utils/ ├─ router/ ├─ store/ # 全局级状态 └─ main.tsxmodules 下的每个业务模块都尽量自包含components 放该模块独有的组件hooks 放模块内的逻辑复用services 放该模块的接口定义store.ts 放模块内部的临时状态。shared 目录放真正跨模块使用的东西比如通用 ECharts 封装、下载工具、权限判断 hook。这样设计的好处是一个需求变更基本只改一个 modules 子目录不会动到其他业务多个前端同学并行开发时代码冲突的概率也低。当然这个划分不是完美的。对于业务之间大量共享组件的系统模块自治会导致重复代码。我们的应对策略是“先收敛到 shared 再下沉”当某个组件被第二个模块使用时就从模块 components 提升到 shared如果未来被更多模块复用再把它抽成独立包。这个原则听起来简单但能有效避免过早抽象也不至于让复用代码散落一地。2. 核心细节解析与实操要点2.1 数据请求层的统一封装图表页面看起来是前端展示实际上大头在和后端的数据交互上。rea 项目里所有接口请求都走同一套 axios 封装核心就是拦截器。import axios from axios; import { message } from antd; import { getToken, clearToken } from /shared/utils/auth; const http axios.create({ baseURL: /api, timeout: 10000, }); http.interceptors.request.use((config) { const token getToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); http.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res.data; }, (error) { if (error.response?.status 401) { clearToken(); window.location.href /login; } else if (error.response) { message.error(请求异常${error.response.status}); } else if (error.code ECONNABORTED) { message.error(请求超时请稍后重试); } return Promise.reject(error); } ); export default http;这套封装的要点是业务状态码和 HTTP 状态码分开处理。HTTP 状态码只负责网络层面的成功失败业务逻辑是否成功看接口返回里的 code 字段。比如查询无数据时HTTP 还是 200但 code 可能返回一个“暂无数据”的业务码。如果业务层每个页面都去写 if (res.code ! 0)不仅重复而且很容易漏掉错误提示。统一在拦截器里处理页面代码就可以直接认为“拿到 res.data 就一定是业务成功的数据”。另一个很容易踩坑的点是重复请求。图表页的筛选条件变化很快用户点了一下筛选按钮又马上改成另一个条件前一个请求可能还在飞后一个已经发出最后页面展示的数据顺序可能是乱的。我们做了一个轻量级的请求取消机制核心是用 AbortController。export const abortControllerMap new Mapstring, AbortController(); export function requestWithAbort(key: string, requestFn: () Promiseunknown) { abortControllerMap.get(key)?.abort(); const controller new AbortController(); abortControllerMap.set(key, controller); return requestFn().finally(() { abortControllerMap.delete(key); }); }在业务层切换筛选条件时用同一把 key 调用前一个未完成的请求会被直接中断。注意 axios 的 cancel 机制在 ECharts 场景下同样有效关键是中断后 setOption 不要被继续执行否则会看到旧数据覆盖新数据。实测中这个模式能把“快速切换筛选条件导致的数据错乱”问题彻底解决。2.2 ECharts 组件的封装与生命周期管理图表是 rea 项目最核心的交互单元。如果每个页面都直接写 echarts.init代码会非常冗余而且销毁和 resize 逻辑很容易漏。我封装了一个统一的 ReactECharts 组件重点解决三件事实例创建、自适应尺寸、按需销毁。import * as echarts from echarts/core; import { LineChart, BarChart, PieChart } from echarts/charts; import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, } from echarts/components; import { CanvasRenderer } from echarts/renderers; import { useEffect, useRef } from react; echarts.use([ LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer, ]); interface ReactEChartsProps { option: echarts.EChartsCoreOption; theme?: string; height?: number | string; onEvents?: Recordstring, (params: unknown) void; } export function ReactECharts({ option, theme, height 360, onEvents }: ReactEChartsProps) { const containerRef useRefHTMLDivElement(null); const chartRef useRefecharts.ECharts | null(null); useEffect(() { if (!containerRef.current) return; const chart echarts.init(containerRef.current, theme ?? undefined); chartRef.current chart; const resizeObserver new ResizeObserver(() chart.resize()); resizeObserver.observe(containerRef.current); return () { resizeObserver.disconnect(); chart.dispose(); chartRef.current null; }; }, [theme]); useEffect(() { if (!chartRef.current) return; chartRef.current.setOption(option, { notMerge: true }); }, [option]); return ( div ref{containerRef} style{{ width: 100%, height: height auto ? undefined : height }} / ); }这个组件有三点关键设计。第一echarts.init只依赖 theme 的变化。如果 theme 没变这个 effect 不会重复执行避免每次图表更新都销毁重建。真实项目中 theme 切换是低频操作而 option 更新是高频操作把两者拆成两个 effect 可以最大程度减少性能损耗。第二setOption默认开启notMerge: true。这是我在实际项目里踩过坑之后才坚持的。ECharts 默认的 merge 模式会保留旧 option 里没被覆盖的配置比如这次数据只有两个系列上次的第三个系列可能还会残留。开启 notMerge 可以保证图表严格跟当前传入的 option 一致减少“幽灵系列”一类问题。第三容器尺寸变化用 ResizeObserver 而不是 window.resize。因为看板页面经常有折叠面板、侧边栏展开收起这些操作不会触发 window 尺寸变化但会改变图表容器的实际宽度。ResizeObserver 能精确感知容器变化图表自适应更可靠。还有一个事件处理的细节onEvents虽然是可选的但实际项目里几乎都要用到。点击图表下钻、拖拽数据缩放、悬浮显示详情这些都是数据看板的高频需求。事件绑定不要在每次 option 变化时都重新 addListener而是通过一个 ref 保存最新回调函数然后只在实例创建时绑定一次。2.3 主题定制与暗色模式适配中后台应用现在基本都要求支持暗色模式图表颜色也要跟着切换。Ant Design 5 和 ECharts 5 对主题的支持方式不太一样两边需要做一层同步。Ant Design 5 的主题切换很简单用 ConfigProvider 的 algorithm 属性切换 lightAlgorithm 和 darkAlgorithm再配合 cssVar 开启 CSS 变量模式页面背景、卡片、文字颜色都会自动变化。import { ConfigProvider, theme } from antd; import zhCN from antd/locale/zh_CN; const isDark useThemeMode(); ConfigProvider locale{zhCN} theme{{ algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm, cssVar: true, token: { colorPrimary: #1677ff, borderRadius: 6, }, }} App / /ConfigProviderECharts 这边则需要注册自定义主题。我们根据 AntD 的 design token 生成了一套图表色板深色模式的背景透明、文字用 rgba(255,255,255,0.65)浅色模式文字用 rgba(0,0,0,0.88)。关键点在于图表容器不要设置背景色保持透明否则切换主题后图表区域会变成一块白底或黑底非常难看。import * as echarts from echarts/core; const lightTheme { color: [#1677ff, #52c41a, #faad14, #ff4d4f, #722ed1, #13c2c2], textStyle: { color: rgba(0, 0, 0, 0.88) }, categoryAxis: { axisLine: { lineStyle: { color: rgba(0,0,0,0.15) } } }, }; const darkTheme { color: [#1677ff, #52c41a, #faad14, #ff4d4f, #722ed1, #13c2c2], textStyle: { color: rgba(255, 255, 255, 0.65) }, categoryAxis: { axisLine: { lineStyle: { color: rgba(255,255,255,0.2) } } }, }; echarts.registerTheme(rea-light, lightTheme); echarts.registerTheme(rea-dark, darkTheme);切换主题时ReactECharts 组件的 theme prop 会变化之前的封装逻辑会自动销毁旧实例、用新主题重建。但这里有个性能问题每次重建图表实例都会重新计算布局如果页面有十几个图表切换主题时会有明显卡顿。优化手段是把图表配置里的通用颜色尽量靠主题解决而不是在 option 里硬编码颜色。这样切换主题时保留 option 不变只靠实例重建去应用新主题ECharts 内部会缓存很多计算结果实际渲染会快很多。3. 实操过程与核心环节实现从零搭一个 rea 工作台3.1 工程初始化与目录规范落地再好的架构设计都要落到代码里。rea 项目的初始化我用 Vite 的 react-ts 模板然后补齐依赖和路径别名。npm create vitelatest rea -- --template react-ts cd rea npm install antd ant-design/icons echarts axios zustand npm install -D types/nodeVite 模板默认的 tsconfig 路径引用有点繁琐我习惯额外维护一个 paths 配置让业务代码用别名导入避免一堆相对路径。// tsconfig.app.json { compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }对应的 vite.config.ts 也要同步改动。import { defineConfig } from vite; import react from vitejs/plugin-react; import path from node:path; export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), }, }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, });路径别名看起来是小事情但项目大了之后一个../../../../shared/services/http会让任何人崩溃。另外还有一条经验所有依赖的版本在项目启动前先统一好特别是 antd、echarts、react 这三个核心包React 18 和 antd 5 的版本兼容性在初期很容易出现“页面渲染了但样式全乱”的问题。建议初始化时直接安装最新稳定版然后锁住版本号避免同事之间环境不一致。3.2 权限路由与动态菜单的实现数据平台最怕数据被没权限的人看到。rea 项目的权限控制分成两层路由级权限和按钮级权限。后端登录后返回当前用户的权限码列表比如PERM_DASHBOARD_VIEW、PERM_REPORT_EXPORT。前端的路由配置里每个路由 meta 记录需要的权限码。import { lazy, Suspense } from react; import { Navigate } from react-router-dom; const routes [ { path: /dashboard, element: lazy(() import(/modules/dashboard)), meta: { permission: PERM_DASHBOARD_VIEW, title: 数据看板 }, }, { path: /report, element: lazy(() import(/modules/report)), meta: { permission: PERM_REPORT_VIEW, title: 报表中心 }, }, ];然后写一个 AuthRouter 组件根据 store 里的权限码过滤路由表再配合 React Router 的 useRoutes 生成最终路由。关键点在于过滤的是路由表本身而不是渲染之后再做判断。这样没有权限的路由根本不会进入路由匹配菜单和路由保持一致用户手动输入路由地址也进不来。菜单生成也直接复用这份过滤后的路由表遍历生成 AntD Menu 的 items 配置。好处是不需要前端维护两份数据加一个页面只需要在路由表里加一条记录菜单和权限自动跟着走。动态菜单和静态菜单的区别我做完后发现一个隐藏收益动态生成的路由天然支持按需加载。每个模块都通过 lazy 引入配合 React Router 的 Suspense fallback页面切换时只加载当前模块的 chunk首屏体积会明显小很多。Suspense fallback{PageLoading /} Outlet / /Suspense3.3 大数据量图表的渲染性能优化图表数据量一大性能问题马上暴露。我们最早在趋势分析页放了几个 10 万点数据的折线图直接把浏览器卡成幻灯片。后来展示数据量被砍到 2 万点还是卡。排查出来几个核心优化点。第一个是开启采样。ECharts 5 在 series 里配置sampling: lttb可以用 Largest-Triangle-Three-Buckets 算法在不改变曲线趋势的前提下大幅减少渲染点数。10 万点采样到 2000 点左右视觉上几乎看不出差异但渲染时间可以降到原来的十分之一。const option { xAxis: { type: category }, yAxis: { type: value }, dataZoom: [{ type: inside }, { type: slider }], series: [ { type: line, data: largeData, sampling: lttb, progressive: 2000, animation: false, }, ], };第二个是关闭动画。大数据量场景下动画不仅没意义还非常消耗帧率。把animation: false打开同时配合progressive渐进渲染参数ECharts 会分块绘制节点避免长时间阻塞主线程。我实测在 2 万点折线图上开启 progressive 后首帧时间从 800ms 降到 300ms 左右。第三个是用 useMemo 缓存 option。图表渲染组件的 option object 如果每次 render 都生成一个新对象ReactECharts 内部的 useEffect 会不断触发 setOption。这个问题的隐藏性很强因为页面不卡但一直有额外的计算开销。把所有图表配置的计算包在 useMemo 里依赖项精确列出筛选条件、数据版本和主题相关字段可以显著减少无效 setOption。第四个是视口懒渲染。一个看板页往往有六七个图表首屏不可能全部展示下面几个图表其实可以等滚动到可视区域再渲染。我写了一个 useInView hook底层用 IntersectionObserver图表组件只有在进入视口后才真正 init。这个优化对首屏加载速度提升特别明显尤其是报表页有几个大图的时候。function useInViewT extends HTMLElement() { const ref useRefT(null); const [inView, setInView] useState(false); useEffect(() { const el ref.current; if (!el) return; const observer new IntersectionObserver( ([entry]) { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } }, { rootMargin: 200px } ); observer.observe(el); return () observer.disconnect(); }, []); return { ref, inView }; }在 ReactECharts 组件外面套一层判断inView 变成 true 才渲染图表内部结构否则渲染一个占位 div。对用户来说滚动到图表位置时马上就出来了感知不到渲染过程。4. 常见问题与排查技巧实录4.1 图表容器宽高为 0页面一片空白这是 ECharts 入坑第一课。图表容器只有在页面布局完成后才能拿到真实尺寸如果容器是隐藏的、父级高度为 auto、或者 Flex 布局里没有设置 min-heightecharts.init出来的图表高度就会是 0页面显示空白。排查步骤很简单打开开发者工具看容器元素的实际尺寸如果宽高都是 0说明布局问题如果容器有尺寸但图表空白再检查是否 init 时机太早。rea 项目里我们定了一条硬性规范图表容器必须有明确的宽度和高度要么写死高度要么通过 aspect-ratio 给定高度比例禁止依赖内容撑开的 auto 高度。所有图表组件默认 height 是 360页面使用方可以覆盖但不能传 undefined。还有一个小技巧setOption之后如果发现图表空白可以在自定义组件里临时打印 chart 实例的getWidth()和getHeight()如果拿到的还是 0就说明 init 时机有问题。4.2 React 18 StrictMode 下请求被发送两次React 18 在开发环境启用了 StrictMode组件挂载后会经历“挂载-卸载-重新挂载”的过程用来提前暴露副作用副作用问题。结果就是开发模式下很多接口请求会发两次第一次请求被 abort 掉第二次正常返回。这个现象本身不是 bug但会干扰调试。最初团队以为接口重复调用是封装的锅查了半天才发现是 StrictMode 的正常行为。处理方案有两个页面数据请求如果通过 useEffect 发起在 cleanup 里调用 AbortController 取消请求这样即使加载两次也只会保留最后一次或者直接从代码链路判断生产模式下不会有这个问题开发模式里出现请求日志重复时不必紧张。这里要特别提醒不要在 StrictMode 下擅自去掉 AbortController也不要为了消除重复日志而关闭 StrictMode。StrictMode 能帮你提前发现副作用清理问题真实价值比多两条日志大得多。4.3 切换主题后图表颜色没有变化这个问题在初次接入暗色模式时很容易遇到。现象是页面背景和卡片都变成深色了但图表还是浅色背景或者只有部分文字变色。原因通常是两个。第一个是图表实例创建时没有指定主题。在echarts.init(dom)没传第二个参数的情况下图表默认使用默认主题注册的自定义主题不会自动生效。记得在封装组件里把 theme prop 传给 init并且这个 prop 要作为重建实例的依赖项。第二个是 option 里写死了颜色。比如图例文字颜色写了color: #333坐标轴颜色写了color: #999这些硬编码颜色不会跟随主题变化。更好的做法是把颜色定义放进主题而不是 option。ECharts 主题里可以定义 textStyle、categoryAxis 等通用样式只要 option 里没覆盖这些主题配置就会自动生效。我们在代码评审时给团队定的规则是图表里的通用样式一律走主题只有业务语义颜色才允许写进 option。4.4 首包体积过大加载白屏时间长rea 项目第一个可运行版本发布到测试环境后首屏加载花了七八秒。打开 Network 面板一看主 bundle 居然快 2MBgzip 之后也有 600KB 以上。这个体积放在内网还好说但如果是远程办公场景体验就会很难受。优化动作做了三个。第一是路由懒加载。上面提到动态路由的时候已经说了所有页面全部改成 React.lazy 动态导入让首屏只加载当前页面代码。第二是第三方库手动分包。Vite 默认的产物拆包策略并不是最优的我在 vite.config 里用 manualChunks 把体积大的依赖单独拆出去。build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom, react-router-dom], antd: [antd, ant-design/icons], echarts: [echarts/core, echarts/charts, echarts/components, echarts/renderers], }, }, }, }这里有个细节echarts 分包一定要用按需引入的入口才能拿到收益。如果整个import * as echarts from echarts即使 manualChunks 单独拆出来echarts 核心代码也有接近 1MB不解决问题。所以全局必须走 echarts/core 按需注册的路径。第三是关闭没用到的依赖。排查后发现项目引了一个工具库的完整包但实际只用了一个函数改成按需引入后体积直接少了 80KB。优化后首屏 gzip 体积从 600KB 降到 400KB 左右加载时间从七八秒降到两秒内。对于中后台这种偏重度交互的场景这个体积已经比较健康了。如果还想再压可以继续做组件级懒加载但收益边际会变小优先级不高。5. 写在最后的实战心得做 rea 这个项目最大的体会是真正决定一个数据看板好不好用的不是哪个图表库更好看而是你能不能把“数据加载、状态同步、生命周期清理”这些基本功做扎实。图表只是表现层藏在下面的是请求的稳定性、权限的正确性和渲染的流畅性。这三件事做好用什么图表库都不会差。最后分享一个调试小技巧排查 ECharts 问题时先不要把焦点放在页面上而是在代码里临时把chart.getOption()打印出来对照实际视觉效果检查。很多时候你以为页面渲染的数据不对其实是配置项被 ECharts 的 merge 机制悄悄保留了一部分旧值。把 option 快照打出来问题基本一眼就能看出来。这个习惯帮我省了很多排查时间也希望你能用上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询