简介:这份资源面向使用 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打日志就够了;如果要“改”,必须走IResourceRequestHandler+IResponseFilter这条链路。这是后面所有代码的地基,绕不开。
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可能是 null(GET 请求),也可能有多个 element(multipart 表单)。参数上,request.Url是完整 URL 含 query string,request.Method是大写字符串。这里有个坑:PostDataElement.Bytes拿到的字节数组在某些 CEF 版本里会被后续操作清空,所以如果你要异步处理,得先拷贝一份。
3.2 用 IResponseFilter 改写返回的 JSON
拦截 response 并改写,是这套方案里技术含量最高的一步。下面是一个把返回 JSON 里某个字段替换掉的例子。
public class JsonReplaceFilter : IResponseFilter { private readonly string _oldValue; private readonly string _newValue; private readonly List<byte> _buffer = new List<byte>(); 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 s=document.createElement('script');" + "s.src='https://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 这条链路是绕不开的,前期踩的坑后面都会变成可复用的模板。希望帮到你。
本文还有配套的精品资源,点击获取