zhuanyongxigua / blog

blog
77 stars 9 forks source link

JavaScript异步编程之Promise到底解决了什么问题? #22

Open zhuanyongxigua opened 6 years ago

zhuanyongxigua commented 6 years ago

我的github https://github.com/zhuanyongxigua/blog

大家都知道Promise解决了回调地狱的问题。说到回调地狱,很容易想到下面这个容易让人产生误解的图片

可回调地狱到底是什么?它到底哪里有问题?是因为嵌套不好看还是读起来不方便?

首先我们要想想,嵌套到底哪里有问题?

举个例子:

function a() {
  function b() {
    function c() {
      function d() {}
      d();
    }
    c();
  }
  b();
}
a();

这也是嵌套,虽然好像不是特别美观,可我们并不会觉得这有什么问题吧?因为我们经常会写出类似的代码。

在这个例子中的嵌套的问题仅仅是缩进的问题,而缩进除了会让代码变宽可能会造成读代码的一点不方便之外,并没有什么其他的问题。如果仅仅是这样,为什么不叫“缩进地狱”或“嵌套地狱”?

把回调地狱完全理解成缩进的问题是常见的对回调地狱的误解。要回到“回调地狱”这个词语上面来,它的重点就在于“回调”,而“回调”在JS中应用最多的场景当然就是异步编程了。

所以,“回调地狱”所说的嵌套其实是指异步的嵌套。它带来了两个问题:可读性的问题和信任问题。

可读性的问题

这是一个在网上随便搜索的关于执行顺序的面试题:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(new Date, i);
  }, 1000);
}

console.log(new Date, i);

答案是什么大家自己想吧,这不是重点。重点是,你要想一会儿吧?

一个整洁的回调:

listen( "click", function handler( evt){ 
  setTimeout( function request(){ 
    ajax( "http:// some. url. 1", function response( text){ 
      if (text == "hello") { 
        handler(); 
      } else if (text == "world") { 
        request(); 
      } 
    }); 
  }, 500); 
});

如果异步的嵌套都是这样干净整洁,那“回调地狱”给程序猿带来的伤害马上就会减少很多。

可我们实际在写业务逻辑的时候,真实的情况应该是这样的:

listen( "click", function handler(evt){ 
  doSomething1();
  doSomething2();
  doSomething3();
  doSomething4();
  setTimeout( function request(){ 
    doSomething8();
    doSomething9();
    doSomething10();
    ajax( "http:// some. url. 1", function response( text){ 
      if (text == "hello") { 
        handler(); 
      } else if (text == "world") { 
        request(); 
      } 
    }); 
    doSomething11();
    doSomething12();
    doSomething13();
  }, 500); 
  doSomething5();
  doSomething6();
  doSomething7();
});

这些“doSomething”有些是异步的,有些是同步。这样的代码读起来会非常的吃力,因为你要不停的思考他们的执行顺序,并且还要记在脑袋里面。这就是异步的嵌套带来的可读性的问题,它是由异步的运行机制引起的。

信任问题

这里主要用异步请求讨论。我们在做AJAX请求的时候,一般都会使用一些第三方的工具库(即便是自己封装的,也可以在一定程度上理解成第三方的),这就会带来一个问题:这些工具库是否百分百的可靠?

一个来自《YDKJS》的例子:一个程序员开发了一个付款的系统,它良好的运行了很长时间。突然有一天,一个客户在付款的时候信用卡被连续刷了五次。这名程序员在调查了以后发现,一个第三方的工具库因为某些原因把付款回调执行了五次。在与第三方团队沟通之后问题得到了解决。

故事讲完了,可问题真的解决了吗?是否还能够充分的信任这个工具库?信任依然要有,可完善必要的检查和错误处理势在必行。当我们解决了这个问题,由于它的启发,我们还会联想到其他的问题,比如没有调用回调。

再继续想,你会发现,这样的问题还要好多好多。总结一下可能会出现的问题:

加上了这些检查,强壮之后的代码可能是这样的:

listen( "click", function handler( evt){ 
  check1();
  doSomething1();
  setTimeout( function request(){ 
    check2();
    doSomething3();
    ajax( "http:// some. url. 1", function response( text){ 
      if (text == "hello") { 
        handler(); 
      } else if (text == "world") { 
        request(); 
      } 
    }); 
    doSomething4();
  }, 500); 
  doSomething2();
});

我们都清楚的知道,实际的check要比这里看起来的复杂的多,而且很多很难复用。这不但使代码变得臃肿不堪,还进一步加剧了可读性的问题。

虽然这些错误出现的概率不大,但我们依然必须要处理。

这就是异步嵌套带来的信任问题,它的问题的根源在于控制反转。控制反转在面向对象中的应用是依赖注入,实现了模块间的解耦。而在回调中,它就显得没有那么善良了,控制权被交给了第三方,由第三方决定什么时候调用回调以及如何调用回调。

一些解决信任问题的尝试

加一个处理错误的回调

function success(data) { 
  console. log(data); 
} 
function failure(err) { 
  console. error( err ); 
} 
ajax( "http:// some. url. 1", success, failure );

nodejs的error-first

function response(err, data) { 
  if (err) { 
    console. error( err ); 
  } 
  else { 
    console. log( data ); 
  } 
} 
ajax( "http:// some. url. 1", response );

这两种方式解决了一些问题,减少了一些工作量, 但是依然没有彻底解决问题。首先它们的可复用性依然不强,其次,如回调被多次调用的问题依然无法解决。

Promise如何解决这两个问题

Promise已经是原生支持的API了,它已经被加到了JS的规范里面,在各大浏览器中的运行机制是相同的。这样就保证了它的可靠。

如何解决可读性的问题

这一点不用多说,用过Promise的人很容易明白。Promise的应用相当于给了你一张可以把解题思路清晰记录下来的草稿纸,你不在需要用脑子去记忆执行顺序。

如何解决信任问题

Promise并没有取消控制反转,而是把反转出去的控制再反转一次,也就是反转了控制反转。

这种机制有点像事件的触发。它与普通的回调的方式的区别在于,普通的方式,回调成功之后的操作直接写在了回调函数里面,而这些操作的调用由第三方控制。在Promise的方式中,回调只负责成功之后的通知,而回调成功之后的操作放在了then的回调里面,由Promise精确控制。

Promise有这些特征:只能决议一次,决议值只能有一个,决议之后无法改变。任何then中的回调也只会被调用一次。Promise的特征保证了Promise可以解决信任问题。

对于回调过早的问题,由于Promise只能是异步的,所以不会出现异步的同步调用。即便是在决议之前的错误,也是异步的,并不是会产生同步(调用过早)的困扰。

var a = new Promise((resolve, reject) => {
  var b = 1 + c;  // ReferenceError: c is not defined,错误会在下面的a打印出来之后报出。
  resolve(true);
})
console.log(1, a);
a.then(res => {
  console.log(2, res);
})
.catch(err => {
  console.log(err);
})

对于回调过晚或没有调用的问题,Promise本身不会回调过晚,只要决议了,它就会按照规定运行。至于服务器或者网络的问题,并不是Promise能解决的,一般这种情况会使用Promise的竞态APIPromise.race加一个超时的时间:

function timeoutPromise(delay) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      reject("Timeout!");
    }, delay);
  });
}

Promise.race([doSomething(), timeoutPromise(3000)])
.then(...)
.catch(...);

对于回调次数太少或太多的问题,由于Promise只能被决议一次,且决议之后无法改变,所以,即便是多次回调,也不会影响结果,决议之后的调用都会被忽略。

参考资料: