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 DOMQt Widgets
按标识查找getElementById("app")findChild<QPushButton*>("btnOk")
批量条件查找querySelectorAll(".item")findChildren<QLabel*>()
访问父节点parentNodeparent()
访问子节点childNodeschildren()
样式规则CSS 选择器QSS 选择器
事件传播捕获 / 冒泡事件过滤器 / 向父 widget 冒泡
生命周期GC 回收父对象析构时删子对象
路径表达式XPathSquish 对象名表达式

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 树改一处要整棵回写。频繁增量更新的场景考虑换成数据库或行式格式

术语表

简称英文全称中文与说明
DOMDocument Object Model文档对象模型。把标记文档解析成节点树的标准
CSSOMCSS Object ModelCSS 对象模型。样式表解析后的树,与 DOM 合成渲染树
CSSCascading Style Sheets层叠样式表
HTMLHyperText Markup Language超文本标记语言
XMLeXtensible Markup Language可扩展标记语言
W3CWorld Wide Web Consortium万维网联盟。DOM 标准的制定组织
SAXSimple API for XMLXML 简单应用程序接口。事件驱动的流式解析,内存恒定
—Render Tree渲染树。DOM + CSSOM 合成,剔除了 display:none 的节点
—reflow / layout重排 / 布局。几何属性变化触发,代价高
—repaint重绘。仅外观变化,跳过布局
—composite合成。transform / opacity 走 GPU 合成层,最便宜
—layout thrashing布局抖动。读写几何属性交替导致每轮强制同步布局
GPUGraphics Processing Unit图形处理器
XSSCross-Site Scripting跨站脚本攻击。拼接 innerHTML 的主要风险
XPathXML Path LanguageXML 路径语言。用路径表达式定位节点
GCGarbage Collection垃圾回收。DOM 节点的生命周期由它管理,与 Qt 的父子所有权不同
QSSQt Style SheetsQt 样式表
APIApplication Programming Interface应用程序编程接口

小结

DOM 树的核心就三件事:文档变成节点树、节点可按层级查找和修改、树的变更触发渲染或回写。Web 端要关注的是重排重绘的成本,Qt 端要关注的是全量载入的内存代价。抓住「树模型换便利、流模型换内存」这条取舍主线,两个平台的问题都能推出来。

posted @ 2026-09-14 17:52  起飞的乌龟  阅读(12)  评论(0)    收藏  举报