更好的方式循环"循环"承诺

时间:2017-12-17 23:16:25

标签: javascript recursion ecmascript-6 promise

这篇文章很可能是概念性的,因为我首先从很多伪代码开始。 - 最后你会看到这个问题的用例,虽然解决方案是一个工具,我可以添加到我的工具带中,使用有用的编程技术"。

问题

有时可能需要创建多个承诺,并且在所有承诺结束后执行某些操作。或者根据先前承诺的结果,可以创建多个承诺。可以类推,创建一个值数组而不是单个值 有两种基本情况需要考虑,其中承诺的数量与所述承诺的结果不一致,以及它是依赖的情况。什么"可以"的简单伪代码完成。

for (let i=0; i<10; i++) {
    promise(...)
        .then(...)
        .catch(...);
}.then(new function(result) {
    //All promises finished execute this code now.
})

基本上创建了n(10)个promise,最后的代码将在所有promise完成后执行。当然,语法不是在javascript中工作,但它显示了这个想法。这个问题相对简单,可以称为完全异步。

现在第二个问题是:

while (continueFn()) {
    promise(...)
        .then(.. potentially changing outcome of continueFn ..)
        .catch(.. potentially changing outcome of continueFn ..)
}.then(new function(result) {
    //All promises finished execute this code now.
})

这要复杂得多,因为人们不能只是开始所有的承诺,然后等待他们完成:最终你必须按照承诺承诺&#34;承诺承诺#34 ;。第二种情况是我想弄清楚的(如果可以做第二种情况,你也可以做第一种情况)。

(坏)解决方案

我确实有一个有效的解决方案&#34;。在代码我谈到为什么我不喜欢这种方法之后,这可能很快就会被看作是一个很好的解决方案。基本上它不是循环,而是使用递归 - 所以&#34;承诺&#34; (或作为承诺的承诺的包装)在代码完成后自行调用:

function promiseFunction(state_obj) {
    return new Promise((resolve, reject) => {
        //initialize fields here
        let InnerFn = (stateObj) => {
            if (!stateObj.checkContinue()) {
                return resolve(state_obj);
            }
            ActualPromise(...)
                .then(new function(result) {
                    newState = stateObj.cloneMe(); //we'll have to clone to prevent asynchronous write problems
                    newState.changeStateBasedOnResult(result);
                    return InnerFn(newState);
                })
                .catch(new function(err) {
                    return reject(err); //forward error handling (must be done manually?)
                });
        }
        InnerFn(initialState); //kickstart
    });
}

重要的是要注意stateObj在其生命周期内不应改变,但它可以非常简单。在我真正的问题中(我将在最后解释),stateObj只是一个计数器(数字),而if (!stateObj.checkContinue())只是if (counter < maxNumber)

现在这个解决方案非常糟糕;它很丑陋,复杂,容易出错,最终无法扩展 丑陋,因为实际的业务逻辑埋藏在一堆乱七八糟的代码中。它没有在罐头上显示&#34;这实际上只是简单地执行上面的while循环 因为执行流程无法遵循而变得复杂。首先,递归代码永远不会轻松#34;要遵循,但更重要的是,您还必须牢记使用状态对象的线程安全性。 (也可能有另一个对象的引用,比如存储结果列表以供以后处理) 由于冗余超过严格必要,因此容易出错;您必须明确转发拒绝。诸如堆栈跟踪之类的调试工具也很快变得难以查看 可伸缩性在某些方面也是一个问题:这是一个递归函数,因此在某一点上它将创建一个stackoverflow /遇到最大递归深度。通常,可以通过尾递归进行优化,或者更常见的是,在堆上创建虚拟堆栈,并使用手动堆栈将函数转换为循环。但是,在这种情况下,不能将递归调用更改为带手动堆栈的循环;仅仅是因为承诺语法有效。

替代(坏)解决方案

一位同事建议采用另一种解决这个问题的方法,这种方法最初看起来不那么成问题,但我放弃了最终的方法,因为它反对所有承诺的意图。

他所建议的基本上是按照上面的承诺循环。但是,不是让循环继续,而是会有一个变量&#34;完成&#34;以及不断检查此变量的内部循环;所以在代码中它就像:

function promiseFunction(state_obj) {
    return new Promise((resolve, reject) => {
        while (stateObj.checkContinue())  {
            let finished = false;
            let err = false;
            let res = null;
            actualPromise(...)
                .then(new function(result) {
                    res = result;
                    finished = true;
                })
                .catch(new function(err) {
                    res = err;
                    err = true;
                    finished = true;
                });
            while(!finished) {
                sleep(100); //to not burn our cpu
            }
            if (err) {
                return reject(err);
            }
            stateObj.changeStateBasedOnResult(result);
        }
    });
}

虽然这不那么复杂,但现在很容易遵循执行流程。这有其自身的问题:至少它不清楚这个功能什么时候结束;这对于表现来说真的很糟糕。

结论

嗯,目前还没有结论,我真的很喜欢上面第一个伪代码中的简单内容。也许是另一种看待事物的方式,这样就不会有深度递归函数的麻烦。

那么你如何重写作为循环一部分的承诺呢?

用作动机的真正问题

现在这个问题源于我必须创造的真实事物。虽然现在已经解决了这个问题(通过应用上面的递归方法),但知道产生这个问题的内容可能会很有趣;然而,真正的问题不是关于这个具体案例,而是关于如何通过任何承诺来实现这一点。

在sails应用程序中,我必须检查一个数据库,该数据库包含订单ID。我必须找到第一个N&#34;不存在的订单ID&#34;。我的解决方案是先获得&#34;#34;来自数据库的M个产品,找到其中缺少的数字。然后,如果缺失数量小于N,则获得下一批M产品。

现在从数据库中获取项目,使用promise(或回调),因此代码不会等待数据库数据返回。 - 所以我基本上处于第二个问题:&#34;

function GenerateEmptySpots(maxNum) {
    return new Promise((resolve, reject) => {
        //initialize fields
        let InnerFn = (counter, r) => {
            if (r > 0) {
                return resolve(true);
            }
            let query = {
                orderNr: {'>=': counter, '<': (counter + maxNum)}
            };
            Order.find({
                where: query,
                sort: 'orderNr ASC'})
                .then(new function(result) {
                    n = findNumberOfMissingSpotsAndStoreThemInThis();
                    return InnerFn(newState, r - n);
                }.bind(this))
                .catch(new function(err) {
                    return reject(err); 
                });
        }
        InnerFn(maxNum); //kickstart
    });
}

<小时/> 编辑: 小帖子脚本:备选方案中的sleep函数来自另一个提供非阻塞睡眠的库。 (不重要) 此外,我应该指出我使用的是es2015。

4 个答案:

答案 0 :(得分:4)

  

替代(坏)解决方案

...实际上没有用,因为JavaScript中没有sleep函数。 (如果你有一个提供非阻塞睡眠的运行时库,你可以使用while循环和非阻塞 - 使用相同的样式等待其中的promise。)

  

糟糕的解决方案是丑陋,复杂,容易出错,最终无法扩展。

不。递归方法确实是实现此目的的正确方法。

  

丑陋,因为实际的业务逻辑埋藏在一堆乱码中。并且容易出错,因为您必须明确转发拒绝。

这只是由Promise constructor antipattern引起的!避免它。

  

复杂,因为无法遵循执行流程。递归代码永远不会“容易”遵循

我会挑战那句话。你必须习惯它。

  

您还必须牢记状态对象的线程安全性。

没有。 JavaScript中没有多线程和共享内存访问,如果你担心并发,其他东西会影响你的状态对象,而循环运行则会出现问题。

  

可扩展性在某些方面也是一个问题:这是一个递归函数,所以在某一点上它会创建一个stackoverflow

没有。这是异步的!回调将在一个新的堆栈上运行,它实际上并不是在函数调用期间递归调用的,并且不会携带这些堆栈帧。异步事件循环已经提供了trampoline来实现这种尾递归。

好的解决方案

function promiseFunction(state) {
    const initialState = state.cloneMe(); // clone once for this run
    // initialize fields here
    return (function recurse(localState) {
        if (!localState.checkContinue())
            return Promise.resolve(localState);
        else
            return actualPromise(…).then(result =>
                recurse(localState.changeStateBasedOnResult(result))
            );
    }(initialState)); // kickstart
}

现代解决方案

您知道,async / await在每个实施ES6的环境中都可用,因为它们现在都实现了ES8!

async function promiseFunction(state) {
    const localState = state.cloneMe(); // clone once for this run
    // initialize fields here
    while (!localState.checkContinue()) {
        const result = await actualPromise(…);
        localState = localState.changeStateBasedOnResult(result);
    }
    return localState;
}

答案 1 :(得分:1)

让我们从简单的案例开始:你有N个承诺,所有人都做了一些工作,并且你希望在所有承诺完成时做点什么。实际上有一种内置的方法可以做到这一点:Promise.all。有了它,代码将如下所示:

let promises = [];
for (let i=0; i<10; i++) {
    promises.push(doSomethingAsynchronously());
}

Promise.all(promises).then(arrayOfResults => {
    // all promises finished
});

现在,第二个调用是您希望在以前的异步结果上继续异步依赖执行某些操作时遇到的情况。一个常见的例子(有点不那么抽象)就是简单地获取页面直到你到达终点。

使用现代JavaScript,幸运的是可以用一种非常易读的方式来编写它:使用asynchronous functionsawait

async function readFromAllPages() {
    let shouldContinue = true;
    let pageId = 0;
    let items = [];

    while (shouldContinue) {
        // fetch the next page
        let result = await fetchSinglePage(pageId);

        // store items
        items.push.apply(items, result.items);

        // evaluate whether we want to continue
        if (!result.items.length) {
            shouldContinue = false;
        }
        pageId++;
    }

    return items;
}

readFromAllPages().then(allItems => {
    // items have been read from all pages
});

如果没有async / await,这看起来会有点复杂,因为你需要自己管理所有这些。但除非你试图让它超级通用,否则看起来不应该那么糟糕。例如,分页可能如下所示:

function readFromAllPages() {
    let items = [];
    function readNextPage(pageId) {
        return fetchSinglePage(pageId).then(result => {
            items.push.apply(items, result.items);

            if (!result.items.length) {
                return Promise.resolve(null);
            }
            return readNextPage(pageId + 1);
        });
    }
    return readNextPage(0).then(() => items);
}
  

首先,递归代码永远不会“容易”遵循

我认为代码可以阅读。正如我所说:除非你试图让它超级通用,否则你可以保持简单。命名也有很大帮助。

  

但更重要的是,您还必须牢记使用状态对象

的线程安全性

不,JavaScript是单线程的。你是异步做事但这并不一定意味着事情同时发生。 JavaScript使用事件循环来处理异步进程,其中一次只运行一个代码块。

  

可扩展性在某些方面也是一个问题:这是一个递归函数,所以在某一点上它会创建一个stackoverflow /遇到最大递归深度。

也没有。在函数引用自身的意义上,这是递归的。但它不会直接调用本身。相反,当异步进程完成时,它会将自身注册为回调。因此,函数的当前执行将首先完成,然后在某个时刻异步进程完成,然后回调最终将运行。这些(至少)是事件循环的三个独立步骤,它们都独立于另一个循环运行,所以这里没有递归深度的问题。

答案 2 :(得分:0)

对不起,这不使用Promises,但有时抽象只是妨碍了。

这个以@ poke的答案为基础的例子简短易懂。

function readFromAllPages(done=function(){}, pageId=0, res=[]) {
    fetchSinglePage(pageId, res => {
        if (res.items.length) {
            readFromAllPages(done, ++pageId, items.concat(res.items));
        } else {
            done(items);
        }
    });
}

readFromAllPages(allItems => {
    // items have been read from all pages
});

这只有一个深度的嵌套函数。通常,您可以解决嵌套回调问题,而无需使用为您管理事物的子系统。

如果我们删除参数默认值并更改箭头功能,我们将获得在旧版ES3浏览器中运行的代码。

答案 3 :(得分:0)

The crux of the matter seems to be that "the actual business logic is buried in a mess of code".

Yes it is ... in both solutions.

Things can be separated out by :

  • having an asyncRecursor function that simply knows how to (asynchronously) recurse.
  • allowing the recursor's caller(s) to specify the business logic (the terminal test to apply, and the work to be performed).

It is also better to allow caller(s) to be responsible for cloning the original object rather than resolver() assuming cloning always to be necessary. The caller really needs to be in charge in this regard.

function asyncRecursor(subject, testFn, workFn) {
    // asyncRecursor orchestrates the recursion
    if(testFn(subject)) {
        return Promise.resolve(workFn(subject)).then(result => asyncRecursor(result, testFn, workFn));
        // the `Promise.resolve()` wrapper safeguards against workFn() not being thenable.
    } else {
        return Promise.resolve(subject);
        // the `Promise.resolve()` wrapper safeguards against `testFn(subject)` failing at the first call of asyncRecursor().
    }
}

Now you can write your caller as follows :

// example caller
function someBusinessOrientedCallerFn(state_obj) { 
    // ... preamble ...
    return asyncRecursor(
        state_obj, // or state_obj.cloneMe() if necessary
        (obj) => obj.checkContinue(), // testFn
        (obj) => somethingAsync(...).then((result) => { // workFn
            obj.changeStateBasedOnResult(result);
            return obj; // return `obj` or anything you like providing it makes a valid parameter to be passed to `testFn()` and `workFn()` at next recursion.
        });
    );
}

You could theoretically incorporate your terminal test inside the workFn but keeping them separate will help enforce the discipline, in writers of the business-logic, to remember to include a test. Otherwise they will consider it optional and sure as you like, they will leave it out!