[译]No JQuery! 原生JavaScript操作DOM

翻译自sitepoint的一篇文章,作者是Sebastian Seitz。虽然日常工作中很少再写原生js来操作DOM了,大家可能都在用主流的前端框架,我也是,但是看到这篇很浅显易懂的文章,还是忍不住想细读一下,复习的同时也会有新的发现。

无论何时我们需要操作DOM的时候,我们都会很快去用jQuery。然而,原生的JavaScript DOM API其实以它自己的方式已经可以解决非常多的需求。因为11以下的IE版本已经被官方丢弃,我们可以没有任何担忧地使用它。

在这篇文章,我将展示如何用原生JavaScript来完成一些最普遍的DOM操作任务,即:

  • 查找并修改DOM
  • 修改class和属性
  • 事件监听
  • 动画

我将在最后展示给各位,如何来创建一个可以用在任何项目里的你自己的超精简DOM库。与此同时,各位可以学到用原生JS操作DOM其实并不难,很多jQuery的方法事实上都有对等的native API。

那么我们开始吧...

DOM操作:查找DOM

请注意:我不会详细地讲解原生DOM API的细节,只是停留在表面。在用例里,你可能会遇到我并没有清楚介绍的方法。这时你可以参考Mozilla Developer Network

可以用.querySelector()方法来查询DOM。需要传入任意的CSS选择器作为参数:

const myElement = document.querySelector('#foo > div.bar')

这行代码返回第一个匹配的元素(深度优先)。相反的,我们可以检查一个元素是否匹配一个选择器:

myElement.matches('div.bar') === true

如果我们想得到所有匹配元素,我们可以用:

const myElements = document.querySelectorAll('.bar')

如果我们已经得到一个父元素的引用,我们可以只查找它的子元素,而不是整个document。像这样缩小查找范围,我们可以简化选择器提高查找性能。

const myChildElemet = myElement.querySelector('input[type="submit"]')

// Instead of
// 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原型上“借用”那些方法。

// Using Array.from()
Array.from(myElements).forEach(doSomethingWithEachElement)

// Or prior to ES6
Array.prototype.forEach.call(myElements, doSomethingWithEachElement)

// Shorthand:
[].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 // this would be a text node

像任何对象那样,我们可以用instanceof操作符检查节点的原型链:

myElement.firstChild.nodeType instanceof Text


修改class和属性

修改元素的class像下面的代码这样简单:

myElement.classList.add('foo')
myElement.classList.remove('bar')
myElement.classList.toggle('baz')

你可以在quick tip by Yaphi Berhanu读到关于如何修改class的更深度的讨论。元素属性值可以像其他任何对象属性一样得到。

// Get an attribute value
const value = myElement.value

// Set an attribute as an element property
myElement.value = 'foo'

// Set multiple properties using Object.assign()
Object.assign(myElement, {
  value: 'foo',
  id: 'bar'
})

// Remove an attribute
myElement.value = null

注意还有.getAttibute(), .setAttribute().removeAttribute()这三个方法。这些方法直接修改的是元素的HTML属性(与DOM属性相对),因此会使浏览器重新渲染(你可以用你的浏览器自带的开发调试工具来检查元素观察它的变化)。浏览器重新渲染不仅比只是设置DOM属性代价更高,而且还会产生不期望的后果

作为一个小原则,除非你真的想对HTML“持久化”那些改变,你就只用上面的方法修改与DOM属性不相关的HTML属性(比如colspan)。(比如当克隆一个元素或者修改它的父元素的.innerHTML的时候想保持这些改变,参考第三部分)

添加CSS样式

CSS规则可以像其他属性那样设置。需要注意的是在JavaScript里要写成驼峰形式:

myElement.style.marginLeft = '2em'

如果我们想获得CSS规则的值,我们可以通过.style属性。然而,通过它只能拿到我们明确设置过的样式。想拿到计算后的样式值,我们可以用.window.getComputedStyle()。它可以拿到这个元素并返回一个CSSStyleDeclaration。这个返回值包括了这个元素自己的和继承自父元素的全部样式。

window.getComputedStyle(myElement).getPropertyValue('margin-left')


修改DOM

我们可以像下面这样移动元素:

// Append element1 as the last child of element2
element1.appendChild(element2)

// Insert element2 as child of element 1, right before element3
element1.insertBefore(element2, element3)

如果我们不想移动元素,而是插入一个拷贝,我们可以这样克隆它:

// Create a clone
const myElementClone = myElement.cloneNode()
myParentElement.appendChild(myElementClone)

.cloneNode()方法可选地接受一个boolean类型的参数;如果传入的是true, 将会创建一个深拷贝,也就是它的所有子元素也会被克隆。

当然我们可以创建一个全新的元素或文本节点:

const myNewElement = document.createElement('div')
const myNewTextNode = document.createTextNode('some text')

然后我们可以像上面展示的代码那样插入创建的元素。如果我们想删除一个元素,我们不能直接删除,而要采用从它的父元素删除子元素的办法来实现,像这样:

myParentElement.removeChild(myElement)

这给了我们一个优雅的解决办法,也就是可以通过它的父元素间接的删除一个元素:

myElement.parentNode.removeChild(myElement)
元素属性

每个元素都有.innerHTML.textContent(还有.innerText,跟.textContent类似,但是有一些重要的区别。它们分别表示HTML内容和纯文本内容。它们是可写的属性,也就是说我们可以直接修改元素和它们的内容:

// Replace the inner HTML
myElement.innerHTML = `
  <div>
    <h2>New content</h2>
    <p>beep boop beep boop</p>
  </div>
`

// Remove all child nodes
myElement.innerHTML = null

// Append to the inner 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里,然后把这一个片段添加到DOM里,这样可以解决这个性能问题。

const fragment = document.createDocumentFragment()

fragment.appendChild(link)
fragment.appendChild(hr)
myElement.appendChild(fragment)


事件监听

这可能是最知名的绑定事件监听的方法:

myElement.onclick = function onclick (event) {
  console.log(event.type + ' got fired')
}

但是这是通常应该避免采用的方法。这里,.onclick是一个元素的属性,也就是说你可以修改它,但是你不能用它再绑定其他的监听函数-你只能把新的函数赋给它,覆盖掉旧函数的引用。

我们可以用更加强大的.addEventListener()方法来尽情地添加各种类型的各种事件的监听器。它接受三个参数:事件类型(比如click),一个无论何时在这个绑定元素上该事件发生都会触发的函数(这个函数会得到一个事件对象传进去作为参数)和一个可选的配置参数,下面会更详细的解释。

myElement.addEventListener('click', function (event) {
  console.log(event.type + ' got fired')
})

myElement.addEventListener('click', function (event) {
  console.log(event.type + ' got fired again')
})

在监听函数内部,event.target指向这个事件触发的元素(this也是,当然除非你用的是箭头函数。译者注:如果监听函数是箭头函数,里面的this指向的是window对象,如果是普通的function函数,里面的this指向的跟event.target相同,都是该元素本身)。因此你可以轻松的拿到它的属性:

// The `forms` property of the document is an array holding
// references to all forms
const myForm = document.forms[0]
const myInputElements = myForm.querySelectorAll('input')

Array.from(myInputElements).forEach(el => {
  el.addEventListener('change', function (event) {
    console.log(event.target.value)
  })
})
阻止默认行为

注意在监听函数内部总是可以拿到event,但是当需要的时候明确地传入这个参数是一个好的实践(当然参数名称可以随意设置)(译者注:即使没有明确地给监听函数传入任何参数,在内部仍然可以拿到原生event对象,变量名就是event)。先不详细解释Event接口,一个特别需要注意的方法是.preventDefault()。它可以用来阻止浏览器的默认行为,比如跳转链接。另一个常见的应用场景是当前端的表单校验失败的时候,可以根据判断条件阻止表单提交。

myForm.addEventListener('submit', function (event) {
  const name = this.querySelector('#name')

  if (name.value === 'Donald Duck') {
    alert('You gotta be kidding!')
    event.preventDefault()
  }
})

另一个重要的事件方法是.stopPropagation(),它可以阻止事件冒泡。也就是说在一个子元素上绑定了阻止事件冒泡的点击事件监听函数,而在它的某一个父元素上也监听了点击事件,在子元素上触发的点击事件,不会触发它的这个父元素的点击事件监听函数-否则,父子元素都会触发。

现在我们看一下.addEventListener()的可选的配置对象这个第三个参数,它可以有以下的布尔属性(它们的默认值都是false):

  • capture: 这个事件会先在父元素触发,然后再向下传递给它的子元素(关于事件捕获和事件冒泡更详细地解释可以参考这里
  • once: 你已经猜到,这个属性表示这个事件只会被触发一次
  • passive: 它的意思是event.preventDefault()会被忽略(通常在控制台都会打印一句警告)

最常用的选项是.capture;事实上,因为它非常常用,所以可以只传入它的一个布尔值,而不必传入整个配置对象:

myElement.addEventListener(type, listener, true)

事件监听可以用.removeEventListener()方法删除。它接受事件类型和回调函数的引用两个参数;例如,once选项也可以像这样实现:

myElement.addEventListener('change', function listener (event) {
  console.log(event.type + ' got triggered on ' + this)
  this.removeEventListener('change', listener)
})
事件委托

另一个有用的模式是事件委托:假如我们有一个表单,并且想给它的每一个input元素绑定一个change事件的监听函数。一种方法是上面已经介绍过的那样用myForm.querySelectorAll('input')取到所有的input元素,然后再通过遍历绑定事件。然而,我们其实只需要给表单本身绑定这个事件监听函数,然后检查event.target是否是input元素就可以了。

myForm.addEventListener('change', function (event) {
  const target = event.target
  if (target.matches('input')) {
    console.log(target.value)
  }
})

用这种模式的另一个优势就是它对动态插入的子元素同样有效,而不需要给每一个绑定新的监听函数。

动画

通常,最优雅的生成动画的方式是结合transition属性用CSS的类,或者用CSS的@keyframes。但是如果你需要更加灵活的方式(比如做游戏),也可以用JavaScript。

简单的方法就是有一个window.setTimeout()函数,不断地调用自己直到期望的动画完成。然而,这会低效地强迫文档进行迅速的重排;并且结构的抖动会很快使页面卡顿,特别是在移动设备上。替代方案是,我们可以用window.requestAnimationFrame()同步页面的更新,把当前的所有改变安排到下一次浏览器重绘。它接受一个回调函数作为参数。这个回调函数会接收到当前的时间戳作为参数:

const start = window.performance.now()
const duration = 2000

window.requestAnimationFrame(function fadeIn (now)) {
  const progress = now - start
  myElement.style.opacity = progress / duration

  if (progress < duration) {
    window.requestAnimationFrame(fadeIn)
  }
}

用这个方法我们可以得到非常流畅的动画。想了解更加详细的讨论,可以参考Mark Brown写的这篇文章。

写你自己的帮助函数

确实,与jQuery简洁的链式的$('.foo').css({color: 'red'})表达式相比,总是要遍历元素去做什么可能是非常的繁琐。所以为什么我们不像下面这样写我们自己的快捷的方法呢?

const $ = function $ (selector, context = document) {
const elements = Array.from(context.querySelectorAll(selector))

  return {
    elements,

    html (newHtml) {
      this.elements.forEach(element => {
        element.innerHTML = newHtml
      })

      return this
    },

    css (newCss) {
      this.elements.forEach(element => {
        Object.assign(element.style, newCss)
      })

      return this
    },

    on (event, handler, options) {
      this.elements.forEach(element => {
        element.addEventListener(event, handler, options)
      })

      return this
    }

    // etc.
  }
}

因此我们有了一个没有向下兼容负担的只有我们需要的方法的超简洁的DOM库。尽管通常在元素的原型链上已经有了那些方法。这里有一个gist(更加详细深入一些),它展示了一些实现这些帮助函数的办法。我们还可以这样保持简单:

const $ = (selector, context = document) => context.querySelector(selector)
const $$ = (selector, context = document) => context.querySelectorAll(selector)

const html = (nodeList, newHtml) => {
  Array.from(nodeList).forEach(element => {
    element.innerHTML = newHtml
  })
}

// And so on...


Demo

为使文章圆满结束,下面的CodePen通过实现一个简单的灯箱效果展示了上面提到的很多概念。我鼓励你们花点时间去看一下源码,如果你们有任何想法或者疑问请在下面评论来让我知道。
CodePen上的Demo代码

结论

我希望我已经证明了用原生JavaScript来操作DOM并不是什么高科技,而且事实上,很多jQuery里的方法在原生DOM的API里有直接对应的实现。这意味着在一些日常的应用场景里(比如导航菜单或者是跳出的模态框),额外的加载过重的DOM库是不合适的。

虽然一部分原生API确实繁琐或是不方便(比如必须总是要手动遍历节点列表),但是我们能够非常轻松的把这些重复工作抽象出来写成我们自己的短小的帮助函数。

但是现在轮到你了。你怎么看?你更愿意在你可以的地方避免使用第三方库,还是使自己卷入根本不值得的认知开销里面?请在下面的评论中让我知道。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容

  • (续jQuery基础(1)) 第5章 DOM节点的复制与替换 (1)DOM拷贝clone() 克隆节点是DOM的常...
    凛0_0阅读 1,318评论 0 8
  • 1.JQuery 基础 改变web开发人员创造搞交互性界面的方式。设计者无需花费时间纠缠JS复杂的高级特性。 1....
    LaBaby_阅读 1,325评论 0 2
  • 1.JQuery 基础 改变web开发人员创造搞交互性界面的方式。设计者无需花费时间纠缠JS复杂的高级特性。 1....
    LaBaby_阅读 1,162评论 0 1
  • 原文地址:https://www.sitepoint.com/dom-manipulation-vanilla-j...
    杜伊特阅读 1,150评论 1 8
  • 青天揽月,醒意朱雀。 风花雪月,牧夜独谢。 万载豪情,负命难违。 功勋不朽,四季百花。 月升日暮,挥笔染墨。 剑指...
    帅包面阅读 293评论 0 1