什么是“回调地狱”以及 RX 如何以及为什么解决它?

IT技术 javascript callback language-lawyer reactive-programming rxjs
2021-02-09 17:36:29

有人可以给出一个明确的定义以及一个简单的例子来解释什么是不了解 JavaScript 和 node.js 的人的“回调地狱”?

“回调地狱问题”什么时候(在什么样的设置中)发生?

为什么会发生?

“回调地狱”总是与异步计算有关吗?

或者“回调地狱”也会出现在单线程应用程序中吗?

我参加了 Coursera 的响应式课程,Erik Meijer 在他的一次讲座中说 RX 解决了“回调地狱”的问题。我在 Coursera 论坛上问什么是“回调地狱”,但我没有得到明确的答案。

在用一个简单的例子解释了“回调地狱”之后,你能否在这个简单的例子上展示 RX 如何解决“回调地狱问题”?

6个回答

1) 对于不了解 javascript 和 node.js 的人来说,什么是“回调地狱”?

这个另一个问题有一些 Javascript 回调地狱的例子:How to avoid long nesting of asynchronous functions in Node.js

Javascript 中的问题是“冻结”计算并让“其余部分”在后面(异步)执行的唯一方法是将“其余部分”放入回调中。

例如,假设我想运行如下所示的代码:

x = getData();
y = getMoreData(x);
z = getMoreData(y);
...

如果现在我想让 getData 函数异步,这意味着我有机会在等待它们返回值的同时运行一些其他代码,会发生什么?在 Javascript 中,唯一的方法是使用延续传递样式重写涉及异步计算的所有内容

getData(function(x){
    getMoreData(x, function(y){
        getMoreData(y, function(z){ 
            ...
        });
    });
});

我不认为我需要说服任何人这个版本比前一个更丑。:-)

2)什么时候(在什么样的设置下)“回调地狱问题”发生?

当你的代码中有很多回调函数时!在代码中使用它们的次数越多,使用它们就会变得越困难,并且当您需要执行循环、try-catch 块和诸如此类的事情时,情况尤其糟糕。

例如,据我所知,在 JavaScript 中执行一系列异步函数的唯一方法是使用递归函数。您不能使用 for 循环。

// we would like to write the following
for(var i=0; i<10; i++){
    doSomething(i);
}
blah();

相反,我们可能需要最终编写:

function loop(i, onDone){
    if(i >= 10){
        onDone()
    }else{
        doSomething(i, function(){
            loop(i+1, onDone);
        });
     }
}
loop(0, function(){
    blah();
});

//ugh!

我们在 StackOverflow 上收到的询问如何做这种事情的问题数量证明了它是多么令人困惑:)

3)为什么会发生?

这是因为在 JavaScript 中延迟计算以便在异步调用返回后运行的唯一方法是将延迟的代码放在回调函数中。您不能延迟以传统同步风格编写的代码,因此您最终会在任何地方使用嵌套回调。

4) 或者“回调地狱”也可以发生在单线程应用程序中?

异步编程与并发有关,而单线程与并行有关。这两个概念其实不是一回事。

您仍然可以在单线程上下文中拥有并发代码。事实上,回调地狱的女王 JavaScript 是单线程的。

并发和并行有什么区别?

5)能否请您在这个简单的示例中还展示 RX 如何解决“回调地狱问题”。

我对 RX 一无所知,但通常这个问题可以通过在编程语言中添加对异步计算的本机支持来解决。实现可能会有所不同,包括:异步、生成器、协同程序和 callcc。

在 Python 中,我们可以使用以下内容来实现前面的循环示例:

def myLoop():
    for i in range(10):
        doSomething(i)
        yield

myGen = myLoop()

这不是完整的代码,但想法是“yield”暂停我们的 for 循环,直到有人调用 myGen.next()。重要的是,我们仍然可以使用 for 循环编写代码,而无需像在递归loop函数中那样“由内而外”转换逻辑

顺便说一句,我在 google 上搜索了响应式扩展,我得到的印象是它们更类似于 Promise 库,而不是引入异步语法的语言扩展。Promise 有助于处理回调嵌套和异常处理,但它们不像语法扩展那样简洁。for 循环对代码来说仍然很烦人,您仍然需要将代码从同步样式转换为 promise 样式。
2021-03-15 17:36:29
另一个相关评论:RX 基本上是延续 monad,如果我没记错的话,它与 CPS 相关,这也可能解释了 RX 如何/为什么对回调/地狱问题有好处。
2021-03-16 17:36:29
回调地狱更多地与使用连续传递样式进行编码的烦人有关。从理论上讲,即使对于常规程序(维基百科文章中有一些示例),您仍然可以使用 CPS 样式重写所有函数,但是,出于充分的理由,大多数人不这样做。通常我们只在被迫的情况下使用延续传递风格,这就是 Javascript 异步编程的情况。
2021-03-17 17:36:29
我应该澄清 RX 通常如何做得更好。RX 是声明性的。您可以声明程序在事件稍后发生时将如何响应事件,而不会影响任何其他程序逻辑。这允许您将主循环代码与事件处理代码分开。您可以轻松处理诸如异步事件排序之类的细节,这些细节在使用状态变量时是一场噩梦。我发现 RX 是最干净的实现,可以在 3 个网络响应返回后执行新的网络请求,或者如果没有返回则错误处理整个链。然后它可以重置自己并等待相同的 3 个事件。
2021-04-05 17:36:29
那么回调地狱只能发生在异步设置中吗?如果我的代码是完全同步的(即没有并发),那么如果我正确理解你的答案,“回调地狱”就不会发生,对吗?
2021-04-11 17:36:29

只需回答这个问题:您能否在这个简单的示例中也展示 RX 如何解决“回调地狱问题”?

神奇的是flatMap我们可以在 Rx 中为@hugomg 的示例编写以下代码:

def getData() = Observable[X]
getData().flatMap(x -> Observable[Y])
         .flatMap(y -> Observable[Z])
         .map(z -> ...)...

这就像你在写一些同步的 FP 代码,但实际上你可以通过Scheduler.

要解决 Rx 如何解决回调地狱的问题

首先让我们再次描述回调地狱。

想象一个案例,我们必须通过 http 来获取三种资源 - 人、行星和星系。我们的目标是找到人所居住的星系。首先我们必须找到人,然后是行星,然后是星系。这是三个异步操作的三个回调。

getPerson(person => { 
   getPlanet(person, (planet) => {
       getGalaxy(planet, (galaxy) => {
           console.log(galaxy);
       });
   });
});

每个回调都是嵌套的。每个内部回调都依赖于其父级。这导致回调地狱的“厄运金字塔”风格该代码看起来像一个 > 符号。

要在 RxJs 中解决这个问题,您可以执行以下操作:

getPerson()
  .map(person => getPlanet(person))
  .map(planet => getGalaxy(planet))
  .mergeAll()
  .subscribe(galaxy => console.log(galaxy));

使用mergeMapAKAflatMap运算符,您可以使其更简洁:

getPerson()
  .mergeMap(person => getPlanet(person))
  .mergeMap(planet => getGalaxy(planet))
  .subscribe(galaxy => console.log(galaxy));

如您所见,代码被扁平化并包含单个方法调用链。我们没有“厄运金字塔”。

因此,避免了回调地狱。

如果您想知道,promise是另一种避免回调地狱的方法,但 Promise 是急切的不像 observable那样懒惰,并且(一般来说)您不能轻易取消它们。

我不是 JS 开发人员,但这很容易解释
2021-03-17 17:36:29

回调地狱是任何在异步代码中使用函数回调变得模糊或难以理解的代码。通常,当存在多个间接层时,使用回调的代码会变得更难跟踪、更难重构和更难测试。由于传递了多层函数文字,代码异味是多级缩进。

这通常发生在行为具有依赖关系时,即当 A 必须发生在 B 必须发生在 C 之前。然后你会得到这样的代码:

a({
    parameter : someParameter,
    callback : function() {
        b({
             parameter : someOtherParameter,
             callback : function({
                 c(yetAnotherParameter)
        })
    }
});

如果你的代码中有很多这样的行为依赖,它会很快变得很麻烦。特别是如果它分支...

a({
    parameter : someParameter,
    callback : function(status) {
        if (status == states.SUCCESS) {
          b(function(status) {
              if (status == states.SUCCESS) {
                 c(function(status){
                     if (status == states.SUCCESS) {
                         // Not an exaggeration. I have seen
                         // code that looks like this regularly.
                     }
                 });
              }
          });
        } elseif (status == states.PENDING {
          ...
        }
    }
});

这不行。我们如何让异步代码按确定的顺序执行而不必传递所有这些回调?

RX 是“react式扩展”的缩写。我没有使用过它,但谷歌搜索表明它是一个基于事件的框架,这是有道理的。事件是使代码按顺序执行而不产生脆弱耦合的常见模式您可以让 C 侦听事件 'bFinished',该事件仅在 B 被称为侦听 'aFinished' 之后发生。然后,您可以轻松添加额外的步骤或扩展这种行为,并且只需在测试用例中广播事件,即可轻松测试您的代码是否按顺序执行。

回调地狱意味着您处于另一个回调内部的回调中,并且它会进行第 n 个调用,直到您的需求未满足为止。

让我们通过一个使用set timeout API的假ajax调用的例子来理解,假设我们有一个recipe API,我们需要下载所有recipe。

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
            }, 1500);
        }
        getRecipe();
    </script>
</body>

在上面的例子中,当计时器到期 1.5 秒后,回调代码将执行,换句话说,通过我们的假 ajax 调用,所有配方将从服务器下载。现在我们需要下载特定的配方数据。

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
                setTimeout(id=>{
                    const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                    console.log(`${id}: ${recipe.title}`);
                }, 1500, recipeId[2])
            }, 1500);
        }
        getRecipe();
    </script>
</body>

为了下载特定的配方数据,我们在第一个回调中编写了代码并传递了配方 ID。

现在假设我们需要下载 id 为 7638 的配方的同一发布者的所有配方。

<body>
    <script>
        function getRecipe(){
            setTimeout(()=>{
                const recipeId = [83938, 73838, 7638];
                console.log(recipeId);
                setTimeout(id=>{
                    const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                    console.log(`${id}: ${recipe.title}`);
                    setTimeout(publisher=>{
                        const recipe2 = {title:'Fresh Apple Pie', publisher:'Suru'};
                        console.log(recipe2);
                    }, 1500, recipe.publisher);
                }, 1500, recipeId[2])
            }, 1500);
        }
        getRecipe();
    </script>
</body>

为了完全满足我们的需求,即下载出版商名称 suru 的所有食谱,我们在第二次回调中编写了代码。很明显,我们编写了一个回调链,称为回调地狱。

如果你想避免回调地狱,你可以使用 Promise,它是 js es6 的特性,每个 Promise 都有一个回调,当一个 Promise 被填满时会调用它。Promise回调有两个选项,要么解决,要么拒绝。假设你的API调用成功,您可以打电话的决心,并通过传递数据解析,您可以通过使用得到这个数据则() 但是如果你的 API 失败了,你可以使用拒绝,使用catch来捕捉错误。记住一个Promise总是使用then来解决和捕获拒绝

让我们使用 Promise 解决之前的回调地狱问题。

<body>
    <script>

        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        getIds.then(IDs=>{
            console.log(IDs);
        }).catch(error=>{
            console.log(error);
        });
    </script>
</body>

现在下载特定的食谱:

<body>
    <script>
        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        const getRecipe = recID => {
            return new Promise((resolve, reject)=>{
                setTimeout(id => {
                    const downloadSuccessfull = true;
                    if (downloadSuccessfull){
                        const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                        resolve(`${id}: ${recipe.title}`);
                    }else{
                        reject(`${id}: recipe download failed 404`);
                    }

                }, 1500, recID)
            })
        }
        getIds.then(IDs=>{
            console.log(IDs);
            return getRecipe(IDs[2]);
        }).
        then(recipe =>{
            console.log(recipe);
        })
        .catch(error=>{
            console.log(error);
        });
    </script>
</body>

现在我们可以编写另一个方法调用allRecipeOfAPublisher ,如 getRecipe ,它也会返回一个Promise,我们可以编写另一个 then() 来接收 allRecipeOfAPublisher 的解决Promise,我希望此时您可以自己完成。

所以我们学习了如何构造和消费 Promise,现在让我们通过使用 es8 中引入的 async/await 来更容易地消费 Promise。

<body>
    <script>

        const getIds = new Promise((resolve, reject)=>{
            setTimeout(()=>{
                const downloadSuccessfull = true;
                const recipeId = [83938, 73838, 7638];
                if(downloadSuccessfull){
                    resolve(recipeId);
                }else{
                    reject('download failed 404');
                }
            }, 1500);
        });

        const getRecipe = recID => {
            return new Promise((resolve, reject)=>{
                setTimeout(id => {
                    const downloadSuccessfull = true;
                    if (downloadSuccessfull){
                        const recipe = {title:'Fresh Apple Juice', publisher:'Suru'};
                        resolve(`${id}: ${recipe.title}`);
                    }else{
                        reject(`${id}: recipe download failed 404`);
                    }

                }, 1500, recID)
            })
        }

        async function getRecipesAw(){
            const IDs = await getIds;
            console.log(IDs);
            const recipe = await getRecipe(IDs[2]);
            console.log(recipe);
        }

        getRecipesAw();
    </script>
</body>

在上面的例子中,我们使用了一个 async 函数,因为它会在后台运行,在 async 函数中,我们在每个返回或作为Promise的方法之前使用await关键字,因为要等待那个位置直到那个Promise实现,换句话说波纹管代码直到 getIds 完成解决或拒绝程序将在 ID 返回时停止执行该行下面的代码,然后我们再次使用 id 调用 getRecipe() 函数并使用 await 关键字等待直到数据返回。所以这就是我们最终从回调地狱中恢复过来的方式。

  async function getRecipesAw(){
            const IDs = await getIds;
            console.log(IDs);
            const recipe = await getRecipe(IDs[2]);
            console.log(recipe);
        }

要使用 await 我们需要一个异步函数,我们可以返回一个Promise,所以使用 then 来解决Promise,用 cath 来拒绝Promise

从上面的例子:

 async function getRecipesAw(){
            const IDs = await getIds;
            const recipe = await getRecipe(IDs[2]);
            return recipe;
        }

        getRecipesAw().then(result=>{
            console.log(result);
        }).catch(error=>{
            console.log(error);
        });