什麼是空白?
空白字元在不同的程式語言環境中由不同的字元組成。就 CSS 空白處理規則而言,文件空白字元僅包括空格(U+0020)、製表符(U+0009)、換行符(LF, U+000A)和回車符(CR, U+000D),其中回車符在各方面都等同於空格。這些字元可以讓你格式化程式碼以提高可讀性。我們的原始碼中充滿了這些空白字元,我們通常只在生產構建步驟中為了減小檔案大小而移除它們。
注意,此列表不包括不間斷空格(U+00A0,在 HTML 中為 )。因此,這些字元不會觸發任何摺疊,這就是為什麼它們經常被用來在 HTML 中建立更長的空格。
CSS 還定義了分段符的概念,在 HTML 的上下文中,它等同於 LF 字元。
HTML 如何處理空白?
有一種常見的誤解是“HTML 會忽略空白”,這是不正確的:HTML 會保留你在原始碼中寫下的所有空白文字內容。作為一種標記語言,HTML 生成的 DOM 會保留文字內容中的所有空白,這些空白可以透過 Node.textContent 等 DOM API 進行檢索和操作。如果 HTML 從 DOM 中剝離了空白,那麼作為作用於 DOM 的下游渲染引擎,CSS 就無法使用 white-space 屬性來保留它們。
備註: 需要明確的是,我們討論的是HTML 標籤之間的空白,這些空白在 DOM 中成為文字節點。任何標籤內部的空白(在尖括號之間但不是屬性值的一部分)只是 HTML 語法的一部分,不會出現在 DOM 中。
備註: 由於 HTML 解析的神奇之處(引自 DOM 規範),確實存在某些地方的空白字元可能被忽略的情況。例如,<html> 和 <head> 開始標籤之間或 </body> 和 </html> 結束標籤之間的空白會被忽略,不會出現在 DOM 中。此外,在解析 <pre> 元素的文字內容時,會剝離掉單個前導換行符。我們在此忽略這些邊緣情況。
此外,HTML 解析器確實會對某些空白進行規範化:它會將 CR 和 CRLF 序列替換為單個 LF。但是,CR 字元也可以透過字元引用或 JavaScript 插入到 DOM 中,因此 CSS 空白處理規則仍然需要定義如何處理它們。
以下面的文件為例:
<!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 樹如下所示:

請注意:
- 一些文字節點將只包含空白。
- 其他文字節點可能在開頭或結尾有空白。
備註: Firefox 開發者工具支援高亮顯示文字節點,從而更容易地看出哪些節點包含空白字元。純空白節點會標有“whitespace”標籤。
在 DOM 中保留空白字元在很多方面都很有用,但也可能使某些佈局更難實現,並可能給希望遍歷 DOM 節點的開發者帶來問題。我們將在稍後的解決空白節點的常見問題一節中探討這些問題和一些解決方案。
CSS 如何處理空白?
當 DOM 傳遞給 CSS 進行渲染時,空白在預設情況下會被大量剝離。這意味著你的程式碼格式對終端使用者是不可見的——在元素周圍和內部建立空間是 CSS 的工作。
<!doctype html>
<h1> Hello World! </h1>
此原始碼在 doctype 之後包含幾個換行符,在 <h1> 元素之前、之後和內部有大量空格字元。但瀏覽器會忽略這些空格,只顯示“Hello World!”,就好像這些字元根本不存在一樣:
CSS 會忽略大部分(但不是全部)空白字元。在此示例中,“Hello”和“World!”之間的一個空格在頁面渲染到瀏覽器中時仍然存在。CSS 使用特定的演算法來決定哪些空白字元與使用者無關,以及如何移除或轉換它們。我們將在接下來的幾節中解釋這個處理過程。
摺疊和轉換
讓我們看一個例子。為了讓空白字元更清晰,我們還添加了一條註釋,用 ◦ 表示所有空格,用 ⇥ 表示所有制表符,用 ⏎ 表示所有換行符:
<h1> Hello
<span> World!</span> </h1>
<!--
<h1>◦◦◦Hello◦⏎
⇥⇥⇥⇥<span>◦World!</span>⇥◦◦</h1>
-->
此示例在瀏覽器中的渲染效果如下:
<h1> 元素包含:
- 一個文字節點(由一些空格、單詞“Hello”、一個換行符和一些製表符組成)。
- 一個行內元素(
<span>,包含一個空格和單詞“World!”)。 - 另一個文字節點(在
<span>之後有一個製表符和一些空格)。
因為這個 <h1> 元素只包含行內元素,所以它建立了一個行內格式化上下文。這是瀏覽器引擎用於在頁面上排列內容的幾種佈局渲染上下文之一。
在這個行內格式化上下文中,空白字元的處理方式如下:
備註: 這個演算法可以透過 white-space-collapse 屬性(或其簡寫屬性 white-space)進行配置。我們首先假設其預設值為(white-space-collapse: collapse),然後看看不同的屬性值如何影響這個演算法。
-
首先,緊接在換行符之前和之後的所有空格和製表符都會被忽略。所以,如果我們以前面的示例標記為例:
html<h1>◦◦◦Hello◦⏎ ⇥⇥⇥⇥<span>◦World!</span>⇥◦◦</h1>……並應用這第一條規則,我們得到:
html<h1>◦◦◦Hello⏎ <span>◦World!</span>⇥◦◦</h1> -
接著,連續的換行符會被摺疊成一個換行符。在這個例子中我們沒有這種情況。
-
接下來,透過移除所有剩餘的換行符,原始碼中的行會被合併成單行。根據換行符前後的上下文,它們要麼被轉換成空格(U+0020),要麼被直接移除。兩者之間的具體選擇取決於瀏覽器和語言。在我們這個英文例子中(單詞之間用空格分隔),我們可以預期所有的換行符都會被“轉換”成空格。所以我們最終得到:
html<h1>◦◦◦Hello◦<span>◦World!</span>◦◦◦</h1>值得注意的是,在沒有詞分隔符的語言中,比如中文,行與行之間合併時沒有空格。所以:
html<div>你好 世界</div>可能會被渲染為“你好世界”,中間沒有任何空格,這取決於瀏覽器的啟發式演算法。
-
接下來,所有制表符都會被轉換成空格,所以例子變成:
html<h1>◦◦◦Hello⏎ <span>◦World!</span>◦◦◦</h1> -
之後,緊跟在另一個空格之後的任何空格(即使跨越兩個獨立的行內元素)都會被忽略,所以我們最終得到:
html<h1>◦Hello◦<span>World!</span>◦</h1>
這就是為什麼訪問網頁的人會看到“Hello World!”這個短語漂亮地寫在頁面頂部,而不是一個奇怪縮排的“Hello”後面跟著一個更奇怪縮排的“World!”在下一行。
在這些步驟之後,瀏覽器會處理換行和雙向文字,我們在此忽略。注意,在 <h1> 開始標籤之後和 </h1> 結束標籤之前仍然有空格,但這些在瀏覽器中不會被渲染。我們接下來在佈局每一行時處理這個問題。
不同的 white-space-collapse 值會跳過此演算法的不同步驟:
preserve和break-spaces:整個演算法被跳過,不發生任何空白摺疊或轉換。preserve-breaks:跳過步驟 2 和 3,保留換行符。preserve-spaces:整個演算法被跳過,並替換為將每個製表符或換行符轉換為一個空格的單一步驟。
簡而言之,不同的空白字元會按以下方式被摺疊和轉換:
- 製表符通常被轉換為空格。
- 如果要摺疊分段符:
- 連續的分段符序列會摺疊成單個分段符。
- 在使用空格分隔單詞的語言(如英語)中,它們被轉換為空格;而在不使用空格分隔單詞的語言(如中文)中,它們被完全移除。
- 如果要摺疊空格:
- 分段符之前或之後的空格或製表符被移除。
- 連續的空格序列會摺疊成單個空格。
- 當保留空格時,連續的空格序列被視為不換行,但它們會在每個序列的末尾進行軟換行——也就是說,下一行總是從下一個非空格字元開始。然而,對於
break-spaces值,軟換行可能在每個空格之後發生,因此下一行可能以一個或多個空格開始。
修剪和定位
在行內和塊級格式化上下文中,元素都以行為單位進行佈局。在行內格式化上下文中,行是透過文字換行建立的。而在塊級格式化上下文中,每個塊級元素自己形成一行。在佈局每一行時,空白會被進一步處理。讓我們透過一個例子來解釋這是如何工作的。
在這個例子中,和之前一樣,我們用註釋標記了空白字元。我們有三個只包含空白的文字節點:一個在第一個 <div> 之前,一個在兩個 <div> 之間,還有一個在第二個 <div> 之後。
<body>
<div> Hello </div>
<div> World! </div>
</body>
<!--
<body>⏎
⇥<div>⇥Hello⇥</div>⏎
⏎
◦◦◦<div>◦◦World!◦◦</div>◦◦⏎
</body>
-->
渲染效果如下:
這個例子中的空白處理如下:
備註: 這個演算法可以透過 white-space-collapse 屬性(或其簡寫屬性 white-space)進行配置。我們首先假設其預設值為(white-space-collapse: collapse),然後看看不同的屬性值如何影響這個演算法。
-
首先,空白會像上一節看到的那樣被摺疊,將這個:
html<body>⏎ ⇥<div>⇥Hello⇥</div>⏎ ⏎ ◦◦◦<div>◦◦World!◦◦</div>◦◦⏎ </body>……變成這個:
html<body>◦<div>◦Hello◦</div>◦<div>◦World!◦</div>◦</body>然後根據
<body>建立的塊級格式化上下文來佈局各行。在這個例子中,<body>的五個子節點每一個都被佈局為單獨的一行。(此程式碼塊中的每一行代表渲染布局中的一行,而不是我們原始 HTML 程式碼中的一行)html<body> ◦ <div>◦Hello◦</div> ◦ <div>◦World!◦</div> ◦ </body>注意,如果行變得太長,每一行可能會換行並建立更多的行。實際上,瀏覽器是在佈局行的同時確定行的內容的。我們將跳過文字換行工作原理的部分。
-
行首的連續空格被移除,所以例子變成:
html<body> <div>Hello◦</div> <div>World!◦</div> </body> -
此時保留的每個製表符都會根據
tab-size進行渲染。這隻可能在white-space-collapse設定為preserve或break-spaces時發生,因為所有其他設定都會把製表符變成空格。 -
行尾的連續空格被移除,所以上面變成:
html<body> <div>Hello</div> <div>World!</div> </body>
我們現在有的三個空行在最終佈局中不會佔據任何空間,因為它們不包含任何可見內容。所以我們最終只會有兩行在頁面上佔用空間。瀏覽網頁的人會看到“Hello”和“World!”在兩條獨立的行上,正如你所期望的兩個 <div> 的佈局方式。瀏覽器基本上忽略了 HTML 程式碼中包含的所有空白。
不同的 white-space-collapse 值會跳過此演算法的不同步驟:
preserve和break-spaces:除了步驟 3 外,整個演算法都被跳過,所以不發生空白摺疊或轉換。preserve-spaces:整個演算法都被跳過,所以行首和行尾的空白字元被保留。preserve-breaks:與collapse值應用相同的演算法。
DOM API 如何處理空白?
如前所述,空白在 DOM 中是保留的。這意味著如果你獲取 Node.textContent,你將得到與你在 HTML 原始碼中編寫的文字內容完全一致的內容;如果你獲取 Node.childNodes,你將得到所有的文字節點,包括那些只包含空白的節點。
並非所有 DOM API 都會保留空白;有些 API 的設計就是為了處理渲染後的文字。例如,HTMLElement.innerText 返回的是與渲染結果完全一致的文字,所有空白都被摺疊和修剪。 Selection.toString() 返回的是貼上時的文字,這通常意味著空白被摺疊。然而,在 Firefox 中(如前述摺疊和轉換一節所述,它會摺疊中文字元間的空白),被摺疊的空白在 toString() 返回的字串和貼上的文字中仍然被保留。
<div id="test">Hello world!</div>
const div = document.getElementById("test");
console.log(div.textContent); // " Hello\n world!\n"
console.log(div.innerText); // "Hello world!"
const selection = document.getSelection();
selection.selectAllChildren(div);
console.log(selection.toString()); // "Hello world!"
解決空白節點的常見問題
由於 CSS 的處理規則,空白節點對網站訪問者是不可見的,但它們可能會干擾某些依賴於 DOM 確切結構的佈局和 DOM 操作。讓我們來看一些常見的問題以及如何解決它們。
行內元素和行內塊元素之間的空白處理
讓我們看一個關於空白節點的佈局問題:行內元素和行內塊元素之間的空格。正如我們之前看到的行內元素和塊級元素一樣,大多數空白字元被忽略,但像空格這樣的單詞分隔符仍然存在。最終進入佈局的額外空白有助於分隔句子中的單詞。
對於 inline-block 元素,情況變得更有趣:這些元素外部表現得像行內元素,內部則像塊級元素。(它們常用於顯示更復雜的 UI 元件,並排在同一行上,例如導航選單項。)任何相鄰的行內或行內塊元素之間的空白都會在佈局中產生空格,就像文字中單詞之間的空格一樣。(這可能會讓開發者感到驚訝,因為它們是塊級元素,而塊級元素通常不會顯示額外的空格。)
思考這個例子(和之前一樣,我們在 HTML 程式碼中加入了註釋來顯示空白字元):
.people-list {
list-style-type: none;
margin: 0;
padding: 0;
}
.people-list li {
display: inline-block;
width: 2em;
height: 2em;
background: #ff0066;
border: 1px solid;
}
<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 開發者工具的 HTML 檢查器可以高亮文字節點,並向你精確顯示元素所佔的區域。如果你懷疑有多餘的外邊距或意外的空白導致了間隙,這將非常有用。

有幾種方法可以解決這個問題:
-
使用 Flexbox 來建立水平專案列表,而不是嘗試
inline-block解決方案。Flexbox 為你處理間距和對齊,絕對是首選方案:cssul { list-style-type: none; margin: 0; padding: 0; display: flex; } -
如果你需要依賴
inline-block,你可以將列表的font-size設定為0。這隻在塊的大小不是用em單位時才有效(因為em是基於font-size的,塊的大小最終也會是0)。在這裡使用rem單位會是一個不錯的選擇:cssul { font-size: 0; /* … */ } li { display: inline-block; width: 2rem; height: 2rem; /* … */ } -
或者,你可以在列表項上設定負外邊距:
cssli { display: inline-block; width: 2rem; height: 2rem; margin-right: -0.25rem; } -
你也可以透過避免
<li>項之間出現空白節點來解決這個問題:html<li> ... </li><li> ... </li>
在 DOM 中處理空白
如前所述,空白在渲染時會被摺疊和修剪,但在 DOM 中是保留的。這在嘗試用 JavaScript 進行 DOM 操作時可能會帶來一些陷阱。例如,如果你有一個父節點的引用,並想用 Node.firstChild 來操作它的第一個元素子節點,父節點開始標籤後的一個意外的空白節點會給你錯誤的結果。該文字節點會被選中,而不是你想要的目標元素。
再舉一個例子,如果你想對一部分元素根據它們是否為空(沒有子節點)來做某些操作,你可以使用 Node.hasChildNodes()。但如果這些元素中任何一個包含了文字節點,你可能會得到錯誤的結果。
以下 JavaScript 程式碼展示了幾個函式,可以更容易地處理 DOM 中的空白:
/**
* 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 isAllWs(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 `Node` interface.
* @return `true` if the node is:
* 1) A `Text` node that is all whitespace
* 2) A `Comment` node
* and otherwise `false`.
*/
function isIgnorable(nod) {
return (
nod.nodeType === 8 || // a comment node
(nod.nodeType === 3 && isAllWs(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 The closest previous sibling to `sib` that is not
* ignorable according to `isIgnorable`, or `null` if
* no such node exists.
*/
function nodeBefore(sib) {
while ((sib = sib.previousSibling)) {
if (!isIgnorable(sib)) {
return sib;
}
}
return null;
}
/**
* Version of `nextSibling` that skips nodes that are entirely
* whitespace or comments.
*
* @param sib The reference node.
* @return The closest next sibling to `sib` that is not
* ignorable according to `isIgnorable`, or `null`
* if no such node exists.
*/
function nodeAfter(sib) {
while ((sib = sib.nextSibling)) {
if (!isIgnorable(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 The last child of `sib` that is not ignorable
* according to `isIgnorable`, or `null` if no
* such node exists.
*/
function lastChild(par) {
let res = par.lastChild;
while (res) {
if (!isIgnorable(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 The first child of `sib` that is not ignorable
* according to `isIgnorable`, or `null` if no
* such node exists.
*/
function firstChild(par) {
let res = par.firstChild;
while (res) {
if (!isIgnorable(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 dataOf(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 屬性和內容。
let cur = firstChild(document.getElementById("test"));
while (cur) {
if (dataOf(cur.firstChild) === "This is the third paragraph.") {
cur.className = "magic";
cur.firstChild.textContent = "This is the magic paragraph.";
}
cur = nodeAfter(cur);
}