三棵树的对比: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 高度对应,但标识机制不一样。

能力DOMQObject / QWidgetQML
唯一标识id 属性objectNameid(编译期)+ objectName
按标识查getElementByIdfindChild<T*>(name)直接用 id 引用
批量条件查querySelectorAllfindChildren<T*>()无内建,靠 children 遍历
取父parentNodeparent()parent / parentItem()
取子childNodes / childrenchildren()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 线程

九、一张速查表

场景DOMWidgetsQML
让节点消失但占位visibility:hiddensetVisible(false) 不占位,需换 layoutopacity: 0
移动节点到别处appendChild(自动摘除)setParent(),所有权跟着变parent: 只改视觉父级
销毁节点摘除 + 丢引用,等 GCdelete 或 deleteLater()destroy()
延迟销毁无需deleteLater()(需事件循环)destroy(delayMs)
阻止事件继续stopPropagation()handler 里 accept() 或过滤器返回 truemouse.accepted = true
跨线程访问不适用禁止,信号回主线程禁止,用信号或 invokeMethod

十、三条容易踩的坑

把 QObject 树当纯结构树用。它首先是所有权树。给对象设父只是为了「逻辑上归属」,结果父对象一析构子对象就没了——这在需要独立生命周期的对象上会出问题。这种情况应该不设父,改用智能指针管理。

在 QML 里以为 parent 改的是所有权。改的是视觉父级。要让动态对象的生命周期跟着某个对象走,得在 createObject(owner) 时就传对,事后赋 parent 不改所有权。

指望 QML 有 CSS 那样的级联。没有。字体、颜色这类想统一,要么做一个单例 Theme.qml 让所有组件绑定它,要么用 Qt Quick Controls 的 Style 体系,别指望往顶层 Item 设个属性就全局生效。

术语表

简称英文全称中文与说明
DOMDocument Object Model文档对象模型
QMLQt Modeling LanguageQt 建模语言。声明式 UI 语言
—parent-child relationship父子关系。QObject 树的核心,决定内存所有权
—visual parent视觉父级。QML 中由 parentItem() 决定,管坐标、层叠、裁剪
—ownership所有权。QObject parent 决定,父析构则子被 delete
—Scene Graph场景图。QML 的渲染树,在渲染线程构建与遍历
—Render Tree渲染树。Web 端 DOM + CSSOM 合成的结果
CSSOMCSS Object ModelCSS 对象模型
—Positioner定位器。QML 的 Row / Column / Grid 系列
—anchors锚布局。QML 的相对定位机制,只能锚到父级或兄弟
—Pointer Handler指针处理器。Qt5.10+ 推荐替代 MouseArea 的输入处理方式
—event capturing / bubbling事件捕获 / 冒泡。DOM 独有的三阶段传播,QML 无捕获阶段
—cascade级联。CSS 的样式继承与优先级机制,QML 完全没有
—thread affinity线程亲和性。QObject 归属创建它的线程
GCGarbage Collection垃圾回收。DOM 与 QML 动态对象的回收机制
GUIGraphical User Interface图形用户界面。Qt 中特指主线程
GPUGraphics Processing Unit图形处理器
CSS / QSSCascading Style Sheets / Qt Style Sheets层叠样式表 / Qt 样式表
APIApplication Programming Interface应用程序编程接口

小结

一句话区分:DOM 树描述文档,QObject 树描述所有权,QML 把所有权和视觉层级拆成两棵。

类比在查找 API 和事件冒泡这两层是成立的,可以放心迁移经验。但在生命周期和样式两层完全不成立——DOM 的 GC 思维带到 Qt 会导致 double free,CSS 的级联思维带到 QML 会发现属性根本不继承。这两处是真正需要重新学的部分。

posted @ 2026-09-14 18:22  起飞的乌龟  阅读(26)  评论(0)    收藏  举报