Javascript异步编程超进化

690 阅读8分钟

Javascript异步编程超进化

js是单线程语言,这就是js设计之初就决定好的,并且在未来也不会改变。因此js并没有多线程那样的同步互斥问题。但单线程也意味着同一时间只能做一件事。如果有些任务耗时很久,那么整个应用就会被停住直到该任务完成为止。为了解决这个问题,js引入了异步编程。

异步编程可用于处理不能立即得到结果的I/O操作,如网络请求,文件读写等操作,保证应用不会因此被卡住。历史的车轮滚滚向前,异步编程的方式也在不断的进化,主要经历了回调函数,Promise,Generator, Async/Await几个过程,现在写异步代码已经可以写的像同步代码一样了。

async programming evolution

同步 vs 异步

同步是执行完一个任务后,再继续执行下一个任务,符合线性思维,所以理解起来很容易。而异步则是将一个任务拆分成为多个部分,先执行一部分,然后去执行其他任务,等到合适的时机再回过头来执行后面的部分,正是这种不连续的执行,造成了我们写异步代码的困难。

sync vs async 以下面代码为例:

function syncTask() {
  console.log("start sync task");
  console.log("end sync task");
}

function asyncTask() {
  console.log("start async task");
  setTimeout(() => {
    console.log("end async task");
  }, 1000);
}

function doSomething(fn) {
  console.log("start");
  fn();
  console.log("end");
}

// 同步任务
doSomething(syncTask);
// start
// start sync task
// end sync task
// end

// 异步任务
doSomething(asyncTask);
// start
// start async task
// end
// end async task

如果传入的fn是同步任务,doSomething中第二个console.log会在其结束执行后才执行,而如果fn是异步任务,那么console.log就不会等到该异步代码执行完毕。

回调函数

回调函数是异步操作最基本也是最原始的方法。以发送请求为例:

const fetchData = (url, callback) => {
  const xhr = new XMLHttpRequest();

  xhr.onreadystatechange = () => {
    if (xhr.readyState === 4 && (xhr.status === 200 || xhr.status === 204)) {
      callback(xhr.response);
    }
  };

  xhr.open("get", url, true);
  xhr.send();
};

fetchData("/api/example", (data) => {
  console.log("result: ", data);
});

我们将XMLHttpRequest相关配置封装在fetchData函数中,并传递一个回调函数用于处理响应结果,这种处理方式,将处理逻辑分散到了几个不同的地方,不便于理解,但好处是实现简单。

回调函数有一个致命的缺点,就是容易产生回调地狱,试想如果需要在发送请求/api/example-a后,根据拿到的结果继续发送请求/api/example-b,然后再根据拿到的结果发送请求/api/example-c,最后得到的结果才是我们想要的,那么代码会变成:

fetchData("/api/example-a", (a) => {
  fetchData(`/api/example-b?a=${a}`, (b) => {
    fetchData(`/api/example-c?b=${b}`, (c) => {
      console.log("result: ", c);
    });
  });
});

可以看到各个部分高度耦合,结构混乱,同时难以使用try/catch捕获异常。这简直是一团乱麻。

Promise

时间继续向前,随着Promise的出现,使得js的异步编程前进了一大步。Promise的本意是承诺,即承诺会在未来的某个时间点返回异步的结果,我们可以自己决定如何使用这个结果,且Promise一旦完成,那么状态就不会在发生改变。更详细的Promise使用详见Promise对象

promise 我们可以将上面封装的异步操作Promise化:

const fetchData = (url) => {
  return new Promise((resolve) => {
    const xhr = new XMLHttpRequest();

    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4 && (xhr.status === 200 || xhr.status === 204)) {
        resolve(xhr.response);
      }
    };

    xhr.open("get", url, true);
    xhr.send();
  });
};

fetchData("/api/example").then((data) => {
  console.log("result: ", data);
});

当然,现代浏览器也提拱了fetch函数,它是基于Promise实现的,比传统的XMLHttpRequest更现代化,使用fetch能帮助我们更方便的完成请求操作:

fetch("/api/example").then((response) => {
  console.log("response: ", response);
});

fetch

Promise也提供了错误捕获机制,可以通过then方法第二个参数,或者catch方法捕获执行过程中的异常:

const p1 = new Promise((resolve, reject) => {
  throw Error("error");
}).catch((error) => {
  console.log("error: ", error);
});

那么回调函数和Promise的区别是什么呢?打个比方:如果我们去银行取钱,在取到钱后希望买点东西。那么买东西这件事是在取钱这个异步操作(毕竟银行办事效率......)结束后的操作。使用Promise就相当于是银行承诺把钱取出来后,会通知我们过去拿钱,我们在拿到钱后自己处理,可以买点东西,也可以零时决定不买。但使用回调函数却是,我们把买东西这件事委托给银行,银行在取完钱后就帮我们买东西,但是既然是我们把回调函数传递给其他函数,那么我们回调函数的触发时机就是受其他函数控制,该函数可能完全不调用我们的回调,也可能多次调用,所以银行可能按照我们的要求买东西,也能买多份:

// callback
withdrawMoney(buySomeStuffs);
// promise
withdrawMoney().then(buySomeStuffs);

可以看到Promise能将控制反转,很好的解决信任问题。

除此之外,Promise还具有链式调用以及值穿透的特性,也能很好的解决回调地狱的问题:

fetchData("/api/example-a")
  .then((a) => {
    return fetchData(`/api/example-b?a=${a}`);
  })
  .then((b) => {
    return fetchData(`/api/example-c?b=${b}`);
  })
  .then((c) => {
    console.log("result: ", c);
  });

通过Promise.all还可以实现多路并发请求数据:

Promise.all([fetchData("/api/example-a"), 
             fetchData("/api/example-b"), 
             fetchData("/api/example-c")])
  .then(([a, b, c]) => {
    console.log("result: ", a, b, c);
  }
);

Promise可以说是js现代异步编程的基石,后续一系列的异步编程的进步都脱离不了Promise,但Promise也并不完美:一旦触发就不能取消,链式同样会把逻辑分散到Promise的语法中,不便于阅读。

Generator

我们的终极理想是希望像写同步代码一样书写异步代码。借助于Generator,我们几乎可以做到这一点。Generator最大的特点是可以控制函数的执行,和交出控制权。更详细的Generator使用详见Generator 函数的语法

Generator函数与普通函数最大的区别是函数名带*号,且内部可使用yield关键字:

function* gen() {
  console.log("gen 1");
  yield 1;
  console.log("gen 2");
  yield 2;
  console.log("gen 3");
  return 3;
}

const it = gen();

console.log("main 1");
console.log(it.next()); //{ value: 1, done: false } 

console.log("main 2");
console.log(it.next()); //{ value: 2, done: false } 

console.log("main 3");
console.log(it.next()); //{ value: 3, done: true } 
  1. 调用gen函数时会返回一个迭代器
  2. 通过调用next返回可以恢复gen函数的执行,而当执行中遇到yield关键字时会暂停执行,返回结果,交出控制权

通过Generator函数,我们可以实现全局代码和Generator函数代码的交替执行。这不正符合我们之前异步编程的逻辑吗。Generator正是因为可以暂停执行也可以恢复执行的特性,使得它很适合用于异步编程。

Generaor内部通过协程实现。协程是一种比线程更加轻量级的存在,可以把协程看做线程上的任务,一个线程上可以存在多个协程,但只能同时执行一个协程,其他协程则都处于暂停状态。且协程的切换不像线程那么消耗资源。

如果从协程A启动协程B,那么协程A就是协程B的父协程

上面代码的协程流程示意图如下:

coroutine 需要特别强调的是,每个协程都有自己调用栈,当某个协程获得执行权时,引擎会首先保存父协程当前的调用栈信息,然后恢复子线程的调用栈信息。

之前的例子中yield返回的是同步代码,如果yield后面紧跟着一个异步请求的Promise,那么就可以通过Promise的then方法拿到结果,并通过next传递结果让Generator重新获取执行权,继续执行:

function* gen() {
  const a = yield fetchData("/api/example-a");
  const b = yield fetchData(`/api/example-b?a=${a}`);
  const c = yield fetchData(`/api/example-c?b=${b}`);
  return c;
}

const it = gen();
it.next().value.then((a) => {
  it.next(a).value.then((b) => {
    it.next(b).value.then((c) => {
      it.next(c);
    });
  });
});

当然,手动写执行器还是很麻烦的,实际开发中,我们可以配合co这样的工具库一起使用:

function* gen() {
  const a = yield fetchData("/api/example-a");
  const b = yield fetchData(`/api/example-b?a=${a}`);
  const c = yield fetchData(`/api/example-c?b=${b}`);
  return c;
}

co(gen).then((result) => {
  console.log("result: ", result);
});

可以看到配合执行器后,我们的异步代码基本和同步代码没有什么区别了。如果要捕获异常,只需要简单的使用try/catch即可:

function* gen() {
  let a;
  try {
    a = yield fetchData("/api/example-a");
  } catch (error) {
    a = "default";
  }

  const b = yield fetchData(`/api/example-b?a=${a}`);
  const c = yield fetchData(`/api/example-c?b=${b}`);
  return c;
}

co(gen).then((result) => {
  console.log("result: ", result);
});

Async/Await

配合co的Generator已经可以很容易实现像写同步代码一样写异步代码了,但人们对完美的追求是永无止境的,毕竟Generator也确实存在一些缺点:

  • 使用*yield这样相对怪异的语法,不够语义化
  • 没有自带执行器,需要其他库配合使用

因此,在Generator推出不久,Async/Await很快就出现了,它相当于是自带执行器的Generator,并且语法上更加语义化:

async function gen() {
  let a;
  try {
    a = await fetchData("/api/example-a");
  } catch (error) {
    a = "default";
  }

  const b = await fetchData(`/api/example-b?a=${a}`);
  const c = await fetchData(`/api/example-c?b=${b}`);
  return c;
}

gen().then((result) => {
  console.log("result: ", result);
});

使用async关键字代替*,使用await关键字代替yield。只需要像调用一般函数一样调用它即可。async函数默认返回Promise对象,也让我们能很方便的处理异步结果。

更详细的Async/Await使用详见Async 函数

总结

随着前端异步编程的逐步发展和完善,现在异步编程已经不再那么痛苦的事情,相反变得轻松写意。但同时新的语法也带来新的学习成本,所以作为开发者,我们也得走在不断自我完善的路上。

如果对本文有什么意见和建议,欢迎讨论和指正!