JavaScript 异步发展史

1,299 阅读9分钟

前言


由于 JavaScript 语言的执行环境是单线程,如果没有异步编程,那么稍微一点耗时的计算过程或者IO等待都会使得线程卡死在等待。

JavaScript 中异步发展史一共有4个比较重要的节点。

分别为

callback -> Promise -> Generator -> Async

概念

同步

同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)
所谓同步,就是在发出一个 调用 时,在没有得到结果之前,该 调用 就不返回。但是一旦调用返回,就得到返回值了。
换句话说,就是由 **调用者 **主动等待这个 调用 的结果。

在代码里面可能就是执行的一段业务计算,如果是大批量的计算就会消耗大量的时间,使线程阻塞。

异步

而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在 调用 发出后,**被调用者 **通过状态、通知来通知调用者,或通过回调函数处理这个调用。

Node.js 中的 fs 模块里面的 readFile 就是例子. 通过回调函数去处理结果。

概念举例理解


首先我们来看一道题目

小明做家务,预计洗衣服需要 1 小时,扫地需要 0.5 小时,请问小明做完家务最少需要多小多长时间?

同步

根据上面的题目来说,我们可能会马上想到,先洗衣服 1 小时,然后接着扫地 0.5 小时, 做完家务最短时长为 1.5 小时,这种思维就是同步了。

也就是说,同步就是你认为事情只能接着一个个去完成。前面的任务不完成会阻塞后面任务的开始。

异步

但是其实我们更贴近生活是这个样子的,我们先把衣服丢进洗衣机,之后开始扫地,扫地需要 0.5 小时,这时候我们还能挽回玩会手机再去晾衣服~最终做完家务的最短时间为 1 小时。

这里异步的精髓就是,我们可以同时做几件事情,他们互不冲突。

在 JS 当中表示为 回调函数/setTimeout/setInterval/Ajax 等等,因为 JS 就是一门单线程,多任务的语言。

从 eventlop 说起


JavaScript 是单线程的,所以如果任务都是同步进行的话,它的执行效率就会很低(或者说是浪费系统资源)

如以下代码

const fs = require('fs');


function main() {
    try {
        const text1 = fs.readFileSync('./text1.txt', 'utf-8')
        const text2 = fs.readFileSync('./text2.txt', 'utf-8')

        console.log(text1, text2)
    } catch(error) {
        console.error(error)
    }
}

console.log('start')

main()

console.log('end')


这里程序需要先处理读取文件 text1.txt 后才能继续读取 text2.txt 文件

读取的过程中,程序也不会继续往下执行,换言之就是会阻塞在 main 函数里面等待 IO 读写操作的完成

所以在 Js 中,IO 处理等等的操作,一般是建议用回调去进行处理的。

上面的代码修改为

const fs = require('fs');

function main() {
  fs.readFile('./text1.txt', { encoding: 'utf-8'}, (err, data) => {
    console.log(err, data);
  })

  fs.readFile('./text2.txt', { encoding: 'utf-8' }, (err, data) => {
    console.log(err, data);
  })
}

console.log('start')

main()

console.log('end')


每当执行 IO 文件读写的时候,Node.js 会执行一个 I/O 任务,去执行需要的读写操作,然后这个任务执行完成后会把我们的回调任务塞回去任务队列中。

当我们主线程忙完了手头上的任务,就会从头到位检查一遍各种类型的里面是否存放着未执行的回调函数,有就清空。如此循环

更多详细请参考大佬的 Blog , 这里不再作详细叙述。

callback


从上面我们可以了解到,JS 是单线程的,为了高效率的处理问题,很多需要耗费时间的操作,都会使用异步任务去执行。

执行完成后会把异步完成的回调函数推入 eventlop 等待线程空闲取出执行。

到这里读者应该了解为什么要用回调函数了。

因为许多 node api 比如 fs.readFile 这种可能需要耗时比较长的任务都会阻塞线程,影响效率。

因此早期的 node.js 的代码容易让人写成 callback hell(回调地狱)

代码来自著名的 Promise 模块 q 的文档。

step1(function (value1) {
    step2(value1, function(value2) {
        step3(value2, function(value3) {
            step4(value3, function(value4) {
                // Do something with value4
            });
        });
    });
});


后续出现了 async.js 来缓解这种尴尬的回调地狱问题,但学习成本仍旧有点高,且非通用型异步解决方案。

async.map(['file1','file2','file3'], fs.stat, function(err, results) {
    // results is now an array of stats for each file
});

Promise


后面 CommonJs 社区提出了 很多的异步解决方案,其中一个就是 Promise/A 规范,后面人们在此基础上提出了 Promise/A+译文), 规范,也就是现在业内流行的标准。

先简单的认识一下 Promise 的语法

// 场景一个 Promise 对象

// new 一个 Promise 里面传入一个回调函数,参数是2个用来改变 Promise 状态的接口
// 这里的作用是让线程等待3秒
let p = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('hello world')
    }, 3000)
})

p.then(value => {
    console.log(value) // 3秒后打印 hello world

    throw new Error('something error')
})
.catch(err => { // 捕获异常
    console.err(err)
})


此时我们用 Promise 的写法把上面的代码重构一下,假设每个 setp 函数都被封装成了 Promise

step1(...args)
    .then(value => {
        return step2(value)
    })
    .then(value => {
        return step3(value)
    })
    .then(value => {
        return step4(value)
    })
    .then(value => {
        // do something
    })


// 继续简化
step1(...args)
    .then(step2)
    .then(step3)
    .then(step4)
    .then(value => {
        // do something
    })


我们在每一个 then 函数里面处理业务逻辑,不用再去嵌套的写 callback 了。

但由于官方支持的 Promise 一开始存在着内存泄漏的情况,所以社区实现了一个遵循 Promise/A+ 规范的三方库 bluebird

且性能相对来说要比原生提供的好,如果是 node.js 项目,可以简单1行代码就能提升性能。

global.Promise = require('bluebird')


Promise源码学习推荐此篇文章

generator

简介


这是 JavaScript 中协程的一种实现(多个线程协作)

这也是 es6 的一个新特性,起初是用来做计算的,但是著名的 Node.js 贡献者 TJ 想用来作为流程控制使用。

所以编写除了 co ,koa 1.x 也是基于 generator 实现的。

直到 2.x es7 的 async await 普及之后才迁移之 async 函数。

有人就要问了,这个是如何用来作为流程控制的?

语法介绍


我们先来看看 generator 的语法

function* gen() {
    yield 1
    yield 2
}

const g = gen()

g.next(); // { value: 1, done: false }
g.next(); // { value: 2, done: false }
g.next(); // { value: undefined, done: true }


用 * 号标识这个函数为 generator 函数

里面用 yeild 关键字来释放函数执行权,此时该函数会保留上下文先挂起,等待调用 next 后继续推入 eventloop 执行。

流程控制


因为 generator 函数不会自动执行,所以不能直接作为流程控制。

下面介绍一下 generator 流程控制的2个方案

  1. thunk


thunk 函数的含义是 “传名调用” 的实现,我们会把函数本身的参数先放在一个 临时函数里面存储起来,这个函数就叫做 thunk.

首先我们需要一个包装函数, 把拥有 callbck 的函数都转换成最后接收 callback 的函数。

function thunk(fn) {
  var that = this;
  return function(...args) {
    return function(callback) {
      args.push(callback);
      return fn.apply(that, args)
    }
  }
}


接着我们来看看普通的 thunk 函数的执行过程

import fs, { readFile } from 'fs'

const readFileThunk = thunk(readFile)

// 文件以及内容示意 -> 后面是该文件内容
// a.txt -> b.txt
// b.txt -> hello world

function* gen() {
  const fileName = yield readFileThunk('./a.txt')
  const data2 = yield readFileThunk(fileName.toString())

  console.log(data2.toString());
}

const g = gen()

// 我们传入 next 中的参数就是下一次该函数被调起的返回值

g.next().value((err, data) => {
  console.log(data.toString()); // a.txt
  g.next(data.toString()).value((err, data) => {
    console.log(data.toString()); // b.txt
  })
})


现在熟悉了这个自动执行的规则,我们就可以实现一个函数让它自动执行了。

function run(gen) {
  const g = gen()

  function next(data) {
    var current = g.next(data)
    if (current.done) return
    current.value((err, data) => {
      next(data)
    })
  }

  next()
}

run(gen) // hello world


通过递归,不停的执行 yield 对象的回调函数,同时通过 next(data) 往下传递需要的参数进去, 最终 done = true 即流程结束

  1. co


结合 Promise 进行流程控制

// 延迟多小秒
const sleep = time => new Promise(resolve => setTimeout(() => resolve(time), time))

function* gen() {
  const time1 = yield sleep(1000)
  const time2 = yield sleep(time1 * 1.5)
  const time3 = yield sleep(time2 * 1.5)

  console.log(time3);
}

const g = gen()

g.next().value.then((data) => {
  g.next(data).value.then(data => {
    console.log(data); // 1500
    g.next(data).value.then(data => {
      console.log(data); // 2250
    })
  })
})


可以看到我们是通过不断的 执行 next 之后 直到 then 执行后才进行下一个函数的执行,以及将参数传递到 next 中

接下来我们实现一个简单的流程自动执行。

function co(gen) {
  const g = gen();

  return new Promise(resolve => {
    function next(data) {
      var current = g.next(data)

      if (current.done) return resolve(data)

      current.value.then(next)
    }
    next()
  })
}

co(gen).then(res => {
  console.log(res);  // 2250 
})


实现上 跟 thunk 的自动流程控制大同小异,不同的是这是通过 Promise 的 then 状态变更机制达到的往下一个流程走的目的。

这里的实现只是一个简略版,更多的处理没有做,如果感兴趣的同学可以去查看 co 的具体实现。

es7 async await


这目前来说是业界里最流行的异步流程控制写法。

弥补了 Promise 写起来业务关联性低的问题。

接下来我们来看简单的使用例子

// 延迟多小秒
const sleep = time => new Promise(resolve => setTimeout(() => resolve(time), time))

async function f() {
  const time1 = await sleep(1000)
  const time2 = await sleep(time1 * 1.5)
  const time3 = await sleep(time2 * 1.5)

  return time3
}

f().then((data) => {
  console.log('执行完了', data)
})


我们可以很轻易的就实现一个看起来同步的异步流程控制

接下来我们看一看在 babel async await 会被编译成什么~

image.png


可以看到,我们的 async 函数最终会被编译成 generaotr 函数。

然后 generator 函数的话,我们也来编译一下
image.png


最终实现是这个样子,由外部的一个 context 变量去保存着内部的上下文变量,直到执行完。

总结


在本篇文章中,作者梳理了自己对 JavaScript 发展史的认知,从一开始的 callback 到 现代化的 es7 async。虽然现在主流语法是 async 但是赢家似乎是 JavaScript 里协程的实现 generator,但是这个作为流程控制使用起来不太好,还需要去了解一堆概念。

普通场景的话,还是使用 Promise 即可,Node.js 的话,可以使用 bluebird 去提高性能。业务流程的话,我们可以使用 async 函数使其流程更加清晰!

参考文章

  1. 阮一峰 generator
  2. 狼书第一版 异步