Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
youngwind opened this issue Nov 1, 2016 · 9 comments
Open

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

youngwind opened this issue Nov 1, 2016 · 9 comments
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Nov 1, 2016

为什么

关于回调、异步与生成器,网上的文章已经很多了,而且很久以前我也写过类似的一篇 #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====

@youngwind youngwind changed the title 异步、回调、生成器函数与co 你所不知道的回调、异步与生成器 Nov 1, 2016
@youngwind youngwind added the JS label Nov 1, 2016
@youngwind youngwind changed the title 你所不知道的回调、异步与生成器 你不知道的回调、异步与生成器 Nov 1, 2016
@riskers
Copy link

riskers commented Nov 3, 2016

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

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

@youngwind
Copy link
Owner Author

这里没有说清楚,应该还描述一下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
Copy link

riskers commented Nov 4, 2016

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

@dukkha-s
Copy link

dukkha-s commented Nov 4, 2016

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

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

@riskers
Copy link

riskers commented Nov 4, 2016

@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
Copy link

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

@dukkha-s
Copy link

dukkha-s commented Nov 9, 2016

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

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

@youngwind
Copy link
Owner Author

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

@riskers
Copy link

riskers commented Nov 9, 2016

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants