本文是系列文章的一部分:Web 基础知识
任何 Web 应用程序都依赖于一些基础技术:HTML、CSS 和 JavaScript。即使是像Angular、React 或 Vue这样的高级前端 JavaScript 框架,也会利用一定程度的 HTML 来加载 JavaScript。
话虽如此,浏览器底层如何处理 HTML 和 CSS 却是一个谜。在本文中,我将解释浏览器是如何理解它应该向用户显示什么的。
DOM
正如 JavaScript 程序的源代码被分解为计算机更容易理解的抽象概念一样,HTML 也是如此。HTML 最初源自SGML(也是 XML 的基础),它实际上在内存中形成了一个树形结构, 用于描述树中各个元素之间的关系、布局和可执行任务。这种内存中的树形结构称为文档对象模型(简称DOM)。
例如,当您加载类似这样的文件时:
<!-- index.html --><!-- ids are only added for descriptive purposes --><main id="a"> <ul id="b"> <li id="c">Item 1</li> <li id="d">Item 2</li> </ul> <p id="e">Text here</p></main>
浏览器获取 HTML 中定义的项目,并将它们转换成一棵树,以便浏览器能够理解如何在屏幕上布局和绘制。这棵树的内部结构可能如下所示:
这是浏览器如何解释 HTML 的一个过于简单的例子,但它可以完成传达介绍性信息的任务。
让我们看看这是如何做到的:在任何 HTML 文件的根目录下,都有三样东西:标签、属性和文本内容。
<!-- A "header" tag --><header> <!-- An "a" tag with an "href" attribute --> <a href="example.com"> <!-- A text node --> Example Site </a></header>
DOM 树
当您输入标签(例如<header>或<a>)时,即会创建一个元素节点。这些节点随后会被组合起来,在 DOM 树上创建 “叶子节点” 。然后,您可以通过属性手动向这些节点添加信息。当一个元素节点位于另一个元素节点中时,您会向该节点添加一个 “子节点” 。节点之间的关系允许保存元数据、CSS 属性等信息。
还有 “兄弟” 节点的概念。当一个节点的父节点有多个子节点时,这些其他节点就是该子节点的 “兄弟” 。
总而言之,用于指代节点及其各种关系的术语与家谱中常用的术语极为相似。
从这些节点创建的树有一些规则:
-
必须有一个“根”或“主干”节点,并且不能有多个根。
-
父母与孩子之间必须是一对多的关系。
- 一个节点可能有多个子节点。
- 一个节点不能有多个父节点。
-
由于父节点有许多子节点,因此非根节点可能有许多兄弟节点。
浏览器如何使用它
这棵树告诉浏览器执行任务所需的所有信息,以便显示和处理与用户的交互。例如,当以下 CSS 应用于此 HTML 文件时:
// index.css
#b li { background: red;}
<!-- index.html --><main id="a"> <p id="e"></p> <ul id="b"> <li id="c"></li> <li id="d"></li> </ul></main>
在浏览树的过程中,浏览器可以跟踪它需要找到一个带有“ IDof”的元素b,然后<li>用红色背景标记其子元素。它们之所以被称为“子元素”,是因为 DOM 树保留了 HTML 定义的关系。
该
<ul>元素被标记为绿色只是为了展示它是选择器第一部分标记的元素。
浏览器排序
通常,浏览器会按照特定的顺序“访问”其节点。例如,在上图中,浏览器可能会:
- 从
<main>标签开始; - 然后转到
<p>标签; - 然后访问
<ul>标签; - 最后按照从左到右的顺序访问这两个孩子(
<li id="c">,<li id="d">)。
浏览器知道要查找什么 CSS,能够看到<ul>具有正确 ID 的 CSS,并知道使用与相关 CSS 的选择器匹配的正确元数据来标记其子项。
这种树关系还支持 CSS 选择器,例如通用兄弟选择器(~)或相邻兄弟选择器(+)或查找给定选择器的兄弟。
为什么没有“父”选择器?复制链接
有趣的是,我经常听到的一个问题是“父选择器”。这个问题背后的想法是,既然直接子选择器 (
>)已经存在,那么为什么不能够选择任意父.classname选择器呢?背后的答案是什么?性能。W3联盟(维护 HTML 和 CSS 标准规范的组织)指出,DOM 的树形结构以及浏览器遍历 DOM(或者说,“访问”节点以确定要应用的 CSS)所使用的算法在允许父选择器时性能不佳。
发生这种情况的原因是浏览器在 DOM 中从上到下读取并在找到匹配节点时应用 CSS;CSS 不会命令浏览器对 DOM 执行任何操作,而是为 DOM 提供元数据,以便在浏览器遇到特定节点时应用相关的 CSS。
如前所述,它们从根节点开始,记录所见内容,然后移动到子节点;然后,移动到兄弟节点,等等。特定浏览器可能对此算法有轻微偏差,但在大多数情况下,它们不允许节点在 DOM 内向上垂直移动。
默认设置和可访问性
HTML 作为一种规范,拥有大量可供用户随意使用的标签。这些标签内部包含各种元数据,用于向浏览器提供有关如何在 DOM 中渲染它们的信息。浏览器可以根据自身情况处理这些元数据;它可以应用默认的 CSS 样式,可以更改用户与元素的默认交互,甚至可以更改元素点击时的行为(例如表单中的按钮)。
考虑支持
任何金额的捐赠都将有助于进一步开发此类文章。
其中一些标签的默认值是规范的一部分,而其他一些则由浏览器供应商自行决定。因此,在很多情况下,开发者可能会选择使用类似的方法normalize.css将所有元素的 CSS 默认值设置为一组明确的默认值。这样做可以避免由于特定标签的默认 CSS 样式存在差异而导致网页的 UI 在不同浏览器上看起来有所不同。
正是这些元数据,使得您的应用程序必须使用预期的 HTML 标签,而不是简单地默认<div>使用 CSS 或 JavaScript 来模拟其他内容。负责任地使用浏览器内置的元数据系统,并使用正确的标签,可以带来两大优势:搜索引擎优化 (SEO) 和可访问性。
请看以下示例:
<div> <div>Bananas</div> <div>Apples</div> <div>Oranges</div></div>
在这个例子中,您的浏览器只知道您想要在屏幕上显示文本。如果使用屏幕阅读器的用户访问该网站,浏览器并不知道应该告知他们列表中有三个项目(视力较差的人非常需要了解这一点,以便能够有效地使用 Tab 键浏览列表),因为您没有采取任何措施告知用户这是一个项目列表:只知道它是一组<div>通用容器。
同样,当 Google 的搜索引擎机器人浏览您的网站时,它们无法解析您正在向用户展示的列表。因此,由于该网站似乎根本没有包含任何列表,您对“最佳地点列表”的搜索评级可能会受到影响。
有什么办法可以解决这个问题呢?当然是使用合适的标签啦!
<ol> <li>Bananas</li> <li>Apples</li> <li>Oranges</li></ol>
在这个例子中,浏览器和谷歌的抓取机器人都能够辨别出这是一个包含三个列表项的列表。
虽然有些标签可能会对 SEO 产生一定程度的影响,但这种影响不太可能发生
<ul>,而且<li>会显著影响您的 SEO 分数。毋庸置疑,使用语义化(正确标记的)HTML 仍然是不错的选择,因为使用屏幕阅读器和其他辅助技术的用户会从这些细微的改进中受益匪浅。此外,它还能提高代码的可读性,并使其更易于自动化工具解析。
我们甚至可以使用属性向元素添加更多元数据。例如,假设我想在列表中添加一个标题,以便在屏幕阅读器获得元素焦点时读取;我们可以使用以下aria-label属性:
<ol aria-label="My favorite fruits"> <li>Bananas</li> <li>Apples</li> <li>Oranges</li></ol>
事实上,特定标签默认拥有的元数据可以手动应用于不同类型的元素。使用 时传递给浏览器的元数据<li>通常与 元素相关listitem,使用role属性,我们可以将这些信息添加到 元素<div>本身。
<ol> <div role="listitem">Bananas</div> <div role="listitem">Apples</div> <div role="listitem">Oranges</div></ol>
值得一提的是,这个例子通常被认为是不当行为。 虽然你可能能够保留元素中标签的部分元数据,但要捕捉浏览器可能应用于原始标签的所有默认值,从而提升使用屏幕阅读器的用户的体验,却极其困难。
<li>``<div>总而言之,除非你真的有充分的理由使用,
role而不是使用某个合适的标签,否则请坚持使用相关的标签。正如任何其他形式的工程一样,正确使用 HTML 需要实现开发人员在细节和逻辑上进行部署。
元素元数据
如果您曾经编写过一个在 HTML 和 JavaScript 之间来回通信的网站,您可能知道您可以从 JavaScript 访问 DOM 元素:根据自己的心意修改、读取和创建它们。
让我们看一下我们可以使用的一些内置实用程序:
全局Document对象
如前所述,DOM 树必须包含一个根节点。对于任何 DOM 实例来说,这个节点都是文档的入口点。在浏览器中,这个入口点通过全局对象document暴露给开发者。该对象具有各种方法和属性,以提供有意义的帮助。例如,给定一个标准的 HTML5 文档:
<!DOCTYPE html><html><head> <title>This is a page title</title></head><body> <p id="mainText"> This is the page body <span class="bolded">and it contains</span> a lot of various content within <span class="bolded">the DOM</span> </p></body></html>
该document对象能够获取<body>节点(document.body)、<head>节点(document.head)甚至文档类型(document.doctype)。
查询元素
<body>除了包含对和 的静态引用之外<head>,还可以使用 CSS 选择器查询任何元素。例如,如果我们想获取带有idof的单个元素的引用mainText,我们可以使用 CSS 选择器获取 id,并结合上的方法querySelector``document:
const mainTextElement = document.querySelector('#mainText');
中
#的#mainText是 CSS 选择器语法,用于根据 来选择元素id。如果您有一个 CSS 选择器#testing,那么您将查找具有以下属性值的元素:id="testing"
此方法将返回 DOM 中渲染元素的引用。虽然我们稍后会详细介绍此引用的功能,但现在我们可以执行以下简短代码来表明它就是我们想要查询的元素:
console.log(mainTextElement.innerHTML);
我们还可以一次性获取多个元素的引用。假设我们想查看有多少个元素应用了bolded该类,可以使用上的方法来querySelectorAll``document实现。
const boldedElements = document.querySelectorAll('.bolded');console.log(boldedElements.length); // Will output 2console.log(boldedElements[0].innerHTML); // Will output the HTML for that element
值得一提的是,这种方式与浏览器“访问”节点时根据 CSS 选择器数据检查节点的方式
querySelector不同,而是采用更自上而下的视角,根据查询逐个搜索元素。首先,它会找到 CSS 选择器的最顶层。然后,它会移动到下一个项,依此类推,直到返回预期结果。querySelector``querySelectorAll
基Element类
虽然innerHTML已用于证明所收集的元素实际上就是所查询的元素,但还有许多可在元素引用上运行的属性和方法。
当查询并返回一个元素时,您将通过Element基类获得对该元素的引用。此类包含可用于访问和修改元素元数据的属性和方法。
例如,假设我想查看元素在屏幕上渲染时的宽度和高度。使用Element.prototype.getBoundingClientRect方法,你可以获取所有这些信息以及更多信息:
const mainTextElement = document.querySelector('#mainText');console.log(mainTextElement.getBoundingClientRect());// Will output: DOMRect {x: 8, y: 16, width: 638, height: 18, top: 16, …}
虽然 背后的解释
Element.prototype很长(当然,它本身就够写一篇文章了),但只需说一下,所有使用 找到的元素引用都有一个基类querySelector。这个基类包含大量的方法和属性。.prototype大致指的是那些有问题的属性和方法。这意味着所有查询的元素都有自己的
getBoundingClientRect方法。
HTML 属性
如前所述,元素可以具有属性,这些属性会将元数据应用于元素,以供浏览器使用。但是,我可能没有提到,您可以使用 JavaScript 读取和写入这些元数据,以及应用新的元数据。
让我们从正确的标签部分中取出一个稍微修改过的示例来演示:
<div id="divToList"> <div>Bananas</div> <div>Apples</div> <div>Oranges</div></div>
我们可以更新此列表以包含roles 和aria-labels,以使此非语义 HTML 在如何将其元数据反映到浏览器方面更加相关。
我们直接放置在元素本身上的元数据被称为attributesHTML 规范(在本文档中也称为 HTML API),它是 HTML 规范的一部分。您可以使用 JavaScript 中的 来访问和修改这些元数据,方法是使用Element来getAttribute读取键值对,并将setAttribute值设置为元素上相应的属性。
让我们看看如何使用 JavaScript 设置DOM 中的role和:aria-label
const divToListEl = document.querySelector('#divToList');// Get the `role` attribute to demonstrate that there's no currently present roleconsole.log(divToListEl.getAttribute('role')); // `null`// Let's set a role that emulates a `list`// Set the value from the HTML API using the Element method `setAttribute`divToListEl.setAttribute('role', 'list');// And let's add an aria-label, for good measuredivToListEl.setAttribute('aria-label', 'My favorite fruits');// Get the value from the HTML API using the Element method `getAttribute`console.log(divToListEl.getAttribute('role')); // `'list'`// Using the CSS selector to get the children of the divsconst listItems = document.querySelectorAll('#divToList > *');// Now, for all of the items in that list, let's use an aria `role` to make them reflect as listitems in their metadata to the browserfor (var i = 0; i < listItems.length; i++) { listItems[i].setAttribute('role', 'listitem');}
一旦运行,如果您检查调试器中的元素选项卡,您应该会得到如下所示的 HTML:
<div id="divToList" role="list" aria-label="My favorite fruits"> <div role="listitem">Bananas</div> <div role="listitem">Apples</div> <div role="listitem">Oranges</div></div>
...如前所述,这对于使用屏幕阅读器的用户来说更加易于访问。您会注意到,尽管之前没有任何 ARIA 属性,但setAttribute仍然能够使用新放置的值隐式创建它们。
特性
如上一节所述,元素还具有与底层基类实例关联的属性和方法。这些属性与特性 (Attribute) 不同,因为它们不属于 HTML 规范。相反,它们是标准化 JavaScript ElementAPI 的附加内容。其中一些属性可以暴露给 HTML,并提供 HTML API 和 JavaScript API 之间的双向绑定Element。
遗憾的是,由于各种历史原因,支持该 API 和 HTML API 之间双向映射的属性列表
Element并不完整且不一致。一些支持这两个 API 之间映射的元素甚至只支持单向映射,即更新一个 API 不会更新另一个 API。这是一种委婉的说法:“哪些属性有特性绑定,哪些没有,以及为什么,这很令人困惑和复杂。如果你不能马上明白也没关系。” 即使是经验丰富的开发人员也可能没有意识到其中的一些限制。话虽如此,让我们继续通过一些遵循双向隐式 API 映射的示例来展示它的工作原理,并了解更多关于属性的信息。
例如,如果您正在处理的元素具有关联的样式属性,则您可以读取该元素的值:
<!-- index.html -->
<div style="background-color: green; color: white; width: 200px; height: 400px;" id="greenEl"> This element is green</div>
// index.js
const greenElement = document.querySelector('#greenEl');
console.log(greenElement.style.backgroundColor); // 'green'
您不仅可以读取相关值,还可以编写和编辑它们:
greenElement.style.backgroundColor = 'red';
例如,将元素的背景颜色变为红色。
有点傻,因为它<div>不再是绿色了。🤭
限制
虽然属性在存储元素数据方面非常有用,但它有一个限制:值始终以字符串形式存储。这意味着对象、数组和其他非字符串原语在读写时必须找到一种与字符串相互转换的方法。
虽然您已经看到
style属性可以通过对象接口读取和写入,但如果您检查元素或使用它getAttribute来访问属性的 HTML API 值,您会发现它实际上是一个带有令人愉快的 API 的字符串,可让您使用对象来与属性值进行交互。console.log(mainTextElement.getAttribute('style')); // This will return a string value, despite the API that lets you use an object to read and write这种不一致的背后原因是HTML API 和
ElementAPI 之间的隐式映射,如上一节开头所述。此处描述的限制也适用于这些类型属性的 HTML API。
例如,我们可以使用data属性来通过属性读取和写入任何给定元素的值。
<!-- index.html -->
<ul id="list" data-listitems="2"> <li>List item 1</li> <li>List item 2</li></ul>
// index.js
const listEl = document.querySelector('#list');
console.log(listEl.dataset.listitems); // '2'
listEl.dataset.listitems = 3;
console.log(listEl.dataset.listitems); // '3'
请注意,尽管我使用了数值来设置值,但在代码示例的注释中,输出结果中我写的是字符串'3'而不是数值。这是由于默认非字符串值保存到属性的方式造成的。3 3
toString默认情况下,将调用原语来存储值。
element.dataset.userInfo = {name: "Tony"};console.log(element.dataset.userInfo); // "[object Object]"/** * "[object Object]" is because it's running `Object.prototype.toString()` * to convert the object to a string to store on the attribute */
如果你不明白为什么
toString运行bring函数或者它prototype在这里做什么,别担心;你并不孤单。JavaScript原型系统非常复杂,理解起来可能有些困难。现在,只要知道只能在元素属性中存储字符串就足够了。
活动
正如浏览器使用 DOM 来处理屏幕内容的可见性一样,浏览器也利用 DOM 来了解如何处理用户交互。浏览器处理用户交互的方式是监听用户执行操作或其他值得注意的变化时发生的事件。
例如,假设你有一个包含默认<button>元素的表单。当该按钮被按下时,它会触发一个submit事件,然后该事件沿着 DOM 树向上冒泡,直到找到一个<form>元素。默认情况下,该<form>元素在收到事件后会向服务器发送GETHTML 请求submit。
如此处所示,冒泡是任何给定事件的默认行为。其行为是将事件沿 DOM 树向上移动到其上方的节点,从子节点移动到父节点,直到到达根节点。父节点可以按预期响应这些事件,停止其在树上的向上移动等等。
Event监听
与本文讨论的 DOM 的许多其他内部用途非常相似,您可以连接到该事件系统来自己处理用户交互。
让我们看一些代码示例:
<!DOCTYPE html><html><head> <title>This is a page title</title></head><body> <div id="red" style="height: 400px; width: 400px; background: red;"> <div id="blue" style="height: 300px; width: 300px; background: blue;"> <div id="green" style="height: 200px; width: 200px; background: green;"></div> </div> </div> <script> const redEl = document.querySelector('#red'); const blueEl = document.querySelector('#blue'); const greenEl = document.querySelector('#green'); redEl.addEventListener('click', () => { console.log("A click handled on red using bubbling"); // This is set to false in order to use bubbling. We'll cover the `true` case later on }, false); blueEl.addEventListener('click', (event) => { // Stop the click event from moving further up in the bubble event.stopPropagation(); console.log("A click handled on blue using bubbling"); }, false); greenEl.addEventListener('click', () => { console.log("A click handled on green using bubbling"); }, false); </script></body></html>
在这个例子中,我们为三个正方形添加了点击监听器,每个正方形都比它们的父正方形小。这样我们就可以在控制台中看到冒泡的效果。如果你点击红色正方形,你会期望事件向上冒泡到<body>,而不是向下冒泡到#green。同样,如果你点击绿色正方形,你会期望事件向上冒泡到#blue和#red以及<body>。
但是,正如你所见,我们正在stopPropagation蓝色方块中运行事件。这将使点击事件停止冒泡。这意味着任何被调用的点击事件#green都不会到达 ,#red因为它们会在 处停止#blue。
捕获
冒泡并非事件移动的唯一方式。正如事件可以从底部向上移动一样,它们也可以从顶部向下移动。这种触发事件的方法称为捕获模式。
让我们看一些示例代码,其中的 HTML 与以前相同,但有一组新的 JavaScript:
redEl.addEventListener('click', () => { console.log("A click handled on red using capturing"); // Setting true here will switch to capture mode}, true);blueEl.addEventListener('click', (event) => { // Stop the click event from moving further down in the bubble event.stopPropagation(); console.log("A click handled on blue using capturing");}, true);greenEl.addEventListener('click', () => { console.log("A click handled on green using capturing");}, true);
如上面的代码所示,stopPropagation它在捕获模式下也能按您期望的方式工作!
这意味着当用户点击红色方块时,您将在控制台中看到以下内容:
"A click handled on red using capturing"
"A click handled on blue using capturing"
但是,您不会从绿色方块中看到任何东西eventListener。
你还会注意到,如果你点击绿色方块,你永远不会看到这"A click handled on green using capture"条消息。这是因为stopPropagation,正如之前提到的。点击首先在红色方块上被记录,然后在蓝色方块上停止。
结论
这篇文章信息量十足。😵 就连我,作为作者,也有几个很棒的朋友重读了一遍,确认我的观点。请不要害怕或羞于重读任何可能不通顺的地方,也不要因为有疑问就重新阅读这篇文章。希望这篇文章能帮助您探索 DOM 以及如何通过代码与它交互!
接下来,我们将利用 JavaScript 的强大功能直接操作 DOM! 你将学习如何使组件具有交互性、添加事件等等!