修改文档内容

DOM(document object model 文档对象模型,此文中全部以缩写 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 一样的元素(假定 CSS 样式是 HTML 中的内联样式或者是从外部引用 CSS 文件)。

生成一个元素

这两种方法都可以创建 DOM 节点:

document.createElement(tag)

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

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

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

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

生成一条信息

在这个例子中,我们想要为 div 设定我们需要的类名和文字信息:

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

之后,我们就有拥有一个 DOM 元素。现在这个元素仅仅存于一个变量中,我们还不能在页面上看到它。因为它还没有被插入到页面中。

插值方法

为了让 div 显示我们想要的内容,我们需要在 document 中找个合适的位置插值,这里我们选择 document.body

这里有个特定的方法 appendChild 来完成这一步:document.body.appendChild(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 alert-success";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

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

这里有一个简短的列表,我们把一个节点插入到父元素中(用 parentElem 指代父元素):

parentElem.appendChild(node)

node 作为 parentElem 最后一个子元素。

可以看到增加了一个 <li><ol> 的最末尾:

<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)

parentElemoldChild 替换为 node

所有这些插入节点的操作都会返回节点。换句话说,parentElem.appendChild(node) 返回 node。但是通常返回的节点都没有用,只是插入方法的默认返回值。

以上方法都是“旧三板斧”:它们从很早就存在,我们在老的脚本里能看到它们的影子。很不幸的是它们不够灵活。

例如,我们怎样在 html 插入字符串呢?又或者,给定你一个节点,如何在不引用其父节点的情况下删除它?虽然也能完成需求开发,总归不是那么优雅的解决方式。

所以诞生了两种优雅插入方法来代替这些繁琐的插入操作。

在开头插入/在末尾插入/在前面插入/在后面插入

This set of methods provides more flexible insertions:

  • 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 替换为节点或者字符串。

所有这些方法都接受 DOM 节点或者文本字符串列表形式。如果给定的是一个字符串,那么它将以文本节点(text node)形式插入。

下面例子是使用以上提到的方法在列表项前面或后面插入文本:

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

<script>
  ol.before('before');
  ol.after('after');

  let prepend = document.createElement('li');
  prepend.innerHTML = 'prepend';
  ol.prepend(prepend);

  let append = document.createElement('li');
  append.innerHTML = 'append';
  ol.append(append);
</script>

这张图片展示插入方法的工作方式:

列表最后表现为:

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

这些方法通过简单的调用就能插入多个节点或者字符串。

例如,这里将字符串和一个元素插入到 div 前面:

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

所有字符串都会作为“文本”插入。

所以最后的 HTML 表现为:

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

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

所以这些方法只能用来插入 DOM 节点或者文本块。

如果我们想在 HTML 页面中插入一个标签,有没有这样的方法,就像调用 elem.innerHTML 方法一样?

在相邻的 HTML 标签中插入/文本/元素

接下来登场的这个方法就可以做到:elem.insertAdjacentHTML(where, html)

该方法第一个参数是代码字符串,指定相对于 elem 的插入位置,必须是以下四个值之一:

  • "beforebegin" —— 在 elem 开头位置前插入 html
  • "afterbegin" —— 在 elem 开头位置后插入 html(译注:即 elem 元素内部的第一个子节点之前),
  • "beforeend" —— 在 elem 结束位置前插入 html(译注:即 elem 元素内部的最后一个子节点之后),
  • "afterend" —— 在 elem 结束位置后插入 html

第二个参数是 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 alert-success">
    <strong>Hi there!</strong> You've read an important message.
  </div>`);
</script>

克隆节点:cloneNode

怎么插入多条相同的信息?

我们可以声明一个函数来实现这个方法。但是怎样通过克隆的方式来替换掉那些原本存在的 div 并且更改里面的文本(如果有这样一个需求)。

如果我们有一个很大的元素,克隆的方式要远比创建后插入来的更简单,性能也更好。

  • elem.cloneNode(true) 方法用来对一个元素进行“深”克隆 —— 包括所有特性和子元素。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 节点,用于传递节点列表的包装器。

我们可以将其他节点附加到它上面,但是当我们将其插入到某个地方的时候,会以其内容的形式插入。

例如,下面的代码中的 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 + “...” 操作符 = 一对好朋友!
</script>

我们在这里提及 DocumentFragment 主要是因为有一些概念是基于它的,比如 模板 元素,我们将在后面的章节中详细介绍它。

移除

想要移除节点,可以通过以下方法:

parentElem.removeChild(node)
parentElem 中移除 node(假设它是元素中的子元素)。
node.remove()
从当前位置移除 node

能看出第二个方法更加简洁,第一个方法的存在是有其历史原因的。

请注意:

如果我们想要移动一个元素到另一个地方 —— 不需要移除旧的元素。

所有插入操作都会从节点原来的位置把节点移除掉。

例如,这里有一些嵌套的元素:

<div id="first">First</div>
<div id="second">Second</div>
<script>
  // 没有用到移除方法
  second.after(first); //在 id 为 #second 的元素后插入id为 #first 的元素
</script>

使信息一秒后消失:

<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 alert-success";
  div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";

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

聊一聊 “document.write”

document.write 是一个很老的方法,用来为 web 页面添加内容。

语法如下:

<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 创建一个完整的 HTML 页面并写入浏览器窗口中。

这个方法的起源于没有 DOM,没有 Web 标准的上古时期……,但是这个方法依旧保留了下来,因为很多的脚本使用它来实现一些功能。

现代的脚本已经很少再看到这个方法,因为使用它有一个很重要的局限性:

只能在页面加载的时候调用 document.write

如果在加载完成以后,渲染好的页面会被擦除。

例如:

<p>After one second the contents of this page will be replaced...</p>
<script>
  // 在一秒后调用 document.write
  // 页面已经加载完毕,所以会被擦除
  setTimeout(() => document.write('<b>...By this.</b>'), 1000);
</script>

所以,不像其他 DOM 操作一样,一旦页面“加载完毕”最好就不使用 document.write 方法。

这是它的缺陷。

从技术上讲,当浏览器正在读取(“解析”)传入的 HTML ,此时再调用 document.write 方法向文档中写入一些东西,浏览器会像它本来就在整个 HTML 文本的那个位置上(调用 document.write 的地方)一样处理它。 “ it were initially there”

反过来说这也是一个优势 —— 它性能出奇的快,因为它不用修改 DOM 结构。它直接在 DOM 结构构建之前,对整个页面直接进行重写,再交给浏览器去构建 DOM 结构。

所以如果我们需要在 HTML 加载阶段动态的添加很多文本,它会很高效。不过能用到的机会不多就是了。在一些很老的脚本里倒是能经常看到。

总结

创建节点的方法:

  • document.createElement(tag) —— 用给定标签创建一个节点,
  • document.createTextNode(value) —— 创建一个文本节点(很少使用),
  • elem.cloneNode(deep) —— 如果参数 deep==true 将元素及后代子元素进行克隆。

插入和移除节点的方法:

  • 从 parent

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

    这些方法都返回 node

  • 添加一些节点和字符串:

    • 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

    把字符串当成“文本”插入。

  • 在 HTML 中添加内容 elem.insertAdjacentHTML(where, html),在 where 位置进行操作:

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

    elem.insertAdjacentTextelem.insertAdjacentElementelem.insertAdjacentHTML 很相似,只不过他们一个用来插入字符串,一个用来插入元素,但是很少使用这两个方法。

  • 在页面加载完成之前添加 HTML 到页面中:

    • document.write(html)

    如果是在页面加载完成以后调用会擦除加载完毕的内容。通常在很老的脚本才会使用这个方法了。

任务

重要程度: 5

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

以下这三个命令行的结果是一样的吗?

  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.textContent = text;
  elem3.innerHTML = 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 应该显示出的样子

  table.remove();
  // 为什么 aaa 依旧在文档中
</script>

HTML 在这个任务中是错误的,这也是造成怪异的原因所在。

浏览器会自动修复它。但 <table> 可能会没有文本:根据 table 的特殊规范,这是允许的。所以浏览器会在 <table> 前面添加 "aaa"

现在当我们移除 table 时,它就被保留下来了。

通过浏览器开发者工具很容易就能在 DOM 找到答案。它显示出 "aaa"<table> 前面。

HTML 标准规范详细描述了对于异常的 HTML 会如何处理,以及浏览器的行为是否合乎规范。

重要程度: 4

编写一个接口,根据用户输入生成一个列表。

每一个列表项:

  1. 使用 prompt 询问用户输入的内容。
  2. 创建 <li> 并添加到 <ul>
  3. 重复以上两步,直到用户输入取消指令(按下 Esc 或者 prompt 的 CANCEL)。

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

如果把用户输入了 HTML 标签,就应该将其视为文本。

在新窗口中演示

请留意 textContent 复制 <li> 内容的用法。

使用沙箱打开解决方案。

重要程度: 5

编写一个函数 createTree 将嵌套的对象生成 ul/li 的嵌套列表。

例如:

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 中。

如果两种方式都尝试一下就更好。

P.S. 树应该没有“额外”的元素,像空的 <ul></ul> 没有列表项。

打开一个任务沙箱。

在对象间游走的最简单方式就是递归了。

  1. The solution with innerHTML.
  2. The solution with DOM.
重要程度: 5

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

编写代码在 <li> 添加后代数量的数字。跳过一些叶子(如果节点没有后代节点)。

最终结果:

打开一个任务沙箱。

为每个 <li> 插入文本,我们可以改变文本节点 data

使用沙箱打开解决方案。

重要程度: 4

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

调用后会创建一个日历并添加到 elem

日历应该是一个 table,每一周用 <tr> 表示,每一天用 <td> 表示。table 顶部是 <th> 表示周几:第一天应该是星期一,直到星期日。

例如,createCalendar(cal, 2012, 9) 应该在 cal 生成一个日历,如下:

P.S. 这里只要生成一个展示日历就好,不需要点击交互功能。

打开一个任务沙箱。

我们用字符串创建 table:"<table>...</table>",然后派发给 innerHTML

算法如下:

  1. 通过 <th> 创建 table 头和周末名字。
  2. 创建一个日期对象 d = new Date(year, month-1)。它是月份的第一天(注意 JavaScript 计算月份是从 0 开始,而不是 1)。
  3. 将每月第一天的日期生成单元格,直到月份的第一天 d.getDay() 是空的。然后将它们填充到 <td></td>
  4. 天数增长 dd.setDate(d.getDate()+1)。如果 d.getMonth() 不是下一个月,就添加新单元格 <td> 到日历表中,如果那天是星期日,就添加一行 “</tr><tr>”
  5. 如果天数遍历完但 table 没有填满,就用空的 <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() { // run the clock
  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

There’s a table:

Name Surname Age
John Smith 10
Pete Brown 15
Ann Lee 5
... ... ...

可能会有更多的行数。

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

打开一个任务沙箱。

这个方法很精简,但是看起来有点难理解,所以我添加了一些注释:

let sortedRows = Array.from(table.rows)
  .slice(1)
  .sort((rowA, rowB) => rowA.cells[0].innerHTML > rowB.cells[0].innerHTML ? 1 : -1);

table.tBodies[0].append(...sortedRows);
  1. 使用 table.querySelectorAll('tr') 获取所有 <tr>,然后生成数组,因为我们需要用到数组方法。

  2. 第一个 TR(table.rows[0])实际上是 table 头,所以我们需要调用 .slice(1) 裁剪掉。

  3. 比较 <td> 的内容(字符在字符集中的序号),进行排序。

  4. 现在使用 .append(...sortedRows) 插入节点。

    table 永远包含 元素,所以我们需要考虑到它,并将内容插入到其中:单纯的调用 table.append(...) 将会失败。

    请留意:我们没有移除操作,只进行“重复插入”,它们会将旧的位置的内容自动去除。

使用沙箱打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 欢迎你在文章下添加补充内容、提出你的问题或回答提出的问题。
  • 使用 <code> 标签插入几行代码,对于多行代码 — 可以使用 <pre>,对于超过十行的代码 — 建议使用沙箱(plnkrJSBincodepen 等)。
  • 如果你无法理解文章中的内容 — 请详细说明。