DOM 树深入:从浏览器渲染管线到 QDomDocument
DOM(Document Object Model,文档对象模型)是 W3C(World Wide Web Consortium,万维网联盟)定义的标准,把标记文档解析成一棵节点树,让程序能按层级访问和修改文档内容。它最出名的场景是浏览器里的 HTML(HyperText Markup Language,超文本标记语言)DOM,但作为一种模型,它同样适用于 XML(eXtensible Markup Language,可扩展标记语言)——Qt 的 QDomDocument 就是这条路子。
一、树的结构
一段简单的 HTML:
<html>
<head><title>示例</title></head>
<body>
<div id="app" class="root">Hello</div>
</body>
</html>
解析后的树:
Document ← 根节点,注意不是 html
└── html (Element)
├── head (Element)
│ └── title (Element)
│ └── "示例" (Text)
└── body (Element)
└── div (Element)
├── id="app" (Attr)
├── class="root" (Attr)
└── "Hello" (Text)
几个容易搞混的点:
- 根节点是 Document 而不是
<html>。document.documentElement才是<html> - 文本也是节点。
<div>Hello</div>里的 div 有一个 Text 子节点,不是「div 的值是 Hello」 - 空白也是 Text 节点。标签之间的换行和缩进会产生 Text 节点,所以
childNodes的数量常常比预期多。想只拿元素得用children(Web)或者手动过滤nodeType - 节点类型主要有 Document、Element、Text、Attr、Comment、DocumentFragment、CDATASection
二、浏览器渲染管线里的位置
这是前端面试的经典链路:
HTML ──解析──→ DOM 树 ┐
├──→ Render Tree ──→ Layout ──→ Paint ──→ Composite
CSS ──解析──→ CSSOM 树 ┘
关键点在于 DOM 树和 Render Tree 不是一回事。display: none 的元素在 DOM 树里存在,但不进 Render Tree;visibility: hidden 的元素两棵树都在,只是不绘制。这个区别直接决定了它是否占位。
其中 CSSOM(CSS Object Model,CSS 对象模型)是样式表解析后的树形结构,和 DOM 树合成 Render Tree(渲染树)。由此引出重排(reflow,也叫 layout)和重绘(repaint):
- 重排——几何属性变化(宽高、位置、字体大小、增删节点),要重新算布局,代价高
- 重绘——只是外观变化(颜色、背景、阴影),跳过布局直接画
- 合成——
transform/opacity走 GPU 合成层,最便宜,动画应该优先用它们
性能优化的核心手段是减少布局抖动(layout thrashing)。下面这段是典型的反面例子:
// 坏:每次循环都强制同步布局
for (let i = 0; i < items.length; i++) {
items[i].style.width = container.offsetWidth + 'px'; // 读 → 写 → 读 → 写
}
// 好:先批量读,再批量写
const w = container.offsetWidth;
for (let i = 0; i < items.length; i++) {
items[i].style.width = w + 'px';
}
原因是浏览器会把样式修改放进队列批量处理,但一旦你读取 offsetWidth、getBoundingClientRect 这类几何属性,它必须立刻把队列刷掉重新布局,才能给出准确值。读写交替就等于每次循环都强制一次完整布局。
三、Qt 里的 DOM:QDomDocument
Qt 的 XML 模块提供了一套 DOM 实现,API 风格和 Web DOM 高度相似。需要在构建文件里加上 Xml 模块(Qt6 里是 Qt6::Xml)。
3.1 解析与遍历
#include <QDomDocument>
QDomDocument doc;
QFile file("config.xml");
if (!file.open(QIODevice::ReadOnly))
return;
// Qt6 推荐的重载,能拿到详细错误位置
QDomDocument::ParseResult r = doc.setContent(&file);
if (!r) {
qWarning() << "解析失败:" << r.errorMessage
<< "行" << r.errorLine << "列" << r.errorColumn;
return;
}
QDomElement root = doc.documentElement(); // 对应 documentElement
QDomNodeList items = root.elementsByTagName("item");
for (int i = 0; i < items.count(); ++i) {
QDomElement e = items.at(i).toElement();
if (e.isNull()) continue; // 防御:可能不是 Element
qDebug() << e.attribute("name") // 读属性
<< e.attribute("port", "8080") // 带默认值
<< e.text(); // 读文本内容
}
按兄弟节点逐个走的写法,比 elementsByTagName 更可控(只找直接子节点,不递归):
for (QDomElement e = root.firstChildElement("item");
!e.isNull();
e = e.nextSiblingElement("item")) {
qDebug() << e.attribute("name");
}
3.2 构造与写出
QDomDocument doc;
doc.appendChild(doc.createProcessingInstruction(
"xml", R"(version="1.0" encoding="UTF-8")"));
QDomElement root = doc.createElement("config");
doc.appendChild(root);
QDomElement item = doc.createElement("item");
item.setAttribute("name", "server");
item.appendChild(doc.createTextNode("172.18.30.153"));
root.appendChild(item);
QFile out("config.xml");
if (out.open(QIODevice::WriteOnly | QIODevice::Text)) {
QTextStream ts(&out);
doc.save(ts, 2); // 缩进 2 空格
}
3.3 关键取舍:QDomDocument 还是 QXmlStreamReader
这是 Qt XML 处理的必答题。QDomDocument 会把整个文档全量载入内存,而且节点对象的开销远大于原始文本——几十兆的 XML 能吃掉数百兆内存。
// 流式解析:内存占用恒定,只能单向前进
QXmlStreamReader xml(&file);
while (!xml.atEnd()) {
if (xml.readNextStartElement()) {
if (xml.name() == u"item") {
const QString name = xml.attributes().value("name").toString();
const QString text = xml.readElementText();
process(name, text);
}
}
}
if (xml.hasError())
qWarning() << xml.errorString();
选择依据很清晰:
- 小文件(配置、几百 KB 以内)、需要随机访问或修改后回写 →
QDomDocument - 大文件、只需顺序读取、内存敏感 →
QXmlStreamReader - 需要生成 XML →
QXmlStreamWriter(比 DOM 拼树更省内存)
这个取舍和 Web 端的 DOM vs SAX(Simple API for XML,XML 简单应用程序接口,一种事件驱动的流式解析方式)是同一回事:树模型换的是便利性,流模型换的是内存。顺带一提,Qt6 里 QXmlSimpleReader(SAX)已被废弃移除,流式一律用 QXmlStreamReader。
四、三棵树的同构关系
理解 DOM 之后有个额外收获:Web 的 DOM 树、Qt 的 QObject parent-child 树、QML 的 Item 树,本质是同一个设计思路——层级容器树 + 选择器查找 + 事件冒泡。API 几乎能一一对应:
| 能力 | Web DOM | Qt Widgets |
|---|---|---|
| 按标识查找 | getElementById("app") | findChild<QPushButton*>("btnOk") |
| 批量条件查找 | querySelectorAll(".item") | findChildren<QLabel*>() |
| 访问父节点 | parentNode | parent() |
| 访问子节点 | childNodes | children() |
| 样式规则 | CSS 选择器 | QSS 选择器 |
| 事件传播 | 捕获 / 冒泡 | 事件过滤器 / 向父 widget 冒泡 |
| 生命周期 | GC 回收 | 父对象析构时删子对象 |
| 路径表达式 | XPath | Squish 对象名表达式 |
Qt 的对象树还有一个 Web 没有的特性:父对象析构会自动 delete 全部子对象。这是 Qt 的半自动内存管理机制,也是 new QWidget(parent) 之后不用手动 delete 的原因。
五、和 Squish 的连接点
Squish 定位界面元素的方式,本质就是在某棵树里按属性匹配节点。测 Qt 应用走 widget 树,测 Web 应用走 DOM 树,语法完全一致:
# Qt 应用:在 widget 树里找
clickButton(waitForObject("{type='QPushButton' objectName='btnOk'}"))
# Web 应用:在 DOM 树里找
clickButton(waitForObject("{tagName='BUTTON' innerText='提交' "
"className='btn-primary'}"))
# 也支持直接用 XPath 定位 DOM 节点
clickButton(waitForObject("{xpath=\"//div[@id='app']//button[1]\"}"))
所以「DOM 树是什么」在 Qt 岗位的面试里被问到,往往不是考前端,而是考你是否理解 Squish 的对象识别原理,或者考 QDomDocument 的内存取舍。
六、常见问题速查
childNodes数量比预期多——空白和换行都是 Text 节点。Web 端改用children,Qt 端用firstChildElement系列innerHTML拼接有 XSS(Cross-Site Scripting,跨站脚本攻击)风险——插入用户内容一律走textContent或createElement,别拼字符串QDomNode::toElement()可能返回空——注释、文本节点转不成 Element,必须查isNull()- XML 命名空间——带
xmlns的文档要用elementsByTagNameNS,或者在setContent时开启命名空间处理 - 编码问题——
QDomDocument::save到QTextStream时确认流的编码,Qt6 默认 UTF-8,Qt5 要显式setCodec("UTF-8") - 大文档修改——DOM 树改一处要整棵回写。频繁增量更新的场景考虑换成数据库或行式格式
术语表
| 简称 | 英文全称 | 中文与说明 |
|---|---|---|
| DOM | Document Object Model | 文档对象模型。把标记文档解析成节点树的标准 |
| CSSOM | CSS Object Model | CSS 对象模型。样式表解析后的树,与 DOM 合成渲染树 |
| CSS | Cascading Style Sheets | 层叠样式表 |
| HTML | HyperText Markup Language | 超文本标记语言 |
| XML | eXtensible Markup Language | 可扩展标记语言 |
| W3C | World Wide Web Consortium | 万维网联盟。DOM 标准的制定组织 |
| SAX | Simple API for XML | XML 简单应用程序接口。事件驱动的流式解析,内存恒定 |
| — | Render Tree | 渲染树。DOM + CSSOM 合成,剔除了 display:none 的节点 |
| — | reflow / layout | 重排 / 布局。几何属性变化触发,代价高 |
| — | repaint | 重绘。仅外观变化,跳过布局 |
| — | composite | 合成。transform / opacity 走 GPU 合成层,最便宜 |
| — | layout thrashing | 布局抖动。读写几何属性交替导致每轮强制同步布局 |
| GPU | Graphics Processing Unit | 图形处理器 |
| XSS | Cross-Site Scripting | 跨站脚本攻击。拼接 innerHTML 的主要风险 |
| XPath | XML Path Language | XML 路径语言。用路径表达式定位节点 |
| GC | Garbage Collection | 垃圾回收。DOM 节点的生命周期由它管理,与 Qt 的父子所有权不同 |
| QSS | Qt Style Sheets | Qt 样式表 |
| API | Application Programming Interface | 应用程序编程接口 |
小结
DOM 树的核心就三件事:文档变成节点树、节点可按层级查找和修改、树的变更触发渲染或回写。Web 端要关注的是重排重绘的成本,Qt 端要关注的是全量载入的内存代价。抓住「树模型换便利、流模型换内存」这条取舍主线,两个平台的问题都能推出来。

浙公网安备 33010602011771号