
简介面向 C/MFC 桌面开发者与需要内嵌浏览器能力的混合应用团队这套工程资料围绕 Visual Studio 2010 与 CEF3 90.6.0演示把 Chromium Web 视图嵌入 MFC 窗口并建立 JavaScript 与 C 交互链路适合具备一定 MFC、Win32 消息循环和 C 基础的中高级开发者参考。压缩包共 629 个文件、约 457.22MB以 h 头文件、pak 运行时资源、dll、lib、cpp、obj/tlog 等构建中间文件为主并带有解决方案与项目配置可辅助完成环境搭建、依赖引入、编译链接及运行排错。已有 1558 人学习说明该组合在实际项目中仍有参考需求。读者可从中了解 CEF 子进程与多线程消息循环配置、MFC 窗口尺寸与绘制同步、JS 调用 C 的注册与回调处理思路以及 Debug/Release 产物组织方式工程虽以示例形式呈现但足以作为 VS2010 下 CEF3 集成与调试的对照模板帮助缩短从零配置到可运行的时间。1. 为什么是 90.6.0 这个版本嵌入 MFC 的现实起点CEF3 90.6.0 属于 Chromium 90 分支的正式二进制分发版把它塞进 vs2010 的 MFC 工程本质是拿 2021 年的浏览器内核去接 2010 年的窗口框架。老上位机、工控 HMI、医疗设备界面这类项目升级 IDE 的成本极高但前端页面又必须跑现代 ES 语法于是「新内核 老壳」的组合就成了刚需。真正要解决的无非三件事浏览器子窗口怎么挂到 MFC 的 View 或对话框上、消息泵归谁泵、JS 与 C 之间怎么互调而不互相拖死。这套东西适合还在维护 VS2010 工程的 C 开发者新手能照第 3、4 章把流程跑通熟手可以直接跳到第 2 章看编译链选型和第 5 章的翻车记录。2. 编译链先理清VS2010 编不动 CEF 90 的头文件怎么办动手之前必须接受一个事实CEF 90 的 C 头文件用了override、std::unique_ptr、可变参数模板这些 VS2010 编不过的语法。这不是配置问题是编译器能力问题。硬啃只有两条路——要么放弃 VS2010要么在编译链上做切分。切分的核心依据是 libcef 的导出边界。2.1 先看清 libcef 的导出边界C ABI 才是能跨编译器的部分libcef.dll 对外暴露的符号是cef_*这一批 C 函数签名里只有基础类型、指针和结构体指针没有 STL 容器、没有 C 类、没有异常。这条 C ABI 是稳定的任何版本的 MSVC 都能链接它包括 VS2010。真正编不过的是include/cef_client.h、include/cef_v8.h这些 C 封装头以及libcef_dll_wrapper这个静态库。wrapper 的作用是把 C 结构体包装成CefRefPtrCefClient那样的 C 类它本身必须跟 libcef.dll 用同一套头文件编译所以它只能用 VS2019 编。于是路线就清楚了wrapper 和所有 CEF 相关的 C 代码封进一个中间层 DLL用 VS2019 编译VS2010 的 MFC 工程只 include 自己写的纯 C 头文件通过LoadLibrary/ 导入库调用。跨 DLL 边界只传HWND、int、const char*、void*这几类 POD 类型绝不传std::string、CefRefPtr、std::function这样两套运行库VS2010 的 msvcr100 和 VS2019 的 vcruntime140同时存在于一个进程里也不会打架。还有一种备选是直接 includeinclude/capi/下那套纯 C 头文件在 VS2010 里手动实现引用计数和回调函数表。这条路能走通但cef_client_t的每个回调都要自己填函数指针一个浏览器实例的光标、下载、生命周期回调填下来接近两百行样板代码维护成本高。除非你的构建环境完全禁止引入 VS2019否则不建议。CEF 二进制包里需要落地的文件并不多但一个都不能少文件/目录放置位置作用漏了会怎样libcef.dllexe 同级内核本体启动直接失败libcef.lib链接目录导入库链接期报 LNK2019libcef_dll_wrapper.lib链接目录C 封装层同上icudtl.datexe 同级ICU 数据页面中文乱码或崩溃locales/*.pakexe 同级 locales语言资源界面文案回退英文cef.pak / cef_100_percent.pakexe 同级内核资源白屏或渲染异常natives_blob.bin / v8_context_snapshot.binexe 同级V8 快照JS 引擎初始化失败2.2 用 VS2019 编一个 C 接口中间层 DLL中间层对外只暴露五个函数够用了初始化、创建浏览器、销毁浏览器、执行 JS、取窗口句柄。头文件长这样/* mycef_bridge.h —— 纯 CVS2010 可以直接 include */ #ifndef MYCEF_BRIDGE_H #define MYCEF_BRIDGE_H #ifdef MYCEF_EXPORTS #define MYCEF_API __declspec(dllexport) #else #define MYCEF_API __declspec(dllimport) #endif #ifdef __cplusplus extern C { #endif /* 返回值0 成功非 0 失败 */ MYCEF_API int MyCef_Init(void* hInstance, const char* subProcessPath, const char* cacheDir, const char* logFile, int debugPort); /* 在 parentHwnd 内创建浏览器rect 传客户区大小 */ MYCEF_API int MyCef_CreateBrowser(void* parentHwnd, int x, int y, int w, int h, const char* urlUtf8); /* 主线程定时器里调每 10~30ms 一次 */ MYCEF_API void MyCef_DoMessageLoopWork(void); /* 窗口尺寸变化 */ MYCEF_API void MyCef_Resize(int w, int h); /* 执行 JSjsUtf8 为 UTF-8 编码脚本文本 */ MYCEF_API int MyCef_ExecuteJavaScript(const char* jsUtf8); /* 关闭浏览器并释放必须在主线程调返回后再调 MyCef_Shutdown */ MYCEF_API int MyCef_CloseBrowser(void); MYCEF_API void MyCef_Shutdown(void); /* 原生事件回调由中间层在 browser 进程 UI 线程回调MFC 侧自行 PostMessage 回主线程 */ typedef void (*MyCef_NativeEventHandler)(const char* cmdUtf8, const char* jsonUtf8, int queryId); MYCEF_API void MyCef_SetNativeHandler(MyCef_NativeEventHandler handler); #ifdef __cplusplus } #endif #endif初始化那段的实现要点在参数取舍上// bridge.cpp —— 用 VS2019 编译链接 libcef.lib libcef_dll_wrapper.lib MYCEF_API int MyCef_Init(void* hInstance, const char* subProcessPath, const char* cacheDir, const char* logFile, int debugPort) { CefMainArgs mainArgs((HINSTANCE)hInstance); // 传 exe 的模块句柄不是 dll 的 CefSettings settings; settings.no_sandbox true; // 老工程常带第三方注入关沙箱最省事 settings.multi_threaded_message_loop false; // 关键消息泵交给 MFC 主线程 settings.windowless_rendering_enabled false; // 走原生子窗口不做离屏 settings.log_severity LOGSEVERITY_INFO; settings.remote_debugging_port debugPort; // 8321方便开 DevTools 查 JS CefString(settings.locale) zh-CN; if (cacheDir) CefString(settings.cache_path) cacheDir; if (logFile) CefString(settings.log_file) logFile; if (subProcessPath) CefString(settings.browser_subprocess_path) subProcessPath; CefEnableHighDPISupport(); // 必须在 Initialize 之前 if (!CefInitialize(mainArgs, settings, nullptr, nullptr)) return -1; return 0; }settings.no_sandbox true这一行在老项目里几乎是必选项因为很多上位机会加载加密狗驱动、注入式监控模块沙箱一起来子进程就直接被杀。代价是安全性下降内网设备可以接受公网产品要重新评估。multi_threaded_message_loop false是整套方案的枢纽。设成 true 意味着 CEF 自己起一条 UI 线程跑CefRunMessageLoop()那条循环和 MFC 的CWinThread::Run()是两条独立的GetMessage循环谁先拿到消息全靠运气MFC 的模态对话框、菜单、快捷键会大面积失灵。设成 false 后CEF 的所有 UI 线程工作都靠主线程主动调用CefDoMessageLoopWork()驱动控制权完全在 MFC 手上。编译 wrapper 用官方二进制包自带的 CMake 工程:: 在 cef_binary 的 cmake 目录下注意必须是 Win32跟 VS2010 工程位宽一致 set CEF_ROOTD:\sdk\cef_binary_90_6_0_windows32 cmake -G Visual Studio 16 2019 -A Win32 -DCMAKE_BUILD_TYPERelease .. cmake --build . --config Release --target libcef_dll_wrapper-A Win32不能省。VS2010 的工程基本都是 x86拿 x64 的 wrapper 去链接会一路 LNK1112 / LNK2038。构建完成后把libcef_dll_wrapper.lib和自己的mycef_bridge.dll一起放进输出目录。注意中间层 DLL 和 MFC 主程序的运行库配置/MD 或 /MT不必一致但中间层内部的 wrapper 和 bridge 必须一致否则CefRefPtr的引用计数会在两个堆之间来回跑退出时必崩。2.3 VS2010 侧工程配置只认自己的头文件和导入库VS2010 工程这边要做的只有四件事全在项目属性里。第一附加包含目录加mycef_bridge.h所在路径不要加 CEF 的include/。这一步是防止有人手滑#include cef_client.h加了就会触发几百行 C11 报错把真正的问题淹掉。第二附加库目录指向mycef_bridge.lib附加依赖项写mycef_bridge.lib。如果暂时没有导入库用LoadLibraryGetProcAddress也行只是函数指针表要自己维护。第三调试工作目录设成 exe 输出目录并在生成后事件里把libcef.dll、icudtl.dat、*.pak、locales/、*.bin拷过去。CEF 的资源文件路径是相对 exe 目录找的用 VS 调试时工作目录不对就会白屏。第四InitInstance最开始加子进程分流这一步漏了会直接导致渲染进程起不来BOOL CMyApp::InitInstance() { // 子进程分流必须放在任何 MFC 初始化之前 HINSTANCE hInst AfxGetInstanceHandle(); CefMainArgs mainArgs(hInst); // 中间层暴露一个探测函数返回 0 说明当前是子进程立刻退出 int exitCode MyCef_ExecuteProcess(hInst); if (exitCode 0) return FALSE; // ... 正常 MFC 初始化 ... return CWinApp::InitInstance(); }MyCef_ExecuteProcess内部就是CefExecuteProcess(mainArgs, nullptr, nullptr)。这个函数会检查命令行里的--typerenderer之类的参数是子进程就直接进 CEF 自己的循环返回时进程该退出了是主进程则返回 -1继续往下走。子进程的 exe 路径默认就是主 exe所以 CEF 会用同一个可执行文件反复启动自己。如果你的主 exe 名字被改过、或者装在带空格的路径下就要显式设browser_subprocess_path指向一个专门的subprocess.exe否则会出现「主窗口出来了但页面永远白屏」的经典现象。3. 把浏览器窗口塞进 MFC窗口句柄、尺寸与消息泵中间层通了之后剩下的活是把浏览器子窗口挂到 MFC 的客户区里并让主线程的消息泵既伺候 MFC 又伺候 CEF。这一段出问题的表现通常很直观窗口位置不对、拖动时白屏、页面点了没反应。3.1 SetAsChild 与 CefWindowInfo 的父子关系CEF 在 Windows 上的默认渲染模式是原生子窗口CefWindowInfo::SetAsChild就是给内核指定一个父 HWND 和一个初始矩形。这个父窗口通常取CView的m_hWnd或者对话框上预留的一个静态控件。// bridge.cpp MYCEF_API int MyCef_CreateBrowser(void* parentHwnd, int x, int y, int w, int h, const char* urlUtf8) { if (!g_client) return -2; CefWindowInfo info; info.SetAsChild((HWND)parentHwnd, CefRect(x, y, w, h)); // 坐标相对父窗口客户区 CefBrowserSettings bs; bs.background_color 0xFFFFFFFF; // 白底避免加载中透出黑色 bs.javascript_access_clipboard STATE_DISABLED; CefRefPtrCefRequestContext ctx CefRequestContext::GetGlobalContext(); bool ok CefBrowserHost::CreateBrowser(info, g_client.get(), CefString(urlUtf8), bs, nullptr, ctx); return ok ? 0 : -3; }CefRect里的坐标是相对父窗口客户区的不是屏幕坐标传错了浏览器窗口会跑到屏幕左上角。父窗口如果是对话框上的静态控件记得把静态控件的SS_NOTIFY打开、ID 设成非IDC_STATIC否则拿不到有效 HWND。浏览器创建是异步的CreateBrowser返回 true 只代表请求入队成功。真正的 HWND 在CefLifeSpanHandler::OnAfterCreated里才拿得到void MyClient::OnAfterCreated(CefRefPtrCefBrowser browser) { m_browser browser; // 保存 CefRefPtr HWND child browser-GetHost()-GetWindowHandle(); // 此刻才是有效 HWND ::ShowWindow(child, SW_SHOW); }这个顺序不能反。在CreateBrowser之后立刻GetWindowHandle()拿到的是 NULL后面所有::SetWindowPos都会静默失败表现出来就是「浏览器已经加载完成但界面上什么都看不见」。3.2 消息循环选型CefDoMessageLoopWork 定时泵multi_threaded_message_loop false之后CEF 的内部任务渲染合成、IPC 处理、定时器全都排队等着被泵。泵的方式就是用 MFC 的SetTimer定期调CefDoMessageLoopWork()。// MainFrm.cpp int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) -1) return -1; MyCef_Init(AfxGetInstanceHandle(), nullptr, .\\cache, .\\cef.log, 8321); MyCef_CreateBrowser(m_viewHwnd, 0, 0, m_clientW, m_clientH, http://127.0.0.1/index.html); // 10~30ms 一次低于 10ms 会明显吃 CPU高于 33ms 滚动会卡 SetTimer(TIMER_CEF_PUMP, 10, NULL); return 0; } void CMainFrame::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_CEF_PUMP) { MyCef_DoMessageLoopWork(); // 内部调用 CefDoMessageLoopWork() return; // 不要再往下传避免重复处理 } CFrameWnd::OnTimer(nIDEvent); }定时器精度取 10ms 是个经验值。CEF 官方注释里明确写着CefDoMessageLoopWork调用过于频繁会拖慢渲染线程而 33ms约 30fps在拖拽窗口时又会明显掉帧。10~16ms 在多数工控机上能兼顾流畅度和 CPU 占用但如果目标机器是低功耗嵌入式主板建议先跑 33ms 看效果再往下调。还有一条更精细的路是external_message_pump true加CefBrowserProcessHandler::OnScheduleMessagePumpWork(int64 delay_ms)由 CEF 反过来告诉你「多久之后需要泵一次」这样空闲时完全不占 CPU。缺点是回调发生在 CEF 内部线程需要用PostMessage转到主线程再泵多一层间接。项目对 CPU 占用敏感时可以换成这套。3.3 尺寸同步、DPI 与关闭时序父窗口大小变了浏览器子窗口不会自动跟。WM_SIZE里要做两件事通知 CEF 尺寸变了以及真正SetWindowPos。void CMyView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); if (cx 0 || cy 0) return; MyCef_Resize(cx, cy); // 内部browser-GetHost()-WasResized(); } // bridge.cpp 里的实现 MYCEF_API void MyCef_Resize(int w, int h) { if (!g_browser) return; HWND hwnd g_browser-GetHost()-GetWindowHandle(); if (hwnd) ::SetWindowPos(hwnd, NULL, 0, 0, w, h, SWP_NOZORDER | SWP_NOACTIVATE); g_browser-GetHost()-WasResized(); // 通知内核重新布局渲染表面 }SetWindowPos和WasResized两个都要调。只调前者子窗口尺寸对了但渲染表面还是旧的页面右侧会出现一条没刷新的黑边只调后者渲染表面重排了但窗口还是老尺寸内容会被裁掉。高 DPI 屏上还要在CefInitialize之前调CefEnableHighDPISupport()并且在 MFC 侧的 manifest 里声明dpiAware。两边不一致的话125% 缩放下浏览器子窗口会偏到右下角越拖越歪。关闭时序是最容易踩雷的地方正确顺序是// 1. 主窗口 OnClose 里先让浏览器自己关 MyCef_CloseBrowser(); // browser-GetHost()-CloseBrowser(true)参数 true 表示强制 // 2. 在 CefLifeSpanHandler::OnBeforeClose 里把 CefRefPtr 置空并停泵 void MyClient::OnBeforeClose(CefRefPtrCefBrowser browser) { ::KillTimer(g_mainHwnd, TIMER_CEF_PUMP); m_browser nullptr; // 最后一个引用在这里释放 } // 3. MFC 退出前调 CefShutdown() void CMyApp::CleanUp() { MyCef_Shutdown(); // 内部 CefShutdown() CWinApp::CleanUp(); }CloseBrowser(true)的true表示不走onbeforeunload确认直接关。如果页面里有未保存的表单需要拦截改传false并在 JS 里监听beforeunload但老项目里这么做往往导致窗口关不掉用户只能杀进程。4. JS 与 C 双向调用V8Handler 与 MessageRouter 的边界JS 调 C 有两种写法很多人第一反应是CefV8Handler因为它写起来最像「注册一个函数」那么简单。但它有个致命的进程边界问题搞不清楚就会出现「代码明明执行了界面纹丝不动」。4.1 CefV8Handler 能做什么、不能做什么CefV8Handler是挂在 V8 上下文里的原生函数。在CefRenderProcessHandler::OnContextCreated里往全局对象上挂一个函数名页面里就能window.nativeCall(...)直接调。void MyRenderApp::OnContextCreated(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefRefPtrCefV8Context context) { CefRefPtrCefV8Value global context-GetGlobal(); CefRefPtrCefV8Value fn CefV8Value::CreateFunction(nativeCall, new MyV8Handler()); global-SetValue(nativeCall, fn, V8_PROPERTY_ATTRIBUTE_NONE); } // V8 handler bool MyV8Handler::Execute(const CefString name, CefRefPtrCefV8Value object, const CefV8ValueList arguments, CefRefPtrCefV8Value retval, CefString exception) { if (name nativeCall arguments.size() 2) { std::string cmd arguments[0]-GetStringValue().ToString(); std::string payload arguments[1]-GetStringValue().ToString(); // 关键这段代码跑在 renderer 进程不能碰 MFC 的 HWND CefRefPtrCefProcessMessage msg CefProcessMessage::Create(native_call); msg-GetArgumentList()-SetString(0, cmd); msg-GetArgumentList()-SetString(1, payload); CefRefPtrCefBrowser browser CefV8Context::GetCurrentContext()-GetBrowser(); browser-GetMainFrame()-SendProcessMessage(PID_BROWSER, msg); return true; } exception bad arguments; return false; }问题就出在那行注释上。CEF 默认是多进程模型页面 JS 在 renderer 子进程里跑Execute回调也在 renderer 进程的 V8 线程执行。那一刻你手里只有CefRefPtrMFC 的窗口句柄、CWnd*、成员变量统统在另一个进程的地址空间里直接拿来用轻则无效重则崩溃。想在 UI 上生效就必须像上面那样SendProcessMessage把消息打到 browser 进程再在CefClient::OnProcessMessageReceived里收bool MyClient::OnProcessMessageReceived(CefRefPtrCefBrowser browser, CefProcessId source_process, CefRefPtrCefProcessMessage message) { if (message-GetName() native_call) { std::string cmd message-GetArgumentList()-GetString(0).ToString(); std::string payload message-GetArgumentList()-GetString(1).ToString(); // 这里在 browser 进程的 UI 线程可以安全地把结果 PostMessage 给 MFC 窗口 ::PostMessage(g_mainHwnd, WM_NATIVE_CALL, (WPARAM)new std::string(cmd), (LPARAM)new std::string(payload)); return true; } return false; }这条链路能跑通但缺了「返回值」——Execute里没法同步等到 browser 进程处理完再retval-SetString(...)。异步返回值要靠再发一条消息回 renderer 进程代码量翻倍。所以只要涉及「调完要拿结果」就换下一条路。注意OnProcessMessageReceived的签名在不同小版本之间有过增减早期版本没有frame参数改版本时先看cef_client.h里的声明别照抄网上的旧代码。4.2 CefMessageRouter 打通 JS 到 MFC 的完整链路CefMessageRouter是 CEF 自带的请求-响应通道既解决跨进程又自带回调和取消机制。它分两部分renderer 侧负责在页面里注入cefQuery函数browser 侧负责收请求并回调业务代码。browser 进程侧建 router// MyClient 构造时 CefMessageRouterConfig config; config.js_query_function cefQuery; // 页面里调用的函数名 config.js_cancel_function cefQueryCancel; // 取消函数名 m_router CefMessageRouterBrowserSide::Create(config); m_router-AddHandler(new MyQueryHandler(), true); // true 表示第一个匹配的 handler 生效然后在CefClient的几个回调里逐一转发少转发一个查询就可能永远不回调bool MyClient::OnProcessMessageReceived(CefRefPtrCefBrowser browser, CefProcessId source_process, CefRefPtrCefProcessMessage message) { return m_router-OnProcessMessageReceived(browser, message); } bool MyClient::OnBeforeBrowse(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefRefPtrCefRequest request, bool user_gesture, bool is_redirect) { m_router-OnBeforeBrowse(browser, frame); // 页面跳转时清掉挂起的查询 return false; } void MyClient::OnRenderProcessTerminated(CefRefPtrCefBrowser browser, TerminationStatus status) { m_router-OnRenderProcessTerminated(browser); // 渲染进程崩了要重置状态 }renderer 侧同样要转发一次bool MyRenderApp::OnProcessMessageReceived(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefProcessId source_process, CefRefPtrCefProcessMessage message) { return m_renderRouter-OnProcessMessageReceived(browser, frame, source_process, message); }业务 handler 才是真正干活的class MyQueryHandler : public CefMessageRouterBrowserSide::Handler { public: bool OnQuery(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, int64 query_id, const CefString request, bool persistent, CefRefPtrCallback callback) override { std::string req request.ToString(); // 建议前端统一传 JSON 字符串 std::string cmd, data; if (!ParseJson(req, cmd, data)) { callback-Failure(-1, bad json); return true; } std::string result; bool ok DispatchToMfc(cmd, data, result); // 这里可以直接调 MFC 成员 if (ok) callback-Success(result); else callback-Failure(-2, handler failed); return true; } };OnQuery跑在 browser 进程的 UI 线程上DispatchToMfc里可以直接访问全局的CMainFrame*、调UpdateData、往数据库写记录没有任何跨进程问题。返回值通过callback-Success(...)同步回给页面的onSuccess。persistent参数决定这是一次性查询还是长连接。传 true 时Callback不释放可以多次Success适合做「C 主动推送状态」传 false默认就是一问一答用完即焚。前端调用长这样注意request里尽量别塞裸字符串统一 JSONfunction callNative(cmd, data) { return new Promise(function (resolve, reject) { window.cefQuery({ request: JSON.stringify({ cmd: cmd, data: data }), onSuccess: function (response) { try { resolve(JSON.parse(response)); } catch (e) { resolve(response); } }, onFailure: function (code, msg) { reject(new Error(code : msg)); } }); }); } // 用法 callNative(readConfig, { key: baudRate }).then(function (r) { console.log(baudRate , r.value); });4.3 C 主动调 JSJSON 序列化与线程约束反方向简单一些CefFrame::ExecuteJavaScript就够但有两个约束必须遵守。第一只能在 browser 进程的 UI 线程调用。browser-GetMainFrame()在 renderer 进程里返回的是空壳调了不报错但什么也不会发生。第二参数必须序列化成 JSON 再嵌进脚本字符串直接拼字符串是血泪经验的重灾区void CallJs(const std::string eventName, const std::string jsonPayload) { if (!g_browser) return; CefRefPtrCefFrame frame g_browser-GetMainFrame(); if (!frame) return; // 转义单引号避免 JSON 里的 把脚本字符串提前截断 std::string safe jsonPayload; ReplaceAll(safe, \\, \\\\); ReplaceAll(safe, , \\); ReplaceAll(safe, \n, \\n); std::string js if(window.onNativeEvent){window.onNativeEvent( eventName , JSON.parse( safe ));}; frame-ExecuteJavaScript(js, frame-GetURL(), 0); }ExecuteJavaScript的第二个参数是脚本来源 URL用于 DevTools 里定位错误传frame-GetURL()就能在 Sources 面板里看到这段注入代码的出处第三个参数是起始行号一般填 0。中文参数最容易翻车。CefString在 Windows 内部是 UTF-16ExecuteJavaScript接受的CefString用 UTF-8 构造时要注意源字符串本身的编码。如果 MFC 那边拿到的是CString宽字符先WideCharToMultiByte(CP_UTF8, ...)再交出去中间任何一次用CP_ACP转换都会把中文变成问号。如果 C 侧是高频推送比如每秒几十帧的设备状态ExecuteJavaScript会累积大量待执行脚本页面直接卡死。常见做法是在 C 侧做节流攒 100ms 一次性推一个数组过去或者干脆走cefQuery的persistent模式反向给页面发。5. 避坑与排查这套组合最容易翻车的五个点VS2010 CEF 90 这个组合横跨两代编译器和十几年的框架演进出问题的表现往往和真实原因隔着好几层。下面五条是按我修过的实际现象排序的。5.1 启动阶段白屏、闪退、子进程缺失现象一主窗口出来了浏览器区域一片白日志里出现Failed to create a valid render process或The CEF renderer process is not running。原因基本锁定在子进程分流。CefExecuteProcess没有在InitInstance最开头调用或者调用了但返回值处理错了——子进程跑完CefExecuteProcess之后必须立刻返回不能再往下走 MFC 的初始化流程否则子进程会把自己当成主程序去加载主窗口、去读配置文件然后在某个空指针上崩掉。解决方式是把分流代码提到InitInstance的第一行并且用return FALSE而不是return exitCode。同时确认browser_subprocess_path指向的 exe 确实存在、路径里没有会被 shell 解释的字符。排查时打开settings.log_severity LOGSEVERITY_INFO并设log_file子进程启动的每一行参数都会写进日志。现象二程序启动几百毫秒后直接退出Windows 事件查看器里记录0xc0000135找不到 DLL。原因不在 libcef.dll而在它依赖的 VC 运行库。CEF 90 的 Windows 二进制是 VS2019 编的依赖vcruntime140.dll/msvcp140.dll。目标机器上只装了 VS2010 的运行库msvcr100.dlllibcef.dll 加载时就会失败。解决是打包时把vcruntime140.dll、msvcp140.dll、vcruntime140_1.dll一起放进 exe 目录或者给装机脚本加一步 VC 2015-2022 运行库安装。这一步在内网离线部署时特别容易被漏掉因为开发机上通常早装过了。5.2 运行与退出阶段查询卡死、关窗崩溃、进程残留现象三页面里点了按钮cefQuery的onSuccess和onFailure都不触发控制台没有任何报错页面就像什么也没发生。原因通常是有多个网络请求同时发起其中某个 handler 返回了false且没有调用callback。OnQuery的返回值语义很容易被误解返回true表示「这个 handler 认领了这次查询一定会调 callback」返回false表示「我不处理交给下一个 handler」。如果一个 handler 既没调 callback 又返回了true路由就会一直等着后续查询也全部排在它后面。排查手法是在OnQuery入口打一行日志、在每个分支打一行日志确认哪条路径没有落到Success或Failure上。另一个高发点是OnBeforeBrowse忘了转发给 router页面一旦发生跳转之前挂起的查询就变成了孤儿。现象四点了主窗口的关闭按钮界面消失了但任务管理器里进程还挂着再启动一个实例就报端口占用或缓存目录被锁。原因是CefShutdown没有等到所有浏览器真正关闭就被调用或者根本没调。CloseBrowser(true)只是发起关闭请求OnBeforeClose之后内核才会释放资源。如果 MFC 的OnClose里直接PostQuitMessage消息循环停了CEF 的关闭流程永远走不完进程自然退不掉。解决顺序是OnClose里调CloseBrowser(true)→ 在OnBeforeClose里停定时器、置空CefRefPtr→ 再让窗口销毁 → 在CWinApp::CleanUp里调CefShutdown()。中间可以用一个标志位拦住重复关闭防止用户连点。现象五程序退出时偶尔弹「内存不能为 written」崩溃栈落在libcef.dll里调试符号一塌糊涂。典型原因是中间层 DLL 的导出函数返回了 C 对象或者CefRefPtr跨越了 DLL 边界。两套运行库各自维护自己的堆一个进程里new出来的对象在另一个堆里delete堆管理器直接判定越界。检查方式是看mycef_bridge.h里有没有出现std::string、class、CefRefPtr这些类型。有就改成const char*intvoid*字符串在边界上按 UTF-8 拷贝一份。另一条隐蔽路径是CefRefPtr作为静态变量的初始化顺序问题——DLL 卸载时静态对象析构此时 libcef 可能已经 Shutdown引用计数操作会踩到已释放的内存。把全局CefRefPtr改成一个裸指针CefRefPtr*在OnAfterCreated里new、在OnBeforeClose里delete顺序可控得多。6. 交付前的自检把 JS 桥接做成可回归的验证用例代码跑通和能交付是两件事。这套组合涉及两个进程、两套运行库、三层调用边界靠手工点几下确认「看着能用」迟早出事。我一般会在工程里放一个selftest.html把桥接的每条路径都覆盖一遍装到目标机器上先跑这个页面。页面里做三件事。第一探测 CEF 版本和进程 ID确认内核真的起来了// selftest.html —— 桥接自检页 (async function () { const info await callNative(sys.info, {}); console.log(cef_version info.cef_version); // 形如 90.6.0gc... console.log(render_pid info.render_pid); // 与主进程 pid 不同即为多进程正常 document.getElementById(ver).textContent info.cef_version; })();第二覆盖四类边界数据空字符串、含单引号双引号的字符串、中文、超过 1KB 的长字符串。每一类都做一次往返比对前后是否一致。中文这一项要专门盯着因为所有乱码都出在CP_ACP和CP_UTF8混用上正常输入法打几个字就能暴露。第三验证异常路径调一个不存在的命令确认onFailure收到的是自定义错误码而不是超时连续发 100 个查询确认全部有回调且没有互相串号。最后一项能揪出 handler 返回值写错的隐患比看代码靠谱。参数上还有两个容易忽略的检查点。remote_debugging_port在发布版本里要关掉或者绑定到127.0.0.1否则同网段任何人都能连上 DevTools 执行任意脚本cache_path要指向可写的用户目录而不是 exe 目录装在Program Files下时写缓存会失败页面表现是本地存储全丢、登录态每次重开都掉。还有个小技巧值得加进自检在OnBeforeClose里主动KillTimer并且把 Do本文还有配套的精品资源点击获取