异步操作

异步操作

JavaScript的执行环境是“单线程”的,一次只能执行单个任务,如果有多个任务要处理,那么后续任务需要等待前一个任务完成才能执行。
这样的运行模式运行起来简单,但是如果前面的任务假死了,那么后面的任务就长时间无法执行了。所以我们引入了”同步模式“和”异步模式”的执行模式。

异步操作的一个典型例子就是ajax,当发起ajax请求并获取到服务端的数据后,再通过返回的结果做出相应的处理。

对比“同步模式”和“异步模式”,其实简要的概括就是代码顺序和任务执行顺序是否一致

  • 同步模式:后面的任务等待前面的任务执行完毕,然后再执行,执行顺序和所有任务的排列顺序一致。
  • 异步模式:每个任务都有一个或多个回调函数,前面的任务运行结束后不会马上执行后面的任务,而是先执行前面的那个任务的回调,后面的任务则不等前面的任务结束就执行。这样看起来,所有的任务执行顺序和排列顺序是不一致的。

同步任务在主线程上排队执行的任务。
异步任务不马上进入主线程,进入任务队列。只有等主线程任务执行完毕,任务队列通知主线程某个异步任务可以执行了,该异步任务才会进入到主线程执行。
异步任务通常可以分为两大类:I/O 函数(AJAX、readFile等)和计时函数(setTimeout、setInterval)
其实都是带回调

传统模式下的异步编程,主要是靠回调函数、事件监听来完成,后来引入了Promise,我们可以避免了传统回调函数的回调地狱,再后来引入了asyncawait,我们就可以避免Promise带来的多个then方法。

回调函数

基本的样子:

step1(function(result1){
    step2(function(result2){
        step3(function(result3){
            //...
        })
    })
})

如果回调多了就看起来很乱。

setTimeout

一个demo:

for (var i=1;i<=2;i++) {
    setTimeout(() => {
        console.log(i)
    }, 1000)
}
console.log(i);

结果是先输出一个2,等待1s后输出两个2,原因:

  • i作用域在setTimeout回调函数内部,一旦脱离该作用域即失效,所以只保留最后的结果
  • 根据前面已知,循环结束后,i等于2,输出也就是2
  • JavaScript事件处理器在现场空闲之前不会运行,setTimeout里的回调是异步任务,它加入到任务队列中,当执行完外层的输出(也就是主线程执行完毕),任务队列里面的任务才会执行,所以这才紧接着输出两个2

另一个demo:

var aa;
var test1 = () => {
    setTimeout(() => {
        aa = 1
        console.log(aa)
        aa += 1
    },0)
    // console.log('y') // 它不会加到任务队列
}
var test2 = () => {
    if (aa !== undefined)
    console.log(aa);
    else 
        console.log('oops')
}
test1();
test2();

上述结果先输出oops,然后输出1,也就是先输出了test2内部的内容,然后在输出test1的,由于变量aa是在test1中赋值的,所以test2在没有取得aa的准确值前都是undefined
这段代码的运行流程是这样的:首先test1的setTimeout内的回调投入到任务队列中,主线程发现还有个test2任务要处理,那么先处理test2test2没有获取到变量值,输出了oops,然后主线程没有什么任务要处理了,任务队列就通知它那就把我的事情解决了吧,此时就调用test1搁置在队列中的内容

var aa;
var test1 = (fn) => {
    setTimeout(() => {
        aa = 1
        console.log(aa)
        aa += 1
        fn()
    },0)
    // console.log('y')
}
var test2 = () => {
    if (aa !== undefined)
    console.log(aa);
    else 
        console.log('oops')
}
test1(test2);

这样,分别输出12
test1含有setTimeout,那么setTimeout内部的回调任务会加到任务队列中,然后在运行的时候发现除了test1之外也没有其他占用主线程的任务了(test2已经作为了test1的回调了,当然也是投入到任务队列中),任务队列就通知主线程请求执行任务。

事件监听/订阅、发布

同样的,如果是事件监听,其内部的回调也会被加到任务队列里面,所以要等到主线程的其他任务都处理完了才会处理其中的回调。
注意:CSS动画不算在主线程任务里面,就像下面的例子那样,在变色还没结束前点击目标dom还是会执行事件监听内部的回调的

// html部分
<body>
  <div id="div1"></div>
  <div id="div2"></div>
</body>

// css部分
#div1 {
  width: 100px;
  height: 100px;
  background-color: #ff0000;
}
#div2 {
  width: 200px;
  height:200px;
  background-color: #32442d;
  animation: change 5s linear;
}
@keyframes change {
  0%{
    background-color: #435523;
  }
  50%{
    background-color: #ff0000;
  }
  100%{
    background-color: #000;
  }
}

// js部分
document.getElementById('div1').addEventListener('click',() => {alert('div1')});
document.getElementById('div2').className = 'test';

Promise

Promise对象可以传入一个函数作为参数,这个函数又可以传入两个参数,分别是resolvereject,分别代表异步操作执行成功后的回调函数和失败后的回调函数
Promise在实例化之后会马上运行,可以将实例化部分代码封装在一个函数中,比如:

function test() {
    return new Promise((resolve, reject) => {
        // do something
        if(true) {  // 事件处理成功
            resolve(data);
        } else {   // 处理失败
            reject(data);
        }
    })
}

then方法

test().then((data) => {
    console.log(data);  // 可以使用前面异步操作的数据
})

test执行完毕后调用then方法,then就相当于回调函数

Promise 有三种状态:pending(进行中)、fulfilled(成功)和 rejected(已失败)
3种状态
其中,pending是不定态,它可以转变为其他两个状态中的任意一个,fulfilledrejected都是最终的确定态,一旦pending转变为确定态,那么就不会再发生状态转变。

demo:

function test() {
    return new Promise((resolve, reject) => {
        let n = Math.ceil(Math.random() * 10);
        if (n < 5) {
            resolve(n);
        } else {
            reject('out of range');
        }
    })
}
test().then((n) => {
    console.log(n);
}, (reason) => {
    console.log(reason);
})

如果随机数小于5,那么将输出这个随机数;如果大于5,那么输出out of range

catch方法

catch方法实际上就等同于promise.then(undefined, onRejected),也就是用来处理失败回调函数失败后的回调函数,并且,还能即使捕获resolve回调中的错误,一旦出错,不会阻塞,立马执行catch中的回调。

catch捕获机制

比如前面的方法我们可以改写一下:

test().then((n) => {
    console.log(n);
}, (reason) => {
    console.log(reason);
}).catch((err) => {
    console.log(err);
})

then本身也可以传入失败的回调,那么和用catch有什么区别?

  • 使用promise.then(onFulfilled, onRejected)的话,在 onFulfilled中发生异常的话,在onRejected中是捕获不到这个异常的。
  • promise.then(onFulfilled).catch(onRejected)的情况下 then中产生的异常能在.catch中捕获
  • .then.catch在本质上是没有区别的,需要分场合使用。

all方法

它接收一个数组参数,数组每一项返回的都是promise对象,只有数组内所有元素都执行完才会进入then回调(如果有其中一项出现了失败,那么最终的结果就是失败的),然后每一项返回的数据都会以一个数组的形式传递到then回调中

const p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('p1');
    }, 1000);
})
.then(result => result)
.catch(err => err);

const p2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('p2');
    }, 1000);
})
.then(result => result)
.catch(err => err);

Promise.all([p1, p2])
.then(result => console.log(result))
.catch(err => console.log(err));

上述代码在3s后输出['p1','p2']

一个更加明显的例子:

function timeDelay(delay) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(delay);
        }, delay);
    });
}
var start = Date.now();
Promise.all([
    timeDelay(1000),
    timeDelay(2000),
    timeDelay(3000),
    timeDelay(4000)
]).then((val) => {
    console.log(Date.now() - start + 'ms');
    console.log(val);
});

最后在4s后输出[1000,2000,3000,4000]。由此可以看出,传递给 Promise.allpromise并不是一个个的顺序执行的,而是同时开始、并行执行的。如果是串行处理,那么等待时间就是总和,即为10s。

race方法

它和all方法很像,不同之处在于all方法需要每一项都返回了成功结果才会执行then,而race则是只要其中任意一项返回成功即执行then回调

const p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('p1');
    }, 1000);
})
.then(result => result);

const p2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('p2');
    }, 3000);
})
.then(result => result);

Promise.race([p1, p2])
.then(result => console.log(result));
// 等待1秒后输出 'p1'

这样看起来,如果只要并行任务中一旦有其中一项运行完成,不等待其他任务结束就直接调用了then

Generator函数

使用generator函数可以避免promise带来的一大堆then方法
基本的格式:

  • function关键字和函数名之间有一个*
  • 函数内部用yield,定义不同的内部状态

demo:

function* g() {
    yield 'a';
    yield 'b';
    return 'end';
}
var gen = g();

每次调用gen.next()的时候,分别输出abend; 后面再调用就输出undefined

循环输出20以内斐波拉切数:

function* fib() {
    let [prev, curr] = [0, 1];
    for(;;) {
        [prev, curr] = [curr, prev + curr];
        yield curr;
    }
}
for (let n of fib()) {
    if (n > 20) break;
    console.log(n);
}

逐行读取文本文件:

function* reader() {
    let file = new FileReader("text.txt");
    try {
        while(!file.eof) {
            yield parseInt(file.readline(), 10);
        }
    } catch(e) {
        console.log(e);
    } finally {
        file.close()
    }
}

这部分内容不细说,更详细内容可以看:
阮一峰 Generator 函数的语法

async/await

async/await就是generator函数的语法糖
如何体现?
比如这个demo:

var plugin = require('xxx');
var doSomethings = ((args) => {
    return new Promise((resolve, reject) => {
        plugin.Func(args, (data, error) => {
            if (error)
                reject(error);
            resolve(data);
        });
    });
});
var gen = function* (){
    var f1 = yield doSomethings('a');
    var f2 = yield doSomethings('b');
}

如果是写成async

var plugin = require('xxx');
var asyncFunc = async function() {
    var f1 = await plugin.Func('a');
    var f2 = await plugin.Func('b');
}

对比一下前后的区别,async就是把generator函数的*替换为async,将yield替换为await

它的实现,就是将generator函数和自执行器放在一个函数内

async function func(args){ 
    //...
}
// 等同于
function func(args) {
    return fn(function* (){
        //...
    });
}

这里的fn函数就是执行器
,其实其内部也是返回了一个Promise对象
你可以这样测试:

async function test() {
    return 'test'
}
var res = test();
console.log(res);

结果将打印一个Promise对象。
如果在async函数中返回一个直接量,那么async会将其通过Promise.resolve()封装为Promise对象
所以,事实上,async/await也可以用类似Promise那样的then方法:

async function test(arg) {
    console.log('result is', arg)
}

test(2).then(() => {
    console.log('end')
})

如果async没有返回值,那么将返回Promise.resolve(undefined)
因为其内部就封装了Promise,所以有着和Promise一样的特点--无等待,在没有await的情况下执行async,它会被立即执行,返回一个Promise对象,并且不会阻塞后面的代码。

说完了async,再说说await。await在使用时要封装在一个async函数内。前面提到async返回一个promise,await就在等待一个表达式,注意,这个表达式不一定是async返回的promise,也可以是其他值:

function test1() {
    return "test1"
}
async function test2() {
    return "test2"
}
async function test3(){
    const t1 = await test1();
    const t2 = await test2();
    console.log(t1,t2)
}
test3();
// test1 test2

从上可以看出,假设await等到的不是一个promise对象,那么它的运算结果就是它接收到的返回值;如果它等到的是一个promise对象,那么它就阻塞后面代码,等着promise的resolve方法,然后获取到resolve处理的返回值后再做处理

一个实际点的demo:

var fn = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('test');
        }, 2000);
    });
};
var test = async () => {
    var t = await fn();
    console.log(t);
};
test();
console.log('out');

在上述代码中,先是打印出out,等待2s后输出test
test函数中,t取得了fn中的值,并且通过await阻塞了后面代码(console.log(t))的运行,直到fn这个异步函数的执行完毕,才执行了后面的代码

看看它相比较promise做了什么优化:


function takeTime(n) {
    return new Promise(resolve => {
        setTimeout(() => resolve(n+200), n);
    });
}
function step1(n) {
    console.log(`step1 with ${n}`);
    return takeTime(n);
}
function step2(n) {
    console.log(`step2 with ${n}`);
    return takeTime(n);
}
function step3(n) {
    console.log(`step3 with ${n}`);
    return takeTime(n);
}

// Use Promise
function doit(){
    console.log('begin');
    var time1 = 300;
    step1(time1)
        .then(time2 => step2(time2))
        .then(time3 => step3(time3))
        .then(result => {
            console.log(`result is ${result}`);
            console.log('end');
        });
}
doit();

// Use async/await
async function doit2() {
    console.log('begin');
    var time1 = 300;
    var time2 = await step1(time1);
    var time3 = await step2(time2);
    var result = await step3(time3);
    console.log(`result is ${result}`);
    console.log('end');
}
doit2();

可以看见,如果用async/await就可以避免promise那一段很长的then,看起来就像是同步的编程方式

优点

  • 1)内置执行器
    async不信generator函数那样,它自带执行器,可以像普通函数那样使用
  • 2)语义化好,比起generator函数的yield*,ayanc/await编程更像是同步操作
  • 3)适用性更广。yield后面只能是thunk函数或者Promise对象,而async后面可以跟Promise对象和原始类型值(数值、字符串和布尔值)



参考:
浏览器事件循环机制(event loop)
Javascript异步编程的4种方法
谈一谈几种处理 JavaScript 异步操作的办法
JavaScript 异步编程学习笔记
js中的同步和异步的个人理解
Promise迷你书
Promise使用手册
Promise的个人理解及实践
ES6 Promise介绍
阮一峰 Generator 函数的语法
[译] 如何在 JavaScript 中使用 Generator?
AlloyTeam ES6 generator介绍
理解 JavaScript 的 async/await
async 函数的含义和用法