HTML、CSS 和 DOM 如何处理空白字符

DOM中存在空白字符可能会导致布局问题,并以意想不到的方式使内容树的操作变得困难,具体取决于其所在位置。本文探讨了何时会出现困难,并介绍了可以采取哪些措施来缓解由此产生的问题。

什么是空白字符?

空白字符是指仅由空格、制表符或换行符(确切地说是 CRLF 序列、回车符或换行符)组成的任何字符串。这些字符允许您以易于自己和他人的阅读方式格式化代码。事实上,我们的许多源代码都充满了这些空白字符,我们只倾向于在生产构建步骤中将其删除以减少代码下载大小。

HTML 在很大程度上忽略空白字符?

在 HTML 的情况下,空白字符在很大程度上被忽略 - 词语之间的空白字符被视为单个字符,元素开头和结尾以及元素外部的空白字符会被忽略。请看以下最小示例

html
<!DOCTYPE html>

  <h1>      Hello      World!     </h1>

此源代码在DOCTYPE之后包含几个换行符,在<h1>元素之前、之后和内部包含大量空格字符,但浏览器似乎根本不在乎,只是显示“Hello World!”,就像这些字符根本不存在一样

这样是为了防止空白字符影响页面布局。在元素周围和内部创建空格是 CSS 的工作。

空白字符发生了什么?

然而,它们并没有消失。

原始文档中 HTML 元素外部的任何空白字符都将在 DOM 中表示。这是内部需要的,以便编辑器可以保留文档的格式。这意味着

  • 将有一些文本节点仅包含空白字符,并且
  • 一些文本节点将在开头或结尾处有空白字符。

例如,请看以下文档

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <title>My Document</title>
  </head>
  <body>
    <h1>Header</h1>
    <p>Paragraph</p>
  </body>
</html>

此文档的 DOM 树如下所示

The DOM tree representing a simple HTML document

在 DOM 中保留空白字符在很多方面都是有用的,但在某些情况下,这会使某些布局更难实现,并给希望遍历 DOM 中节点的开发人员带来问题。我们将在后面介绍这些问题以及一些解决方案。

CSS 如何处理空白字符?

大多数空白字符都被忽略,但并非全部。在前面的示例中,“Hello”和“World!”之间的空格之一在浏览器中呈现页面时仍然存在。浏览器引擎中有一些规则决定哪些空白字符是有用的,哪些不是 - 这些规则至少部分在CSS 文本模块级别 3中指定,特别是关于CSS white-space 属性空白字符处理细节的部分,但我们下面也提供了一个更简单的解释。

示例

让我们再举一个例子。为了方便起见,我们添加了一条注释,其中所有空格用◦表示,所有制表符用⇥表示,所有换行符用⏎表示

此示例

html
<h1>   Hello
        <span> World!</span>   </h1>

<!--
<h1>◦◦◦Hello◦⏎
⇥⇥⇥⇥<span>◦World!</span>⇥◦◦</h1>
-->

在浏览器中呈现如下

解释

<h1>元素仅包含内联元素。实际上,它包含

  • 一个文本节点(包含一些空格、单词“Hello”和一些制表符)。
  • 一个内联元素(<span>,包含一个空格和单词“World!”)。
  • 另一个文本节点(仅包含制表符和空格)。

因此,它建立了所谓的内联格式化上下文。这是浏览器引擎使用的可能的布局渲染上下文之一。

在此上下文中,空白字符处理可以概括如下

  1. 首先,忽略换行符前后所有空格和制表符,因此,如果我们采用之前示例中的标记
    html
    <h1>◦◦◦Hello◦⏎
    ⇥⇥⇥⇥<span>◦World!</span>⇥◦◦</h1>
    
    ...并应用此第一条规则,我们得到
    html
    <h1>◦◦◦Hello⏎
    <span>◦World!</span>⇥◦◦</h1>
    
  2. 接下来,所有制表符都将被视为空格字符,因此示例变为
    html
    <h1>◦◦◦Hello⏎
    <span>◦World!</span>◦◦◦</h1>
    
  3. 接下来,换行符将转换为空格
    html
    <h1>◦◦◦Hello◦<span>◦World!</span>◦◦◦</h1>
    
  4. 之后,忽略紧跟在另一个空格之后的任何空格(即使跨越两个单独的内联元素),因此我们最终得到
    html
    <h1>◦Hello◦<span>World!</span></h1>
    
  5. 最后,删除元素开头和结尾处的空格序列,因此我们最终得到
    html
    <h1>Hello◦<span>World!</span></h1>
    

这就是为什么访问网页的人会在页面顶部看到“Hello World!”这个短语整齐地写着,而不是一个奇怪地缩进的“Hello”后面跟着一个更奇怪地缩进的“World!”在下一行。

注意:Firefox 开发者工具从 52 版本开始支持突出显示文本节点,这使得更容易准确地查看空白字符包含在哪些节点中。纯空白字符节点用“whitespace”标签标记。

块格式化上下文中的空白字符

上面我们只查看了包含内联元素和内联格式化上下文的元素。如果某个元素至少包含一个块级元素,则它将建立所谓的块格式化上下文

在此上下文中,空白字符的处理方式大不相同。

示例

让我们看一个例子来解释一下。我们像之前一样标记了空白字符。

我们有 3 个仅包含空白字符的文本节点,第一个 <div> 之前有一个,两个 <div> 之间有一个,第二个 <div> 之后有一个。

html
<body>
  <div>  Hello  </div>

   <div>  World!   </div>
</body>

<!--
<body>⏎
⇥<div>◦◦Hello◦◦</div>⏎
⏎
◦◦◦<div>◦◦World!◦◦</div>◦◦⏎
</body>
-->

呈现如下

解释

我们可以概括此处空白字符的处理方式如下(不同浏览器之间的确切行为可能存在一些细微差异,但这基本上有效)

  1. 因为我们在块格式化上下文中,所以所有内容都必须是块,因此我们的 3 个文本节点也变成块,就像 2 个 <div> 一样。块占据可用的全部宽度,并彼此堆叠,这意味着,从上面的示例开始
    html
    <body>⏎
    ⇥<div>◦◦Hello◦◦</div>⏎
    ⏎
    ◦◦◦<div>◦◦World!◦◦</div>◦◦⏎
    </body>
    
    ...我们最终得到由以下块列表组成的布局
    html
    <block>⏎⇥</block>
    <block>◦◦Hello◦◦</block>
    <block>⏎◦◦◦</block>
    <block>◦◦World!◦◦</block>
    <block>◦◦⏎</block>
    
  2. 然后通过将内联格式化上下文中空白字符的处理规则应用于这些块,进一步简化此布局
    html
    <block></block>
    <block>Hello</block>
    <block></block>
    <block>World!</block>
    <block></block>
    
  3. 我们现在拥有的 3 个空块不会在最终布局中占用任何空间,因为它们不包含任何内容,因此我们最终只会得到 2 个占用页面空间的块。查看网页的人会看到“Hello”和“World!”分别在两行上,正如您期望的 2 个 <div> 的布局一样。浏览器引擎基本上忽略了源代码中添加的所有空白字符。

内联和内联块元素之间的空格

让我们继续探讨由于空白字符可能出现的一些问题以及如何解决这些问题。首先,我们将了解内联和内联块元素之间的空格会发生什么。事实上,我们在第一个示例中已经看到了这一点,当时我们描述了如何在内联格式化上下文中处理空白字符。

我们说有一些规则可以忽略大多数字符,但分隔单词的字符会保留下来。当您只处理仅包含内联元素(如 <em><strong><span> 等)的块级元素(如 <p>)时,您通常不会关心这一点,因为最终进入布局的额外空白字符有助于分隔句子中的单词。

但是,当您开始使用 inline-block 元素时,情况会变得更有趣。这些元素在外部表现得像内联元素,在内部表现得像块元素,并且通常用于并排显示比文本更复杂的 UI 部分,例如导航菜单项。

由于它们是块级元素,许多人期望它们的行为像块级元素那样,但实际上并非如此。如果相邻的内联元素之间存在格式化空白字符,这将在布局中产生空格,就像文本中单词之间的空格一样。

示例

考虑以下示例(同样,我们包含了一个 HTML 注释,其中显示了 HTML 中的空白字符)

css
.people-list {
  list-style-type: none;
  margin: 0;
  padding: 0;
}

.people-list li {
  display: inline-block;
  width: 2em;
  height: 2em;
  background: #f06;
  border: 1px solid;
}
html
<ul class="people-list">
  <li></li>

  <li></li>

  <li></li>

  <li></li>

  <li></li>
</ul>

<!--
<ul class="people-list">⏎
◦◦<li></li>⏎
⏎
◦◦<li></li>⏎
⏎
◦◦<li></li>⏎
⏎
◦◦<li></li>⏎
⏎
◦◦<li></li>⏎
</ul>
-->

呈现效果如下

您可能不希望块级元素之间出现间隙——根据用例(这是一个头像列表还是水平导航按钮?),您可能希望元素边彼此紧贴,并能够自己控制任何间距。

Firefox DevTools HTML 检查器将突出显示文本节点,并准确显示元素占据的区域——如果您想知道是什么导致了问题,并且可能认为您在其中添加了一些额外的边距或其他内容,这将非常有用!

Example of displaying whitespaces between blocks in the Firefox DevTools HTML Inspector

解决方案

有一些方法可以解决此问题

使用 Flexbox 创建水平项目列表,而不是尝试使用 inline-block 解决方案。这将为您处理所有内容,并且绝对是首选解决方案

css
ul {
  list-style-type: none;
  margin: 0;
  padding: 0;
  display: flex;
}

如果您需要依赖 inline-block,您可以将列表的 font-size 设置为 0。这仅在您的块未使用 em(基于 font-size,因此块大小最终也将为 0)进行大小调整时才有效。rems 将是这里一个不错的选择

css
ul {
  font-size: 0;
  /* … */
}

li {
  display: inline-block;
  width: 2rem;
  height: 2rem;
  /* … */
}

或者,您可以在列表项上设置负边距

css
li {
  display: inline-block;
  width: 2rem;
  height: 2rem;
  margin-right: -0.25rem;
}

您还可以通过将列表项全部放在源代码中的同一行来解决此问题,这会导致不会在第一时间创建空白字符节点。

html
<li></li><li></li><li></li><li></li><li></li>

DOM 遍历和空白字符

在尝试使用 JavaScript 进行 DOM 操作时,您也可能会遇到由于空白字符节点导致的问题。例如,如果您有一个父节点的引用,并希望使用 Node.firstChild 影响其第一个元素子节点,如果在父标签的开始标记之后有一个不相关的空白字符节点,则您将无法获得预期的结果。文本节点将被选中,而不是您要影响的元素。

再举一个例子,如果您有一组特定的元素,您希望根据它们是否为空(没有子节点)来对其执行某些操作,您可以使用类似 Node.hasChildNodes() 的方法检查每个元素是否为空,但同样,如果任何目标元素包含文本节点,您可能会得到错误的结果。

空白字符辅助函数

下面的 JavaScript 代码定义了几个函数,使处理 DOM 中的空白字符变得更容易

js
/**
 * Throughout, whitespace is defined as one of the characters
 *  "\t" TAB \u0009
 *  "\n" LF  \u000A
 *  "\r" CR  \u000D
 *  " "  SPC \u0020
 *
 * This does not use JavaScript's "\s" because that includes non-breaking
 * spaces (and also some other characters).
 */

/**
 * Determine whether a node's text content is entirely whitespace.
 *
 * @param nod  A node implementing the |CharacterData| interface (i.e.,
 *             a |Text|, |Comment|, or |CDATASection| node
 * @return     True if all of the text content of |nod| is whitespace,
 *             otherwise false.
 */
function is_all_ws(nod) {
  return !/[^\t\n\r ]/.test(nod.textContent);
}

/**
 * Determine if a node should be ignored by the iterator functions.
 *
 * @param nod  An object implementing the DOM1 |Node| interface.
 * @return     true if the node is:
 *                1) A |Text| node that is all whitespace
 *                2) A |Comment| node
 *             and otherwise false.
 */

function is_ignorable(nod) {
  return (
    nod.nodeType === 8 || // A comment node
    (nod.nodeType === 3 && is_all_ws(nod))
  ); // a text node, all ws
}

/**
 * Version of |previousSibling| that skips nodes that are entirely
 * whitespace or comments. (Normally |previousSibling| is a property
 * of all DOM nodes that gives the sibling node, the node that is
 * a child of the same parent, that occurs immediately before the
 * reference node.)
 *
 * @param sib  The reference node.
 * @return     Either:
 *               1) The closest previous sibling to |sib| that is not
 *                  ignorable according to |is_ignorable|, or
 *               2) null if no such node exists.
 */
function node_before(sib) {
  while ((sib = sib.previousSibling)) {
    if (!is_ignorable(sib)) {
      return sib;
    }
  }
  return null;
}

/**
 * Version of |nextSibling| that skips nodes that are entirely
 * whitespace or comments.
 *
 * @param sib  The reference node.
 * @return     Either:
 *               1) The closest next sibling to |sib| that is not
 *                  ignorable according to |is_ignorable|, or
 *               2) null if no such node exists.
 */
function node_after(sib) {
  while ((sib = sib.nextSibling)) {
    if (!is_ignorable(sib)) {
      return sib;
    }
  }
  return null;
}

/**
 * Version of |lastChild| that skips nodes that are entirely
 * whitespace or comments. (Normally |lastChild| is a property
 * of all DOM nodes that gives the last of the nodes contained
 * directly in the reference node.)
 *
 * @param sib  The reference node.
 * @return     Either:
 *               1) The last child of |sib| that is not
 *                  ignorable according to |is_ignorable|, or
 *               2) null if no such node exists.
 */
function last_child(par) {
  let res = par.lastChild;
  while (res) {
    if (!is_ignorable(res)) {
      return res;
    }
    res = res.previousSibling;
  }
  return null;
}

/**
 * Version of |firstChild| that skips nodes that are entirely
 * whitespace and comments.
 *
 * @param sib  The reference node.
 * @return     Either:
 *               1) The first child of |sib| that is not
 *                  ignorable according to |is_ignorable|, or
 *               2) null if no such node exists.
 */
function first_child(par) {
  let res = par.firstChild;
  while (res) {
    if (!is_ignorable(res)) {
      return res;
    }
    res = res.nextSibling;
  }
  return null;
}

/**
 * Version of |data| that doesn't include whitespace at the beginning
 * and end and normalizes all whitespace to a single space. (Normally
 * |data| is a property of text nodes that gives the text of the node.)
 *
 * @param txt  The text node whose data should be returned
 * @return     A string giving the contents of the text node with
 *             whitespace collapsed.
 */
function data_of(txt) {
  let data = txt.textContent;
  data = data.replace(/[\t\n\r ]+/g, " ");
  if (data[0] === " ") {
    data = data.substring(1, data.length);
  }
  if (data[data.length - 1] === " ") {
    data = data.substring(0, data.length - 1);
  }
  return data;
}

示例

以下代码演示了如何使用上述函数。它迭代元素(其所有子节点都是元素)的子节点,以查找文本为 "This is the third paragraph" 的子节点,然后更改该段落的 class 属性和内容。

js
let cur = first_child(document.getElementById("test"));
while (cur) {
  if (data_of(cur.firstChild) === "This is the third paragraph.") {
    cur.className = "magic";
    cur.firstChild.textContent = "This is the magic paragraph.";
  }
  cur = node_after(cur);
}