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

有人能给出一个简单的例子来解释什么是不懂 JavaScript 和 node.js 的人的“回调地狱”吗?

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

为什么会这样?

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

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

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

在一个简单的例子中解释了“回调地狱”之后,您是否可以在这个简单的例子中展示 RX 是如何解决“回调地狱问题”的?

78493 次浏览

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()

这并不是完整的代码,但是其思想是“屈服”暂停 for 循环,直到有人调用 myGen.next ()。重要的是,我们仍然可以使用 for 循环编写代码,而不需要像在递归 loop函数中那样将逻辑“反过来”。

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

这通常发生在行为具有依赖性的时候,也就是说,当 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 是“反应性扩展”的简称。我还没有使用它,但是谷歌表明它是一个基于事件的框架,这是有意义的。事件是使代码按顺序执行而不产生脆弱耦合的常见模式.你可以让 C 监听事件“ bFinish”,这只发生在 B 被调用监听“ aFinish”之后。然后,您可以轻松地添加额外的步骤或扩展这种行为,并且可以通过仅仅在测试用例中广播事件来按顺序执行代码的 很容易测试

只要回答这个问题: 你能不能在这个简单的例子中展示一下 RX 是如何解决“回调地狱问题”的?

神奇的是 flatMap。我们可以用 Rx 编写以下代码,作为@Hugomg 的例子:

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

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

用爵士乐 Https://github.com/javanile/jazz.js

它是这样简化的:



// run sequential task chained
jj.script([
// first task
function(next) {
// at end of this process 'next' point to second task and run it
callAsyncProcess1(next);
},
// second task
function(next) {
// at end of this process 'next' point to thirt task and run it
callAsyncProcess2(next);
},
// thirt task
function(next) {
// at end of this process 'next' point to (if have)
callAsyncProcess3(next);
},
]);


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

使用 mergeMap,又名 flatMap操作员,你可以使它更简洁:

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

正如您可以看到的,代码是扁平化的,包含一个方法调用链。我们没有“厄运金字塔”。

因此,避免了回访地狱。

如果你想知道,我保证是另一种方式来避免回调地狱,但承诺是 渴望,而不是 懒惰喜欢观察和(一般来说)你不能取消他们很容易。

如果你不了解复试和地狱复试,那就没有问题。重要的是,打回去,打回地狱。例如: hell call back 就像是我们可以在一个类中存储一个类。嵌套意味着一个类在另一个类中。

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

我最近开始使用 FRP,因为我发现了一个很好的实现,称为 Sodium(http://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,而是将 Callback-s 推送到应用程序的“外层”/“表面”,在那里,状态处理与外部世界的逻辑接口。在内部状态处理逻辑(实现状态机)中传播数据不需要回调。

完整的源代码是 给你

上面的代码片段符合以下简单的创建/显示/更新示例:

enter image description here

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

所有的事件处理都是通过使用 StreamCell来完成的。这些是玻璃钢的概念。只有在 FRP 逻辑与外部世界接口的地方才需要回调,如用户输入、编辑文本、按下按钮、 AJAX 调用返回。

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

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

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

我建议看看 钠书,它详细解释了 FRP 如何去除所有 Callback,这些 Callback 对于描述数据流逻辑来说是不必要的,因为数据流逻辑需要根据一些外部刺激来更新应用程序状态。

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

钠也可用于 Javascript/Type 脚本。

回调地狱意味着你在一个回调的内部,另一个回调的内部,它去 nth 调用,直到你的需要没有得到满足。

让我们通过一个使用 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 的食谱,我们在我们的第二次回电编写代码。很明显,我们写了一个回调链,这是所谓的回调地狱。

如果你想避免回调,你可以使用“承诺”,也就是 jses6的特性,每个承诺都接受一个回调,当一个承诺已经实现时,就会调用这个回调。承诺回调有两个选项,要么解决,要么拒绝。假设你的 API 调用成功,你可以通过 决心来调用和传递数据,你可以通过使用 那么(())来获得这些数据。但是如果您的 API 失败,您可以使用拒绝,使用 接住来捕获错误。记住一个承诺,总是使用 那么解决和 接住拒绝

让我们用一个承诺来解决之前的回调问题。

<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>

现在我们可以编写另一个类似 getRecipe 的方法调用 AllRecipeOfAPPublisher,它也会返回一个承诺,我们还可以编写另一个 then ()来接收 allRecipeOfAPublisher 的解析承诺,我希望在这一点上您可以自己完成。

因此,我们学习了如何构造和消费承诺,现在让我们使用 es8中引入的异步/等待来简化消费承诺。

<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>

在上面的例子中,我们使用了一个异步函数,因为它将在后台运行,在异步函数中,我们在每个返回或者是一个承诺的方法之前使用了 等待关键字,因为要等待该位置,直到该承诺实现,换句话说,在下面的代码中,直到 getIds 完成解析或者拒绝程序将停止执行该行下面的代码,当 ID 返回时,我们再次使用 id 调用 getRecipe ()函数,并且通过使用 wait 关键字等待。这就是我们最终从复试地狱中恢复过来的原因。

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

为了使用 wait,我们将需要一个异步函数,我们可以返回承诺,因此用于解析承诺,用于拒绝承诺

例如:

 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);
});