Javascript异步编程超进化
js是单线程语言,这就是js设计之初就决定好的,并且在未来也不会改变。因此js并没有多线程那样的同步互斥问题。但单线程也意味着同一时间只能做一件事。如果有些任务耗时很久,那么整个应用就会被停住直到该任务完成为止。为了解决这个问题,js引入了异步编程。
异步编程可用于处理不能立即得到结果的I/O操作,如网络请求,文件读写等操作,保证应用不会因此被卡住。历史的车轮滚滚向前,异步编程的方式也在不断的进化,主要经历了回调函数,Promise,Generator, Async/Await几个过程,现在写异步代码已经可以写的像同步代码一样了。
同步 vs 异步
同步是执行完一个任务后,再继续执行下一个任务,符合线性思维,所以理解起来很容易。而异步则是将一个任务拆分成为多个部分,先执行一部分,然后去执行其他任务,等到合适的时机再回过头来执行后面的部分,正是这种不连续的执行,造成了我们写异步代码的困难。
以下面代码为例:
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化:
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);
});
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 }
- 调用
gen
函数时会返回一个迭代器 - 通过调用
next
返回可以恢复gen
函数的执行,而当执行中遇到yield
关键字时会暂停执行,返回结果,交出控制权
通过Generator函数,我们可以实现全局代码和Generator函数代码的交替执行。这不正符合我们之前异步编程的逻辑吗。Generator正是因为可以暂停执行也可以恢复执行的特性,使得它很适合用于异步编程。
Generaor内部通过协程实现。协程是一种比线程更加轻量级的存在,可以把协程看做线程上的任务,一个线程上可以存在多个协程,但只能同时执行一个协程,其他协程则都处于暂停状态。且协程的切换不像线程那么消耗资源。
如果从协程A启动协程B,那么协程A就是协程B的父协程
上面代码的协程流程示意图如下:
需要特别强调的是,每个协程都有自己调用栈,当某个协程获得执行权时,引擎会首先保存父协程当前的调用栈信息,然后恢复子线程的调用栈信息。
之前的例子中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 函数。
总结
随着前端异步编程的逐步发展和完善,现在异步编程已经不再那么痛苦的事情,相反变得轻松写意。但同时新的语法也带来新的学习成本,所以作为开发者,我们也得走在不断自我完善的路上。
如果对本文有什么意见和建议,欢迎讨论和指正!