简介:在VS2019与.NET 4.6环境中,面向需要在WinForm窗体程序中集成CefSharp的.NET桌面开发人员,这份完整示例项目演示如何获取页面加载后的资源、截取request参数、拦截response数据,并支持注入jQuery与自定义JS代码,覆盖了浏览器内核嵌入开发中常见的调试与扩展需求。压缩包共718个文件,总大小约371.21MB。其中273个C#源文件承载示例的关键逻辑,174个PAK及33个DLL为CefSharp/Chromium运行依赖,112个H和43个CPP等C/C++文件便于排查底层调用,17个PDB可辅助调试,另含sln、csproj、config、settings等工程文件,可在VS2019中快速还原结构;少量data、index与cache文件则有助于了解CefSharp运行时的资源与快照组织方式。资源已有6674人学习下载,示例从资源获取、请求截取到响应拦截再到脚本注入形成完整链路,读者可以从中理解CefSharp的事件机制、资源加载流程及内核交互方式,直接提取代码移植到自有WinForm项目中,显著降低环境搭建与排错成本。
1. 把 WinForm 里的 CefSharp 从“显示器”改成“数据管道”:资源获取、请求拦截与脚本注入
接手一个桌面助手项目时,页面是别人家的 Web 系统,登录态在浏览器里,数据渲染在表格里,接口返回埋在 XHR 里。绕开页面直接调接口会撞上登录态和跨域,把 CefSharp 嵌进 WinForm 又只能看不能拿。标题这串诉求,本质是把 CefSharp 从一个“能打开网页的控件”变成一个可审计、可干预的数据管道:页面加载完能取渲染后的资源,出站请求能截参数,响应体能拦原文,还能在合适的时机把 jquery 和业务 JS 塞进页面。适合做桌面采集、Web 系统自动化、页面数据二次加工这类活。这套东西不难,但生命周期不熟会绕很多路,下面按我实际落地的顺序拆开讲。
2. 拿到加载后的资源:GetSourceAsync、EvaluateScriptAsync 与三个生命周期事件
2.1 先搞清“加载完”的信号:LoadingStateChanged 与 FrameLoadEnd
CefSharp 里没有单一的“加载完成”信号,这是新手最容易误解的地方。LoadingStateChanged 的 IsLoading 变为 false,只代表顶层导航结束,不代表 DOM 可以安全操作;真正能读页面内容的是 FrameLoadEnd,它表示某个 frame 已经解析完;如果中途失败,还会触发 LoadError。三个信号各管一段,用错时机就会出现“页面白着但事件已触发”的假象。
| 信号 | 触发时机 | 此时能做什么 |
|---|---|---|
| LoadingStateChanged.IsLoading=false | 顶层导航结束 | 更新 UI 状态,不适合操作 DOM |
| FrameLoadEnd | 每个 frame 解析完 | 读取 HTML、执行 JS |
| LoadError | 框架加载失败 | 记录错误码、错误文本 |
代码里订阅事件时要注意线程:CefSharp 的回调不一定在 UI 线程,直接碰 WinForm 控件会偶发跨线程异常,回 UI 要用 BeginInvoke。
var browser = new ChromiumWebBrowser("https://internal.example.com/login"); browser.LoadingStateChanged += (s, e) => { // 回调不保证在 UI 线程,操作控件前必须 BeginInvoke if (!e.IsLoading) { _statusLabel.BeginInvoke(() => _statusLabel.Text = "页面加载结束"); } }; browser.FrameLoadEnd += async (s, e) => { // iframe 加载也会进入这个事件,只处理主框架 if (!e.Frame.IsMain) return; if (e.Frame.IsDisposed) return; string html = await e.Frame.GetSourceAsync(); Log($"主框架 HTML 长度: {html.Length}"); };这段代码里,e.Frame 是 IFrame 类型,GetSourceAsync 返回 Task ,拿到的不是网络传输的原始报文,而是浏览器渲染后的 DOM 序列化结果。JS 动态改过的内容、SPA 路由切换后的结构,都会体现在这里。IsDisposed 判断不能省,关闭窗体后事件仍可能触发,访问已释放的 frame 会直接抛异常。
2.2 用 GetSourceAsync 拿 HTML,用 EvaluateScriptAsync 拿结构化数据
有了 HTML 字符串只是第一步。实际项目里更常用的是直接在页面上下文里执行 JS,把表格、列表、接口数据转成 JSON 再取回来。GetSourceAsync 适合整体快照,EvaluateScriptAsync 适合精准提取,两者配合能覆盖绝大多数“取资源”的需求。
async Task<string> CaptureTableJson(IFrame frame) { // 用 JS 把表格转成 JSON,比逐个 getElementById 快得多 string script = @"JSON.stringify( Array.from(document.querySelectorAll('table tr')).map(row => Array.from(row.querySelectorAll('td')).map(td => td.innerText) ))"; var result = await frame.EvaluateScriptAsync(script, timeout: TimeSpan.FromSeconds(5)); // Success 为 false 时,Message 里带具体脚本异常 if (!result.Success) { Log("eval failed: " + result.Message); return null; } if (result.Result is string json) { return json; } return null; }EvaluateScriptAsync 的第二个参数是超时时间,不传时默认无超时,脚本死循环会卡住整个浏览器进程,所以 5 秒左右的超时几乎必填。返回值 JsResult 的 Result 类型取决于脚本最终表达式:字符串返回 string,对象要先用 JSON.stringify 转一下,否则拿回来的是 JsValue 包装对象,处理起来很别扭。另一个注意点:脚本里如果用了 document.body 这类依赖 DOM 的对象,就必须等 FrameLoadEnd 之后再调用,这就是 2.1 节强调时机的原因。
3. 抓 request、拦 response:IRequestHandler 与 IResponseFilter 的配合
3.1 出站请求的入口:IRequestHandler.OnBeforeResourceLoad
CefSharp 拦截请求的主入口是 IRequestHandler 接口,实现后赋给 browser.RequestHandler 即可。OnBeforeResourceLoad 会对每个资源请求触发,包括 HTML、JS、CSS、XHR、图片;OnBeforeBrowse 只触发顶层导航。要截接口参数,重点放在前者。
public class CaptureRequestHandler : IRequestHandler { // 捕获到请求时抛给上层 public event Action<RequestInfo> RequestCaptured; public CefReturnValue OnBeforeResourceLoad( IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) { // 只关心目标接口,降低日志噪音 if (IsTargetUrl(request.Url)) { var info = new RequestInfo { Url = request.Url, Method = request.Method, Headers = FormatHeaders(request.Headers) }; // POST body 只在第一次读取时可靠,后续见第 5 章 if (request.Method == "POST" && request.PostData != null && !request.PostData.IsReadOnly) { info.Body = ExtractPostBody(request.PostData); } RequestCaptured?.Invoke(info); } // Continue 放行,Cancel 会中断这次请求 return CefReturnValue.Continue; } // IRequestHandler 里其它成员按默认实现补齐,业务逻辑不需要逐个写 }CefReturnValue 有三个常见取值,含义要分清:
| 返回值 | 行为 |
|---|---|
| Continue | 继续请求,不做干预 |
| Cancel | 取消请求,客户端会收到加载失败 |
| + Continue 配合 callback.Continue(true/false) | 决定是否放行重定向 |
这个回调是同步阻塞的,不要在这里做数据库写入或网络等待,否则页面加载会被拖慢。Headers 格式化只用于日志展示;真正要拿请求头里的 token、cookie,建议逐个字段读取,而不是整体 ToString 后拿去解析。
3.2 拦截 response 体:IResponseFilter 的 Filter 循环与数据拼接
CefSharp 不给“一个回调返回完整响应体”这种接口,响应体是分块到达的,必须自己实现 IResponseFilter。这里需要理解 FilterStatus 的状态流转:每来一块数据回调一次 Filter,返回 NeedMoreData 表示继续等待,全部读完返回 Done,最后在 Dispose 里拿完整数据。
public class CaptureBodyFilter : IResponseFilter { // 拼装完成的响应体事件 public event Action<string> ResponseBodyCaptured; private readonly MemoryStream _body = new MemoryStream(); private byte[] _buffer = new byte[8192]; public bool InitFilter() => true; public FilterStatus Filter(Stream dataIn, out long dataInRead, Stream dataOut, out long dataOutWritten) { dataInRead = 0; dataOutWritten = 0; if (dataIn == null) { return FilterStatus.Done; } int read; while ((read = dataIn.Read(_buffer, 0, _buffer.Length)) > 0) { dataInRead += read; _body.Write(_buffer, 0, read); // 只观察不改写,必须原样回写,否则页面内容会被“吃掉” dataOut.Write(_buffer, 0, read); dataOutWritten += read; } return dataInRead == 0 && dataIn.Length == 0 ? FilterStatus.Done : FilterStatus.NeedMoreData; } public void Dispose() { var text = Encoding.UTF8.GetString(_body.ToArray()); ResponseBodyCaptured?.Invoke(text); _body.Dispose(); } }这个类里最关键的参数是 dataInRead 和 dataOutWritten。dataInRead 告诉 CEF 这次消费了多少输入,dataOutWritten 告诉 CEF 输出了多少;如果只读不写,页面会白屏。我第一版就栽在这——数据能拦下来,但整个页面渲染空白,排查了半天才发现是没回写。另一个坑是数据拼装:Filter 可能被调用多次,必须在 MemoryStream 里累积,不能每次回调都新建字符串。
3.3 把请求和响应串起来:GetResourceResponseHandler 与 URL 筛选
ResponseFilter 不会自动生效,需要告诉 CEF 哪个请求要用哪个过滤器,这个入口在 IRequestHandler.GetResourceResponseHandler 方法里。按 URL 做筛选,只拦接口 JSON,图片、JS、CSS 直接返回 null 放行。
public IResponseFilter GetResourceResponseHandler( IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) { // 常见做法:只过滤 JSON 接口,其它资源直接放行 if (IsTargetApi(request.Url)) { var filter = new CaptureBodyFilter(); filter.ResponseBodyCaptured += body => ResponseCaptured?.Invoke(request.Url, body); return filter; } return null; }每个请求都会调用一次 GetResourceResponseHandler,返回的 Filter 只服务当前这一个 response,响应结束后 CefSharp 负责调用 Dispose。所以 Filter 实例不能是单例共享的,必须每次 new 一个,否则多个响应会互相覆盖拼接数据。我的习惯是在这个入口打一条日志,记录“哪个 URL 挂了过滤器”,排错时能快速判断是没进 Handler 还是 Filter 里读不到数据。
4. 注入 jquery 与业务 JS:时机、通道与作用域
4.1 注入 jquery 的两种姿势:DocumentCreated 注册与 FrameLoadEnd 补入
注入脚本有两个时间点:AddScriptToExecuteOnDocumentCreatedAsync 注册的脚本在 document 创建时执行,时机极早,适合放 jquery 这类库文件;FrameLoadEnd 时再执行 EvaluateScriptAsync,适合放依赖 DOM 的业务代码。库文件走前者,业务代码走后者,这个分工不要颠倒。
// 项目里把 jquery 文件设为“复制到输出目录”,运行时直接读文件 string jquerySource = File.ReadAllText( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "jquery.min.js")); // 用 JSON 序列化转义,避免源码里的引号、换行破坏拼接后的字符串 string escaped = JsonConvert.SerializeObject(jquerySource); var browser = new ChromiumWebBrowser(url); await browser.AddScriptToExecuteOnDocumentCreatedAsync("inject-jquery", escaped);AddScriptToExecuteOnDocumentCreatedAsync 的注册动作要在浏览器首次导航之前完成,导航一旦开始,部分页面已经创建过 document,脚本就不会执行。第二个参数是脚本 id,用来区分不同脚本;重复注册同名 id 可能会被 CEF 忽略,换 id 或先移除旧注册。
DocumentCreated 注册对所有 frame 生效,包括 iframe,这点有利也有弊:iframe 里如果用到 jquery 会省事,但会让每个 frame 都执行一次库文件,内存开销翻倍。如果只希望主框架有 jquery,就别走这条路,改在 FrameLoadEnd 里判断 IsMain 后注入。下面的兜底方案是处理“注册时机错过”最有效的做法:
private async void OnFrameLoadEnd(object sender, FrameLoadEndEventArgs e) { if (!e.Frame.IsMain || e.Frame.IsDisposed) return; // 有些页面错过了 DocumentCreated 注册,这里做存在性兜底 var check = await e.Frame.EvaluateScriptAsync("typeof window.jQuery"); if (check.Success && check.Result?.ToString() == "undefined") { string script = "var s=document.createElement('script');" + "s.textContent=" + escaped + ";" + "document.documentElement.appendChild(s);s.remove();"; await e.Frame.EvaluateScriptAsync(script); } }这里 escaped 要提升为类字段,让两个方法都能用到。判断“是否已存在”用 typeof,比直接执行 jquery 再 try/catch 更干净,不进页面污染。补注入时用 appendChild 挂到 documentElement 后立即 remove,脚本照样会执行,不会在 DOM 上留下多余的 script 标签。
4.2 注入业务 JS:等 DOM 就绪再执行,并做幂等保护
业务脚本和 jquery 不一样,它依赖 DOM 结构,DocumentCreated 阶段很可能 body 还没建好。我的做法是统一在 FrameLoadEnd 里执行,并且加幂等保护,防止刷新、iframe 加载、第二次导航时重复注入造成事件绑定翻倍。
private async Task InjectBusinessScript(IFrame frame) { // 幂等保护:标记变量存在就跳过 var flagCheck = await frame.EvaluateScriptAsync("window.__BIZ_INJECTED__"); if (flagCheck.Success && flagCheck.Result?.ToString() == "true") return; string bizCode = File.ReadAllText("biz.js"); string escaped = JsonConvert.SerializeObject(bizCode); // 先打标记再执行,保证同一页面只注入一次 string wrapper = "(function(){" + "if(window.__BIZ_INJECTED__)return;" + "window.__BIZ_INJECTED__=true;" + escaped + "})();"; var result = await frame.EvaluateScriptAsync(wrapper); if (!result.Success) Log("business script error: " + result.Message); }抛脚本内容的技巧是用 JSON 序列化把源码变成一个合法的字符串字面量,再拼进 wrapper。这样源码里哪怕有反引号、${}、换行,也不会破坏外层字符串。EvaluateScriptAsync 的第二个重载可以传超时,业务代码量大的时候建议加上,防止 catch 到异常但页面已经卡死的情况。
4.3 数据回传 WinForm:用 EvaluateScriptAsync 轮询,还是注册 C# 对象
页面数据要回传 WinForm,有两条路。一条是注册 C# 桥接对象,JS 里直接调用;另一条是 WinForm 定时 EvaluateScriptAsync 去页面里取。桥接对象实时性好,但生命周期坑多——JS 调用发生在 CEF 的 IO 线程,函数体里直接碰控件会跨线程异常,登录态失效时对象可能已经释放。
// 注册桥接对象,供页面 JS 调用 browser.JavascriptObjectRepository.Register("bridge", new Bridge(), isAsync: true, options: BindingOptions.DefaultBinder);注意:Bridge 里 public 方法被 JS 调用时不在 UI 线程,更新控件必须 BeginInvoke,异常也要在方法内部捕获,不要指望 CEF 帮你兜。
我一般会优先用事件加主动取的方式:页面把状态写到 DOM 的 data 属性或 window 变量里,WinForm 侧在业务需要时 EvaluateScriptAsync 拉取。少一个双向通道就少一类崩溃,稳定性比实时性重要。等到方案跑稳了,再考虑把高频通道切到桥接对象上。
5. CefSharp 拦截与注入的避坑记录:五个反复出现的坑
这套东西没有太多玄学,全是时机和生命周期问题。下面五个坑是我自己项目里真实翻过车的,按现象到解决写清楚。
5.1 response 数据读一半或中文乱码,页面还渲染空白
现象:Filter 里 dataIn.Length 明明有几 KB,但一次回调只给几百字节,拼出来的字符串乱码;更严重的是整个页面白屏,控制台没有任何报错。 原因:响应体本来就是分块到达的,Filter 会被多次调用,把 dataIn.Length 当成完整 body 一次性读,数据自然不完整。白屏则是只读不写 dataOut 造成的——等于把页面的 body 数据吃掉了。 解决:每次回调把数据块累积到 MemoryStream,Dispose 时统一转 UTF8;每次 Read 后立刻原样 Write 进 dataOut,并正确累加 dataOutWritten。遇到白屏,先怀疑有没有回写,再怀疑编码。
5.2 POST body 在重定向后变空了
现象:登录接口第一次 OnBeforeResourceLoad 能读到用户名和密码,第二次回调里 URL 还是登录地址,但 PostData 为空。 原因:CEF 重定向时会重建 Request 对象,body 被清掉,而且 IsReadOnly=true 的 PostData 不能读取 Elements,硬读会抛异常。 解决:第一次读到请求时就把 body 存进字典,key 用 request.Url 或 request.Identifier。后续事件直接取缓存数据,不要指望第二次还能读到。读取前先判 IsReadOnly,并在 try/catch 里兜底。
5.3 jquery 注入不生效或提示“jQuery is not defined”
现象:DocumentCreated 注册了 jquery,但 FrameLoadEnd 里执行业务代码时,typeof jQuery 还是 undefined;刷新后偶尔又好了。 原因:注册时机太晚。导航已经开始才调用 AddScriptToExecuteOnDocumentCreatedAsync,页面已经创建过 document,脚本就错过了执行点。另外脚本内容过长或含非法字符时,CEF 可能静默丢弃注册内容,不报错也不执行。 解决:注册动作放在首次导航之前;同时保留 FrameLoadEnd 里的 typeof 兜底检查。注入内容一律用 JsonConvert.SerializeObject 转义,不做任何手拼字符串。
5.4 窗体关闭时在事件里访问 Browser 导致崩溃
现象:关闭窗体时偶发 ObjectDisposedException,进程退出后还残留几个 cef 相关子进程。 原因:FrameLoadEnd、LoadingStateChanged 这些回调不在 UI 线程,控件销毁后事件仍可能触发,此时访问 e.Frame 或 browserControl 已经访问了已释放对象。 解决:事件处理函数第一行判 IsMain 和 IsDisposed;回 UI 用 BeginInvoke 包 try/catch。FormClosing 里先注销事件,再在程序收尾统一调用一次 Cef.Shutdown()。注意 Cef.Shutdown 只能调一次,多窗体时要保证只执行一次,否则二次调用会崩。
5.5 多窗口共用 Handler 导致数据串台
现象:开了两个窗体操作不同账号,A 窗体的接口数据出现在 B 窗体的日志里。 原因:CefSharp 事件参数里的 IBrowser 是全局共享的,如果 RequestHandler 是静态单例,捕获的数据没有归属信息,串台是必然结果。 解决:每个窗体创建自己的 CaptureRequestHandler,赋给自己的控件的 RequestHandler。事件回调里用 browserControl 参数确认当前实例。凡是设计成全局共享的 Handler,必须在事件参数里做浏览器实例归属判断,否则日志和数据都是乱的。
6. 进阶:把拦截与注入封装成可复用的 BrowserInterceptor,并做可验证的注入自检
6.1 用事件封装 Handler 与 Filter,UI 只订阅不接触 API
第二个项目里,我把 RequestHandler、Filter 和注入逻辑收拢成一个 BrowserInterceptor 类,对外只暴露事件。窗体里订阅事件更新 DataGridView,业务代码不再直接接触 IFrame、IRequest 这些 CEF 类型,后续换控件、升级包都只动一个类。
public class BrowserInterceptor { private readonly CaptureRequestHandler _handler = new CaptureRequestHandler(); public event Action<RequestInfo> RequestCaptured; public event Action<string, string> ResponseCaptured; public void Attach(ChromiumWebBrowser browser) { browser.RequestHandler = _handler; _handler.RequestCaptured += info => RequestCaptured?.Invoke(info); _handler.ResponseCaptured += (url, body) => ResponseCaptured?.Invoke(url, body); } public void Detach(ChromiumWebBrowser browser) { browser.RequestHandler = null; } }Detach 必须在 FormClosing 里调用,先把 RequestHandler 摘掉,再让控件释放。窗体里只需要两行:构造函数 Attach,关闭事件 Detach。日志面板里同时打印请求 URL、请求方法和响应体字节数,数据流水能对上,再放开业务逻辑跑。
6.2 注入自检:打标记读回来,比肉眼验证靠谱
注入是否真正执行,不要靠肉眼在页面里找。我的习惯是注入完成后,在页面 body 上写一个自定义属性,再读回来验证。读到了才算真正生效,这个断言能抓出很多“以为注入了但实际没执行”的问题。
var mark = await frame.EvaluateScriptAsync( "document.body ? " + "(document.body.setAttribute('data-injected','1'), 'ok') : 'not-ready'"); if (mark.Success && mark.Result?.ToString() == "ok") { var verify = await frame.EvaluateScriptAsync( "document.body.getAttribute('data-injected')"); if (verify.Result?.ToString() == "1") { Log("injection verified"); } }这套自检方法一直沿用到现在。我自己最初做这套方案时,就栽在“相信控制台、不相信日志”,页面白屏查了半天才发现是 Filter 没回写。后来所有拦截和注入逻辑都带自检标记,数据对不上先看事件流,再查具体环节,问题定位时间从小时级降到分钟级。希望帮到你。
本文还有配套的精品资源,点击获取