React+ECharts生产级可视化方案:支持5类数据源与7种图表模板
简介:这是一套开箱即用的React+ECharts大数据可视化大屏实战项目,面向中高级前端开发者及数据可视化需求方,解决企业级大屏快速搭建、图表组件化封装与响应式适配等核心问题。资源包共63个文件,涵盖30个JS逻辑模块、8个JSX组件文件(含router.jsx、各区域PageData组件等)、7个PNG/SVG/ICO静态资源,以及webpackrc、.eslintrc等工程配置文件,完整呈现典型React大屏项目的目录结构与工程规范,压缩包大小为11.1MB。已有1533人学习下载,项目已实现左右中三区布局、动态数据绑定、ECharts实例生命周期管理及轻量性能优化策略,读者可直接运行调试、复用组件、扩展业务图表或迁移至实际监控看板场景,具备强实践性与二次开发基础。
1. 这不是又一个“Hello World”大屏:一套能直接进答辩、能上线跑通、能改参数就换业务的 React+ECharts 可视化方案
你是不是也见过那种“React + ECharts 大屏”的 Demo:首页飘着几个带阴影的卡片,地图上插了三根静态柱子,数字每秒加 1,切换主题要手动改 CSS 变量,一加真实接口就报Cannot read property 'setOption' of null?我带过三届毕设、帮某高校实验室搭过两套教学平台、给某公司交付过产线监控看板,最常听到的反馈不是“太难”,而是“看着像,用不了”。这套方案要解决的,就是那个卡在「能跑」和「能用」之间的断层——它不追求炫技粒子动画,但默认支持 5 类主流数据源(Mock / JSON 文件 / RESTful API / WebSocket 实时流 / 本地 CSV),内置 7 种可复用图表模板(含全国地图下钻、时间轴联动折线图、多维度环形占比堆叠、设备状态热力格栅、指标趋势双 Y 轴、告警滚动列表、TOP10 横向条形),所有图表组件都封装了loading状态、错误 fallback、空数据占位、响应式重绘钩子。适合正在赶毕设 deadline 的 A 同学、需要快速交付内部管理看板的某公司前端工程师、或是想拿一份“真能跑起来”的课设材料交差的学生。它不是教你怎么写第一个 ECharts 实例,而是给你一套已经调好宽高比、防抖节流、内存释放、主题切换、路由懒加载的生产级骨架。
2. 总体架构设计:三层结构与四个核心模块的职责划分
整套方案采用清晰的分层架构,避免把逻辑全塞进一个Dashboard.jsx里导致后期维护崩溃。我们不搞微前端、不引入复杂状态库,用 React 18 原生能力 + 最小必要依赖达成稳定性和可维护性平衡。整体分为数据层(Data Layer)→ 逻辑层(Logic Layer)→ 视图层(View Layer),中间通过自定义 Hook 和 Context 做解耦。四个核心模块各司其职:数据接入模块负责统一处理不同来源的数据格式转换与错误兜底;图表配置中心管理所有图表的 schema 定义、主题变量、尺寸策略;布局编排引擎支持拖拽式网格布局(基于react-grid-layout封装,但去除了所有 UI 拖拽依赖,仅保留配置驱动);状态同步中枢协调跨图表联动(如点击地图省份触发右侧指标刷新)、全局 loading 控制、主题切换广播。这种结构让 A 同学改一个业务指标,只需动src/config/charts/industry-overview.js里的apiUrl和transform函数,不用碰任何 JSX 或 useEffect 逻辑。
2.1 数据接入模块:统一抽象五类数据源,拒绝每个图表写一遍 fetch
该模块的核心是useDataSource自定义 Hook,它接收一个配置对象,自动判断数据来源类型并返回标准化的data、loading、error和refetch。关键不在“怎么请求”,而在“怎么兜底”——真实项目里,Mock 数据和线上 API 返回结构往往不一致,WebSocket 断连后如何降级,CSV 解析失败怎么提示,这些才是翻车高发区。
// src/hooks/useDataSource.js import { useState, useEffect, useCallback } from 'react'; import { parseCSV } from '@/utils/csvParser'; export function useDataSource(config) { const { type = 'mock', // 'mock' | 'json' | 'api' | 'websocket' | 'csv' source, // string: url / file path / ws endpoint transform, // (raw) => processedData, 必填,强制要求业务方做数据清洗 interval, // number: ms, 仅 api/websocket 生效 fallback = [] // 当请求失败或 transform 抛错时的兜底数据 } = config; const [data, setData] = useState(fallback); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchData = useCallback(async () => { try { setLoading(true); setError(null); let raw; switch (type) { case 'mock': raw = await import(`@/mock/${source}.js`).then(m => m.default()); break; case 'json': raw = await fetch(source).then(r => r.json()); break; case 'api': raw = await fetch(source).then(r => r.json()); break; case 'websocket': // 使用 useRef 存储 ws 实例,避免重复连接 const ws = new WebSocket(source); raw = await new Promise((resolve) => { ws.onmessage = (e) => resolve(JSON.parse(e.data)); ws.onerror = () => resolve(fallback); }); break; case 'csv': const csvText = await fetch(source).then(r => r.text()); raw = parseCSV(csvText); break; default: throw new Error(`Unknown data type: ${type}`); } // 强制要求 transform,否则报错提醒,杜绝 raw 直接塞给 ECharts if (typeof transform !== 'function') { throw new Error('transform function is required for data normalization'); } const processed = transform(raw); setData(processed); } catch (err) { console.error('[DataSource] Fetch failed:', err); setError(err.message); setData(fallback); } finally { setLoading(false); } }, [type, source, transform, fallback]); useEffect(() => { if (type === 'api' && interval) { const timer = setInterval(fetchData, interval); return () => clearInterval(timer); } if (type !== 'websocket') { fetchData(); } }, [type, fetchData, interval]); return { data, loading, error, refetch: fetchData }; }提示:
transform函数是本模块的“契约点”。比如地图下钻数据,API 返回的是{ province: "广东", value: 12345 }数组,而 ECharts 地图 series 需要data: [{ name: "广东", value: 12345 }]。这个转换必须由业务方在配置里显式声明,而不是在组件里硬编码。这样当后端字段名变更(如province→areaName),你只改一处transform,所有引用该数据源的图表自动生效。
2.2 图表配置中心:用 JSON Schema 描述图表,告别硬编码 option
ECharts 的setOption是个黑匣子,option 对象嵌套深、字段多、容错差。我们把每个图表的配置拆成两部分:基础 schema(描述图表类型、尺寸策略、是否联动)和动态 dataConfig(绑定具体数据源)。所有图表组件只认configProp,内部通过useEChartsOptionHook 将其编译为最终 option。
// src/config/charts/sales-trend.js export default { id: 'sales-trend', title: '销售额趋势(近30天)', type: 'line', // echart chart type grid: { left: '5%', right: '5%', top: '10%', bottom: '15%' }, responsive: true, resizeDebounce: 300, // 防抖毫秒数 theme: 'light', // 'light' | 'dark' | 'auto' dataConfig: { source: { type: 'api', source: '/api/v1/sales/trend', transform: (raw) => ({ xAxis: raw.map(item => item.date), series: [{ name: '销售额', data: raw.map(item => item.amount) }] }) } }, // 下面是 ECharts option 的静态部分,可被 dataConfig 动态覆盖 option: { tooltip: { trigger: 'axis' }, legend: { data: ['销售额'] }, xAxis: { type: 'category', boundaryGap: false }, yAxis: { type: 'value' }, series: [{ type: 'line', smooth: true, areaStyle: {}, emphasis: { focus: 'series' } }] } };useEChartsOptionHook 的核心逻辑是深度合并:先取config.option作为基底,再用dataConfig.transform()返回的对象做浅层覆盖(如替换xAxis、series),最后注入全局主题色、字体大小等。这样,A 同学想把“销售额趋势”改成“用户活跃度趋势”,只需复制sales-trend.js,改title、source、transform里的字段映射,完全不用碰 ECharts 的 option 细节。
2.3 布局编排引擎:配置即布局,不依赖 UI 拖拽也能灵活调整
很多大屏方案吹嘘“所见即所得拖拽”,结果交付时发现:拖拽逻辑和业务逻辑耦合,换个屏幕尺寸就错位,导出配置 JSON 无法复用。我们反其道而行——布局完全由 JSON 配置驱动,UI 层只做渲染。使用react-grid-layout的底层 GridItem API,但剥离所有交互逻辑,只保留layout配置数组。
// src/config/layout.js export default [ { i: 'sales-trend', // 对应 chart config.id x: 0, y: 0, w: 12, h: 6, minW: 6, minH: 4, static: false }, { i: 'province-map', x: 12, y: 0, w: 12, h: 10, minW: 8, minH: 6, static: true }, { i: 'alert-list', x: 0, y: 6, w: 8, h: 6, minW: 6, minH: 4, static: false } ];每个i字段关联到src/config/charts/下的具体图表配置。w/h是基于 24 栅格系统的相对宽度高度(总宽 24),minW/minH保证缩放时最小尺寸。static: true表示该区块禁止拖拽和缩放(如地图,拖拽会破坏地理坐标系)。组件层GridLayout只读取此配置,生成对应<GridItem>,并通过React.lazy动态加载图表组件,实现真正的按需加载。
2.4 状态同步中枢:用 Context + useReducer 管理跨图表联动与全局状态
当点击地图上的“江苏”,右侧“TOP10 城市”列表要刷新,下方“设备状态”热力图要过滤出江苏的设备——这种联动不能靠 props 一层层透传,也不能每个图表都监听全局事件。我们用一个轻量DashboardContext管理:
// src/context/DashboardContext.js import { createContext, useContext, useReducer } from 'react'; const DashboardContext = createContext(); const dashboardReducer = (state, action) => { switch (action.type) { case 'SET_GLOBAL_FILTER': return { ...state, globalFilter: action.payload }; case 'SET_THEME': return { ...state, theme: action.payload }; case 'SET_LOADING': return { ...state, loading: { ...state.loading, [action.payload.key]: action.payload.value } }; default: return state; } }; export function DashboardProvider({ children }) { const [state, dispatch] = useReducer(dashboardReducer, { theme: 'light', globalFilter: {}, // { province: '江苏', category: 'A' } loading: {} }); return ( <DashboardContext.Provider value={{ state, dispatch }}> {children} </DashboardContext.Provider> ); } export function useDashboard() { const context = useContext(DashboardContext); if (!context) { throw new Error('useDashboard must be used within a DashboardProvider'); } return context; }图表组件内通过useDashboard()获取dispatch,触发SET_GLOBAL_FILTER即可。例如地图组件点击后:
// 在 ProvinceMap.jsx 内 const handleClick = (params) => { dispatch({ type: 'SET_GLOBAL_FILTER', payload: { province: params.name } }); };而 TOP10 列表组件则订阅state.globalFilter.province,自动触发重新请求。这种模式让联动逻辑集中、可测试、易追溯,避免满屏useEffect(() => { ... }, [filter])的混乱。
3. 关键模块实现:从地图下钻到实时告警,每个功能都附可运行代码
这一章不讲原理,只给能粘贴进项目、改个路径就能跑的实操代码。我们挑三个最具代表性的模块:全国地图下钻(省→市→区)、WebSocket 实时告警滚动列表、双 Y 轴趋势对比图。它们覆盖了地理可视化、实时流处理、多维度指标对比三大高频需求,也是学生最容易写崩的点。
3.1 全国地图下钻:用 ECharts GeoJSON + 自定义事件实现三级联动
ECharts 官方地图只有省级,要下钻到市级,必须自己准备 GeoJSON。但网上下载的 JSON 文件体积大、坐标系不统一、边界有毛刺。我们采用“分级加载 + 缓存”策略:首次加载全国地图,点击某省后,动态import()对应的市级 GeoJSON(如import('@/geojson/shanghai.json')),避免首屏加载 5MB 地图文件。
// src/components/ProvinceMap.jsx import React, { useRef, useEffect, useState } from 'react'; import * as echarts from 'echarts/core'; import { SVGRenderer } from 'echarts/renderers'; import { MapChart } from 'echarts/charts'; import { TooltipComponent, VisualMapComponent, GeoComponent } from 'echarts/components'; import { useDashboard } from '@/context/DashboardContext'; import { useDataSource } from '@/hooks/useDataSource'; echarts.use([SVGRenderer, MapChart, TooltipComponent, VisualMapComponent, GeoComponent]); export default function ProvinceMap({ config }) { const chartRef = useRef(null); const chartInstance = useRef(null); const { dispatch } = useDashboard(); const [currentLevel, setCurrentLevel] = useState('province'); // 'province' | 'city' | 'district' const [geoJson, setGeoJson] = useState(null); // 加载基础全国地图数据 const { data: provinceData, loading: provinceLoading } = useDataSource({ type: 'json', source: '/data/province-data.json', transform: (raw) => raw.map(item => ({ name: item.name, value: item.value })), fallback: [] }); // 动态加载下级 GeoJSON const loadSubRegion = async (regionName) => { try { let json; if (currentLevel === 'province') { // 点击省,加载该省的市级 GeoJSON json = await import(`@/geojson/${regionName}.json`).then(m => m.default); } else if (currentLevel === 'city') { // 点击市,加载该市的区级 GeoJSON(需提前准备好) json = await import(`@/geojson/${regionName}-district.json`).then(m => m.default); } setGeoJson(json); setCurrentLevel(currentLevel === 'province' ? 'city' : 'district'); } catch (err) { console.warn(`Failed to load ${currentLevel} GeoJSON for ${regionName}:`, err); // 加载失败,退回上一级 setCurrentLevel(currentLevel === 'province' ? 'province' : 'province'); } }; useEffect(() => { if (!chartRef.current || !provinceData.length) return; const chart = echarts.init(chartRef.current, config.theme); chartInstance.current = chart; const updateChart = () => { const option = { tooltip: { trigger: 'item' }, geo: { map: currentLevel === 'province' ? 'china' : undefined, roam: true, label: { show: true }, emphasis: { label: { show: true } } }, series: [{ name: config.title, type: 'map', map: currentLevel === 'province' ? 'china' : undefined, data: provinceData, label: { show: true }, emphasis: { label: { show: true } } }] }; // 如果已加载下级 GeoJSON,注册到 ECharts if (geoJson && currentLevel !== 'province') { echarts.registerMap(currentLevel === 'city' ? 'shanghai' : 'shanghai-district', geoJson); option.series[0].map = currentLevel === 'city' ? 'shanghai' : 'shanghai-district'; option.series[0].data = []; // 下钻后数据需重新请求 } chart.setOption(option); }; updateChart(); // 绑定点击事件 chart.on('click', (params) => { if (currentLevel === 'province') { // 点击省,加载该省市级地图 loadSubRegion(params.name); // 同时广播筛选条件 dispatch({ type: 'SET_GLOBAL_FILTER', payload: { province: params.name } }); } else { // 点击市/区,触发更细粒度筛选 dispatch({ type: 'SET_GLOBAL_FILTER', payload: { province: currentLevel === 'city' ? params.name : undefined, city: currentLevel === 'city' ? params.name : undefined, district: currentLevel === 'district' ? params.name : undefined }}); } }); return () => { chart.dispose(); chartInstance.current = null; }; }, [provinceData, currentLevel, geoJson, config.theme, dispatch]); return ( <div ref={chartRef} style={{ width: '100%', height: '100%' }} /> ); }注意:
@/geojson/下的文件需提前准备好。上海示例中,shanghai.json是上海市所有地级市(实际为区)的 GeoJSON,shanghai-district.json是浦东新区等下辖区的 GeoJSON。文件命名必须与import()路径严格一致。这是地理可视化最耗时的准备工作,但一旦完成,后续所有下钻逻辑都复用同一套事件流。
3.2 WebSocket 实时告警滚动列表:断连自动重试 + 消息去重 + 滚动平滑
告警列表不是简单map渲染,它要解决三个现实问题:1)WebSocket 断开后页面白屏;2)同一条告警重复推送(因网络抖动);3)新消息插入顶部时,列表突然跳动影响阅读。我们用useRef缓存消息 ID Set,用setTimeout模拟重连,用 CSSscroll-behavior: smooth实现平滑滚动。
// src/components/AlertList.jsx import React, { useRef, useEffect, useState } from 'react'; import { useDashboard } from '@/context/DashboardContext'; export default function AlertList({ config }) { const listRef = useRef(null); const wsRef = useRef(null); const messageIdsRef = useRef(new Set()); const [alerts, setAlerts] = useState([]); const [isConnected, setIsConnected] = useState(false); const { state } = useDashboard(); const connectWebSocket = () => { const ws = new WebSocket(config.websocketUrl || 'ws://localhost:8080/alerts'); ws.onopen = () => { console.log('Alert WebSocket connected'); setIsConnected(true); // 连接成功后,清空旧 ID 缓存,避免历史消息重复 messageIdsRef.current.clear(); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); // 消息去重:检查 id 是否已存在 if (messageIdsRef.current.has(data.id)) return; messageIdsRef.current.add(data.id); // 插入顶部,并限制最多显示 50 条 setAlerts(prev => { const newAlerts = [data, ...prev]; return newAlerts.slice(0, 50); }); } catch (err) { console.error('Parse alert message failed:', err); } }; ws.onerror = (err) => { console.error('WebSocket error:', err); setIsConnected(false); }; ws.onclose = () => { console.log('WebSocket closed, retrying in 3s...'); setIsConnected(false); // 3秒后自动重连 setTimeout(connectWebSocket, 3000); }; wsRef.current = ws; }; useEffect(() => { connectWebSocket(); return () => { if (wsRef.current) { wsRef.current.close(); } }; }, []); // 滚动到最新消息(平滑) useEffect(() => { if (listRef.current && alerts.length > 0) { listRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } }, [alerts.length]); return ( <div className="alert-list-container"> <div className="alert-header"> <span>实时告警({isConnected ? '在线' : '离线'})</span> <span className="alert-count">{alerts.length}</span> </div> <div ref={listRef} className="alert-list"> {alerts.length === 0 ? ( <div className="alert-empty">暂无告警</div> ) : ( alerts.map((alert, index) => ( <div key={alert.id} className={`alert-item ${alert.level === 'critical' ? 'critical' : ''}`}> <div className="alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</div> <div className="alert-content">{alert.message}</div> <div className="alert-device">{alert.deviceId}</div> </div> )) )} </div> <style jsx>{` .alert-list-container { height: 100%; display: flex; flex-direction: column; } .alert-header { display: flex; justify-content: space-between; padding: 8px 12px; font-weight: bold; border-bottom: 1px solid #333; } .alert-count { background: #ff6b6b; color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; } .alert-list { flex: 1; overflow-y: auto; padding: 8px 0; } .alert-item { padding: 10px 12px; border-bottom: 1px solid #222; } .alert-item.critical { background: rgba(255, 107, 107, 0.1); } .alert-time { font-size: 12px; color: #aaa; margin-bottom: 4px; } .alert-content { font-weight: 500; margin-bottom: 4px; } .alert-device { font-size: 12px; color: #888; } .alert-empty { text-align: center; padding: 20px; color: #666; } /* 平滑滚动 */ .alert-list { scroll-behavior: smooth; } `}</style> </div> ); }提示:“断连重试”不是简单
onclose里new WebSocket(),因为重连期间可能有大量onmessage事件堆积。我们用setTimeout延迟重连,并在onopen后清空messageIdsRef,确保重连后收到的新消息不会被误判为重复。这是真实产线环境踩过的坑——某次网络波动导致 300+ 条重复告警刷屏。
3.3 双 Y 轴趋势对比图:一个配置搞定左右轴单位、刻度、颜色分离
销售数据(万元)和用户数(万人)放在同一张图上,必须双 Y 轴。但 ECharts 的yAxis配置容易写错:yAxis[0]和yAxis[1]的position、offset、scale、splitNumber都要手动对齐,稍有不慎就出现刻度错位、标签重叠。我们把双轴逻辑封装进useDualYAxisOptionHook,业务方只需声明左右轴字段和单位。
// src/hooks/useDualYAxisOption.js import { useMemo } from 'react'; export function useDualYAxisOption(config) { const { leftField = 'sales', // 左轴数据字段名 leftUnit = '万元', // 左轴单位 rightField = 'users', // 右轴数据字段名 rightUnit = '万人', // 右轴单位 data = [], // 原始数据数组,如 [{ date: '2023-01', sales: 120, users: 8.5 }] xAxisField = 'date' // X 轴字段名 } = config; return useMemo(() => { const xAxisData = data.map(item => item[xAxisField]); const leftData = data.map(item => item[leftField]); const rightData = data.map(item => item[rightField]); return { tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, legend: { data: [leftUnit, rightUnit] }, grid: { left: '5%', right: '5%', top: '10%', bottom: '15%' }, xAxis: { type: 'category', data: xAxisData, boundaryGap: false }, yAxis: [ { type: 'value', name: leftUnit, position: 'left', alignTicks: true, axisLine: { lineStyle: { color: '#5470C6' } }, axisLabel: { formatter: '{value} {unit}' } }, { type: 'value', name: rightUnit, position: 'right', alignTicks: true, axisLine: { lineStyle: { color: '#91CC75' } }, axisLabel: { formatter: '{value} {unit}' } } ], series: [ { name: leftUnit, type: 'line', yAxisIndex: 0, data: leftData, itemStyle: { color: '#5470C6' } }, { name: rightUnit, type: 'line', yAxisIndex: 1, data: rightData, itemStyle: { color: '#91CC75' } } ] }; }, [data, leftField, leftUnit, rightField, rightUnit, xAxisField]); } // 在组件中使用 // src/components/DualYAxisChart.jsx import React, { useRef, useEffect } from 'react'; import * as echarts from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; import { LineChart } from 'echarts/charts'; import { TooltipComponent, LegendComponent, GridComponent, DataZoomComponent } from 'echarts/components'; import { useDualYAxisOption } from '@/hooks/useDualYAxisOption'; import { useDataSource } from '@/hooks/useDataSource'; echarts.use([CanvasRenderer, LineChart, TooltipComponent, LegendComponent, GridComponent, DataZoomComponent]); export default function DualYAxisChart({ config }) { const chartRef = useRef(null); const chartInstance = useRef(null); const { data, loading } = useDataSource({ type: 'api', source: config.api, transform: (raw) => raw, fallback: [] }); const option = useDualYAxisOption({ leftField: 'sales', leftUnit: '销售额(万元)', rightField: 'users', rightUnit: '用户数(万人)', data, xAxisField: 'date' }); useEffect(() => { if (!chartRef.current || loading) return; const chart = echarts.init(chartRef.current); chartInstance.current = chart; chart.setOption(option); return () => { chart.dispose(); chartInstance.current = null; }; }, [loading, option]); return <div ref={chartRef} style={{ width: '100%', height: '100%' }} />; }注意:
alignTicks: true是关键。它强制左右 Y 轴的刻度线对齐,避免出现左轴 0/50/100,右轴 0/2/4/6 的错位感。axisLabel.formatter中的{unit}会被自动替换为传入的leftUnit/rightUnit,无需在 option 里硬编码。这种封装让 A 同学改一个业务场景(如把“销售额 vs 用户数”换成“能耗 vs 产量”),只需改leftField和leftUnit两个字符串,其他全部自动适配。
4. 避坑指南:五个让你在答辩前夜崩溃的高频问题及血泪解法
别等答辩 PPT 放到“系统演示”页才看到白屏、报错、数据不更新。这五个问题,是我带过的项目里,80% 的学生在最后一周集中爆发的“玄学故障”。它们不难,但排查路径曲折,文档里找不到答案,Stack Overflow 的答案往往是过时的。以下按“现象 → 原因 → 解决”给出可立即执行的方案。
4.1 现象:ECharts 图表首次渲染正常,窗口缩放后图表变形、文字挤压、坐标轴错位
原因:ECharts 实例未监听resize事件,或监听了但resize()方法调用时机不对(如在组件卸载后还调用)。React 18 的并发渲染也可能导致useEffect清理函数执行顺序异常。
解决:
- 在
useEffect中注册resize事件,必须在清理函数中移除:
useEffect(() => { const handleResize = () => { if (chartInstance.current) { chartInstance.current.resize(); // 注意:resize() 无参数 } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);- 更可靠的做法:用
ResizeObserver监听容器 DOM 变化(兼容性更好,且不依赖 window):
useEffect(() => { if (!chartRef.current) return; const resizeObserver = new ResizeObserver(() => { if (chartInstance.current) { chartInstance.current.resize(); } }); resizeObserver.observe(chartRef.current); return () => resizeObserver.disconnect(); }, []);- 确保
chartRef.current在resize()前已初始化,可在if (chartInstance.current && chartRef.current)双重校验。
4.2 现象:地图组件点击后,控制台报错TypeError: Cannot read property 'dispatch' of undefined
原因:useDashboard()Hook 在组件树中未被DashboardProvider包裹,或者DashboardProvider的位置太低(如只包了某个子路由,没包整个App)。
解决:
- 检查
main.jsx或index.js,确认DashboardProvider是最外层 Provider:
// main.jsx import { DashboardProvider } from '@/context/DashboardContext'; import { RouterProvider } from 'react-router-dom'; ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <DashboardProvider> {/* 必须在这里 */} <RouterProvider router={router} /> </DashboardProvider> </React.StrictMode> );- 在
useDashboard()开头加防御性报错:
export function useDashboard() { const context = useContext(DashboardContext); if (!context) { throw new Error( 'useDashboard must be used within a DashboardProvider. ' + 'Did you forget to wrap your app with <DashboardProvider>?' ); } return context; }这样报错信息直指问题根源,而不是模糊的undefined.dispatch。
4.3 现象:WebSocket 连接成功,但onmessage从未触发,或只触发一次
原因:后端发送的消息不是合法 JSON 字符串(如多了逗号、中文引号、BOM 头),或前端onmessage回调里JSON.parse()抛错后静默失败。
解决:
- 在
onmessage中加完整 try-catch,并打印原始event.data:
ws.onmessage = (event) => { console.log('Raw message:', event.data); // 关键!先看原始数据 try { const data = JSON.parse(event.data); // 处理 data... } catch (err) { console.error('Failed to parse JSON:', err, 'Raw:', event.data); } };- 用浏览器开发者工具的 Network 标签页,过滤 WS,点击连接,查看 Frames,确认后端发送的是纯 JSON(无 HTML、无注释、无 BOM)。常见坑:后端用
res.send(JSON.stringify(data))但data本身已是字符串,导致双重序列化。
4.4 现象:切换主题(light/dark)后,地图颜色不变,或图表文字消失
原因:ECharts 主题未正确注册,或init时传入的主题名与registerTheme时不一致。ECharts 4.x 之后,主题必须显式注册,且init(dom, themeName)的themeName必须与registerTheme(themeName, themeConfig)的themeName完全相同(区分大小写)。
解决:
- 确保在
main.jsx中注册主题:
import { registerTheme } from 'echarts/core'; import darkTheme from '@/themes/dark.json'; import lightTheme from '@/themes/light.json'; registerTheme('dark', darkTheme); registerTheme('light', lightTheme);- 图表初始化时,传入字符串
'dark'或'light',不能传对象:
// ✅ 正确 const chart = echarts.init(dom, 'dark'); // ❌ 错误:传对象会导致主题失效 const chart = echarts.init(dom, darkTheme);- 检查主题 JSON 文件,确认 `
本文还有配套的精品资源,点击获取