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


113

有人可以给出一个清晰的定义,以及一个简单的示例,为不懂JavaScript和node.js的人解释什么是“回调地狱”吗?

什么时候(以哪种设置)发生“回调地狱问题”?

为什么会发生?

“回调地狱”是否总是与异步计算相关?

还是在单线程应用程序中也可能发生“回调地狱”?

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

在一个简单的示例上解释了“回调地狱”之后,您还可以说明该简单示例上的RX如何解决“回调地狱问题”吗?

Answers:


136

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

这另一个问题有一些Javascript回调地狱的例子: 如何避免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函数中那样将逻辑“由内而外” 。


那么回调地狱只能在异步设置中发生吗?如果我的代码是完全同步的(即没有并发性),那么如果我正确理解了您的答案,就不会发生“回调地狱”,对吗?
jhegedus 2014年

回调地狱与使用连续传递样式进行编码的烦恼程度有关。从理论上讲,即使对于常规程序,您仍然可以使用CPS样式重写所有功能(维基百科文章提供了一些示例),但是,出于充分的原因,大多数人不这样做。通常,如果强制执行,我们只会使用延续传递样式,Javascript异步编程就是这种情况。
hugomg 2014年

顺便说一句,我用谷歌搜索了反应性扩展,并且给人的印象是它们更类似于Promise库,而不是引入异步语法的语言扩展。Promise帮助处理回调嵌套和异常处理,但它们与语法扩展一样简洁。for循环仍然困扰代码,您仍然需要将代码从同步样式转换为Promise样式。
hugomg 2014年

1
我应该阐明RX通常如何做得更好。RX是声明性的。您可以声明程序在以后发生事件时将如何响应,而不影响任何其他程序逻辑。这使您可以将主循环代码与事件处理代码分开。使用状态变量时,您可以轻松处理诸如噩梦般的事件之类的细节。我发现RX是在返回3个网络响应之后执行一个新的网络请求的最干净的实现,或者如果没有返回则错误处理整个链。然后,它可以重置自身并等待相同的3个事件。
colintheshots 2014年

另一个相关的评论:RX基本上是延续monad,如果我没有记错的话,它与CPS有关,这也可以解释RX如何/为什么对回调/地狱问题有好处。
jhegedus 2014年

30

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

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

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

就像您正在编写一些同步FP代码一样,但实际上您可以通过来使它们异步Scheduler


26

解决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));

使用mergeMapAKA flatMap运算符,您可以使其更加简洁:

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

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

因此,避免了回调地狱。

万一您想知道,promise是避免回调地狱的另一种方法,但是promise很渴望,不像可观察对象那样懒惰,并且(通常来说)您不能轻易取消它们。


我不是JS开发人员,但这很容易解释
Omar Beshary

15

回调地狱是在异步代码中使用函数回调变得晦涩难懂或难以遵循的任何代码。通常,当间接调用的级别不只一种时,使用回调的代码将变得更难遵循,更难重构和更难测试。代码气味是由于传递函数文字的多层而导致的多个缩进级别。

当行为具有相关性时,即当A必须在B之前必须发生在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是“反应性扩展”的缩写。我没有使用过它,但是Googling建议它是一个基于事件的框架,这很有意义。事件是使代码按顺序执行而不会产生脆弱耦合的常见模式。您可以使C监听事件“ bFinished”,该事件仅在B被称为监听“ aFinished”之后发生。然后,您可以轻松地添加额外的步骤或扩展这种行为,并且仅通过在测试用例中广播事件就可以轻松地测试代码是否按顺序执行。


1

回调地狱意味着您在另一个回调内部的回调中,并且将转到第n个调用,直到您的需求没有得到满足为止。

让我们通过使用set timeout API来了解一个伪造的ajax调用示例,假设我们有一个食谱API,我们需要下载所有食谱。

<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解决先前的回调地狱问题。

<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像一样,该也将返回一个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,换句话说,以下代码,直到返回完ID的getIds完成解析或拒绝程序后,将停止执行该行代码,然后再次使用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 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);
        });

0

可以避免回调地狱的一种方法是使用FRP,它是RX的“增强版本”。

我最近开始使用FRP,因为我找到了一个名为Sodiumhttp://sodium.nz/)的良好实现。

一个典型的代码如下所示(Scala.js):

def render: Unit => VdomElement = { _ =>
  <.div(
    <.hr,
    <.h2("Note Selector"),
    <.hr,
    <.br,
    noteSelectorTable.comp(),
    NoteCreatorWidget().createNewNoteButton.comp(),
    NoteEditorWidget(selectedNote.updates()).comp(),
    <.hr,
    <.br
  )
}

selectedNote.updates()Stream,如果selectedNode(是Cell)更改会触发,NodeEditorWidget然后相应地更新。

因此,根据的内容selectedNode Cell,当前编辑的内容Note将会更改。

这段代码几乎完全避免了Callback-s,Cacllback-s被推到了应用程序的“外层” /“表面”,状态处理逻辑与外部世界交互。在内部状态处理逻辑(实现状态机)内传播数据不需要回调。

完整的源代码是 这里

上面的代码段与以下简单的“创建/显示/更新”示例相对应:

在此处输入图片说明

此代码还将更新发送到服务器,因此对更新的实体的更改将自动保存到服务器。

所有事件处理都通过使用Streams和Cell s。这些是FRP概念。仅当FRP逻辑与外部世界连接时才需要回调,例如用户输入,编辑文本,按下按钮,返回AJAX调用。

使用FRP(由Sodium库实现)以声明的方式显式地描述了数据流,因此不需要事件处理/回调逻辑来描述数据流。

FRP(是RX的“更严格”版本)是一种描述数据流图的方法,该数据流图可以包含包含状态的节点。事件触发包含节点的状态的变化(称为Cells)。

钠是高阶FRP库,这意味着使用flatMap/ switch原语可以在运行时重新排列数据流图。

我建议看一看Sodium书,它详细解释了FRP如何摆脱所有回调,这些回调对于描述与响应某些外部刺激来更新应用程序状态有关的数据流逻辑不是必不可少的。

使用FRP,只需保留描述与外部世界交互的那些回调。换句话说,当一个人使用FRP框架(如Sodium)或当一个人使用“类似FRP”的框架(如RX)时,以功能/声明的方式描述数据流。

钠也可用于Javascript / Typescript。


-3

如果您对回调和地狱回调一无所知,那就没问题了,第一件事就是回调并回调地狱,例如:地狱回调就像我们可以将一个类存储在一个类中。关于嵌套在C,C ++语言中的内容。嵌套的意思是一个类位于另一个类中。


如果答案中包含显示“回调地狱”的代码段以及删除“回调地狱”后与Rx相同的代码段,则答案将更为有用
rafa

-4

使用jazz.js https://github.com/Javanile/Jazz.js

它简化如下:

    //运行顺序任务链
    jj.script([
        //第一项任务
        函数(下一个){
            //在此过程结束时,“ next”指向第二个任务并运行它 
            callAsyncProcess1(next);
        },
      //第二项任务
      函数(下一个){
        //在此过程结束时,“下一步”指向任务并运行 
        callAsyncProcess2(next);
      },
      //渴任务
      函数(下一个){
        //在此过程结束时,“ next”指向(如果有) 
        callAsyncProcess3(next);
      },
    ]);


By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.