五、异步 JavaScript

如今,互联网用户不耐烦了,在页面加载或导航过程中会出现 2-3 秒的延迟,他们会失去兴趣,很可能会因为其他事情而离开这项服务。我们的首要任务是减少用户响应时间。这里的主要方法是切芥末http://www.creativebloq.com/web-design/responsive-web-design-tips-bbc-news-9134667 )。我们提取核心体验所需的应用组件,并首先加载它们。然后,我们逐步添加增强的体验。至于 JavaScript,我们最需要关心的是非阻塞流。因此,我们必须避免在 HTML 呈现之前同步加载脚本,并且必须将所有长时间运行的任务包装到异步回调中。这可能是你已经知道的。但是你做得有效率吗?

在本章中,我们将介绍以下主题:

  • 非阻塞 JavaScript
  • 错误第一次回调
  • 延续传球风格
  • 以 ES7 方式处理异步函数
  • 使用 Async.js 库的并行任务和任务系列
  • 事件处理优化

非阻塞 JavaScript

首先,让我们看看当我们异步做事时,会发生什么。每当我们在 JavaScript 中调用函数时,它都会创建一个新的堆栈帧(执行对象)。每个内部调用都会进入这个框架。在这里,帧以后进先出后进先出的方式从调用堆栈的顶部推动和弹出。换句话说,在代码中,我们先调用foo函数,然后调用bar函数;但是,在执行过程中,foo调用baz函数。在本例中,在call堆栈中,我们有以下顺序:foobaz,然后才是bar。所以在foo的堆栈帧为空后调用bar。如果任何函数执行 CPU 密集型任务,则所有后续调用都将等待它完成。但是,JavaScript 引擎有事件队列(或任务队列)。

Nonblocking JavaScript

如果我们向 DOM 事件订阅一个函数,或将回调传递给计时器(setTimeoutsetInterval),或通过任何 Web I/O API(XHR、IndexedDB 和文件系统),它最终会进入相应的队列。然后,浏览器的事件循环决定何时以及将哪个回调推入回调堆栈。以下是一个例子:

function foo(){
  console.log( "Calling Foo" );
}
function bar(){
  console.log( "Calling Bar" );
}
setTimeout(foo, 0 );
bar();

使用setTimeout( foo, 0 ),我们声明应立即调用foo,然后我们调用bar。但是,foo落在队列中,事件循环将其放在调用堆栈的更深处:

Calling Bar
Calling Foo

这也意味着如果foo回调执行 CPU 密集型任务,它不会阻塞主执行流。类似地,异步发出的 XHR/Fetch 请求在等待服务器响应时不会锁定交互:

function bar(){
  console.log( "Bar complete" );
}
fetch( "http://www.telize.com/jsonip" ).then(function( response ) {
  console.log( "Fetch complete" );
});
bar();

// Console:
// Bar complete
// Fetch complete

这是如何应用于现实世界的应用的?以下是一个常见的流程:

"use strict";
// This statement loads imaginary AMD modules
// You can find details about AMD standard in 
// "Chapter 2: Modular programming with JavaScript" 
require([ "news", "Session", "User", "Ui" ], function ( News, Session, User, Ui ) {
  var session = new Session(),
      news = new News(),
      ui = new Ui({ el: document.querySelector( "[data-bind=ui]" ) });
  // load news
 news.load( ui.update );
 //  authorize user 
 session.authorize(function( token ){
   var user = new User( token );
   // load user data
   user.load(function(){
     ui.update();
     // load user profile picture
     user.loadProfilePicture( ui.update );
     // load user notifications  
     user.loadNotifications( ui.update );
   });
 });
});

JavaScript 依赖项的加载是排队的,因此浏览器可以呈现 UI 并将其交付给用户,而无需等待。一旦脚本完全加载,应用就会将两个新任务推送到队列:加载新闻授权用户。同样,它们都不会阻塞主线程。只有当这些请求中的任何一个完成并且主线程参与时,它才会根据新接收的数据增强 UI。一旦用户获得授权并检索到会话令牌,我们就可以加载用户数据。任务完成后,我们将新任务排队。

如您所见,异步代码比同步代码更难读取。执行序列可能相当复杂。此外,我们还必须特别注意错误控制。在进行同步代码时,我们可以用try/catch包装程序块,并截获执行过程中抛出的任何错误:

function foo(){
  throw new Error( "Foo throws an error" );
}
try {
  foo();
} catch( err ) {
  console.log( "The error is caught" );
}

但是,如果呼叫已排队,则会滑出try/catch范围:

function foo(){
  throw new Error( "Foo throws an error" );
}
try {
  setTimeout(foo, 0 );
} catch( err ) {
  console.log( "The error is caught" );
}

是的,异步编程有它的怪癖。为了掌握这一点,我们将研究编写异步代码的现有实践。

因此,为了使代码异步,我们将任务排队,并订阅在任务完成时触发的事件。实际上,我们使用的是事件驱动编程,特别是我们使用的是PubSub模式。例如,我们在第 3 章DOM 脚本和 AJAX中提到的EventTarget接口,简言之,就是为 DOM 元素上的事件订阅侦听器,并从 UI 或编程方式触发这些事件:

var el = document.createElement( "div" );
    event = new CustomEvent( "foo", { detail: "foo data" });
el.addEventListener( "foo", function( e ){
  console.log( "Foo event captured: ", e.detail );
}, false );

el.dispatchEvent( event );

// Foo event captured: foo data

在 DOM 背后,我们使用了类似的原则,但实现可能有所不同。最流行的接口可能基于两种主要方法,obj.on(订阅处理程序)和obj.trigger(触发事件):

obj.on( "foo", function( data ){
  console.log( "Foo event captured: ", data );
});
obj.trigger( "foo", "foo data" );

这就是 PubSub 在抽象框架(例如主干)中的实现方式。jQuery 在 DOM 事件上也使用此接口。该接口通过简单性获得了发展势头,但它对意大利面代码并没有真正的帮助,也没有涵盖错误处理。

第一次回调出错

Node.js 中所有异步方法使用的模式称为错误优先回调。以下是一个例子:

fs.readFile( "foo.txt", function ( err, data ) {
  if ( err ) {
    console.error( err );
  }
  console.log( data );
});

任何异步方法都希望其中一个参数是回调。完整回调参数列表取决于调用方方法,但第一个参数始终是错误对象或 null。当我们使用异步方法时,try/catch语句中无法检测到函数执行期间引发的异常。该事件发生在 JavaScript 引擎离开try块之后。在前面的示例中,如果在读取文件的过程中引发了任何异常,它将作为第一个强制参数落在回调函数上。尽管这种方法被广泛使用,但它也有其缺陷。在编写具有深度回调序列的真实代码时,很容易遇到所谓的回调地狱)http://callbackhell.com/ 。代码变得很难遵循。

延续传球风格

我们通常需要一系列异步调用,也就是一系列任务,其中一个任务在另一个任务完成后启动。我们对异步调用链的最终结果感兴趣。在这种情况下,我们可以从延续传球方式CPS中获益。JavaScript 已经有一个内置的Promise对象。我们用它来创建一个新的Promise对象。我们将异步任务放在Promise回调中,并调用参数列表的resolve函数通知Promise回调任务已解决:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      /**
       * Return a promise.
       * @param {Function} resolve
       */
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 1 );
        }, 0 );
      });
    };

foo( 1 ).then(function( val ){
  console.log( "Result: ", val );
});

// Result: 5

在前面的示例中,我们调用了foo,它返回Promise。使用这个方法,我们设置了一个处理程序,在Promise完成时调用它。

差错控制呢?创建Promise时,我们可以使用第二个参数(reject中给出的函数来报告失败:

"use strict";
/**
 * Make GET request
 * @param {String} url
 * @returns {Promise}
 */
function ajaxGet( url ) {
  return new Promise(function( resolve, reject ) {
    var req = new XMLHttpRequest();
    req.open( "GET", url );
    req.onload = function() {
      // If response status isn't 200 something went wrong
      if ( req.status !== 200 ) {
        // Early exit
        return reject( new Error( req.statusText ) );
      }
      // Everything is ok, we can resolve the promise
      return resolve( JSON.parse( req.responseText ) );
    };
    // On network errors
    req.onerror = function() {
      reject( new Error( "Network Error" ) );
    };
    // Make the request
    req.send();
  });
};

ajaxGet("http://www.telize.com/jsonip").then(function( data ){
  console.log( "Your IP is ", data.ip );
}).catch(function( err ){
  console.error( err );
});
// Your IP is 127.0.0.1

关于Promises最令人兴奋的部分是它们可以被链接。我们可以通过管道将回调传递到队列异步任务或转换值:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      /**
       * Return a promise.
       * @param {Function} resolve
       * @param {Function} reject
       */
      return new Promise(function( resolve, reject ) {
        if ( !val ) {
          return reject( new RangeError( "Value must be greater than zero" ) );
        }
        setTimeout(function(){
          resolve( val + 1 );
        }, 0 );
      });
    };

foo( 1 ).then(function( val ){
  // chaining async call
  return foo( val );
}).then(function( val ){
  // transforming output
  return val + 2;
}).then(function( val ){
  console.log( "Result: ", val );
}).catch(function( err ){
  console.error( "Error caught: ", err.message );
});

// Result: 5

请注意,如果我们将0传递给foo函数,则入口条件会引发异常,我们最终将返回catch方法的回调。如果在其中一个回调中抛出异常,它也会出现在catch回调中。

Promise链的解析方式类似于瀑布模型,任务一个接一个地被调用。我们也可以在多个并行处理任务完成后使Promise解析:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 1 );
        }, 100 );
      });
    },
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
    bar = function( val ) {
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 2 );
        }, 200 );
      });
    };

Promise.all([ foo( 1 ), bar( 2 ) ]).then(function( arr ){
  console.log( arr );
});
//  [2, 4]

在所有最新的浏览器中,Promise.all静态方法尚不受支持,但您可以通过上的 polyfill 获得此方法 https://github.com/jakearchibald/es6-promise

另一种可能性是,无论何时完成任何并发运行的任务,都会导致Promise解决或拒绝:

Promise.race([ foo( 1 ), bar( 2 ) ]).then(function( arr ){
  console.log( arr );
});
// 2

以 ES7 方式处理异步功能

我们已经有了 JavaScript 中的 Promise API。即将推出的技术是 Async/Await API,并在一份建议书(中介绍 https://tc39.github.io/ecmascript-asyncawait/ EcmaScript 第 7 版的。这描述了我们如何声明异步函数,这些函数可以在不阻塞任何内容的情况下停止并等待Promise的结果:

"use strict";

// Fetch a random joke
function fetchQuote() {
  return fetch( "http://api.icndb.com/jokes/random" )
  .then(function( resp ){
    return resp.json();
  }).then(function( data ){
    return data.value.joke;
  });
}
// Report either a fetched joke or error
async function sayJoke()
{
  try {
    let result = await fetchQuote();
    console.log( "Joke:", result );
  } catch( err ) {
    console.error( err );
  }
}
sayJoke();

目前,任何浏览器都不支持 API;但是,您可以在运行时使用 Babel.js transpiler 来运行它。您也可以在在线处理此示例 http://codepen.io/dsheiko/pen/gaeqRO

这个新语法允许我们编写一个异步运行的代码,同时看起来是同步的。因此,我们可以使用诸如try/catch之类的通用构造进行异步调用,这使得代码更具可读性,更易于维护。

使用 Async.js 库的并行任务和任务系列

另一种处理异步调用的方法是一个名为Async.js的库 https://github.com/caolan/async )。在使用这个库时,我们可以明确地指定如何将这批任务解析为瀑布(链)或并行。

在第一种情况下,我们可以向async.waterfall提供一个回调数组,假设一个回调完成后,调用下一个回调。我们还可以将解析值从一个回调传递到另一个回调,并在方法的on-done回调中接收聚合值或抛出的异常:

/**
 * Concat given arguments
 * @returns {String}
 */
function concat(){
  var args = [].slice.call( arguments );
  return args.join( "," );
}

async.waterfall([
    function( cb ){
      setTimeout( function(){
        cb( null, concat( "foo" ) );
      }, 10 );
    },
    function( arg1, cb ){
      setTimeout( function(){
        cb( null, concat( arg1, "bar" ) );
      }, 0 );
    },
    function( arg1, cb ){
      setTimeout( function(){
        cb( null, concat( arg1, "baz" ) );
      }, 20 );
    }
], function( err, results ){
   if ( err ) {
     return console.error( err );
   }
   console.log( "All done:", results );
});

// All done: foo,bar,baz

类似地,我们将回调数组传递给async.parallel。这一次它们都并行运行,但当全部解决后,我们会在方法的on-done回调中收到结果或抛出的异常:

async.parallel([
    function( cb ){
      setTimeout( function(){
        console.log( "foo is complete" );
        cb( null, "foo" );
      }, 10 );
    },
    function( cb ){
      setTimeout( function(){
        console.log( "bar is complete" );
        cb( null, "bar" );
      }, 0 );
    },
    function( cb ){
      setTimeout( function(){
        console.log( "baz is complete" );
        cb( null, "baz" );
      }, 20 );
    }
], function( err, results ){
   if ( err ) {
     return console.error( err );
   }
   console.log( "All done:", results );
});

// bar is complete
// foo is complete
// baz is complete
// All done: [ 'foo', 'bar', 'baz' ]

当然,我们可以合并这些流程。此外,该库还提供了迭代方法,如mapfiltereach等,适用于异步任务数组。

Async.js 是第一个此类项目。今天,许多图书馆都受到了这一点的启发。如果您想要一个类似于 Async.js 的轻量级和健壮的解决方案,我建议您检查 Contra(https://github.com/bevacqua/contra

事件处理优化

您在编写表单内联验证程序时一定遇到了问题。键入时,user-agent会不断向服务器发送验证请求。这样,您可能会很快用生成的 XHR 污染网络。您可能熟悉的另一类问题是,一些 UI 事件(touchmovemousemovescrollresize被密集触发,订阅的处理程序可能会使主线程过载。这些问题可以使用两种方法之一解决,即去抖动节流。这两种功能都可以在第三方库中使用,例如下划线和 Lodash(_.debounce_.throttle。然而,它们可以用少量的o代码实现,并且不需要依赖额外的库来实现此功能。

去抖动

通过去 Bouncing,我们确保对重复发出的事件调用一次处理程序函数:

  /**
   * Invoke a given callback only after this function stops being called `wait` milliseconds
   * usage:
   * debounce( cb, 500 )( ..arg );
   *
   * @param {Function} cb
   * @param {Number} wait
   * @param {Object} thisArg
   */
  function debounce ( cb, wait, thisArg ) {
    /**
     * @type {number}
     */
    var timer = null;
    return function() {
      var context = thisArg || this,
          args = arguments;
      window.clearTimeout( timer );
      timer = window.setTimeout(function(){
        timer = null;
        cb.apply( context, args );
      }, wait );
    };
  }

假设我们希望一个小部件只有在进入视图时才延迟加载,在我们的例子中,这要求用户向下滚动页面至少 200 像素:

var TOP_OFFSET = 200;
// Lazy-loading
window.addEventListener( "scroll", debounce(function(){
  var scroll = window.scrollY || window.pageYOffset || document.documentElement.scrollTop;
  if ( scroll >= TOP_OFFSET ){
     console.log( "Load the deferred widget (if not yet loaded)" );
  }
}, 20 ));

如果我们只是订阅一个 scroll 事件的侦听器,那么在用户开始滚动和停止滚动的时间间隔内,它会被调用很多次。多亏了 debounce 代理,当用户停止滚动时,检查是否是加载小部件的时间的处理程序只被调用一次。

节流

通过节流,我们设置触发事件时允许调用处理程序的频率:

  /**
   * Invoke a given callback every `wait` ms until this function stops being called
   * usage:
   * throttle( cb, 500 )( ..arg );
   *
   * @param {Function} cb
   * @param {Number} wait
   * @param {Object} thisArg
   */
 function throttle( cb, wait, thisArg ) {
  var prevTime,
      timer;
  return function(){
    var context = thisArg || this,
        now = +new Date(),
        args = arguments;

    if ( !prevTime || now >= prevTime + wait ) {
      prevTime = now;
      return cb.apply( context, args );
    }
    // hold on to it
    clearTimeout( timer );
    timer = setTimeout(function(){
      prevTime = now;
      cb.apply( context, args );
    }, wait );
  };
}

因此,如果我们通过 throttle 订阅容器上的mousemove事件的处理程序,handler函数一次(此处为秒),直到鼠标光标离开容器边界:

document.body.addEventListener( "mousemove", throttle(function( e ){
  console.log( "The cursor is within the element at ", e.pageX, ",", e.pageY );
}, 1000 ), false );

// The cursor is within the element at 946 , 715
// The cursor is within the element at 467 , 78

编写不影响延迟关键事件的回调

中的一些任务不属于核心功能,可能在后台运行。例如,我们希望在滚动时发送分析数据。我们这样做没有去抖动或节流,这会使 UI 线程过载,并可能使应用无响应。去抖动在这里不相关,节流不能提供精确的数据。但是,我们可以使用requestIdleCallback本地方法(https://w3c.github.io/requestidlecallback/user-agent空闲时安排任务。

总结

我们最优先考虑的目标之一是减少用户响应时间,也就是说,应用体系结构必须确保用户流不会被阻塞。这可以通过将任何长时间运行的任务排队进行异步调用来实现。但是,如果您有许多异步调用,其中一些是并行运行的,另一些是顺序运行的,而不需要特别注意,那么很容易遇到所谓的回调地狱。正确使用延续传递样式Promise API)、Async/Await API 或 Async.js 等外部库等方法可能会显著改进异步代码。我们还必须记住,一些事件,例如scroll/touch/mousemove,在密集触发时,可能会通过频繁调用订阅的侦听器而导致不必要的 CPU 负载。我们可以使用去抖动和节流技术来避免这些问题。

通过学习异步编程的基础,我们可以编写非阻塞应用。在第 6 章一个大规模 JavaScript 应用架构中,我们将讨论如何使我们的应用具有可扩展性,并从总体上提高可维护性。