无论何时,我们需要进行DOM操作,我们将很快地借助于jQuery。然而,JavaScript DOM API事实上是非常有能力的,并且自从低于 IE11 被 完全放弃 后,它现在可以被放心地使用了。
在本文中,我将演示如何完成一些最常见的DOM操作任务用普通的JavaScript,即:
第一节:查询和修改DOM
第二节:修改类和属性
第三节:监听事件
第四节:动画
我将给你展示如何创建你自己的超精简的DOM库,你可以把它放入任何项目中。一路上,你将学习到,使用vanilla JS进行DOM操作不是什么高端玩法,事实上许多jQuery方法在本地API中都有直接的类似实现。
让我们开始吧……
请注意:我不会解释vanilla DOM API的全部细节,只是简单地了解一下。在示例中,你可能会遇到我没有明确介绍的方法。这时,只需要去参考优秀的 Mozilla Developer Network (MDN) 里的细节了。
DOM可以使用 .querySelector()
方法来查询,该方法把任意的CSS选择器作为一个参数:
const myElement = document.querySelector('#foo > div.bar');
这将返回第一个匹配的DOM(深度优先)。相反地,我们可以检测一个元素是否匹配一个选择器:
myElement.matches('div.bar') === true
如果我们想获取所有的匹配到的DOM,我们可以使用:
const myElements = document.querySelectorAll('.bar');
如果我们已经有了一个父元素的引用,我们可以只查询该父元素的子元素而不是整个 document。通过像这样缩小上下文,我们可以简化选择器并且提高性能。
const myChildElement = myElements.querySelector('input[type="submit"]');
// 而不是
// document.querySelector('#foo > div.bar input[type="submit"]')
那么为什么要使用其它不太方便的方法,比如 .getElementsByTagName()
?一个重要的不同就是 .querySelector()
的结果不是动态的,所以当我们动态地通过搭配一个选择器来添加一个元素(请在第三节寻找细节)时,集合将不会更新。
const elements1 = document.querySelectorAll('div');
const elements2 = document.getElementsByTagName('div');
const newElement = document.createElement('div');
document.body.appendChild(newElement);
elements1.length === elements2.length // false
另一个注意的地方是这样一个动态集合不需要预先提供所有的信息,而 .querySelectorAll()
立即收集静态列表中的所有内容,使其性能降低。
现在关于 .querySelectorAll()
有两个常见的陷阱。第一个就是我们不能在结果上调用节点(Node)方法并将它们传播给它的元素(就像你可能从jQuery对象中使用那样)。相反,我们必须明确地重复这些元素。另一个陷阱是:返回值是一个节点列表(NodeList),不是一个数组。这意味着常规的数组方法不能直接使用。有几个对应的NodeList实现,例如.forEach,但是任何 IE 仍然不支持。所以我们不得不首先把这和列表转换为一个数组,或者从数组原型中“借用”这些方法。
// 使用 Array.from()
Array.from(myElements).forEach(doSomethingWithEachElement);
// ES6之前的做法
Array.prototype.forEach.call(myElements, doSomethingWithEachElement);
// 简写:
[].forEach.call(myElements, doSomethingWithEachElement);
每个元素还有一些引用“家族”的默认的只读属性,所有这些都是动态的:
myElement.children
myELement.firstElementChild
myElement.lastElementChild
myElement.previousElementSibling
myElement.nextElementSibling
由于元素(Element)接口继承自节点(Node)接口,因此还可以使用以下属性:
myElement.childNodes
myElement.firstChild
myElement.lastChild
myElement.previousSibling
myElement.nextSibling
myElement.parentNode
myElement.parentElement
如果前者是唯一的参考元素,后者(除了 .parentElement
)可以是任何类型的节点,例如,文本节点。我们可以检测一个给定节点的类型,像这样:
myElement.firstChild.nodeType === 3
// 它是一个文本节点
与任何对象一样,我们可以使用 instanceof
操作符来检查节点呃原型链:
myElement.firstChild.nodeType instanceof Text
修改元素的类是这样容易:
myElement.classList.add('foo');
myElement.classList.remove('bar');
myElement.classList.toggle('baz');
你可以阅读一个更深入的讨论,关于如何用这种 Yaphi Berhanu 的快速技巧来修改类。元素属性可以像任何其他对象属性一样被访问。
// 获取一个属性值
const value = myElement.value;
// 给一个元素设置一个属性
myElement.value = "foo";
// 使用 Object.assign() 来设置多个属性
Object.assign(myElement, {
value: 'foo',
id: 'bar'}
);
// 移除一个属性
myElement.value = null;
注意,还有 .getAttribute()
、.setAttribute()
、.removeAttribute()
这些方法。这些方法可以直接修改HTML的元素属性(而不是DOM属性),这会引起浏览器重绘(你可以通过使用浏览器的 dev tools 来检查元素观察这些更改)。这种浏览器重绘不仅比设置DOM属性更加耗费性能,而且这些方法也会产生意想不到的结果。
作为一个经验之谈,只能将它们用于没有相应DOM属性的属性(如 colspan
),如果你真的想将这些改变“坚持”用到HTML中(例如,在克隆元素或修改它的父元素的 .innerHTML
时保留它们 - 请参阅第三部分)
CSS规则可以像其他属性一样应用;注意,这些属性在JavaScript中是驼峰式的:
myElement.style.marginLeft = '2em';
如果我们想要某些值,我们可以通过 .style
属性获得这些值。然而,这只会给我们已经明确应用的样式。为了获得计算值,我们可以使用 .window.getComputedStyle
。它接受元素并返回一个 CSSStyleDeclaration ,其中包含元素本身以及从父元素继承的所有样式:
window.getComputedStyle(myElement).getPropertyValue('margin-left')
我们可以像这样移动元素:
// 给 element1 追加最后一个子元素 element2
element1.appendChild(element2)
// 在element3 之间插入element2 作为 element1 的子元素
element1.insertBefore(element2, element3)
如果我们不想移动元素,而是插入一个副本,我们可以像这样克隆它:
// 新建一个克隆
const myElementClone = myElement.cloneNode()
myParentElement.appendChild(myElementClone)
.cloneNode()
方法可以选择一个布尔值作为参数;如果设置为true,则会创建一个深层副本,意味着它的子项也被克隆了。
当然,我们也可以创建全新的元素或文本节点:
const myNewElement = document.createElement('div');
const myNewTextNode = document.createTextNode('some text');
我们可以按照上面那样插入。如果我们想要移除一个元素,不能直接这样做,但我们可以从父元素中移除子元素,像这样:
myParentElement.removeChild(myElement)
这给了我们一个很好的解决方法,意思是通过引用它的父元素,可以间接地移除一个元素:
myElement.parentNode.removeChild(myElement)
每个元素也有 .innerHTML
和 .textContent
属性(.innerText
和 .textContent
类似,但有一些重要的区别)。 这些分别包含HTML和纯文本内容。它们是可写属性,这意味着我们可以直接修改元素及其内容:
// 替换内部的 HTML
myElement.innerHTML = `
<div>
<h2>New content</h2>
<p>beep boop beep boop</p>
</div>
`
// 移除所有的子节点
myElement.innerHTML = null
// 添加内部的 HTML
myElement.innerHTML += `
<a href="foo.html">continue reading...</a>
<hr/>
`
如上所示向HTML添加标记通常是一个坏主意,因为我们将失去之前在受影响的元素上进行的任何属性更改(除非我们将这些更改作为HTML属性持续保存,如第二节中所示)和绑定事件侦听器。设置 .innerHTML
对于完全抛弃标记并将其替换为其他内容是很好的,例如,服务器呈现的标记。所以追加元素最好像这样完成:
const link = document.createElement('a')
const text = document.createTextNode('continue reading...')
const hr = document.createElement('hr')
link.href = 'foo.html'
link.appendChild(text)
myElement.appendChild(link)
myElement.appendChild(hr)
然而,使用这种方法,我们会导致两次浏览器抽回 — 每个附加元素一次 — 而更改 .innerHTML
只会导致一次。作为解决此性能问题的一种方法,我们可以首先在 DocumentFragment 中组装所有节点,然后再附加单个片段:
const fragment = document.createDocumentFragment()
fragment.appendChild(text)
fragment.appendChild(hr)
myElement.appendChild(fragment)