三、DOM 脚本和 AJAX

当涉及到文档对象模型DOM)操作和 AJAX 时,第一反应可能是使用 jQuery 或 Zepta。但当浏览器提供您所需的一切时,您是否会为常见任务加载一个重要的第三方库?有些人使用 jQuery 来实现跨浏览器兼容性。众所周知,该库修复了损坏的 DOM API。当我们支持像 IE7 这样古老的浏览器时,这真的很有帮助。然而,今天我们几乎不需要关心旧式浏览器,因为它们的使用率低于 0.1%(http://www.w3schools.com/browsers/browsers_explorer.asp 。现代浏览器在支持 Web API 方面相当一致。总的来说,跨浏览器兼容性不再是一个问题。

第二个也是最常见的借口是,该库简化了查询和操作 DOM 所需编写的代码量。它确实在某种程度上简化了代码,但缺点是现在我们有一代开发人员不懂 JavaScript 和 WebAPI,只懂 jQuery。他们中的许多人无法解决没有库的简单任务,也不知道调用库方法时实际发生了什么。好的代码意味着可移植性和高性能。如果不了解本机 API,就很难实现这一点。

因此,在本章中,我们将研究处理 DOM 和 AJAX 的本机方法,重点关注高性能。

本章将涵盖以下主题:

  • 高速 DOM 操作
  • 与服务器的通信

高速 DOM 操作

为了有效地处理 DOM,我们需要了解它的本质。DOM 是一种树结构,表示在浏览器中打开的文档。DOM 的每个元素都是一个称为节点的对象。

High-speed DOM operations

作为对象的每个节点都有属性和方法(https://developer.mozilla.org/en/docs/Web/API/Node 。有不同类型的节点。在上图中,您可以看到文档节点、元素节点和文本节点。实际上,树还可能包含特定的节点类型,如注释节点、doctype 节点等。为了说明树中的关系,我们可以说 HTML 有两个子节点,它们作为兄弟节点相互关联。显然,HTML 是头部和身体的父节点。我们可以使用这些可通过节点属性访问的关系在树中导航:

var html = document.documentElement;
console.log( html.nodeName ); // HTML

var head = html.childNodes[0];
console.log( head.nodeName );  // HEAD
console.log( head.parentNode === html );  // true

这一部分很清楚,但如果我们要求下一个兄弟姐妹是头而不是身体。我们将得到一个内容中带有空格的文本节点(nodeValue

var sibling = head.nextSibling;
// the same as html.childNodes[1]
console.log( sibling.nodeName ); // #text
console.dir( sibling.nodeValue ); // "\n  "

在 HTML 中,我们通常使用空格、制表符和换行符来分隔元素,以提高可读性,这些元素也构成 DOM 的一部分。因此,为了访问元素,我们使用文档和元素方法。

穿越 DOM

当然,您知道如何通过 ID(document.getElementById)或标记名(document.getElementsByTagName来查找元素。您还可以通过 CSS 选择器(document.querySelector搜索元素):

<article id="bar">
  <h2>Lorem ipsum</h2>
</article>
var article = document.querySelector( "#bar" ),
      heading = article.querySelector( "h2" );

选择器由一个或多个类型(标记)选择器、类选择器、ID 选择器、属性选择器或伪类/元素选择器(生成 http://www.w3.org/TR/CSS21/selector.html%23id-选择器。考虑到组合(以匹配组、后代或兄弟姐妹),这提供了许多可能的选择。因此,很难选择从 JavaScript 绑定 HTML 元素的策略。我的建议是始终使用data-*属性选择器:

<article data-bind="bar">
  <h2 data-bind="heading">Lorem ipsum</h2>
</article>

var article = document.querySelector( "[data-bind=\"bar\"]" ),
      heading = article.querySelector( "[data-bind=\"heading\"]" );

这样我们就独立于 HTML 结构。例如,如果我们为了更好的语义而更改标记,那么 JavaScript 端就不会出现任何问题。我们独立于 CSS 类,这意味着我们可以安全地重构 CSS。我们不受 ID 的限制,每个文档的 ID 应该是唯一的。

querySelector获取 DOM 中的第一个元素以匹配选择器时,querySelectorAll检索所有元素:

<ul data-bind="bar">
  <li data-bind="item">Lorem ipsum</li>
  <li data-bind="item">Lorem ipsum</li>
  <li data-bind="item">Lorem ipsum</li>
</ul>

var ul = document.querySelector( "[data-bind=\"bar\"]" ),
      lis = ul.querySelectorAll( "[data-bind=\"item\"]" );
console.log( lis.length );

找到的元素表示为NodeList。它看起来像一个数组,但不是。它是一个实时收集,每次 DOM 回流都会更新它。考虑下面的例子:

var divs = document.querySelectorAll( "div" ), i; 
for ( i = 0; i < divs.length; i++ ) { 
  document.appendChild( document.createElement( "div" ) ); 
}

前面的代码导致了一个无限循环,因为每当我们访问集合的下一个元素时,一个新元素被追加到集合中,divs.length递增,并且我们从不满足循环条件。

重要的是要知道,通过一个实时集合(NodeListHTMLCollection进行的迭代是缓慢的,而且资源相当昂贵。如果您不需要它是实时的,只需将集合转换为一个数组,如第 1 章跳入 JavaScript 核心中所述的[].slice.call( nodeList )。在 ES6 中,这可以通过[...nodeList]spread操作员完成:

var ul = document.querySelector( "[data-bind=\"bar\"]" ),
      lis = ul.querySelectorAll( "[data-bind=\"item\"]" );
console.log( [].slice.call( lis ) ); // into array ES5 way
console.log( [ ...lis ] ); // into array ES6 way

除了查询,我们还可以测试找到的元素是否与给定选择器匹配:

console.log( el.matches( ".foo > .bar" ) );
console.log( input.matches( ":checked" ) );

改变 DOM

现在我们知道了如何在 DOM 中查找元素。让我们看看如何在 DOM 树中动态地插入新元素。有不同的方法。我们可以简单地用el.innerHTML方法设置新的 HTML 内容:

var target = document.getElementById( "target" );
target.innerHTML = "<div></div>";

否则,我们可以创建一个节点(document.createElement),并将其注入 DOM(el.appendChild):

var target = document.getElementById( "target" ),
      div = document.createElement( "div" ),
target.appendChild( div );

在这里,您应该记住,每次更改el.innerHTML或将子元素附加到元素时,都会导致 DOM 回流。当这种情况在循环中重复发生时,可能会降低应用的速度。

当我们通过el.innerHTML传递 HTML 时,浏览器首先必须解析字符串。这是一个消耗资源的操作。但是,如果我们显式地创建元素,这将快得多。如果我们生产一批类似的元件,流程可以进一步优化。我们可以克隆最初创建的元素(el.cloneNode),而不是创建循环中的每个元素,这要快得多:

var target = document.getElementById( "target" ),
    /**
     * Create a complex element
     * @returns {Node}
     */
    createNewElement = function(){
      var div = document.createElement( "div" ),
          span = document.createElement( "span" );
      span.appendChild( document.createTextNode( "Bar" ) );
      div.appendChild( span );
      return div;
    },
    el;

el = createNewElement();
// loop begins
target.appendChild( el.cloneNode( true ) );
// loop ends

另一方面,我们可以创建一个文档片段(document.createDocumentFragment),并在循环期间将创建的节点附加到片段中。文档片段是一种虚拟 DOM,我们操纵它而不是真实的 DOM。完成后,我们可以将文档片段作为一个分支注入到真正的 DOM 中。通过将这项技术与克隆技术相结合,我们应该在性能方面有所提高。实际上,这是不确定的(http://codepen.io/dsheiko/pen/vObVOR )。例如,在 WebKit 浏览器中,虚拟 DOM(document.createDocumentFragment)的运行速度比真实 DOM 慢。

正如我们在性能方面所做的那样,让我们关注准确性。如果我们需要将元素注入到精确的位置(例如,foobar节点之间),el.appendChild不是正确的方法。我们必须使用el.insertBefore

parent.insertBefore(el, parent.firstChild);

要从 DOM 中删除特定元素,我们需要执行以下技巧:

el.parentNode.removeChild(el);

此外,我们可以重新加载一个元素,例如重置所有订阅的侦听器:

function reload( el ) {
    var elClone = el.cloneNode( true );
    el.parentNode && el.parentNode.replaceChild( elClone, el );
 }

设计 DOM 的样式

说到样式,我们必须尽可能使用 CSS 类。这提供了更好的可维护性继承、组合和关注点分离。您肯定知道如何通过el.className属性将预期的类分配给元素。然而,在现实世界中,el.classList对象更有用:

el.classList.add( "is-hidden" );
el.classList.remove( "is-hidden" );
var isAvailable = true;
el.classList.toggle("is-hidden", !isAvailable );
if ( el.classList.contains( "is-hidden" ) ){}

在这里,除了明显的 add/remove/contains 方法外,我们还使用了toggle。此方法根据作为第二个参数传递的布尔值添加或删除指定的类。

有时我们需要显式地操纵样式。DOM 的一部分称为CSS 对象模型CSSOM)提供了一个操作 CSS 的接口。因此,我们可以使用el.style属性读取或设置元素的动态样式信息:

el.style.color = "red";
el.style.fontFamily = "Arial";
el.style.fontSize = "1.2rem";

一种鲜为人知的技术是更改样式规则的实际文本:

el.style.cssText = "color:red;font-family: Arial;font-size: 1.2rem;";

如您所见,第二种方法没有那么灵活。您不能更改或访问单个声明,只能更改或访问整个规则。但是,这种方式的设计速度要快得多(http://codepen.io/dsheiko/pen/qdvWZj )。

el.style包含元素的显式样式时,window.getComputedStyle返回继承(计算)的样式:

var el = document.querySelector( "h1" ),
    /**
     * window.getComputedStyle
     * @param {HTMLElement} el
     * @param {String} pseudo - pseudo-element selector or null 
     * for regular elements
     * @return {CSSStyleDeclaration}
     */
    css = window.getComputedStyle( el, null );
console.log( css.getPropertyValue( "font-family" ) );

我们刚才检查的案例涉及内联样式。事实上,我们也可以访问外部或内部样式表:

<style type="text/css">
.foo {
 color: red;
}
</style>
<div class="foo">foo</div>
<script type="text/javascript">
var stylesheet = document.styleSheets[ 0 ];
stylesheet.cssRules[ 0 ].style.color = "red";
// or
// stylesheet.cssRules[ 0 ].style.cssText = "color: red;";
</script>

我们为什么要这样做?有一些特殊情况。例如,如果我们想要修改,比如说,伪元素样式,我们必须涉及样式表:

var stylesheet = document.styleSheets[ 0 ];
stylesheet.addRule( ".foo::before", "color: green" );
// or
stylesheet.insertRule( ".foo::before { color: green }", 0 );

利用属性和属性

HTML 元素具有属性,我们可以从 JavaScript 访问它们:

el.setAttribute( "tabindex", "-1" );
if ( el.hasAttribute( "tabindex" ) ) {}
el.getAttribute( "tabindex" );
el.removeAttribute( "tabindex" );

元素属性由 HTML 定义,属性由 DOM 定义。这就不同了。例如,如果您有一个输入,那么最初属性和属性(el.value)都具有相同的值。但是,当用户或脚本更改值时,属性不受影响,但属性为:

// attribute
console.log( input.getAttribute( "value" ) );
// property
console.log( input.value );

您可能知道,除了全局属性之外,还有一种特殊类型的自定义数据属性。这些属性旨在提供 HTML 与其 DOM 表示之间的专有信息交换,后者由脚本使用。一般的想法是定义一个自定义属性,如data-foo并为其设置一个值。然后从脚本中,我们使用el.dataset对象访问并更改属性:

console.log( el.dataset.foo ); 
el.dataset.foo = "foo";

如果定义多部分属性,如data-foo-bar-baz,则对应的dataset属性为fooBarBaz

console.log( el.dataset.fooBarBaz ); 
el.dataset.fooBarBaz = "foo-bar-baz";

处理 DOM 事件

浏览器中会发生大量事件。它可以是设备事件(例如,设备改变位置或方向)、窗口事件(例如,窗口大小)、进程(例如,页面加载)、媒体事件(例如,视频暂停)、网络事件(连接状态改变),当然还有用户交互事件(点击、键盘、鼠标和触摸)。我们可以让代码侦听这些事件,并在事件发生时调用订阅的处理程序函数。为了订阅 DOM 元素上的事件,我们使用addEventListener方法:

EventTarget.addEventListener( <event-name>, <callback>, <useCapture> );

在前面的代码中,EventTarget可以是窗口、文档、元素或其他对象,如XMLHttpRequest

useCapture是一个布尔值,您可以通过它指定希望事件传播的方式。例如,用户单击表单中的按钮,我们为该单击事件订阅了两个元素的处理程序。当useCapturetrue时,首先调用表单元素(ancestor)的处理程序(capturing flow。否则,表单处理程序将在按钮的处理程序(bubbling flow之后调用。

callback是在事件触发时调用的函数。它将Event对象作为参数接收,该参数具有以下属性:

事件还有以下方法:

  • Event.stopPropagation():这将阻止事件的进一步传播。
  • Event.stopImmediatePropagation():如果我们有多个监听器订阅了同一个事件目标,那么调用此方法后,将不会调用剩余的监听器。
  • Event.preventDefault():这将阻止默认操作。例如,如果它是 submit 类型按钮上的单击事件,则通过调用此方法,我们可以防止它自动提交表单。

现在让我们在实践中尝试一下:

<form action="/">
<button type="submit">Click me</button>
</form>
<script>
var btn = document.querySelector( "button" )
    onClick = function( e ){
      e.preventDefault(); 
      console.log( e.target );
    };
btn.addEventListener( "click", onClick, false );
</script>

在这里,我们订阅了一个onClick监听器,用于按钮元素上的点击事件。单击按钮时,它会在 JavaScript 控制台中显示未提交表单的按钮元素。

如果我们想订阅键盘事件,可以按如下方式执行:

addEventListener( "keydown", function( e ){
    var key = parseInt( e.key || e.keyCode, 10 );
     // Ctrl-Shift-i
    if ( e.ctrlKey && e.shiftKey && key === 73 ) {
      e.preventDefault();
      alert( "Ctrl-Shift-L pressed" );
    }
  }, false );

流程事件最常见的例子是文档就绪状态更改。我们可以收听DOMContentLoadedload事件。当文档完全加载和解析后,将触发第一个。第二个还等待样式表、图像和子帧完成加载。这里有一个怪癖。我们必须检查readyState,因为如果我们在事件可能被触发后向其注册侦听器,则永远不会调用回调:

function ready( cb ) {
  if ( document.readyState !== "loading" ){
    cb();
  } else {
    document.addEventListener( "DOMContentLoaded", cb );
  }
}

嗯,我们知道如何使用EventTarget.addEventListener方法订阅 DOM 事件。EventTarget对象也有一个从侦听器取消订阅的方法。例如,请参见以下内容:

btn.removeEventListener( "click", onClick );

如果我们想要触发一个 DOM 事件,例如模拟一个按钮点击,我们必须创建一个新的Event对象,设置它,并在我们想要触发事件时对元素进行调度:

var btn = document.querySelector( "button" ),
    // Create Event object
    event = document.createEvent( "HTMLEvents" );
// Initialize a custom event that bubbles up and cannot be canceled 

event.initEvent( "click", true, false );
// Dispatch the event
btn.dispatchEvent( event );

同样,我们可以创建我们的自定义事件:

var btn = document.querySelector( "button" ),
    // Create Event object
    event = document.createEvent( "CustomEvent" );
// Subscribe to the event 
btn.addEventListener("my-event", function( e ){
  console.dir( e );
});
// Initialize a custom event that bubbles up and cannot be canceled 
event.initEvent( "my-event", true, false );
// Dispatch the event
btn.dispatchEvent( event );

与服务器通信

许多人使用第三方库向服务器发出任何请求。但我们需要这些图书馆吗?让我们在以下内容中研究如何在本机上使用 AJAX,以及下一个通信 API 是什么。

XHR

XMLHttpRequestXHR是 JavaScript 中的主 API,用于客户端和服务器之间的数据交换。XHR 是微软在 IE5 中通过 ActiveX(1999)首次提出的,在 IE 浏览器中有专有语法,直到版本 7(2006)。这导致了兼容性问题,从而催生了AJAX 库的兴起,如 Prototype 和 jQuery。如今,对 XHR 的支持在所有主流浏览器中都是一致的。通常,要执行 HTML 或 HTTPS 请求,我们必须执行许多任务。我们创建一个 XHR 实例,通过 open 方法初始化请求,向侦听器订阅请求相关事件,设置请求头(setRequestHeader,最终调用 send 方法:

var xhr = new XMLHttpRequest();
xhr.open( "GET", "http://www.telize.com/jsonip?callback=0", true );
xhr.onload = function() {
      if ( this.status === 200 ) {
        return console.log( this.response );
      }
    };

xhr.responseType = "json";
xhr.setRequestHeader( "Content-Type", "application/x-www-form-urlencoded" );
xhr.send( null );

更多选项可用。例如,我们可以利用progressabort事件来控制文件上传(https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest )。

我突然想到,对于一个简单的调用,这个接口过于复杂了。在互联网上有大量的 XHR 包装器实现。最流行的实现之一可以在中找到 https://github.com/Raynos/xhr 。它使 XHR 的使用变得非常简单:

xhr({
  uri: "http://www.telize.com/jsonip",
  headers: {
    "Content-Type": "application/json"
  }
}, function ( err, resp ) {
  console.log( resp );
})

此外,该库还提供了一个模拟对象,可用于替换单元测试中的真实 XHR。

获取 API

我们刚刚检查了 XHRAPI。15 年前看起来还不错,但现在看起来很笨拙。我们必须使用包装纸使其更友好。幸运的是,这种语言已经进化,现在我们有了一种新的内置方法,称为 fetchapi。试想一下,用它打电话是多么容易:

fetch( "/rest/foo" ).then(function( response ) {
  // Convert to JSON
  return response.json();
}).catch(function( err ) {
  console.error( err );
});

尽管表面上很简单,但 API 非常强大。fetch方法在第一个强制参数中需要一个带远程方法 URL 的字符串或一个Request对象。请求选项可以在第二个可选参数中传递:

fetch( "/rest/foo", {
  headers: {
    "Accept": "application/json",
    "Content-Type": "application/json"
  }
});

与前面的代码片段类似,fetch 方法返回Promise。承诺正在成为异步或延迟操作的常见做法。在 Promise Completed 事件(请参见 then)上调用的函数接收一个Response对象。此函数有许多属性和方法(https://developer.mozilla.org/en-US/docs/Web/API/Response 。因此,我们可以使用相应的方法将响应转换为 JSON、文本、blob 或流,并获得请求相关信息:

console.log( response.text() );
console.log( response.status );
console.log( response.statusText );
console.log( response.headers.get( "Content-Type" ) );

那么POST请求呢?Fetch 有一个名为body的 mixin,它代表Response/Request的主体。我们可以通过以下方式传递POST数据:

var form = document.querySelector( "form[data-bind=foo]" ),
    inputEmail = form.querySelector( "[name=email]" ),
    inputPassword = form.querySelector( "[name=pwd]" );

fetch( "/feedback/submit", {
  method: "post",
  body: JSON.stringify({
    email: inputEmail.value,
    answer: inputPassword.value
  })
});

它不仅接受键值对,还接受例如FormData,因此您可以按原样提交整个表单,包括附件:

var form = document.querySelector( "form[data-bind=foo]" );
fetch( "/feedback/submit", {
  method: "post",
  body: new FormData( form )
});

目前,一些主要浏览器(例如 IE/Edge、Safari)不支持此 API。但是,如果您打算使用 Fetch API,您可以使用 Fetch polyfill(https://github.com/github/fetch )。

总结

在过去,每个浏览器的供应商都有定制的 DOM 实现,这些实现在很大程度上是不兼容的。然而,这已经改变了,我们已经在浏览器中很好地支持 W3CDOM 至少十年了。今天,我们可以安全地使用 JavaScript 本机 API 来访问、操作和设置 DOM 的样式。

在 JavaScript 中,XHR 仍然是客户端和服务器之间通信的主要 API。不过,它对开发人员不太友好。因此,我们通常为它编写自定义包装。

然而,一个名为 Fetch 的新 API 已经被提出并在 Chrome、Firefox 和 Opera 中实现。这个新的 API 更易于使用,与 XHR 相比,它提供了更令人印象深刻、更灵活的特性。