JavaScript异步流程控制
JavaScript特性
JavaScript属于单线程语言,即在同一时间,只能执行一个任务。在执行任务时,所有任务需要排队,前一个任务结束,才会执行后一个任务。
当我们向后台发送一个请求时,主线程读取 “向后台发送请求” 这个事件并执行之后,到获取后台返回的数据这一过程会有段时间间隔,这时CPU处于空闲阶段,直到获取数据后再继续执行后面的任务,这就降低了用户体验度,使得页面加载变慢。于是,所有任务可以分成两种:同步任务和异步任务。
- 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制,这个过程会不断重复。"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
JavaScript异步实现的5种方式
1. callback(回调函数)
回调函数,也被称为高阶函数,是一个被作为参数传递给另一个函数并在该函数中被调用的函数。看一个在JQuery中简单普遍的例子:
// 注意: click方法是一个函数而不是变量 $("#button").click(function() { alert("Button Clicked"); });
可以看到,上述例子将一个函数作为参数传递给了click
方法,click
方法会调用该函数,这是JavaScript中回调函数的典型用法,它在jQuery
中广泛被使用。它不会立即执行,因为我们没有在后面加( ),而是在点击事件发生时才会执行。
比如,我们要下载一个gif,但是不希望在下载的时候阻断其他程序,可以实现如下:
downloadPhoto('http://coolcats.com/cat.gif', handlePhoto) function handlePhoto (error, photo) { if (error) { console.error('Download error!', error); } else { console.log('Download finished', photo); } } console.log('Download started')
首先声明handlePhoto
函数,然后调用downloadPhoto
函数并传递handlePhoto
作为其回调函数,最后打印出“Download started”。
请注意,handlePhoto
尚未被调用,它只是被创建并作为回调传入downloadPhoto
。但直到downloadPhoto
完成其任务后才能运行,这可能需要很长时间,具体取决于Internet
连接的速度,所以运行代码后,会先打印出Download started
。
这个例子是为了说明两个重要的概念:
handlePhoto
回调只是稍后存储一些事情的一种方式;- 事情发生的顺序不是从顶部到底部读取,而是基于事情完成时跳转;
1. callback hell(回调地狱)
var fs = require('fs'); /** * 如果三个异步api操作的话 无法保证他们的执行顺序 * 我们在每个操作后用回调函数就可以保证执行顺序 */ fs.readFile('./data1.json', 'utf8', function(err, data){ if (err) { throw err; } else { console.log(data); fs.readFile('./data2.json', 'utf8', function(err, data){ if (err) { throw err; } else { console.log(data) fs.readFile('./data3.json', 'utf8', function(err, data){ if (err) { throw err; } else { console.log(data); } }) } }) } })
有没有看到这些以"})"结尾的金字塔结构?由于回调函数是异步的,在上面的代码中每一层的回调函数都需要依赖上一层的回调执行完,所以形成了层层嵌套的关系最终形成类似上面的回调地狱。
2. 代码层面解决回调地狱
1. 保持代码简短
var form = document.querySelector('form') form.onsubmit = function formSubmit (submitEvent) { var name = document.querySelector('input').value request({ uri: "http://example.com/upload", body: name, method: "POST" }, function postResponse (err, response, body) { var statusMessage = document.querySelector('.status') if (err) return statusMessage.value = err statusMessage.value = body }) }
可以看到,上面的代码给两个函数加了描述性功能名称,使代码更容易阅读,当发生异常时,你将获得引用实际函数名称而不是“匿名”的堆栈跟踪。
现在我们可以将这些功能移到我们程序的顶层:
document.querySelector('form').onsubmit = formSubmit; function formSubmit (submitEvent) { var name = document.querySelector('input').value; request({ uri: "http://example.com/upload", body: name, method: "POST" }, postResponse); } function postResponse (err, response, body) { var statusMessage = document.querySelector('.status'); if (err) return statusMessage.value = err; statusMessage.value = body; }
重新整改代码结构之后,可以清晰的看到这段函数的功能。
2. 模块化
从上面取出样板代码,并将其分成几个文件,将其转换为模块。
这是一个名为formuploader.js
的新文件,它包含了之前的两个函数:
module.exports.submit = formSubmit; function formSubmit (submitEvent) { var name = document.querySelector('input').value; request({ uri: "http://example.com/upload", body: name, method: "POST" }, postResponse) } function postResponse (err, response, body) { var statusMessage = document.querySelector('.status'); if (err) return statusMessage.value = err; statusMessage.value = body; }
把它们exports
后,在应用程序中引入并使用,这就使得代码更加简洁易懂了:
var formUploader = require('formuploader'); document.querySelector('form').onsubmit = formUploader.submit;
3. error first
处理每一处错误,并且回调的第一个参数始终保留用于错误:
var fs = require('fs') fs.readFile('/Does/not/exist', handleFile); function handleFile (error, file) { if (error) return console.error('Uhoh, there was an error', error); // otherwise, continue on and use `file` in your code; }
有第一个参数是错误是一个简单的惯例,鼓励你记住处理你的错误。如果它是第二个参数,会更容易忽略错误。
除了上述代码层面的解决方法,还可以使用以下更高级的方法,也是另外4种实现异步的方法。但是请记住,回调是JavaScript的基本组成部分(因为它们只是函数),在学习更先进的语言特性之前学习如何读写它们,因为它们都依赖于对回调。
2. 发布订阅模式
订阅者把自己想订阅的事件注册到调度中心,当该事件触发时候,发布者发布该事件到调度中心(顺带上下文),由调度中心统一调度订阅者注册到调度中心的处理代码。
比如有个界面是实时显示天气,它就订阅天气事件(注册到调度中心,包括处理程序),当天气变化时(定时获取数据),就作为发布者发布天气信息到调度中心,调度中心就调度订阅者的天气处理程序。简单来说,发布订阅模式,有一个事件池,用来给你订阅(注册)事件,当你订阅的事件发生时就会通知你,然后你就可以去处理此事件。
使用发布订阅模式,来修改Ajax
:
xhr.onreadystatechange = function () {//监听事件 if (this.readyState === 4) { if (this.status === 200) { switch (dataType) { case 'json': { Event.emit('data '+method,JSON.parse(this.responseText)); //触发事件 break; } case 'text': { Event.emit('data '+method,this.responseText); break; } case 'xml': { Event.emit('data '+method,this.responseXML); break; } default: { break; } } } } }
3. Promise
ES6
将Promise
写进了语言标准,统一了用法,原生提供了Promise
对象。Promise
,简单说就是一个容器,里面保存着一个异步操作的结果。从语法上说,Promise
是一个对象,从它可以获取异步操作的消息。
Promise
有3种状态:pending
(进行中)、fulfilled
(成功)、rejected
(失败)。
Promise
很重要的两个特点:
- 状态不受外界影响;只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
- 一旦状态改变,就不会再变,任何时候都可以得到这个结果;
Promise
对象的状态改变,只有两种可能:从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为resolved
(已定型)。
1. 基本用法
const p = new Promise((resolve,reject) => { // resolve在异步操作成功时调用 resolve('success'); // reject在异步操作失败时调用 reject('error'); }); p.then(result => { console.log(result); }); p.catch(result => { console.log(result); })
ES6
规定,Promise
对象是一个构造函数,用来生成Promise
实例。new
一个Promise
实例时,这个对象的起始状态就是pending
状态,再根据resolve
或reject
返回fulfilled
状态 / rejected
状态。
2. Promise.prototype.then( )
前面可以看到,Promise
实例具有then
方法,所以then
方法是定义在原型对象Promise.prototype
上的,它的作用是为Promise
实例添加状态改变时的回调函数。
then
方法返回的是一个新的Promise
实例,因此then
可以采用链式写法:
getJSON("/posts.json").then(function(json) { return json.post; }).then(function(post) { // ... });
3. Promise.prototype.catch( )
Promise.prototype.catch
方法是.then(null, rejection)
或.then(undefined, rejection)
的别名,用于指定发生错误时的回调函数。
getJSON('/posts.json').then(function(posts) { // ... }).catch(function(error) { // 处理 getJSON 和 前一个回调函数运行时发生的错误 console.log('发生错误!', error); });
4. Promise.all( )
Promise.all
方法用于将多个Promise
实例,包装成一个新的Promise
实例。
const p = Promise.all([p1, p2, p3]);
上面代码中,p
的状态由p1
、p2
、p3
决定,分成两种情况:
- 只有
p1
、p2
、p3
的状态都变成fulfilled
,p
的状态才会变成fulfilled
,此时p1
、p2
、p3
的返回值组成一个数组,传递给p的回调函数。 - 只要
p1
、p2
、p3
之中有一个被rejected
,p
的状态就变成rejected
,此时第一个被reject
的实例的返回值,会传递给p
的回调函数。
5. Promise.race( )
Promise.race
方法同样是将多个Promise
实例,包装成一个新的Promise
实例。不同的是,race()
接受的对象中,哪个对象返回快就返回哪个对象,如果指定时间内没有获得结果,就将Promise
的状态变为reject
。
const p = Promise.race([ fetch('/resource-that-may-take-a-while'), new Promise(function (resolve, reject) { setTimeout(() => reject(new Error('request timeout')), 5000) }) ]); p .then(console.log) .catch(console.error);
上面代码中,如果 5 秒之内fetch
方法无法返回结果,变量p
的状态就会变为rejected
,从而触发catch
方法指定的回调函数。
6. Promise.resolve( )
Promise.resolve('foo') // 等价于 new Promise(resolve => resolve('foo'))
7. Promise.reject( )
const p = Promise.reject('出错了'); // 等同于 const p = new Promise((resolve, reject) => reject('出错了')) p.then(null, function (s) { console.log(s) }); // 出错了
下面是一个用Promise
对象实现的Ajax
操作的例子:
const getJSON = function(url) { const promise = new Promise(function(resolve, reject){ const handler = function() { if (this.readyState !== 4) { return; } if (this.status === 200) { resolve(this.response); } else { reject(new Error(this.statusText)); } }; const client = new XMLHttpRequest(); client.open("GET", url); client.onreadystatechange = handler; client.responseType = "json"; client.setRequestHeader("Accept", "application/json"); client.send(); }); return promise; }; getJSON("/posts.json").then(function(json) { console.log('Contents: ' + json); }, function(error) { console.error('出错了', error); });
8. callbackify & promisify
Node 8
提供了两个工具函数util.promisify
、util.callbackify
用于在回调函数和Promise
之间做方便的切换,我们也可以用JavaScript
代码来实现一下。
1. promisify:把callback转化为promise
function promisify(fn_callback) { //接收一个有回调函数的函数,回调函数一般在最后一个参数 if(typeof fn_callback !== 'function') throw new Error('The argument must be of type Function.'); //返回一个函数 return function (...args) { //返回Promise对象 return new Promise((resolve, reject) => { try { if(args.length > fn_callback.length) reject(new Error('arguments too much.')); fn_callback.call(this,...args,function (...args) { //nodejs的回调,第一个参数为err, Error对象 args[0] && args[0] instanceof Error && reject(args[0]); //除去undefined,null参数 args = args.filter(v => v !== undefined && v !== null); resolve(args); }.bind(this)); //保证this还是原来的this } catch (e) { reject(e) } }) } }
2. callbackify:promise转换为callback
function callbackify(fn_promise) { if(typeof fn_promise !== 'function') throw new Error('The argument must be of type Function.'); return function (...args) { //返回一个函数 最后一个参数是回调 let callback = args.pop(); if(typeof callback !== 'function') throw new Error('The last argument must be of type Function.'); if(fn_promise() instanceof Promise){ fn_promise(args).then(data => { //回调执行 callback(null,data) }).catch(err => { //回调执行 callback(err,null) }) }else{ throw new Error('function must be return a Promise object'); } } }
个人而言,最好直接把代码改成Promise
形式的,而不是对已有的callback
加上这个中间层,因为其实改动的成本差不多。但总有各种各样的情况,比如,你的回调函数已经有很多地方使用了,牵一发而动全身,这时这个中间层还是比较有用的。
4. generator(生成器)函数
Generator
函数是ES6
提供的一种异步编程解决方案,通过yield
标识位和next()
方法调用,实现函数的分段执行。
1. next( )方法
先从下面的例子看一下Generator
函数是怎么定义和运行的。
function *gen() { yield "hello"; yield "generator"; return; } gen(); // 没有输出结果 var g = gen(); console.log(g.next()); // { value: 'hello', done: false } console.log(g.next()); // { value: 'generator', done: false } console.log(g.next()); // { value: 'undefined', done: true }
从上面可以看到,Generator
函数定义时要带*
,在直接执行gen()
时,没有像普通的函数一样,输出结果,而是通过调用next()
方法得到了结果。
这个例子中我们引入了yield
关键字,分析下这个执行过程:
- 创建了
g
对象,指向gen
的句柄 - 第一次调用
next()
,执行到yield hello
,暂缓执行,并返回了hello
- 第二次调用
next()
,继续上一次的执行,执行到yield generator
,暂缓执行,并返回了Generator
- 第三次调用
next()
,直接执行return
,并返回done:true
,表明结束。
经过上面的分析,yield
实际就是暂缓执行的标示,每执行一次next()
,相当于指针移动到下一个yield
位置。next()
方法返回的结果是个对象,对象里面的value
是运行结果,done
表示是否运行完成。
2. throw( )方法
throw()
方法在函数体外抛出一个错误,然后在函数体内捕获。
function *gen1() { try{ yield; } catch(e) { console.log('内部捕获') } } let g1 = gen1(); g1.next(); g1.throw(new Error());
3. return( )方法
return()
方法返回给定值,并终结生成器,在return
后面的yield
不会再被执行。
function *gen2(){ yield 1; yield 2; yield 3; } let g2 = gen2(); g2.next(); // { value:1, done:false } g2.return(); // { value:undefined, done:true } g2.next(); // { value:undefined, done:true }
5. Promise + async & await
在ES2017
中,提供了async / await
两个关键字来实现异步,是异步编程的最高境界,就是根本不用关心它是否是异步,很多人认为它是异步编程的终极解决方案。async / await
寄生于Promise
,本质上还是基于Generator
函数,可以说是Generator
函数的语法糖,async
用于申明一个function
是异步的,而await
可以认为是async wait
的简写,等待一个异步方法执行完成。
async function demo() { let result = await Promise.resolve(123); console.log(result); } demo();
async
函数返回的是一个Promise
对象,在上述例子中,表示demo
是一个async
函数,await
只能用在async
函数里面,表示等待Promise
返回结果后,再继续执行,await
后面应该跟着Promise
对象(当然,跟着其他返回值也没关系,只是会立即执行,这样就没有意义了)。
Promise
虽然一方面解决了callback
的回调地狱,但是相对的把回调 “纵向发展” 了,形成了一个回调链:
function sleep(wait) { return new Promise((res,rej) => { setTimeout(() => { res(wait); },wait); }); } /* let p1 = sleep(100); let p2 = sleep(200); let p =*/ sleep(100).then(result => { return sleep(result + 100); }).then(result02 => { return sleep(result02 + 100); }).then(result03 => { console.log(result03); })
将上述代码改成async/await
写法:
async function demo() { let result01 = await sleep(100); //上一个await执行之后才会执行下一句 let result02 = await sleep(result01 + 100); let result03 = await sleep(result02 + 100); // console.log(result03); return result03; } demo().then(result => { console.log(result); });
因为async
返回的也是Promise
对象,所以用then
接收就行了。
如果是reject
状态,可以用try-catch
捕捉:
let p = new Promise((resolve,reject) => { setTimeout(() => { reject('error'); },1000); }); async function demo(params) { try { let result = await p; } catch(e) { console.log(e); } } demo();
这是基本的错误处理,但是当内部出现一些错误时,和Promise
有点类似,demo()
函数不会报错,还是需要catch
回调捕捉,这就是内部的错误被 “静默” 处理了。
let p = new Promise((resolve,reject) => { setTimeout(() => { reject('error'); },1000); }); async function demo(params) { // try { let result = name; // } catch(e) { // console.log(e); // } } demo().catch((err) => { console.log(err); })
最后,总结一下JavaScript
实现异步的5种方式的优缺点:
- 回调函数:写起来方便,但是过多的回调会产生回调地狱,代码横向扩展,不易于维护和理解。
- 发布订阅模式:方便管理和修改事件,不同的事件对应不同的回调,但是容易产生一些命名冲突的问题,事件到处触发,可能代码可读性不好。
Promise
对象:通过then
方法来替代掉回调,解决了回调产生的参数不容易确定的问题,但是相对的把回调 “纵向发展” 了,形成了一个回调链。Generator
函数:确实很好的解决了JavaScript
中异步的问题,但是得依赖执行器函数。async/await
:这可能是JavaScript
中,解决异步的最好的方式了,让异步代码写起来跟同步代码一样,可读性和维护性都上来了。