
简介集成WebView2内核的WinForm桌面浏览器程序源码面向C#/.NET开发者适合需要快速搭建个性化浏览器或学习WebView2与桌面应用结合方式的人群。程序采用Visual Studio 2019编写以类似Edge、Chrome的交互实现自用定制浏览体验项目可正常编译运行便于二次扩展资源包共134个文件以cs源码、dll依赖库、png界面资源、xml配置等类型为主压缩包大小15.33MB目录结构清晰涉及工程文件、图标及依赖配置适合按模块查阅。目前已有1097人学习下载。通过这份源码可掌握WebView2内核初始化、WinForm窗体布局、依赖库引用与界面资源打包等关键环节同时配套博文提供了编译配置说明与常见排错思路能减少环境搭建中的时间成本无论是用于个人工具开发还是教学示例都具有较好的参考价值适合有一定WinForm基础并想深入浏览器定制方向的开发者。1. 自用浏览器做到什么程度才算好用WinForm WebView2 的取舍如果你手头攒了十几个内部系统网址每天要开三个浏览器、登录五遍、还要在广告页里翻找常用的那个按钮你会意识到所谓“浏览器”其实只差最后一步把它剪裁成自己的工作台。WinFormWebView2 自用个性化浏览器就是拿 C# 写一个 WinForm 壳把 WebView2 控件当内核做成只替你干活的桌面程序。网页不用重写登录态不用丢窗体侧还能加快捷键、拦截规则和本地能力——这是桌面程序开发里性价比很高的一条路。它适合三类人给公司做内部工具的技术人员不想被导航页和广告绑架的普通用户以及想在 WinForm 项目里平滑嵌入网页又不愿意引入重型框架的开发者。相比 CefSharp 那种把整个 Chromium 塞进进程的做法WebView2 把内核交给系统的 Runtime 管理应用壳只管交互和业务更新和维护都要轻得多。这篇笔记我会从 Runtime 选型讲起走到初始化、个性化和打包分发把能复现的代码和翻车点都摊开说。2. 为什么是 WebView2 而不是 CefSharpRuntime 选型与环境准备2.1 WebView2 Runtime 有什么用先分清 SDK 包和运行时很多第一次接触的人在 NuGet 里装完Microsoft.Web.WebView2包代码写完一跑弹一个 “could not find the webview2 runtime” 就懵了。这里有个关键概念必须分清SDK 包只是 C# 侧的程序集和 API 定义真正干活的是装着 Chromium 内核的 WebView2 Runtime。SDK 负责给你一个操作入口Runtime 才是那个渲染页面、执行 JavaScript、管理 Cookie 的浏览器本体。Runtime 有安装版和离线包两种形态。开发机上一般会被 Edge 浏览器顺带装上常青版 Runtime所以你本地跑得起来换一台干净机器就立刻翻车。判断系统里有没有 Runtime最直接的办法是查注册表reg query HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5} /v pv这行命令查的是 64 位系统上 32 位 Runtime 的版本号返回值形如109.0.1518.49。如果你在 32 位系统上路径要去掉WOW6432Node段。没有返回值不代表一定没有 Runtime它也可能装成按用户安装的版本所以更稳妥的做法是写一段初始化代码让程序自己把版本打到日志里。var env await CoreWebView2Environment.CreateAsync( browserExecutableFolder: null, userDataFolder: null, options: null); string version env.BrowserVersionString; Console.WriteLine(WebView2 Runtime 版本: version);CreateAsync的三个参数第一个指固定版 Runtime 的目录传null表示用系统安装的常青版第二个是用户数据目录传null就落到默认位置第三个是环境选项后面做 CDP 调试时会用到。能打出版本号说明 Runtime 链路是通的这时候再报错就是别的坑了。2.2 开发环境三件套SDK 包、离线 Runtime、VS 配置常见做法是准备三样东西再开工。第一是 NuGet 包在包管理器控制台里用命令安装Install-Package Microsoft.Web.WebView2这个包会拉下来 WinForms 和 WPF 两套控件。VS2015 用户注意WebView2 官方要求 .NET Framework 4.6.2 或更高VS2015 只要装了对应的 Developer Pack 就能编译但 NuGet 还原、C# 7.0语法和async await支持要留意项目语言版本设置别让旧 IDE 把新语法拦在门外。第二是离线 Runtime 安装包。做分发时你不可能要求每台目标机器都挂着 Edge 或者联网下载正确姿势是下载MicrosoftEdgeWebView2Runtime完整版或固定版离线包部署时静默安装。网上流传的所谓“精简版 Runtime”不建议碰它阉割了组件有些页面打印、PDF 预览功能会莫名失踪排查起来够你喝一壶。第三是 IDE 本身。建议在 Visual Studio 里把项目的“目标平台”显式设为 x64 或 x86不要留 AnyCPU。Runtime 是按位数安装的AnyCPU 跑在 64 位系统上会优先加载 64 位 Runtime而你机器上恰好只装了 32 位固定版就会报找不到运行时。这种事情发生一次就长记性了。2.3 常青版还是固定版版本策略别等交付了再改WebView2 Runtime 有两种分发模式很多人把项目写完了才纠结这个。常青版是跟着 Edge 一起自动更新的版本你的应用每次启动用的是最新内核安全修复和渲染能力自动跟上代价是你没法锁定内核行为某个页面昨天正常今天渲染变了你只能接受。固定版是把特定版本的 Runtime 文件放在应用目录里完全自包含版本可控但 Chromium 内核的安全补丁要你自己跟进。对比项常青版固定版更新方式系统自动更新应用随包分发手动升级安装体积不占应用目录每个应用约 150MB 起版本一致性受系统策略影响完全锁定适合场景内部工具、个人自用交付给客户、监管环境我做自用浏览器一般选常青版省心安全更新不用自己惦记。但如果是做成安装包交付给别人我强烈建议固定版加预先检测。固定版不写注册表初始化时在browserExecutableFolder参数里指向Runtime子目录即可代码里要做一次文件存在性检查不存在就提示运行引导安装程序。版本策略在动手前定好后面打包、升级、排错都会稳很多。3. 在 WinForm 里跑起第一个 WebView2 页面最小工程与初始化参数3.1 从空白窗体到浏览器控件最小工程的文件清单一个能跑的最简 WinFormWebView2 工程只需要三个文件Program.cs负责启动消息循环MainForm.cs承载窗体逻辑MainForm.Designer.cs描述控件布局。源码结构长这样SelfBrowser/ ├── Program.cs ├── MainForm.cs ├── MainForm.Designer.cs └── SelfBrowser.csprojProgram.cs里是标准的 WinForm 入口唯一要注意的是[STAThread]特性不能丢WebView2 内部依赖单线程单元模型少了它控件可能初始化异常。using System; using System.Windows.Forms; namespace SelfBrowser { internal static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } } }这个文件基本不用改重点在窗体设计。我习惯在 Designer 里放一个TableLayoutPanel第一行放地址栏和导航按钮第二行放 WebView2 控件这样窗体重缩放大时浏览器区域会自动跟着伸缩。MainForm.Designer.cs里初始化的代码是 IDE 自动生成的但有几个属性需要手动确认webView.Dock DockStyle.Fill并且把webView的Name设成webView后续事件绑定靠这个变量名。3.2 初始化阻塞点为什么 EnsureCoreWebView2Async 必须在控件可见后调用WebView2 控件加入窗体后还需要异步初始化内核。这个初始化的时机有讲究EnsureCoreWebView2Async之前控件应该已经完成Controls.Add和句柄创建否则某些版本会抛InvalidOperationException。常见做法是在窗体的Load事件里做初始化此时窗体句柄已就绪。private async void MainForm_Load(object sender, EventArgs e) { try { string userData Path.Combine( Application.StartupPath, UserData); var env await CoreWebView2Environment.CreateAsync( browserExecutableFolder: null, userDataFolder: userData, options: null); await webView.EnsureCoreWebView2Async(env); webView.CoreWebView2.Navigate(https://example.com); } catch (Exception ex) { MessageBox.Show(初始化失败: ex.Message); } }这里有两个新手必踩的点。第一不要用.Result去同步等待CreateAsync它在 UI 线程上会造成经典死锁因为异步回调需要回到 UI 线程而线程正被阻塞。第二userDataFolder不要留空字符串WebView2 的缓存、Cookie、LocalStorage 全在这里默认位置在用户临时目录重启清理临时文件时会把登录态一起清掉。把它挪到程序目录下的UserData子目录备份和清理都可控。CoreWebView2.Navigate里的地址可以随时换。此时CoreWebView2对象已经可用后续所有注入、拦截、脚本调用的入口都在这个对象上。执行完这一步你的 WinForm 程序已经是一个能打开的浏览器了剩下的就是让它“好用”。3.3 让地址跳转和加载进度先能感知导航事件与状态栏联动裸奔的浏览器只能打开一个固定页面用户没有反馈体验为零。接入导航事件是浏览器个性化的第一层。WinForms 的StatusStrip加一个ToolStripStatusLabel然后在NavigationStarting和NavigationCompleted里更新它。webView.NavigationStarting (s, e) { statusLabel.Text 正在加载: e.Uri; toolStripProgressBar.Visible true; }; webView.NavigationCompleted (s, e) { toolStripProgressBar.Visible false; if (e.IsSuccess) { statusLabel.Text 完成: webView.Source.ToString(); addressBox.Text webView.Source.ToString(); } else { statusLabel.Text 加载失败: e.WebErrorStatus.ToString(); } };NavigationStarting事件里可以做很多事比如拦截指定域名、埋点统计、甚至根据 URL 决定是否注入脚本。e.Uri是原始请求地址e.IsSuccess和e.WebErrorStatus是判断成败的唯二依据。有的页面 HTTP 状态码返回 404 但IsSuccess仍可能为 false这属于正常现象不要拿 HTTP 状态码和 WebErrorStatus 混着判断。到这里一个带地址刷新和状态反馈的最小浏览器已经成立。下一步才进入重头戏让它变成“你的”浏览器。4. 做成“我的浏览器”脚本注入、资源拦截与界面美化4.1 注入自定义脚本屏蔽页面噪音而不是写插件很多网页不提供关闭广告位或侧边栏的开关但前端 DOM 就在那里你完全可以在页面加载完成后插入一段自己的脚本。这在 WebView2 里就是ExecuteScriptAsync一行事配合导航事件正好做“页面级定制”。private async Task InjectPageCustomizations(string url) { string script (function() { if (location.hostname mail.example.com) { var banner document.querySelector(.ad-banner); if (banner) { banner.style.display none; } document.body.classList.add(compact-layout); } if (location.pathname.startsWith(/report)) { var btn document.createElement(button); btn.textContent 导出当前报表; btn.onclick function() { window.chrome.webview.postMessage(export-report); }; document.body.appendChild(btn); } })(); ; await webView.CoreWebView2.ExecuteScriptAsync(script); }注入逻辑要放在NavigationCompleted里调用这时候文档已就绪。window.chrome.webview.postMessage是 WebView2 提供的桥接 API网页侧的按钮点击可以发消息给 WinForm再在WebMessageReceived事件里接收webView.WebMessageReceived (s, e) { if (e.TryGetWebMessageAsString() export-report) { // 这里调用 C# 侧导出逻辑 ExportReport(); } };注入脚本最常踩的坑是时机。有些页面是 SPA导航完成之后 DOM 还会被框架重绘你注入的隐藏样式会被覆盖。这时候不要硬调setTimeout正确做法是配合MutationObserver在脚本里监听 DOM 变化反复应用样式。另外注意脚本字符串里别用 C# 插值$拼接网页代码网页代码里的{}会和插值语法打架用普通字符串加占位符替换更稳。4.2 页面嗅探与资源拦截同一个 URL 返回不同内容个性化浏览器的进阶玩法是把网络请求拦下来换成你自己的内容。比如某个旧系统引用的图片资源已经失效或者你想把耗时的大图替换成本地压缩版WebResourceRequested事件都能做到。webView.CoreWebView2.AddWebResourceRequestedFilter( *, CoreWebView2WebResourceContext.All); webView.CoreWebView2.WebResourceRequested async (s, e) { if (e.Request.Uri.EndsWith(/logo.png, StringComparison.OrdinalIgnoreCase)) { string localPath Path.Combine( Application.StartupPath, Resources, logo.png); var stream new FileStream(localPath, FileMode.Open, FileAccess.Read); e.Response webView.CoreWebView2.Environment.CreateWebResourceResponse( stream, 200, OK, Content-Type: image/png; Content-Length: stream.Length); } };AddWebResourceRequestedFilter的过滤规则支持*通配也能写具体域名加路径。回调里e.Request.Uri是完整请求地址e.Response一旦赋值原请求就不会真正发出去。替换响应时Content-Length头最好自己带上否则个别站点会出现加载一半的假象。这里有个容易忽略的细节如果页面里存在 Service Worker 或 HTTP 缓存你的拦截可能不会触发。开发时可以先在WebResourceResponseReceived里打印已缓存的响应状态确认是真走了网络再调拦截逻辑。资源拦截适合“已知死链替换”和“弱网模拟”不建议拿它做全量改包性能和可维护性都会失控。4.3 右键菜单、快捷键与地址栏让操作习惯留在桌面侧既然是“自用”浏览器浏览器本身的右键菜单往往不符合你自己的使用习惯。WinForm 侧完全可以把网页区域包一层自定义ContextMenuStrip把复制、翻译、固定标签、打开本地工具这些动作放在手边。var contextMenu new ContextMenuStrip(); var copyItem new ToolStripMenuItem(复制选中文本, null, async (s, e) { string result await webView.CoreWebView2.ExecuteScriptAsync( window.getSelection().toString();); Clipboard.SetText(result.Trim()); }); var translateItem new ToolStripMenuItem(用默认浏览器打开, null, (s, e) { Process.Start(webView.Source.ToString()); }); contextMenu.Items.Add(copyItem); contextMenu.Items.Add(translateItem); webView.ContextMenuStrip contextMenu;ExecuteScriptAsync返回的是 JSON 字符串所以取选中文本时记得Trim()。WinForm 里给 WebView2 挂右键菜单不需要处理网页内部事件直接设置控件的ContextMenuStrip属性即可。网页里如果有自己的右键菜单会在 WebView2 的内部层显示你设置的菜单在它上层这是分层绘制的结果属于正常行为。快捷键方面在窗体KeyDown事件里判断 CtrlT 开新标签、CtrlW 关当前页。注意先设KeyPreview true否则焦点在 WebView2 内部时按键事件不会冒泡到窗体。4.4 用 DataGridView 做收藏侧栏WinForm 原生控件和网页并存个性化浏览器的界面美化不一定非要画自定义控件把 WinForm 原生的DataGridView用好了效果就很实用。我在窗体内右侧放一个 260 像素宽的收藏面板用DataGridView展示两列名称和 URL。双击行就在主视图里导航。var table new DataTable(); table.Columns.Add(名称); table.Columns.Add(地址); table.Rows.Add(内部报表系统, http://report.local); table.Rows.Add(文档中心, http://wiki.local); favGrid.DataSource table; favGrid.CellDoubleClick (s, e) { string url favGrid.Rows[e.RowIndex].Cells[1].Value.ToString(); webView.CoreWebView2.Navigate(url); };DataSource绑定的DataTable支持运行时增删行配合 WebView2 的 Cookie 共享你可以把“常用系统”和“登录状态”一起留在桌面程序里。这个侧栏用SplitContainer和主区域分隔窗体伸缩时不会挤压网页内容。表格列头是原生绘制的自带排序和视觉样式比在网页里自绘一张表格省事得多这也是 WinForm 的老本行。5. 打包与分发避坑Runtime 缺失、白屏和五个另类翻车现场5.1 现象could not find the webview2 runtime 与启动即白屏开发机上双击程序跑得好好的拷到同事电脑上一启动弹窗could not find the webview2 runtime。点击确定后窗体还能显示但网页区域一片白。这个现象的原因很直接目标机器没有安装 WebView2 Runtime。常青版 Runtime 跟着 Edge 浏览器装很多办公机器装的还是老版 Edge或者管理员用组策略禁用了 Edge 自动更新Runtime 压根没进系统。解决的办法是在安装程序里预置离线 Runtime 包安装时静默执行。常见做法是把MicrosoftEdgeWebView2RuntimeInstaller.exe放在安装目录的Runtime子目录用参数--silent --install安装MicrosoftEdgeWebView2RuntimeInstaller.exe --silent --install静默参数的大小写必须严格匹配/silent和/install的写法在部分版本会被忽略然后弹出交互窗口部署时注意统一用--前缀。装完再启动主程序CreateAsync就能找到 Runtime。如果装完还是找不到查系统是否装的是 32 位 Runtime而你程序编译的是 x64位数错配同样报这个错误。5.2 现象安装 Runtime 时退出码 2日志里写 “installing webview2 failed”离线包安装时偶尔会报exit code 2这个退出码多半不是 Runtime 本身的问题而是系统里已经存在一个更高版本的 Runtime。常青版安装程序检测到版本不低于目标版本直接拒绝降级安装。解决方法是先读注册表判断版本只有目标机器版本低于需要时才执行安装。private bool IsRuntimeInstalled(string minimumVersion) { string keyPath SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}; using (var key Registry.LocalMachine.OpenSubKey(keyPath)) { var pv key?.GetValue(pv)?.ToString(); if (pv null) return false; return string.Compare(pv, minimumVersion, StringComparison.OrdinalIgnoreCase) 0; } }这里用字符串比较版本号不是严格语义但 Runtime 版本号都是定长数字加点的格式字符串比较在大多数情况下可用。要做得严谨可以拆成 int 数组逐位比较。记住这步检测要放到安装流程的最前面别让它变成“每次都安装三次”。5.3 现象程序能启动但 UserData 目录被占用Cookie 丢失用户数据目录的坑最隐蔽。你把userDataFolder设在Application.StartupPath下安装目录在Program Files的时候普通用户没有写权限WebView2 初始化时静默失败表现就是页面能开但设置和登录态每隔几次启动就丢。解决方式是把用户目录放到Environment.SpecialFolder.LocalApplicationData下这是每个用户都能写的地方。string userData Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), SelfBrowser, UserData);同理如果你同时开了两个实例指向同一个UserData目录第二个实例可能拿不到句柄。自用场景一般单实例用Mutex限制一下最稳妥。5.4 其他五个高频打包翻车点WinForms 程序启动时控件区域白屏但不报错原因多半是加载时窗体已显示但EnsureCoreWebView2Async还没跑完。处理方式是初始化期间在页面区域放一个带动画的等待面板初始化结束再切换可见性。固定版 Runtime 放进应用目录后启动缓慢这是正常的首次冷启动要初始化 150MB 左右的组件文件。你不要为此改成常青版前者在离线环境里反而是优点。打包时把 UserData 目录一起打进去了用户数据、缓存、日志会被带到新机器轻则泄露上次的浏览痕迹重则因路径不同引发配置错乱。打包前排除这个目录让程序首次运行时重建。界面在 125% 缩放的显示器上字体发虚窗体布局错位这是 WinForm 程序的老毛病。在Program.cs里加上Application.SetHighDpiMode(HighDpiMode.SystemAware);配合AutoScaleMode Dpi能缓解大部分模糊。注意这行代码要在创建窗体之前调用。程序退出后进程仍驻留通常是你注册了WebResourceRequested事件但窗体关闭时没有把事件处理器解绑WebView2 内核进程还在等句柄释放。在FormClosing里调用webView.Dispose()并把事件置空能减少“杀不掉进程”的投诉。自用程序虽然不涉及交付但资源泄露累加久了浏览器会越用越卡。6. 进阶用 CDP 远程调试页面搭一个可控的自动化小助手到这一步你的自用浏览器已经具备导航、注入和资源替换能力但你可能还需要看清页面内部发生了什么。WebView2 支持 Chromium 的 DevTools Protocol通过给 Runtime 加启动参数可以在本机开一个调试端口用任意 HTTP/WebSocket 客户端和页面对话。var options new CoreWebView2EnvironmentOptions(); options.AdditionalBrowserArguments --remote-debugging-port9222; var env await CoreWebView2Environment.CreateAsync( browserExecutableFolder: null, userDataFolder: userData, options: options);程序启动后访问http://localhost:9222/json会返回一个 JSON 数组里面列出所有页面目标。找到你要的webSocketDebuggerUrl用 WebSocket 连上去往里发一条Runtime.evaluate命令就能拿到页面里任意表达式的执行结果。var client new WebSocket(ws://127.0.0.1:9222/devtools/page/xxxx); await client.SendAsync(JsonSerializer.Serialize(new { id 1, method Runtime.evaluate, params new { expression document.title } }), CancellationToken.None);这段调试链路的威力在于它不依赖 WebView2 控件上有没有开放接口把页面当成一台可以远程指挥的小机器。我常用它来做批处理在夜间定时任务里打开指定页面用 CDP 读取关键数据再把结果写回 WinForm 侧的状态栏。相比ExecuteScriptAsyncCDP 方式可以脱离界面线程在后台任务里独立运行。最后说一个我自己的教训给自用浏览器加功能永远不要把所有逻辑塞进 UI 线程。注入脚本、资源拦截、CDP 请求全部压在 UI 线程上跑页面滚动会一卡一卡的用async/await配合任务队列把耗时操作挪到后台体验会完全两样。这个教训让我后来写任何 WinForm 功能都先画一张“哪些逻辑触碰了 CoreWebView2、哪些只碰数据”的分工图再动手写代码。希望帮到你。本文还有配套的精品资源点击获取