JS的异步操作手札

写在前面:在语言级别上,Javascript是单线程的,然而很多情况下需要异步操作,因此异步编程对其尤为重要。

基本方法

  • 回调函数
  • 事件监听(事件发布/订阅)
  • Promise对象
  • Generator函数(协程coroutine)
  • async和await
    ---------------------------------我是正式开始的分隔符(๑•̀ㅂ•́)و✧--------------------------------

1.回调函数

// 基本方式
let fun = (time, callback) => {
  setTimeout(() => {
    callback()
  }, time);
}
fun(2000, function(){
  console.log('两秒钟到了')
})

分析:回调函数可以初步得解决异步编程,在回调次数小于两次的时候能够发挥相当好的结果,但是随着业务逻辑的增加和趋于复杂,一旦遇到“回调地狱”的情况,重构代码将非常困难。回调函数还有一个问题就是我们在回调函数之外无法捕获到回调函数中的异常。

// 回调地狱
  doSomethingAsync1(function(){
      doSomethingAsync2(function(){
          doSomethingAsync3(function(){
              doSomethingAsync4(function(){
                  doSomethingAsync5(function(){
                      // code...
                  });
            });
         });
     });
 });

捕获异常:一般情况下,一般会使用try/catch语句捕获异常。但是为什么异步代码的回调函数中的异常无法被最外层的try/catch语句捕获?
原因是:异步调用一般分为两个阶段,提交请求和处理结果,这两个阶段之间有事件循环的调用,它们属于两个不同的事件循环(tick),彼此没有关联。异步调用一般以传callback的方式来指定异步操作完成后要执行的动作。而异步调用本体和callback属于不同的事件循环。而try/catch语句只能捕获当次事件循环的异常,对callback无能为力。

2.事件监听(事件发布/订阅)

//发布和订阅事件
let evt = document.createEvent("event"); // 创建自定义事件
 evt.initEvent("click", true, true); // 初始化完毕,即发布
 window.addEventListener("click", function(e){ // 监听事件,即订阅
     console.log(1);
 });

 window.dispatchEvent(evt); //派发事件

分析:事件监听是一种非常常见的异步编程模式,它是一种典型的逻辑分离方式,对代码解耦很有用处。通常情况下,我们需要考虑哪些部分是不变的,哪些是容易变化的,把不变的部分封装在组件内部,供外部调用,需要自定义的部分暴露在外部处理。从某种意义上说,事件的设计就是组件的接口设计。所以这种事件监听处理的异步编程方式特别适合于一些需要高度解耦的场景。

3.Promise

//Promise基本语法和功能
function runAsync(a){
    var p = new Promise(function(resolve, reject){
            resolve('异步');
    });
    console.log('我先被执行')
    return p;            
}
runAsync().then(function(data){
    console.log(data);
});

分析:Promise是ES6里提供的一个对象,它可以扮演一个“先知”的角色在监控代码的异步操作。Promise对象有三种状态:Pending(进行中)、Resolved(已完成)、Rejected(已失败)。在构造Promise对象之后,就可以调用then方法,此方法接收一个参数,是函数,并且会拿到在构造函数中调用resolve时传的的参数。简单来讲,就是能把原来的回调写法分离出来,在异步操作执行完后,用链式调用的方式执行回调函数。这看起来和回调函数的方式很像,而实质上,Promise的精髓是“状态”,用维护状态、传递状态的方式来使得回调函数能够及时调用,它比传递callback函数要简单、灵活的多。在promise身上还有几个重要的方法:reject、catch、all、race、finally、try;

reject的用法

当出现“失败”状态的情况,需要用到reject。它的作用就是把Promise的状态置为rejected,这样我们在then中就能捕捉到,然后执行“失败”情况的回调。

function getNumber(num){
    var p = new Promise(function(resolve, reject){
            if(num<=5){
                resolve(num);
            }
            else{
                reject('数字太大了');
            }
    });
    return p;            
}

getNumber(6) 
.then(
    function(data){
        console.log('resolved');
        console.log(data);
    }, 
    function(reason, data){
        console.log('rejected');
        console.log(reason);
    }
);

catch的用法

Promise对象除了then方法,还有一个catch方法,其实它和then的第二个参数一样,用来解决reject的回调,但不同的是,它能够对代码异常进行处理,从而不至于让代码报错停止运行。并且在catch之后可以继续.then();

function getNumber(num){
    var p = new Promise(function(resolve, reject){
            if(num<=5){
                resolve(num);
            }
            else{
                reject('数字太大了');
            }
    });
    return p;            
}
getNumber()
.then(function(data){
    console.log('resolved');
    console.log(data);
    console.log(somedata); //此处的somedata未定义
})
.catch(function(reason){
    console.log('rejected');
    console.log(reason);
});

all的用法

Promise的all方法提供了并发执行异步操作的能力,并且在所有异步操作执行完后才执行回调。所谓并发可以理解为一起执行但互不干涉,但并不是同时。用Promise.all来执行,all将接收一个数组参数,里面的值最终都算作返回Promise对象。(“算作”的意思就是all接受的参数并不需要是一个Promise对象)这样,三个异步操作的并行执行的,等到它们都执行完后的数据会放进一个数组中并传入到then里面。这种方法尤其适合于预加载资源的使用情景里。

let a = function () {
  return 'a';
}
let b = function () {
  return 'b';
}
Promise
.all([a(), b()])
.then(function (results) {
  console.log(results);
});

race的用法

在all的方法里,我们可以从另外一个角度理解其运行的机制,那就是以Resolved状态的对象最后执行完的时间点作为方法返回的时间节点,所以与all不同的是,race方法是以Resolved状态的对象最早执行完的点作为方法返回的时间节点。这种情况可以运用在请求资源是否超时不确定的时候。所以无论是all还是race,一旦遇到失败状态便立即停止。

function requestImg() {
   let p = new Promise(function (resolve, reject) {
   let img = new Image();
   img.onload = function () {
   resolve(img);
}
   img.src = 'xxxxxx';
});
  return p;
}
 //延时函数,用于给请求计时
function timeout() {
  let p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    reject('图片请求超时');
  }, 5000);
});
  return p;
}
Promise
.race([requestImg(), timeout()])
.then(function (results) {
  console.log(results);
})
.catch(function (reason) {
  console.log(reason);
});

finally的用法

finally方法用于指定不管 Promise 对象最后状态如何,都会执行finally的操作指定的回调函数。finally方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是fulfilled还是rejected。这也表明,finally方法里面的操作,是与状态无关的,不依赖于 Promise 的执行结果。所以finally本质上是then方法的特例。

Promise.reject(2)
.catch((data) =>{
  console.log(data);
})
.finally(() => { 
  console.log(1);
})

try的用法

在实际编程里不知道或者不想区分,某函数f是同步函数还是异步操作,但是想用Promise来处理它。因为这样就可以不管f是否包含异步操作,都用then方法指定下一步流程,用catch方法处理f抛出的错误。所以一般就会采用下面的写法。

let f = () => console.log('now');
Promise.resolve().then(f);
console.log('next');

以上的写法容易造成一个结果就是,同步函数却被异步执行了,因此会在事件循环的末尾执行。为解决这个问题,Promise对象提供了try方法,可以同时保证执行对象是否需要异步执行。

    const f = () => console.log('now');
    Promise.try(f);
    console.log('next');

3.Generator函数

Generator函数特征
(1)function 关键字和函数之间有一个星号(*),且内部使用yield表达式,定义不同的内部状态。
(2)调用Generator函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。
Generator 函数在语法上可以理解成是一个状态机,内部封装了多个状态。虽然在形式上,它是一个普通函数。但整个Generator函数是一个封装了异步任务的容器,在异步操作需要暂停的地方,使用yield语句。

function* fn(){   // 定义一个Generator函数
    yield 'hello';
    yield 'world';
    return 'end';
}
var f1 =fn();          
console.log(f1);    
console.log(f1.next()); 
console.log(f1.next()); 
console.log(f1.next()); 
console.log(f1.next());

分析:yiled语句执行暂停的效果,若要进行下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。即:每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。所以Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
Generator函数的暂停执行的效果,意味着可以把异步操作写在yield语句里面,等到调用next方法时再往后执行。这实际上等同于不需要写回调函数了。所以,Generator函数的一个重要实际意义就是用来处理异步操作,改写回调函数。

yield使用的注意点
1)yield语句只能用于function* 的作用域,如果function* 的内部还定义了其他的普通函数,则函数内部不允许使用yield语句。
2)yield语句如果参与运算,必须用括号括起来。

// 定义一个Generator函数
function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
//return 8;
}
var a = foo(5);
console.log(a.next());
console.log(a.next(1));
//console.log(a.next());

从以上的例子我们还可以看出next()方法表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的。从语义上讲,第一个next方法用来启动遍历器对象,所以可以不用带有参数。

return和yield的区别:
1)return终结遍历,之后的yield语句都失效;next返回本次yield语句的返回值。
2)return没有参数的时候,返回{ value: undefined, done: true };next没有参数的时候返回本次yield语句的返回值。
3)return有参数的时候,覆盖本次yield语句的返回值,也就是说,返回{ value: 参数, done: true };next有参数的时候,覆盖上次yield语句的返回值,返回值可能跟参数有关(参数参与计算的话),也可能跟参数无关(参数不参与计算)。

迭代对象
自动遍历 Generator 函数的迭代对象,此时可不再需要调用next方法。一旦next方法的返回对象的done属性为true,迭代循环就会中止,且不包含该返回对象。

//输出斐波那契数列
function *fibonacci(){
    let [pre, cur] = [0,1];
    for(;;){
        [pre, cur] = [cur, pre+cur];
        yield cur;
    }
}
for(let n of fibonacci()){
    if( n>1000 )
        break;
    console.log(n);
}

4.async/await

async
async/await虽然是ES7的关键字,但是通过编译器的解译,也是可以正常使用。async/await的语法很简洁,直接作为一个关键字放到函数前面,用于表示该函数是一个异步函数。

async function timeout() {
    return 'hello world'
}
console.log(timeout());

分析:可以看出其实async 函数返回的是一个promise 对象,即使其中包含非promise。所以,async内部执行的逻辑机理与promise是一致的。因此上述例子相当于

async function timeout() {
    return Promise.resolve('hello world')
}

继续看

async function timeout(flag) {
    if (flag) {
        return Promise.resolve('hello world')
    } else {
        Promise.reject('error')
    }
}
timeout(true).then((res) => {
  console.log(res)
}) ;
timeout(false).catch(err => {
    console.log(err)
})

await
await的语法是将其放在async 函数中,表示暂停的意思。可以将异步代码像同步代码一般书写了。

// 2s 之后返回双倍的值
function doubleAfter2seconds(num) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(2 * num)
        }, 2000);
    } )
}
async function testResult() {
    let first = await doubleAfter2seconds(30);
    let second = await doubleAfter2seconds(50);
    let third = await doubleAfter2seconds(30);
    console.log(first + second + third);
}

你可能感兴趣的:(JS的异步操作手札)