简介:本资源是一个面向C# WPF开发者的ECharts图表集成实战项目,专为需要在桌面应用中嵌入交互式数据可视化功能的中高级开发者设计。项目完整演示了如何通过WebBrowser控件加载ECharts JS库、注入配置脚本、实现C#与JavaScript双向通信,并支持动态数据更新与用户事件响应,有效解决WPF原生控件图表能力不足的痛点。压缩包共60个文件,含11个核心C#源码(如MainWindow.xaml.cs、App.xaml.cs)、20个JS脚本(含ECharts初始化与图表配置)、2个XAML界面文件、3个可执行exe及配套dll、config、sln等工程文件,结构清晰,开箱即用;整体大小仅827KB,轻量易部署。目前已有170人学习下载,读者可直接复用项目框架、参考完整的目录组织逻辑、掌握WPF+WebBrowser+ECharts协同开发的关键步骤与避坑要点,快速落地业务场景中的图表展示需求。
1. 为什么在 WPF 里硬塞 ECharts 不是“调用 JS 库”,而是重构渲染链路?
很多刚接触 C# 数据可视化的人,看到WebBrowser.NavigateToString就以为“把 HTML 塞进去,echarts 就跑起来了”——结果图表不显示、数据传不进、鼠标悬停没响应,甚至整个窗口卡死。这不是代码写错了,而是对 WPF 渲染模型和 ECharts 运行时环境的根本误判。ECharts 是纯前端库,依赖 DOM、Canvas、事件循环和完整的浏览器 JavaScript 引擎;而 WPF 的WebBrowser控件(基于 IE 内核或 Edge WebView2)本质是嵌入式 Web 容器,它和宿主进程之间存在严格的跨进程边界、脚本执行沙箱、DOM 生命周期隔离。直接拼 HTML 字符串注入,会丢失window上下文、无法注册resize监听、echarts.init()返回的实例无法被 C# 持有,更别说动态更新数据或响应点击事件。这个项目EChartsDemo_C#_echarts的价值,恰恰在于它绕开了“简单拼接”的陷阱,用WebView2+CoreWebView2初始化控制权、AddScriptToExecuteOnDocumentCreated预埋初始化逻辑、RegisterAsyncObject暴露 C# 方法供 JS 调用,把 ECharts 从“被渲染的静态内容”变成“与 WPF 共享状态的协同组件”。它适合正在开发工业上位机、设备监控大屏、实验室数据采集终端的 C# 工程师——你需要的不是“能画图”,而是“图能实时响应传感器数据流、支持右键导出 PNG、点击钻取明细记录、缩放时 UI 不卡顿”。
2. 用 WebView2 替代 WebBrowser:从 DOM 加载失败到稳定初始化的关键跃迁
2.1 为什么 WebBrowser 在 .NET 6+ 中必须淘汰
WPF 默认的WebBrowser控件绑定的是已停更的 IE11 引擎(即使系统装了 Edge),其 JavaScript 引擎为 JScript9,不支持Promise、async/await、Map/Set等现代语法。ECharts 5.x 的源码中大量使用Array.from()、Object.assign()和fetchAPI,直接导致echarts.min.js加载后报SyntaxError: Unexpected token 'const'。更致命的是,WebBrowser.NavigateToString()的 HTML 注入时机不可控:document.readyState可能为"loading"或"interactive",此时调用echarts.init()会因#main元素未挂载而返回null。而WebView2基于 Chromium,完整支持 ES2019+,且提供CoreWebView2InitializationCompleted事件,确保 JS 运行环境就绪后再执行初始化逻辑。
提示:项目中
.csproj文件若仍引用<PackageReference Include="Microsoft.Toolkit.Wpf.UI.Controls.WebView" />,需立即替换为Microsoft.Web.WebView2(v1.0.2420.43+)。NuGet 包名变更易被忽略,但旧包在 .NET 6+ 下会引发TypeLoadException。
2.2 WebView2 初始化四步法:从控件声明到 echarts 实例就绪
2.2.1 XAML 中声明 WebView2 控件并绑定生命周期
<!-- MainWindow.xaml --> <wpf:WebView2 x:Name="EChartsWebView" Source="about:blank" CreationProperties="{Binding WebView2CreationProperties}" Margin="0,0,0,0"/>关键点在于Source="about:blank"强制触发CoreWebView2InitializationCompleted事件,而非依赖Navigate()后的异步加载。CreationProperties绑定到 ViewModel 中的CoreWebView2EnvironmentOptions,用于启用开发者工具(调试 JS 错误必备):
// MainWindow.xaml.cs public CoreWebView2EnvironmentOptions WebView2CreationProperties { get; } = new CoreWebView2EnvironmentOptions("--remote-debugging-port=9222");2.2.2 在 CoreWebView2 初始化完成后注入 ECharts 环境
private async void EChartsWebView_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e) { if (e.IsSuccess) { // 步骤1:预加载 echarts.min.js 到内存(避免 CDN 延迟) var jsContent = await File.ReadAllTextAsync("echarts.min.js"); await EChartsWebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(jsContent); // 步骤2:注入初始化 HTML 模板(含 #main 容器和基础样式) var htmlTemplate = @" <html><head> <meta charset='utf-8'> <style>body{margin:0;padding:0;}#main{width:100%;height:100%;}</style> </head><body><div id='main'></div></body></html>"; EChartsWebView.NavigateToString(htmlTemplate); } }AddScriptToExecuteOnDocumentCreatedAsync确保 JS 在document创建后、DOMContentLoaded之前执行,此时window.echarts已可用,但#main尚未渲染——这正是下一步的切入点。
2.2.3 使用ExecuteScriptAsync动态创建图表容器并初始化
private async void EChartsWebView_CoreWebView2Initialized(object sender, CoreWebView2InitializedEventArgs e) { // 等待 document.body 可用(规避 DOM 未就绪) await EChartsWebView.CoreWebView2.ExecuteScriptAsync(@" function waitForBody() { if (document.body) { return Promise.resolve(); } else { return new Promise(resolve => setTimeout(resolve, 10)); } } waitForBody().then(() => { const div = document.createElement('div'); div.id = 'main'; div.style.width = '100%'; div.style.height = '100%'; document.body.appendChild(div); window.myChart = echarts.init(document.getElementById('main')); }); "); }此段 JS 显式创建#main并调用echarts.init(),将实例挂载到window.myChart,为后续 C# 调用预留入口。
2.2.4 注册 C# 对象供 JS 调用,实现双向通信
// 在 CoreWebView2Initialized 事件中追加 await EChartsWebView.CoreWebView2.AddHostObjectToScript("CSharpBridge", new ChartBridge(this)); public class ChartBridge { private readonly MainWindow _owner; public ChartBridge(MainWindow owner) => _owner = owner; [WebView2WebMessageReceived] public async void OnDataUpdate(string jsonData) { // 接收 JS 发来的数据更新请求(如点击事件) var data = JsonSerializer.Deserialize<ChartData>(jsonData); _owner.UpdateChartFromClick(data); } public void ExportToPng() => _owner.ExportChartAsPng(); }AddHostObjectToScript将 C# 对象暴露给 JS 全局作用域,JS 可通过window.chrome.webview.postMessage()触发 C# 方法,形成闭环。
3. 实现 echarts 中国地图与动态数据绑定:从静态 JSON 到实时坐标映射
3.1 加载中国地图 GeoJSON 的三种方式对比
| 方式 | 优点 | 缺陷 | 适用场景 |
|---|---|---|---|
CDN 直接引入(https://cdn.jsdelivr.net/npm/echarts@5.4.3/map/json/china.json) | 无需本地文件,版本自动更新 | 网络波动导致地图加载失败,离线不可用 | 快速原型验证 |
| 内嵌 Base64 字符串 | 完全离线,无网络依赖 | JSON 体积大(约 400KB),XAML 中硬编码降低可维护性 | 固定地图、无更新需求的嵌入式设备 |
资源文件嵌入 +GetResourceStream | 二进制打包,加载快,支持多语言地图 | 需手动管理资源路径,首次加载稍慢 | 工业上位机、医疗监测等强稳定性场景 |
项目采用第三种方式,将china.json设为Resource构建操作,并通过Application.GetResourceStream()读取:
private async Task<string> LoadChinaGeoJson() { var uri = new Uri("pack://application:,,,/Resources/china.json"); using var stream = Application.GetResourceStream(uri).Stream; using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(); }3.2 在 JS 中注册地图并配置 series
private async void InitializeChinaMap() { var geoJson = await LoadChinaGeoJson(); await EChartsWebView.CoreWebView2.ExecuteScriptAsync($@" // 注册中国地图 echarts.registerMap('china', {geoJson}); // 初始化图表选项 window.myChart.setOption({{ tooltip: {{ trigger: 'item', formatter: '{{b}}<br/>数值: {{c}}' }}, visualMap: {{ min: 0, max: 100, text: ['高', '低'], realtime: false, calculable: true, inRange: {{ color: ['#e07171', '#50a3ba'] }} }}, series: [ {{ name: '人口分布', type: 'map', map: 'china', label: {{ show: true }}, data: [] }} ] }}); "); }注意visualMap.realtime: false—— 若设为true,每次数据更新都会触发颜色重计算,导致高频刷新时 CPU 占用飙升。工业场景中应改为false,配合myChart.setOption(option, { notMerge: true })手动控制合并策略。
3.3 动态绑定传感器数据:从 List 到 GeoJSON coordinates 映射
假设设备采集的数据结构为:
public class SensorData { public string Province { get; set; } // 如 "广东省" public double Value { get; set; } // 当前温度值 public DateTime Timestamp { get; set; } }需将Province名称映射为 GeoJSON 中的properties.name。ECharts 中国地图的features数组中,每个省的properties.name为全称(如"广东省"),但部分数据源可能用简称("广东")或拼音("Guangdong")。项目内置映射表:
private static readonly Dictionary<string, string> ProvinceMapping = new() { ["广东"] = "广东省", ["北京"] = "北京市", ["上海"] = "上海市", ["Guangdong"] = "广东省", ["Beijing"] = "北京市" };生成 EChartsdata数组的 C# 方法:
public string GenerateMapData(List<SensorData> dataList) { var mappedData = dataList.Select(d => { var province = ProvinceMapping.GetValueOrDefault(d.Province, d.Province); return $"{{name: '{province}', value: {d.Value}}}"; }); return $"[{string.Join(",", mappedData)}]"; } // 调用 JS 更新 await EChartsWebView.CoreWebView2.ExecuteScriptAsync($@" window.myChart.setOption({{ series: [{{ data: {GenerateMapData(sensorList)} }}] }}, {{ notMerge: true }}); ");注意:
notMerge: true防止历史数据残留。若省略此参数,多次调用setOption会导致data数组不断追加,最终内存溢出。
4. 解决 C# WPF 中 echarts 图表卡顿与 UI 刷新冲突的核心技巧
4.1 避免主线程阻塞:将数据采集与图表更新解耦
工业现场常见问题:Modbus RTU 每 100ms 读取一次寄存器,while(true)循环中直接调用myChart.setOption(),导致 UI 线程每秒被抢占 10 次,窗口拖拽卡顿、按钮点击无响应。根本解法是分离采集线程与渲染线程:
private readonly ConcurrentQueue<SensorData> _dataQueue = new(); private readonly CancellationTokenSource _renderCts = new(); private async Task StartDataCollectionAsync() { while (!_collectionCts.IsCancellationRequested) { var data = await ReadFromModbusAsync(); // 非阻塞异步读取 _dataQueue.Enqueue(data); await Task.Delay(100, _collectionCts.Token); } } private async Task StartRenderLoopAsync() { while (!_renderCts.IsCancellationRequested) { if (_dataQueue.TryDequeue(out var data)) { // 批量收集 5 条再更新,降低 JS 调用频次 var batch = new List<SensorData> { data }; while (_dataQueue.TryDequeue(out data) && batch.Count < 5) batch.Add(data); await UpdateChartAsync(batch); // 执行 JS 更新 } await Task.Delay(50, _renderCts.Token); // 渲染间隔不低于 50ms } }ConcurrentQueue保证线程安全,Task.Delay(50)限制最大刷新率为 20 FPS,既满足人眼感知流畅性,又避免过度消耗 WebView2 渲染资源。
4.2 优化 JS 执行性能:用setOption的notMerge和replaceMerge参数
ECharts 默认setOption会深度合并新旧配置,对大型地图数据(如含 34 个省份的data数组)耗时可达 15~30ms。项目中强制指定合并策略:
| 参数 | 行为 | 适用场景 | 平均耗时 |
|---|---|---|---|
notMerge: true | 完全替换 series.data,不比较差异 | 数据全量更新(如每分钟刷新) | ~5ms |
replaceMerge: ['series'] | 仅替换 series 数组,保留 tooltip/visualMap 等全局配置 | 高频局部更新(如单点温度突变) | ~8ms |
| 默认(无参数) | 深度遍历比对每个字段 | 配置微调(如修改标题文字) | ~22ms |
实际调用示例:
await EChartsWebView.CoreWebView2.ExecuteScriptAsync($@" window.myChart.setOption({{ series: [{{ data: {jsonBatch} }}] }}, {{ replaceMerge: ['series'] }}); ");4.3 处理 WebView2 内存泄漏:强制回收 chart 实例与事件监听器
长期运行的上位机程序中,若用户反复切换图表类型(柱状图 → 地图 → 折线图),myChart.dispose()未被调用会导致内存持续增长。项目在MainWindow.Closing事件中注入清理脚本:
private async void MainWindow_Closing(object sender, CancelEventArgs e) { await EChartsWebView.CoreWebView2.ExecuteScriptAsync(@" if (window.myChart) { window.myChart.dispose(); window.myChart = null; } // 清理所有事件监听器 if (window.removeEventListener) { window.removeEventListener('resize', window.onResizeHandler); } "); }同时,在InitializeChinaMap()中为resize事件添加防抖:
window.onResizeHandler = debounce(() => { if (window.myChart) window.myChart.resize(); }, 200); function debounce(func, wait) { let timeout; return function executedFunction() { const later = () => { clearTimeout(timeout); func(...arguments); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }200ms 防抖将窗口拉伸时的resize事件从数十次压缩为 1~2 次,避免myChart.resize()频繁触发重绘。
5. 实现 echarts 饼图点击钻取与字段级数据导出:从展示层到业务层穿透
5.1 捕获饼图点击事件并传递 C# 处理
ECharts 的click事件默认在 JS 层处理,但工业场景需要点击后查询数据库明细。项目通过registerAction注册自定义动作,并用postMessage透传数据:
await EChartsWebView.CoreWebView2.ExecuteScriptAsync(@" window.myChart.on('click', function(params) { // 过滤非饼图点击 if (params.seriesType !== 'pie') return; // 构造结构化数据 const payload = { seriesName: params.seriesName, name: params.name, value: params.value, percent: params.percent }; // 发送给 C# window.chrome.webview.postMessage(JSON.stringify(payload)); }); ");C# 端监听消息并执行业务逻辑:
EChartsWebView.CoreWebView2.WebMessageReceived += (sender, args) => { try { var payload = JsonSerializer.Deserialize<PieClickPayload>(args.WebMessageAsJson); // 根据 payload.name 查询 SQL Server 中该省份的详细传感器记录 var details = _dbContext.SensorRecords .Where(r => r.Province == payload.Name && r.Timestamp > DateTime.Now.AddHours(-1)) .ToList(); // 在新窗口显示明细表格 new DetailWindow(details).Show(); } catch (JsonException ex) { Debug.WriteLine($"Pie click parse error: {ex.Message}"); } };5.2 导出当前图表为 PNG:绕过 WebView2 截图黑屏问题
WebView2.CapturePreview()在某些显卡驱动下返回黑色图片。项目改用 ECharts 自带的getDataURL()方法,确保导出质量:
private async Task ExportChartAsPng() { var dataUrl = await EChartsWebView.CoreWebView2.ExecuteScriptAsync(@" window.myChart.getDataURL({type: 'png', pixelRatio: 2}); "); // dataUrl 形如 "data:image/png;base64,iVBORw0KGgoAAAANS..." var base64 = dataUrl.Trim('"').Split(',')[1]; var bytes = Convert.FromBase64String(base64); var saveDialog = new SaveFileDialog { Filter = "PNG Image|*.png", FileName = $"chart_{DateTime.Now:yyyyMMdd_HHmmss}.png" }; if (saveDialog.ShowDialog() == true) { await File.WriteAllBytesAsync(saveDialog.FileName, bytes); } }pixelRatio: 2启用 Retina 高清导出,适配 4K 显示屏。导出的 PNG 与 ECharts 官网示例完全一致,无锯齿、无字体模糊。
5.3 配置 echarts 饼图 legend 交互:禁用图例点击隐藏系列
默认情况下,点击图例项会隐藏对应数据系列,但在监控场景中需强制显示全部数据。项目在初始化时关闭图例交互:
await EChartsWebView.CoreWebView2.ExecuteScriptAsync(@" window.myChart.setOption({{ legend: {{ data: ['温度', '湿度', '压力'], selectedMode: false // 关键:禁用点击切换 }} }}); ");selectedMode: false使图例变为纯展示,避免操作员误点导致数据消失。若需保留交互,可设为'single'或'multiple',但必须配套legend.select事件监听,记录用户选择状态并同步到后台配置。
注意:
legend.selectedMode与series.silent不同——后者禁用系列内所有交互(包括 tooltip),而前者仅控制图例开关行为。项目中二者需按需组合使用。
本文还有配套的精品资源,点击获取