YSMull
<-- home

理解 JavaScript 异步编程(一)

先从同步操作说起。

首先我们有一个函数,可以返回把传入参数加一的结果。

var syncPlusOne = function(a) {
  return a+1
}

如果我们需要把每一次调用的结果作为下一次调用的参数,就这样调用:

var continuousSyncPlusOne = function (s) {
  var a = syncPlusOne(s)
  var b = syncPlusOne(a)
  var c = syncPlusOne(b)
}

如果我们现在有一个异步Plus函数呢?

var wrongAsyncPlusOne = function(a) {
  setTimeout(function() {
    return a+1
  }, Math.random()*1000)
}

调用这个函数,会直接返回undefined,因为异步过程会直接返回,而不会阻塞。对于一个异步过程,我们有两种方法可以得知它的计算过程是否结束。

一种办法是轮训,比如如下代码:

var tv
setTimeout(function (){
  tv = 1
}, 1000);
while (true) {
  if (tv != undefined) {
    console.log("tv is set to :" + tv);
    break;
  }
}

我们在while循环中不断的轮询tv的值,直到tv被赋值。但很不幸,这个代码并不会按照我们的想法执行,这与javascript的运行机制有关,这里不展开讲,正确的写法如下:

var tv
setTimeout(function (){
  tv = 1
}, 1000);

var intervalId = setInterval(function() {
  if (tv != undefined) {
    console.log("tv is set to :" + tv);
    clearInterval(intervalId);
  }
}, 0)

另一种方法,是这个函数执行结束后自己告诉我们:

var tv
setTimeout(function (){
  tv = 1
  console.log("tv is set to :" + tv)
}, 1000);

我们在函数体内调用 console.log() 来表示 tv 的值已经被计算好了,本质上是将 tv 计算好之后的值,主动传递给了后续过程。如果把需要这个异步计算结果的函数作为参数传递进来,并在计算结束后将结果传递给它,那么这个函数就被称为回调函数
setTimeout的第一个参数传入的也是一个回调函数,当计时结束后,会调用回调函数。其实 setTimeoutsetInterval 的第三个可选参数可以给回调函数传参。)

那么重新改写我们之前的异步Plus如下:

var asyncPlusOne = function(a, callback) {
  setTimeout(function() {
    callback(a+1)
  }, Math.random()*1000)
}

如果我们需要得到某个数字连续调用asyncPlusOne之后的结果,则要写为:

var continuousAsyncPlusOne = function(s) {
  asyncPlusOne(s, function(a) {
    asyncPlusOne(a, function(b) {
      asyncPlusOne(b, function(c) {
        console.log(c); // 3
      })
    })
  })
}
continuousAsyncPlusOne(0);

es6语法可以这样写:

var continuousAsyncPlusOne = function(s) {
  asyncPlusOne(s, (a) => {
    asyncPlusOne(a, (b) => {
      asyncPlusOne(b, (c) => {
        console.log(c); // 3
      })
    })
  })
}

第一次写这种代码,感觉还挺酷的样子。但是久而久之,代码中到处都是这种callback结构,太不简洁了,这被称为 callback hell

那么我们如何把异步调用变成同步的书写方式呢?比如像这样子:

var a = asyncPlusIWant(s)
var b = asyncPlusIWant(a)
var c = asyncPlusIWant(b)
console.log(c);

我们希望:

  1. asyncPlusIWant() 是异步的
  2. 它能立即返回计算之后的结果(而不是undifined)

这两条不是自相矛盾吗。唉,如果我们的语句走到 asyncPlusIWant() 时,能够停下来,等待这个异步过程执行完成之后,再继续执行下面的语句,就好了。

Wait! 停下来再继续,好熟悉的东西,ES6 引入的 Generator 不是可以做到这件事吗!
把我们的语句放到 Generator 中看看!

var genAsync = function* (s) {
  var a = yield asyncPlusOne(s)
  console.log(a) // undefined
  var b = yield asyncPlusOne(a)
  var c = yield asyncPlusOne(b)
}
console.log(g.next().value); // undefined

并没有得到正确的输出,不是说好的会停下来的吗?
其实这里确实停了,只不过不是我们想象的那种停,这里两个地方输出都是undefined原因如下:

  • console.log(a) 输出 undefined 是因为 yield <expression> 的值取决于外部调用next时传入的值,外部调用g.next()时候并没有传值进去。
  • console.log(g.next().value) 输出 undefined 是因为 g.next().value 应该拿到的是表达式 asyncPlusOne(s) 的值,而 asyncPlusOne 是异步的,它直接返回了 undefined

那么如何才可以结合 Generator 函数实现异步过程的同步调用呢?

为了实现我们的目标,考虑如下辅助函数:

var g = function(f){
  return function (args){
    return function (callback){
      return f(args, callback)
    }
  }
}

那么

\[\mathrm{f(s, callback) = g(f)(s)(callback)}\]

好吧,你看出来了,这里就是做了一个柯里化。

那我们令

\[\mathrm{asyncPlusIWant = g(asyncPlusOne)}\]
var asyncPlus = function (a, callback) {
  setTimeout(function () {
    callback(a + 1);
  }, Math.random() * 1000);
}

var asyncPlusIWant = g(asyncPlus)

var genAsync =  function* (s) {
  var a = yield asyncPlusIWant(s);
  console.log('async a: ' + a)
  var b = yield asyncPlusIWant(a);
  console.log('async b: ' + b)
  var c = yield asyncPlusIWant(b);
  console.log('async c: ' + c)
  return c+1
};

var run = function run(gen) {
  function go(lastRes) {
    // 1.[唤醒],把上一次计算出的值放到LHS(left-hand side),然后移动到下一个异步调用的位置停下来
    var res = gen.next(lastRes);
    if (res.done) return;
    // 2.[执行],调用res.value(go)将开始执行异步计算,计算完成后调用会调用go继续唤醒generator
    res.value(go);
  }
  go()
}
run(genAsync(0));

这里的辅助函数 g 被称作 Thunk 函数

var Thunk = function(fn){
  return function (){
    var args = Array.prototype.slice.call(arguments);
    return function (callback){
      args.push(callback);
      return fn.apply(this, args);
    }
  };
};

var thunkFunc = Thunk(func); 可以把fn包裹成另一个函数 thunkFunc
他改变了原来 func 的调用方式:

func(arg_1, arg_2, ... , arg_k, callback)
func(arg_1, arg_2, ... , arg_k)(callback)

通过上面的 run() 方法,执行 run(genAsync) 就可以依次执行三个异步函数了。

这件很酷的事情本质是什么?

使用 Thunk 和 Generator 使异步函数以同步的方式书写,本质是:

Thunk 把异步函数的「执行」从 Generator 内,分离到了 Generator 外部,把 Generator 的「唤醒」放到了「在外部执行的异步操作的回调函数内」,所以整个执行流程变成了,在外部执行的异步函数,通过回调,不断的去唤醒 Generator 继续执行后续操作。