Promise 简约用法,像同步函数一样书写加载逻辑

此文算初级内容,主要给新玩家提供一个易用的异步处理方案

一句话总结:完全舍弃 Promise 的 reject 和 catch 功能,只用 resolve。

这个做法是有弊端的,那就是如果 Promise 里报错的话,函数会卡住不继续往下执行。

但是换个角度想,同步函数如果执行过程中报错一样会卡住。我们需要做的是从业务层面做好判空处理,而不是依靠 Promise 来做不可靠的兜底。

具体优劣对比我放在文末,下面先举3个例子看看具体实现。

举例1:串行加载3张图片并显示

假设我们需要加载3张图片,并在加载成功后显示出来。

下面直接上代码,主体流程控制函数是 _loadAndShowPics,异步加载函数是 _loadBundle 和 _loadImage。可以看到三个函数看起来跟同步函数差不多,可读性非常好。

    private async _loadAndShowPics(): Promise<void> {
        // 先加载 bundle
        let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
        if (bundle == null) {
            return;
        }

        // 加载并显示图片1
        let image1: cc.SpriteFrame = await this._loadImage(bundle, 'image1');
        image1 && (this.pic1.SpriteFrame = image1);

        // 加载并显示图片2
        let image2: cc.SpriteFrame = await this._loadImage(bundle, 'image2');
        image2 && (this.pic2.SpriteFrame = image2);

        // 加载并显示图片3
        let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
        image3 && (this.pic3.SpriteFrame = image3);
    }

    /** 加载 bundle */
    private async _loadBundle(name: string): Promise<cc.AssetManager.Bundle> {
        return new Promise((resolve) => {
            cc.assetManager.loadBundle(name, (err: Error, res: cc.AssetManager.Bundle) => {
                if (err) {
                    console.log(`加载bundle失败,name: ${name}, err: ${err}`);
                }

                resolve(res);
            });
        });
    }

    /** 加载图片 */
    private async _loadImage(bundle: cc.AssetManager.Bundle, path: string): Promise<cc.SpriteFrame> {
        return new Promise((resolve) => {
            bundle.load(path, cc.SpriteFrame, null, (err: Error, res: cc.SpriteFrame) => {
                if (err) {
                    console.log(`加载图片失败, path: ${path}, err: ${err}`);
                }

                resolve(res);
            });
        });
    }

举例2:并行加载 image1、image2 并显示,然后加载 image3 并显示

同时加载是并行,Promise 实现并行需要用到 Promise.all,只需要把 _loadAndShowPics 修改成下面这样就好了,另外两个函数不用动。

    private async _loadAndShowPics(): Promise<void> {
        // 先加载 bundle
        let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
        if (bundle == null) {
            return;
        }

        // 加载并显示另外 图片1 和 图片2,此处的 await 会等 Promise.all 里面两个函数执行完之后才往下走
        let [image1, image2]: [cc.SpriteFrame, cc.SpriteFrame] = await Promise.all([
            this._loadImage(bundle, 'image1'),
            this._loadImage(bundle, 'image2')
        ]);

        image1 && (this.pic1.SpriteFrame = image1);
        image2 && (this.pic2.SpriteFrame = image2);

        // 加载并显示 图片3
        let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
        image3 && (this.pic3.SpriteFrame = image3);
    }

举例3:串行加载2张图片,并在第二张图片加载好的时候播一个缩放动画,播完再加载并显示第三张图片

    private async _loadAndShowPics(): Promise<void> {
        // 先加载 bundle
        let bundle: cc.AssetManager.Bundle = await this._loadBundle('image');
        if (bundle == null) {
            return;
        }

        // 加载并显示图片1
        let image1: cc.SpriteFrame = await this._loadImage(bundle, 'image1');
        image1 && (this.pic1.SpriteFrame = image1);

        // 加载并显示图片2
        let image2: cc.SpriteFrame = await this._loadImage(bundle, 'image2');
        image2 && (this.pic2.SpriteFrame = image2);

        // 播放缩放动画
          let scaleAction: Promise<void> = new Promise((resolve) => {
            cc.tween(this.pic)
                .to(1.5, { scale: 2 })
                .to(1.5, { scale: 1 })
                .call(() => {
                    resolve();
                })

                .start();
        });

        // 这个 await 会等动画播放完毕,然后才往下走
        await scaleAction;

        // 加载并显示图片3
        let image3: cc.SpriteFrame = await this._loadImage(bundle, 'image3');
        image3 && (this.pic3.SpriteFrame = image3);
    }

总结:

只需要实现下面这个函数结构,就可以轻松处理异步:

    // 定义函数
    function func(): Promise<void> {
        // 建议直接 return new Promise,不要掺杂业务代码,降低代码报错的可能性
        return new Promise((resolve) => {
            setTimeout(() => {
                /** 若干业务代码 */
           
                resolve();
            }, 1500);
        });
    }

    // 调用函数
    await func();

面对需要返回值的情况,有很多处理方法,我列举三种常用的

第一种:

    // 这是直接返回值的方法,相当于函数直接 return ret;
    function func(): Promise<string> {
        return new Promise((resolve) => {
            setTimeout(() => {
                let ret = '';

                resolve(ret);
            }, 1500);
        });
    }

    // 调用取值直接用等号即可
    let res: string = await func();

第二种:


    // 有时候不适合直接返回值,可以传一个对象进去接收返回值
    function func(data: { res: cc.Node }): Promise<void> {
        return new Promise((resolve) => {
            setTimeout(() => {
                let ret = new cc.Node();

                // 这是 js 的坑之一,原理与指针类似,此处不展开
                // 必须用 data.res = ret 才能让外部 data 正确取到值
                // 如果写成 data = ret 的话,外界的 data 不变
                data.res = ret;

                resolve();
            }, 1500);
        });
    }

    // 创建一个变量接收返回值
    let data: { res: cc.Node } = { res: null };
    await func(data);
    // 执行完之后 data.res = 一个node

第三种:

    // 这是传统的传入回调函数法
    function func(callback: (res: cc.Node) => void): Promise<void> {
        return new Promise((resolve) => {
            setTimeout(() => {
                let ret: cc.Node = new cc.Node();
                callback && callback(ret);
                resolve();
            }, 1500);
        });
    }

    let callback: (res: cc.Node) => void = (res: cc.Node) => {
        this._picNode = res;
    };
    await func(callback);

这种写法的利弊分析

前面说过,这个做法是有弊端的,那就是如果 Promise 里报错的话,函数会卡住不继续往下执行。我们用下面这个函数来分析一下

    async function main(): Promise<void> {
        let test1: number = await func();
        console.log('流程点1, 返回值:', test1);

        let test2: number = await func(test1);
        console.log('流程点2, 返回值:', test2);
    }

    function func(index: number = 0): Promise<number> {
        a.age = 100;    // 报错点1

        return new Promise((resolve) => {
            a.age = 100;    // 报错点2

            setTimeout(() => {
                a.age = 100;    // 报错点3

                index++;
                let ret = index;

                resolve(ret);
            }, 1500);
        });
    }

所谓函数卡住,在这里可以理解为无法运行到 main 函数的 流程点1 和 流程点2。

针对 func 函数的3个报错点,我们逐一分析:

报错点1:此时未生成 Promise,需要 try catch 捕获错误并返回一个只执行 reject 的 Promise

报错点2:已生成 Promise,需要用 Promise.catch 捕获

报错点3:异步执行后报错,Promise.catch 无法捕获,需要用 try catch 捕获错误后调用 reject 返回错误

改造后的函数如下:

    async function main(): Promise<void> {
        let test1: number | void = await func().catch((e) => { console.log(e); });
        console.log('流程点1, 返回值:', test1);

        let test2: number | void = await func(test1 as number).catch((e) => { console.log(e); });
        console.log('流程点2, 返回值:', test2);
    }

    function func(index: number = 0): Promise<number> {
        try {
            a.age = 100;    // 报错点1

            return new Promise((resolve, reject) => {
                a.age = 100;    // 报错点2

                setTimeout(() => {
                    try {
                        a.age = 100;    // 报错点3

                        index++;
                        let ret = index;

                        resolve(ret);
                    } catch (error) {
                        reject(error);
                    }
                }, 1500);
            });
        } catch (error) {
            return new Promise((resolve, reject) => { reject(error); });
        }

    }

哪怕不考虑大量使用 try catch 可能带来的问题,改造后的代码在可读性上也差了很多,写起来又很繁琐,很难在项目中实际推行。

并且 2.4.6 和 2.4.7 使用 Promise.catch 捕获错误的时候无法定位到报错代码的堆栈,只能定位到 .catch 的位置,比较影响调试。

通常情况下异步报错往往是这种情况:

加载 image1,然后调用 image1.show(),在 image1 加载失败的时候就报错了。或者网络请求数据 data,然后调用 data.xxx 的时候报错。

这种情况我称之为业务逻辑,完全可以在业务代码中用判空处理来解决,并且在构造异步函数的时候尽量遵循单一原则,不相关的逻辑不要放在异步函数内部。

全文完。

15赞

hahaha,一直都是用 resolve,报错就返回空,判断非空就好了

同不用 reject,但是有时候 catch 是有必要的。

膜拜大佬~

确实,三方库之类不可控性比较高的地方要接一下catch

我个人比较喜欢resolve和reject都用,reject一般在低网速或者断线重连时,做重连,或者加载资源失败时再次请求加载资源,,,,,我觉得成功和失败还是成对出现的好,,

我觉得 catch 作为错误捕捉有时候还是有必要的,但是 reject 真想不出应用场景。
在我的理解里,promise 是没有 “成功” 和 “失败” 概念的,promise 作为语法糖应该只有 “出错” 和 “继续” 两个功能,是否成功属于业务逻辑,应该用业务的方法解决,resolve 作为 “下一步” 更好理解。
用 reject 的话跟报错一样都走 Promise.catch,意味着 “业务失败” 和 “代码报错” 走同一个逻辑,个人觉得不太妥当。

下面这个是不用 reject 的请求实现方式。

async function main(): Promise<void> {
            await tryGet();
        }

        async function tryGet(): Promise<string> {
            let ret: string = null;

            for (let i = 0; i < 4; i++) {
                let data: string = await _doGet();
                if (data != null) {
                    console.log(`网络请求成功,data:${data}`);

                    ret = data;
                    break;
                } else {
                    console.log(`网络请求失败,重试第${i + 1}次`);
                }
            }

            return ret;
        }

        function _doGet(): Promise<string> {
            return new Promise((resolve) => {
                setTimeout(() => {
                    let statusCode = Math.random() > 0.5 ? 200 : 400;
                    let data: string = null;
                    if (statusCode === 200) {
                        data = 'one json';
                    } else {
                        console.log('网络请求失败,code:', statusCode);
                    }

                    resolve(data);
                }, 1500);
            });
        }

操作ui可以防御一下ui已经destory:

// 加载并显示图片1
let image1: cc.SpriteFrame = await this._loadImage(bundle, 'image1');
// this为组件
if (this.isValid) {
    image1 && (this.pic1.SpriteFrame = image1);
}

hhh是的,示例代码表达核心思想就行了

我最近新手引导这样做的,像写同步逻辑一样写新手引导逻辑。
就是跳过按钮麻烦点。

那可以考虑封装可以取消的 Promise 试试。不过新手引导和资源加载的业务场景确实差挺大的,只靠一两个函数解决不了问题hhh

大概是这样做的,在每个await后面都判断是否已经跳过了。然后跳过流程和新手引导流程使用Promise.race。再resolve掉两个流程中还在等待的promise。

let guideFlow = (async () => {
    //第1关
    await GameScene.enterNewbie(0);
    if (this.isSkipped(GuideStep.Game)) return;
    //弹对话框
    await TalkLogic.instance.showTalkLayerUntilClose(1, 4);
    if (this.isSkipped(GuideStep.Game)) return;

    //第2关
    await GameScene.enterNewbie(1);
    if (this.isSkipped(GuideStep.Game)) return;
    //弹对话框
    await TalkLogic.instance.showTalkLayerUntilClose(1, 7);
    if (this.isSkipped(GuideStep.Game)) return;
})();

let skipFlow = (async () => {
    await SkipGuideBtn.show();
    this._setSkipped(GuideStep.Game);
})();

await Promise.race([guideFlow, skipFlow]);

TalkLogic.instance.closeTalkLayer();
SkipGuideBtn.close();

还是比较赞同只需要处理resolve就好, 一切掌控在手中才能成就真理!

这个试过了是属于异步中的同步执行,似乎没有一帧加载完多个资源的方法

mark~

明确不用处理失败的业务 可以reject ,但是大部分业务都是要处理失败情况的。

报错点1,报错点2,不需要考虑,因为代码自己会报错。而报错点3是需要try catch,因为图片下载失败,会走一段代码,这段代码可能会报错。如果能保证这段代码不会报错,那么也可以不用try catch