原文链接:https://javascript.info/modifying-document,translate with ❤️ by zhangbao.
DOM 修改是创建“动态”网页的关键。
本章,我们将看到如何“动态地”创建新元素,并修改现有的页面内容。
我们先看一个简单的示例,然后再解释。
例子:显示一个消息框
我们先写一个比原声 alert
好看的弹框。
<style>
.alert {
border: 1px solid #d6e9c6;
border-radius: 4px;
padding: 15px;
color: #3c763d;
background-color: #dff0d8;
}
</style>
<div class="alert">
<strong>Hi, 看这里!</strong> 这是一条很重要的信息。
</div>
这个 <div>
是我们手写出来的,下面用 JavaScript 来创建它。
创建元素
创建 DOM 节点的方法有两个:
document.createElement(tag)
创建指定标签名的元素:
let div = document.createElement('div');
document.createTextNode(text)``
创建指定文本的元素:
let textNode = document.createTextNode('我在这里');
创建消息框
现在我们创建一个 div
,给它类名和文本内容:
let div = document.createElement('div');
div.className = 'alert alert-success';
div.innerHTML = '<strong>嗨,看这里!</strong> 这是一条很重要的信息。'
现在,我们已经有了一个准备好的 DOM 元素了。就保存在变量 div
中,但是还看不见,因为还没有插入到页面。
插入方法
为了能看到我们创建的 <div>
元素,下面我们要将它插入到 document
中的某处。例如,插入到 document.body
中。
有一个特别的方法:document.body.appendChild(div)
。
下面是完整代码:
<style>
.alert {
border: 1px solid #d6e9c6;
border-radius: 4px;
padding: 15px;
color: #3c763d;
background-color: #dff0d8;
}
</style>
<script>
let div = document.createElement('div');
div.className = 'alert alert-success';
div.innerHTML = '<strong>嗨,看这里!</strong> 这是一条很重要的信息。';
document.body.appendChild(div);
</script>
下面列举了向父元素插入节点的方法列表(parentElem
表示父节点):
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 = '你好,世界!';
list.appendChild(newLi);
</script>
parentElem.insertBefore(node, nextSibling)
可解释为”insert node before nextSibling“,node
是我们插入的节点,nextSibling
是 parentElem
中已存在的节点,我们要做的就是在 nextSibling
之前插入 node
。
下面的例子,在第二个 <li>
之前插入新的一个 <li>
。
<ol id="list">
<li>0</li>
<li>1</li>
<li>2</li>
</ol>
<script>
let newLi = document.createElement('li');
newLi.innerHTML = '你好,世界!';
list.insertBefore(newLi, list.children[1]);
</script>
将 newLi
作为第一个元素插入到 list
中:
list.insertBefore(newLi, list.firstChild);
parentElem.replaceChild(node, oldChild)``
可解释为”replace oldChild with node“。
这些方法都返回被插入的节点。就是说,parentElem.appendChild(node)
返回 node
。但通常返回值不被使用,我们只是为了使用这些插入方法而已。
这些方法都是“老派”:它们存在于很久之前,我们可以用许多旧的脚本中看到它们。不幸的是,有些任务很难用它们来解决。
例如,我们如何将字符串作为html插入?给了一个节点,怎样在这个节点之前 插入另一个节点。当然,所有这些都是可行的,但并不优雅。
因此,还有另外两套插入方法可以轻松处理所有的情况。
prepend/append/before/after
这些方法提供了更加灵活的插入方式:
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
替换为我们给的节点/字符串。
以下是使用这些方法向列表中添加更多项目以及在其之前/之后添加文本的示例:
<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 id="div"></div>
<script>
div.before('<p>Hello</p>', document.createElement('hr'));
</script>
字符串都会当作纯文本内容插入。
所以脚本执行后,最终 HTML 代码如下:
<p>Hello</p><hr><div id="div"></div>
也就是说,字符串内容会被安全插入,如同 elem.textContent
一样。
所以,这些方法只能用于插入 DOM 节点或者文本节点。
不过如果我们想把字符串当作 HTML 插入,如同 elem.innerHTML
一样,怎么办?
insertAdjacentHTML/Text/Element
还有另一个非常通用的方法:elem.insertAdjacentHTML(where, html)
。
这个方法的第一个参数是个字符串,表示插入的位置,可能的取值是:
"beforebegin"
:在elem
之前插入html
。"afterbegin"
:将html
作为elem
的第一个孩子插入。"beforeend"
:将html
作为elem
的最后一个孩子插入。"afterend"
:在elem
之后插入html
。
小提示:可以把这里的 begin 看成是开始标签,把 end 看成是结束标签。
第二个参数是个字符串,表示要插入的 HTML 内容。
例如:
<div id="div"></div>
<script>
div.insertAdjacentHTML('beforebegin', '<p>你好</p>');
div.insertAdjacentHTML('afterend', '<p>再见</p>');
</script>
运行结果如下:
<p>你好</p><div id="div"></div><p>再见</p>
这就是我们如何在页面中附加任意的 HTML 的方法。
下面是描述 4 种插入位置的图示:
我们可以很容易地注意到这和前一幅图的相似之处。插入点实际上是相同的,但是这个的方法插入的是 HTML。
这个方法还有两个相似的兄弟方法:
elem.insertAdjacentText(where, text)
:相同的语法,不过插入的是文本。elem.insertAdacentElement(where, text)
:相同的语法,不过插入的是元素。
它们存在主要是为了使语法“统一”。在实践中,大多数情况下只使用 insertAdjacentHTML
,因为对于元素和文本,我们已经有了方法 append/prepend/before/after
——它们更短,并且可以插入节点/文本块。
这是另一种显示消息框的方案:
document.body.insertAdjacentHTML('afterbegin',
`<div class="alert alert-success">
<strong>嗨,看这里!</strong> 这是一条很重要的信息。
</div>`);
克隆节点:cloneNode
怎样去多插入一个类似的消息框呢?
我们可以把显示消息框的代码封装在一个函数里面。还有一个可选的方案是克隆已存在的 div 元素,然后修改它的内容(需要的话)。
有时候我们有一个很大的元素,用克隆的方式可能更快和简单。
elem.cloneNode(true)
:“深度”克隆。元素elem
的所有特性和所有子元素都被克隆。elem.cloneNode()
:等同于elem.cloneNode(false)
,只克隆元素本身,不包含子元素。
下面举例子:
<div class="alert" id="div">
<strong>嗨,看这里!</strong> 这是一条很重要的信息。
</div>
<script>
let div2 = div.cloneNode(true); // 深度克隆 div
div2.querySelector('strong').innerHTML = '再见!'; // 改变文本
div.after(div2); // 在已经存在的 div 之后显示克隆元素
</script>
删除方法
删除节点,可以使用下列方法:
parentElem.removeChild(node)
删除 parentElem
的孩子节点 node
。
node.remove()
删除 node
节点。
第二个方法更简短,第一个方法为了兼容,仍然存在。
请注意:
如果我们要移动一个元素到另外一个位置的话,不需要先删除它再移动。
因为对插入方法而言,如果插入的是一个已存在的节点,那么就是在移动这个节点。
例如,我们看个交换元素的例子:
<div id="first">第一个</div>
<div id="second">第二个</div>
<script>
// 无需删除,#first 就处于 #second 之后了
second.after(first);
</script>
我们再添加一个消息框,设定在 1 秒钟后删除:
let div = document.createElement('div');
div.className = 'alert alert-success';
div.innerHTML = '<strong>嗨,看这里!</strong> 这是一条很重要的信息。';
document.body.append(div);
setTimeout(() => div.remove(), 1000);
// 或者使用 setTimeout(() => document.body.removeChild(div), 1000);
一句话介绍 document.write
还有一种非常古老的方法,可以将东西添加到页面上:document.write
。
<p>页面中的一些内容...</p>
<script>
document.write('<b>来自 JS 的问候</b>');
</script>
<p>结束</p>
结果如下:
调用 document.write(html)
会将字符串 html
“立即写到当前页面的当前位置处”。html
字符串可以动态生成,所以它很灵活。我们可以使用 JavaScript 创建一个完整的网页并写入它。
这个方法来自于没有 DOM,没有标准的时代。真是旧时代,它现在仍然存在,是因为仍有脚本在使用它。
在现代脚本代码中,我们很少能看到它,是因为有以下几个重要的限制:
document.write`` **只在页面加载时才能工作。**
如果我们在事后调用它,那么现有的文档内容都会被删除。
例如:
<p>1 秒钟后,这个页面里的内容都将被替换...</p>
<script>
// 1 秒钟后执行 document.write
// 这是在页面加载完成之后,页面内已经存在的内容都会被新内容替换掉
setTimeout(() => document.write('<b>...被这个替换掉了。</b>'), 1000);
</script>
所以,与上面介绍的其他DOM方法不同的是,它在“加载后”阶段无法使用。
这是它的缺点。
从技术上讲,当调用 document.write
,浏览器还在读取 HTML 的时候,它向页面中附加了一些内容,而浏览器会像最初那样使用它。
这给了我们一个好处——它运行得非常快,因为 没有 DOM 修改。它被直接写入页面文本,而此时 DOM 还没有构建,浏览器会在它执行的地方放置写入内容。
因此,如果我们需要动态地将大量的文本添加到 HTML 中,并且我们处于页面加载阶段,很重视速度的话,使用这个方法可能会有所帮助。但在实践中,很少有这样的场景。通常我们可以在脚本中看到这个方法,说明它是旧代码。
总结
创建新节点的方法:
document.createElement(tag)
:创建指定标签的元素节点。document.createTextNode(value)
:创建文本节点(很少使用)。elem.cloneNode(deep)
:克隆元素阶段,如果deep
为true
,所有子元素也会被克隆。
插入和删除节点:
从父元素角度:
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
。
字符串会被当作文本插入。
浏览器兼容性 😥:
remove
:IE 浏览器不支持,mobile safair 不支持。
append/preappend
:IE/Edge 浏览器不支持。
before/after/replaceWith
:IE/Edge/Safair 浏览器不支持。
给出一段 HTML 文本:使用
elem.insertAdjacent(where, html)
,插入到指定的位置:"beforebegin"
:在elem
之前插入html
,"afterbegin"
:在elem
的头部插入html
,"beforeend"
: 在elem
的尾部插入html
,"afterend"
:在elem
之后插入html
。
还有两个类似的方法 elem.insertAdjacentText
和 elem.insertAdjacentElement
,分别用来插入文本和元素,不过很少使用。
在页面加载完成之前将 HTML 附加到页面上:
document.write(html)
在页面加载之后,调用这个会替换当前文档的全部内容。大部分都是在旧脚本里才能看到这个代码。
练习题
问题
一、creatTextNode vs innerHTML vs textContent
有一个空的 DOM 元素和字符串 text。
下面这 3 个命令哪些是在做一样的事情?
elem.append(document.createTextNode(text))
elem.innerHTML = text
elem.textContent = text
二、清除元素
创建一个函数 clear(elem)
删除元素中的所有东西。
<ol id="elem">
<li>Hello</li>
<li>World</li>
</ol>
<script>
function clear(elem) { /* 你的代码 */ }
clear(elem); // 清除列表
</script>
三、为什么 “aaa” 没有被删除?
执行下面的例子。为什么 table.remove()
没有删除文本 "aaa"
?
<table id="table">
aaa
<tr>
<td>测试</td>
</tr>
</table>
<script>
alert(table); // [object HTMLTableElement]
table.remove();
// 为什么文档中还存在 aaa?
</script>
四、创建列表
写一个页面,根据用户输入来创建列表。
针对每个列表项:
使用
prompt
询问用户内容。使用用户输入的内容创建
<li>
,并将其附加到<ul>
中。继续直到用户取消了输入(通过按下
Esc
按键或者prompt
的取消按钮)。
所有的元素都应该是动态创建的。
如果用户输入了 HTML 标签,应该当成普通文本对待。
五、从一个对象中,创建一个 DOM 树
写一个函数 createTree
,从一个嵌套对象来创建出对应的 DOM 树结构表示。
例如:
let data = {
"Fish": {
"trout": {},
"salmon": {}
},
"Tree": {
"Huge": {
"sequoia": {},
"oak": {}
},
"Flowering": {
"redbud": {},
"magnolia": {}
}
}
};
使用:
let container = document.getElementById('container');
createTree(container, data); // 在容器中创建树
结果看起来应该是这样的:
选择下列两种方式之一来解决这个任务:
创建树的 HTML 代码,然后赋值给
container.innerHTML
。使用 DOM 的
append
方法来创建树节点。
要是两种方式都做出来了,就更好了。
P.S. 树结构中,不应该存在像 <ul></ul>
这样的空叶子“额外”元素。
六、展示 DOM 树的后代
有一个由内嵌 ul/li
组成的 DOM 树结构。
写代码,为每一个 <li>
标签的内容追加其后代元素个数。忽略叶子节点(没有孩子的节点)。
结果如下:
七、创建日历
写一个函数 createCalendar(elem, year, month)
。
调用这个函数的结果是创建指定年/月下的日历,并放入 elem
元素中。
例如应该是一张表格,一周用 <tr>
表示,一天用 <td>
表示。表格最顶部是表示周几的 <th>
元素:第一天是周一,一直到周日。
例如,createCalendar(cal, 2012, 9)
会在 cal
元素中产生如下的日历:
P.S. 对于此任务,简单生成日历就可以了,不需要提供点击功能。
八、为时钟上色
创建一个彩色时钟:
九、向列表中插入 HTML
写代码,在下面两个 <li>
之间插入内容 <li>2</li><li>3</li>
。
<ul id="ul">
<li id="one">1</li>
<li id="two">4</li>
</ul>
十、排序表格
这里有一张表:
可能还有更多行数据。
写代码,让数据能按照 "name"
列排序。
答案
一、
答案是:1 和 3。
两个命令都是将 text
作为“普通文本”插入到 elem
中去的。
这有个例子:
<div id="elem1"></div>
<div id="elem2"></div>
<div id="elem3"></div>
<script>
let text = '<b>文本内容</b>';
elem1.append(document.createTextNode(text));
elem2.textContent = text;
elem3.innerHTML = text;
</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 = '';
}
三、
所提供的 HTML 代码是不正确的,这就是发生奇怪事情的原因。
浏览器会自动修改我们代码的问题。在 <table>
中文本是不能直接作为孩子节点存在的:根据规范,只有特定于表格的标签被允许使用,因此浏览器会将 "aaa"
移动到 <table>
之前显示。
现在我们就能知道,当我们删除表格时,文本仍然存在的原因。
通过使用浏览器工具检查 DOM 可以轻松回答这个问题。检查结果显示 "aaa"
文本位于 <table>
之前。
HTML 规范详细说明了如何处理错误的 HTML,浏览器的这种行为是正确的。
四、
let ul = document.createElement('ul');
document.body.append(ul);
var content = ''
while (content = prompt('请输入内容')) {
let li = document.createElement('li');
li.textContent = data;
ul.append(li);
}
五、
- innerHTML 方法。
function createTree (container, data) {
container.innerHTML = walkData(data)
}
function walkData(data) {
let keys = Object.keys(data)
let lis = keys.reduce((lis, key) => {
let childHtml = ''
if (!isEmptyObject(data[key])) {
childHtml = walkData(data[key])
}
lis += `<li>${key}${childHtml}</li>`
return lis
}, '')
let html = lis ? `<ul>${lis}</ul>` : lis
return html
}
function isEmptyObject(obj) {
for (let prop in obj) {
return false
}
return true
}
- DOM 方法。
function createTree (container, data) {
container.appendChild(walkData(data))
}
function walkData(data) {
let keys = Object.keys(data)
return keys.reduce((ul, key) => {
let childNode = null
if (!isEmptyObject(data[key])) {
childNode = walkData(data[key])
}
let li = document.createElement('li')
li.textContent = key
if (childNode) {
li.appendChild(childNode)
}
ul.appendChild(li)
return ul
}, document.createElement('ul'))
}
function isEmptyObject(obj) {
for (let prop in obj) {
return false
}
return true
}
六、
let lis = document.getElementsByTagName('li');
for (let li of lis) {
// 统计当前 <li> 下的所有 <li> 标签个数
let descendantsCount = li.getElementsByTagName('li').length;
if (!descendantsCount) continue;
// 直接将个数附加到文本中
li.firstChild.data += ' [' + descendantsCount + ']';
}
七、
function createCalendar(cal, year, month) {
var date = new Date(year, month - 1)
// 1 号是周几
var day = date.getDay()
// 一共有几天
date.setMonth(month, 0)
var dates = date.getDate()
// 日期占几行
var offsetDict = [6, 0, 1, 2, 3, 4, 5]
var offset = offsetDict[day]
var rows = Math.ceil((offset + dates) / 7)
// 先把日历框架遍历出来
var frag = document.createDocumentFragment()
for (let i = 0; i < rows; i++) {
let tr = document.createElement('tr')
tr.innerHTML = '<td> </td>'.repeat(7)
frag.appendChild(tr)
}
// 然后填充日期
var tds = frag.querySelectorAll('td')
for (let i = 0; i < dates; i++) {
let day = i + 1
let td = tds[i + offset]
td.textContent = day
}
var table = document.createElement('table')
table.className = 'cal'
thDict = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
table.innerHTML = `
<thead>
<tr>
<th>${thDict[0]}</th>
<th>${thDict[1]}</th>
<th>${thDict[2]}</th>
<th>${thDict[3]}</th>
<th>${thDict[4]}</th>
<th>${thDict[5]}</th>
<th>${thDict[6]}</th>
</tr>
</thead>
<tbody></tbody>
`
var tBody = table.tBodies[0]
tBody.append(frag) // 将生成好的日期附加到 tBody
// 将最终生成的 table 附加到 cal 中
cal.append(table)
}
八、
我们先来写 HTML/CSS:
每次时间组件(时、分、秒)都用 标签包装:
<div id="clock">
<span class="hour">hh</span>:<span class="min">mm</span>:<span class="sec">ss</span>
</div>
同时,我们需要用一些 CSS 样式装饰一下。
update
函数负责更新时钟时间,内部使用 setInterval
间隔 1 秒实现:
function update() {
let clock = document.getElementById('clock');
let date = new Date(); // (*)
let hours = date.getHours();
clock.children[0].innerHTML = add0(hours);
let minutes = date.getMinutes();
clock.children[1].innerHTML = add0(minutes);
let seconds = date.getSeconds();
clock.children[2].innerHTML = add0(seconds);
}
function add0(v) {
return v.toString().padStart(2, '0')
}
在 (*)
这个地方,每次都获取下当前时间。setInterval
调用并不可靠——可能会延迟发生。
管理时钟运行的函数:
let timerId;
function clockStart() { // 运行时钟
update(); // (*)
timerId = setInterval(update, 1000);
}
function clockStop() {
clearInterval(timerId);
timerId = null;
}
请注意,对 update()
的调用不仅安排在 setInterval
中,而且在(*)
处还立即调用了。否则访问者必须等 1 秒后才能看到效果,在这之前都是空的。
九、
one.insertAdjacentHTML('afterend', '<li>2</li><li>3</li>');
十、
解决方案很简短,但可能看起来有点棘手,所以在这里我提供了详细的注释:
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);
获得所有的
<tr>
,类似table.querySelectorAll('tr')
,然后转换成数组,因为我们要用到数组方法。第一个 TR 实际上是表格表头,我们不需要它,只需要排序
.slice(1)
后剩下的。我们排序比较的是第一个
<td>
里的内容(name
字段)。现在通过
.append(...sortedRows)
将正确顺序的节点插入。
表格总是包含一个隐藏元素<tbody>
,我们要插入到它里面,简单的使用table.append(...)
会失败的。
需要注意的是:我们无需删除它们,只要“重新插入”就可以了,元素节点会自动离开老位置,转移到新位置的。
(完)