ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CHTMLDome2:将C代码编译为HTML的轻量方案与实战指南

CHTMLDome2:将C代码编译为HTML的轻量方案与实战指南

简介:本资源面向使用 MFC 进行桌面开发的程序员,聚焦 CHtmlView 类在对话框与 HTML、JavaScript 交互中的实战应用。内容围绕 WebBrowser 控件封装、页面加载导航、ExecWB 执行脚本、DocumentComplete 事件监听、IDocHostUIHandler 接口重写以及 ActiveX 暴露 C++ 函数等关键环节展开,帮助开发者打通 C++ 与前端脚本的双向调用链路。压缩包共 57 个文件,约 645KB,包含 6 个 cpp 与 8 个 h 源码文件、6 个 js 脚本、3 个 html 页面及配套 css、png 图片资源,另有 sln 解决方案与 vcxproj 工程配置,可直接在 Visual Studio 中打开编译运行。目前已有 447 人学习下载。通过完整示例工程,读者可掌握在 MFC 对话框中嵌入网页、用 JavaScript 调用 C++ 功能、拦截右键菜单及处理文件操作等实用技巧,为构建融合 Web 技术的桌面应用提供可复用的参考实现。

1. CHTMLDome2 到底是什么:一个把 C 代码编译成 HTML 的轻量方案

第一次看到 CHTMLDome2 这个名字,很多人会以为是某个前端模板库的二代版本,或者是一个 HTML 的 DOM 操作封装。实际上它走的是另一条路:把 C 语言写的逻辑,通过一套约定好的接口,编译成可以直接在浏览器里跑的 HTML 页面。换句话说,你写的是 C,浏览器里跑的是网页,中间那层胶水由 CHTMLDome2 帮你抹平。

这个方向解决的是一个很具体的痛点。嵌入式或者桌面端已经有一大坨用 C 写好的算法、状态机、协议解析逻辑,现在想给它配一个能看的界面,又不想把逻辑用 JavaScript 重写一遍。CHTMLDome2 的思路是让 C 函数直接对应到页面上的元素和事件,编译产物是一个自包含的 HTML 文件,双击就能打开。适合谁?适合手里有 C 代码、需要快速做演示界面、又不想引入 Node 工具链的开发者。下面从最小可跑的例子开始,把编译链路、参数、坑位一层层拆开。

2. 把 C 函数映射成页面元素:CHTMLDome2 的编译链路拆解

2.1 从 C 源码到 HTML 的三段式流程

CHTMLDome2 的编译链路可以拆成三段:解析、绑定、生成。第一段解析你的 C 文件,找出所有带特定前缀标记的函数;第二段把这些函数和一份声明式的界面描述做绑定,决定哪个函数对应哪个按钮、哪个变量对应哪块文本;第三段把绑定结果翻译成 HTML 加 JavaScript,最终输出一个单文件页面。

这个流程里最关键的是第二段。很多同类方案失败就失败在绑定层太薄,C 和页面之间只能靠字符串拼来拼去,改一个字段要动三处。CHTMLDome2 的做法是要求你在 C 里用一套固定的宏来声明界面元素,编译器读这些宏,而不是读你的业务逻辑。业务逻辑该怎么写还怎么写,界面声明单独抽出来。

常见做法是在项目根目录放一个ui.def文件,里面用类似ELEMENT(id, type, label)的语法描述页面结构。编译器先读ui.def,再扫 C 文件里的CHTML_EXPORT标记函数,两边做名字匹配。匹配不上的元素会在编译期报 warning,而不是等到运行时页面空白——这一点比很多“运行时才告诉你绑定失败”的方案要省心。

2.2 最小可跑示例:一个计数器页面

先看一个能跑起来的最小例子。假设你有一个counter.c,里面只有一个自增函数,想在页面上放一个按钮和一个显示数字的区域。

// counter.c #include "chtml_dome2.h" // 声明一个全局状态,编译器会把它映射到页面上的文本节点 CHTML_STATE(int, count, 0); // 声明一个按钮点击后触发的函数 CHTML_EXPORT void on_increment(void) { count += 1; // 修改状态 CHTML_UPDATE(count); // 通知编译器更新对应文本节点 } // 页面初始化时调用一次 CHTML_EXPORT void on_init(void) { CHTML_UPDATE(count); }

对应的ui.def这样写:

PAGE main TEXT id=count_label bind=count BUTTON id=btn_inc label="加一" onclick=on_increment END

编译命令通常长这样:

chtmlc --input counter.c --ui ui.def --output counter.html --mode release

跑完之后目录里会多出一个counter.html,用浏览器打开,点按钮数字就会加。这里有几个参数值得注意:--mode release会压缩生成的 JavaScript,--mode debug会保留变量名并输出一份绑定映射表,方便你在浏览器控制台里对账。--input可以传多次,用来把多个 C 文件一起编进来。

2.3 绑定规则里最容易搞混的三个点

第一个点是状态变量的作用域。CHTML_STATE声明的变量在生成的 JavaScript 里是模块级变量,不是全局window上的属性。这意味着你在浏览器控制台里直接敲count是拿不到的,得通过编译器生成的调试接口。这个设计是为了避免和页面里其他脚本冲突,但第一次用的人往往会在这里愣一下。

第二个点是CHTML_UPDATE的调用时机。它只负责把 C 侧的值同步到页面,不会触发重排以外的任何逻辑。如果你在on_increment里改了count但忘了调CHTML_UPDATE,页面上的数字不会动,而 C 侧的值已经变了。这种“逻辑对了但界面没反应”的情况,排查时优先看有没有漏掉更新调用。

第三个点是函数名匹配。ui.def里onclick=on_increment写的是 C 函数名去掉CHTML_EXPORT之后的部分,大小写敏感。如果 C 里写的是on_Increment,def 里写on_increment,编译期会报“未找到绑定目标”。建议统一用下划线小写命名,减少这类玄学问题。

3. 参数怎么设:编译选项、状态类型与事件绑定的实操细节

3.1 编译选项的取舍:debug 还是 release

--mode这个参数看起来简单,实际影响比想象中大。debug 模式下,编译器会在生成的 HTML 里注入一个__CHTML_DEBUG__对象,里面挂着所有状态变量的读写代理和绑定关系表。你可以在控制台里用__CHTML_DEBUG__.get('count')看当前值,也可以用__CHTML_DEBUG__.trace()打开更新日志,每次CHTML_UPDATE都会打印一行。

release 模式则把这些全部去掉,生成的 JavaScript 只保留必要的更新逻辑,体积通常能小三分之一左右。但 release 模式下如果页面行为不对,你几乎没有可观测手段,只能靠二分法注释代码。所以我的习惯是:开发期一律 debug,联调通过后再切 release 做最终验证。切换之后至少要把主要交互路径再点一遍,因为压缩过程偶尔会暴露一些依赖变量名的写法问题。

还有一个--inline-css选项,默认开启,会把样式直接写进 HTML 的<style>标签。如果你的页面要嵌入到已有系统里,可能需要关掉它,改成外链 CSS。关掉之后编译器会额外生成一个.css文件,部署时两个文件要一起带上,漏了就会变成裸 HTML。

3.2 状态类型支持到什么程度

CHTMLDome2 对状态类型的支持是有限度的。基本类型int、float、double没问题,字符串用CHTML_STATE_STR声明,底层映射成 JavaScript 的字符串。数组和结构体需要显式注册,编译器不会自动推导。

// 基本类型直接声明 CHTML_STATE(int, retry_count, 0); CHTML_STATE(float, progress, 0.0f); // 字符串用单独宏,第二个参数是缓冲区大小 CHTML_STATE_STR(status_text, 64, "idle"); // 数组需要指定长度和元素类型 CHTML_STATE_ARRAY(int, samples, 128);

数组的更新和单值不一样。改完数组元素后,不能只调CHTML_UPDATE(samples),那样只会同步整个数组的引用,页面上的列表不会重新渲染。正确做法是调CHTML_UPDATE_AT(samples, index),只更新指定下标对应的 DOM 节点。这个接口在文档里往往一笔带过,但实际做列表渲染时天天要用。

结构体目前需要你手动拆成多个状态变量,或者写一个自定义的序列化函数,用CHTML_STATE_STR存 JSON 字符串。后者灵活但失去了类型检查,改字段名的时候容易漏。我一般建议字段少于五个就拆开,多于五个再考虑 JSON 方案。

3.3 事件绑定的三种写法和适用场景

事件绑定在ui.def里有三种写法。第一种是直接写函数名,onclick=on_increment,适合无参数的回调。第二种是带固定参数的,onclick=on_select(3),编译器会生成一个闭包把参数传进去。第三种是绑定到状态变量,onchange=on_text_change(bind=status_text),输入框的值会作为字符串参数传给函数。

BUTTON id=btn_a label="选项A" onclick=on_select(1) BUTTON id=btn_b label="选项B" onclick=on_select(2) INPUT id=inp_name bind=status_text onchange=on_text_change

带参数的回调在生成时会多一层函数包装,如果同一个函数被绑到很多元素上,生成的代码量会线性增长。元素超过二十个的时候,建议改成在函数内部根据事件源判断,而不是每个元素写一个固定参数。判断事件源可以用编译器注入的CHTML_EVENT_SOURCE宏,它在回调里展开成触发元素的 id 字符串。

输入框的onchange默认在失焦时触发,不是每次按键都触发。如果你需要实时响应,得用oninput,但要注意高频输入下的性能。常见做法是加一个防抖,在 C 侧用一个时间戳变量记录上次处理时间,间隔小于 100 毫秒的直接返回。

4. 避坑与排查:CHTMLDome2 落地时最容易翻车的五个地方

4.1 页面打开一片空白,控制台也没有报错

现象:编译成功,HTML 文件也生成了,但浏览器打开就是白屏,F12 控制台干净得让人心里发毛。

原因:最常见的是ui.def里PAGE块的名字和 C 里on_init的注册没对上。编译器默认只自动调用名为on_init的初始化函数,如果你把初始化函数改名成init_page又没有在 def 里显式声明onload=init_page,页面就不会执行任何初始化逻辑,所有绑定元素都处于未赋值状态。

解决:在ui.def的PAGE行加上onload=你的初始化函数名,或者把初始化函数改回on_init。排查时可以先在 debug 模式下打开控制台,敲__CHTML_DEBUG__.listBindings(),如果返回空数组,基本就是这个问题。

4.2 按钮点一次触发两次

现象:明明只点了一下,计数器加了二,日志也打印了两行。

原因:ui.def里同一个元素被绑定了两次事件,或者 C 文件被--input传了两次导致函数被注册两遍。前者常见于复制粘贴 def 块时忘了改 id,后者常见于构建脚本里用通配符把备份文件也扫进去了。

解决:先看 debug 模式下的绑定表,同一个元素 id 出现两次就是 def 重复。如果是 C 文件重复,检查编译命令里的--input列表,把*.c换成明确列出的文件名。另外注意有些编辑器会生成counter.c~这样的备份文件,通配符会把它也吃进去。

4.3 中文显示成乱码

现象:页面上的中文标签变成问号或者方块。

原因:生成的 HTML 默认用 UTF-8,但如果你的 C 源文件或者ui.def文件本身是 GBK 编码,编译器读进去的时候就已经错了。另一种情况是--charset参数没设,编译器按系统默认编码处理。

解决:统一把源文件和 def 文件存成 UTF-8 无 BOM 格式,编译时显式加--charset utf-8。如果标签是在 C 代码里用字符串拼出来的,还要确认字符串字面量本身的编码。排查时可以用file -i yourfile.c看一眼实际编码,别信编辑器状态栏。

4.4 状态更新了但页面延迟一拍

现象:在 C 函数里改了值也调了CHTML_UPDATE,但页面上要等下一次交互才刷新。

原因:CHTML_UPDATE默认是异步批量提交的,编译器会把多次更新合并到一次 DOM 操作里,用requestAnimationFrame的时机执行。如果你在更新之后立刻去读 DOM,拿到的还是旧值。

解决:如果确实需要同步刷新,用CHTML_UPDATE_SYNC宏,它会立即写 DOM。但不要在所有地方都换成同步,批量合并是有意为之的性能优化。判断标准是:更新之后如果还要读页面值做逻辑判断,就用同步;只是展示,就用默认异步。

4.5 编译通过但运行时报未定义函数

现象:浏览器控制台报on_increment is not defined,但 C 文件里明明写了。

原因:CHTML_EXPORT标记的函数如果带了参数,而ui.def里的绑定没有提供对应参数,编译器生成的调用代码会缺少实参,导致函数签名不匹配。另一种情况是函数被static修饰了,编译器不会导出静态函数。

解决:检查所有CHTML_EXPORT函数的参数列表,确保 def 里的绑定提供了正确数量和类型的参数。静态函数要么去掉static,要么在 def 里用bind_static=1显式声明——但这个选项在部分版本里行为不一致,稳妥做法还是不要用 static。

5. 进阶技巧:用 CHTMLDome2 做多页面切换与状态持久化

多页面切换在 CHTMLDome2 里不是靠路由库,而是靠PAGE块的显隐控制。你可以在一个ui.def里定义多个PAGE,每个页面有自己的元素集合,编译器会生成对应的显示隐藏逻辑。切换页面用CHTML_SWITCH_PAGE(page_name)宏,它会把当前页面隐藏、目标页面显示,同时触发目标页面的onload回调。

// 在按钮回调里切换页面 CHTML_EXPORT void on_goto_settings(void) { CHTML_SWITCH_PAGE(settings); } CHTML_EXPORT void on_goto_main(void) { CHTML_SWITCH_PAGE(main); }

对应的 def 里两个页面各自独立:

PAGE main BUTTON id=btn_settings label="设置" onclick=on_goto_settings END PAGE settings BUTTON id=btn_back label="返回" onclick=on_goto_main TEXT id=ver_label bind=version_text END

这里有个细节:页面切换时,非活动页面的 DOM 并没有被销毁,只是display: none。这意味着隐藏页面里的输入框内容会保留,切回来还在。如果你希望每次进入页面都重置,得在onload回调里手动把状态变量重置并调CHTML_UPDATE。

状态持久化方面,CHTMLDome2 没有内置的本地存储接口,但你可以用CHTML_STATE_STR存一个 JSON 字符串,然后在on_init里从localStorage读回来。编译器生成的代码里暴露了一个CHTML_STORAGE对象,封装了读写和序列化,但文档里很少提。用法是在 C 里调CHTML_STORAGE_SAVE(key, state_var)和CHTML_STORAGE_LOAD(key, state_var),底层会走localStorage,键名会自动加前缀避免冲突。

CHTML_EXPORT void on_init(void) { // 尝试从本地存储恢复上次的计数 if (!CHTML_STORAGE_LOAD("count", count)) { count = 0; // 没有存档就归零 } CHTML_UPDATE(count); } CHTML_EXPORT void on_increment(void) { count += 1; CHTML_UPDATE(count); CHTML_STORAGE_SAVE("count", count); // 每次改动都存档 }

存档的时机要注意,不要每次更新都写localStorage,它在某些浏览器里是同步 IO,高频写入会卡。常见做法是加一个脏标记,在页面onunload或者切换页面时统一写一次。但onunload在移动端不可靠,所以更稳的是定时写,比如每五秒检查一次脏标记。

最后说一个我踩过的坑:CHTML_STORAGE_LOAD的返回值是布尔,表示是否成功读到并解析。如果存档的 JSON 格式因为版本升级变了,解析会失败返回 false,但不会报错。这时候状态变量保持原值,看起来像是“存档丢了”。所以每次改状态结构,记得在键名里带一个版本号,比如count_v2,旧存档读不到就自然走默认值,比在解析失败里做兼容要省事得多。

这套方案值不值得投入,取决于你手里 C 代码的体量。如果只是几十行逻辑,用 JavaScript 重写可能更快;但如果是几千行的状态机和协议栈,CHTMLDome2 这种“C 写逻辑、编译出页面”的路子能省掉大量重复劳动。编译链路本身不复杂,坑主要集中在绑定匹配和状态同步上,把 debug 模式用熟,大部分问题都能在控制台里定位。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进