Javascript 修改文档(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');
大多数情况下,我们需要为此消息创建像 div
这样的元素节点。
创建一条消息
创建一个消息 div
分为 3 个步骤:
// 1. 创建 <div> 元素
let div = document.createElement('div');
// 2. 将元素的类设置为 "alert"
div.className = "alert";
// 3. 填充消息内容
div.innerHTML = "<strong>Hi there!</strong> You've read an important message.";
我们已经创建了该元素。但到目前为止,它还只是在一个名为 div
的变量中,尚未在页面中。所以我们无法在页面上看到它。
插入方法
为了让 div
显示出来,我们需要将其插入到 document
中的某处。例如,通过 document.body
将其插入到 <body>
元素里。
对此有一个特殊的方法 append
:document.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>
在这个例子中,我们对 document.body
调用了 append
方法。不过我们可以在其他任何元素上调用 append
方法,以将另外一个元素放入到里面。例如,通过调用 div.append(anotherElement)
,我们便可以在 <div>
末尾添加一些内容。
这里是更多的元素插入方法,指明了不同的插入位置:
-
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 节点列表,或者文本字符串(会被自动转换成文本节点)。
让我们在实际应用中看一看。
下面是使用这些方法将列表项添加到列表中,以及将文本添加到列表前面和后面的示例:
<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>
请注意:这里的文字都被“作为文本”插入,而不是“作为 HTML 代码”。因此像 <
、>
这样的符号都会被作转义处理来保证正确显示。
所以,最终的 HTML 为:
<p>Hello</p>
<hr>
<div id="div"></div>
换句话说,字符串被以一种安全的方式插入到页面中,就像 elem.textContent
所做的一样。
所以,这些方法只能用来插入 DOM 节点或文本片段。
但如果我们想要将内容“作为 HTML 代码插入”,让内容中的所有标签和其他东西都像使用 elem.innerHTML
所表现的效果一样,那应该怎么办呢?
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 方法
老式用法
这些内容有助于理解旧脚本,但不适合用于新代码的开发中。
由于历史原因,还存在“老式”的 DOM 操作方法。
这些方法来自真正的远古时代。如今,没有理由再使用它们了,因为诸如 append
,prepend
,before
,after
,remove
,replaceWith
这些现代方法更加灵活。
我们在这儿列出这些方法的唯一原因是,你可能会在许多脚本中遇到它们。
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)
在 parentElem
的 nextSibling
前插入 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
(假设 node
为 parentElem
的后代)。
下面这个示例从 <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)
- 在
html
中给定一些 HTML,elem.insertAdjacentHTML(where, html)
会根据 where
的值来插入它: -
"beforebegin"
—— 将 html
插入到 elem
前面, -
"afterbegin"
—— 将 html
插入到 elem
的开头, -
"beforeend"
—— 将 html
插入到 elem
的末尾, -
"afterend"
—— 将 html
插入到 elem
后面。
文本字符串被“作为文本”插入。
这些方法都返回 node
。
另外,还有类似的方法,elem.insertAdjacentText
和 elem.insertAdjacentElement
,它们会插入文本字符串和元素,但很少使用。
- 要在页面加载完成之前将 HTML 附加到页面:
-
document.write(html)
页面加载完成后,这样的调用将会擦除文档。多见于旧脚本。
任务
createTextNode vs innerHTML vs textContent
我们有一个空的 DOM 元素 elem
和一个字符串 text
。
下面这 3 个命令中的哪些命令会执行完全相同的操作?
-
elem.append(document.createTextNode(text))
-
elem.innerHTML = text
-
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>
清除元素
创建一个函数 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 = '';
}
为什么留下 "aaa"?
在下面这个示例中,我们调用 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,并且浏览器的这种行为是正确的。
创建一个列表
编写一个接口,根据用户输入创建一个列表(list)。
对于每个列表项:
- 使用
prompt
向用户询问列表项的内容。 - 使用用户输入的内容创建
<li>
,并添加到 <ul>
。 - 重复以上步骤,直到用户取消输入(按下
Esc
键,或输入一个空内容)。
所有元素应该都是动态创建的。
如果用户输入了 HTML 标签,那么这些内容应该被视为文本进行后续处理。
解决方案
请注意使用 textContent
对 <li>
的内容进行赋值的用法。
从对象创建树
编写一个函数 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 中
结果(树)看起来像这样:
选择下面两种方式中的一种,来完成这个任务:
- 为树创建 HTML,然后将它们赋值给
container.innerHTML
。 - 创建节点树,并使用 DOM 方法将它们附加(append)上去。
如果这两种方式你都做,那就太好了。
P.S. 树上不应该有“多余”的元素,例如空的 <ul></ul>
叶子节点。
解决方案
遍历对象的最简单的方法是使用递归。
在树中显示后代
这里有一棵由嵌套的 ul/li
组成的树。
编写代码,为每个 <li>
添加其后代数量。跳过叶子节点(没有子代的节点)。
结果:
解决方案
为了将文本附加到每个 <li>
中,我们可以改变文本节点的 data
。
创建一个日历
编写一个函数 createCalendar(elem, year, month)
。
对该函数的调用,应该使用给定的 year/month 创建一个日历,并将创建的日历放入 elem
中。
创建的日历应该是一个表格(table),其中每一周用 <tr>
表示,每一天用 <td>
表示。表格顶部应该是带有星期名的 <th>
:第一天应该是 Monday,依此类推,直到 Sunday。
例如,createCalendar(cal, 2012, 9)
应该在元素 cal
中生成如下所示的日历:
P.S. 在这个任务中,生成一个日历就可以了,不需要有点击交互的功能。
解决方案
我们将表格创建为字符串:"<table>...</table>"
,然后将其赋值给 innerHTML
。
算法如下:
- 使用
<th>
创建带有星期名的表头。 - 创建日期对象
d = new Date(year, month-1)
。它是 month
的第一天(考虑到 JavaScript 中的月份从 0
开始,而不是从 1
开始)。 - 直到月份的第一天
d.getDay()
,前面的几个单元格是空的。让我们用 <td></td>
填充它们。 - 天数增长
d
:d.setDate(d.getDate()+1)
。如果 d.getMonth()
还没到下一个月,那么就将新的单元格 <td>
添加到日历中。如果那天是星期日,就添加一个新行 “</tr><tr>”
。 - 如果该月结束,但表格的行尚未填满,就用空的
<td>
补齐。
使用 setInterval 的彩色时钟
创建一个像这样的彩色时钟:
使用 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() { // 运行时钟
if (!timerId) { // 仅当时钟停止时 setInterval
timerId = setInterval(update, 1000);
}
update(); // (*)
}
function clockStop() {
clearInterval(timerId);
timerId = null; // (**)
}
请注意,update()
不仅在 clockStart()
中被调度,而且还立即在 (*)
行运行。否则,访问者将不得不等到 setInterval
第一次执行。在那之前,时钟将是空的。
此外,在 clockStart()
中仅当时钟未运行时才进行 setInterval 也是至关重要的。否则多次点击 Start 按钮会设置多个并发的间隔。更糟糕的是 —— 我们只会保留最后一个时间间隔的 timerID
,失去对所有其他时间间隔的引用。那我们就再也无法停止时钟了!请注意,当时钟停止时,我们需要在 (**)
行这样清除 timerID
,以便可以通过执行 clockStart()
再次启动时钟。
将 HTML 插入到列表中
编写代码,将 <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>');
对表格进行排序
下面是一个表格:
<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)
对此算法一步一步进行讲解:
- 从
<tbody>
获取所有 <tr>
。 - 然后将它们按第一个
<td>
(name
字段)中的内容进行比较。 - 然后使用
.append(...sortedRows)
按正确的顺序插入节点。
我们不必删除行元素,只需要“重新插入”,它们就会自动离开原来的位置。
P.S. 在我们的例子中,表格中有一个明确的 <tbody>
,但即使 HTML 中的表格没有 <tbody>
,DOM 结构也总是具有它。
更多建议: