js学习之异步处理

学习js开发,无论是前端开发还是node.js,都避免不了要接触异步编程这个问题,就和其它大多数以多线程同步为主的编程语言不同,js的主要设计是单线程异步模型。正因为js天生的与众不同,才使得它拥有一种独特的魅力,也给学习者带来了很多探索的道路。本文就从js的最初设计开始,整理一下js异步编程的发展历程。

什么是异步

在研究js异步之前,先弄清楚异步是什么。异步是和同步相对的概念,同步,指的是一个调用发起后要等待结果返回,返回时候必须拿到返回结果。而异步的调用,发起之后直接返回,返回的时候还没有结果,也不用等待结果,而调用结果是产生结果后通过被调用者通知调用者来传递的。

举个例子,A想找C,但是不知道C的电话号码,但是他有B的电话号码,于是A给B打电话询问C的电话号码,B需要查找才能知道C的电话号码,之后会出现两种场景看下面两个场景:

  • A不挂电话,等到B找到号码之后直接告诉A
  • A挂电话,B找到后再给A打电话告诉A

能感受到这两种情况是不同的吧,前一种就是同步,后一种就是异步。

为什么是异步的

先来看js的诞生,JavaScript诞生于1995年,由Brendan Eich设计,最早是在Netscape公司的浏览器上实现,用来实现在浏览器中处理简单的表单验证等用户交互。至于后来提交到ECMA,形成规范,种种历史不是这篇文章的重点,提到这些就是想说一点,js的最初设计就是为了浏览器的GUI交互。对于图形化界面处理,引入多线程势必会带来各种各样的同步问题,因此浏览器中的js被设计成单线程,还是很容易理解的。但是单线程有一个问题:一旦这个唯一的线程被阻塞就没办法工作了–这肯定是不行的。由于异步编程可以实现“非阻塞”的调用效果,引入异步编程自然就是顺理成章的事情了。

现在,js的运行环境不限于浏览器,还有node.js,node.js设计的最初想法就是设计一个完全由事件驱动,非阻塞式IO实现的服务器运行环境,因为网络IO请求是一个非常大的性能瓶颈,前期使用其他编程语言都失败了,就是因为人们固有的同步编程思想,人们更倾向于使用同步设计的API。而js由于最初设计就是全异步的,人们不会有很多不适应,加上V8高性能引擎的出现,才造就了node.js技术的产生。node.js擅长处理IO密集型业务,就得益于事件驱动,非阻塞IO的设计,而这一切都与异步编程密不可分。

js异步原理

这是一张简化的浏览器js执行流程图,nodejs和它不太一样,但是都有一个队列

这个队列就是异步队列,它是处理异步事件的核心,整个js调用时候,同步任务和其他编程语言一样,在栈中调用,一旦遇上异步任务,不立刻执行,直接把它放到异步队列里面,这样就形成了两种不同的任务。由于主线程中没有阻塞,很快就完成,栈中任务边空之后,就会有一个事件循环,把队列里面的任务一个一个取出来执行。只要主线程空闲,异步队列有任务,事件循环就会从队列中取出任务执行。

说的比较简单,js执行引擎设计比这复杂的多得多,但是在js的异步实现原理中,事件循环和异步队列是核心的内容。

异步编程实现

异步编程的代码实现,随着时间的推移也在逐渐完善,不止是在js中,许多编程语言的使用者都在寻找一种优雅的异步编程代码书写方式,下面来看js中的曾出现的几种重要的实现方式。

最经典的异步编程方式–callback

提起异步编程,不能不提的就是回调(callback)的方式了,回调方式是最传统的异步编程解决方案。首先要知道回调能解决异步问题,但是不代表使用回调就是异步任务了。下面以最常见的网络请求为例来演示callback是如何处理异步任务的,首先来看一个错误的例子:

1
2
3
4
5
6
function getData(url) {
const data = $.get(url);
return data;
}

const data = getData('/api/data'); // 错误,data为undefined

由于函数getData内部需要执行网络请求,无法预知结果的返回时机,直接通过同步的方式返回结果是行不通的,正确的写法是像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getData(url, callback) {
$.get(url, data => {
if (data.status === 200) {
callback(null, data);
} else {
callback(data);
}
});
}

getData('/api/data', (err, data) => {
if (err) {
console.log(err);
} else {
console.log(data);
}
});

callback方式利用了函数式编程的特点,把要执行的函数作为参数传入,由被调用者控制执行时机,确保能够拿到正确的结果。这种方式初看可能会有点难懂,但是熟悉函数式编程其实很简单,很好地解决了最基本的异步问题,早期异步编程只能通过这种方式。

然而这种方式会有一个致命的问题,在实际开发中,模型总不会这样简单,下面的场景是常有的事:

1
2
3
4
5
6
7
8
9
fun1(data => {
// ...
fun2(data, result => {
// ...
fun3(result, () => {
// ...
});
});
});

整个随着系统越来越复杂,整个回调函数的层次会逐渐加深,里面再加上复杂的逻辑,代码编写维护都将变得十分困难,可读性几乎没有。这被称为毁掉地狱,一度困扰着开发者,甚至是曾经异步编程最为人诟病的地方。

从地狱中走出来–promise

使用回调函数来编程很简单,但是回调地狱实在是太可怕了,嵌套层级足够深之后绝对是维护的噩梦,而promise的出现就是解决这一问题的。promise是按照规范实现的一个对象,ES6提供了原生的实现,早期的三方实现也有很多。在此不会去讨论promise规范和实现原理,重点来看promise是如何解决异步编程的问题的。

Promise对象代表一个未完成、但预计将来会完成的操作,有三种状态:

  • pending:初始值,不是fulfilled,也不是rejected
  • resolved(也叫fulfilled):代表操作成功
  • rejected:代表操作失败

整个promise的状态只支持两种转换:从pending转变为resolved,或从pending转变为rejected,一旦转化发生就会保持这种状态,不可以再发生变化,状态发生变化后会触发then方法。这里比较抽象,我们直接来改造上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getData(url) {
return new Promise((resolve, reject) =>{
$.get(url, data => {
if (data.status === 200) {
reject(data);
} else {
resolve(data);
}
});
});
}

getData('/api/data').then(data => {
console.log(data);
}).catch(err => {
console.log(err);
});

Promise是一个构造函数,它创建一个promise对象,接收一个回调函数作为参数,而回调函数又接收两个函数做参数,分别代表promise的两种状态转化。resolve回调会使promise由pending转变为resolved,而reject 回调会使promise由pending转变为rejected。

当promise变为resolved时候,then方法就会被触发,在里面可以获取到resolve的内容,then方法。而一旦promise变为rejected,就会产生一个error。无论是resolve还是reject,都会返回一个新的Promise实例,返回值将作为参数传入这个新Promise的resolve函数,这样就可以实现链式调用,对于错误的处理,系统提供了catch方法,错误会一直向后传递,总是能被下一个catch捕获。用promise可以有效地避免回调嵌套的问题,代码会变成下面的样子:

1
2
3
4
5
6
7
8
9
fun1().then(data => {
// ...
return fun2(data);
}).then(result => {
// ...
return fun3(result);
}).then(() => {
// ...
});

整个调用过程变的很清晰,可维护性可扩展性都会大大增强,promise是一种非常重要的异步编程方式,它改变了以往的思维方式,也是后面新方式产生的重要基础。

转换思维–generator

promise的写法是最好的吗,链式调用相比回调函数而言却是可维护性增加了不少,但是和同步编程相比,异步看起来不是那么和谐,而generator的出现带来了另一种思路。

generator是ES对协程的实现,协程指的是函数并不是整个执行下去的,一个函数执行到一半可以移交执行权,等到可以的时候再获得执行权,这种方式最大的特点就是同步的思维,除了控制执行的yield命令之外,整体看起来和同步编程感觉几乎一样,下面来看一下这种方式的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getDataPromise(url) {
return new Promise((resolve, reject) =>{
$.get(url, data => {
if (data.status === 200) {
reject(data);
} else {
resolve(data);
}
});
});
}

function *getDataGen(url) {
yield getDataPromise(url);
}

const g = getDataGen('/api/data');
g.next();

generator与普通函数的区别就是前面多一个*,不过这不是重点,重点是generator里面可以使用yield关键字来表示暂停,它接收一个promise对象,返回promise的结果并且停在此处等待,不是一次性执行完。generator执行后会返回一个iterator,iterator里面有一个next方法,每次调用next方法,generator都会向下执行,直到遇上yield,返回结果是一个对象,里面有一个value属性,值为当前yield返回结果,done属性代表整个generator是否执行完毕。generator的出现使得像同步一样编写异步代码成为可能,下面是使用generator改造后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
* fun() {
const data = yield fun1();
// ...
const result = yield fun2(data);
// ...
yield fun3(result);
// ...
}

const g = fun();
g.next();
g.next();
g.next();
g.next();

在generator的编写过程中,我们还需要手动控制执行过程,而实际上这是可以自动实现的,接下来的一种新语法的产生使得异步编程真的和同步一样容易了。

新时代的写法–async,await

异步编程的最高境界,就是根本不用关心它是不是异步。在最新的ES中,终于有了这种激动人心的语法了。async函数的写法和generator几乎相同,把*换成async关键字,把yield换成await即可。async函数内部自带generator执行器,我们不再需要手动控制执行了,现在来看最终的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getDataPromise(url) {
return new Promise((resolve, reject) =>{
$.get(url, data => {
if (data.status === 200) {
reject(data);
} else {
resolve(data);
}
});
});
}

async function getData(url) {
return await getDataPromise(url);
}

const data = await getData(url);

除了多了关键字,剩下的和同步的编码方式完全相同,对于异常捕获也可以采取同步的try-catch方式,对于再复杂的场景也不会逻辑混乱了:

1
2
3
4
5
6
7
8
9
* fun() {
const data = await fun1();
// ...
const result = await fun2(data);
// ...
return await fun3(result);
// ...
}
fun()

现在回去看回调函数的写法,感觉好像换了一个世界。这种语法比较新,在不支持的环境要使用babel转译。

写在最后

在js中,异步编程是一个长久的话题,很庆幸现在有这么好用的async和await,不过promise原理,回调函数都是要懂的,很重要的内容,弄清楚异步编程模式,算是扫清了学习js尤其是node.js路上最大的障碍了。


尊重原创,转载分享前请先知悉作者,也欢迎指出错误不足共同交流,更多内容欢迎关注作者博客点击这里

-------------本文结束感谢您的阅读-------------
坚持原创技术分享,您的支持将鼓励我继续创作!