zhangxiang958 / Blog

阿翔的个人技术博客,博文写在 Issues 里,如有收获请 star 鼓励~
https://github.com/zhangxiang958/zhangxiang958.github.io/issues
152 stars 11 forks source link

promise 杂谈 #42

Open zhangxiang958 opened 5 years ago

zhangxiang958 commented 5 years ago

本篇文章是笔者近期对 Promise 的几点思考的总结。

Promise 是如何解决回调地狱问题的?

所谓回调地狱,可能它的危害并不全在于由于过多的嵌套函数导致日渐增长的缩进最终会超过你的屏幕宽度,而是它剥夺了我们编写 try-catch,throw 这样的代码的权利,并且会有调用信任的问题。

使用回调函数调用第三方服务或者模块,回调函数的执行权会交给了第三方,可能出现的问题就会可能有多次调用,回调时间不稳定(过早或过晚)等等的问题,虽然这些问题我们可能通过其他手段进行解决,比如通过一个 flag 值来控制调用次数的问题:

let called = false;

rpc('https://path/to/service', (err, res) => {
    if (called) return;
    // do somthing with res
});

但是这样始终非常不方便,代码中也会出现很多不必要的副作用,往往会给我们代码设计带来麻烦。

而 Promise 的出现,通过控制反转的方法,让回调函数只做将它本身的返回值返回的操作,然后让 Promise 将这个值存起来,并通知所有需要知道此函数调用成功的函数。

new Promise((resolve, reject) => {
    rpc('https://path/to/service', (err, res) => {
        resolve(res);
    });
}).then((res) => {
    // do somthing with res
});

这样,有利于隔离副作用,基于回调函数的返回值与代码逻辑不再全部放在第三方调用的回调中了。

而对于回调函数执行的时间不稳定问题,由于使用第三方模块的时候,回调函数的执行时机对于我们是一个黑盒,如果模块的设计者不加注意,很容易会导致 release zalgo 问题,也就是如下代码:

let urls = {};

function rpc (url, callback) {
    if (urls[url]) {
        return callback(urls[url]);
    }
    request(url, (err, res) => {
        urls[url] = res;
        callback(err, res);
    }));
}

如果这样实现第三方模块,那么这里的回调函数调用时机是不确定的,有可能是同步调用,有可能是异步调用,有时候这里会导致一些难以追踪的 bug

但是使用了 Promise 进行包裹,不需要担心这个问题,因为 then 函数始终都是异步执行的。因为我们执行 new Promise 函数中的 resolve 函数的时候,内部实现是:

// promise 的简单实现
function _resolve(value){
    if (self.state === 'pending') {
       nextTick(() => { // nextTick 代表异步执行,可以是 process.nextTick, 也可以是 setTimeout
           self.state = 'fulfilled';
           self.value = value;
           // 批量执行通过 then 函数添加的成功回调,回调函数存放到一个数组中
           self.resolveQueue.forEach(cb => {
               cb(value);
           });
       });
    }
}

所以我们在取得回调函数返回值的 then 函数始终是被异步执行的,无需担心 release zalgo 问题。

并且由于 Promise 的内部实现,不会出现多次调用回调函数的问题,原因在由于 Promise 本身是一个有穷状态机,本身包含 pending, fulfilled, rejected 三种状态,并且从 pending 状态到 fulfilled 状态与 pending 状态到 rejected 状态不可逆,所以上面的回调函数中,即使写成:

....
rpc('https://path/to/service', (err, res) => {
    if (err) reject(err);
    resolve(res);
});
....

也不会影响实际代码的运行,如果出现了 err, 会先运行 reject 函数, Promise 的状态就会变为 rejected 状态,后面的 resolve 函数执行会被忽略,代码变得可靠很多。

另外,在 Promise 中,你甚至可以 resolve 一个 PromiseLike 的对象,也就是一个包含 then 方法的对象,根据 Promise/A+ 规范的实现,在执行这种 PromiseLike 对象的 then 方法的时候,会对传入的 resolve,reject 方法的执行次数进行控制:

....
// 此为本人根据 Promise/A+ 规范实现部分代码
let called = false;
// x 表示 上一个 Promise resolve 传入的值
if ((x !== null && typeof x === 'object') || typeof x === 'function') { // 如果是对象或者是函数
    try {
        // 这里是鸭子模型,只要有 then 方法就尝试执行
        let then = x.then;
        if (typeof then === 'function') {
             // 说明是一个 thenable 对象
            then.call(x, y => {
                // 避免 then 函数中多次执行第一次传入的函数(即此函数),如多次执行则以第一次执行为准
                 // 而且如果 reject 函数先执行,那么这个函数的执行会被忽略
                if (called) return;
                called = true;
                resolvePromise(promise, y, resolve, reject);
            }, err => {
                // 避免 then 函数中多次执行第二次传入的函数(即此函数),如多次执行则以第一次执行为准
                // 而且如果 resolve(resolvePromise) 函数先执行,那么这个函数的执行会被忽略
                if (called) return;
                called = true;
                return reject(err);
            });
        } else {
            resolve(x);
        }
    } catch (err) {
        // 如果 then 方法中有异常,也需要将 promise 置为 reject
        // 但是为了防止前面 then 函数中 resolve(resolvePromise) 与 reject 已经被执行过后
        // then 方法执行又出错,添加 called 值来判断,如果已经执行过 resolvePromise/reject 方法
        // 那么这里 catch 到的错误会被忽略
        // 否则以此错误来 reject Promise
        if (called) return;
        called = true;
        reject(err);
    }
} else {
    ...
}
....

其实,called 变量判断回调执行与否是为了防范 thenable 对象的实现不符合 Promise 的机制,例如:

let p = new Promise((resolve) => {
    resolve();
});

p.then(() => {
    return {
        then(a, b) {
            a(); // 会执行一次 reoslve(resolvePromise)
            a(); // 同样会执行 resolve 函数,但是后续逻辑被 called 拦截了,不会真正执行
            b(); // 执行 reject 函数,但是被 called 拦截了
            throw 'real?'; // 这里会使 try-catch 中 catch 后的代码执行,但是同样被 called 拦截
        }
    };
});

对于超时问题,Promise 还提供了 race 方法,让我们的调用可以方便地添加超时机制,让代码尽可能在可控的范围内。

let rpcSync = new Promise((resolve, reject) => {
    rpc('https://path/to/service', (err, res) => {
        if (err) return reject(err);
        resolve(res);
    });
});

Promise.race([
    rpcSync('http://path/to/service'),
    new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
        }, 5000);
    });
]);

注:函数副作用指调用函数时除了返回函数值还会修改函数外的变量

composing Promise

我们在使用 Promise 的时候,应当充分利用 Promise 的优势, 不能将 Promise 当成回调函数来使用,避免像以下的代码:

UserOrm.get(id).then((data) => {
    ...
    SchoolOrm.get(data.school).then((school) => {
        ...
        ChairManOrm.get(school.chairMan).then((chairMan) => {
            .....
        }); 
    });
});

当然,Promise 通过上面阐释的多种机制优化了很多回调函数存在的问题,上面的代码可能会比单纯使用回调函数要好,但是这样的代码风格很糟糕,如果业务逻辑复杂,会让编写者非常难受,而其实我们可以借助 Promise,让其他 Promise 进行链接。

UserOrm.get(id)
.then((data) => {
    return SchoolOrm.get(data.school);
})
.then((school) => {
    return ChairManOrm.get(school.chairMan);
})
.then((chairMan) => {
    ....
})
.catch((err) => {

})

这样的链式调用会比第一个例子中的 then 地狱要好得多,我们只需要在 then 函数里面返回另一个 Promise,那么在下一个链式的 then 函数中就能拿到返回的 Promise 的输出结果。

而原理在于,根据 Promise/A+ 规范,then 函数的内部处理中,如果 then 函数传入的第一个函数的返回值是一个 Promise 的时候,会根据 Promise Resolution Procedure 的处理,递归地为返回的 Promise 添加 then 函数处理,直到返回结果不是 Promise 对象,并以此值作为上一层 Promise 的返回结果。

而对于返回非 Promise 值,then 函数也会直接将这个返回值直接传递给下一个 Promise,这样就有利于我们写出利用缓存结果的函数了:

getUrl().then((url) => {
    if (urls[url]) {
        return resolve(urls[url]);
    }
    return rpc(url); // rpc 返回一个 promise
}).then((data) => {
    // get data
});

上面这个函数利用了内存结果缓存而不用担心上面所说的 release zalgo 问题,因为后一个 then 函数一定是异步被执行的。另外由于 Promise 内部对 then 函数传入的函数都添加了 try-catch 处理,所以我们在 then 函数中直接 throw 一个错误,那么这个错误会被捕获到,并以这个错误作为 Promise 的 reject 原因传给下一个 Promise。

getUrl().then((url) => {
    if (/^http|https/.test(url)) throw new Error('url Error');
    if (urls[url]) {
        return resolve(urls[url]);
    }
    return rpc(url); // rpc 返回一个 promise
}).then((data) => {
    // get data
})
.catch((err) => {
    // get err
});

后面的 catch 函数会得到在 then 函数 throw 出来的错误。

所以,我们在 then 函数里面,最好显式地 return 一个值或者 throw 一个错误,这样我们可以让 then 函数的行为符合我们的预期。

promise 值穿透问题

所谓 promise 值穿透问题就是当你在调用 then 函数的时候,传入的参数不是一个函数的话,那么传入的参数会被忽略,并返回上一个 Promise 的返回结果。

Promise.resolve(1).then(Promise.resolve(2)).then((res) => { console.log(res); }); // 1

这是因为在 Promise 中,then 函数会有传入的值类型判断:

then (onFulfilled, onReject) {
    if (typeof onFulfilled !== 'function') onFulfilled = (res) => res;
    if (typeof onReject !== 'function') onReject = (err) => throw err;
    ...
}

当传入值非函数的时候,内部会赋予一个默认的返回 Promise 返回结果的函数,因此当我们在调用 then 函数的时候一定需要注意 then 函数是接受函数作为参数的。

批量 Promise

在业务场景中,我们会常常遇到需要批量执行一些异步操作,通常数据会存放在一个数组中,但是如果我们直接循环数组,然后希望在这些异步操作都结束之后做某些操作,在编写代码的时候不加注意,会容易写出这样的代码:

orm.getList().then((datas) => {
    datas.forEach(({id}) => {
        orm.delete(id);
    });
})
.then(() => {
    console.log('all list be deleted');
});

实际上,最后一个 then 函数的执行时机并不等于所有列表数据都被删除之后的时刻。按照逻辑,真正的代码应该是:

orm.getList().then((datas) => {
    let promises = datas.map(({id}) => {
        return orm.delete(id);
    });

    return Promise.all(promises);
})
.then(() => {
    console.log('all list be deleted');
});

借助 Promise.all 来达到批量的 Promise 都执行完后再去执行某些逻辑的目的。

而如果在服务端,有一些批量异步操作可能由于数据列表过长,不希望如此大量的异步操作同一时间进行,那么我们就需要控制同一时间内可进行的异步操作的数量:

// 一个控制 Promise 并发量的 demo
function parallel (fn, con, thisArg) {
    let jobs = [];
    let current = 0;
    let next = () => {
        let job = jobs.shift();
        if (job) {
            current ++;
            let { args, resolve, reject } = job;
            fn.apply(thisArg, args).then((res) => {
                current --;
                next();
                resolve(res);
            }, (err) => {
                current --;
                next();
                reject(err);
            });
        }
    };
    return function (...args) {
        if (current < con) {
            current ++;
            return new Promise((resolve, reject) => {
               return fn.apply(thisArg, args);
            }).then(res => {
                current --;
                next();
                return res;
            });
        } else {
            return new Promise((resolve, reject) => {
                jobs.push({
                    resolve,
                    reject,
                    args
                });    
            });
        }
    }
}

let executor = parallel(function (data) {
    // dom something with data
    return asyncOperation(data); // asyncOPeration 返回一个 Promise
}, 10);

Promise.all(datas.map(executor)).then(() => {
    console.log('all done');
});

async/await 时代

async/await 是谁的语法糖?

这个问题见仁见智,实现过 co 库的更熟悉 generator 运行机制的也许会说是生成器函数与 yield 操作符的语法糖,但是更熟悉 Promise 运行机制的也许会说是 Promise 的语法糖。

(async function (){
    await new Promise((resolve) => {
        setTimeout(() => {
            resolve(1);
        }, 5000);
    });
})();

所谓语法糖,是以某种对功能没有影响的语法,方便程序员调用并使程序更简洁。

所以在生成器角度来看,async/await 提供了类似 co 库的自动运行 generator 对象的 next 方法的机制,达到异步控制流的目的。

function _asyncToGenerator(fn) { 
    return function () { 
        var gen = fn.apply(this, arguments);
        return new Promise(function (resolve, reject) {
            function step(key, arg) {
                try {
                    var info = gen[key](arg);
                    var value = info.value;
                } catch (error) { 
                    reject(error);
                    return;
                }
                if (info.done) { 
                    resolve(value);
                } else { 
                    return Promise.resolve(value).then(function (value) {
                        step("next", value);
                    }, function (err) { 
                        step("throw", err);
                    });
                }
            }
            return step("next");
        });
    };
}

_asyncToGenerator(function* () {
    yield new Promise(function (resolve) {
        setTimeout(function () {
            resolve(1);
        }, 5000);
    });
})();

*注:以上代码通过 babel 插件 babel-plugin-transform-async-to-generator 进行转化

在 Promise 的角度,async/await 提供了使某个函数统一返回值为 Promise,并且提供 then 方法的方便调用方式。

Promise.resolve().then(function () {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(1);
        }, 5000);
    });
}).then(function () {});

*注:以上代码通过 babel 插件 babel-plugin-async-to-promises 进行转化

可以看作 async 操作符内部创建了一个 Promise 对象,await 只能在 async 函数中使用,可以理解为 then 方法只能在 Promise 对象之后调用。

但是模拟与实现是两码事,从实现上来说,个人认为 async/await 更偏向于 Promise 的实现,因为在 node 中原生的 async/await 与 Promise 几乎是一样快的。

async/await 内部干了些什么事情?

对于 async 操作符来说,它基本功能就类似下面的代码:

async function test () {
    return 1;
};

// 经过转化后
function test () {
    return Promise.resolve(1);
};

也就是说,async 操作符执行后会返回一个 Promise。

那么 await 做了什么事情呢?根据 tc39 的 async/await 实现规范来讲,它类似于以下这幅图:

那么我们自行来翻译一下,将左边 foo 函数使用我们 Promise 语法来表达右边的意思:

function foo (v) {
    let implicit_promise = new Promise((resolve, reject) => {
        let promise = new Promise((resolve, reject) => {
            resolve(v);
        }).then(w => {
            resolve(w)
        });
    });

    return implicit_promise;
}

首先,只要使用了 async 声明的函数,内部会创建一个隐式 promise,也就是所谓的 implicit_promise,然后 await 会将传入的 v 包裹成一个新的 Promise,对于 w 的赋值操作与后面返回 return w 这些,都放在了这个新建的 Promise 的 then 函数逻辑中了。

注意,在翻译实现的时候,故意忽略了一个 Promise 的创建也就是 throwaway Promise 这个 promise,这个 promise 是我们翻译的噪音,不需要理会,因为它本身的作用是底层引擎为了兼容适应 API 的实现,创建出来的一个 Promise,它本身并没有其他作用。

所以我们知道了,其实 async/await 更多地是给我们提供语法糖,优化了 Promise 的 then 调用链的语法使用,而对于 await 后面,它接受的是一个表达式,至于传入的 v 是一个基本类型的值还是一个 Promise 对象,它都会统一包裹一层 Promise。

对于传入值 v 为一个 Promise 的时候,我们在调用 resolve 函数的时候传入一个 Promise,最终我们在 then 函数中拿到的是传入的 Promise 的返回结果,这部分的实现逻辑与 Promise A+ 规范有关,这里不再阐述。

当然,这里是可以进一步优化的,当我们传入的 v 是一个 Promise 的时候,我们其实不需要额外包裹一层 Promise,而这个优化点也就是 node@V8 到 node@V10 的对于 async/await 的优化所在。优化的手段类似如下代码:

function foo (v) {
    let implicit_promise = new Promise((resolve, reject) => {
        Promise.resolve(v).then(w => {
            resolve(w);
        });
    });

    return implicit_promise;
}

即是用 Promise.resolve(v) 来代替 new Promise(res => res(v))。因为在 Promise.resolve 中如果传入值为另一个 Promise,会将这个传入的 Promise 直接返回:

let p = new Promise(res => res(1));

let testP = Promise.resolve(p);

testP === p // true

多个 async/await 的执行顺序

曾经看过一个题目,代码如下:

async function first() {
  console.log('first start');
  await second();
  console.log('first end');
}

async function second() {
  console.log('second');
}

console.log('start');
setTimeout(() => {
  console.log('setTimeout');
});

first();

new Promise((resolve, reject) => {
  console.log('promise1');  
  resolve();
}).then(res => {
  console.log('then');
});

// 结果
// start
// first start
// seond
// promise1
// then
// first end
// setTimeout

对于结果来说,如果你已经理解了上面章节所说的 async/await 底层原理,那么就很容易写出类似下面我对上面代码进行翻译的代码:

function first () {
    const implicit_promise = new Promise((resolve, reject) => {
        console.log('first start');
        second().then((res) => {
            resolve();
        }).then(() => {
            console.log('first end');
        });
    });

    return implicit_promise;
}

function second () {
    const implicit_promise = new Promise((resolve, reject) => {
        console.log('second');
        resolve();
    });

    return implicit_promise;
}

console.log('start');
setTimeout(() => {
  console.log('setTimeout');
});

first();

new Promise((resolve, reject) => {
  console.log('promise1');  
  resolve();
}).then(res => {
  console.log('then');
});

这两段代码在 chrome 浏览器 71.0.3578.98 下运行结果是一样的。

以上就是我对于 Promise 以及基于 Promise 的语法的一些思考与见解,希望大家看完都能有所收获。

附录

  1. https://v8.js.cn/blog/fast-async/
  2. https://github.com/xieranmaya/blog/issues/3
  3. 《you don't know javascript》
css-master commented 5 years ago

then 和 first end 的位置错了。

 second().then((res) => {
            resolve();
        }).then(() => {
            console.log('first end');
        });

这里应该改为

 second().then((res) => {
      console.log('first end');
})
alexya commented 1 year ago

@css-master 我觉得楼主的写法应该是正确的。 否则

// 结果
// start
// first start
// seond
// promise1
// then
// first end
// setTimeout

中的 first end 就会跑到 promise1 前面去了。