三棵树的对比:DOM 树、QObject 对象树与 QML Item 树
这三棵树经常被拿来类比,因为它们表面上很像:都是层级容器结构,都能按标识查找节点,都有事件沿树传播。但真正写代码时,差异往往出在「这棵树到底负责什么」上——DOM 树管文档结构,QObject 树管内存所有权,QML 的 Item 树则把所有权和视觉层级拆成了两棵。搞混这一点,会写出对象被提前回收、控件不显示、事件收不到之类的问题。
一、三棵树的来源
树是怎么被建起来的,决定了它的性格。
DOM 树由解析器构建。你写的是标记文本,浏览器解析成树,运行时才通过 JS API 修改它。树的形状最初来自文档,天然是声明式的。
<div id="app" class="root"> <button class="btn">提交</button> </div>
QObject 树靠手写代码构建,父子关系在构造时通过 parent 参数或 setParent() 建立:
auto *panel = new QWidget(this); // this 成为 panel 的父对象
auto *button = new QPushButton("提交", panel);
button->setObjectName("btnSubmit");
QML 的 Item 树由引擎在实例化 QML 文档时构建,形式上声明式,底下仍然是 QObject:
Rectangle {
id: root
objectName: "root"
Button {
objectName: "btnSubmit"
text: "提交"
}
}
看起来 QML 最像 DOM。但接着往下就分岔了。
二、职责对照:这是核心差异
| 职责 | DOM 树 | QObject / QWidget 树 | QML Item 树 |
|---|---|---|---|
| 文档结构 | 是(主要职责) | 否 | 否 |
| 内存所有权 | 否(GC 管) | 是(主要职责) | 是(QObject 层) |
| 视觉包含关系 | 是 | 是(与所有权重合) | 独立的第二棵树 |
| 坐标系嵌套 | 是 | 是 | 由视觉父级决定 |
| 布局 | CSS 决定 | QLayout 另一套结构 | anchors / Layouts |
| 样式级联 | CSS,完整级联 | QSS,部分继承 | 无级联 |
| 事件传播 | 捕获 + 冒泡 | 过滤器 + 向父冒泡 | 仅向上传播,无捕获阶段 |
| 线程约束 | 单线程 | 有线程亲和性 | GUI 线程 + 渲染线程 |
| 渲染用的树 | Render Tree(另一棵) | 就是 widget 树本身 | Scene Graph(另一棵) |
表里最值得注意的三行:QObject 树的主要职责是内存所有权而非结构描述;QML 有两棵重叠的树;QML 没有样式级联。下面逐个展开。
三、所有权与生命周期
这是三者差别最大的地方,也是实际最容易出 bug 的地方。
DOM 没有所有权概念。removeChild 摘下来的节点仍然活着,只要 JS 里还有引用就能重新插回去;没引用了才被 GC(Garbage Collection,垃圾回收)回收。
const el = document.getElementById('app');
const detached = el.removeChild(el.firstChild); // 摘下来但没销毁
document.body.appendChild(detached); // 可以再插回去
QObject 树是强所有权。父对象析构时会 delete 全部子对象,这是 Qt 不需要智能指针也能管住 UI 对象的原因:
{
QWidget window;
new QPushButton("A", &window); // 故意不保存指针
new QLabel("B", &window); // window 析构时两个都会被删
} // 无泄漏
由此派生出几条硬规则,面试和实战都高频:
- 栈对象不要挂到有父的树里——父对象析构时会 delete 一个栈地址,直接崩
setParent(nullptr)会把对象变成孤儿,此后必须自己负责删除,而且 QWidget 变成顶层窗口moveToThread要求对象无父,且会连带整个子树一起搬- 子对象必须和父对象同线程——跨线程
setParent会报警告并失败
QML 走的是 QObject 那一套所有权,但多了个 JS 引擎的 GC 参与。声明式写出来的对象由引擎设好 QObject parent,生命周期跟着父级;而 createObject() 动态创建的对象,如果没有 parent,会被 JS 的 GC 回收:
var comp = Qt.createComponent("Popup.qml");
// 危险:没有父对象,随时可能被 GC 掉
var orphan = comp.createObject(null);
// 正确:第一个参数就是 parent,同时确定所有权和视觉父级
var child = comp.createObject(root, { "x": 10, "y": 20 });
// 手动销毁
child.destroy();
这个坑很典型:动态创建的弹窗偶发消失,原因就是传了 null 当 parent。
四、QML 的双父级模型
这是 QML 独有的设计,也是它和另外两棵树最本质的区别:一个 Item 同时有 QObject parent 和视觉 parent,两者可以不同。
// C++ 侧的两个不同 API QObject *owner = item->parent(); // QObject 树:决定生命周期 QQuickItem *vis = item->parentItem(); // Item 树:决定坐标、层叠、裁剪
两者各管什么:
| QObject parent | 视觉 parent(parentItem) | |
|---|---|---|
| 决定生命周期 | 是 | 否 |
| 决定坐标系原点 | 否 | 是 |
| 决定 z 序层叠 | 否 | 是 |
| 裁剪(clip) | 否 | 是 |
| 继承 opacity / visible | 否 | 是 |
| QML 里怎么改 | 引擎设定,通常不动 | parent: someItem |
在 QML 里给 Item 赋 parent 属性,改的是视觉父级(底层调 setParentItem),所有权不变:
Item {
id: container
Rectangle {
id: floating
// 视觉上挂到 overlay 下面(坐标、层叠都跟 overlay 走)
// 但生命周期仍归 container
parent: overlay
width: 100; height: 100
}
}
实际用途:拖拽时把被拖的项临时提到顶层 overlay,避免被父级裁剪;拖完再放回去。Widgets 里做同样的事得真的 reparent,会连带所有权一起变。
另外,非可视 QML 类型只存在于 QObject 树里——Timer、QtObject、Connections 这些没有视觉父级,也不参与 Item 树。
五、节点标识与查找
三者的查找 API 高度对应,但标识机制不一样。
| 能力 | DOM | QObject / QWidget | QML |
|---|---|---|---|
| 唯一标识 | id 属性 | objectName | id(编译期)+ objectName |
| 按标识查 | getElementById | findChild<T*>(name) | 直接用 id 引用 |
| 批量条件查 | querySelectorAll | findChildren<T*>() | 无内建,靠 children 遍历 |
| 取父 | parentNode | parent() | parent / parentItem() |
| 取子 | childNodes / children | children() | children / data |
| 类型过滤 | 标签选择器 | 模板参数 <T*> | JS instanceof 或属性判断 |
关键差别在 QML 的 id:它是编译期的作用域引用,不是运行时属性。同一个 QML 文档里可以直接用,但 C++ 侧拿不到,跨文档也拿不到。
Rectangle {
id: root
objectName: "root" // 想被 C++ 找到,必须额外设 objectName
Text { id: label; text: "hi" }
Component.onCompleted: {
label.text = "changed" // 同文档内:直接用 id
}
}
// C++ 侧:只能靠 objectName,id 是查不到的
auto *root = engine.rootObjects().first();
auto *item = root->findChild<QQuickItem *>("root");
这一点在 Squish 测 QML 应用时尤其明显:脚本里写的是 objectName,所以要求开发给关键控件都设上,否则只能靠属性组合和位置去猜。
Widgets 侧的 findChild 默认递归查找整棵子树,想只查直接子级要传 Qt::FindDirectChildrenOnly:
auto *btn = findChild<QPushButton *>("btnOk"); // 递归
auto *b2 = findChild<QPushButton *>("btnOk", Qt::FindDirectChildrenOnly);
auto all = findChildren<QLabel *>(); // 全部 QLabel
六、事件传播
三者都有「事件沿树走」的机制,但阶段和拦截方式不同。
DOM 是完整的三阶段:捕获(从 window 往下)→ 目标 → 冒泡(往上回到 window)。
// 第三参数 true 表示在捕获阶段监听
document.body.addEventListener('click', onCapture, true);
button.addEventListener('click', e => {
e.stopPropagation(); // 阻止继续冒泡
e.preventDefault(); // 阻止默认行为,两者独立
});
Qt Widgets 的路径是:QApplication::notify → 全局事件过滤器 → 目标对象自身的 eventFilter → QObject::event() → 具体 handler。未被接受的事件继续向父 widget 传递。
bool MyFilter::eventFilter(QObject *watched, QEvent *e) {
if (e->type() == QEvent::MouseButtonPress)
return true; // 吞掉,不再往下走
return QObject::eventFilter(watched, e);
}
void MyWidget::mousePressEvent(QMouseEvent *e) {
if (!interesting(e))
e->ignore(); // 交给父 widget 继续处理
}
注意事件过滤器安装在对象上,走的是 QObject 树,和视觉层级重合。这也是 Widgets 里两棵树合一带来的便利。
QML 没有捕获阶段。指针事件送给命中的最上层 Item,靠 accepted 决定是否继续往下(往视觉父级)传:
MouseArea {
anchors.fill: parent
propagateComposedEvents: true // 允许 clicked 等合成事件继续传播
onPressed: (mouse) => {
mouse.accepted = false // 放行,让下层 MouseArea 也能收到
}
}
// 键盘事件走焦点链
Item {
focus: true
Keys.onEscapePressed: (event) => { close(); event.accepted = true }
Keys.forwardTo: [otherItem] // 显式转发
}
Qt5.10 起推荐用 Pointer Handler(指针处理器,如 TapHandler、DragHandler)替代嵌套 MouseArea,它们的传播规则更清晰,也避免了 MouseArea 层层叠加导致的事件黑洞。
七、布局与样式
布局三者的思路完全不同:
- DOM——布局是 CSS 的职责,树本身不描述布局。同一棵 DOM 配不同 CSS 能排成完全不同的样子
- Widgets——
QLayout是另一套结构,独立于 widget 树。把 widget 加进 layout 会隐式 reparent 到 layout 所属的 widget,这是新手常见困惑点 - QML——三种方式并存:
anchors(锚布局,相对定位,只能锚到父级或兄弟)、Row/Column(Positioner,定位器)、RowLayout/GridLayout(Qt Quick Layouts 模块,支持拉伸和尺寸约束)
// Widgets: layout 会把 button 的父对象改成 panel
auto *panel = new QWidget;
auto *layout = new QVBoxLayout(panel);
auto *button = new QPushButton("A"); // 此时无父
layout->addWidget(button); // 隐式 setParent(panel)
样式的差异更大,这是 QML 最反直觉的一点:
- CSS——完整级联和继承。
color、font这类属性自动向下继承,选择器有优先级计算 - QSS——CSS 的子集,支持选择器和部分继承,靠
QStyleSheetStyle实现。设了 QSS 后自绘控件会失效,因为绘制路径被换掉了 - QML——完全没有级联。父级设
color不会影响子级,每个属性都得显式设或用绑定。想复用样式只能靠自定义 Component、单例属性集,或 Qt Quick Controls 的 Style 机制
// QML 里模拟「继承」只能靠显式绑定
Item {
id: theme
property color fg: "#333"
Text { color: theme.fg } // 必须自己接上,不会自动继承
Text { color: theme.fg }
}
QML 放弃级联是有意为之:级联的代价是运行时要解析选择器、算优先级,而 QML 的属性绑定要保证能编译成高效的依赖图。用显式绑定换性能和可预测性。
八、渲染:都不止一棵树
有个容易被忽略的对称性:DOM 和 QML 都把「结构树」和「渲染树」分开了,只有 Widgets 是合一的。
Web: DOM 树 + CSSOM ──→ Render Tree ──→ Layout → Paint Widgets: widget 树 ──→ (就用它自己) ──→ paintEvent QML: Item 树 ──→ Scene Graph ──→ GPU 批渲染
差异带来的实际影响:
- Render Tree 会剔除节点。
display:none的元素在 DOM 里但不在 Render Tree 里,所以不占位;visibility:hidden两棵树都在,占位但不可见 - Scene Graph(场景图)在渲染线程构建。QML 的 Item 树住在 GUI 线程,场景图节点在渲染线程遍历。这是 QML 动画能不被 JS 卡顿影响的原因,也是
updatePaintNode()必须线程安全的原因 - Widgets 没有中间层。
update()直接触发paintEvent,简单直接,但也没法把绘制挪出 GUI 线程
九、一张速查表
| 场景 | DOM | Widgets | QML |
|---|---|---|---|
| 让节点消失但占位 | visibility:hidden | setVisible(false) 不占位,需换 layout | opacity: 0 |
| 移动节点到别处 | appendChild(自动摘除) | setParent(),所有权跟着变 | parent: 只改视觉父级 |
| 销毁节点 | 摘除 + 丢引用,等 GC | delete 或 deleteLater() | destroy() |
| 延迟销毁 | 无需 | deleteLater()(需事件循环) | destroy(delayMs) |
| 阻止事件继续 | stopPropagation() | handler 里 accept() 或过滤器返回 true | mouse.accepted = true |
| 跨线程访问 | 不适用 | 禁止,信号回主线程 | 禁止,用信号或 invokeMethod |
十、三条容易踩的坑
把 QObject 树当纯结构树用。它首先是所有权树。给对象设父只是为了「逻辑上归属」,结果父对象一析构子对象就没了——这在需要独立生命周期的对象上会出问题。这种情况应该不设父,改用智能指针管理。
在 QML 里以为 parent 改的是所有权。改的是视觉父级。要让动态对象的生命周期跟着某个对象走,得在 createObject(owner) 时就传对,事后赋 parent 不改所有权。
指望 QML 有 CSS 那样的级联。没有。字体、颜色这类想统一,要么做一个单例 Theme.qml 让所有组件绑定它,要么用 Qt Quick Controls 的 Style 体系,别指望往顶层 Item 设个属性就全局生效。
术语表
| 简称 | 英文全称 | 中文与说明 |
|---|---|---|
| DOM | Document Object Model | 文档对象模型 |
| QML | Qt Modeling Language | Qt 建模语言。声明式 UI 语言 |
| — | parent-child relationship | 父子关系。QObject 树的核心,决定内存所有权 |
| — | visual parent | 视觉父级。QML 中由 parentItem() 决定,管坐标、层叠、裁剪 |
| — | ownership | 所有权。QObject parent 决定,父析构则子被 delete |
| — | Scene Graph | 场景图。QML 的渲染树,在渲染线程构建与遍历 |
| — | Render Tree | 渲染树。Web 端 DOM + CSSOM 合成的结果 |
| CSSOM | CSS Object Model | CSS 对象模型 |
| — | Positioner | 定位器。QML 的 Row / Column / Grid 系列 |
| — | anchors | 锚布局。QML 的相对定位机制,只能锚到父级或兄弟 |
| — | Pointer Handler | 指针处理器。Qt5.10+ 推荐替代 MouseArea 的输入处理方式 |
| — | event capturing / bubbling | 事件捕获 / 冒泡。DOM 独有的三阶段传播,QML 无捕获阶段 |
| — | cascade | 级联。CSS 的样式继承与优先级机制,QML 完全没有 |
| — | thread affinity | 线程亲和性。QObject 归属创建它的线程 |
| GC | Garbage Collection | 垃圾回收。DOM 与 QML 动态对象的回收机制 |
| GUI | Graphical User Interface | 图形用户界面。Qt 中特指主线程 |
| GPU | Graphics Processing Unit | 图形处理器 |
| CSS / QSS | Cascading Style Sheets / Qt Style Sheets | 层叠样式表 / Qt 样式表 |
| API | Application Programming Interface | 应用程序编程接口 |
小结
一句话区分:DOM 树描述文档,QObject 树描述所有权,QML 把所有权和视觉层级拆成两棵。
类比在查找 API 和事件冒泡这两层是成立的,可以放心迁移经验。但在生命周期和样式两层完全不成立——DOM 的 GC 思维带到 Qt 会导致 double free,CSS 的级联思维带到 QML 会发现属性根本不继承。这两处是真正需要重新学的部分。

浙公网安备 33010602011771号