简介:在WinForm窗体程序中嵌入CefSharp时,往往需要获取页面加载后的资源、截取网络请求参数、拦截响应数据,并动态注入jQuery和自定义JS代码。这份基于VS2019与.NET 4.6环境的示例工程,为正在做浏览器内核集成、页面数据采集或Web交互增强的开发者提供了完整可运行的参考实现;由于涉及请求与响应拦截等操作,更适合已有一定CefSharp使用基础、希望减少二次开发踩坑的读者。压缩包共718个文件,大小约371MB,核心部分以273个cs源码文件为主,辅以112个h、43个cpp等C++相关文件,以及运行所需的dll、pak资源文件,目录结构完整,基本覆盖CefSharp二次开发中的常用交互场景。已有6673人学习下载。借助该示例,读者可快速理解请求与响应拦截的挂载方式、资源加载完成后的处理流程,以及外部JS和jQuery的注入位置与写法,节省自行摸索的时间,并可直接套用到自己的WinForm项目中。 做WinForm内嵌浏览器的活儿,绕不开CefSharp。这段时间我一直在折腾一个问题:程序里内嵌的网页,能不能像浏览器F12一样,把页面发出去的request参数、拿回来的response数据全截下来,再往页面里塞点自己的JS和jquery辅助库。折腾完发现这事完全可行,而且实现起来没有想象中复杂,但坑确实不少。
这篇文章把完整的方案记录下来,包括CefSharp环境搭建、request参数截取、response数据拦截、DOM资源获取、jquery注入几个核心模块。如果你正在做WinForm+浏览器混合应用、自动化测试工具、页面数据采集之类的项目,这篇文章应该能帮你省下不少查资料的时间。
1. 整体设计与方案选型
1.1 为什么选CefSharp而不是WebBrowser或WebView2
WinForm下做内嵌浏览器,老方案是WebBrowser控件,新方案是WebView2。WebBrowser本质是IE内核,页面渲染效果太老,网络层拦截能力也很有限。WebView2虽然好用,但前提是用户机器装了EdgeRuntime,发布时需要额外考虑运行时安装问题。
CefSharp基于Chromium内核,渲染效果和Chrome基本一致,发布时可以把整个CEF运行时打进安装包,不依赖用户系统环境。最关键的一点是CefSharp暴露了一整套网络层钩子接口,从请求发出前到响应返回后都有明确的回调入口,这就给了我们拦截request和response的抓手。
我用的是CefSharp.WinForms的84系列版本,这个版本API相对稳定,网上资料也多。网速快的机器NuGet直接装就行,装完会自动带出CefSharp.Common和cef.redist包。
1.2 核心架构设计
整个方案分四层:
- 浏览器层:负责页面加载渲染,就是普通的CefSharp.WinForms控件
- 请求拦截层:实现IRequestHandler和IResourceRequestHandler接口,处理请求头、请求体、URL参数
- 响应拦截层:实现IResponseFilter接口,拿到服务端返回的原始字节流
- JS注入与DOM交互层:通过ExecuteScriptAsync注入jquery和自定义js,用EvaluateScriptAsync拿到页面里的数据
这四层各自独立,通过几个自定义类串联起来。实际项目中建议把这几个类单独放一个文件夹,不要堆在MainForm里,不然代码会非常臃肿。
1.3 开发环境准备
我用的环境是Visual Studio 2022 + .NET Framework 4.7.2,CefSharp在.NET Framework下兼容性最好。.NET Core/.NET 5+也能跑,但配置上会多几步。
创建项目后,通过NuGet安装:
Install-Package CefSharp.WinForms -Version 84.6.30装完建议把项目的平台目标改成x64或x86,AnyCPU在CEF下会出不少莫名其妙的问题。另外记得在项目里调用初始化代码:
CefSettings settings = new CefSettings(); Cef.Initialize(settings, shutdownOnProcessExit: true);这个初始化建议放在Program.cs的Main方法里,放在窗体的Load事件里也可以,但必须保证初始化一次。
2. 请求拦截:从回调到拿参数的关键路径
2.1 请求处理器的入口
CefSharp的请求拦截入口在IResourceRequestHandler里。每个网络请求发出前,都会先经过这里。我们需要在自定义的IRequestHandler中重写GetResourceRequestHandler方法,把请求拦截器挂上去。
public class CustomRequestHandler : IRequestHandler { public IResourceRequestHandler GetResourceRequestHandler( IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, bool isNavigation, bool isDownload, bool isRedirect, bool isInitialRequest) { return new CustomResourceRequestHandler(); } }然后把这个handler绑定到浏览器控件上:
var browser = new ChromiumWebBrowser("https://example.com"); browser.RequestHandler = new CustomRequestHandler();这段代码是整条链路的起点。注意isNavigation参数,它标识当前请求是否为主导航请求(也就是页面跳转),如果只想拦截页面本身的请求,可以在这里做判断。
2.2 截取URL参数和请求头
请求核心信息读取放在OnBeforeResourceLoad方法里,这是请求发出前最后的拦截点:
public class CustomResourceRequestHandler : IResourceRequestHandler { public CefReturnValue OnBeforeResourceLoad( IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) { string url = request.Url; string method = request.Method; NameValueCollection headers = request.Headers; if (url.Contains("/api/")) { Console.WriteLine($"拦截到请求: {method} {url}"); Console.WriteLine($"User-Agent: {headers["User-Agent"]}"); Console.WriteLine($"Referer: {request.ReferrerUrl}"); } return CefReturnValue.Continue; } }headers是NameValueCollection类型,遍历起来很顺手。常见的请求头像Accept、Content-Type、Cookie都在里面。这里有个小经验:request.Headers拿到的Cookie并不是完整的,有些情况下Cookie信息在request.Cookie字段里,看情况决定用哪个。
2.3 POST请求体怎么拿
这是很多人卡住的地方。POST请求的body不在Headers里,需要通过request.PostData获取。而且PostData拿到的body可能是多个片段组成的:
if (method == "POST" && request.PostData != null) { var elements = request.PostData.Elements; foreach (var element in elements) { if (element.Type == PostDataElementType.Bytes) { string body = System.Text.Encoding.UTF8.GetString(element.Bytes); Console.WriteLine($"POST body: {body}"); } } }注意:如果你拿到的body是空字符串,很大概率是请求体已经被浏览器消费过一次了,多见于重定向场景。这种情况下需要从原始请求入口拦截,或者开启setResposeHeader(这个操作在后续版本中有变化),我实际测试下来84版本直接读PostData基本都能读到。
2.4 请求拦截的常见坑
OnBeforeResourceLoad有个隐藏的坑:你return了CefReturnValue.Continue之后,其实还可以调用callback,但两者不要混用。要么只return,要么只调callback,否则CEF内部逻辑会乱。
另一个坑是拦截不要一网打尽。页面里的图片、字体、静态资源都会走这个回调,如果对所有请求都做复杂处理,页面加载会明显变慢。我的做法是:用URL关键词过滤,只处理包含特定路径(比如/api/)的请求,其他请求直接return Continue。
3. 响应数据拦截:拿到服务器返回的内容
3.1 IResponseFilter的工作原理
request拦截容易,response拦截就考验人了。CefSharp提供了一个专门的IResponseFilter接口,可以在响应数据到达浏览器之前,先经过我们的过滤器。
这个接口的核心方法是Filter,CEF会把服务端返回的数据分片通过dataIn传给我们,我们处理后再写到dataOut流里。整个过程是流式处理的,数据可能分多次到达,这一点要提前有预期。
3.2 实现一个响应拦截器
下面是一个能实际运行的HTML响应拦截器,我直接贴简化版:
public class CustomResponseFilter : IResponseFilter { private MemoryStream mData = new MemoryStream(); private bool mHasReadHeader = false; public FilterStatus Filter(Stream dataIn, out long dataInRead, Stream dataOut, out long dataOutWritten) { if (dataIn != null) { dataIn.CopyTo(mData); dataInRead = dataIn.Length; } else { dataInRead = 0; } byte[] data = mData.ToArray(); int bytesWritten = Math.Min((int)data.Length, 8192); if (bytesWritten > 0) { dataOut.Write(data, 0, bytesWritten); } dataOutWritten = bytesWritten; return FilterStatus.Done; } }这个实现虽然能拿到数据,但只适合一次性读取的小响应。对大的HTML页面,建议在Filter里移动dataIn的Position循环读取,并先把数据写入临时缓冲,等完整接收后再统一处理。
3.3 在请求处理器中挂载Filter
有Filter还不够,还要在IResourceRequestHandler的GetResponseFilter方法里把它挂上去:
public IResponseFilter GetResponseFilter( IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) { var contentType = response.Headers["Content-Type"]; if (contentType != null && contentType.Contains("text/html")) { return new CustomResponseFilter(); } return null; }这里只对Content-Type为text/html的响应做过滤,其他资源直接返回null跳过。这样做的原因很简单:response拦截如果处理了图片、JS、CSS,一方面性能损耗大,另一方面这些二进制数据很容易在流式处理中搞坏,导致页面渲染异常。
3.4 压缩编码与数据完整性
理论上Filter拿到的数据是CEF内部解压后的原始数据,但我实际测试时遇到过Content-Encoding为gzip的接口返回数据在Filter里是压缩字节流的情况,处理不好就会出现乱码或者数据不完整。
解决办法有两个:一是如果发现Content-Encoding是gzip,用GZipStream手动解压;二是在发起请求前,把Accept-Encoding请求头里的gzip去掉,让服务端返回未压缩数据。
第一版我图省事直接用方案二,后来发现会破坏一些正常请求的默认行为,还是回到方案一。建议在Filter里加一个判断:
if (response.Headers["Content-Encoding"] == "gzip") { using (var gzip = new GZipStream(mData, CompressionMode.Decompress)) { gzip.CopyTo(resultStream); } }4. 获取页面加载后的资源
4.1 执行JS代码取DOM数据
网络层能拿到request和response,但页面渲染完成后那些动态生成的数据,还得靠执行JS去拿。CefSharp提供了EvaluateScriptAsync方法,可以让C#同步等待JS执行结果:
var script = "document.querySelector('#userName').innerText;"; var response = await browser.EvaluateScriptAsync(script); if (response.Success && response.Result != null) { string userName = response.Result.ToString(); Console.WriteLine($"页面用户名: {userName}"); }这个方法返回的是Task类型,在WinForm里用async/await调用比较方便。注意JS执行是在浏览器进程里,UI线程和CEF渲染线程不是一回事,await之后拿到的结果要回到UI线程更新控件,可以用BeginInvoke。
4.2 把C#对象暴露给页面:JSObjectRepository
有时我们需要反方向操作:页面里的JS主动调用C#方法,把某些数据回传。CefSharp的JavascriptObjectRepository可以做到:
browser.JavascriptObjectRepository.Register("hostBridge", new BridgeObject(), isAsync: false);对应的JS调用方式:
hostBridge.GetData(function(result) { console.log(result); });BridgeObject是一个普通C#类,方法标记即可。这样页面和WinForm主程序之间就建立了一条双向通道。我做项目的时候常用这个方式汇报加载进度,比轮询URL方便得多。
4.3 等待DOM出现的轮询封装
页面加载完成后,部分数据是异步渲染的,直接EvaluateScriptAsync很可能返回空。我封装了一个简单的延时查询方法,原理是循环执行一条判断JS,直到目标元素出现或者超时:
public static async Task<string> WaitAndGetValue(ChromiumWebBrowser browser, string selector, int timeoutMilliseconds = 10000) { var timer = 0; while (timer < timeoutMilliseconds) { var result = await browser.EvaluateScriptAsync($"document.querySelector('{selector}')?.innerText || ''"); if (result.Success && result.Result != null && result.Result.ToString() != "") { return result.Result.ToString(); } await Task.Delay(200); timer += 200; } return null; }这段代码在动态渲染的SPA应用里很管用。实测下来轮询间隔200ms比较合适,太短了CPU占用高,太长了又影响响应速度。
5. 注入jquery与js代码
5.1 两种注入思路
jquery注入有两个思路:一个是把jquery.js内容作为C#字符串,直接用ExecuteScriptAsync执行;另一个是在HTML响应流里插入script标签,让页面加载时就自动引入jquery。
第一种方式简单直接,适合页面已经加载完成、需要临时注入的场景。第二种方式更接近原生加载,jquery在页面最早阶段就就绪,后续页面脚本可以直接使用。我在实际项目里两种都用,根据场景切换。
5.2 用ExecuteScriptAsync注入jquery源码
把jquery.min.js读成字符串,然后塞进页面:
string jquerySource = File.ReadAllText("jquery.min.js"); string script = $"window.$ = window.jQuery = (function(){{ {jquerySource} return window.jQuery; }})();"; await browser.EvaluateScriptAsync(script);这里有个坑:jquery压缩文件里有大量特殊字符,直接拼字符串很容易出错。我的做法是先用base64编码,在浏览器端解码头执行:
string jquerySource = File.ReadAllText("jquery.min.js"); string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(jquerySource)); string script = "var s = atob('" + base64 + "'); eval(s);"; await browser.EvaluateScriptAsync(script);这样完全避免了转义问题,实测注入成功率接近100%。注入完成后可以立刻验证:
await browser.EvaluateScriptAsync("if (typeof jQuery === 'undefined') throw 'jquery not loaded';");5.3 在响应流里插入jquery标志
第二种方式是在3.2节的Filter基础上做,当时机合适时,在HTML的head标签前插入一行script标签:
string injectTag = "<script src='http://local/jquery.min.js'></script>"; byte[] injectBytes = Encoding.UTF8.GetBytes(injectTag); // 在返回数据最前面插入 resultStream.Write(injectBytes, 0, injectBytes.Length); resultStream.Write(data, 0, data.Length);这样的写法可以在任何HTML页面加载前先加载jquery。缺点是如果页面本身有严格的CSP(内容安全策略)策略,外链脚本可能被拦截,需要视情况调整。
5.4 注入时机与线程约束
注入时机很重要。页面还没加载完成时就执行针对DOM的脚本,可能在document仍在解析阶段。CefSharp提供了LoadingStateChanged事件,可以判断是否加载完成:
browser.LoadingStateChanged += async (sender, args) => { if (!args.IsLoading) { // 页面加载完成后注入 await browser.GetBrowser().MainFrame.EvaluateScriptAsync(script); } };另外注意CEF的JS执行必须通过MainFrame调用,不要用browser.EvaluateScriptAsync(这个实际是ChromiumWebBrowser的扩展方法,内部转发到MainFrame)。还有跨线程访问浏览器实例时,使用Control.BeginInvoke或Dispatcher确保在正确线程上操作。
6. 常见问题与排查实录
把这段时间踩过的坑和解决方案整理成了表格,方便大家排查。
| 问题现象 | 原因 | 解决方式 |
|---|---|---|
| 页面加载后一直转圈,卡死 | OnBeforeResourceLoad里调用了callback但未回调Continue | callback.Continue()放到分支末尾,确保每次都执行 |
| Filter拿到的数据是乱码 | 服务端返回gzip压缩数据 | 判断Content-Encoding,手动GZipStream解压 |
| 注释了一半的过滤代码后页面崩了 | Filter返回的dataOutWritten不准确 | 循环时确保dataOut写入长度与返回状态一致 |
| JS注入后页面无反应,但无报错 | 页面可能存在CSP限制或者执行时机太早 | 检查页面CSP,延后到LoadingStateChanged完成后再注入 |
| request拦截能拦到HTML,但拦不到XHR | XHR请求走了子框架或者CDN域名 | 扩大URL过滤范围,用子框架判断if(frame == browser.MainFrame) |
| EvaluateScriptAsync返回Success但Result为null | JS表达式本身返回undefined | 改用JSON.stringify或 |
| WinForm关闭后进程不退出 | CEF资源未释放 | MainForm\FormClosed里调用Cef.Shutdown() |
还有一个容易忽略的点:如果你在Filter里修改了响应数据,需要同步调整响应长度。有些接口对Content-Length有依赖,替换过后不处理就可能导致页面解析错乱。最保险的方法是修改数据后,把response.Headers里的Content-Length删掉,让它按实际数据长度自行协商。
注入jquery时也要注意性能。jquery.min.js大约90KB,注入一次影响不大,但如果页面里频繁执行大段JS,CEF的渲染进程会产生额外内存占用。实际测试中,连续注入5次以上,渲染进程内存大概会增加30-50MB,这是CEF自身的算账逻辑,没法完全避免,只能尽量控制注入次数。
7. 扩展翻车思路
如果项目里还有更深的需求,比如要拦截WebSocket消息、要模拟浏览器发请求、要操作页面里的数据验证码,这套框架基本都能覆盖。request和response拦截数据可以结合本地文件记录或SQLite存档,做成一个简易的流量回放工具;js注入模块可以做成插件化,让不同项目通过配置文件加载不同的注入脚本,不需要每次改代码重编译。
在做数据采集类项目时,记住不要把拦截数据和业务逻辑耦合在一起。拦截层只负责拿到数据、写入队列,业务层从队列消费处理。这样哪怕页面改版、拦截逻辑重写,业务代码不用动。
个人经验来说,CefSharp这套东西最需要的不是API文档,而是对“事件时序”的把控。请求回调、过滤器回调、页面加载事件、JS执行结果,每个模块的执行顺序稍有偏差,结果就完全不一样。调试时建议在自己关注的回调入口都打上日志,把时间和URL记录下来跑一遍,基本就能串起整条流程。
以上这些就是我在WinForm+CefSharp里做资源获取、请求截取、响应拦截和jquery注入的完整实战记录。如果大家在自己的项目里遇到类似问题,直接按这几个模块拆开排查,大概率能快速定位到具体环节。有问题欢迎在评论区交流。
本文还有配套的精品资源,点击获取