原文链接: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); // 深度克隆 divdiv2.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 = textelem.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>` : lisreturn 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 = nullif (!isEmptyObject(data[key])) {childNode = walkData(data[key])}let li = document.createElement('li')li.textContent = keyif (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 + 1let 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(...)会失败的。
需要注意的是:我们无需删除它们,只要“重新插入”就可以了,元素节点会自动离开老位置,转移到新位置的。
(完)
请注意: