基于官方规范系列篇之——HTML 篇(二)HTML 语法介绍了HTML 的语法规范、BOM、字符编码声明、DOCTYPE
大家好呀,我是前端创可贴。
上一章我们一起学习了 HTML 的历史、设计原则和基本介绍,这一章我们再一起来学习一下 HTML 的语法,并且会做出一些延伸,以此来更好的理解语法以及扩展自己的知识储备。
基本组成
一个 HTML 文档必须以指定顺序包含以下部分:
- 可选的字节顺序标记(Byte Order Mark, BOM)字符;
- 任意数量的注释和空格;
- 一个
DOCTYPE
;
- 任意数量的注释和空格;
document element
(这个名字很容易让人误解为document element
就是 document 对象本身,但其实代表的是父元素是 document 对象的元素),用html
元素来表示;
- 任意数量的注释和空格。
HTML 语法中许多字符串(比如元素名称和属性名)对于英文字母是不区分大小写的(case-insensitive),所以要注意一个元素如果 2 个属性名全都小写后是相同的,只有第一个属性生效。
<div id="aaa" ID="bbb">前端创可贴</div>
什么是 BOM
首先来解释一下上面说的字节顺序标记(Byte Order Mark, BOM)是个什么东西。
这个 BOM 可不是我们常说的浏览器的那个 BOM(Browser Object Model),Byte Order Mark (BOM) 是位于 DOCTYPE
之前的一个特殊字符,但它并不是直接写在 HTML 代码里的,而是作为文件的字节序列存在,它存在于文件的编码层面上,是文件的第一个字节序列。它的存在是通过特定的字节序列(例如 UTF-8 中的 EF BB BF
)来标识的,使用十六进制编辑器打开文件,如果文件开头的字节是 EF BB BF
,那么该文件就包含了表示 UTF-8 的 BOM。
BOM 的主要作用是帮助浏览器和其他应用程序自动识别文本文件的编码,尤其是 Unicode 编码(比如 UTF-8)。一些编辑器比如 Visual Studio Code、Notepad++ 等允许在保存文件时可以选择带有 BOM 的编码方式(例如 UTF-8 with BOM)。
我们来用 VS Code 测试一下,用 UTF-8 with BOM 字符编码方式存储文件,并且用 16 进制查看文件内容。首先需要在 VS Code 下载 Hex Editor
插件,然后鼠标右键文件 -> Reopen Editor With -> Hex Editor,即可查看该文件 16 进制文件内容。
源文件是 UTF-8 的格式:
接下来我们将文件字符编码格式修改为 UTF-8 with BOM,然后查看 16 进制内容:
可以看到,UTF-8 with BOM 的字节序正由 EF BB BF
开头。
但是为什么我们开发过程中基本上没见过这个 BOM 呢?因为我们都会用 <meta charset="UTF-8">
在 HTML 代码里显式的指定编码方式,毕竟 BOM 在我们代码里是看不见的。
对于 UTF-16 和 UTF-32,BOM 还可以负责指定字节存储顺序是大端序
(Big-Endian)还是小端序
(Little-Endian)。在各种计算机体系结构中,对于多字节的数据,这多个字节该以什么顺序存储呢?对于大端序,数据的高位存储在地址的低端,数据的低位存储在地址的高端;而小端序正好反过来,数据的高位存储在地址的高端,数据的低位存储在地址的低端。对于 UTF-16,如果含有 BOM,大端序的字节序是以 FE FF
开头,小端序是以 FF FE
开头。
以 UTF-16 为例,它是可变长度编码方式,一个字符占 2 或 4 个字节。一个代码单元(Code Unit,一个字符由一到多个代码单元组成)16 位,即 2 个字节,一个字符至少由 2 个字节组成,那就存在这 2 个字节顺序如何存放和解析的问题,所以 BOM 需要指定 UTF-16 的字节存储顺序。
UTF-16 代码单元 16 位即 2 个 字节,最大值为 2^16 - 1 = 65535,加上 0 一个代码单元总共可以表示 65536 个字符,大部分常用的字符都被包含在内,所以大部分常用字符占据 2 个字节。
但是整个 Unicode 字符集个数远远超过 65536。超出 65536 的字符需要用代理对(Surrogate Pairs) 存储,代理对是一对(即 2 个) 16 位的 code unit,表示一个单一字符,此时该字符就占据 4 个字节。
为了避免歧义,代理对的两部分的 code unit 的值都必须在 0xD800
和 0xDFFF
区间,并且这些 code unit 不能编码单一 code unit 的字符。(更准确的说,首位代理(leading surrogate
)也被称为高位代理(high-surrogate
)的 code unit,值在 0xD800
和 0xDBFF
区间,包含两端。尾部代理(trailing surrogate
)也被称为低位代理(low-surrogare
)的 code unit,值在 0xDC00
和 0xDFFF
区间,包含两端)。
而对于 UTF-8,一个代码单元 8 位即 1 个字节,虽然它也是可变长度的字符编码(使用 1-4 个字节),但是它的设计能通过每个字节中的特定位来识别出字节的类型(单字节、起始字节、后续字节),字节顺序对 UTF-8 没有影响:
- 单字节的字符(即标准 ASCII 字符),适用于 Unicode 代码点 U+0000 到 U+007F,使用 1 个字节,最高位为
0
,不存在顺序的问题,格式为:
0xxxxxxx
- 双字节字符,适用于 Unicode 代码点 U+0080 到 U+07FF,使用 2 个字节,第一个字节以
110
开头,第二个字节以10
开头,格式为:
110xxxxx 10xxxxxx
- 三字节字符,适用于 Unicode 代码点 U+0800 到 U+FFFF,使用 3 个字节,第一个字节以
1110
开头,后两个字节以10
开头。
1110xxxx 10xxxxxx 10xxxxxx
- 四字节字符,适用于 Unicode 代码点
U+10000
到U+10FFFF
,使用 4 个字节,第一个字节以11110
开头,后面三个字节以10
开头。
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
可以看到,UTF-8 不管字符是几个字节的长度,每个字节都有一个前缀标识符,可以以字节为单位编码,不受字节顺序的影响。其设计确保了无论字符有多少字节,编码都能被无歧义地解析。每个多字节字符的后续字节都以 10
开头,确保它们不会被误解为新的字符的开始。
由此可见,所有 UTF-8 字符串的字节序列都是独立的,按照顺序读取即可,无需考虑机器的字节序,所以对于 UTF-8 来说 BOM 不是必需的,唯一的作用是用来标识当前字符编码是 UTF-8。
画个图帮助大家理解一下大端序和小端序,我们以在 UTF-16 中占据 4 个字节的字符 𠀀
为例,它的代码点超出了 2 个字节能表示的范围,需要用到代理对,所以占据 4 个字节 D84F DC00
(16 进制中每 2 位代表 1 个字节。D84F
为高位代理,DC00
为低位代理),并且注意,代理对中高位代理和低位代理的字符顺序是分开计算的,然后再将计算得到的高位代理和低位代理按字节序存储到内存中。
- 大端序,数据高位在地址的低端,数据低位在地址的高端,所以由下图可得,大端序时保存的顺序为:
D84F DC00
,和原数据相同。
- 小端序,数据高位在地址的高端,数据低位在地址的低端,所以由下图可得,小端序时保存的顺序为:
4FD8 00DC
。
但是大家注意,BOM 对于 UTF-16 来说,并不是严格必需的,当没有 BOM 时,UTF-16 并没有明确规定如何确定字节序。在这种情况下,解析器一般会基于以下方式来推断字节序:
- 系统默认字节序:有些解析器会使用系统的默认字节序(例如,Windows 系统通常默认小端序)。
- 协议或应用层规范:一些协议或应用(如某些网络协议、文件格式)可能会规定字节序,必须根据这些规定来解析。
- 启发式方法:某些解析器可能会查看文本内容,尝试基于某些常见的 Unicode 代码点(如常用的标点符号或空格字符)推断字节序。
当然为了避免字节序带来的解析问题,对于 UTF-16 来说,最简单可靠的方式,还是老老实实地带上 BOM 吧~
字符编码声明
HTML 的标准字符编码格式是 UTF-8,强烈不建议大家使用 UTF-16 甚至是 UTF-32。如果使用非 UTF-8 的字符编码格式,在表单提交、URL 编码等等情况时可能会出现意外的结果。
除了字符编码要使用 UTF-8,还有一些限制需要注意:
- 字符编码的声明不能使用字符引用(比如空格的字符引用就是
)和字符转义。 - 包含字符编码声明的元素(比如 meta 元素)必须完全在文档的前 1024 字节中。
如果你的 HTML 是通过 meta 元素指定字符编码的(指定字符编码的方式除了 meta 元素还可以使用上面说的 BOM),整个文档只能有这一个指定字符编码的 meta 元素,不能再有一个 meta 元素指定字符编码(请注意不是用来指定字符编码的 meta 元素可以有很多个)。
指定字符编码有以下几种方式:
- 通过 BOM 指定字符编码。
- HTML 文件的 HTTP 响应头 content-type 指定字符编码。
Content-Type: text/html;charset=utf-8
- 文档通过含有 srcdoc 属性的 iframe 元素展示(这种情况就不能再显式指定字符编码了,因为当前文档是外层拥有 iframe 元素文档的一部分,可以看作直接使用了外层文档的字符编码)
<iframe sandbox srcdoc="<p>前端创可贴</p>"></iframe>
- 带有 charset 或者 http-equiv 属性的 meta 标签指定字符编码。
<meta charset="utf-8" />
<!-- 或者 -->
<meta http-equiv="text/html;charset=utf-8" />
那么问题来了,如果我用的全都是 ASCII 字符,我还需要指定字符编码吗?毕竟任何字符编码格式都能正确解析 ASCII 字符。
这种情况字符编码格式还是需要指定的,虽然你的代码都是 ASCII 字符,但是其他情况例如用户通过表单输入的非 ASCII 字符、由脚本生成的 URL 等等,都需要指定字符编码格式。所以不管怎么样,都要通过上面的任意一种方式指定字符编码格式。
DOCTYPE
DOCTYPE(Document Type Declaration,文档类型声明),用来告诉浏览器当前页面的 HTML 版本是多少,确保不同的浏览器对于同一个页面的解析结果是一样的。
对于 HTML 4.01,DOCTYPE 声明引用了一个 Document Type Definition
(DTD,文档类型定义),DTD 定义了一个 XML 文档的结构和合法元素,HTML 4.01 是基于标准通用标记语言(Standard Generalised Markup Language,SGML) 的,所以在 DOCTYPE 声明中引用 DTD 是很有必要的。
HTML 4.01 把 DTD 分为strict
、transitional
以及 frameset
3 种类型,各有各的用处,由于 DTD 限制较多,使用时较不方便,已逐渐被 W3C XML Schema 等所取代。
HTML 4.01 的 DOCTYPE 写法还是比较繁琐的:
- strict HTML 4.01:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
- transitional HTML 4.01:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
- frameset HTML 4.01:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">
对于 HTML5 规范来说,DOCTYPE 由以下部分组成:
- 不区分大小的
<!DOCTYPE
;
- 一个或多个 ASCII 空白字符;
- 不区分大小写的
html
;
- 可选的历史遗留的 DOCTYPE 字符串(用于不能输出带有短 DOCTYPE 即
<! DOCTYPE html>
的 HTML 标记的 HTML 生成器),例如<!DOCTYPE html SYSTEM "about:legacy-compat">
;
- 0 或多个 ASCII 空白字符;
>
符号。
简单来说,就是:
<!DOCTYPE html>
出于历史原因,DOCTYPE 的声明是必需的,如果省略了 DOCTYPE,浏览器可能会使用一种与某些规范不兼容的渲染模式,所以为了让不同的浏览器都能遵循 HTML 规范,能有一样的渲染结果,我们一定要记得声明 DOCTYPE。
ASCII 空白字符
介绍一下上面说到的 ASCII 空白字符(ASCII whitespace),可以是 TAB
、LF
(Line Feed)、FF
(Form Feed)、CR
(Carriage Return)或者空格
,其中 TAB 和空格我们都认识,这个 LF、FF 和 CR 又是什么东西呢?
早期计算机系统常使用电传打字机作为控制台设备,CR 是将打印头回车移动到第一列,LF 是将纸张向上移动,需要使用 CR+LF 字符序列结合起来将打印头定位在新行的开头,相当于是先打印头回到第一列,然后再纸张向上移动,结合起来就是换行的操作了。这个习俗被 Windows 操作系统继承了,所以可以看到 Windows 操作系统的换行符是 CRLF
。
而 Unix、Linux 等系统使用单独的 LF
作为换行符,这就导致 Windows 和类 Unix 操作系统之间造成了冲突,在一个操作系统上编写的文件无法正确格式化或由另一个操作系统解释。所以像 VS Code 这种编辑器就提供了 2 种换行符:
而 FF 是指换页符号,将光标从当前页跳到下一页的顶部,通常这个符号也会产生 CR,即光标在下一页的第一列。
元素
HTML 有 6 大类元素:
-
void elements
(空元素),不包含内容,不需要结束标签,也不需要开始标签自闭合的元素。元素:area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr。这些元素不能有内容,也不能有结束标签,也不需要自闭合。
<br>
<hr>
<input type="text">
如果让这些元素自闭合,也不会有问题,在浏览器中会自动去掉自闭合:
<div>前端创可贴</div>
<hr />
如果给他们加上结束标签,渲染不会有问题,但是如果加上了结束标签还加上了内容,浏览器的渲染就会有问题了:
<div>前端创可贴</div>
<hr>xxx</hr>
<input type="text">yyy</input>
可以看到元素的内容溢出到元素外面去了。
-
the template element
(模板元素),用于在页面加载时不显示的内容,但可以通过 JavaScript 克隆和插入到文档中。元素:template。每个模板元素都有模板内容(template contents),模板内容存储在一个 DocumentFragment 对象里。这个 DocumentFragment 对象又是个啥呢?看到这个 api 你应该就认识了:
document.createDocumentFragment()
,即可创建一个 DocumentFragment 对象,它其实是一个轻量版的 Document 对象,可以在它身上挂一些 DOM 子树,此时并不会影响渲染、引发回流重绘等,说白了就是不会渲染到页面上。可以一次性将 DOM 子树挂到当前的 DOM 树上,仅引发一次回流重绘,而不是分开挂载从而引发多次回流重绘。template 元素里的内容默认是不会挂在 DOM 树上的:
<div>大家好,我是:</div>
<template id="template">
<div>前端创可贴</div>
</template>
从 DOM 结构中也可以看到,template 元素里的内容,会存储在一个 DocumentFragment 对象里。
通过 Javascript 可以获取 template 元素里的内容并且克隆,然后渲染到页面上:
<div>大家好,我是:</div>
<template id="template">
<div>前端创可贴</div>
</template>
<script>
const clone = template.content.cloneNode(true);
template.parentNode.appendChild(clone);
</script>
插句题外话,观察上面代码,可以发现我是直接用 template 变量的,而没有获取 DOM 元素,原因是当 HTML 里某个元素有 id 属性时,可以不用通过 document.getElementById() 这样的方式获取元素,id 属性值已经映射了一个全局变量名,变量的值就是该 DOM 元素。
-
raw text elements
(原始文本元素),其内容被视为原始文本的元素,浏览器不会对其内容进行解析。元素:script, style。原始文本元素的内容可以是文本内容,但是不能误包含结束标签的模式,否则会被浏览器解析为元素的结束标签。
-
escapable raw text elements
(可转义原始文本元素),其内容可以包含转义序列的原始文本。元素:textarea, title。可转义原始文本元素的内容可以是文本内容和字符引用(
&
开头,;
结尾,中间的字符匹配上预定义的命名字符引用,例如
代表空格),但是不能是无效的字符引用(不能匹配预定义的命名字符引用),以及和上面的原始文本元素一样,不能误包含结束标签的模式。
-
foreign elements
(外来元素),来自非 HTML 命名空间的元素,主要是 SVG 和 MathML 元素,这些元素在 HTML 文档中使用,但属于不同的 XML 命名空间。元素:MathML 命名空间和 SVG 命名空间的元素。开始标签不是自闭合时,内容可以是文本内容、字符引用、CDATA 区段(用于在 XML 和 HTML(主要在 XHTML 中)文档中标记不需要解析的文本内容,在 CDATA 区段中的所有内容都会被当作纯文本处理,不会被解析为标签或实体,通常用于嵌入 XML 片段)、其他元素和注释,但是文本不能包含
<
和无效的字符引用。
-
normal elements
(普通元素):除了上面的元素,所有其他合法的元素都是普通元素。
标签用于在 HTML 中界定元素的开始和结束,raw text、escapable raw text 和 normal elements 有开始和结束标签;void elements 只能有开始标签,不允许有结束标签;foreign elements 必须同时有开始和结束标签,或者开始标签是自闭合的,这种情况就不能有结束标签。
开始标签
开始标签由这几部分组成:
<
字符开头;
- 元素的标签名,标签名必须是字母或数字,不区分大小写。元素名可以是预定义的元素,也可以是自定义的元素(自定义元素在上一章有简单介绍);
- 如果元素含有属性(attribute),标签名和属性间必须有一个或多个 ASCII 空白字符;
- 属性与属性之间必须有一个或多个 ASCII 空白字符;
- 在属性后面,或者没有属性的标签名后面,必须有一个或多个 ASCII 空白字符;
- 如果元素是自闭合的,则有一个
/
字符;
>
字符结尾。
结束标签
结束标签由这几部分组成:
<
字符开头;
- 紧接着是
/
字符;
- 标签名;
- 可选的一个或多个 ASCII 空白字符;
>
字符结尾。
属性
属性写在元素的开始标签里,属性值可以包含普通文本内容或者字符引用,但是不能是无效的字符引用。
属性的写法有以下 4 种:
- 空属性语法:只有属性名,当属性值是空字符串时,可以省略属性值。
<input name="前端创可贴" disabled="" />
<input name="前端创可贴" disabled />
- 不加引号的属性值语法:当属性值没有
空格
、"
、'
、`
、=
、<
、>
字符且非空时,属性值可以不写引号。
<input name="前端创可贴" />
<input name=前端创可贴 />
- 单引号属性值语法:当属性值不包含
'
字符时,可以使用单引号。
<input name='前端创可贴' />
- 双引号属性值语法:当属性值不包含
"
字符时,可以使用双引号。
<input name="前端创可贴" />
可选标签
可选标签是指可以省略的开始标签或结束标签,HTML 解析器能够根据上下文自动推断出这些标签的存在,因此不需要显式地编写。
- html 元素的第一个子元素不是 comment 时,则 html 元素的开始标签可以省略。
<!DOCTYPE html>
<!-- 注意这里,我删除了 html 元素的开始标签,真实代码中请去掉这段注释 -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
前端创可贴
</body>
</html>
可以看到 DOM 树的结构并没有任何变化,但是如果 html 元素的第一个子元素是一个注释,此时省略 html 元素开始标签:
<!DOCTYPE html>
<!-- 这是一段注释 -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
前端创可贴
</body>
</html>
可以看到,注释本应该是在 html 元素里的,但是因为省略了 html 元素开始标签,注释跑到了 html 元素外边,导致 DOM 树结构发生了变化。当然了,注释的位置在哪如果对你来说无关紧要,那此时确实仍然可以省略 html 元素开始标签。
- html 元素结束标签后面没有紧跟着注释,则该结束标签可以省略。
- head 元素元素是空的,或者第一个子元素是另一个元素,则该 head 元素开始标签可以省略。
- head 元素结束标签后面没有紧跟 ASCII 空白字符或注释,则该结束标签可以省略。
- body 元素是空的,或者第一个子元素不是 ASCII 空白字符或注释,则该 body 元素的开始标签可以省略。除非 body 元素内的第一个子元素是 meta、noscript、link、script、style 或 template 元素。
- body 元素后面没有紧跟注释,则该 body 元素的结束标签可以省略。
- li 元素后面紧跟另一个 li 元素,或者父元素中没有更多内容,则该 li 元素的结束标签可以省略。
- dt 元素后面紧跟另一个 dt 元素或 dd 元素,则该 dt 元素的结束标签可以省略。
- dd 元素后面紧跟另一个 dd 元素或 dt 元素,或者父元素中没有更多内容,则该 dd 元素的结束标签可以省略。
- p 元素后面紧跟 address、article、aside、blockquote、details、dialog、div、dl、fieldset、figcaption、figure、footer、form、h1、h2、h3、h4、h5、h6、header、hgroup、hr、main、menu、nav、ol、p、pre、search、section、table 或 ul 元素,或者父元素中没有更多内容,且父元素是一个 HTML 元素,但不是 a、audio、del、ins、map、noscript 或 video 元素,或者是一个自主的自定义元素,则该 p 元素的结束标签可以省略。
- ...
还有很多这样的可以省略开始标签或结束标签的情况,我只是截取了其中一些比较常见的部分,但是我建议大家不要记这个,非常非常鸡肋,可选标签确实简化了 HTML 代码的书写,浏览器是可以读懂的,但是我们却看不懂了,为了代码的可读性和维护性,大家还是老老实实的写全标签,不然会被同事按在地上摩擦的。
字符引用
上面在介绍元素的时候,提到了字符引用,这里再详细的介绍一下。
字符引用是用于在 HTML 中安全地显示特殊符号,避免它们被解析为 HTML 标签或其他特殊含义。比如 <
在 HTML 代码里是标签的第一个字符,有特殊含义,所以如果我们想以普通文本显示出来,就必须经过字符引用的转义。
字符引用必须以 &
开头,后面具体跟着什么,分为 3 类:
- 命名字符引用(Named character references):后面跟着预定义的命名字符引用。然后以
;
结尾。
- 十进制数字字符引用(Decimal numeric character reference):后面跟着
#
字符和 1 个或多个数字,用来表示字符的 Unicode 码点。然后以;
结尾。比如A
就代表着字符A
。
- 十六进制数字字符引用(Hexadecimal numeric character reference):后面跟着
#
字符和大写或小写的字母x
,以及 1 个或多个 16 进制字符,使用十六进制数字来表示字符的 Unicode 码点。然后以;
结尾。比如A
就代表着字符A
。
CDATA 区段
CDATA 区段(CDATA sections)只能用于外来元素(MathML 或者 SVG),在 CDATA 区段中的所有内容都会被当作纯文本处理,不会被解析为标签或实体,通常用于嵌入 XML 片段。
CDATA 区段由这几部分组成:
<![CDATA[
字符;
- 可选的文本内容,但是不能包含
]]>
字符;
]]>
字符。
<p>You can add a string to a number, but this stringifies the number:</p>
<math>
<ms><![CDATA[x<y]]></ms>
<mo>+</mo>
<mn>3</mn>
<mo>=</mo>
<ms><![CDATA[x<y3]]></ms>
</math>
CDATA 区段大家混个眼熟就可以了,平时是用不到的。
注释
注释由这几部分组成:
<!--
字符;
- 可选的文本内容,但是不能以
>
或者->
字符开头,也不能包含<!--
、-->
或者--!>
字符,也不能以<!-
字符结尾。可以以<!
字符结尾,例如<!-- 前端创可贴 <! -->
。
-->
字符。
注释在 DOM 树中也是一个有效的节点,并且通过父元素的 childNodes 属性可以获取到注释节点(这个属性还可以获取到 ASCII 空白字符节点),但是父元素的 children 属性会过滤掉注释节点和 ASCII 空白字符节点。
结束语
我们一起学习了 HTML 的语法规范,学习到了许多平时很容易被我们忽略的一些细节。
也做了很多相关知识的扩展,大家可以看到学习 HTML 并不是仅仅学习 HTML 这一门语言就足够了,毕竟它是要运行在计算机上,就会有一些计算机相关的基础知识需要我们学习,大家切记不要排斥这些基础知识的学习,在未来的某一天一定会用到的,一定要重视我们平时的知识积累和储备,或许在未来某一天看到一个知识点的时候,会突然茅塞顿开,对于学习新知识的速度和接受能力,也会大大上升的~
这一章的介绍就结束了,咱们下一章再见啦。
欢迎关注我的公众号,前端创可贴。
转载自:https://juejin.cn/post/7411452970082189349