五、异步 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
堆栈中,我们有以下顺序:foo
、baz
,然后才是bar
。所以在foo
的堆栈帧为空后调用bar
。如果任何函数执行 CPU 密集型任务,则所有后续调用都将等待它完成。但是,JavaScript 引擎有事件队列(或任务队列)。
如果我们向 DOM 事件订阅一个函数,或将回调传递给计时器(setTimeout
或setInterval
),或通过任何 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' ]
当然,我们可以合并这些流程。此外,该库还提供了迭代方法,如map
、filter
、each
等,适用于异步任务数组。
Async.js 是第一个此类项目。今天,许多图书馆都受到了这一点的启发。如果您想要一个类似于 Async.js 的轻量级和健壮的解决方案,我建议您检查 Contra(https://github.com/bevacqua/contra 。
事件处理优化
您在编写表单内联验证程序时一定遇到了问题。键入时,user-agent
会不断向服务器发送验证请求。这样,您可能会很快用生成的 XHR 污染网络。您可能熟悉的另一类问题是,一些 UI 事件(touchmove
、mousemove
、scroll
和resize
被密集触发,订阅的处理程序可能会使主线程过载。这些问题可以使用两种方法之一解决,即去抖动和节流。这两种功能都可以在第三方库中使用,例如下划线和 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 应用架构中,我们将讨论如何使我们的应用具有可扩展性,并从总体上提高可维护性。
版权属于:月萌API www.moonapi.com,转载请注明出处