异步处理

359 阅读16分钟

同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在JS这种单线程事件循环模型中,同步操作和异步操作更是代码所要依赖的核心机制

同步

同步行为对应内存中顺序执行的处理器指令。

这样的执行流程容易分析程序在执行到代码任意位置时的状态

首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态

异步

相对的,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。

异步行为是为了优化因计算量大而时间长的操作。

异步代码不容易推断

如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的

重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用

本文中我们使用setTimeout模拟异步行为,setTimeout可以定义一个在指定时间之后会被调度执行的回调函数。

function double(value) {
    setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}
double(3); // 大约1000毫秒之后打印 6

对于这个例子而言,1000毫秒之后,JS运行时会把回调函数推到自己的消息队列上去等待执行。推倒队列之后,回调什么时候出列被执行对JS代码就完全不可见了。

double()函数在setTimeout成功调度异步操作之后会立即退出。

在早期JS中,只支持定义回调函数来表明异步操作完成。

function double(value, callback) {
    setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`cb res: ${x}`);)

串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决

异步操作的失败处理也在回调模型中也要考虑,因此自然就出现了成功回调和失败回调

function double(value, success, failure) {
    setTimeout(() => {
        try {
            if(typeof value !== 'number') {
                throw 'Must provide number as first argument';
            }
            success(value * 2);
        } catch(e) {
            failure(e);
        }
    }, 1000);
}
const successCallback = x => console.log(`success: ${x}`);
const failureCallback = e => console.log(`failure: ${e}`);
double(3, successCallback, failureCallback); // success: 6
double('a', successCallback, failureCallback); // Error: Must provide number as first argument

这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它

嵌套异步回调

如果异步返回值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。

const successCallback = x => {
    double(x, y => console.log(`success: ${y}`));
}

显然,随着代码越来越复杂,回调策略是不具有扩展性的,“回调地狱”这个称呼可谓实至名归

Promise(期约)

ES6新增了正式的Promise(期约)引用类型,支持优雅地定义和组织异步逻辑

Promises/A+规范

早期的期约机制在jQueryDojo中是以Deferred API的形式出现的。到了2010年,CommonJS项目实现的Promises/A规范日益流行起来。为弥合现有实现之间的差异,2012年Promise/A+组织forkCommonJSPromises/A建议,并以相同的名字制定了Promises/A+规范。这个规范最终成为了ECMAScript6规范实现的范本

ES6增加了对Promises/A+规范的完善支持,即Promise类型。

所有现代浏览器都支持ES6期约,很多其他浏览器API(如fetch()Battery Status API)也以此为基础

Promise的两大用途:

  • 抽象地表示一个异步操作

    某些情况下,这个状态机就是Promise可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。

  • 使用异步操作产生的值

    在另外一些情况下,Promise封装的异步操作会实际生成某个值,而程序期待Promise状态改变时可以访问这个值

    每个Promise只要状态切换为兑现,就会有一个私有的内部值(value);如果为拒绝,则会有一个私有的内部理由(reason)。这两个值都包含原始值或对象的不可修改的引用,默认值都是undefined

Promise的状态:

  • pending 待定 —— 最初状态

  • fulfilled 兑现(有时也称resolved

  • rejected 拒绝

状态更改后不可逆,只要从待定转换为兑现或拒绝,Promise的状态就不再改变。而且,也不能保证Promise必然会脱离待定状态。因此,无论哪种状态,代码都应该具有恰当的行为

期约的状态是私有的,不能直接通过JS检测到。 这主要是为了避免根据读取到的Promise状态,以同步方式处理Promise对象;另外,Promise的状态也不能被外部JS代码修改

由于Promise的状态是私有的,所以只能在内部进行操作(内部操作在Promise的执行器函数中完成)。

执行器函数主要有两项职责:

  • 初始化Promise的异步行为

    通过调用它的两个函数参数实现:resolve()reject()

  • 控制状态的最终转换

    调用resolve()会把状态切换为兑现

    调用reject()会把状态切换为拒绝,同时抛出错误

    let p1 = new Promise((resolve, reject) => resolve());
    setTimeout(console.log, 0, p1); // Promise <resolve>
    

为避免promise卡在待定状态,可以添加一个定时退出功能

let p = new Promise((resolve, reject) => {
    setTimeout(reject, 1000); // 10秒后调用reject
    // 正常的代码逻辑
    ...
});
setTomeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 11000, p); // 11秒后再检查状态

// (After 10 seconds) Uncaught error
// (After 11 seconds) Promise <rejected>

Promise.resolve()

通过调用Promise.resolve()静态方法,可以实例化一个解决的Promise

对于这个静态方法而言,如果传入的参数本身是一个Promise,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法

let p = Promise.resolve(7);

setTimeout(console.log, 0, p === Promise.resolve(p));
// true

setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)))'
// true

这个静态方法能够包装任何非Promise值,包括错误对象,并将其转换为解决的Promise。因此,也可能会导致不符合预期的行为

let p = Promise.resolve(new Error('foo'));
setTimeout(console.log, 0, p);
// Promise <resolved>: Error: foo

Promise.reject()

Promise.resolve()类似,Promise.reject()会实例化一个拒绝的Promise并抛出一个异步错误(这个错误不能通过try/catch捕获,而只能通过拒绝处理程序捕获)

Promise.reject()并没有照搬Promise.resolve()的幂等逻辑。如果给它传一个Promise对象,则这个Promise会成为它返回的拒绝Promise的理由

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>

try/catch并不能捕获Promise.reject的错误

try{
    Promise.reject(new Error('foo'));
} catch(e) {
    console.log(e);
}
// Uncaught (in promise) Error: foo

代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构 —— 更具体地说,就是Promise的方法

Promise的实例方法

Promise实例的方法是连接外部同步代码与内部异步代码之间的桥梁。

这些方法可以访问异步操作返回的数据,处理Promise成功和失败的结果,连续对Promise求值,或者添加只有Promise进入终止状态时才会执行的代码

then()方法

在异步结构中,任何对象都有一个then()方法,这个方法被认为实现了Thenable接口。

Promise.prototype.then()是为Promise实例添加处理程序的主要方法

let p = new Promise((resolve, reject) => setTimeout(resolve, 2000));

p.then(res => {...}, reason => {...})

传给then()的任何非函数类型的参数都会被静默忽略。

如果想只提供onRejected参数,那就要在onResolved参数的位置上传入undefined。这样有助于避免在内存中创建多余的对象

p.then(undefined, reason => { ... })
catch()

Promise.prototype.catch()方法用于给期约添加拒绝处理程序。

这个方法只接收一个参数:onRejected处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null, onRejected)

let p = Promise.reject();
let onRejected = function(e) {
    setTimeout(console.log, 0, 'rejected');
};

// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected); // rejected
p.catch(onRejected); // rejected
finally()

Promise.prototype.finally()方法用于给Promise添加onFinally处理程序,这个处理程序在状态转换为兑现或拒绝时都会执行。

这个方法可以避免onResolvedonRejected处理程序中出现冗余代码。

onFinally处理程序没办法知道Promise的状态是什么,所以这个方法主要用于添加清理代码

这个新Promise实例不同于then()catch()方式返回的实例。因为onFinally被设计为一个状态无关的方法,所以在大多数情况下它将表现为父Promise的传递。

Promise进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行

代码执行顺序

跟在添加这个处理程序的代码之后的同步代码一定会再处理程序之前先执行。即使Promise一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由JS运行时保证,被称为“非重入”特性

// 创建决绝的Promise
let p = Promise.resolve();

// 添加resolve处理程序
// 直觉上,这个处理程序会等待Promise一兑现就执行
p.then(() => console.log('onResolved handler'));

// 同步输出,证明then()已经返回
console.log('then() returns');

// 实际的输出
// then() returns
// onResolved handler

如果给Promise添加了多个处理程序,当状态变化时,相关处理程序会按照添加他们的顺序依次执行。无论是then()catch()还是finally()添加的处理程序都是如此

在执行函数中,解决的值和拒绝的理由是分别作为resolve()reject()的第一个参数往后传的。

拒绝Promise类似于throw()表达式,因为他们都代表一种程序状态,即需要终端或特殊处理。在Promise的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。

// 以下这些Promise都会以一个错误对象为由被拒绝,也会抛出4个未捕获错误
let p1 = new Promise((resolve, reject) => reject(Error('foo')));
let p2 = new Promise((resolve, reject) => { throw Error('foo'); });
let p3 = Promise.resolve().then(() => { throw Error('foo'); });
let p4 = Promise.reject(Error('foo'));

Promise可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。

正常情况下,在通过throw()关键字抛出错误时,JS运行时的错误处理机制会停止执行抛出错误之后的任何指令

throw Error('foo');
console.log('bar'); // 这一行不会执行
// Uncaught Error: foo

但是,在Promise中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令

Promise.reject(Error('foo'));
console.log('bar');
// bar
// Uncaught (in promise) Error: foo

异步错误只能通过异步的onRejected处理程序捕获

Promise.reject(Error('foo')).catch(e => {...})

这不包括捕获执行函数中的错误,在解决或拒绝Promise之前,仍然可以使用try/catch在执行函数中捕获错误

let p = new Promise((resolve, reject) => {
    try{
        throw Error('foo');
    } catch(e) {
        ...
    }
    resolve('bar');
});
setTimeout(console.log, 0, p); // Promise <resolved>: bar

then()catch()在语义上相当于try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。

Promise连锁与合成

多个Promise组合在一起可以构成强大的代码逻辑。组合的方式有两种

  • 连锁: 一个接一个的拼接构成Promise连锁

    let p = new Promise((resolve, reject) => {
        console.log('first');
        setTimeout(resolve, 1000)
    });
    p.then(() => new Promise((resolve, reject) => {
        console.log('second');
        setTimeout(resolve, 1000);
    }))
     .then(() => new Promise((resolve, reject) => {
        console.log('third');
        setTimeout(resolve, 1000);
    }))
     .then(() => new Promise((resolve, reject) => {
        console.log('fourth');
        setTimeout(resolve, 1000);
    }));
    // first
    // second
    // third
    // fourth
    

    每个后续的处理程序都会等待前一个Promise解决,然后实例化一个新Promise并返回它。

    这种结构可以简洁地将异步任务串行化,解决之前依赖回调的难题

  • 合成: 将多个Promise组合为一个Promise

Promise.all()

Promise.all()静态方法创建的Promise会在一组Promise全部解决之后再执行

let p = Promise.all([ Promise.resolve(), Promise.resolve() ]);
p.then(() => {...})

如果其中任何一个Promise处于待定状态,则合成的Promise也会待定;如果有一个Promise拒绝,则合成的Promise也会拒绝,且第一个拒绝的Promise会将自己的理由作为合成Promise的拒绝理由;只有所有Promise都兑现,则合成的Promixe的解决值才是所有包含Promise解决值的数组

let p = Promise.all([
    Promise.resolve(3),
    Promise.resolve(),
    Promise.resolve(4)
]);
p.then(values => {
    console.log(values);
}); // [3, undefined, 4]
Promise.race()

Promise.race()静态方法返回一个包装Promise,与Promise.all类似,不同的是取最先响应的Promise的结果作为自己的结果,是一组集合中最先解决或拒绝的Promise的镜像。

Promise扩展

ES6的Promise实现是很可靠的,但它也有不足之处。比如,很多第三方Promise库实现中具备而ECMAScript规范却未涉及的两个特性:

取消

我们经常会遇到Promise正在处理过程中,程序却不再需要其结果的情形。这时候如果能够取消就好了

实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消Promise的功能。这可以用到Kevin Smith提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消Promise;同时也提供了一个Promise的实例,可以用来触发取消后的操作并求值取消状态

<button id="start">start</button>
<button id="cancel">cancel</button>
class CancelToken {
    constructor(cancelFn) {
        this.promise = new Promise((resolve, reject) => {
            cancelFn(resolve);
        })
    }
}
const startButton = document.querySelector('#start');
const cancelButton = document.querySelector('#cancel');
function cancelDelayedResolve(delay) {
    console.log('set delay');
    return new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            console.log('delayed resolve');
            resolve();
        }, delay);
        const cancelToken = new CancelToken(cancelCB => {
            cancelButton.addEventListener('click', cancelCB)
        });
        cancelToken.promise.then(() => clearTimeout(id));
    })
}
startButton.addEventListener('click', () => cancelDelayedResolve(1000))

每次单击“start”按钮都会开始计时,并实例化一个新的CancelToken实例。此时,“cancel”按钮一旦被点击,就会触发令牌实例中的期约解决,而解决之后,单击“start”按钮设置的超时也会被取消

进度追踪

执行中的Promise可能会有不少离散的“阶段”,在最终解决之前必须依次经过。某些情况下,监听Promise的执行进度会很有用。ES6的Promise并不支持进度追踪,但是可以通过扩展来实现

一种实现方式是扩展Promise类,为它添加notify()方法

class TrackablePromise extends Promise {
    constructor(executor) {
        const notifyHandlers = [];

        super((resolve, reject) => {
            return executor (resolve, reject, status => {
                notifyHandlers.map(handler => handler(status));
            })
        })
        this.notifyHandlers = notifyHandlers;
    }
    notify(notifyHandler) {
        this.notifyHandlers.push(notifyHandler);
        return this;
    }
}
let p = new TrackablePromise((resolve, reject, notify) => {
    function countdown(x) {
        if(x > 0) {
            notify(`${20 * x}% remaining`);
            setTimeout(() => countdown(x - 1), 1000);
        } else {
            resolve();
        }
    }

    countdown(5);
})
p.notify(x => setTimeout(console.log, 0, 'progress:', x));
p.then(() => setTimeout(console.log, 0, 'completed'));

截图.PNG

ES6不支持取消Promise和进度通知,一个主要原因就是这样会导致Promise连锁和Promise合成过度复杂化。

比如,在一个Promise连锁中,如果某个被其他Promise依赖的Promise被取消了或者发出了通知,那么接下来应该发生什么完全说不清楚

延伸思考:取消http请求

常见场景是,一般在项目中会使用promise统一封装http请求,当需要进行token校验,token失效时,撤销接口请求

异步函数 async/await

async

异步函数是ES6 Promise模式在ECMAScript函数中的应用

async/await是ES8规范新增的。这个特性从行为和语法上都增强了JS,让以同步方式写的代码能够异步执行

ES8的async/await旨在解决利用异步结构组织代码的问题

async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上

async function foo() {}

let bar = async function() {};

let baz = async () => {}

class Qux {
    async qux() {}
}

使用async关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JS函数的正常行为

异步函数如果使用return关键字返回了值(如果没有return则会返回undefined),这个值会被Promise.resolve()包装成一个Promise对象。

async function foo () {
    console.log(1);
    return 3;
}
// 给返回的Promise添加一个解决处理程序
foo().then(res => {
    console.log(res);
});

// 1 3

异步函数的返回期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以(会被当做已经解决的Promise)。

与在Promise处理程序中一样,在异步函数中抛出错误会返回拒绝的Promise。但是,拒绝Promise的错误不会被异步函数捕获

async function foo() {
    console.log(1);
    Promise.reject(3);
}
foo().catch(e => {
    console.log(e);
})

截图.PNG

await

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。

使用await关键字可以暂停异步函数代码的执行,等待Promise解决

async function foo() {
    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
    console.log(await p);
}
foo(); // 3

await关键字会暂停执行异步函数后面的代码,让出JS运行时的执行线程。这个行为与生成器函数中的yield关键字时一样的。

await关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行

await关键字可以单独使用,也可以在表达式中使用

async function foo() {
    console.log(await Promise.resolve('foo'));
}
foo(); // 'foo'

async function bar() {
    return await Promise.resolve('bar');
}
bar().then(console.log); // bar

单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的Promise使用await则会释放错误值

async function foo() {
    console.log(1);
    await Promise.reject(3);
    console.log(4); // 这行代码不会执行
}
foo().catch(console.log);
console.log(2);

// 1 2 3

await的限制

await关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过定义并立即调用异步函数是没问题的

此外,异步函数的特质不会扩展到嵌套函数。因此,await关键字也只能直接出现在异步函数的定义中

在同步函数内部使用await会抛出SyntaxError

// 不允许,await出现在了箭头函数中
function foo() {
    const syncFn = () => {
        return await Promise.resolve('foo');
    };
    console.log(syncFn());
}
// 不允许,await出现在了同步函数声明中
function bar() {
    function syncFn() {
        return await Promise.resolve('bar');
    }
    console.log(syncFn());
}
// 不允许,await出现在了同步函数表达式中
function baz() {
    const syncFn = function() {
        return await Promise.resolve('baz');
    }
    console.log(syncFn());
}
// 不允许,IIFE使用同步函数表达式或箭头函数
function qux() {
    (function() {
        console.log(await Promise.resolve('qux'));
    })();
    (() => console.log(await Promise.resolve('qux')))();
}

async/await中真正起作用的是awaitasync关键字无论从哪方面来看,都不过是一个标识符

毕竟,异步函数如果不包含await关键字,其执行基本上跟普通函数没什么区别

JS运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可用了,JS运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行

因此,即使await后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

async function foo() {
    console.log(2);
    await null;
    console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1 2 3 4

总结

一般在程序中处理异步操作可以使用以下几种方法:

  • Promise 最常见的是处理接口请求的封装

  • async/await 处理代码中的异步逻辑

  • generator 比如redux-saga中监听相关action

手写Promise

JS的Event Loop

知道JS最大的特点是单线程,有了同步异步的概念,接下来我们可以了解一下JS中到底是怎样执行代码的