yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
253 stars 12 forks source link

理解回调函数与 Promise #51

Open yinguangyao opened 3 years ago

yinguangyao commented 3 years ago

1. 前言

在现代化的前端开发中,前后端分离已经成为主流。后端提供restful 接口,前端通过 ajax 请求拿到接口的数据,这样使得双方职责明确,减少了各自的负担。

这中间就少不了异步网络请求。我们在前端发送一个 http 请求,在接口返回数据后,我们可以拿到数据并执行相应的操作。

异步请求在前端和 NodeJS 中是很常见的。因此,如何优雅地处理异步操作也是前端开发一直以来在探索的难题。

2. 异步

说了那么多,那什么是异步呢?我们引用维基百科上面的一段话:

异步通信(英语:Asynchronous conferencing)是科学领域中正式使用的术语,特指以计算机为介质,沟通,协作和学习,在互动贡献者中有一定延迟的技术。与之相对的是同步通信,同步会议指各种“聊天”系统,在该系统中,用户“实时”同步通信。

单看这段话还是比较晦涩难懂的,我们可以简单地理解为,在将来执行的程序可以看做是异步的。比如我们注册了一个 setTimeout,但并没有马上执行里面的回调函数,setTimeout 的这个表现就是异步的。

// 依次打印出first、third、second
console.log('first');
setTimeout(() => {
    console.log('second');
}, 1000)
console.log('third');

如果你还不能理解,那么假设我们有个烧水壶。我们烧水的时候,如果一直在旁边等着,直到水烧开去接水,这就是同步。 如果我们在烧水的时候离开去做自己的事情,等水烧开后会提醒你水烧开了,你再去接水,这就是异步。 那么如果我们想在水烧开后就去做某件事情,这个时候该怎么办呢?所以这就涉及到了这节要讲的两个概念 —— 回调函数和 Promise。

3. 回调函数

我们最早接触到的异步处理就是回调函数。我们给函数传入一个回调函数作为参数,可以规定在等待某个操作结束之后,就去执行这个回调函数。

// 我们可以规定在time ms后,再去执行callback函数
const sleep = (time, callback) => {
    setTimeout(() => {
        callback();
    }, time)
}

如果你用过 jQuery,那么一定会对 $.ajax 比较熟悉,这也是一个典型的通过 callback 来获取异步结果的。我们可以把要执行的回调函数在 success 里面执行,甚至还可以把 data 传给这个回调函数。

$.ajax({
    url: '/getBookList',
    method: 'GET',
    success(data) {
        // 执行回调函数
    },
    fail(error) {
        // 执行错误回调函数
    }
})

4. 回调函数的弊端

可能你也会觉得,这样看起来 callback 不是挺好的吗?虽然不够优雅,但也挺清晰的。为啥还要去探索其他的各种方式呢?

4.1 回调地狱

我们有一个例子,假设我们控制一个红绿灯切换的动画(假设红绿灯时间都是 60s)。由于每次都要依赖前一次结束,所以只能在对方的回调函数里面执行。这样就造成了一层层回调函数嵌套。

    green(60, function() {
        red(60, function() {
            green(60, function() {
                red(60, function() {
                    green(60, function() {
                        // ...
                    })
                })
            })
        })
    })

这样的代码风格一般被我们称为回调地狱。从直观上来看,函数一层层嵌套,让可读性和可维护性变得非常差。 当你想修改回调里面代码的时候,只能到函数中进行修改,这样也违反了【开闭原则】。

4.2 错误跟踪

同时,由于异步的存在,导致了 try...catch 无法捕获到异步调用中的异常,导致调试变得很难。 我们可以看下面这个例子,很难去捕获到异步里面的报错。

const time = (callback) => {
  setTimeout(() => {
    try {
        console.log(aaaa) // aaaa未定义
        callback()
    } catch (err) {
        throw err
    }
  }, 1000)
}
const cb = () => {
  console.log('success')
}
// try...catch无法捕获到time中的报错
try {
  time(cb)
} catch (err) {
  console.log('err', err)
}

要解决这个问题,有两种方式,一个是将成功和失败的回调分开,jQuery 就是用的这种方式。 我们使用 success 和 fail 两个函数来处理成功和失败两种场景,将捕获到的异常传给 fail 函数。

function sleep(success, fail) {
    setTimeout(() => {
        try {
            success();
        } catch (err) {
            fail(err);
        }
    }, 1000)
}
function success() {
    console.log('success');
}
function fail(error) {
    console.log('error: ', error);
}
sleep(success, fail);

另一种就是将 error 当做参数返回。NodeJS 中的很多异步接口都是这样做的。

readFile('test.txt', function(error, data) {
    if (error) { return error; } // 失败
    // 成功
})

4.3 失去控制权

除此之外,由于回调函数何时执行、执行多少次是由我们依赖的函数决定的,控制权不在我们这边,就有可能会导致很多奇奇怪怪的问题。 假设我们使用的 jQuery ajax,如果在请求接口成功之后,jQuery 将我们的 success 方法执行了两遍会发生什么? 当然,jQuery 这种很多人维护的项目很难出现这种低级的问题,但很难保证我们用的其他第三方库不会出现这些问题。

4.4 并行问题

假设我们有一种场景,需要等待三个接口都请求成功后,我们再去执行某个操作,这样我们该怎么知道三个接口什么时候全部请求成功呢? 我们是对三个接口分别设置三个不同的变量,执行成功后修改这个变量的值,在每个接口中都判断一下?

let isAjaxASuccess = false, 
    isAjaxBSuccess = false, 
    isAjaxCSuccess = false;
function ajaxA (callback) {
    // 请求成功后更新状态
    isAjaxASuccess = true;
    if (isAjaxBSuccess && isAjaxCSuccess) {
        callback();
    }
}
function ajaxB (callback) {
    // 请求成功后
    isAjaxBSuccess = true;
    if (isAjaxCSuccess && isAjaxCSuccess) {
        callback();
    }
}
function ajaxC (callback) {
    // 请求成功后
    isAjaxCSuccess = true;
    if (isAjaxCSuccess && isAjaxBSuccess) {
        callback();
    }
}

亦或者是,我们设置一个 setInterval 进行轮询?

let isASuccess = false, 
    isBSuccess = false, 
    isCSuccess = false;
function ajaxA (callback) {
    // 请求成功后
    isASuccess = true;
}
function ajaxB (callback) {
    // 请求成功后
    isBSuccess = true;
}
function ajaxC (callback) {
    // 请求成功后
    isCSuccess = true;
}
const interval = setInterval(() => {
    if (isASuccess && isBSuccess && isCSuccess) {
        callback();
        clearInterval(interval);
    }
}, 500)

但不管是哪一种方法,我相信这样的代码都会让你抓狂。如果以后再加个接口,扩展性也都很差。

5. Promise

于是,在 ES2015 中,Promise 诞生了。Promise 成功解决了回调函数中嵌套调用和错误跟踪、回调函数控制权等问题。

如果你还没用过 Promise,可以先读一下阮一峰老师的这篇文章:ES6 Promise对象

Promise 像是一个状态机,内部有三种状态:PENDING、REJECTED、FULFILLED。一旦从 PENDING 状态转化为另两种状态,就无法再转换为其他状态。

  1. 如果是 PENDING 状态,则 Promise 可以转换到 FULFILLED 或 REJECTED 状态。
  2. 如果是 FULFILLED 状态,则 Promise 不能转换成任何其它状态。
  3. 如果是 REJECTED 状态,则 Promise 可以不能转换成任何其它状态。

Promise 提供了可以链式调用的 then 方法,允许我们在执行完上一步操作后(Promise 从 PENGDING 到 FULFILLED 状态的时候)再去调用 then 方法。

const p = (value) => {
    return new Promise((resolve, reject) => {
        if (value > 99) {
            resolve(value);
        } else {
            reject('err');
        }
    })
}
p(100).then(value => {
    console.log(value); // 100
    return value + 10;
}).then(value => {
    console.log(value); // 110
    return value + 10;
})

p(90).then(value => {
    console.log(value);
    return value + 10;
}).catch(err => {
    console.log(err); // err
})

需要注意的是,Promise 的链式调用并非是像 jQuery 中通过在 then 函数中 return this 来实现的,而是每次 return 了一个新的 Promise 对象,这是因为 Promise 的状态是不可逆的。 在上一个 then 回调函数中每次 return 出来的值会作为下一个 then 函数回调的参数传入。 如果 return 的是一个 Promise 对象,那么 then 方法就会等这个 Promise 执行完成后再去执行回调。 猜猜下面这段程序的执行情况?

const sleep = (time) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("success");
        }, time)
    })
}
const promise1 = sleep(100);
promise1.then(function onFulfilled1(value) {
    const promise2 = sleep(1000);
    return promise2;
})
.then(function onFulfilled2(value) {
    console.log("success");
})

promise1 会在 100ms 之后变成 FULFILLED 状态,这时会调用 onFulfilled1 函数。在 onFulfilled1 中,我们最后又返回了一个 promise2。 那么这里的 onFulfilled2 什么时候会执行呢?是在 onFulfilled1 执行之后立即执行吗?当然不是。 onFulfilled2 在 promise2 的状态变为 FULFILLED 之后才会执行,这也是因为 then 方法的实现中每次会创建一个新的 promise,实际上第二个 then 就是这个新的 promise 调用的。 而这个 promise 会等当前 then 方法中返回的 promise2 状态变为 FULFILLED 之后才会调用下一个 then 中的回调。

注意: 当你传给 then 的不是一个函数,而是一个值,那么这个值就会被透传给下一个 then。

new Promise((resolve) => { 
    resolve(111)
})
.then(2222)
.then(v => {
    console.log(v) // 111
})

5.1 Promise 处理异步

我们用 Promise 来重写上述【回调地狱】红绿灯的代码:

const greenAsync = (time) => {
    return new Promise((resolve) => {
        green(time, function() {
            resolve()
        })
    })
}
const redAsync = (time) => {
    return new Promise((resolve) => {
        red(time, function() {
            resolve()
        })
    })
}
greenAsync(60).then(() => {
    return redAsync(60)
}).then(() => {
    return greenAsync(60)
}).then(() => {
    return redAsync(60)
})

我们可以看到,经过 Promise 包装之后的代码,代码实现和回调函数分离开来,完美解决了函数嵌套带来的高度耦合。 通过 Promise,我们还获得了回调函数的控制权,我们可以规定在什么时候执行、执行多少次,这样就完美解决了信任问题。 Promise 还解决了函数嵌套带来的错误调试难的问题。Promise 提供了 catch 方法,可以用来捕获回调函数执行时抛出的异常。 我们再来进一步改造一下上面的 time 函数。

const cb = () => {
  console.log('success')
}
const time = (time) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            try {
                console.log(aaaa); // aaaa未定义
                resolve()
            } catch (err) {
                reject(err)
            }
        }, time * 1000)
    })
}
time(60).then(cb).catch(err => {
    console.log('err', err)
})

5.2 Promise.all 和 Promise.race

对于上述所说的需要等待多个请求成功后再去执行某个操作,Promise 还提供了 all 这个静态方法,all 接收一个 Promise 数组作为参数,而 它的 then 回调函数中返回的也不是一个值,而是一个数组。 很明显,这个数组就是前面 Promise 数组返回值的集合。 Promise 会在所有 Promise 都变为 FULFILLED 状态后执行 then 中的回调,然而在有任何一个 Promise 变为 REJECT 状态后就会去立即调用 catch 方法。

Promise.all([ajaxA, ajaxB, ajaxC]).then(dataArr => {
    // 这里的dataArr是每个请求返回结果的集合
})

除了 Promise.all 之外,还提供了一个 Promise.race 的静态方法。这个方法的执行方式和 Promise.all 完全相反。 Promise.race 意思是赛跑,只要有一个 Promise 状态变为 FULFILLED,它就会立即执行 then 的回调,这点儿和 Promise.all 完全相反。

Promise.race([ajaxA, ajaxB, ajaxC]).then(value => {
    // 哪个返回的快,value 就是哪个
})

6. Promise 原理

Promise 并非是什么神秘莫测的 api,我们这里通过分析一个基于 PromiseA+ 标准的 Lie.js 库来理解它的原理。

6.1 Promise 类

首先,从调用方式上来看。Promise 对象通常都是使用 new 操作符来创建,可以得知 Promise 一定是一个类。 然后,这个类可以有三种状态,那么一定有一个 state 属性来保存当前的状态。 同时,Promise 的构造函数接收了一个函数,并给这个函数传入了 resolve 和 reject 两个参数。 除此之外,我们还需要保存当前操作执行后返回的 value 或者抛出的异常,以便于将其传给 then 或者 catch 的回调。

const REJECTED = 'REJECTED';
const FULFILLED = 'FULFILLED';
const PENDING = 'PENDING';
class Promise {
    static all() {}
    static race() {}
    constructor(resolver) {
        this.state = PENDING;
        this.outcome = void 0;
        this.queue = [];
        safelyResolveThenable(this, resolver);
    }
    then() {}
    catch() {}
}

接下来,我们来处理这个构造函数。在构造函数中,我们会调用 resolve 或者 reject,以此来改变 Promise 的状态。这一步在 lie 中是这样实现的。这里的 handlers.onResolve 之后再做分析。

function safelyResolveThenable(promise, resolver) {
    let isCalled = false; // 来控制只有一种状态
    function onError(error) {
        if (isCalled) return;
        called = true;
        handlers.onError(promise, error);
    }

    function onResolve(value) {
        if (isCalled) return;
        called = true;
        handlers.onResolve(promise, value);
    }

    try {
        thenable(onSuccess, onError);
    } catch (err) {
        onError(err);
    }
}

6.2 延迟执行

我们又可以知道,如果在构造函数中设置了 setTimeout,规定在一定时间后才会进行 resolve,这个时候 Promise 的 then 方法已经执行了,那么怎么保证 then 回调函数是在 1000ms 之后才被执行的呢。

new Promise((resolve) => {
    setTimeout(() => {
        resolve(1111)
    }, 1000)
}).then(value => {
    console.log(value); // 1111
})

这种延迟执行有没有让你想到经常用到的发布-订阅?我们先将函数放到一个数组中,等到合适的时机再拿出来执行。 所以我们可以用一个 queue 队列来保存注册的 then 回调函数,等到 setTimeout 执行结束后会执行这个回调函数。 我们可以借助下面这个代码简单地理解一下:

const promise = new Promise((resolve) => {
  setTimeout(() => {
    resolve('success')
  }, 1000)
})
var p = promise.then(function() {})
var q = promise.catch(function() {})
console.dir(promise)

打印出来的 promise 大概应该是这种结构(当然如果你用 ES6 的 Promise 是看不到这种结构的)。

Promise {
  state: 0,
  outcome: undefined,
  queue:
   [ QueueItem {
       promise: Promise { state: 0, outcome: undefined, queue: [] },
       callFulfilled: [Function],
       callRejected: [Function] },
     QueueItem {
       promise: Promise { state: 0, outcome: undefined, queue: [] },
       callFulfilled: [Function],
       callRejected: [Function] } ] }

而 p 就是 promise.queue[0].promise 这个对象。

6.3 then

那么如何实现注册回调函数呢?我们可以在 then 方法中,判断当前 Promise 状态是否为 PENDING,如果还是 PENDING,就将回调函数放到当前的 Promise 对象的 queue 数组 里面。

function INTERNAL() {}

Promise.prototype.then = function (onFulfilled, onRejected) {
  // 如果传入的 onFulfilled 不是函数,就将当前 Promise 的值透传给下一个 then
  if (typeof onFulfilled !== 'function' && this.state === FULFILLED ||
    typeof onRejected !== 'function' && this.state === REJECTED) {
    return this;
  }
  // 创建了一个新的 promise 对象
  var promise = new this.constructor(INTERNAL);
  // 如果当前还是 PENDING 状态,那么就需要放到队列中,否则就直接执行。
  if (this.state !== PENDING) {
    var resolver = this.state === FULFILLED ? onFulfilled : onRejected;
    // 这里的 outcome 就是 Promise 的值
    unwrap(promise, resolver, this.outcome);
  } else {
    this.queue.push(new QueueItem(promise, onFulfilled, onRejected));
  }
  return promise;
};

从上面可以看到,如果当前已经不是 PENDING 状态了,那么就会将执行的结果和回调函数一起传给 unwrap 方法。 而 unwrap 方法的源码如下:

function unwrap(promise, func, value) {
  immediate(function () {
    var returnValue;
    try {
      returnValue = func(value);
    } catch (e) {
      return handlers.reject(promise, e);
    }
    if (returnValue === promise) {
      handlers.reject(promise, new TypeError('Cannot resolve promise with itself'));
    } else {
      handlers.resolve(promise, returnValue);
    }
  });
}

unwrap 方法比较简单,总之就是执行前面传给 then/catch 的回调函数,将执行后的结果再传给 handlers.resolve 方法。这里还引入了一个叫 immediate 的库,这个 immediate 其实是个异步方法,这也是为什么 then 方法是异步的。 前面的 safelyResolveThenable 里面也用到了 handlers.resolve,那么我们来看一下这个方法的实现。

handlers.resolve = function (self, value) {
  var result = tryCatch(getThen, value);
  if (result.status === 'error') {
    return handlers.reject(self, result.value);
  }
  var thenable = result.value;
  // 如果传来的value是个promise,那么就重新走一遍safelyResolveThenable
  if (thenable) {
    safelyResolveThenable(self, thenable);
  } else {
    self.state = FULFILLED;
    self.outcome = value;
    var i = -1;
    var len = self.queue.length;
    while (++i < len) {
      self.queue[i].callFulfilled(value);
    }
  }
  return self;
};

tryCatch(getThen, value) 这一步是获取 value 上面的 then 方法,相当于 result.value = value.then,所以这里是用来判断传入的 value 是否为一个 Promise 对象。 可以看到,如果 thenable 存在,即当前传入的依然是个 Promise,那么就会再次调用 safelyResolveThenable 这个方法。 前面我们讲过,then 中会创建一个新的 Promise,这个 Promise 状态的改变是依据 then 函数返回的 Promise 来的。所以再次调用 safelyResolveThenable 就是为了根据 thenable 执行后的结果来修改 self 的状态。 如果你觉得比较难懂,那我就用一段代码来讲解这个。

const sleep = () => {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(111)
        }, 1000);
    })
}
new Promise(r => r(222)).then(value => {
    return sleep();
})

这段代码中,一共涉及到了三个 Promise,分别是 new Promise、then 中创建的 Promise、sleep 返回的 Promise。 在执行 then 函数的时候,会新创建一个 Promise2。在 1000ms 之后,第一个 Promise1 状态变成 FULFILLED,再去调用 then 中的回调函数,而 sleep 中返回了一个新的 Promise3。这里的 Promise2 会在 Promise3 状态变成 FULFILLED 之后再变成 FULFILLED。

6.4 queue

如果不存在 thenable,也就是说 resolve 接收到的或者 then 返回的不是一个 Promise 对象。 可以看到这里将 state 置为了 FULFILLED 状态,并且遍历并执行当前 promise 对象上挂载的 queue 队列里面的 callFulfilled 方法。 那么 queue 队列是又是怎么实现的呢?从上面我们可以看到有个 callFulfilled 方法。

function QueueItem(promise, onFulfilled, onRejected) {
  this.promise = promise;
  // 如果当前队列存的是then回调函数
  if (typeof onFulfilled === 'function') {
    this.onFulfilled = onFulfilled;
    this.callFulfilled = this.otherCallFulfilled;
  }
  // 如果当前队列存的是catch回调函数
  if (typeof onRejected === 'function') {
    this.onRejected = onRejected;
    this.callRejected = this.otherCallRejected;
  }
}
QueueItem.prototype.callFulfilled = function (value) {
  handlers.resolve(this.promise, value);
};
QueueItem.prototype.otherCallFulfilled = function (value) {
  unwrap(this.promise, this.onFulfilled, value);
};
QueueItem.prototype.callRejected = function (value) {
  handlers.reject(this.promise, value);
};
QueueItem.prototype.otherCallRejected = function (value) {
  unwrap(this.promise, this.onRejected, value);
};

当我们把 then 的回调函数存到队列中时,callFulfilled 方法其实就是 otherCallFulfilled 方法,otherCallFulfilled 方法依然是调用的 unwrap 方法。 结合前面讲过 unwrap 的实现,很明显这里就是修改 this.promise 的状态,并将 value 挂载到它的上面,这也是为什么 then 的回调函数会一直等待 Promise 状态改变后才执行。

这样一个基本的 Promise 实现原理就很清晰了,其实主要就是三种状态转换,配合队列来实现 then 的延迟执行。

7. 总结

这篇文章我们介绍了 JS 异步编程中最常用的回调函数和 Promise 这两种方式。我们可以通过 Promise 将繁琐的回调函数给封装的更加简洁,以此来增强代码的可读性。 当然 Promise 并不是解决异步的终极方案,也有自己的弊端,下篇文章我会介绍另两种解决方案 generator 和 await。

推荐阅读

  1. 深入 Promise(一)——Promise 实现详解
  2. 深入 Promise(二)——进击的 Promise
  3. 深入 Promise(三)——命名 Promise