youngwind / blog

梁少峰的个人博客
4.66k stars 385 forks source link

你不知道的回调、异步与生成器 #96

Open youngwind opened 7 years ago

youngwind commented 7 years ago

为什么

关于回调、异步与生成器,网上的文章已经很多了,而且很久以前我也写过类似的一篇 #49 。 为什么现在还要写这个呢? 原因:最近我在看《你不知道的JavaScript中卷》,发现书中一些少有且独特的观点,是我以往所不知道的,也是已有文章很少提到的,所以便有了此文。 (注:本文观点绝大多数来自于《你不知道的JavaScript中卷》第二部分的第1、2、4章,经二次演绎而成。强烈推荐阅读《你不知道的JavaScript》系列书,绝对的不容错过。)

阅读前请确保熟悉以下概念:

  1. JS事件循环
  2. generator
  3. promise
  4. TJ写的co库
  5. ES7 stage2的async

回调不仅仅是代码缩进

长久以来,JS实现异步只能用回调这一种方式。随着应用的渐趋复杂,过度嵌套回调的弊端渐渐显现,最为人们所诟病的就是层层嵌套导致的代码缩进,俗称回调金字塔。 我一开始对回调弊端的认识也仅限于此。然而,我发现我错了。因为代码的缩进问题可以通过工厂模式抽象来缓解,这并非很严重的问题。那么,过度的回调嵌套还有什么更严重的问题吗?

顺序的大脑

请观察下面的伪代码。

doA(function(){
  doB();
  doC(function() {
    doD();
  });
  doE();
});
doF();

无论多么熟悉JS异步的人,要完全搞懂这段代码实际的运行顺序,恐怕也得思考一番。 为什么会这样?因为人的大脑是顺序的,天生适合顺序的思考,难以理解(不是不能理解)非顺序的东西。 无论是在书写还是在阅读这段代码的时候,我们的大脑都会下意识地以为这段代码的执行逻辑是这样的doA→doB→doC→doD→doE→doF,然而实际运行逻辑很可能(假设)是这样的doA→doF→doB→doC→doE→doD。 这是回调嵌套另一个严重的问题。还有其他问题吗?

下一步该做什么

让我们来进行一个思想实验:两种游戏。

  1. 第一种游戏,举办方提前跟你说:“这游戏总共有X关。第一关你应该做.....然后在....(地方)进入第二关。第二关你应该做....然后在....(地方)进入第三关。……"。我称之为呆板的游戏
  2. 第二种游戏,举办方提前跟你说:”你只管从这个门口进去,等你快到下一关的时候,自然会有人出来给你提示。“我称之为灵活的游戏。

我个人更喜欢玩后者,也就是灵活的游戏。因为它有两个特点:

  1. 游戏很灵活。我根本不知道下一关会是什么,这充满未知的期待。
  2. 我不需要顾虑在哪儿进入下一关,因为到时候会有人给我提示。我只需要专心完成当前这一关就好了。

对应到代码当中,我们便能发现回调的另一个严重问题:硬编码前后的操作被回调强制硬编码绑定到一起了。在调用函数A的时候,你必须指定A结束之后该干什么,并且显式地传递进去。这样,其实你已经指定了所有的可能事件和路径,代码将变得僵硬且难以维护。同时,在阅读代码的时候,由于必须记住前后的关联操作,这也加重了大脑记忆的负担。

so,让我们总结一下回调的弊端:

  1. 代码缩进造成金字塔(小问题)
  2. 嵌套的书写方式与人类顺序大脑思考方式相违背(大问题)
  3. 前后操作被硬编码绑定在一起,代码变得僵硬,难以维护。(大问题)

为了解决过度回调导致的各种问题,无数卓绝的先驱创造了一个又一个的方法:promise、generator、co、async等等。在此,我不打算详细将讲这些,因为已经有很多文章讲得很好了,例如这个。下面我们继续来探索一下异步的本质。

谁的异步

以Ajax为例。我们都知道,在Ajax执行成功之后,指定的回调函数会被放入”任务队列“中。JS执行引擎在主线程空闲的时候便会轮询任务队列,执行其中的任务。 我们仔细想想,是不是漏了一个关键点:”我知道最终是JS引擎执行了这个回调函数。但是,到底是谁调度这个回调函数的?到底是谁在特定的时间点把这个回调函数放入任务队列中去?“ 答案是宿主环境,在本例中也就是浏览器。是浏览器检测到Ajax已经成功返回,是浏览器主动将指定的回调函数放到”任务队列”中,JS引擎只不过是执行而已。

由此,我们澄清了一件(可能令人震惊)的事情: 在回调时代,尽管你已经能够编写异步代码了。但是,其实JS本身,从来没有真正內建直接的异步概念,直到ES6的出现。 事实就是如此。JS引擎本身所做的只不过是在不断轮询任务队列,然后执行其中的任务。JS引擎根本不能做到自己主动把任务放到任务队列中,任务的调度从来都是宿主完成的。举个形象的例子就是:“JS引擎就像是流水线上的工人,宿主就像是派活的老板。工人只知道不断地干活,不断地完成流水线上出现的任务,这些任务都是老板给工人指定的。工人从来没有(也不能)自己给自己派活,自己给自己的流水线上放任务。”

所以,这是JS引擎与宿主之争。ES6从本质上改变了在哪里管理事件循环,这意味着在技术上将其纳入了JavaScript引擎的势力范围,而不再是由宿主来管理。

===2016.11.4更新=== 经@riskers提醒,补充此处内容。 promise本质上与setTimeout等不同,他们是两个不同的队列,有先后执行的顺序关系。 此处涉及概念颇为复杂,我并未完全理解。所以,关于这个的更多内容,请参考这个链接

顾名思义

问题:generator不是用来处理异步的吗?那为什么要叫这个名字呢? 答案:generator是可以用来处理异步,但是它不仅仅是用来处理异步。或者说,本质上,generator是一个函数,它执行的结果是一个iterator迭代器,每一次调用迭代器的next方法,就会产生一个新的值。迭代器本身就是用来生成一系列值的,同时也广泛应用于扩展运算符...、解构赋值和for...of循环遍历等地方。

问题:为什么要用yield作为关键字? 答案:在英语中,yield有两层含义:让位与产出。

  1. 让位是什么意思?就是交出程序的执行权。在ES6执行,JS的函数都是一次性执行完成的。也就是说,函数一旦开始执行,就根本停不下来,直到全部执行完。生成器的引入打破了这一局面。每次调用next,执行到yield,函数便会交出执行权,让其他代码得以运行,它自己则等待下一次next指令的到来。
  2. 产出是什么意思?产出对应于迭代器。每次yield都会返回一个结果,传递到迭代器的res.value中去。同时,在调用next方法的时候也可以传递新的参数进去。就是这样一个不断输入、输出的过程,而且这个过程是可随意终端、重启的。

如果你能理解下面的例子,那么算是对生成器基本入门了。

function* foo(x){
  let y = x * (yield);
  return y;
}

let it = foo(6);
let res = it.next();  // res是什么
res = it.next(7);     // res是什么

异步的演化

最后,我写了一个实际的列子,分别采取回调、promise、generator与co、async这四种方法,演示了JS实现异步的演化进程。 本例所要完成的功能是:按顺序执行三次setTimeout,并且在指定的时间之后打印出当前的时间。 (注:下面的例子均可直接运行。推荐一个插件,通过它可以直接在chrome中运行ES6的代码,再也不用自己去折腾babel那些东西了,非常的方便。)

回调

setTimeout(() => {
  console.log(1, new Date());
  setTimeout(() => {
    console.log(2, new Date());
    setTimeout(() => {
       console.log(3, new Date());
    },2000)
  }, 1000);
},1000);

promise

function p(time){
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(new Date());
    }, time)
  });
}

p(1000).then((data) => {
  console.log(1, data);
  return p(1000);
}).then((data) => {
  console.log(2, data);
  return p(2000);
}).then((data) => {
  console.log(3, data);
})

generator与co

function p(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(new Date());
    }, time)
  });
}

function* delay(){
  let time1 = yield p(1000);
  console.log(1, time1);

  let time2 = yield p(1000);
  console.log(2, time2)

  let time3 = yield p(2000);
  console.log(3, time3);
}

function co(gen){
  let it = gen();
  next();
  function next(arg){
    let ret = it.next(arg);
    if(ret.done) return;
    ret.value.then((data) => {
      next(data)
    })
  }
}

co(delay);

async

function p(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       resolve(new Date());
    }, time)
  });
}

(async function(){
  let time1 = await p(1000);
  console.log(1, time1);

  let time2 = await p(1000);
  console.log(2, time2)

  let time3 = await p(2000);
  console.log(3, time3);
})()

====EOF====

riskers commented 7 years ago

ES6从本质上改变了在哪里管理事件循环,这意味着在技术上将其纳入了JavaScript引擎的势力范围,而不再是由宿主来管理

这里没有说清楚,应该还描述一下ES6(比如Promise)是在宿主(比如浏览器)的事件、定时器任务队列的task完成后执行

youngwind commented 7 years ago

这里没有说清楚,应该还描述一下ES6(比如Promise)是在宿主(比如浏览器)的事件、定时器任务队列的task完成后执行

@riskers 多谢指出!确实,这个部分我当时并没有想清楚,所以没详细展开。后来我又去研究了一番,主要参考这份资料。个人认可以下Macro-task和Micro-task的观点。

在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。 两个类别的具体分类如下: macro-task: script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering micro-task: process.nextTick, Promises(这里指浏览器实现的原生 Promise), Object.observe, MutationObserver

出处:【翻译】Promises/A+规范

riskers commented 7 years ago

@youngwind 是的,就是这个意思 👍

dukkha-s commented 7 years ago

这里没有说清楚,应该还描述一下ES6(比如Promise)是在宿主(比如浏览器)的事件、定时器任务队列的task完成后执行

Promise 应该是先执行,不是后执行。

riskers commented 7 years ago

@imasshole,是后执行的。

setTimeout(function(){
  console.log(1)
},0)

new Promise(function(resolve, reject){
    console.log(2)
    resolve('resolve')
}).then(function(){
    console.log(3)
})

比如这段代码,依次输出 2 3 1。代码执行顺序是:

  1. 整体代码作为 macro-task ,输出 2 (Promise内部是同步的)
  2. 然后执行 mirco-task ,这里也就是 Promise 的resolve了,输出 3
  3. 再之后执行 macro-task ,这里就是 1 了
waitinghope commented 7 years ago

总结的很好,可是我还是觉得中卷这部分翻译的很糟糕。

dukkha-s commented 7 years ago

@riskers 是的,我是说第二步的 micrco-task 是在第三步的 macro-task 之前执行, 你说的是第一步的task 是在第二步的mirco-task前执行,这是没错的。

不过第一步的貌似不能叫 macro-task ,就是正常的 task。

youngwind commented 7 years ago

@waitinghope 我也觉得中卷读起来没那么流畅,可能是随着内容的渐渐深入,翻译的难度比上卷要大了。

riskers commented 7 years ago

@imasshole task 还是 macro-task ,我看到的资料叫法都不一样。