简介:面向MFC桌面应用开发者的CHtmlView与JavaScript交互示例项目,适合需要在对话框或窗口中嵌入HTML页面、并让C++与JavaScript互相调用的开发场景。压缩包共57个文件,约645KB,包含完整的Visual Studio解决方案、C++头文件与实现源码、6个JS脚本、3个HTML页面(登录、注册、数据展示页),以及CSS、图标和图片素材,从资源清单即可看出页面设计与后台逻辑是成对组织的。已有447人浏览学习。项目覆盖了通过CHtmlView加载本地HTML、使用ExecWB执行JS、处理DocumentComplete事件以及实现IDocHostUIHandler接口来接管右键菜单等关键机制,同时提供了登录注册表单、状态图表、图片切换等可直接运行的示例。对想用Web技术增强MFC界面而缺乏现成参考的开发者,这套代码给出了从工程搭建到回调联调的完整路径,还保留了备份文件便于对比修改过程中的差异。 很多前端开发者第一次看到CHTMLDome2这个项目名,第一反应多半是:这又是一个什么新框架?还是哪个 3D 可视化库?其实都不是。它是我在自己的业务项目里沉淀出来的一套“自定义 HTML 的 DOM 增强方案”,版本号 2,代表这是完全重写了第一版之后的产物。简单来说,它解决的痛点是:当你的项目既不想引入一个完整重框架、又受够了用 querySelector 满世界找节点、拼模板字符串改 innerHTML 时,用最轻量的方式让 HTML 自己具备数据绑定和局部刷新能力。
这个项目不涉及任何构建工具依赖,也不要求你学习一门新语言。模板就是普通 HTML 标签,只需要在标签上写一个><div id="app"> <h1>function setData(key, value) { // 支持 'user.name' 这样的路径 const keys = key.split('.'); let target = data; for (let i = 0; i < keys.length - 1; i++) { if (target[keys[i]] == null) return; target = target[keys[i]]; } target[keys[keys.length - 1]] = value; // 遍历绑定表执行更新 bindingTable.forEach((binding) => { if (binding.key === key || binding.key.startsWith(key + '.')) { updateNode(binding); } }); }
注意这段代码里的startsWith(key + '.'),它的作用是实现了“父级更新,子级联动”。比如数据对象profile下的nickname、avatar都绑定了 DOM,你只改了profile.nickname,那profile.avatar对应的 DOM 是不会被动到的。只有把整个profile对象替换掉时,下面的子级绑定才会全部刷新。这个粒度的控制在实际开发里足够精确,又避免了大框架里 diff 带来的额外开销。
2.3 事件委托与作用域隔离
事件绑定我用的是“全局委托”的方式。CHTMLDome2 实例化时,会在根容器上挂一个统一的 click / input / change 监听器。用户点击任意绑定过事件的元素,事件会冒泡到根容器,然后由委托器根据元素上的>function parseTemplate(root, bindings, events) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); let node; while ((node = walker.nextNode())) { const bindExpr = node.getAttribute('data-bind'); if (bindExpr) { bindings.push({ node: node, key: bindExpr, expr: () => getValueByPath(data, bindExpr), }); } const onExpr = node.getAttribute('data-on'); if (onExpr) { const [eventName, methodName] = onExpr.split(':').map((s) => s.trim()); events.push({ node, eventName, methodName }); } } }
这里我选择用TreeWalker而不是querySelectorAll('[data-bind], [data-on]'),原因是 TreeWalker 的性能更好,而且在遍历过程中如果你需要临时跳过某棵子树,可以直接控制遍历游标,灵活性高很多。整个模板解析只需要在初始化时执行一次,所以这个性能优势在首屏加载时尤其有意义。
第一次实现时,我犯过一个错误:把>let updateQueue = []; let pending = false; function queueUpdate(binding) { updateQueue.push(binding); if (!pending) { pending = true; requestAnimationFrame(flushUpdates); } } function flushUpdates() { updateQueue.forEach((binding) => updateNode(binding)); updateQueue = []; pending = false; }
这段代码的思路类似于“合并请求”:无论一帧之内你的数据被更新了多少次,最终只在下一帧渲染之前统一执行一次 DOM 写入。requestAnimationFrame是浏览器专门为动画和渲染准备的 API,用它来处理 DOM 更新,节奏跟浏览器的绘制完全对齐,既不会因为同步执行太多次 DOM 操作而卡顿,也不会像setTimeout那样出现掉帧的感知。
提示:在这个实现里,
updateQueue存的是绑定记录而不是数据快照。这样即使同一帧内多次修改同一个绑定对应的数据,最终也只会按最后一次赋值的结果刷新 DOM,天然避免中间状态的反复渲染。
3.3 列表渲染与局部刷新
列表是后台系统绕不开的场景。CHTMLDome2 的>function renderList(container, listKey, templateNode) { container.innerHTML = ''; const list = getValueByPath(data, listKey) || []; list.forEach((item, index) => { const clone = templateNode.cloneNode(true); // 在克隆节点上做一次局部绑定 scopeBind(clone, item, index); container.appendChild(clone); }); }
这个方案的好处是逻辑非常直白,缺点是无法做复用和差值更新——数据一变,整个列表重建。这也是我在前面的“能力边界”里提到的取舍点。如果列表的单个条目很重、内部有复杂嵌套交互,这种重建方式可能不合适;但对于大部分表格、菜单、卡片列表来说,重建成本完全在可接受范围内。实测在 100 条以内的列表,重建耗时约在几毫秒到十几毫秒,不会有体感卡顿。
为了弥补“全量重建”的不足,我在清理容器前加了保护逻辑:如果列表条目标记了>[CHTMLDome2] 绑定表: #app > h1 => key: "page.title" #app > p => key: "user.name" #app > input => key: "form.keyword"
对照一下控制台里的setData('pageTitle', 'xxx'),明显跟绑定表里的page.title对不上,自然无法更新。这类问题我至少有三次是因为手滑把 key 和对象的属性名写混了。
4.3 实例分析:重复绑定导致方法执行两次
有段时间用户反馈一个按钮点击后,接口会被调用两次。排查下来问题出在“实例重复初始化”上。因为页面是后端模板渲染的,有些脚本在特定场景下会被重复加载,CHTMLDome2 在同一个根节点上执行了两次 mount,导致事件委托器里注册了两份相同的处理方法。
解决办法是在实例化时先检测根节点上是否已经有__chtml_instance__标记,如果存在,直接复用旧实例而不是重新初始化。我的做法是给根节点添加一个不可枚举的自定义属性,初始化前先检查:
if (root.__chtmlInstance) { return root.__chtmlInstance; } const instance = createInstance(root); Object.defineProperty(root, '__chtmlInstance', { value: instance, enumerable: false, }); return instance;这个防御性的写法看似只是多了一行判断,却避免了许多因为脚本加载时机不确定而导致的重复挂载问题,尤其是多页面项目里公共脚本被到处引用时特别好用。
4.4 列表重建导致滚动位置丢失的修复
这是我在一个工单列表页面里踩到的问题。筛选条件变化时,列表刷新本身没问题,但刷新后自动跳回顶部,用户每次都要重新滚动到刚才查看的位置,体验很差。用过各种框架的同学都知道,列表刷新丢滚动位置是个经典难点。CHTMLDome2 里我没有做文档碎片复用那种高级操作,而是用一个临时变量在渲染前后记录和恢复滚动高度:
const scroller = container.closest('.scrollable-area') || container.parentElement; const scrollTop = scroller.scrollTop; renderList(...); requestAnimationFrame(() => { scroller.scrollTop = scrollTop; });这个方案在列表高度变化不大时非常有效。如果列表首部的内容在刷新后变多了,滚动位置会有一点点偏移,不过相比之下,用户忍受不了的是“完全丢失位置”。先用一个简单的恢复逻辑满足 80% 的场景,比为了完美差值更新而引入复杂的列表复用机制更划算。这就是自研方案里经常要面对的“够用就好”和“尽善尽美”的取舍。
4.5 排查工具:多用 console.table 观察绑定表
这里分享一个很实用的习惯:在 CHTMLDome2 内部,我专门留了一个调试方法__debug(),调用后会打印当前实例的完整状态。除了上面提到的基础绑定表,它还会把事件委托的注册表、数据对象的当前快照一起用console.table以表格形式输出。面对一次 DOM 不同步的 bug,我往往只需要看这个表格,就能在十秒内判断问题出在哪一层:
- 绑定表维度的 key 是否对齐;
- 事件表里是否出现了重复条目;
- 数据快照是否符合预期。
建议你在自己改造的时候也保留这样一个调试入口。它不参与生产逻辑,但能在关键时刻省下大量“打断点 → 刷新页面 → 查看变量”的时间。
5. 写在最后的个人体会
CHTMLDome2 这个项目从第一版到第二版,前后经历了大半年。第一版解决了“从无到有”的问题,第二版解决了“从有到稳”的问题。它最大的意义不在于代码写得多么优雅,而在于让我真正理解了“数据驱动视图”这层抽象的本质:页面状态的每一处变化,要么有迹可循地绑定到数据,要么就是潜在的 bug 源头,两者之间没有缓冲地带。
如果你也想在自己项目里试用类似方案,我建议从最简单的用法开始:先只做style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />