
简介这份资源面向使用 WinForm 开发桌面应用的 .NET 开发者重点解决在窗体程序中嵌入浏览器内核后如何获取页面加载完成的资源、截取请求参数、拦截响应数据以及注入 jQuery 与自定义 JS 代码等常见需求。项目基于 VS2019 与 .NET 4.6 环境构建适合具备一定 C# 与 WinForm 基础、希望深入掌握 CefSharp 高级用法的中高级开发者参考。压缩包共 718 个文件约 371.21MB以 273 个 cs 源码、174 个 pak 资源包、112 个 h 与 43 个 cpp 头源文件为主另含 33 个 dll、17 个 pdb 及若干 xml、nupkg、exe 等完整保留了 CefSharp 运行所需的依赖与调试符号。目前已有 6674 人学习下载。通过该示例读者可掌握请求与响应拦截的注册方式、JS 注入时机与脚本管理思路并借助完整工程结构快速复用到自己的采集、自动化或内嵌浏览器项目中。1. 从一次抓不到包的 WinForm 内嵌浏览器说起做 WinForm 桌面端的朋友大概率遇到过这种场景程序里嵌了个浏览器控件页面加载完想拿它渲染后的 DOM、想抓它发出的请求参数、想改它返回的数据结果发现 WebBrowser 控件用的是老 IE 内核页面直接白屏或者样式全乱。换成 CefSharp 之后页面正常了但新的问题来了——资源怎么拿、请求怎么截、响应怎么改、jQuery 和自定义 JS 怎么注入官方文档语焉不详网上搜到的代码片段又互相矛盾。这篇笔记就围绕 CefSharp 在 WinForm 里的四件事展开获取加载后的资源、截取 request 参数、拦截 response 数据、注入 jquery 文件和 js 代码。适合已经能把 CefSharp 跑起来、但卡在“怎么拿到数据”这一步的开发者也适合想评估这套方案能不能落到自己项目里的技术负责人。下面按“先立住原理、再动手复现、最后讲坑”的顺序推。2. CefSharp 的请求生命周期四个拦截点分别在哪一层2.1 从 Chromium 多进程模型看你能插手的位置CefSharp 是 Chromium Embedded Framework 的 .NET 封装页面加载不是在一个线程里顺序跑完的而是 Browser 进程、Render 进程、GPU 进程分工。你能插手的点本质上是 CEF 暴露出来的几个 Handler 接口。理解这一点很关键因为很多人一上来就想着“页面加载完我遍历一下 DOM 不就行了”结果发现拿到的 HTML 是空的或者只有骨架。请求从发起到落地大致经过发起请求 → 网络层 → 响应头到达 → 响应体到达 → DOM 构建 → JS 执行 → 资源加载完成。CefSharp 对应给你留了四个口子IRequestHandler管请求发起前的拦截能改 URL、改 Header、决定要不要发。IResourceRequestHandler管单个请求的细粒度控制能拿到 request 的 method、post data、header。IResponseFilter管响应体的流式过滤能在数据到达渲染引擎之前改写它。IRenderProcessMessageHandler/EvaluateScriptAsync管页面上下文里的 JS 执行和注入。选型上如果你只是想“看”请求和响应用 DevTools 协议或者IRequestHandler打日志就够了如果要“改”必须走IResourceRequestHandlerIResponseFilter这条链路。这是后面所有代码的地基绕不开。2.2 四个 Handler 的注册顺序与生效时机注册顺序错了Handler 根本不触发这是最常见的翻车点。CefSharp 的 Handler 是在创建ChromiumWebBrowser实例时通过属性挂上去的而且必须在浏览器初始化之前挂好。常见做法是继承CefSharp.Handler.RequestHandler和CefSharp.Handler.ResourceRequestHandler然后在RequestHandler.GetResourceRequestHandler里返回你自己的 ResourceRequestHandler 实例。// 自定义 RequestHandler负责把请求转交给 ResourceRequestHandler public class CustomRequestHandler : CefSharp.Handler.RequestHandler { protected override IResourceRequestHandler GetResourceRequestHandler( IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, bool isNavigation, bool isDownload, string requestInitiator, ref bool disableDefaultHandling) { // 只拦截主框架和子框架的 XHR/Fetch导航请求放行 if (isNavigation) return null; return new CustomResourceRequestHandler(); } }这段代码的逻辑是GetResourceRequestHandler是 CEF 在每次请求前回调的入口返回 null 表示不拦截、走默认处理。参数isNavigation区分是页面跳转还是页面内资源请求isDownload区分下载。实际项目里我一般只拦 XHR 和 Fetch因为导航请求拦下来容易把页面搞白。requestInitiator能告诉你这个请求是谁发起的调试时很有用。挂载方式var browser new ChromiumWebBrowser(https://example.com) { RequestHandler new CustomRequestHandler() };注意RequestHandler属性必须在浏览器开始加载之前赋值如果你先Load()再赋值第一批请求就漏掉了。这个顺序问题坑过不少人。2.3 为什么 response 拦截必须用流式 Filter很多人以为IResourceRequestHandler里能直接拿到完整的 response body其实拿不到。CEF 的设计是响应体以流的方式经过IResponseFilter你只能一块一块地处理。这是性能考虑也是为什么你没法在 Filter 里“等全部数据到了再改”。IResponseFilter的核心是两个方法InitFilter和Filter。Filter会被反复调用每次给你一段byte[]数据你处理后写回输出缓冲区。如果你要改内容就得在这里做字符串替换或者 JSON 改写。注意编码问题——CEF 给你的数据是原始字节可能是 gzip 压缩过的也可能是分块的直接当 UTF-8 字符串处理会乱码。常见做法是先判断Content-Encoding如果是 gzip 就先解压再处理处理完再决定要不要重新压缩。3. 动手截取 request 参数与拦截 response 数据3.1 拿到 request 的 URL、Method、Header 和 PostData在CustomResourceRequestHandler里重写OnBeforeResourceLoad这是请求真正发出前的最后一道关口。public class CustomResourceRequestHandler : CefSharp.Handler.ResourceRequestHandler { protected override CefReturnValue OnBeforeResourceLoad( IWebBrowser browser, IBrowser browserRef, IFrame frame, IRequest request, IRequestCallback callback) { // 打印请求基础信息 Console.WriteLine($URL: {request.Url}); Console.WriteLine($Method: {request.Method}); // 遍历 Header foreach (var key in request.Headers.AllKeys) { Console.WriteLine($Header: {key} {request.Headers[key]}); } // 读取 PostData表单或 JSON body var postData request.PostData; if (postData ! null) { foreach (var element in postData.Elements) { if (element.Type PostDataElementType.Bytes) { var bytes element.Bytes; var body System.Text.Encoding.UTF8.GetString(bytes); Console.WriteLine($PostBody: {body}); } } } return CefReturnValue.Continue; } }逻辑说明OnBeforeResourceLoad返回CefReturnValue.Continue表示放行返回Cancel表示阻断。request.Headers是个NameValueCollection能直接遍历。PostData可能是 nullGET 请求也可能有多个 elementmultipart 表单。参数上request.Url是完整 URL 含 query stringrequest.Method是大写字符串。这里有个坑PostDataElement.Bytes拿到的字节数组在某些 CEF 版本里会被后续操作清空所以如果你要异步处理得先拷贝一份。3.2 用 IResponseFilter 改写返回的 JSON拦截 response 并改写是这套方案里技术含量最高的一步。下面是一个把返回 JSON 里某个字段替换掉的例子。public class JsonReplaceFilter : IResponseFilter { private readonly string _oldValue; private readonly string _newValue; private readonly Listbyte _buffer new Listbyte(); public JsonReplaceFilter(string oldValue, string newValue) { _oldValue oldValue; _newValue newValue; } public FilterStatus Filter(Stream dataIn, out long dataInRead, Stream dataOut, out long dataOutWritten) { dataInRead 0; dataOutWritten 0; if (dataIn null) { // 数据流结束把缓冲区剩余内容写出 var remaining _buffer.ToArray(); dataOut.Write(remaining, 0, remaining.Length); dataOutWritten remaining.Length; return FilterStatus.Done; } // 读取本次到达的数据 var buffer new byte[dataIn.Length]; var read dataIn.Read(buffer, 0, buffer.Length); dataInRead read; _buffer.AddRange(buffer.Take(read)); // 简单策略等数据攒够再一次性替换生产环境要按 Content-Length 判断 var text System.Text.Encoding.UTF8.GetString(_buffer.ToArray()); if (text.Contains(_oldValue)) { var replaced text.Replace(_oldValue, _newValue); var outBytes System.Text.Encoding.UTF8.GetBytes(replaced); dataOut.Write(outBytes, 0, outBytes.Length); dataOutWritten outBytes.Length; _buffer.Clear(); return FilterStatus.Done; } return FilterStatus.NeedMoreData; } public void Dispose() { } }逻辑说明Filter返回NeedMoreData表示数据还没收完CEF 会继续调返回Done表示处理完毕。dataIn为 null 是流结束的信号。参数上dataInRead和dataOutWritten是 out 参数必须赋值否则 CEF 会认为你没处理。这个实现是简化版真实场景里要处理 gzip 解压、分块传输、Content-Length 不匹配等问题。我一般会在OnResourceResponse里先判断response.MimeType是不是application/json只对 JSON 走这个 Filter避免误伤图片和 JS 文件。3.3 把 Filter 挂到指定请求上Filter 不是全局挂的是在GetResourceResponseFilter里按请求返回的。protected override IResponseFilter GetResourceResponseFilter( IWebBrowser browser, IBrowser browserRef, IFrame frame, IRequest request, IResponse response) { // 只拦截目标 API 的 JSON 响应 if (request.Url.Contains(/api/userinfo) response.MimeType application/json) { return new JsonReplaceFilter(\role\:\guest\, \role\:\admin\); } return null; }逻辑说明返回 null 表示不拦截该请求。response.MimeType是服务端返回的 Content-Type用它做过滤比用 URL 后缀可靠。参数上request.Url做粗筛response.MimeType做精筛。注意GetResourceResponseFilter在响应头到达时调用此时还没有 body所以只能基于 header 信息决定要不要挂 Filter。4. 注入 jquery 文件和自定义 js 代码的正确姿势4.1 为什么 ExecuteScriptAsync 有时不生效ExecuteScriptAsync是最常用的注入方式但它有个前提页面上下文必须已经创建。如果你在BrowserInitialized事件里就调DOM 还没建好脚本执行了也找不到元素。正确时机是FrameLoadEnd或者LoadingStateChanged里判断IsLoading false。browser.FrameLoadEnd (sender, args) { if (args.Frame.IsMain) { // 主框架加载完成注入 jQuery args.Frame.ExecuteJavaScriptAsync( var sdocument.createElement(script); s.srchttps://code.jquery.com/jquery-3.6.0.min.js; document.head.appendChild(s);); } };逻辑说明这里用动态创建 script 标签的方式加载 jQuery而不是直接把 jQuery 源码塞进ExecuteJavaScriptAsync。原因是 jQuery 源码几万行直接当字符串传容易触发长度限制和转义问题。参数上args.Frame.IsMain确保只对主框架操作子 iframe 单独处理。ExecuteJavaScriptAsync是异步的不返回结果如果要拿返回值用EvaluateScriptAsync。4.2 注入本地 jquery 文件而不是 CDN生产环境往往不能访问外网得把 jquery.min.js 打包进程序。常见做法是读本地文件内容然后通过EvaluateScriptAsync执行。var jqueryPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Scripts, jquery.min.js); var jqueryCode File.ReadAllText(jqueryPath); browser.FrameLoadEnd async (sender, args) { if (args.Frame.IsMain) { // 先注入 jQuery await args.Frame.EvaluateScriptAsync(jqueryCode); // 再注入业务脚本 await args.Frame.EvaluateScriptAsync( $(document).ready(function(){ $(#loginBtn).text(已注入); }); ); } };逻辑说明EvaluateScriptAsync返回JavascriptResponse可以检查Success和Result。参数上jqueryCode是完整文件内容注意文件编码要 UTF-8 无 BOM否则可能报语法错误。业务脚本里用了 jQuery 的$所以必须在 jQuery 注入成功之后再执行。这里有个时序坑EvaluateScriptAsync是异步的如果你不 await 就紧接着注入业务脚本jQuery 可能还没定义报$ is not defined。4.3 用 EvaluateScriptAsync 拿页面数据回 C#注入不只是为了改页面更重要的是把页面里的数据取回 C# 端。var response await browser.EvaluateScriptAsync( JSON.stringify({title: document.title, url: location.href})); if (response.Success response.Result ! null) { var json response.Result.ToString(); Console.WriteLine($页面数据: {json}); }逻辑说明EvaluateScriptAsync的返回值会被序列化成 .NET 对象复杂结构建议先JSON.stringify再传避免类型转换问题。参数上response.Success表示脚本是否执行成功response.Result是返回值。注意脚本里不能有语法错误否则Success为 false 但Result为 null排查时容易懵。5. 避坑与排查那些让我加班到凌晨的细节5.1 现象Handler 完全不触发日志一条没有原因RequestHandler属性赋值晚于浏览器初始化或者GetResourceRequestHandler里对isNavigation判断写反了把该拦的放行了。解决确保在new ChromiumWebBrowser之后、Load之前赋值在GetResourceRequestHandler里加日志确认回调是否进入。5.2 现象response 改写后页面报 JSON 解析错误原因Filter 里改了内容但没更新Content-Length浏览器按旧长度截断JSON 不完整。解决在OnResourceResponse里如果决定改写就把response.Headers里的Content-Length删掉或改成新长度让 CEF 用 chunked 方式处理。5.3 现象注入的 jQuery 报$ is not defined原因EvaluateScriptAsync没 await业务脚本先于 jQuery 执行。解决用 async/await 串行执行或者把业务脚本包在setTimeout里等 jQuery 加载完。更稳的做法是注入后轮询typeof jQuery直到不为 undefined。5.4 现象PostData 读出来是乱码原因请求体是 gzip 压缩的或者编码不是 UTF-8。解决先看request.Headers[Content-Encoding]如果是 gzip 先解压编码不确定时用Encoding.GetEncoding(gbk)试。另外PostDataElement.Bytes在某些版本里会被清空读之前先拷贝。5.5 现象程序退出时崩溃报 CEF 未正确释放原因ChromiumWebBrowser没 Dispose或者Cef.Shutdown()调用时机不对。解决在 FormClosing 里先browser.Dispose()再Cef.Shutdown()。注意Cef.Shutdown()只能调一次重复调用会抛异常。6. 进阶用 DevTools 协议做无侵入式抓包与验证前面讲的 Handler 方案是“侵入式”的代码要嵌进 CefSharp 的初始化流程。如果你只是想验证某个请求到底发了什么、响应到底长什么样用 DevTools 协议更省事。CefSharp 支持browser.GetDevToolsClient()能直接订阅 Network 事件。var devTools browser.GetDevToolsClient(); await devTools.Network.EnableAsync(); devTools.Network.ResponseReceived (sender, e) { Console.WriteLine($响应: {e.Response.Url} 状态: {e.Response.Status}); }; devTools.Network.RequestWillBeSent (sender, e) { Console.WriteLine($请求: {e.Request.Url} 方法: {e.Request.Method}); if (e.Request.PostData ! null) { Console.WriteLine($Body: {e.Request.PostData}); } };逻辑说明Network.EnableAsync()开启网络域监听之后所有请求响应都会走事件。参数上e.Response.Status是 HTTP 状态码e.Request.PostData是请求体字符串。这套方案的好处是不用改 Handler坏处是只能“看”不能“改”而且事件是异步的高频请求下要注意性能。验证 Filter 是否生效我一般用两步先在Filter里打日志确认被调用再在页面里用fetch重新请求同一个接口对比改写前后的返回值。如果日志有但页面没变多半是Content-Length没更新或者 Filter 返回了NeedMoreData但数据已经结束。最后说个习惯每次改完 Handler 或 Filter我都会把CefSettings.LogSeverity设成LogSeverity.Info把 CEF 自己的日志打开很多“玄学”问题在日志里其实写得很清楚。这套方案值不值得做取决于你的场景——如果只是偶尔抓个包DevTools 协议够了如果要稳定地改写数据、注入脚本、做自动化Handler Filter 这条链路是绕不开的前期踩的坑后面都会变成可复用的模板。希望帮到你。本文还有配套的精品资源点击获取