一、JavaScript, HTML, DOM

学习目标

在本章结束时,你将能够:

  • 描述 HTML文档对象模型(DOM)
  • 使用 Chrome DevTools 源代码选项卡来探索网页的 DOM
  • 实现 JavaScript 来查询和操作 DOM
  • 使用 Shadow DOM 构建自定义组件

在这一章中,我们将学习 DOM 以及如何使用 JavaScript 与之交互和操作。 我们还将学习如何使用可重用的自定义组件构建动态应用。

简介

HTML 最初是一种用于静态文档的标记语言,易于使用,可以使用任何文本编辑器编写。 在 JavaScript 成为互联网世界的主要参与者之后,有必要将 HTML 文档公开给 JavaScript 运行时。 这就是 DOM 创建的时候。 DOM 是映射到可以使用 JavaScript 查询和操作的对象树的 HTML。

在本章中,您将学习什么是 DOM 以及如何使用 JavaScript 与它进行交互。 您将了解如何在文档中查找元素和数据,如何操作元素状态,以及如何修改它们的内容。 您还将学习如何创建 DOM 元素并将它们添加到页面。

在了解 DOM 及其操作方法之后,您将使用一些示例数据构建一个动态应用。 最后,您将学习如何使用 Shadow DOM 创建自定义 HTML 元素来构建可重用组件。

HTML 和 DOM

当浏览器加载 HTML 页面时,它会创建一个表示该页面的树。 这个树是基于 DOM 规范的。 它使用标记来确定每个节点的开始和结束位置。

考虑下面这段 HTML 代码:

<html>
  <head>
    <title>Sample Page</title>
  </head>
  <body>
    <p>This is a paragraph.</p>
    <div>
      <p>This is a paragraph inside a div.</p>
    </div>
    <button>Click me!</button>
  </body>
</html>

浏览器将创建以下节点层次结构:

Figure 1.1: A paragraph node contains a text node

图 1.1:段落节点包含文本节点

所有东西都变成了一个节点。 文本、元素和注释,一直到树的根。 此树用于匹配来自 CSS 的样式并呈现页面。 它还被转换成一个对象,供 JavaScript 运行时使用。

但它为什么叫 DOM 呢? 因为 HTML 最初的设计目的是共享文档,而不是设计我们今天拥有的丰富的动态应用。 这意味着每个 HTML DOM 都以文档元素开始,所有元素都附加在文档元素上。 考虑到这一点,前面的 DOM 树图实际上变成了下面的样子:

Figure 1.2: All DOM trees have a document element at the root

图 1.2:所有 DOM 树的根都有一个文档元素

当我说浏览器使 DOM 对 JavaScript 运行时可用时,这意味着什么? 这意味着,如果您在 HTML 页面中编写一些 JavaScript 代码,就可以访问该树并使用它做一些非常有趣的事情。 例如,您可以轻松地访问文档根元素并访问页面上的所有节点,这将是您在下一个练习中将要做的。

练习 1:在文档中迭代节点

在这个练习中,我们将编写 JavaScript 代码来查询 DOM,以找到一个按钮,并向它添加一个事件监听器,以便在用户单击按钮时执行一些代码。 当事件发生时,我们将查询所有段落元素,计算并存储它们的内容,然后在最后显示一个警告。

这个练习的代码文件可以在https://github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise01中找到。

执行以下步骤来完成练习:

  1. 打开你喜欢的文本编辑器,创建一个新文件名为alert_paragraphs.html一节包含的示例 HTML(可以在 GitHub 找到:https://bit.ly/2maW0Sx):

    js <html>

    js   <head>

    js     <title>Sample Page</title>

    【4】【5】

    js     <p>This is a paragraph.</p>

    js     <div>

    【显示】

    js     </div>

    js     <button>Click me!</button>

    js   </body>

    【病人】 2. 在body元素的末尾添加script标签,使最后几行看起来如下:

    js     </div>

    js     <button>Click me!</button>

    js     <script>

    js     </script>

    js   </body>

    js </html>

  2. script标签内,为按钮的点击事件添加一个事件监听器。 为此,您可以查询文档对象中所有带有button标签的元素,获取第一个元素(页面上只有一个按钮),然后调用addEventListener:

    js document.getElementsByTagName('button')[0].addEventListener('click', () => {});

  3. 在事件监听器内部,再次查询文档以找到所有段落元素:

    js const allParagraphs = document.getElementsByTagName('p');

  4. 然后,在事件监听器中创建两个变量来存储你找到的段落元素的数量,并创建另一个变量来存储它们的内容:

  5. 遍历所有段落元素,计数,存储内容:

    js for (let i = 0; i < allParagraphs.length; i++) {  const node = allParagraphs[i];

    js   count++;

    js   allContent += `${count} - ${node.textContent}\n`;

    js }

  6. After the loop, show an alert that contains the number of paragraphs that were found and a list with all their content:

    js alert(`Found ${count} paragraphs. Their content:\n${allContent}`);

    你可以在这里看到最终代码的样子:https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise01/alert_paragraphs.html

    在浏览器中打开 HTML 文档并单击按钮,您应该看到以下警告:

Figure 1.3: Alert box showing information about paragraphs on the page

图 1.3:显示页面上段落信息的警告框

在这个练习中,我们编写了一些 JavaScript 代码来查询 DOM 中的特定元素。 我们收集了元素的内容,并将其显示在一个警告框中。

在本章后面的章节中,我们将探索查询 DOM 和遍历节点的其他方法。 但是从这个练习中,你已经可以看到这是多么的强大,并开始想象它打开的可能性。 例如,我经常用它来计算东西或从互联网上所有的网页中提取我需要的数据。

开发工具

现在我们已经理解了 HTML 源代码和 DOM 之间的关系,我们可以使用一个非常强大的工具来更详细地研究它:浏览器开发人员工具。 在这本书中,我们将探索谷歌 Chrome 的DevTools,但你可以很容易地在所有其他浏览器中找到相同的工具。

我们要做的第一件事是探索我们在上一节中创建的页面。 当你在谷歌 Chrome 中打开它时,你可以通过打开Chrome菜单找到开发工具。 然后选择More ToolsDeveloper Tools,打开开发人员工具:

Figure 1.4: Accessing the developer tools in Google Chrome

图 1.4:在谷歌 Chrome 中访问开发工具

Developer Tools将在页面底部打开一个面板:

Figure 1.5: Google Chrome DevTools panel when open

图 1.5:谷歌 Chrome DevTools 面板时,打开

您可以在顶部看到各种选项卡,它们提供了关于所加载页面上发生的事情的不同视角。 在本章中,我们主要关注三个标签:

  • Elements-显示浏览器看到的 DOM 树。 您可以检查浏览器是如何查看 HTML 的,CSS 是如何应用的,以及每种样式是由哪些选择器激活的。 您还可以更改节点的状态,以模拟特定的状态,如hovervisited:

Figure 1.6: View of the Elements tab

图 1.6:Elements 选项卡的视图
  • 控制台-提供访问 JavaScript 运行时在页面的上下文中。 可以在加载页面后使用控制台来测试简短的代码片段。 也可以用来打印重要的调试信息:

Figure 1.7: View of the Console tab

图 1.7:控制台选项卡的视图
  • 源代码-显示当前页面加载的所有源代码。 该视图可用于设置断点和启动调试会话:

图 1.8:Sources 选项卡的视图

选择Elements选项卡,你会看到当前文档的 DOM 树:

Figure 1.9: DOM tree viewed inside the Elements tab in Chrome DevTools

图 1.9:在 Chrome DevTools 的 Elements 标签中查看 DOM 树

练习 2:从 Elements Tab 操作 DOM

为了了解这个工具的强大功能,我们将对练习 1 中的页面做一些修改,在文档中迭代节点。 我们将添加一个新的段落,并删除一个现有的段落。 然后,我们将使用样式侧边栏来改变元素的一些样式。

执行以下步骤来完成练习:

  1. To start, right-click the body element and select Edit as HTML:

    Figure 1.10: Editing the body element as HTML

    图 1.10:将 body 元素编辑为 HTML
  2. That will change the node to a textbox that you can type in. Under the first paragraph, add another one with the text Another paragraph. It should look like the following:

    Figure 1.11: Add a new paragraph in the body of the HTML

    图 1.11:在 HTML 主体中添加一个新段落
  3. Ctrl + Enter(或Cmd + Enter在 Mac 上)保存您的更改。

  4. Click the Click me! button again and you should see that the new paragraph and its contents are now shown in the list:

    Figure 1.12: Alert showing the content of all paragraphs, including the one added to the page

    图 1.12:显示所有段落内容的警报,包括添加到页面中的段落
  5. You can also play around with the styles of the elements and see the changes reflected live on the page. Let's change the background to black and the color to white for the first paragraph. First, we select it on the DOM tree by clicking on it; it will turn blue to indicate that it is selected:

    Figure 1.13: DOM element selected on the Elements tab

    图 1.13:在 Elements 选项卡上选择的 DOM 元素
  6. Now, on the right-hand side, you will see the Styles tab. It contains the styles already applied to the element and one empty placeholder for styles for the element. Clicking on it, you'll get an input box. Type background: black, hit Enter, and then type color: white, and hit Enter again. You'll see that the element changes as you type. In the end, it will look like the following:

    Figure 1.14: The styled paragraph on the left and the applied styles on the right

    图 1.14:样式段落在左边,应用的样式在右边
  7. You can also create a new CSS rule to apply to the page by clicking on the new rule button at the top right of the Styles tab:

    Figure 1.15: When you click to add a new rule, it will add a new rule based on the element selected – a paragraph, in this case

    图 1.15:当你点击添加一个新规则时,它将基于所选元素添加一个新规则——在本例中是一个段落
  8. 让我们添加类似的规则,以影响所有段落,输入背景:绿色,按输入,输入颜色:黄色,然后按输入。 现在,除了第一个段落外,所有段落都将有一个绿色背景和黄色文本。 这是页面现在应该看起来的样子:

Figure 1.16: Adding rules to a paragraph

图 1.16:在段落中添加规则

在本练习中,您从页面更改了 DOM,并看到了实时反映的更改。 您向页面添加了元素,更改了一个元素的样式,然后添加了一个新的 CSS 规则来影响更广泛的元素组。

像这样实时操作 DOM 对于试图确定布局和测试迭代或操作 DOM 元素的代码的情况非常有用。 在我们的例子中,我们可以很容易地测试如果向页面添加一个新的段落元素会发生什么。

练习 3:从源代码选项卡调试代码

我们之前提到过,您可以从Sources选项卡调试代码。 要做到这一点,您只需要设置一个断点并确保代码通过该断点。 对于这个练习,我们将在调试代码时探索Sources选项卡。

执行以下步骤来完成练习:

  1. The first thing you'll need to do is select the Sources tab in the Developer Tools panel. Then, open the one source file we have so far. You do that by clicking on it in the left-hand side panel:

    Figure 1.17: Sources tab showing where to find your source files

    图 1.17:Sources 选项卡显示在哪里可以找到你的源文件
  2. To set a breakpoint in the source, you click on the gutter where the line numbers are, at the line you want to set a breakpoint at. For this exercise, we'll set a breakpoint at the first line inside the event handler. A blue arrow-like symbol will appear on that line:

    Figure 1.18: Breakpoints show as arrow-like markers on the gutter of the source file

    图 1.18:断点显示为源文件边栏上的类似箭头的标记
  3. Click the Click me! button on the page to trigger the code execution. You'll notice that two things happen – the browser window freezes and there's a message indicating that the code is paused:

    Figure 1.19: The browser pauses the execution when it hits a breakpoint

    图 1.19:浏览器在遇到断点时暂停执行
  4. Also, the line of code being executed gets highlighted in the Sources tab:

    Figure 1.20: Execution paused in the source code, highlighting the line that will be executed next

    图 1.20:源代码中的执行暂停,突出显示接下来要执行的行
  5. In the side panel, notice the currently executing stack and everything that's in the current scope, both globally and locally. This is the view of the right-hand panel, showing all the important information about the running code:

    Figure 1.21: The right-hand side of the Sources tab shows the execution context and stack trace of the currently paused execution

    图 1.21:Sources 选项卡的右侧显示了当前暂停执行的执行上下文和堆栈跟踪
  6. The bar at the top can be used to control code execution. This is what each button can do:

    play键结束暂停,正常继续执行。

    步过按钮完成当前行并在下一行再次暂停。

    按钮中的步进将执行当前行并步进任何函数调用,这意味着它将在该线上调用的任何函数的第一行暂停。

    step out按钮将执行退出当前功能所需的所有步骤。

    步进按钮将执行下一步动作。 如果它是一个函数调用,它会介入。 如果没有,它将在下一行继续执行。

  7. Press the step over the button until the execution gets to line 20:

    Figure 1.22: The highlighted line shows the execution paused for debugging

    图 1.22:突出显示的行显示为调试暂停执行
  8. In the Scope panel on the right-hand side, you'll see four scopes: two scopes for Block, then one Local and one Global. The scopes will vary depending on where you are in the code. In this case, the first Block scope includes only what's inside the for loop. The second Block scope is the scope for the whole loop, including the variable defined in the for statement. Local is the function scope and Global is the browser scope. This is what you should see:

    Figure 1.23: The Scope panel shows all the variables in the different scopes for the current execution context

    图 1.23:Scope 面板显示了当前执行上下文的不同作用域中的所有变量
  9. 另一件值得注意的事情是,如果你将鼠标悬停在当前页面的 HTML 元素变量上,Chrome 会为你高亮显示该元素:

Figure 1.24: Chrome highlights DOM elements when you hover over them in various places

图 1.24:当你将鼠标悬停在 DOM 元素的不同位置时,Chrome 会突出显示 DOM 元素

使用Sources选项卡调试代码是作为一个 web 开发人员要做的最重要的事情之一。 理解浏览器如何查看您的代码以及每行变量的值是找到复杂应用问题根源的最简单方法。

请注意

内行值:当你在调试时跳过Sources选项卡中的代码时,你会注意到 Chrome 在每行边上添加了一些浅橙色的高亮显示,显示了在该行中受影响的变量的当前值。

控制台选项卡

现在你知道如何遍历和操作 DOM 树的选项卡元素,以及如何探索和调试代码的来源选项卡,让我们探索控制台选项卡。****

**控制台选项卡可以帮助您调试问题,也可以探索和测试代码。 为了理解它能做什么,我们将使用本书代码库中的Lesson01/sample_002文件夹中的示例店面。

打开商店的首页,您可以看到这是一家食品商店。 它看起来是这样的:

Figure 1.25: Screenshot of the storefront sample page

图 1.25:店面示例页面的截图

在底层,您可以看到 DOM 非常简单。 它有一个section元素,包含所有页面内容。 在内部,它有一个带有代表产品列表的类项的div标签和一个带有每个产品的类项的div标签。 查看Elements选项卡,如下所示:

Figure 1.26: The DOM tree for the storefront page is very simple

图 1.26:店面页面的 DOM 树非常简单

回到控制台选项卡:您可以在这个 DOM 中运行一些查询,以了解更多关于元素和内容的信息。 让我们写一些代码来列出所有产品的价格。 首先,我们需要找到价格在 DOM 树中的位置。 我们可以查看Elements选项卡,但现在,我们将坚持Console选项卡来了解更多信息。 在控制台选项卡中运行以下代码将打印一个包含 21 个项目的HTMLCollection对象:

document.getElementsByClassName('item')

让我们打开第一个,看看里面有什么:

document.getElementsByClassName('item')[0]

现在您可以看到 Chrome 打印了一个 DOM 元素,如果您将鼠标悬停在它上面,您会看到它在屏幕上高亮显示。 你也可以打开Console选项卡中显示的迷你 DOM 树,看看这个元素是什么样子的,就像Elements选项卡中一样:

Figure 1.27: The Console tab printing elements from the DOM

图 1.27:从 DOM 打印元素的 Console 选项卡

你可以看到价格在一个span标签里面。 要获取价格,可以像查询根文档一样查询元素。

注意:自动完成和以前的命令

在“Console”选项卡中,按选项卡可以根据当前上下文使用自动完成功能,按上下方向键可以快速访问之前的命令。

运行以下代码获取列表中第一个产品的价格:

document.getElementsByClassName('item')[0]
  .getElementsByTagName('span')[0].textContent

产品的价格将在控制台显示为字符串:

Figure 1.28: Querying for the DOM element that contains the price and fetching its content

图 1.28:查询包含 price 的 DOM 元素并获取其内容

活动 1:从页中提取数据

假设您正在编写一个需要 Fresh products Store 中的产品和价格的应用。 商店不提供 API,而且它的产品和价格每周都会变化一次——这还不够频繁,不足以证明整个过程是自动化的,但也不够慢,不足以让你手工完成一次。 如果他们改变了网站的外观,你也不想经历太多麻烦。

您希望以一种易于生成和解析的方式为应用提供数据。 最后得出的结论是,最简单的方法是生成可以提供给应用的 CSV。

在这个活动中,您将编写一些 JavaScript 代码,可以粘贴在控制台选项卡在店面页面并使用从 DOM 中提取数据,打印它作为一个 CSV,您可以复制和粘贴为您的应用使用。

注意:Console 选项卡中的代码很长

当在 Chrome 控制台中编写长代码片段时,我建议在文本编辑器中进行,然后在你想测试它时粘贴它。 在编辑代码时,控制台还不错,但在试图修改较长的代码时,它很容易把事情弄糟。

执行步骤如下:

  1. 初始化一个变量以存储 CSV 的全部内容。
  2. 查询 DOM 以找到表示每个产品的所有元素。
  3. 迭代找到的每个元素。
  4. product元素中,查询以单位查找价格。 使用斜杠分割字符串。
  5. 同样,从product元素中查询名称。
  6. 将所有信息附加到第 1 步中初始化的变量,用逗号分隔值。 不要忘记在追加的每一行中添加换行符。
  7. 使用console.log函数打印包含累计数据的变量。
  8. Console选项卡中运行代码,并打开店面页面。

在“控制台”页签中可以看到如下内容:

name,price,unit
Apples,$3.99,lb
Avocados,$4.99,lb
Blueberry Muffin,$2.50,each
Butter,$1.39,lb
...

请注意

这个活动的解决方案可以在 582 页找到。

在此活动中,您可以使用Console选项卡查询现有页面并从中提取数据。 有时候,从页面中提取数据是非常复杂的,抓取也会变得非常脆弱。 根据您需要页面数据的频率,从Console选项卡运行脚本可能比编写完整的应用更容易。

节点与元素

在前几节中,我们学习了 DOM 以及如何与它交互。 我们看到浏览器中有一个全局文档对象,它表示树的根。 然后,我们观察了如何查询它来获取节点并访问它们的内容。

但是,在前面几节中研究 DOM 时,有一些对象名称、属性和函数被访问和调用,无需介绍。 在本节中,我们将深入研究这些对象,并学习如何在每个对象中找到可用的属性和方法。

要找到本节将要讨论的文档,最好的地方是 Mozilla Developer Network web 文档。 你可以在developer.mozilla.org上找到。 他们有关于所有 JavaScript 和 DOM api 的详细文档。

节点是一切开始的地方。 节点是在 DOM 树中表示的接口。 如前所述,树中的所有东西都是一个节点。 所有节点都有一个nodeType属性,用于描述节点的类型。 它是一个只读属性,其值为数字。 对于每个可能的值,节点接口都有一个常量。 最常见的节点类型如下:

  • Node.ELEMENT_NODE- HTML 和 SVG 元素是这种类型。 在店面代码中,如果你从产品中获取description元素,你会看到它的nodeType属性是1,这意味着它是一个元素:

Figure 1.29: The description element node type is Node.ELEMENT_NODE

图 1.29:描述元素节点类型为 node。 ELEMENT_NODE

这是我们从 DOM 中获取的元素,在Elements标签中查看:

Figure 1.30: The description node as seen in the Elements tab

图 1.30:在 Elements 选项卡中看到的描述节点
  • Node.TEXT_NODE-标签内的文本成为文本节点。 如果你从description节点中获得第一个子节点,你可以看到它的类型是TEXT_NODE:

Figure 1.31: The text inside tags becomes text nodes

图 1.31:标签内的文本变成了文本节点

这是在Elements选项卡中看到的节点:

Figure 1.32: The text node selected in the Elements tab

图 1.32:在 Elements 选项卡中选择的文本节点
  • Node.DOCUMENT_NODE-每个 DOM 树的根是一个document节点:

Figure 1.33: The root of the tree is always a document node

图 1.33:树的根总是一个文档节点

需要注意的一件重要的事情是,html节点不是根节点。 当创建 DOM 时,document节点是根节点,它包含html节点。 你可以通过获取document节点的第一个子节点来确认:

Figure 1.34: The html node is the first child of the document node

图 1.34:html 节点是文档节点的第一个子节点

nodeName是节点的另一个重要属性。 在element节点中,nodeName将为您提供 HTML 标签。 其他节点类型将返回不同的内容。 document节点总是返回#document(如上图所示),Text节点总是返回#text

对于像TEXT_NODECDATA_SECTION_NODECOMMENT_NODE这样的文本节点,您可以使用nodeValue来获取它们所包含的文本。

但是关于节点最有趣的事情是你可以像树一样遍历它们。 它们有子节点和兄弟节点。 让我们在接下来的练习中稍微练习一下这些性质。

练习 4:遍历 DOM 树

在本练习中,我们将遍历示例页面中的图 1.1中的所有节点。 我们将使用递归策略遍历所有节点并打印整个树。

执行以下步骤来完成练习:

  1. 第一步是打开文本编辑器并设置它以编写一些 JavaScript 代码。
  2. 要使用递归策略,我们需要为树中的每个节点调用一个函数。 这个函数将接收两个参数:要打印的节点和节点在 DOM 树中的深度。 函数的声明如下:
  3. 我们在函数中要做的第一件事是启动将标识该节点开始位置的消息。 为此,我们将使用nodeName,对于HTMLElements将给出标签,对于其他类型的节点将给出合理的标识符:

    js let message = `${"-".repeat(4 * level)}Node: ${node.nodeName}`;

  4. 如果节点也与nodeValue相关联,如Text和其他文本行节点,我们也将把它添加到消息中,然后将其打印到控制台:

  5. 之后,我们将获取当前节点的所有子节点。 对于某些节点类型,childNodes属性将返回 null,因此我们将添加一个空数组的默认值,以使代码更简单:
  6. 现在我们可以使用for循环遍历数组。 对于我们找到的每个子节点,我们将再次调用该函数,初始化算法的递归性质:
  7. 我们将在函数中做的最后一件事是为有子节点的节点打印一个关闭消息:
  8. Now we can initiate the recursion by calling the function and passing the document as the root node with level zero, just after the function declaration ends:

    js printNodes(document, 0);

    最终代码应该如下所示:https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise04/open_close_tree_print.js

  9. 在 Chrome 中打开 HTML 样本。 这是文件所在的位置:https://bit.ly/2maW0Sx

  10. 打开Developer Tools面板,将 JavaScript 代码粘贴到Console选项卡,运行即可。 下面是你应该看到的输出:

Figure 1.35: Traversing the DOM and printing all the nodes and its children recursively

图 1.35:遍历 DOM 并递归地打印所有节点及其子节点

在这个练习中,您学习了如何使用递归逐个节点导航整个 DOM 树。 您还学习了如何检查节点的属性,因为在导航整个树时,您将看到不是 HTML 的节点,比如文本和注释。

值得注意的一件非常有趣的事情是,浏览器还保留了您添加到 HTML 中的空白。 下面的截图比较了源代码和练习中打印出来的树:

Figure 1.36: Demonstration of how whitespaces also become nodes in your DOM tree

图 1.36:演示了空格如何成为 DOM 树中的节点

你可以看到使用颜色代码的映射:

  • 红色表示包含标题文本的文本节点。
  • 绿色表示整个title元素。
  • Blue boxes and arrows mark the whitespace before and after the title element.

    注意:注意缝隙

    在处理 DOM 节点时,一定要记住并非所有节点都是 HTML 元素。 有些甚至可能是您没有故意放入文档的内容,例如换行符。

我们已经讨论了很多关于节点的内容。 您可以查看 Mozilla Developer Network 文档,了解其他节点属性和方法。 但是您会注意到,节点接口主要关注 DOM 树中节点之间的关系,比如兄弟节点和子节点。 它们非常抽象。 因此,让我们更具体地探讨一下Element类型的节点。

所有 HTML 元素都转换为HTMLElement节点,HTMLElement节点继承自ElementElement继承自一个节点。 它们继承父类型的所有属性和方法。 这意味着一个元素是一个节点,一个HTMLElement实例是一个元素。

因为element代表一个元素(包含所有属性和内部标记的标签),所以可以访问它的属性。 例如,在image元素中,可以读取src属性。 下面是获取首页第一个img元素的src属性的示例:

Figure 1.37: Fetching the src attribute of the first image of a page

图 1.37:获取页面的第一张图像的 src 属性

HTML 元素的另一个有用属性是innerHTML属性。 使用它,您可以获取(并设置)元素的 HTML。 下面是获取第一个具有image类的div并打印其innerHTML的示例:

Figure 1.38: innerHTML can be used to access the HTML inside an element

图 1.38:innerHTML 可以用来访问元素内部的 HTML

还有outerHTML属性,它将为元素本身提供 HTML,包括它里面的所有内容:

Figure 1.39: outerHTML gives the HTML for the element and everything inside it

图 1.39:outerHTML 给出了元素及其内部内容的 HTML

最后但同样重要的是,还有className属性,它可以让你访问应用于元素的类:

Figure 1.40: className gives access to the classes the element has

图 1.40:className 允许访问元素所拥有的类

这些属性更重要的是它们是读/写的,这意味着您可以使用它们来修改 DOM、添加类和更改元素的内容。 在接下来的小节中,我们将使用这里介绍的内容创建基于用户交互的动态页面。

特殊物体

到目前为止,我们已经在许多示例和练习中访问了document对象。 但它到底是什么?它还能做什么? 文档是一个全局对象,它表示浏览器中加载的页面。 正如我们所看到的,它充当 DOM 树中元素的入口点。

到目前为止我们还没有讨论过它的另一个重要作用是在页面中创建新节点和元素。 然后可以将这些元素附加到树的不同位置,以便在页面已经加载之后对其进行修改。 我们将在接下来的章节中探索这种能力。

除了document,DOM 规范中还有另一个对象,它是window对象。 window对象是一个全局对象,它也是浏览器中所有未显式定义绑定目标的 JavaScript 代码的绑定目标。 这意味着变量是指向window对象的指针:

Figure 1.41: The global scope and default bind target in the browser is the window object

图 1.41:浏览器中的全局作用域和默认绑定目标是窗口对象

对象包含所有你需要从浏览器访问:位置,导航历史,其他窗口(弹出),本地存储,以及更多。 documentconsole对象也归为window对象。 当你访问document对象时,你实际上是在使用window.document对象,但是绑定是隐式的,所以你不需要一直写window。 因为window是一个全局对象,这意味着它必须包含一个对自身的引用:

Figure 1.42: The window object contains a reference to itself

图 1.42:窗口对象包含对自身的引用

使用 JavaScript 查询 DOM

我们一直在讨论通过document对象查询 DOM。 但是我们用来查询 DOM 的所有方法也可以从 DOM 中的元素中调用。 本节介绍的元素也可以从 DOM 中的元素中获得。 我们还将看到一些只在元素中可用而不在document对象中可用的元素。

从元素进行查询非常方便,因为查询的范围被限制在它被执行的地方。 活动 1 中我们看到,提取数据从 DOM,我们可以开始查询,发现所有的基本元素——产品元素,在特定情况下,然后我们可以从元素只会执行一个新的查询搜索元素内的元素查询被执行死刑。

在上一节中,我们用来查询 DOM 的方法包括使用childNodes列表直接访问 DOM 中的元素,或者使用getElementsByTagNamegetElementsByClassName方法。 除了这些方法之外,DOM 还提供了其他一些非常强大的查询元素的方法。

首先,有getElement*方法家族:

  • getElementsByTagName-我们以前见过,也用过。 它获取指定标记的所有元素。
  • getElementsByClassName-这是getElement的变体,返回所有具有指定类的元素。 请记住,元素可以通过空格分隔类列表来包含类。 下面是在商店首页运行的代码的截图,你可以看到选择ui类名将获取同样具有itemsteal(颜色)和label类的元素:

图 1.43:通过类名获取元素通常会返回包含其他类的元素
  • getElementById-注意这个方法名称中的单数形式。 这个方法将获取唯一具有指定 ID 的元素。 这是因为 id 在页面上应该是唯一的。

getElement*系列方法是非常有用的。 但有时,仅指定类或标记名是不够的。 这意味着您必须使用使代码非常复杂的操作组合:使用这个类获取所有元素,然后使用另一个标记获取元素,然后使用这个类获取元素,然后选择第三个元素,以此类推。

多年来,jQuery 一直是唯一的解决方案,直到querySelectorquerySelectorAll方法被引入。 这两个方法可用于在 DOM 树上执行复杂的查询。 它们的工作方式完全相同。 两者之间的唯一区别是,querySelector将只返回与查询匹配的第一个元素,而querySelectorAll将返回一个可以迭代的列表。

querySelector*方法使用 CSS 选择器。 您可以使用任何 CSS 选择器来查询元素。 让我们在下一个练习中进一步探讨。

练习 5:使用 querySelector 查询 DOM

在本练习中,我们将探索在前几节中学习的各种查询和节点导航技术。 为此,我们将使用店面代码作为基础 HTML 进行探索,并编写 JavaScript 代码在店面页面上查找所有有机水果的名称。 让事情变得更困难的是,有一种蓝莓松饼的标签是有机的。

在开始之前,让我们看看product元素及其子元素。 下面是从Elements标签查看的product元素的 DOM 树:

Figure 1.44: The product element and its sub-elements

图 1.44:product 元素及其子元素

可以看到,每个产品的根元素是带有class项的div标记。 名称和标记位于带有类内容的子 div 中。 产品的名称位于带有类头的锚中。 标签为一组div标签,分为uilabelteal三类。

在处理类似这样的问题时,如果你想在一个公共父节点下通过一组相互关联的元素进行查询和筛选,有两种常见的方法:

  • 首先查询根元素,然后过滤并找到所需的元素。 下面是这种方法的图形表示:

Figure 1.45: The first approach involves starting from the root element

图 1.45:第一种方法是从根元素开始
  • 从匹配筛选条件一部分的子元素开始,必要时应用额外的筛选,然后导航到要查找的元素。 下面是这种方法的图形表示:

Figure 1.46: The second approach involves starting from a filtering condition

图 1.46:第二种方法从过滤条件开始

执行以下步骤来完成练习:

  1. 为了解决使用第一种方法的练习,我们需要一个函数来检查产品是否包含指定的标签列表。 这个函数的名字将是the,它接收两个参数——product 根元素和要检查的标签列表:
  2. 在这个函数中,我们将使用一些数组映射和过滤来找到参数中指定的标签与被检查产品的标签之间的交集:
  3. 函数中的最后一件事是返回检查,它将告诉我们产品是否包含所有标签。 检查告诉我们交集的大小是否与所有要检查的标签的大小相同,如果这样我们就有一个匹配:
  4. 现在我们可以使用查询方法找到的元素,将它们添加到一个数组,过滤器,并映射到我们想要的,然后打印到控制台:

    js //Start from the product root element

    js Array.from(document.querySelectorAll('.item'))

    js //Filter the list to only include the ones with both labels

    js .filter(e => containLabels(e, 'organic', 'fruit'))

    【4】【5】

    js .map(a => a.innerHTML)

    js //Print to the console

    【显示】 5. 要使用第二种方法解决这个问题,我们需要一个函数来查找指定元素的所有兄弟元素。 打开文本编辑器,让我们从声明一个数组函数开始,该数组存储我们找到的所有兄弟类。 然后返回数组:

    js function getAllSiblings(element) {

    js   const siblings = [];

    js   // rest of the code goes here

    js   return siblings;

    js }

  5. Then, we'll iterate over all previous sibling elements using a while loop and the previousElementSibling attribute. As we iterate over the siblings, we'll push them into the array:

    js let previous = element.previousElementSibling;

    js while (previous) {

    js   siblings.push(previous);

    js   previous = previous.previousElementSibling;

    js }

    注意:再次注意缝隙

    我们使用previousElementSibling代替previousNode,因为它将排除所有文本节点和其他节点,以避免为每个节点检查nodeType

  6. js let next = element.nextElementSibling;

    js while (next) {

    js   siblings.push(next);

    js   next = next.nextElementSibling;

    js }

  7. 现在我们有了getAllSiblings功能,我们可以开始寻找产品了。 我们可以使用querySelectorAll功能,一些数组映射和过滤查找和打印数据,我们希望:

    js //Start by finding all the labels with content 'organic'

    js Array.from(document.querySelectorAll('.label'))

    【4】【5】

    js .filter(e => getAllSiblings(e).filter(s => s.innerHTML === 'fruit').length > 0)

    js //Find root product element

    【显示】

    js //Find product name

    js .map(p => p.querySelector('.content a.header').innerHTML)

    js //Print to the console

    【病人】 9. 在Developer ToolsConsole选项卡中执行代码,你会看到如下输出:

Figure 1.47: Output of the code from the exercise. Prints the names of all organic fruits.

图 1.47:练习的代码输出。 打印所有有机水果的名字。

请注意

这个练习的代码可以在 GitHub 上找到。 这是包含第一种方法代码的文件的路径:https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise05/first_approach.js

包含第二种方法代码的文件的路径是:https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise05/second_approach.js

在本练习中,我们使用了两种不同的技术从页面中获取数据。 我们使用了许多查询和节点导航方法以及属性来查找元素并在 DOM 树中移动。

在构建现代 web 应用时,这些技术是必不可少的。 在这类应用中,导航 DOM 和获取数据是最常见的任务。

操作 DOM

现在我们已经知道了什么是 DOM 以及如何查询元素并围绕它进行导航,现在该学习如何使用 JavaScript 更改它了。 在本节中,我们将通过加载产品列表和使用 JavaScript 创建页面元素来重写店面,使其更具交互性。

本节的示例代码可以在 GitHub 上的https://bit.ly/2mMje1K上找到。

在使用 JavaScript 创建动态应用时,我们首先需要知道的是如何创建新的 DOM 元素并将它们添加到树中。 因为 DOM 规范都是基于接口的,所以没有具体的类需要实例化。 当你想要创建 DOM 元素时,你需要使用document对象。 对象有一个名为createElement的方法,它接收一个字符串形式的标签名。 下面是一个创建div元素的代码示例:

const root = document.createElement('div');

product元素有一个item类。 要将这个类添加到它中,只需设置className属性,如下所示:

root.className = 'item';

现在我们可以把元素附加到它需要去的地方。 但首先,我们得找到它的去向。 这个示例代码的 HTML 可以在 GitHub 上的https://bit.ly/2nKucVo找到。 你可以看到它有一个空的div元素,产品项目将被添加:

<div class="ui items"></div>

我们可以使用querySelector来找到那个元素,然后调用它的appendChild方法,这是每个节点都有的方法,然后将我们刚刚创建的元素节点传递给它,这样它就会被添加到 DOM 树中:

const itemsEl = document.querySelector('.items');
products.forEach((product) => {
  itemsEl.appendChild(createProductItem(product));
});

这里,createProductItem是接收产品并使用前面提到的createElement函数为其创建 DOM 元素的函数。

创建一个 DOM 元素并不是很有用。 对于动态店面示例,我们有一个对象数组,其中包含构建页面所需的所有数据。 对于它们中的每一个,我们需要创建所有 DOM 元素,并将它们按正确的位置和顺序粘在一起。 但首先,让我们看看数据是怎样的。 下面展示了每个product对象的外观:

{
  "price": 3.99,
  "unit": "lb",
  "name": "Apples",
  "description": "Lorem ipsum dolor sit amet, ...",
  "image": "img/products/apples.jpg",
  "tags": [ "fruit", "organic" ]
}

下面是我们在前几节中使用的静态店面代码中相同产品的 DOM 外观:

Figure 1.48: The DOM tree section for a product

图 1.48:产品的 DOM 树部分

您可以看到,需要创建许多嵌套的元素来获得所需的最终 DOM 树。 因此,让我们看看在使用 JavaScript 构建复杂应用时非常有用的一些技术。

让我们先看一下示例代码中的createProductItem:

function createProductItem(product) {
  const root = document.createElement('div');
  root.className = 'item';
  root.appendChild(createProductImage(product.image));
  root.appendChild(createContent(product));
  return root;
}

我们通过创建产品树的根元素开始这个方法,它是一个div元素。 从上面的截图中,你可以看到这个div需要一个item类,这就是元素创建后的下一行所发生的事情,正如本节开始部分所描述的。

元素准备好之后,就可以开始向它添加其子元素了。 不是用相同的方法做所有的事情,而是创建其他函数,负责创建每个子元素并直接调用它们,将每个函数的结果附加到根元素:

root.appendChild(createProductImage(product.image));
root.appendChild(createContent(product));

这种技术很有用,因为它将每个子节点的逻辑隔离在自己的位置上。

现在我们来看一下createProductImage函数。 从前面的示例代码中,可以看到函数接收到product图像的路径。 下面是函数的代码:

function createProductImage(imageSrc) {
  const imageContainer = document.createElement('div');
  imageContainer.className = 'image';
  const image = document.createElement('img');
  image.setAttribute('src', imageSrc);
  imageContainer.appendChild(image);
  return imageContainer;
}

功能分为两大部分:

  1. 它为图像创建容器元素。 从 DOM 截图中,你可以看到img元素在divimage类中。
  2. 它创建img元素,设置src属性,然后将其添加到container元素。

这种风格的代码简单、易读、易于理解。 但这是因为需要生成的 HTML 非常短。 它是一个img标签在一个div标签。

但是,有时候,树会变得非常复杂,使用这种策略会使代码几乎不可读。 那么,让我们来看看另一种策略。 附加到 product 根目录的另一个子元素是content元素。 这是一个有许多子标签的div,包括一些嵌套的子标签。

我们可以用createProductImage函数的方法来处理它。 但是这个方法必须做到以下几点:

  1. 创建一个container元素并向其添加一个类。
  2. 创建存储产品名称的锚元素并将其附加到容器中。
  3. 创建用于价格的容器并将其附加到根容器。
  4. 创建带有 price 的span元素,并将其添加到上一步创建的元素中。
  5. 创建包含描述的元素并将其添加到容器中。
  6. tag元素创建一个container元素,并将其附加到根容器中。
  7. 对于每个标签,创建tag元素并将其添加到容器中。

这听起来像是一长串步骤,不是吗? 我们可以使用一个模板字符串来生成 HTML,然后为container元素设置innerHTML,而不是尝试编写所有这些代码。 因此,步骤应该如下所示:

  1. 创建container元素并向其添加一个类。
  2. 使用字符串模板创建内部内容的 HTML。
  3. container元素上设置innerHTML

这听起来比以前的方法简单多了。 而且,我们会看到,它的可读性也会更强。 让我们看一下代码。

如前所述,第一步是创建根容器并为它添加类:

function createContent(product) {
  const content = document.createElement('div');
  content.className = 'content';

然后,我们开始为tag元素生成 HTML。 为此,我们有一个函数,它将标签作为字符串接收,并为它返回一个 HTML 元素。 我们使用它来使用tags数组上的map函数将所有标记映射到元素。 然后,我们使用元素的outerHTML属性将元素映射到 HTML:

 const tagsHTML = product.tags.map(createTagElement)
    .map(el => el.outerHTML)
    .join('');

container元素创建好了,标签的 HTML 也准备好了,我们可以使用模板字符串设置content元素的innerHTML属性并返回它:

  content.innerHTML = `
    <a class="header">${product.name}</a>
    <div class="meta"><span>$${product.price} / ${product.unit}</span></div>
    <div class="description">${product.description}</div>
    <div class="extra">${tagsHTML}</div>
  `;
  return content;
}

与生成 HTML 元素和附加元素所需的许多步骤相比,这段代码更短,也更容易理解。 在编写动态应用时,应该由您来决定每种情况下什么是最好的。 在这种情况下,主要的权衡是可读性和简洁性。 但对于其他元素,也可能需要缓存元素以添加事件监听器,或者基于某些过滤器隐藏/显示它们。

练习 6:过滤和搜索产品

在本练习中,我们将向店面应用添加两个特性,以帮助客户更快地找到产品。 首先,我们将使标签可单击,这将根据所选标签过滤产品列表。 然后,我们将在顶部添加一个搜索框,用户可以通过名称或描述中的文本进行查询。 这是页面的外观:

Figure 1.49: New storefront with a search bar at the top

图 1.49:顶部有搜索栏的新店面

在这个新的店面中,用户可以点击标签来过滤具有相同标签的产品。 当他们这样做时,用于过滤列表的标签将显示在顶部,以橙色显示。 用户可以单击搜索栏中的标签来删除过滤器。 它看起来是这样的:

Figure 1.50: How the tag filtering at the top works

图 1.50:顶部的标签过滤如何工作

用户还可以使用右边的搜索框根据名称或描述搜索产品。 当他们键入时,列表将被过滤。

这个练习的代码可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise06找到。

执行以下步骤来完成练习:

  1. The first thing we'll do is write the base HTML code where all the other elements will be added later using JavaScript. This HTML now contains a base div container, where all the content will be. The content inside it is then divided into two parts: a section with the header, which contains the title and the search bar, and a div, which will contain all the product items. Create a file called dynamic_storefront.html and add the following code in it:

    js <html>

    js   <head>

    js     <link rel="stylesheet" type="text/css" href="../css/semantic.min.css" />

    js     <link rel="stylesheet" type="text/css" href="../css/store_with_header.css" />

    js   </head>

    js   <body>

    js     <div id="content">

    js       <section class="header">

    js         <h1 class="title">Welcome to Fresh Products Store!</h1>

    js         <div class="ui menu">

    js           <div class="right item">

    js             <div class="ui icon input">

    js               <input type="text" placeholder="Search..." />

    js               <i class="search icon"></i>

    js             </div>

    js           </div>

    js         </div>

    js       </section>

    js       <div class="ui items"></div>

    js     </div>

    js     <script src="../data/products.js"></script>

    js     <script src="../sample_003/create_elements.js"></script>

    js     <script src="filter_and_search.js"></script>

    js   </body>

    js </html>

    这个 HTML 使用了products.jscreate_elements.js脚本,它们与本节中使用的示例代码相同。 它还使用了Lesson01文件夹中的 CSS 文件。 如果您在同一个文件夹中,可以直接引用它们,或者将它们复制并粘贴到您的项目中。

  2. 创建一个名为filter_and_search.js的文件,它是 HTML 代码中加载的最后一个 JavaScript 代码。 我们将在这里添加本练习的所有代码。 我们需要做的第一件事是存储过滤器状态。 用户可以对页面应用两种可能的过滤器:选择标签和/或键入一些文本。 为了存储它们,我们将使用一个数组和一个字符串变量:

  3. 现在,我们将创建一个函数,为页面中的所有标记添加事件监听器。 这个函数会发现所有tag元素,包装在一个数组,并添加一个事件监听器使用Element中的addEventListener方法应对事件:click【4】【5】

    js     tagEl.addEventListener('click', () => {

    js       // code for next step goes here

    【显示】

    js   });

    js }

  4. 在事件监听器中,我们将检查标记是否已经在要过滤的标记数组中。 如果没有,我们将添加它,并调用另一个函数,称为applyTagFilters:

    js if (!tagsToFilterBy.includes(tagEl.innerHTML)) {

    js   tagsToFilterBy.push(tagEl.innerHTML);

    js   applyFilters();

    js }

  5. applyFilters只是一个包罗万象的函数,它将包含与过滤条件改变时更新页面相关的所有逻辑。

    js function applyFilters() {

    js   createListForProducts(filterByText(filterByTags(products)));

    js   addTagFilter();

    js   updateTagFilterList();

    js }

  6. 在我们继续使用applyFilters函数之前,我们将添加另一个函数来处理文本搜索输入框上的事件。 这个处理程序将监听keyup事件,当用户输入每个字母时触发这些事件。 处理程序只获取输入中的当前文本,将值设置为textToSearch变量,然后调用applyFilters函数:

  7. 现在,回到applyFilters函数。 其中调用的第一个函数几乎是隐藏的。 它是filterByTags函数,它使用tagsToFilterBy数组过滤产品列表。

    js function filterByTags() {

    js   let filtered = products;

    js   tagsToFilterBy

    js     .forEach((t) => filtered = filtered.filter(p => p.tags.includes(t)));

    js   return filtered;

    js }

  8. Whatever comes out of the filter function is passed to another filter function, the one that filters the products based on the text search. The filterByText function transforms all text to lowercase before comparing. That way, the search will always be case-insensitive:

    js function filterByText(products) {

    js   const txt = (textToSearch || '').toLowerCase();

    js   return products.filter((p) => {

    js     return p.name.toLowerCase().includes(txt)

    js       || p.description.toLowerCase().includes(txt);

    js   });

    js }

    在经过选定的标签和键入的文本过滤之后,我们将过滤后的值传递给createListForProducts,这是create_elements.js中的一个函数,本节将对其进行描述。

  9. 现在页面上已经显示了新的产品列表,我们需要重新注册标记筛选器事件监听器,因为 DOM 树元素已经重新创建。 因此,我们再次调用addTagFilterapplyFilters功能如下:

    js function applyFilters() {

    js   createListForProducts(filterByText(filterByTags(products)));

    js   addTagFilter();

    js   updateTagFilterList();

    js }

  10. applyTagFilter函数中调用的最后一个函数是updateTagFilterList。 这个函数会将过滤器的元素指标,检查是否有标签选择滤波器,并相应地更新它,将文本设置为No filters或添加一个指标为每个标签应用:

    js function updateTagFilterList() {

    【4】【5】

    js     tagHolder.innerHTML = 'No filters';

    js   } else {

    【显示】

    js     tagsToFilterBy.sort();

    js     tagsToFilterBy.map(createTagFilterLabel)

    js       .forEach((tEl) => tagHolder.appendChild(tEl));

    【病人】

    js }

  11. 我们需要将所有这些联系在一起的最后一个函数是createTagFilterLabel函数,它用于创建标记在搜索栏中被选中的指示符。 这个函数将创建 DOM 元素,并添加一个事件监听器,当点击时,会从数组中删除标签,又叫applyTagFilter功能:

    js function createTagFilterLabel(tag) {

    js   const el = document.createElement('span');

    【4】【5】

    js   el.addEventListener('click', () => {

    js     const index = tagsToFilterBy.indexOf(tag);

    【显示】

    js     applyTagFilter();

    js   });

    【病人】

    js }

  12. The last step you need to take to make the page work is to call the applyTagFilter function so that it will update the page to the initial state, which is no tags selected. Also, it will call addTextSearchFilter to add the event handler for the textbox:

    js addTextSearchFilter();

    js applyFilters();

    在 Chrome 中打开页面,你会看到过滤器顶部是空的,所有产品都显示在列表中。 它看起来像这个练习开始时的截图。 单击标签或在文本框中输入内容,您将看到页面更改,以反映新的状态。 例如,选择两个饼干面包店标签和输入巧克力文本框会使页面只显示产品这两个标签和【T6 巧克力】在他们的名字或描述:

Figure 1.51: The storefront filtered by the two bakery and  tags and the word chocolate

图 1.51:通过两个 bakery 和 cookie 标签以及单词 chocolate 过滤的店面

在这个练习中,您了解了如何响应用户事件并相应地更改页面以反映用户希望页面处于的状态。 您还了解到,当元素被删除并重新添加到页面时,事件处理程序会丢失,需要重新注册。

Shadow DOM and Web Components

在前几节中,我们已经看到一个简单的 web 应用可能需要复杂的编码。 当应用变得越来越大时,它们就变得越来越难维护。 代码开始变得混乱,一个地方的更改会影响到其他意想不到的地方。 这是因为 HTML、CSS 和 JavaScript 的全局特性。

创建了大量的解决方案来规避这个问题,和World Wide Web Consortium (W3C)开始工作建议创建自定义的标准方法,分离组件,可以有自己的风格和 DOM 根。 Shadow DOM 和自定义组件就是由此产生的两个标准。****

**Shadow DOM 是一种创建独立 DOM 子树的方法,它可以有自己的样式,并且不受添加到父树的样式的影响。 它还隔离了 HTML,这意味着在文档树中使用的 id 可以在每个影子树中重用多次。

下图说明了处理 Shadow DOM 时涉及的概念:

Figure 1.52: Shadow DOM concepts

图 1.52:Shadow DOM 的概念

让我们来描述一下这些概念的含义:

  • 文档树是页面的主 DOM 树。
  • 影子主机是影子树所连接的节点。
  • Shadow Tree是一个独立的 DOM 树,附加在文档树上。
  • 影子根是影子树中的根元素。

影子主机是文档树中的一个元素,在文档树中附加了影子树。 Shadow Root 元素是不在页面上显示的节点,就像主文档树中的文档对象一样。

为了理解它是如何工作的,让我们从一些带有一些奇怪样式的 HTML 开始:

<style>
  p {
    background: #ccc;
    color: #003366;
  }
</style>

这将使页面上的每个段落都有一个灰色的背景和一些蓝色的文本。 这是这个页面上的一段文字的样子:

Figure 1.53: Paragraph with the styles applied

图 1.53:应用样式的段落

让我们添加一个阴影树,并在其中添加一个段落,以查看它的行为。 我们将用一个div元素包装段落元素,并添加一些文本:

<div><p>I'm in a Shadow DOM tree.</p></div>

然后我们可以在一个元素中使用attachShadow方法来创建一个阴影根元素:

const shadowHost = document.querySelector('div');
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });

上面的代码从页面中选择div元素,然后调用attachShadow方法,将一个配置对象传递给它。 配置说明这个影子树是开放的,这意味着它的影子根可以通过影子树附加到的元素的shadowRoot属性访问,在本例中:

Figure 1.54: Open shadow trees can be accessed through the element where the tree is attached

图 1.54:可以通过附加树的元素访问打开的阴影树

阴影树可以关闭,但不建议采用这种方法,因为它会给用户一种错误的安全感,并使用户的生活更加困难。

在将阴影树附加到文档树之后,就可以开始操作它了。 让我们将 HTML 从影子主机复制到影子根目录,看看会发生什么:

shadowRoot.innerHTML = shadowHost.innerHTML;

现在,如果你在 Chrome 中加载页面,你会看到以下内容:

Figure 1.55: Page with the shadow DOM loaded

图 1.55:加载了阴影 DOM 的页面

您可以看到,即使添加到页面的样式选择了所有段落,添加到阴影树的段落也不会受到它的影响。 Shadow DOM 中的元素与文档树完全隔离。

如果您现在查看 DOM,您会发现有些东西看起来很奇怪。 阴影树替换并包装了div元素中的段落,它是阴影宿主:

Figure 1.56: The shadow tree is at the same level as the other nodes in the shadow host

图 1.56:影子树与影子主机中的其他节点处于同一层

但是影子主机内部的原始段落不会呈现在页面上。 这是因为当浏览器呈现页面时,如果元素包含带有新内容的阴影树,它将替换主机下的当前树。 这个过程被称为扁平化,下面的图表描述了它的工作原理:

图 1.57:当缩放时,浏览器会忽略阴影主机下的节点

现在我们了解了 Shadow DOM 是什么,我们可以开始使用它来构建或拥有 HTML 元素。 这是正确的! 使用自定义组件 api,您可以创建自己的 HTML 元素,然后像使用其他元素一样使用它。

在本节的其余部分中,我们将构建一个名为counter的自定义组件,它有两个按钮和中间的文本。 您可以单击按钮来增加或减少存储的值。 您还可以将其配置为具有一个初始值和一个不同的增量值。 下面的截图显示了完成后组件的样子。 这个代码在 GitHub 上的https://bit.ly/2mVy1XP:

Figure 1.58: The counter component and how it is used in HTML

图 1.58:计数器组件及其在 HTML 中的使用方法

要定义您的自定义组件,您需要调用自定义组件注册表中的define方法。 注册表有一个全局实例,称为customElements。 要注册你的组件,你调用define,传递你的组件将被引用的字符串。 至少要有一个破折号。 您还需要传递实例化组件的构造函数。 以下是代码:

customElements.define('counter-component', Counter);

构造函数可以是普通函数,也可以使用新的 JavaScriptclass定义。 需要延长HTMLElement:

class Counter extends HTMLElement {
}

要将自定义组件与页面的其余部分隔离,可以使用阴影树,其中阴影主机是组件元素。 您不需要使用 Shadow DOM 来构建自定义组件,但推荐使用它来构建更复杂的组件,这些组件也将包装一些样式。

在元素的构造函数中,你可以通过调用attachShadow到你自己的实例来创建影子根:

constructor() {
  super(); // always call super first
  // Creates the shadow DOM to attach the parts of this component
  this.attachShadow({mode: 'open'});
  // ... more code here
}

请记住,当您使用open模式将阴影 DOM 附加到元素时,元素会将阴影根存储在shadowRoot属性中。 所以,从现在开始,我们可以用this.shadowRoot来指代它。

在上图中,您可以看到counter组件有两个属性用于配置自己:valueincrement。 使用ElementgetAttribute方法在构造函数的开头设置这些值,如果它们不可用,则设置合理的默认值:

this.value = parseInt(this.getAttribute('value') || 0);
this.increment = parseInt(this.getAttribute('increment') || 1);

之后,我们为这个组件创建所有的 DOM 元素,并将它们附加到影子根。 由于您已经了解了足够多的 DOM 操作,所以我们不打算深入研究细节。 在构造函数中,我们只需要调用创建这些元素的函数,然后使用this.shadowRoot.appendChild将它们添加到后面:

// Create and attach the parts of this component
this.addStyles();
this.createButton('-', () => this.decrementValue());
this.createValueSpan();
this.createButton('+', () => this.incrementValue());

第一个方法创建一个link元素,为counter组件导入 CSS 文件。 第二个和第四个方法创建decrementincrement按钮并附加事件处理程序。 第三个方法创建一个span元素,并在property跨度下保持对它的引用。

incrementValuedecrementValue方法将当前值增加指定的数量,然后调用updateState方法,该方法将值的状态同步到 DOM(在本例中为 Shadow DOM)。 incrementValueupdateState方法代码如下:

incrementValue() {
  this.value += this.increment;
  this.triggerValueChangedEvent();
  this.updateState();
}
updateState() {
  this.span.innerText = `Value is: ${this.value}`;
}

incrementValue函数中,我们也调用该函数来触发事件,通知用户值发生了变化。 这个函数将在后面讨论。

现在已经定义并注册了新的HTMLElement,可以像使用任何其他现有的 HTML 元素一样使用它。 你可以在 HTML 代码中通过标签添加它,如下所示:

<counter-component></counter-component>
<counter-component value="7" increment="3"></counter-component>

或者,通过 JavaScript 创建一个元素并将其添加到 DOM 中:

const newCounter = document.createElement('counter-component');
newCounter.setAttribute('increment', '2');
newCounter.setAttribute('value', '3');
document.querySelector('div').appendChild(newCounter);

要完全理解 web 组件的力量,你需要知道的最后两件事:回调和事件。

自定义组件具有生命周期回调,您可以在类中设置这些回调,以便在它们周围发生变化时获得通知。 最重要的两个是connectedCallbackattributeChangedCallback

当您希望在添加组件后对 DOM 进行操作时,第一个选项非常有用。 对于counter组件,我们只是在控制台上打印一些东西,以显示该组件现在已连接到 DOM:

connectedCallback() {
  console.log("I'm connected to the DOM!");
}

当页面加载时,你可以看到每个添加到 DOM 的counter组件打印的语句:

Figure 1.59: Statement printed in the console when counter components are attached to the DOM

图 1.59:当计数器组件附加到 DOM 时,控制台中打印的语句

当组件中的某些属性被更改时,将调用attributeChangedCallback。 但是要使其工作,您需要一个静态 getter,它将告诉您希望在发生更改时通知哪些属性。 下面是静态 getter 的代码:

static get observedAttributes() {
  return ['value', 'increment'];
}

它只是返回一个数组,其中包含我们想要被通知的所有属性。 attributeChangedCallback接收一些参数:更改的属性的名称,旧的值(如果没有设置,这将是空的),和新值。 下面是计数器组件的回调代码:

attributeChangedCallback(attribute, _, newValue) {
  switch(attribute) {
    case 'increment':
      this.increment = parseInt(newValue);
      break;
    case 'value':
      this.value = parseInt(newValue);
      break;
  }
  this.updateState();
}

我们的回调函数检查属性名时会忽略旧值,因为我们不需要它,然后转换它,将它解析为整数,然后根据属性名相应地设置新值。 最后,它调用updateState函数,该函数将根据组件的属性更新组件的状态。

关于 web 组件,你需要知道的最后一件事是如何分派事件。 事件是标准组件的重要组成部分; 它们构成了所有与用户交互的基础。 正因为如此,将逻辑封装到组件中的很大一部分是为了理解组件的用户会对哪些事件感兴趣。

对于我们的计数器组件,在每次值改变时分派一个事件是很有意义的。 在事件中传递该值也很有用。 这样,用户就不需要查询组件来获取当前值。

要分派自定义事件,我们可以使用ElementdispatchEvent方法,并使用CustomEvent构造函数用自定义数据构建事件。 我们活动的名字是value-changed。 用户可以添加事件处理程序来监听此事件,并在值更改时得到通知。

下面的代码是前面提到的triggerValueChangedEvent函数; 这个函数从incrementValuedecrementValue函数中调用:

triggerValueChangedEvent() {
  const event = new CustomEvent('value-changed', { 
    bubbles: true,
    detail: { value: this.value },
  });
  this.dispatchEvent(event);
}

这个函数创建了一个CustomEvent的实例,它在 DOM 中冒泡,并在detail属性中包含当前值。 我们可以创建一个普通的事件实例并直接在对象上设置属性,但是对于自定义事件,建议使用CustomEvent构造函数,它可以正确地处理自定义数据。 创建事件后,调用dispatchEvent方法,传递事件。

现在我们已经发布了事件,我们可以注册并在页面上显示信息。 下面是查询所有counter-components并为value-changed事件添加事件监听器的代码。 每当一个组件被单击时,处理程序就会向现有的div添加一个段落:

const output = document.getElementById('output');
Array.from(document.querySelectorAll('counter-component'))
  .forEach((el, index) => {
    el.addEventListener('value-changed', (e) => {
    output.innerHTML += '<p>Counter ${index} value is now ${e.detail.value}</p>';
  });
});

这是在不同的计数器上点击几次后页面的样子:

Figure 1.60: Paragraphs added to the page showing that the counters were clicked

图 1.60:添加到页面上显示计数器被点击的段落

练习 7:用 Web 组件替换搜索框

为了充分理解 web 组件的概念,您需要了解如何将应用分解为封装的、可重用的组件。 我们在前面的练习中创建的店面页面是一个很好的开始。

在这个练习中,我们将编写一个 web 组件来替换页面右上角的搜索框。 这是我们正在讨论的组件:

Figure 1.61: Search box that will be transformed into a web component

图 1.61:搜索框将被转换为 web 组件

其思想是,组件将处理它的外观、呈现和状态,当状态改变时,它将发出事件。 在这种情况下,搜索框只有一个状态:搜索文本。

执行以下步骤来完成练习:

  1. 将代码从Exercise 6复制到一个新文件夹中,这样我们就可以在不影响现有店面的情况下更改它。
  2. 让我们从创建一个 web 组件开始。 创建一个名为search_box.js的文件,添加一个名为SearchBox的新类,并使用这个类定义一个新组件:
  3. 在类中,添加一个构造函数,调用super,并将组件附加到影子根。 构造函数还将通过设置一个名为_searchText to:

    js constructor() {

    js   super();

    js   this.attachShadow({ mode: 'open' });

    js   this._searchText = '';

    js }

    的变量来初始化状态。 4. 为了暴露当前状态,我们将向_searchText字段添加一个 getter: 5. 仍然在类中,创建一个名为render的方法,它将把shadowRoot.innerHTML设置为我们想要的模板组件。 在本例中,它将搜索框的现有的 HTML +链接语义界面风格,这样我们可以重用:

    js render() {

    js   this.shadowRoot.innerHTML = '

    【4】【5】

    js       <input type="text" placeholder="Search..." />

    js       <i class="search icon"></i>

    【显示】

    js   ';

    js }

  4. 创建另一个名为triggerTextChanged的方法,它将触发一个事件,通知侦听器搜索文本更改。

    js triggerTextChanged(text) {

    js   const event = new CustomEvent('changed', {

    js     bubbles: true,

    js     detail: { text },

    js   });

    js   this.dispatchEvent(event);

    js }

  5. 在构造函数中,在附加了影子根之后,调用render方法并向输入框注册一个侦听器,这样我们就可以触发组件的更改事件。

    js constructor() {

    js   super();

    js   this.attachShadow({ mode: 'open' });

    js   this._searchText = '';

    js   this.render();

    js   this.shadowRoot.querySelector('input').addEventListener('keyup', (e) => {

    js     this._searchText = e.target.value;

    js     this.triggerTextChanged(this._searchText);

    js   });

    js }

  6. 我们的网络组件准备好了,我们可以用它来取代旧的搜索框。 在dynamic_storefront.htmlHTML 中,将div标签替换为uiiconinput类及其所有内容,并使用我们创建的新组件:search-box。 另外,在所有其他脚本之前,将新的 JavaScript 文件添加到 HTML 中。 你可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise07/dynamic_storefront.html上看到最终的 HTML。

  7. 使用querySelector方法从文档中保存对search-box组件的引用:
  8. 为已更改的事件注册一个事件监听器,以便我们知道何时有新值可用,并调用applyFilters:

    js searchBoxElement.addEventListener('changed', (e) => applyFilters());

  9. Now we can clean the filter_and_search.js JavaScript since part of the logic was moved to the new component. We'll do the following cleanup:

    删除textToSearch变量(第 2 行),用searchBoxElement.searchText替换它(第 40 行)。

    在脚本末尾删除addTextSearchFilter函数(第 16-22 行)和对它的调用(第 70 行)。

    如果一切顺利,在 Chrome 中打开文件会得到完全相同的店面,这正是我们想要的。

现在处理搜索框和搜索文本的逻辑是封装的,这意味着如果我们需要更改它,我们不需要四处寻找分散的代码块。 当我们需要知道搜索文本的值时,我们可以查询保存它的组件。

活动 2:用 Web 组件替换标签过滤器

现在我们已经用 web 组件替换了搜索框,让我们用同样的技术替换标签过滤器。 我们的想法是,我们将有一个组件来存储选中标签的列表。

该组件将封装可使用mutator方法(addTagremoveTag)修改的选定标签列表。 当内部状态发生变化时,将触发一个已更改的事件。 此外,当单击列表中的标签时,将触发一个tag-clicked事件。

步骤:

  1. 首先将练习 7 中的代码复制到一个新文件夹中。
  2. 创建一个名为tags_holder.js的新文件,并在其中添加一个扩展HTMLElement的类TagsHolder,然后定义一个新的自定义组件tags-holder
  3. 创建两个render方法:一个用于呈现基本状态,另一个用于呈现标记或一些表示没有选择标记进行过滤的文本。
  4. 在构造函数中,调用super,将组件附加到影子根,初始化选中的标签列表,并同时调用render方法。
  5. 创建一个 getter 来公开选定标记的列表。
  6. 创建两个触发方法:一个触发changed事件,一个触发tag-clicked事件。
  7. 创建两个mutator方法:addTagremoveTag。 这些方法接收标记名,如果标记不存在,则在选中的标记列表中添加标记;如果存在,则删除标记。 如果列表被修改,则触发changed事件并调用该方法来重新呈现标签列表。
  8. 在 HTML 中,用新组件替换现有代码,并向其中添加新的脚本文件。
  9. In filter_and_search.js, remove the tagsToFilterBy variable and replace it with the new mutator methods and events in the newly created component.

    请注意

    这个活动的解决方案可以在 584 页找到。

小结

在本章中,我们通过学习 DOM 的基本接口、属性和方法来探索 DOM 规范。 我们了解了您编写的 HTML 和浏览器从中生成的树之间的关系。 我们查询 DOM 并导航 DOM 树。 我们学习了如何创建新元素、将它们添加到树中以及操作现有元素。 最后,我们学习了如何使用 Shadow DOM 来创建独立的 DOM 树和定制组件,这些组件可以很容易地在 HTML 页面中重用。

在下一章中,我们将切换到后端世界。 我们将开始学习 Node.js 及其相关的基本概念。 我们将通过使用nvm来安装和管理多个版本的 Node.js,最后但并非最不重要的是,我们还将学习npm以及如何找到和使用外部模块。**