简介:这是一份围绕 Visual Studio 2010 与 CEF3 90.6.0 的 MFC 嵌入工程资源,面向需要在传统 C++ 桌面项目中集成 Chromium 浏览器内核、并打通 JavaScript 与 C++ 调用的开发者。资源包共 629 个文件,约 457.22MB,以 CEF 头文件、PAK 资源包、DLL 运行时、LIB/OBJ 构建产物以及少量 CPP 源码和解决方案配置为主,同时保留 Debug、Release 输出与预编译相关文件,便于直接对照工程结构、依赖配置和运行环境。已有 1558 人学习,适合正在维护 MFC 老项目、研究 CEF 消息循环同步、渲染窗口承载和 JS 调用 C++ 路由机制的读者参考。其价值在于提供一套可落地的集成样例与完整依赖集合,能帮助快速理解 CEF3 在 MFC 窗口中的组织方式,排查库引用、子进程启动和资源加载问题,并作为二次开发与兼容性验证的基础工程。
1. 从对话框里长出一个浏览器:为什么要在MFC里嵌CEF3
接手一个维护多年的老MFC客户端,产品经理说要在里面嵌一个数据看板,前端用 H5 写,后端还是原来的 C++ 业务逻辑。这种需求听着不复杂,真正动起手来,光是把 CEF3 的初始化塞进 MFC 的消息泵就够折腾一阵。CEF3 90.6.0 是基于 Chromium 90 的嵌入式浏览器框架,它把浏览器内核、V8 引擎、渲染管线打包成一套 C++ 接口,让你能在自己的窗口里显示网页、跑 JavaScript,并且和原生代码双向通信。适合的人群很具体:手上维护着 MFC 老工程、需要内嵌网页做报表或配置界面、又不想把整个前端栈推倒重来的 C++ 工程师。这一篇就围着「vs2010 + cef3 90.6.0 + MFC + js 调用」这条线,把工程怎么搭、窗口怎么挂、函数怎么注册、哪些地方容易翻车,一步步拆开。
2. 工程对接:CEF3 90.6.0 的目录、工具集与运行时库
CEF3 的接入不是把一个 DLL 丢进目录那么简单。它分三块:C API 的头文件和导入库、C++ 封装层 libcef_dll_wrapper、以及运行时资源(libcef.dll、locales、icudtl.dat、snapshot 等)。头文件和库放错位置,工程能编译过、链接过,但一运行就闪退,这是最常见的第一道坎。
2.1 为什么选 90.6.0 这个分支而不是随手下一个最新包
CEF3 每个版本对应一个 Chromium 大版本,90.6.0 对应 Chromium 90。老项目锁这个版本通常有两个现实原因:一是接口相对稳定,CefClient、CefV8Handler、CefLifeSpanHandler 这套回调模型和早期 3.2xxx 系列差别不大,迁移成本低;二是它还在用传统的窗口句柄模型(SetAsChild),没有完全倒向 OSR(离屏渲染)。如果你已经有一份 3.2623 时代的代码,升级到 90.6.0 主要是改一改 CefSettings 里的字段和部分枚举名,主体骨架能复用。
不过需要说清楚一个现实约束:CEF3 90.x 的官方二进制分发和 libcef_dll_wrapper 源码,默认是用较新的 MSVC 工具集编译的,C++ 标准用到了 C++14 的部分特性。用 VS2010(v100 工具集)直接编 wrapper,大概率会在 std:: 相关头文件上报错。我一般的做法是保留 VS2010 的 .vcxproj 工程文件外壳(团队里还有人用 VS2010 打开看代码),但把「平台工具集」切到 v142(VS2019)来实际编译。libcef.dll 本身是预编译的,只要你调用侧的 C++ ABI 和 wrapper 一致,运行时就能对上。
提示:如果你确实被锁死在 v100 工具集,只能退到 3.2623 附近的版本,或者自己用 VS2010 能接受的语法重写 wrapper 里出问题的部分,工作量大且容易漏。
下表是我在 90.6.0 包上实际摆放的目录约定,和后面对应的工程配置直接相关:
| 目录 | 放什么 | 对应工程配置 |
|---|---|---|
third_party/cef/include | 全部 CEF 头文件(含cef_v8.h、cef_client.h) | 附加包含目录 |
third_party/cef/lib/x86 | libcef.lib、libcef_dll_wrapper.lib | 附加库目录 |
third_party/cef/bin | libcef.dll、locales/、icudtl.dat、snapshot_blob.bin | 生成后事件拷贝到输出目录 |
third_party/cef/wrapper | wrapper 源码(若自行编译) | 单独建静态库工程 |
2.2 附加包含目录与附加库目录的配置
打开工程属性,C/C++ → 常规 → 附加包含目录,加两行(相对路径以工程目录为基准):
$(ProjectDir)third_party\cef\include $(ProjectDir)third_party\cef链接器 → 常规 → 附加库目录加$(ProjectDir)third_party\cef\lib\x86,链接器 → 输入 → 附加依赖项写libcef.lib;libcef_dll_wrapper.lib;。
这里有个容易被忽略的细节:libcef.lib是导入库,它只负责把cef_*这组 C 接口导出到 libcef.dll;真正的 C++ 类(CefClient、CefV8Handler 等)的符号在libcef_dll_wrapper.lib里。只加前者,编译能过,链接时一堆CefClient::...找不到定义。
// stdafx.h 末尾统一包一层,避免每个文件重复写 #include "include/cef_app.h" #include "include/cef_client.h" #include "include/cef_browser.h" #include "include/cef_v8.h" #include "include/wrapper/cef_helpers.h"逻辑说明:cef_helpers.h里的CEF_REQUIRE_UI_THREAD()这类宏能在调试期帮你抓线程误用,后面 JS 回调那章会反复用到。参数上,include和include/wrapper两个路径都要进包含目录,wrapper 的头文件里有CefRefPtr的辅助封装。
2.3 运行时库必须与 wrapper 一致
CEF 的 C++ 封装层和你的工程共用 libcef.dll 里的对象,内存分配跨了 DLL 边界。如果 wrapper 用/MD(多线程 DLL 运行时)编,你的工程用/MT(静态运行时),运行到CefRefPtr析构时就是释放了错误的堆,表现为退出时崩溃或者随机位置崩。
项目属性 → C/C++ → 代码生成 → 运行时库 Debug: 多线程调试 DLL (/MDd) Release: 多线程 DLL (/MD)这个设置要和编译libcef_dll_wrapper.lib时用的保持一致。官方预编译的 wrapper 默认是/MD,所以你的工程也必须是/MD,不要图省事选静态运行时。这一步踩过的人不少,表现又特别玄学——不报错、不定时崩,加上 PDB 也看不出所以然,血泪经验就是先确认这对设置。
另外,生成后事件里把libcef.dll和那几个资源文件拷到$(OutDir):
xcopy /Y /E "$(ProjectDir)third_party\cef\bin\*.*" "$(OutDir)"注意locales目录和icudtl.dat缺一不可,少了任何一个,CefInitialize返回 false 但不会告诉你具体缺什么,只能自己对着目录一个个比。
3. 把浏览器挂到 MFC 窗口:CefWindowInfo 与消息循环
工程配置对了,接下来是让浏览器真正显示在你的对话框或视图里。CEF 的窗口模型分两种:用SetAsChild挂到已有的 HWND 上(窗口化模式),或者用 OSR 自己贴图。MFC 工程里绝大多数场景走前者,因为省事、渲染性能好、跟原生控件共存也自然。
3.1 MFC 消息泵如何驱动 CEF 的单线程循环
CEF 默认使用单线程消息循环(CefSettings.multi_threaded_message_loop = false),在这种模式下,你必须主动在自己的消息循环里「喂」它。MFC 的CWinApp::Run是一个标准的GetMessage/DispatchMessage循环,CEF 需要你在空闲时调用CefDoMessageLoopWork()。
常见的做法是重写CWinApp::OnIdle:
BOOL CMyApp::OnIdle(LONG lCount) { BOOL bRet = CWinApp::OnIdle(lCount); // lCount 为 0 表示消息队列已空,此时让 CEF 处理它的任务 if (lCount == 0) { CefDoMessageLoopWork(); } return TRUE; // 返回 TRUE 让 MFC 继续在空闲时调用 }逻辑说明:OnIdle被 MFC 在消息队列为空时反复调用,lCount是连续空闲次数。只在lCount == 0时喂 CEF,避免在连续空闲时重复调同一批任务。返回TRUE保持空闲循环运转,否则 CEF 的定时器、渲染提交都会被拖慢,页面看起来就是一卡一卡的。
如果你不想动OnIdle,也可以在CWinApp::Run里自己写循环,但那样要处理PeekMessage和CefDoMessageLoopWork的交错,容易漏消息,不推荐新手走这条路。
3.2 CefWindowInfo 绑定父窗口句柄
初始化流程的骨架是这样的:先CefInitialize,再CefBrowserHost::CreateBrowser,最后在窗口销毁时CefShutdown。
// 初始化放在 App 的 InitInstance 里,CreateBrowser 放在 View/Dialog 的初始化里 CefMainArgs main_args(::GetModuleHandle(nullptr)); CefSettings settings; settings.no_sandbox = true; // 调试期关沙箱,方便附加进程 settings.multi_threaded_message_loop = false; CefString(&settings.locales_dir_path) = L"locales"; CefString(&settings.resources_dir_path) = L""; CefInitialize(main_args, settings, nullptr, nullptr); // 在视图窗口的 OnCreate 里挂浏览器 void CWebView::CreateBrowser() { RECT rc; GetClientRect(&rc); CefWindowInfo info; info.SetAsChild(GetSafeHwnd(), rc); // 父句柄 + 初始矩形 CefBrowserSettings browser_settings; CefRefPtr<CMyClient> client(new CMyClient()); CefBrowserHost::CreateBrowser(info, client, L"http://localhost:8080/index.html", browser_settings, nullptr, nullptr); }逻辑说明:SetAsChild传入 MFC 视图的 HWND,CEF 会在这个父窗口里创建一个子 HWND 承载渲染。CefBrowserSettings里可以关掉插件、设默认字体,90.6.0 里字段名和旧版略有差异,比如javascript字段在旧版叫javascript_disabled,90.x 改成了javascript(默认允许),照着头文件改就行。
参数要点:创建时的矩形用客户区尺寸,不要用GetWindowRect,否则窗口边框会被算进去,页面出现滚动条。URL 用file:///绝对路径或本地 HTTP 服务,路径里有中文或空格时要做 URL 编码,这是后面避坑章要展开的一个点。
3.3 尺寸变化与 DPI 缩放
窗口一调整大小,浏览器不会自动跟着变,得在OnSize里调WasResized:
void CWebView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); if (m_browser && m_browser->GetHost()) { // 通知 CEF 渲染视口变了,内部会重新布局 m_browser->GetHost()->WasResized(); } }逻辑说明:WasResized()告诉 CEF 客户区尺寸改变了,它会重新计算视口并触发一次渲染。只改父窗口大小不调这个方法,页面会停留在旧尺寸,看起来像被裁掉了。
高 DPI 环境下还有一层:如果你的进程是 DPI 感知的(manifest 里声明了),CEF 也会按缩放比例渲染,但父窗口的客户区坐标是逻辑像素,两边要对齐。我一般会在CefSettings里保持默认,然后在视图里用GetClientRect拿物理像素矩形传给SetAsChild,避免 125% 缩放下页面模糊。踩过一次 150% 缩放下页面只显示左上角四分之一的坑,原因就是传了逻辑坐标。
4. JS 调用 C++:CefV8Handler 注册与回调线程
嵌入浏览器只是第一步,真正让这套方案有价值的是 JS 和 C++ 能互相调用。前端点一个按钮,C++ 去查本地数据库或者调系统 API,把结果回给页面。这条路走通了,MFC 客户端才算真的「长出」一个前端层。
4.1 用 CefV8Handler 把原生函数注册进 JS 上下文
CEF 的机制是:在页面加载完成、JS 上下文创建后,往window对象上挂自定义函数,函数的实现由CefV8Handler::Execute接管。
class CNativeBridge : public CefV8Handler { public: bool Execute(const CefString& name, CefRefPtr<CefV8Value> object, const CefV8ValueList& arguments, CefRefPtr<CefV8Value>& retval, CefString& exception) override { if (name == "queryDeviceInfo") { // 从 JS 拿一个字符串参数 std::string sn = arguments.size() > 0 && arguments[0]->IsString() ? arguments[0]->GetStringValue().ToString() : ""; std::string result = QueryLocalDb(sn); // 你的原生逻辑 retval = CefV8Value::CreateString(result); return true; } return false; // 未处理的函数返回 false,CEF 继续找别的 handler } IMPLEMENT_REFCOUNTING(CNativeBridge); };逻辑说明:Execute的返回值true表示这个函数已经被处理,CEF 不再往下找;retval是返回给 JS 的值,可以是字符串、数字、对象。IMPLEMENT_REFCOUNTING是 CEF 的引用计数宏,必须写,否则对象生命周期管不住。
注册发生在CefLoadHandler::OnContextCreated里:
void CMyClient::OnContextCreated(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context) { CefRefPtr<CefV8Value> window = context->GetGlobal(); CefRefPtr<CNativeBridge> handler(new CNativeBridge()); CefRefPtr<CefV8Value> func = CefV8Value::CreateFunction("queryDeviceInfo", handler); // 挂到 window.__nativeBridge.queryDeviceInfo,避免污染全局 CefRefPtr<CefV8Value> bridge = CefV8Value::CreateObject(nullptr, nullptr); bridge->SetValue("queryDeviceInfo", func, V8_PROPERTY_ATTRIBUTE_NONE); window->SetValue("__nativeBridge", bridge, V8_PROPERTY_ATTRIBUTE_NONE); }逻辑说明:把原生函数收进一个__nativeBridge对象里,比直接挂window.queryDeviceInfo干净,前端调用写成window.__nativeBridge.queryDeviceInfo(sn)。OnContextCreated在每次页面导航、iframe 创建时都会触发,所以注册逻辑要放在这里而不是初始化时一次性挂。
参数要点:SetValue的第三个参数是属性描述符,V8_PROPERTY_ATTRIBUTE_NONE表示可写可枚举,一般用这个就够。如果你的函数不想被页面覆盖,用V8_PROPERTY_ATTRIBUTE_READONLY。
4.2 C++ 主动调 JS:CefFrame::ExecuteJavaScript
反方向的调用简单得多,拿到 frame 直接执行一段 JS 字符串:
void CMyClient::NotifyPage(const CefString& json) { if (!m_browser || !m_browser->GetMainFrame()) return; // 用 JSON 字符串安全地嵌入,避免引号拼接导致语法错误 CefString script = L"window.onNativeData(" + json + L");"; m_browser->GetMainFrame()->ExecuteJavaScript(script, m_browser->GetMainFrame()->GetURL(), 0); }逻辑说明:ExecuteJavaScript的第二个参数是脚本来源 URL,填当前页面 URL 即可,影响报错时的堆栈显示;第三个参数是起始行号,填 0。json必须是合法 JSON,别在 C++ 里手拼"data":"这种,一个反斜杠就能让整段脚本语法错误,页面静默失败,调试半天。
前端侧约定一个全局入口:
// 页面里预先定义好,C++ 端只管调 window.onNativeData = function (payload) { const data = typeof payload === 'string' ? JSON.parse(payload) : payload; document.getElementById('deviceSn').innerText = data.sn; };4.3 回调线程与 CefPostTask 的切换
这里是最容易出问题的地方。CefV8Handler::Execute跑在渲染进程的 V8 线程上,你在里面碰 MFC 的控件就是跨线程访问,轻则断言、重则崩溃。正确的做法是把要做的事 post 回 UI 线程,或者自己起工作线程再 post 回来。
// 在 Execute 里不要直接操作 UI,先 post 到 CEF 的 UI 线程 CefPostTask(TID_UI, base::BindOnce(&CMyClient::DoNativeWork, m_client.get(), sn));逻辑说明:TID_UI是浏览器进程的 UI 线程,也是 MFC 主线程,在这里碰窗口控件是安全的。base::BindOnce是 90.x 里用的回调绑定方式,如果 wrapper 版本较老,可能是base::Bind,照着你的头文件选。
如果原生逻辑比较重(比如查数据库要几十毫秒),不要占着 UI 线程,再 post 到TID_FILE_USER_BLOCKING或者自己std::thread起步,算完再CefPostTask(TID_UI, ...)回主线程更新页面。这套「渲染线程 → UI 线程 → 工作线程 → UI 线程」的跳法,是 CEF 多线程模型的固定套路,记住这个顺序,基本不会犯线程错。
5. 避坑与排查:CEF3 嵌 MFC 的五个高频翻车点
这一章按「现象 → 原因 → 解决」写,都是我在真实工程里踩过、或者帮人定位过的。每一条都能独立对照排查,不用按顺序看。
5.1 链接报 LNK2001,找不到 CefClient 相关符号
现象:编译全过,链接时报unresolved external symbol CefClient::...、CefV8Value::CreateString之类的错。
原因:只加了libcef.lib,漏了libcef_dll_wrapper.lib。前者是 C 接口导入库,后者才包含 C++ 类的实现。
解决:链接器输入里补上 wrapper 库;如果 wrapper 是自己编的静态库,确认它和主工程用同一个工具集、同一套运行时库(/MD对/MD),否则即使名字对上,符号修饰也不一致。
5.2 窗口一片白,页面没加载出来
现象:浏览器窗口创建成功,但一直白屏,DevTools 也看不到网络请求。
原因:大概率是CefInitialize返回了 false,但返回值被忽略。常见原因是缺少icudtl.dat、locales目录或者libcef.dll版本不匹配。
解决:第一步先判断CefInitialize的返回值,为 false 就return FALSE。第二步对照 bin 目录,把icudtl.dat、snapshot_blob.bin、locales/en-US.pak逐个确认到位。第三步检查libcef.dll的位数(x86 工程配 x86 DLL),位数不对时进程直接起不来或者初始化失败。
if (!CefInitialize(main_args, settings, nullptr, nullptr)) { // 先别急着往下走,这里失败后面全是白屏 AfxMessageBox(L"CEF 初始化失败,检查 bin 目录资源是否齐全"); return FALSE; }5.3 JS 调 C++ 没反应,或直接崩在 CefV8Handler
现象:页面调用window.__nativeBridge.xxx(),控制台报undefined is not a function,或者进程在 Execute 里崩。
原因一:注册时机不对。OnContextCreated没触发,或者CefClient没设置好GetV8ContextHandler。原因二:在Execute里直接操作了 MFC 控件,跨线程崩。
解决:先确认CefClient的GetV8ContextHandler()返回了有效对象,再确认OnContextCreated里context->GetGlobal()拿到的是当前 frame 的上下文。跨线程的问题按 4.3 节改成CefPostTask(TID_UI, ...)。另外,Execute里抛异常(比如arguments[0]越界访问)也会导致进程崩溃,取值前先判arguments.size()和IsString()。
5.4 程序退出卡死或退出时崩溃
现象:点关闭,窗口没了,进程一直挂着;或者退出时弹「内存不能读」之类的崩溃框。
原因:CefShutdown调用时机不对。CEF 要求所有浏览器窗口都销毁、引用计数归零后才能CefShutdown,否则内部线程还在跑,主进程退出时资源已经释放。
解决:在CWinApp::ExitInstance里先销毁所有 browser,等OnBeforeClose触发后再CefShutdown。如果进程里只挂了一个浏览器,按下面顺序走:
int CMyApp::ExitInstance() { // 1. 关闭所有 browser(会触发 OnBeforeClose) if (m_client && m_client->GetBrowser()) { m_client->GetBrowser()->GetHost()->CloseBrowser(true); } // 2. 等待引用计数释放(简单做法:跑几轮消息循环) for (int i = 0; i < 5 && m_client && m_client->GetBrowser(); ++i) { CefDoMessageLoopWork(); Sleep(10); } // 3. 最后再 shutdown CefShutdown(); return CWinApp::ExitInstance(); }更稳的做法是用CefLifeSpanHandler的DoClose+OnBeforeClose配对,最后一章会展开。这个顺序错一步,退出必崩,是 CEF 嵌 MFC 里最经典的翻车现场。
5.5 中文路径、带空格路径加载失败
现象:本地页面file:///D:/我的项目/index.html加载失败,控制台报ERR_FILE_NOT_FOUND。
原因:URL 里不能直接出现中文和空格,CreateBrowser传进去的 CefString 会被原样当 URL 解析,浏览器找不到文件。
解决:在 C++ 侧做 URL 编码,或者干脆在本地起一个 HTTP 服务(比如用httplib或者打包一个轻量服务),页面统一走http://127.0.0.1:port/。我现在的项目一律走本地 HTTP,既能避开路径编码问题,也方便调试时换资源不用重打包。
// 若必须用 file 协议,路径要编码成 %XX 形式 // 简单做法:用 CefURIEncode 处理 CefString encoded = CefURIEncode(L"D:/我的项目/index.html", true); CefString url = L"file:///" + encoded;6. 收尾与验证:用 CefLifeSpanHandler 管住生命周期
前面所有环节能跑通之后,剩下最耗时间的一件事就是让程序退出得干净。我见过太多工程,功能都对,就是每次关窗口都弹一次崩溃框,测试提了几个月都没修。这个问题基本都出在CefLifeSpanHandler的三个回调没有配对好:OnAfterCreated、DoClose、OnBeforeClose。
OnAfterCreated触发时,CefBrowser已经创建完成,此时把m_browser引用记下来。DoClose在窗口收到关闭消息时触发,你的处理是「同意关闭」,返回 false 表示不阻止。真正关闭时OnBeforeClose被调用,这时才把m_browser置空,并让你的 MFC 窗口执行DestroyWindow。
class CMyLifeSpanHandler : public CefLifeSpanHandler { public: void OnAfterCreated(CefRefPtr<CefBrowser> browser) override { // 记录引用,供后续关闭时使用 m_browser = browser; } bool DoClose(CefRefPtr<CefBrowser> browser) override { // 返回 false:允许关闭;同时让宿主窗口也关闭 return false; } void OnBeforeClose(CefRefPtr<CefBrowser> browser) override { // 浏览器真正销毁了,这里可以通知主窗口退出 m_browser = nullptr; if (m_hostWnd && ::IsWindow(m_hostWnd)) ::PostMessage(m_hostWnd, WM_CLOSE, 0, 0); } CefRefPtr<CefBrowser> m_browser; HWND m_hostWnd = nullptr; IMPLEMENT_REFCOUNTING(CMyLifeSpanHandler); };验证方法很直接:开一个带页面加载的实例,点关闭,观察三件事——窗口是否立刻消失、进程是否在 2 秒内退出、有没有崩溃框。如果进程挂着不退,多半是m_browser还有别处持有引用,或者CefShutdown在OnBeforeClose之前被调了。可以把 5.4 节那段ExitInstance里的等待循环当保险,但根子还是在回调顺序。
这套代码我从一个维护多年的 MFC 客户端里抽出来,前后改了三版,最深的教训是:CEF 的生命周期不能靠「看起来关掉了」来判断,必须以OnBeforeClose为准。从那以后我每次新接一个 CEF 工程,都会先只写一个最简页面,把创建、加载、关闭、退出这条链跑通,再往上叠业务逻辑。顺序对了,后面的 JS 调用、多标签、下载管理都只是加减法;顺序错了,每个功能都会以「偶发崩溃」的形式还回来。
希望帮到你。
本文还有配套的精品资源,点击获取