首页 / 文章 / 实时DOM集合、布局抖动以及属性缺失问题解析

实时DOM集合、布局抖动以及属性缺失问题解析

了解为何 DOM 是一种实时渲染的树结构,这种结构如何导致循环项被跳过以及布局被迫重新计算,以及为何自定义数据属性需要使用 getAttribute 方法。

1407 词

DOM 一直以速度慢而著称,常见的解释是它结构过于复杂。但这种说法大多不正确。DOM 操作的实际速度相当快;真正影响性能的是一种常见的编程模式,即在循环中反复向 DOM 请求信息又要求其进行更改。要理解其中原因,首先需要准确了解 DOM 的本质,而这一认知也能解释另外两种常让开发者困惑的现象:跳过某些元素的循环,以及返回 undefined 的自定义属性。

DOM 是动态树结构,而非 HTML 的副本

当浏览器解析 HTML 时,并不会将标记内容以文本形式保存。它会为每个元素创建一个对象,并根据标记结构将这些对象嵌套起来,从而形成一棵树结构。以一个简小的产品目录为例:

<div id="catalog">
  <div class="product">
    <h3>Desk Lamp</h3>
    <span class="price">$34</span>
  </div>
  <div class="product">
    <h3>Standing Mat</h3>
    <span class="price">$58</span>
  </div>
</div>

基于此,浏览器会构建一个JavaScript可以访问的动态对象树:#catalog中包含两个.product元素,每个元素又包含一个<h3>和一个<span>。关键在于,这棵树并非一次性生成后就被搁置的描述,而是浏览器当前正在渲染的结构。只要修改这些节点对象中的某个属性,页面就会立即反映出来,无需额外的保存步骤或重新渲染操作。

document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";

这条语句并非要求浏览器稍后为你处理某项操作,赋值操作本身就代表了变化。牢记DOM的“动态”特性是理解其工作原理最有效的思维模型,但这也是几乎每个开发者都曾犯过的错误根源。

为何遍历动态集合时会跳过某些元素

假设你想删除所有标记为缺货的产品卡片。显而易见的循环代码如下:

const outOfStockCards = document.getElementsByClassName("out-of-stock");

for (let i = 0; i < outOfStockCards.length; i++) {
  outOfStockCards[i].remove();
}

这段代码看似正确,但实际上却悄悄地漏掉了每两个匹配项中的一个。原因在于 getElementsByClassName 返回的并非固定数组,而是一个会随文档变化而实时更新的 HTMLCollection。一旦 .remove() 删除了第一个卡片,集合的长度就会减少一个,剩余的每个卡片索引都会向下移动一位。由于循环计数器仍在继续递增,因此刚刚移至索引0的元素就永远不会被访问到。在循环执行过程中集合的大小一直在变化,大约有一半的匹配项因此被遗漏。

解决方法是先在开始修改之前获取一个静态副本:

const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));

for (const card of outOfStockCards) {
  card.remove();
}

Array.from会将动态集合快照为普通数组,因此后续的删除操作无法缩小正在遍历的范围。更简单的选择是document.querySelectorAll(".out-of-stock"),它从一开始就会返回一个静态的NodeList,无需进行任何转换。正是这种可预测性使得querySelectorAll在很大程度上取代了旧版的查询API。如果必须操作动态集合,从最后一个索引开始反向遍历也能避免元素位置变动的问题,不过使用静态快照通常更为清晰。

布局抖动:DOM代码运行缓慢的真正原因

现在来看实际的性能问题。计算布局,也就是确定每个元素的确切大小和位置,是一项耗时的操作。浏览器会尽量避免不必要的计算:当你更改样式时,它们不会立即重新计算布局,而是会将待处理的变更收集起来,在下一帧渲染之前统一进行一次计算。

只有当代码允许时,这种策略才能发挥作用。某些属性和方法,包括offsetWidthoffsetHeight以及getBoundingClientRect(),需要最新的几何信息才能返回数值。读取这些属性会迫使浏览器同步执行布局计算,因为不经过计算就无法得出元素的宽度。

以下循环为每张卡片交替执行读取和写入操作:

// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
  const width = card.offsetWidth; // read: forces layout
  card.style.width = width + 10 + "px"; // write: invalidates layout again
});

每次迭代都会读取宽度值,然后写入新的宽度值。由于上一次迭代中排队的样式更改可能会影响读取结果,因此不能使用过时的数据来进行读取。于是浏览器会清除待处理的更改,重新计算布局,返回数值,而紧接着下一行又会再次使该布局失效。这种循环被称为布局抖动(或强制同步布局),正是人们说DOM运行缓慢时所经历的现象。由于代码不断要求DOM执行操作,它不得不频繁地进行原本不必要的繁重工作。

应将这些操作分为两轮来完成:先全部读取数据,然后再全部写入数据:

const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
  card.style.width = widths[i] + 10 + "px"; // all writes, together
});

现在,布局在首次读取宽度时就会计算一次,之后的读取都会使用相同的计算结果,因为其间没有任何变化。后续的写入操作会被排队,在下一次重绘之前一起处理。DOM的实际运行速度并没有提升;只是代码不再强制它在每次迭代时都重新进行相同的计算而已。

由此可得出几点实际注意事项:

  • 同样的规则也适用于其他几何属性的读取,如clientWidthscrollTopgetComputedStyle()
  • 在规模较大的应用中,读取和写入操作往往发生在不同的函数或组件中,因此即便没有明显的循环问题,也可能会出现性能问题。浏览器性能分析工具能够标出强制触发布局的操作,从而便于定位问题。
  • 使用 requestAnimationFrame 来安排写入操作,是常见的一种在渲染前将它们集中处理的方法。
  • 属性与特性并不总是一回事

    最后一个令人意外的点是 HTML 属性与 JavaScript 特性之间的关系。它们通常是一一对应的,但这种对应关系存在局限,而一旦添加自定义数据,这些局限就会变得很重要。以这个元素为例:

    <div class="product" data-sku="LAMP-2201"></div>
    

    以及以下三种不同的读取该元素的代码方式:

    const el = document.querySelector(".product");
    console.log(el.className); // "product" — standard attributes map to properties directly
    console.log(el.sku);       // undefined — custom attributes don't
    console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it
    

    浏览器识别的标准属性,如hrefsrcclass,会自动作为对应的属性暴露出来。由于class在JavaScript中是保留字,因此它以className的形式出现。自定义属性则没有这种处理方式,包括那些以data-为前缀的属性——该前缀是用于在元素上存储自定义元数据的标准机制。不存在el.sku属性,因此查询会返回undefined,需要使用getAttributesetAttribute来读取或修改其值。为方便使用,浏览器还通过dataset对象暴露了data-属性,因此el.dataset.sku也能返回相同的值。这种差异虽微小,却正是造成困惑的原因。

    当你期望某个自定义属性表现得像 href 时,首次就会出现 ode>undefined 的情况。

    一个思维模型,而非三个需要注意的点

    这些行为并非独立的琐碎知识,它们都源于同一个事实:DOM 是一个正在被渲染的动态结构,而非只需填充一次的静态数据。

    • 在遍历动态集合时,这些集合会发生变化,因此应在修改之前先对它们进行快照或使用 querySelectorAll
    • 读取几何信息会迫使浏览器立即计算布局,因此应在写入之前批量读取。
    • 为方便使用,JavaScript 的属性位于属性之上,但并不涵盖所有属性,因此应通过 getAttributedataset 来访问自定义数据。

    一旦将 DOM 视为一棵动态变化的树而非缓慢的数据结构,这些现象就不再令人意外,而会成为可预测的结果。如需更全面地了解在框架环境中状态变化如何转化为屏幕上的像素,请参阅React 如何将状态更新转化为屏幕像素

    相关阅读

  • React 19.2详解:Activity、useEffectEvent与静态渲染 — 了解React 19.2新增的Activity组件、useEffectEvent钩子以及部分静态渲染功能如何解决现代用户界面中存在的隐性性能问题。