JavaScript异步流程控制

JavaScript特性

JavaScript属于单线程语言,即在同一时间,只能执行一个任务。在执行任务时,所有任务需要排队,前一个任务结束,才会执行后一个任务。

当我们向后台发送一个请求时,主线程读取 “向后台发送请求” 这个事件并执行之后,到获取后台返回的数据这一过程会有段时间间隔,这时CPU处于空闲阶段,直到获取数据后再继续执行后面的任务,这就降低了用户体验度,使得页面加载变慢。于是,所有任务可以分成两种:同步任务和异步任务。

  1. 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  2. 异步任务:不进入主线程、而进入"任务队列"(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

这个例子是为了说明两个重要的概念:

  1. handlePhoto回调只是稍后存储一些事情的一种方式;
  2. 事情发生的顺序不是从顶部到底部读取,而是基于事情完成时跳转;

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. 发布订阅模式

订阅者把自己想订阅的事件注册到调度中心,当该事件触发时候,发布者发布该事件到调度中心(顺带上下文),由调度中心统一调度订阅者注册到调度中心的处理代码。

比如有个界面是实时显示天气,它就订阅天气事件(注册到调度中心,包括处理程序),当天气变化时(定时获取数据),就作为发布者发布天气信息到调度中心,调度中心就调度订阅者的天气处理程序。简单来说,发布订阅模式,有一个事件池,用来给你订阅(注册)事件,当你订阅的事件发生时就会通知你,然后你就可以去处理此事件。

JavaScript异步流程控制

使用发布订阅模式,来修改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

ES6Promise写进了语言标准,统一了用法,原生提供了Promise对象。Promise,简单说就是一个容器,里面保存着一个异步操作的结果。从语法上说,Promise是一个对象,从它可以获取异步操作的消息。

Promise有3种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。

Promise很重要的两个特点:

  1. 状态不受外界影响;只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果;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状态,再根据resolvereject返回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的状态由p1p2p3决定,分成两种情况:

  1. 只有p1p2p3的状态都变成fulfilledp的状态才会变成fulfilled,此时p1p2p3的返回值组成一个数组,传递给p的回调函数。
  2. 只要p1p2p3之中有一个被rejectedp的状态就变成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.promisifyutil.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关键字,分析下这个执行过程:

  1. 创建了g对象,指向gen的句柄
  2. 第一次调用next(),执行到yield hello,暂缓执行,并返回了hello
  3. 第二次调用next(),继续上一次的执行,执行到yield generator,暂缓执行,并返回了Generator
  4. 第三次调用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种方式的优缺点:

  1. 回调函数:写起来方便,但是过多的回调会产生回调地狱,代码横向扩展,不易于维护和理解。
  2. 发布订阅模式:方便管理和修改事件,不同的事件对应不同的回调,但是容易产生一些命名冲突的问题,事件到处触发,可能代码可读性不好。
  3. Promise对象:通过then方法来替代掉回调,解决了回调产生的参数不容易确定的问题,但是相对的把回调 “纵向发展” 了,形成了一个回调链。
  4. Generator函数:确实很好的解决了JavaScript中异步的问题,但是得依赖执行器函数。
  5. async/await:这可能是JavaScript中,解决异步的最好的方式了,让异步代码写起来跟同步代码一样,可读性和维护性都上来了。

相关推荐