18日 七月 2020

修改文档(document)

DOM 修改是创建“实时”页面的关键。

在这里,我们将会看到如何“即时”创建新元素并修改现有页面内容。

例子:展示一条消息

让我们使用一个示例进行演示。我们将在页面上添加一条比 alert 更好看的消息。

它的外观如下:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert">
  <strong>Hi there!</strong> You've read an important message.
</div>

这是一个 HTML 示例。现在,让我们使用 JavaScript 创建一个相同的 div(假设样式已经在 HTML/CSS 文件中)。

创建一个元素

要创建 DOM 节点,这里有两种方法:

document.createElement(tag)

用给定的标签创建一个新 元素节点(element node)

let div = document.createElement('div');
document.createTextNode(text)

用给定的文本创建一个 文本节点

let textNode = document.createTextNode('Here I am');

Most of the time we need to create element nodes, such as the div for the message.

创建一条消息

Creating the message div takes 3 steps:

// 1. Create <div> element
let div = document.createElement('div');

// 2. Set its class to "alert"
div.className = "alert";

// 3. Fill it with the content
div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

我们已经创建了该元素。但到目前为止,它还只是在一个名为 div 的变量中,尚未在页面中。所以我们无法在页面上看到它。

插入方法

为了让 div 显示出来,我们需要将其插入到 document 中的某处。例如,into <body> element, referenced by document.body.

对此有一个特殊的方法 appenddocument.body.append(div)

这是完整代码:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

  document.body.append(div);
</script>

Here we called append on document.body, but we can call append method on any other element, to put another element into it. For instance, we can append something to <div> by calling div.append(anotherElement).

Here are more insertion methods, they specify different places where to insert:

  • node.append(...nodes or strings) — 在 node 末尾 插入节点或字符串,
  • node.prepend(...nodes or strings) — 在 node 开头 插入节点或字符串,
  • node.before(...nodes or strings) — 在 node 前面 插入节点或字符串,
  • node.after(...nodes or strings) — 在 node 后面 插入节点或字符串,
  • node.replaceWith(...nodes or strings) — 将 node 替换为给定的节点或字符串。

Arguments of these methods are an arbitrary list of DOM nodes to insert, or text strings (that become text nodes automatically).

Let’s see them in action.

下面是使用这些方法将列表项添加到列表中,以及将文本添加到列表前面和后面的示例:

<ol id="ol">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  ol.before('before'); // 将字符串 "before" 插入到 <ol> 前面
  ol.after('after'); // 将字符串 "after" 插入到 <ol> 后面

  let liFirst = document.createElement('li');
  liFirst.innerHTML = 'prepend';
  ol.prepend(liFirst); // 将 liFirst 插入到 <ol> 的最开始

  let liLast = document.createElement('li');
  liLast.innerHTML = 'append';
  ol.append(liLast); // 将 liLast 插入到 <ol> 的最末尾
</script>

这张图片直观地显示了这些方法所做的工作:

因此,最终列表将为:

before
<ol id="ol">
  <li>prepend</li>
  <li>0</li>
  <li>1</li>
  <li>2</li>
  <li>append</li>
</ol>
after

如上所述,这些方法可以在单个调用中插入多个节点列表和文本片段。

例如,在这里插入了一个字符串和一个元素:

<div id="div"></div>
<script>
  div.before('<p>Hello</p>', document.createElement('hr'));
</script>

Please note: the text is inserted “as text”, not “as HTML”, with proper escaping of characters such as <, >.

所以,最终的 HTML 为:

&lt;p&gt;Hello&lt;/p&gt;
<hr>
<div id="div"></div>

换句话说,字符串被以一种安全的方式插入到页面中,就像 elem.textContent 所做的一样。

所以,这些方法只能用来插入 DOM 节点或文本片段。

But what if we’d like to insert an HTML string “as html”, with all tags and stuff working, in the same manner as elem.innerHTML does it?

insertAdjacentHTML/Text/Element

为此,我们可以使用另一个非常通用的方法:elem.insertAdjacentHTML(where, html)

该方法的第一个参数是代码字(code word),指定相对于 elem 的插入位置。必须为以下之一:

  • "beforebegin" — 将 html 插入到 elem 前插入,
  • "afterbegin" — 将 html 插入到 elem 开头,
  • "beforeend" — 将 html 插入到 elem 末尾,
  • "afterend" — 将 html 插入到 elem 后。

第二个参数是 HTML 字符串,该字符串会被“作为 HTML” 插入。

例如:

<div id="div"></div>
<script>
  div.insertAdjacentHTML('beforebegin', '<p>Hello</p>');
  div.insertAdjacentHTML('afterend', '<p>Bye</p>');
</script>

……将导致:

<p>Hello</p>
<div id="div"></div>
<p>Bye</p>

这就是我们可以在页面上附加任意 HTML 的方式。

这是插入变体的示意图:

我们很容易就会注意到这张图片和上一张图片的相似之处。插入点实际上是相同的,但此方法插入的是 HTML。

这个方法有两个兄弟:

  • elem.insertAdjacentText(where, text) — 语法一样,但是将 text 字符串“作为文本”插入而不是作为 HTML,
  • elem.insertAdjacentElement(where, elem) — 语法一样,但是插入的是一个元素。

它们的存在主要是为了使语法“统一”。实际上,大多数时候只使用 insertAdjacentHTML。因为对于元素和文本,我们有 append/prepend/before/after 方法 — 它们也可以用于插入节点/文本片段,但写起来更短。

所以,下面是显示一条消息的另一种变体:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  document.body.insertAdjacentHTML("afterbegin", `<div class="alert">
    <strong>Hi there!</strong> You've read an important message.
  </div>`);
</script>

节点移除

想要移除一个节点,可以使用 node.remove()

让我们的消息在一秒后消失:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<script>
  let div = document.createElement('div');
  div.className = "alert";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

  document.body.append(div);
  setTimeout(() => div.remove(), 1000);
</script>

请注意:如果我们要将一个元素 移动 到另一个地方,则无需将其从原来的位置中删除。

所有插入方法都会自动从旧位置删除该节点。

例如,让我们进行元素交换:

<div id="first">First</div>
<div id="second">Second</div>
<script>
  // 无需调用 remove
  second.after(first); // 获取 #second,并在其后面插入 #first
</script>

克隆节点:cloneNode

如何再插入一条类似的消息?

我们可以创建一个函数,并将代码放在其中。但是另一种方法是 克隆 现有的 div,并修改其中的文本(如果需要)。

当我们有一个很大的元素时,克隆的方式可能更快更简单。

调用 elem.cloneNode(true) 来创建元素的一个“深”克隆 — 具有所有特性(attribute)和子元素。如果我们调用 elem.cloneNode(false),那克隆就不包括子元素。

一个拷贝消息的示例:

<style>
.alert {
  padding: 15px;
  border: 1px solid #d6e9c6;
  border-radius: 4px;
  color: #3c763d;
  background-color: #dff0d8;
}
</style>

<div class="alert" id="div">
  <strong>Hi there!</strong> You've read an important message.
</div>

<script>
  let div2 = div.cloneNode(true); // 克隆消息
  div2.querySelector('strong').innerHTML = 'Bye there!'; // 修改克隆

  div.after(div2); // 在已有的 div 后显示克隆
</script>

DocumentFragment

DocumentFragment 是一个特殊的 DOM 节点,用作来传递节点列表的包装器(wrapper)。

我们可以向其附加其他节点,但是当我们将其插入某个位置时,则会插入其内容。

例如,下面这段代码中的 getListContent 会生成带有 <li> 列表项的片段,然后将其插入到 <ul> 中:

<ul id="ul"></ul>

<script>
function getListContent() {
  let fragment = new DocumentFragment();

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    fragment.append(li);
  }

  return fragment;
}

ul.append(getListContent()); // (*)
</script>

请注意,在最后一行 (*) 我们附加了 DocumentFragment,但是它和 ul “融为一体(blends in)”了,所以最终的文档结构应该是:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

DocumentFragment 很少被显式使用。如果可以改为返回一个节点数组,那为什么还要附加到特殊类型的节点上呢?重写示例:

<ul id="ul"></ul>

<script>
function getListContent() {
  let result = [];

  for(let i=1; i<=3; i++) {
    let li = document.createElement('li');
    li.append(i);
    result.push(li);
  }

  return result;
}

ul.append(...getListContent()); // append + "..." operator = friends!
</script>

我们之所以提到 DocumentFragment,主要是因为它上面有一些概念,例如 template 元素,我们将在以后讨论。

老式的 insert/remove 方法

Old school
This information helps to understand old scripts, but not needed for new development.

由于历史原因,还存在“老式”的 DOM 操作方法。

这些方法来自真正的远古时代。如今,没有理由再使用它们了,因为诸如 appendprependbeforeafterremovereplaceWith 这些现代方法更加灵活。

我们在这儿列出这些方法的唯一原因是,你可能会在许多就脚本中遇到它们。

parentElem.appendChild(node)

node 附加为 parentElem 的最后一个子元素。

下面这个示例在 <ol> 的末尾添加了一个新的 <li>

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Hello, world!';

  list.appendChild(newLi);
</script>
parentElem.insertBefore(node, nextSibling)

parentElemnextSibling 前插入 node

下面这段代码在第二个 <li> 前插入了一个新的列表项:

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>
<script>
  let newLi = document.createElement('li');
  newLi.innerHTML = 'Hello, world!';

  list.insertBefore(newLi, list.children[1]);
</script>

如果要将 newLi 插入为第一个元素,我们可以这样做:

list.insertBefore(newLi, list.firstChild);
parentElem.replaceChild(node, oldChild)

parentElem 的后代中的 oldChild 替换为 node

parentElem.removeChild(node)

parentElem 中删除 node(假设 nodeparentElem 的后代)。

下面这个示例从 <ol> 中删除了 <li>

<ol id="list">
  <li>0</li>
  <li>1</li>
  <li>2</li>
</ol>

<script>
  let li = list.firstElementChild;
  list.removeChild(li);
</script>

所有这些方法都会返回插入/删除的节点。换句话说,parentElem.appendChild(node) 返回 node。但是通常我们不会使用返沪值,我们只是使用对应的方法。

聊一聊 “document.write”

还有一个非常古老的向网页添加内容的方法:document.write

语法如下:

<p>Somewhere in the page...</p>
<script>
  document.write('<b>Hello from JS</b>');
</script>
<p>The end</p>

调用 document.write(html) 意味着将 html “就地马上”写入页面。html 字符串可以是动态生成的,所以它很灵活。我们可以使用 JavaScript 创建一个完整的页面并对其进行写入。

这个方法来自于没有 DOM,没有标准的上古时期……。但这个方法依被保留了下来,因为还有脚本在使用它。

由于以下重要的限制,在现代脚本中我们很少看到它:

document.write 调用只在页面加载时工作。

如果我们稍后调用它,则现有文档内容将被擦除。

例如:

<p>After one second the contents of this page will be replaced...</p>
<script>
  // 1 秒后调用 document.write
  // 这时页面已经加载完成,所以它会擦除现有内容
  setTimeout(() => document.write('<b>...By this.</b>'), 1000);
</script>

因此,在某种程度上讲,它在“加载完成”阶段是不可用的,这与我们上面介绍的其他 DOM 方法不同。

这是它的缺陷。

还有一个好处。从技术上讲,当在浏览器正在读取(“解析”)传入的 HTML 时调用 document.write 方法来写入一些东西,浏览器会像它本来就在 HTML 文本中那样使用它。

所以它运行起来出奇的快,因为它 不涉及 DOM 修改。它直接写入到页面文本中,而此时 DOM 尚未构建。

因此,如果我们需要向 HTML 动态地添加大量文本,并且我们正处于页面加载阶段,并且速度很重要,那么它可能会有帮助。但实际上,这些要求很少同时出现。我们可以在脚本中看到此方法,通常是因为这些脚本很旧。

总结

  • 创建新节点的方法:

    • document.createElement(tag) — 用给定的标签创建一个元素节点,
    • document.createTextNode(value) — 创建一个文本节点(很少使用),
    • elem.cloneNode(deep) — 克隆元素,如果 deep==true 则与其后代一起克隆。
  • 插入和移除节点的方法:

    • node.append(...nodes or strings) — 在 node 末尾插入,
    • node.prepend(...nodes or strings) — 在 node 开头插入,
    • node.before(...nodes or strings) — 在 node 之前插入,
    • node.after(...nodes or strings) — 在 node 之后插入,
    • node.replaceWith(...nodes or strings) — 替换 node
    • node.remove() — 移除 node

    文本字符串被“作为文本”插入。

  • 这里还有“旧式”的方法:

    • parent.appendChild(node)
    • parent.insertBefore(node, nextSibling)
    • parent.removeChild(node)
    • parent.replaceChild(newElem, node)

    这些方法都返回 node

  • html 中给定一些 HTML,elem.insertAdjacentHTML(where, html) 会根据 where 的值来插入它:

    • "beforebegin" — 将 html 插入到 elem 前面,
    • "afterbegin" — 将 html 插入到 elem 的开头,
    • "beforeend" — 将 html 插入到 elem 的末尾,
    • "afterend" — 将 html 插入到 elem 后面。

另外,还有类似的方法,elem.insertAdjacentTextelem.insertAdjacentElement,它们会插入文本字符串和元素,但很少使用。

  • 要在页面加载完成之前将 HTML 附加到页面:

    • document.write(html)

    页面加载完成后,这样的调用将会擦除文档。多见于旧脚本。

任务

重要程度: 5

我们有一个空的 DOM 元素 elem 和一个字符串 text

下面这 3 个命令中的哪个命令做的是完全相同的事儿?

  1. elem.append(document.createTextNode(text))
  2. elem.innerHTML = text
  3. elem.textContent = text

回答:1 和 3

这两个命令都会将 text “作为文本”添加到 elem 中。

这是一个例子:

<div id="elem1"></div>
<div id="elem2"></div>
<div id="elem3"></div>
<script>
  let text = '<b>text</b>';

  elem1.append(document.createTextNode(text));
  elem2.innerHTML = text;
  elem3.textContent = text;
</script>
重要程度: 5

创建一个函数 clear(elem) 用来移除元素里的内容。

<ol id="elem">
  <li>Hello</li>
  <li>World</li>
</ol>

<script>
  function clear(elem) { /* 你的代码 */ }

  clear(elem); // 清除列表
</script>

首先,让我们看看 错误 的做法:

function clear(elem) {
  for (let i=0; i < elem.childNodes.length; i++) {
      elem.childNodes[i].remove();
  }
}

这是行不通的,因为调用 remove() 会从首端开始移除 elem.childNodes 集合中的元素,因此,元素每次都从索引 0 开始。但是 i 在增加,所以元素就被跳过了。

for..of 循环的结果也跟上面一样。

正确的做法是:

function clear(elem) {
  while (elem.firstChild) {
    elem.firstChild.remove();
  }
}

还有一种更简单的方法,也可以达到我们所要的效果:

function clear(elem) {
  elem.innerHTML = '';
}
重要程度: 1

在下面这个示例中,我们调用 table.remove() 从文档中删除表格。

但如果运行它,你就会看到文本 "aaa" 并没有被删除。

这是为什么?

<table id="table">
  aaa
  <tr>
    <td>Test</td>
  </tr>
</table>

<script>
  alert(table); // 表格,就是它应有的样子

  table.remove();
  // 为什么 aaa 还存在于文档中?
</script>

这个题目中的 HTML 是错的。这就是造成怪异现象的原因。

浏览器必须自动修复它。但 <table> 内可能会没有文本:根据规范,只允许特定于表格的标签。所以浏览器把 "aaa" 添加到了 <table> 前面

当我们删除表格后,文本 "aaa" 仍然存在的原因就很明显了吧。

通过使用浏览器开发者工具查看 DOM,就可以轻松地回答这个问题。从浏览器开发者工具中我们可以看到,"aaa"<table> 前面。

HTML 标准规范详细描述了如何处理错误的 HTML,并且浏览器的这种行为是正确的。

重要程度: 4

编写一个接口,根据用户输入创建一个列表(list)。

对于每个列表项:

  1. 使用 prompt 向用户询问列表项的内容。
  2. 使用用户输入的内容创建 <li>,并添加到 <ul>
  3. 重复以上步骤,直到用户取消输入(按下 Esc 键,或点击 prompt 弹窗的 CANCEL 按钮)。

所有元素应该都是动态创建的。

如果用户输入了 HTML 标签,那么这些内容应该被视为文本进行后续处理。

在新窗口中演示

请注意使用 textContent<li> 的内容进行赋值的用法。

使用沙箱打开解决方案。

重要程度: 5

编写一个函数 createTree,从嵌套对象创建一个嵌套的 ul/li 列表(list)。

例如:

let data = {
  "Fish": {
    "trout": {},
    "salmon": {}
  },

  "Tree": {
    "Huge": {
      "sequoia": {},
      "oak": {}
    },
    "Flowering": {
      "apple tree": {},
      "magnolia": {}
    }
  }
};

语法:

let container = document.getElementById('container');
createTree(container, data); // 将树创建在 container 中

结果(树)看起来像这样:

选择下面两种方式中的一种,来完成这个任务:

  1. 为树创建 HTML,然后将它们赋值给 container.innerHTML
  2. 创建节点树,并使用 DOM 方法将它们附加(append)上去。

如果这两种方式你都做,那就太好了。

P.S. 树上不应该有“多余”的元素,例如空的 <ul></ul> 叶子节点。

打开一个任务沙箱。

遍历对象的最简单的方法是使用递归。

  1. 使用 innerHTML 的解决方案
  2. 使用 DOM 的解决方案
重要程度: 5

这里有一棵由嵌套的 ul/li 组成的树。

编写代码,为每个 <li> 添加其后代数量。跳过叶子节点(没有子代的节点)。

结果:

打开一个任务沙箱。

为了将文本附加到每个 <li> 中,我们可以改变文本节点的 data

使用沙箱打开解决方案。

重要程度: 4

编写一个函数 createCalendar(elem, year, month)

对该函数的调用,应该使用给定的 year/month 创建一个日历,并将创建的日历放入 elem 中。

创建的日历应该是一个表格(table),其中每一周用 <tr> 表示,每一天用 <td> 表示。表格顶部应该是带有星期名的 <th>:第一天应该是 Monday,依此类推,直到 Sunday。

例如,createCalendar(cal, 2012, 9) 应该在元素 cal 中生成如下所示的日历:

P.S. 在这个任务中,生成一个日历就可以了,不需要有点击交互的功能。

打开一个任务沙箱。

我们将表格创建为字符串:"<table>...</table>",然后将其赋值给 innerHTML

算法如下:

  1. 使用 <th> 创建带有星期名的表头。
  2. 创建日期对象 d = new Date(year, month-1)。它是 month 的第一天(考虑到 JavaScript 中的月份从 0 开始,而不是从 1 开始)。
  3. 直到月份的第一天 d.getDay(),前面的几个单元格是空的。让我们用 <td></td> 填充它们。
  4. 天数增长 dd.setDate(d.getDate()+1)。如果 d.getMonth() 还没到下一个月,那么就将新的单元格 <td> 添加到日历中。如果那天是星期日,就添加一个新行 “</tr><tr>”
  5. 如果该月结束,但表格的行尚未填满,就用空的 <td> 补齐。

使用沙箱打开解决方案。

重要程度: 4

创建一个像这样的彩色时钟:

使用 HTML/CSS 进行样式设计,JavaScript 仅用来更新元素中的时间。

打开一个任务沙箱。

首先,让我们编写 HTML/CSS。

时间的每个组件都有其自己的 <span>,那将会看起来很棒:

<div id="clock">
  <span class="hour">hh</span>:<span class="min">mm</span>:<span class="sec">ss</span>
</div>

另外,我们需要使用 CSS 为它们着色。

函数 update 会刷新时钟,由 setInterval 每秒调用一次:

function update() {
  let clock = document.getElementById('clock');
  let date = new Date(); // (*)
  let hours = date.getHours();
  if (hours < 10) hours = '0' + hours;
  clock.children[0].innerHTML = hours;

  let minutes = date.getMinutes();
  if (minutes < 10) minutes = '0' + minutes;
  clock.children[1].innerHTML = minutes;

  let seconds = date.getSeconds();
  if (seconds < 10) seconds = '0' + seconds;
  clock.children[2].innerHTML = seconds;
}

(*) 行中,我们每次都检查当前时间。setInterval 调用并不可靠:它们可能会发生延迟现象。

时钟管理函数:

let timerId;

function clockStart() { // 运行时钟
  timerId = setInterval(update, 1000);
  update(); // (*)
}

function clockStop() {
  clearInterval(timerId);
  timerId = null;
}

请注意,update() 不仅在 clockStart() 中被调度,而且还立即在 (*) 行运行。否则,访问者将不得不等到 setInterval 第一次执行。在那之前,时钟将是空的。

使用沙箱打开解决方案。

重要程度: 5

编写代码,将 <li>2</li><li>3</li>,插入到两个 <li> 之间:

<ul id="ul">
  <li id="one">1</li>
  <li id="two">4</li>
</ul>

当我们需要在某处插入 HTML 时,insertAdjacentHTML 是最适合的方案。

解决方法:

one.insertAdjacentHTML('afterend', '<li>2</li><li>3</li>');
重要程度: 5

下面是一个表格:

<table>
<thead>
  <tr>
    <th>Name</th><th>Surname</th><th>Age</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td>John</td><td>Smith</td><td>10</td>
  </tr>
  <tr>
    <td>Pete</td><td>Brown</td><td>15</td>
  </tr>
  <tr>
    <td>Ann</td><td>Lee</td><td>5</td>
  </tr>
  <tr>
    <td>...</td><td>...</td><td>...</td>
  </tr>
</tbody>
</table>

可能会有更多行。

编写代码,按 "name" 列对其进行排序。

打开一个任务沙箱。

这个解决方案虽然很短,但可能看起来有点难理解,因此,在这里我提供了一些扩展性的注释:

let sortedRows = Array.from(table.tBodies[0].rows) // 1
  .sort((rowA, rowB) => rowA.cells[0].innerHTML.localeCompare(rowB.cells[0].innerHTML));

table.tBodies[0].append(...sortedRows); // (3)

对此算法一步一步进行讲解:

  1. <tbody> 获取所有 <tr>
  2. 然后将它们按第一个 <td>name 字段)中的内容进行比较。
  3. 然后使用 .append(...sortedRows) 按正确的顺序插入节点。

我们不必删除行元素,只需要“重新插入”,它们就会自动离开原来的位置。

P.S. 在我们的例子中,表格中有一个明确的 <tbody>,但即使 HTML 中的表格没有 <tbody>,DOM 结构也总是具有它。

使用沙箱打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)