乐趣区

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 中,解决异步的最好的方式了,让异步代码写起来跟同步代码一样,可读性和维护性都上来了。

退出移动版