toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
20 stars 1 forks source link

细谈空白符 #313

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

前言

作为 Web 前端开发,你一定听到过类似「HTML 会将多个空格合并为一个」的说法,你有深入了解过它是如何折叠(合并)的吗?

我们平常编写的源码文件,通常会包含与最终呈现无关的格式。比如,项目采用 Space 或 Tab 的缩进风格,里面可能包含了空格、制表符、换行符等。浏览器在渲染源文件的时候,会根据一定的规则处理(保留或折叠)这些字符,开发者也可以通过 CSS 属性(white-space)控制其渲染规则。

在此之前,我们要充分了解空白符是什么,有哪些?

空白符

空白符(Whitespace)是不可见,但可能会占据一定空间,可在排版中提供水平或垂直空间的一类字符

比如空格、制表符、换行符等。空白符在各类编程语言中可作为分割 Token 的标识。

空白符 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
Space(普通空格) U+0020 32 20  
Horizontal tabulation(水平制表符) HT \t U+0009 9 9 	
Line Feed(换行符) LF \n U+000A 10 0A 

Vertical tabulation(垂直制表符) VT \v U+000B 11 0B 
Form Feed(分页符) FF \f U+000C 12 0C 
Carriage Return(回车符) CR \r U+000D 13 0D 

其中垂直制表符、分页符可能少见一些。在 ASCII 编码也没有专门的字符来表示垂直制表符。

在正则表达式中,可通过 \s 来匹配一个除普通空格之外的空白符(也就是 \n\r\f\t\v),相当于 /[\n\r\f\t\v]/

空格

通常,我们会使用键盘上的 Space 键输入空格(也就是普通空格)。

除此之外,表示空格的字符还有很多种。

名称 HTML 实体 描述
Space &32; 普通空格,键盘 Space 键产生的空格。
Non-breaking space &npsp; 半角不换行空格,其占据的宽度受字体影响明显。区别于普通空格,在 macOS 上可通过「Alt + Space」打出一个 Non-breaking space。
En space   半角空格,表现为 1/2 个中文的宽度,基本不受字体影响。
Em space   全角空格,表现为 1 个中文的宽度,基本不受字体影响。
Thin space   宅空格,顾名思义其占据的宽度比较小,为 em 的 1/6 宽。
Zero-width space ​ 零宽空格,是一种不可见、不可打印的 Unicode 字符。
Zero-width joiner ‍ 零宽连字,是一个控制字符,可使得两个本不会发生连字的字符产生连字效果。
Zero-width non-joiner ‌ 零宽不连字,是一个控制字符,用于抑制本来会发生的连字使得其以原本的字符来绘制。

请注意,只有「普通空格」才算作空白符。

在 HTML 中可发生折叠的空格,也只有普通空格才会。

回车符与换行符

可能还有一部分同学还分不清「回车」和「换行」区别的。

随着计算机的快速发展和普及,大家常说的「换行」或「回车」操作所表示的意思,相比之前,其实早就发生了变化。

通常表示为「移动至下一行行首」,也就是按下键盘上的 Return/Enter 键所做的事情。不仅如此,如今的应用程序功能越来越强大,在按下 Return 键后,它甚至可以自动插入指定数量的制表符或空格以快速实现文本对齐(具体取决于操作系统或者应用程序的实现)。

关于「回车」和「换行」的词源,它们源起自打字机,后应用于计算机。在打字机中概念如下:

在早期,机械打字机处理字符的效率很低,为了避免丢失字符,须先执行 CR 操作,再执行 LF 操作。现代意义上的换行相当于打字机中「回车 + 换行」的组合。

作为一名软件开发者,本着严谨的态度,习惯地将 CR 称为「回车符」,将 LF 称为「换行符」还是有必要的。

计算机中的两种换行符

很多同学都知道,目前主流操作系统中表示换行的字符,有 \r\n\n 两种。原因是当时存储非常昂贵,有些人认为用 CR+LF 两个字符表示换行过于浪费,因此产生了分歧。

操作系统 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
Windows CR LF \r\n U+000D U+000A 13 10 0D 0A 

Unix, Linux, OS X, macOS LF \n U+000A 10 0A 

classic Mac OS CR \r U+000D 13 0D 
More...

请注意,classic Mac OS 止于 2001 年发布的 Mac OS 9,再后来的 OS X、macOS 的换行符为 \n

既然存在两种换行符,对我们会产生什么影响?

如果软件层面没做好适配,Windows 文件在 Unix/Unix-like 操作系统上打开可能会变成一行。而 Unix/Unix-like 文件在 Windows 操作系统上打开,每行结尾可能会多一个 ^M 符号。

但基本不用担心,已经 2023 年了,大多数软件都可以很好地兼容两种换行符。对于开发者来说,很多 IDE/Editor 都可以设置默认换行符的,也有各种 Linter 工具(比如 EditorConfig)自动处理。通常,更多人选择将默认换行符设为 LF,也就是 \n

制表符

制表符就是键盘上 Tab 键输入的字符,用于将光标前进到下一个制表位(Tab stop)。

它同样源自打字机,在当时如果要打出一个表格等内容时,需要使用大量的空格键等,为此人们发明了一个类似现代的 Tab 键的东西,按下该键可以前进到下一个制表位。

随着计算机快速发展,现代的应用程序也足够聪明,除了实现打字机那些基本功能之外,还提供了一些具有对其属性的制表位(比如 Microsoft Word 等),动态制表位、补全(Tab completion)等能力。比如,代码编辑器中 Code Snippet 的占位符可通过 Tab 键快速切换,就是利用了动态制表位实现的。

平常写代码过程中,按下回车键快速切换至下一行指定位置,相当于「CR + LF + Tab」。如果项目中指定了类似 useTab=false, tabSize=2 的风格要求,在按下 Tab 键时,应用程序会根据配置替换为指定数量的空格实现快速对齐效果(取决于应用程序的实现)。

通常 Tab 键产生的字符为水平制表符(Horizontal tabulation, HT),除此之外,还有垂直制表符(Vertical tabulation, VT),用于打印或者 Word 排版等场景。

制表符 简称 转义字符 码位 十进制值 十六进制值 HTML 实体
Horizontal tabulation(水平制表符) HT \t U+0009 9 9 	
Vertical tabulation(垂直制表符) VT \v U+000B 11 0B 

有些标准中垂直制表符也称为 Line tabbulation。

如无特殊说明,下文提到的制表符(Tab)均表示水平制表符(U+0009)。

HTML 实体

在 HTML 中有些字符是预留的。比如小于号 < 和大于号 >,如果在源码中直接键入,会被浏览器误认为它们是标签。为了正确地显示保留字符、难以用键盘输入的特殊字符,需要用 HTML 实体表示。

HTML 实体是以 & 开头,以 ; 结束的文本,中间可以是实体名称(Entity Name),也可以是实体数字(Entity Number)。

比如,小于号 <,其实体名称是 lt,实体数字为 60(对应的 ASCII 编码值),所以它有 &lt;&#60;&#060; 三种表示方法。

以下列出对应的 HTML 实体:

名称 实体名称 实体数字
Space &#32;
Non-breaking space &nbsp; &#160;
En space &ensp; &#8194;
Em space &emsp; &#8195;
Thin space &thinsp; &#8201;
Zero-width space &ZeroWidthSpace; &#8203;
Zero-width non-joiner &zwnj; &#8204;
Zero-width joiner &zwj; &#8205;
Horizontal tabulation &Tab; &#9;
Line feed &#10;
Vertical tabulation &#11;
Form feed &#12;
Carriage Return &#13;

HTML 实体编码在页面上展示取决于页面中所设字符集。常见的页面乱码,就是因为字符集不一致导致的。

相关链接:

CSS 空白符处理规则

写在前面

在 CSS 中,规则指出,CSS 的空白处理仅影响以下三种空白符

如无特殊说明,下文中提到可折叠(忽略)的空白符是指这三种。

这三个空白符可称为 document white space characters,本文称作「文档空白符」。

但在 HTML 中表示 Newlines(换行)的字符包含换行符(LF, U+000A)、回车符(CR, U+000D)以及成对的 CR+LF。

那么,为什么 CSS 不处理回车符(CR, U+000D)呢?

首先,浏览器渲染 HTML 的流程大致包括:HTML 解析得到 DOM Tree,CSS 解析得到 CSSOM Tree,然后 DOM Tree 和 CSSOM Tree 结合生成 Render Tree,接着 Layout 阶段将 Render Tree 的所有节点分配空间确定坐标,最后 Painting 阶段再将所有阶段绘制出来。而 CSS 起作用发生在解析之后,因此 CSS 并不直接作用于 HTML,而是 DOM Tree。

其次,在 HTML 解析为 DOM Tree 的过程中,每个换行序列字符(newline sequence)都会做规范化处理为 Line Feed(U+000A),此时 Line Feed 被称为 segment break。

To normalize newlines in a string, replace every U+000D CR U+000A LF code point pair with a single U+000A LF code point, and then replace every remaining U+000D CR code point with a U+000A LF code point.

也就是在规划化处理时,先将成对的 U+000D CR U+000A LF 替换为一个 U+000A LF,然后将剩余的 U+000D CR 也替换为一个 U+000A LF。因此对 CSS 而言,它压根感受不到 U+000D 的存在。

请注意,如果 HTML 文档中存在回车符(CR, U+000D),它不会因为上述规划化处理而凭空消失,通过 Element.innerHTML 等方式仍然可以看得到其转义字符 \r 的。

相关链接:

white-space 属性

用于控制空白处理的 CSS 属性是 white-space,其默认值是 normal

该属性指定了两件事:

white-space 设为 normalnowrappre-line,那么文档空白符被认为是可折叠(collapsible)的。这种折叠现象可以称为空白符折叠(white space collapsing)。在空白处理过程中,没有被移除或折叠而保留下来的空白符,称为保留空白符(preserved white space)。

white-space New Lines Spaces and Tabs Text Wrapping End-of-line spaces End-of-line other space separators
normal 折叠 折叠 换行 移除 悬挂(Hang)
pre 保留 保留 不换行 保留 不换行
nowrap 折叠 折叠 不换行 移除 悬挂
pre-wrap 保留 保留 换行 悬挂 悬挂
break-spaces 保留 保留 换行 换行 换行
pre-line 保留 折叠 换行 移除 悬挂

Unicode® Standard Annex #44 定义了一些包括 U+1680、U+2000 ~ U+200A 在内的 12 个空格分隔符(Space separator)。在 CSS 规范文档中,将除了普通空格(U+0020)和不换行空格(U+00A0)之外的空格分隔符称为其他空格分隔符(other space separators)。

white-space: normal 就像循规蹈矩的倔牛,它不管你是否有显示指定换行符(Line Feed),它只会在该换行的时候(有软换行机会)才进行换行,比如块容器空间不够了,而且有软换行机会的时候才进行换行。同时为了排版更美观,它会将连续的空白符折叠成一个。但为了满足各种各样的排版需求,才有了其他值以方便换行、或者保留多个空格等。

关于软换行机会:

以中文为例,每个空格、中文字后面都存在一个软换行机会。还有中文标点符号有一种「避头」或「避尾」特性,它们通常不会在行首或行尾出现,这点可以通过 line-break 来改变。

以英文和数字为例,软换行机会通常是空格,由连续的数字或字母组成的字符串,被认为是一个单词,要不就从这个字符串开头就折行显示,要不就是在字符串结束后的空格处才会换行(即使内容溢出)。比如 <div>11111...</div>(这里有足够多的 1 组成),默认情况下它只会单行显示,原因就是它中间没有软换行机会。

对于一些不以空格或标点符号分割的语言,这里就不展开叙述了。

空白处理规则

分为三个阶段:

以下三个阶段中提到的 Line Feed,在规范中时用 segment break 表示的。如果 Document language 未定义 segment break 或 newline sequence 的话,那么文本中的 Line Feed(U+000A)被视为 segment break。因此下面才直接称为 Line Feed。

折叠与转换(Collapsing and Transformation)

对于内联格式上下文(Inline formatting contexts, IFC)中的每个内联元素,空白序列在换行和双向重新排序(针对诸如阿拉伯语等从右到左书写的语言)之前,按如下方式进行处理:

如何理解内联格式上下文中的每个内联元素(包含匿名内联元素)?

<p>Several <em>emphasized words</em> appear
<strong>in this</strong> sentence, dear.</p>

<p> 是一个块级元素,里面包含了 5 个内联元素,其中 3 个是匿名的:

任何直接包含在块级元素内(而不是内联元素内)的文本都视为匿名内联元素(Anonymous inline element)。

裁剪与定位(Trimming and Positioning)

接着,整个块(block)被渲染。内联元素(inlines)按照双向重排的规则进行布局,并根据 white-space 属性指定的方式进行换行。在逐行布局的过程中:

  1. 移除行开头的一系列可折叠 Space。
  2. tab-size0,保留的 Tab 不进行渲染。否则,每个保留的 Tab 都呈现为水平移动,使得下一个字形的起始边缘与下一个制表位(Tab Stop)对齐。
  3. 若在一行的末尾有一系列可折叠的 Space,它们会被移除。同时,如果在行末尾有 OGHAM SPACE MARK(U+1680)字符,并且它的 white-spacenormalnowrappre-line,那么也会将该字符移除。
  4. 如果在一行的末尾有文档空白符(document white space characters)、其他空格分隔符(other space separators)和保留的 Tab:
    • 如果 white-spacenormalnowrappre-line,浏览器必须(无条件地)挂起(hang)该字符。
    • 如果 white-spacepre-wrap,浏览器必须挂起该字符,除非该字符后跟了一个 forced line break,这时浏览器必须(有条件地)挂起该字符。此外,浏览器还可以在该字符的宽度超出限制时,将其字符宽度进行视觉上的折叠。
    • 如果 white-spacebreak-spaces,Space、Tab 和 other space separators 被视为与其他可见字符相同,它们不能被挂起,也不能折叠其前进宽度。

换行转换规则(Segment Break Transformation Rules)

white-spaceprepre-wrapbreak-spacespre-line 时,Line Feed 不可折叠,而是转换为保留的 Line Feed(preserved line feed, U+000A)。

white-space 为其他值,Line Feed 是可折叠的,并按如下方式进行折叠:

  1. 首先,移除紧跟在另一个可折叠的 Line Feed 之后的任何可折叠 Line Feed。
  2. 然后,根据中断前后的上下文,任何剩余的 Line Feed 要么被转换为 Space,要么被移除。具体取决于浏览器的定义。请注意:在评估此上下文之前,空白处理规则已经移除了 Line Feed 周围的所有 Tab 和 Space。

换行与单词边界

当换行仅在允许的换行点处执行,称为 soft wrap opportunity(软换行机会)。对于大多数书写系统来说,在没有使用连字的情况下,软换行只会在单词之间发生。对于使用空格或标点符号分隔单词的书写系统,软换行的位置可以通过这些字符来确定。

尽管 CSS 没有用于软换行机会的属性,但可以通过诸如 line-breakwork-breakhyphensoverflow-wrap/work-wrap 属性去指定改变换行点。

换行细节

在确定 line break 时:

work-break 属性

此属性指定字母之间的软换行机会,即它是“正常”且允许换行的位置。

line-break 属性

此属性指定元素内应用的换行规则的严格性:尤其是换行如何与标点符号(punctuation)和符号(symbols)交互。

请注意:line-break: anywhere 只允许在 white-space 设为 break-spaces 时,将行末的保留空格换行到下一行,因为在其他情况下:

line-break: anywhere 对保留空格产生影响时(在 white-space: break-spaces 下),它允许在连续空格序列的第一个空格之前进行换行,而独立使用 white-space: break-spaces 则不具备这个功能。

overflow-wrap 和 word-wrap 属性

word-wrap 属性原本属于微软扩展的一个非标准、无前缀的属性,后来在大多数浏览器中以相同的名称实现。目前它已被更名为 overflow-wrapword-wrap 相当于其别称。若考虑到兼容性,还是用 word-wrap 吧。

此属性指定浏览器是否可以在一行内不允许的位置换行以防止溢出,当 otherwise-unbreakable 的字符串太长而无法放入行盒子时。它仅在 white-space 允许换行时有效。

行内元素标签之间的空白符

两个块级元素之间、块级元素与行内元素之间的普通空白符会被忽略。但行内元素之间的普通空白符就要注意了。

举个例子,我们可能会经常遇到类似的排版:

<div class="list">
  <div class="item">1</div>
  <div class="item">2</div>
  <div class="item">3</div>
</div>
.list {
  background: #f00;
}

.item {
  font-size: initial;
  display: inline-block;
  background: #eee;
  padding: 4px 10px;
}

给每个 item 设为 display: inline-block,使得它既能像块级元素那样设置边距宽高等,又能像行内元素那样排列在一行中,兼容性还好。但问题却是行内元素之间有间隙,如下:

原因很简单,我们在源码中输入了空格、换行符(或是格式化工具产生的),可以打印一下 list 元素的 HTML 结构:

\n  <div class="item">1</div>\n  <div class="item">2</div>\n  <div class="item">3</div>\n

本文不讨论采用 flex 等布局方式。

前导和尾随的普通空白符被忽略了,但中间的则被解析为普通空格,而普通空格占据的宽度又跟字体大小有关系,因此就产生了缝隙。

诸如外部元素设为 font-size: 0、改变布局方式等,都能一定程度上解决问题,更多可看张鑫旭大佬的文章《去除 inline-block 元素间间距的 N 种方法》。

问题的本质是行内元素之间的空白符是会被解析出来的,比如 <a>link1</a> <a>link2</a> 两个 <a> 标签之间的空格是无法忽略的。那么彻底解决问题的方式是在源码中干掉空格,比如:

<div class="list">
  <div class="item"
    >1</div
  ><div class="item"
    >2</div
  ><div class="item">3</div>
</div>

虽然这种写法是「丑」的,可读性不好,但它才是终极解决方法。

难道我们开发的过程中真的要这样写?

并不是,我们现在享受着各种框架所带来的便利,各种编译、压缩工具横行,它们帮我们做了很多「脏活」,在编译出 HTML 或者动态更新 DOM 之前已经把标签之间的空格、换行符都去掉了。不信你可以用 React 等框架还原上述示例。

这也是只注重框架使用,而忽视基础知识的一个反映,它掩盖了一些细节,容易让使用者忽略掉了事物的本质。

htmlWhitespaceSensitivity

我们知道在 Prettier 中有一个 htmlWhitespaceSensitivity 选项,就是用来指定如何格式化 HTML 文件的。

通过前面的内容,我们可以知道 1<b> 2 </b>3 将会被解析为 1 2 3(中间是有空格的),但如果我们本地的格式化程序将源码格式化为 1<b>2</b>3(文本的前导和尾随空格被移除),那么解析结果就是 123(中间没有空格),这可能是非预期行为。

举个例子,以下元素不能安全地格式化:

<a href="https://prettier.io/">Prettier is an opinionated code formatter.</a>

由于 printWidth 的存在,它有可能会被格式化为:

<a href="https://prettier.io/">
  Prettier is an opinionated code formatter.
</a>

这样页面呈现的链接左右可能会出现空格。

htmlWhitespaceSensitivity 提供了三个可选值:

<!-- <span> is an inline element, <div> is a block element -->

<!-- input -->
<span class="dolorum atque aspernatur">Est molestiae sunt facilis qui rem.</span>
<div class="voluptatem architecto at">Architecto rerum architecto incidunt sint.</div>

如果是 "htmlWhitespaceSensitivity": "css",格式化结果如下。即块级元素的前导和尾随空白符可以忽略,因此换行显示。而行内元素前导或尾随空白符是影响显示的不应忽略。

<!-- output -->
<span class="dolorum atque aspernatur"
  >Est molestiae sunt facilis qui rem.</span
>
<div class="voluptatem architecto at">
  Architecto rerum architecto incidunt sint.
</div>

如果是 "htmlWhitespaceSensitivity": "strict",格式化结果如下。

<!-- output -->
<span class="dolorum atque aspernatur"
  >Est molestiae sunt facilis qui rem.</span
>
<div class="voluptatem architecto at"
  >Architecto rerum architecto incidunt sint.</div
>

如果是 "htmlWhitespaceSensitivity": "ignore",格式化结果如下。

<!-- output -->
<span class="dolorum atque aspernatur">
  Est molestiae sunt facilis qui rem.
</span>
<div class="voluptatem architecto at">
  Architecto rerum architecto incidunt sint.
</div>

在全局指定处理方式之后,还支持在局部添加 <!-- display: block --> 来覆盖全局配置。更多请看 Whitespace-sensitive formatting

<pre><code>

如果要使得前导、尾随或中间的普通空格等如源码般呈现在页面中,可以使用 <pre> 元素。

该元素中的文本通常按照原文件中的编排,以等宽字体的形式展现出来,文本中的空白符(比如空格和换行符)都会显示出来。但紧跟在 <pre> 开始标签后的换行符会被省略。

<pre>
  *
  *    ┏┓   ┏┓
  *   ┏┛┻━━━┛┻┓
  *   ┃       ┃
  *   ┃   ━   ┃
  *   ┃ ┳┛ ┗┳ ┃
  *   ┃       ┃
  *   ┃   ┻   ┃
  *   ┃       ┃
  *   ┗━┓   ┏━┛
  *     ┃   ┃
  *     ┃   ┃
  *     ┃   ┗━━━┓
  *     ┃       ┗┓
  *     ┃       ┏┛
  *     ┗┓┓┏━┳┓┏┛
  *      ┃┫┫ ┃┫┫
  *      ┗┻┛ ┗┻┛
  *
  *   Code is far away from bug with the animal protecting.
  *
</pre>
pre {
  margin: 0;
  border: 1px solid #f00;
}

以上示例有普通空格、换行符、半角空格和全角空格等。在 Markdown 文档常用的 Code Block 也是用 <pre> 进行渲染的。

类似的,还有一个 <code> 元素,表示呈现一段计算机代码。默认情况下,它以浏览器的默认等宽字体显示。但它与 <pre> 不同的是,连续多个空白符仅算作一个。

<p>Regular text. <code>This is code.</code> Regular text.</p>

同样地,在 Markdown 中单行代码块就是用 <code>(右键检查元素试试?)元素进行渲染的,但通常网站会给该元素设置背景颜色使得更加突出。

React 如何表示换行

由于 JSX 在解析的时候,会将文本中的换行符转换为空格(详见)。

JSX removes whitespace at the beginning and ending of a line. It also removes blank lines. New lines adjacent to tags are removed; new lines that occur in the middle of string literals are condensed into a single space.

因此,以下几种写法的表现是一样的,并不会表现出换行,即使给设置上 white-space: pre-wrap

<div>Hello World</div>

<div>
  Hello World
</div>

<div>
  Hello
  World
</div>

<div>

  Hello World
</div>

解法一:

function MyComponent() {
  return <div style={{ whiteSpace: "pre-wrap" }}>{'Hello\nWorld'}</div>
}

解法二:

function MyComponent() {
  return (
    <div>
      Hello
      <br />
      World
    </div>
  )
}

解法二:

function MyComponent() {
  return <div style={{ whiteSpace: 'pre-wrap' }}>{'Hello\u000AWorld'}</div>
}

解法三:

function MyComponent() {
  return (
    <div
      dangerouslySetInnerHTML={{
        __html: '<div style="white-space: pre-wrap">Hello&#10;World</div>',
      }}
    />
  )
}

一个有趣的 Issue:Newline having a trailing whitespace character is removed in JSX attribute value #10356

References