作为一个成熟的前端er,熟练的书写异步是必备技能,但是灵魂拷问一下大家:写了这么多JS异步,你知道目前已有的异步方案是怎么来的嘛?现有的异步方案有啥可能啥槽点可以改进的不? 下面我们就带大家一起从JS异步史开始一步一步讲起,循着先人的脚步,探索一种更优雅的异步写法~
一、异步来源
众所周知,js是单线程的语言,也就是说js语句只能在一个线程上执行,一些耗时任务不可以通过多开线程的方式进行。但是编程时我们不免要碰到一些耗时任务,比如浏览器运行中的网络请求等等。
这些任务要如何运行呢? js将运行中的任务,区分出两个概念:
同步任务和异步任务
-
同步任务:js在主线程执行过程中遇到的不阻塞可顺序执行的任务。
-
异步任务:需要耗时的任务都应作为异步任务执行,比如网络请求之类的,涉及IO等资源,但是不占用CPU资源。CPU运行js主线程时,会将这类任务推入任务队列(CPU反正不能光等着IO出结果,啥也不做),接着运行主线程中剩下的同步任务。任务队列中的异步任务运行结束后,会通知主线程,请求执行被挂起的回调代码。而主线程这个时候,如果把剩下的同步任务都执行完了,主线程空了,就会进去下一个任务循环,执行已经完成了的异步任务。(这里还可以引申出新的问题:宏任务和微任务,此处略过不提,可自行了解)。主线程的这种循环会不断进行。
那,在代码层面,我们是如何进行这种异步任务处理的呢?
二、JS异步历险记
JS异步历经回调、事件监听和发布订阅、promise、generator+thunk、async await阶段,下面我们将依次分析下这几种异步方案。
1、回调
JS洪荒时期常用的异步解决方法,简单易懂,异步任务执行结束后,在其回调函数中进行下一步操作,但是容易出现回调地狱。
设想一下,如果你要顺序读取两个文件的内容
fs.readFile(filenames[0], "utf-8", (err, data) => {
if (err) {
console.log("err=>",err)
}
console.log("data1=>",data)
fs.readFile(filenames[1], "utf-8", (err, data) => {
if (err) {
console.log("err=>",err)
}
console.log("data2=>",data)
});
});
使用回调的话就是上面这个样子,如果有更多异步任务呢?嵌套的层级会特别深,代码复杂度大大增加,对开发者十分不友好。
2、发布订阅模式
让我们举个例子来说明发布订阅模式:有个很好看的公众号,发布文章的时间不规律,需要用户时不时去查看,十分麻烦,现在我们给这个公众号增加一个订阅功能,用户只要订阅,公众号新发布文章的时候就会自动遍历订阅用户列表,推送信息到订阅用户处。
这种解决方案实际就叫发布订阅。发布订阅模式可以广泛运用于异步编程中,用于替代回调式写法,可以实现事件之间时间和空间层面的解耦。公众号可以专注于文章发布,用户也无需关心公众号发布的时间细节等,消息到了看文章就行啦。
我们平常DOM中使用到的事件绑定,就是一种发布订阅模式。
document.body.addEventListener('click',function(){
alert("你点击我啦")
})
document.body.click() // 模拟用户点击
除了DOM中已封装好的事件监听,我们如何用js实现一些自定义事件的发布订阅呢。古早时期jQuery中就有相关实现:
// 给onething订阅事件f1
jQuery.subscribe('onething', f1);
// onething触发,f1执行
jQuery.publish('onething');
现在我们来尝试一下,如何自己实现一套发布订阅并应用
class emitEvent{
constructor(){
this.clientList = [];
}
listen(key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = [];
}
this.clientList[key].push(fn);
};
trigger() {
// 取到订阅key
let key = Array.prototype.shift.call(arguments);
// 取到订阅key对应的回调
let fns = this.clientList[key];
if (!fns || !fns.length) {
console.log("没有人订阅该事件哦");
return false;
}
for (let i = 0, fn; (fn = fns[i]); i++) {
fn.apply(this, arguments); //执行订阅对象回调
}
};
};
let a = new emitEvent();
let b = new emitEvent();
a.listen("article1", function (time) {
console.log("a-article1=>", "小红", time);
});
b.listen("article1", function (time) {
console.log("b-article1=>", "小红", time);
});
a.trigger("article1", "2021-12-10");
b.trigger("article1", "2021-12-10");
现在一个基本的事件监听对象已经封装完成啦
3、 Promise(ES2015)
promise是ES2015推出的一套异步解决方案。Promise接收一个函数类型的参数,该函数接收两个入参:resolve函数和reject函数,异步被挂起后处于pending状态,异步操作成功后状态变成fulfilled(resolve函数执行),失败则变成rejected(reject函数执行)。
异步操作处理结束后,可通过then、catch等执行回调,Promise支持链式调用。
// 封装一个异步操作:读取文件中的内容
let fs = require("fs");
const path = require("path");
let filenames = ["shaonianyou_1.txt", "shaonianyou_2.txt", "shaonianyou_3.txt"];
filenames = filenames.map((item) => {
return path.resolve(__dirname, "static/poem/", item);
});
function readFileAsync(...args) {
return new Promise((resolve, reject) => {
fs.readFile(...args, (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
}
// 下面,顺序读取上述三个文件中的内容,并将其输出,promise写法如下:
readFileAsync(filenames[0], "utf-8")
.then((data) => {
result = result + data + "\n";
return readFileAsync(filenames[1], "utf-8");
})
.then((data) => {
result = result + data + "\n";
return readFileAsync(filenames[2], "utf-8");
})
.then((data) => {
result = result + data + "\n";
})
.catch((err) => {
console.log("error=>", err); //和状态无关的变量
});
.finally(() => { // promise.finally不接受任何参数,在promise操作的最后执行,可以用来执行server关闭等诸类收尾操作
console.log("result=>\n", result);
})
promise写法解决了传统异步写法的回调地狱问题,异步任务更为清晰,但是如上所示,链式调用时要写一堆then,语义不是很清晰,有没有其他写法呢?
2.2 Thunk+Generator(ES2015)
接上文,Generator应运而生。Generator在ES2015中发布。
我们可以把Generator函数看成一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,用yield语句注明。Generator也是ES6新特性之一。
(1)、generator语法与特性
Generator函数的简单写法如下:
function* gen(x) {
var y = yield x + 2;
return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }
generator函数执行后并不会直接执行内部的函数,而是生成一个指向内部状态的指针对象,调用这个指针对象的next方法,可以移动指针,指向下一个遇到的yield语句并执行yield后的内容,然后返回一个状态值:{value:'x', done:false}。value表示当前执行的yield后的表达式的值,done表示是否还有下一个阶段。
generator还有一个特性是,它可以控制yield执行的结果(上一个异步任务的结果),向generator函数体内反输数据:
function* count() {
let a = 3;
let b = yield a * 2;
let c = yield b + 1;
return `最终值为:${c}`;
}
const countThunk = count();
let count1 = countThunk.next(); //6
let count2 = countThunk.next(count1.value); //7
let count3 = countThunk.next(count2.value);//最终值为:7
同时,Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}
var g = gen(1);
g.next()
g.throw('出错啦'); // 出错啦
基于generator的这些特性,我们可以使用它封装一套异步任务执行方法
(2)、generator+thunk
基于generator的这些特性,我们可以使用它书写异步任务。
但是,如果多个异步任务,每一个都要手动next一下然后取值吗,这样显然过于冗余了些。
现在我们可以先介绍一下thunk方法。
Thunk函数:将函数中的一部分逻辑抽出,作为参数传入,如果这个逻辑比较复杂的话,这个临时函数就叫做Thunk函数。thunk函数本意是用来处理函数“传名调用”问题,比如下面,但是JS函数是传值调用,JS里的thunk通常替换的不是参数表达式,而是将多参数函数,替换成一个只接受回调函数作为参数的单参数函数。
//调用方式1
function countTest(a){return a*2};
let a = countTest(1+2*8);
//调用方式2
function thunk(){
return 1+2*8
}
function countTest1(fun){
let num = fun();
return num*2;
};
let b = countTest(thunk)
我们如何结合thunk的思想,让generator自执行呢?
先封装一个thunk函数
const Thunk = function (fn) {
return function (...args) {
return function (callback) {
return fn.call(this, ...args, callback);
};
};
};
再封装一套基于thunk函数的Generator执行器,让generator自动执行
// fn为generator函数
function run(fn) {
let gen = fn();
function next(err, data) {
let result = gen.next(data);
if (result.done) {
return;
}
result.value(next);
}
next();
}
现在,我们看看如果我们要实现异步顺序读取三个文件的内容要怎么做
// 使用thunk得到一个异步读取文件函数
const readFileThunk = Thunk(fs.readFile);
function* readFileByGenerator() {
let filaData1 = yield readFileThunk(filenames[0], "utf-8");
let filaData2 = yield readFileThunk(filenames[1], "utf-8");
let filaData3 = yield readFileThunk(filenames[2], "utf-8");
console.log("filaData=>",filaData1,filaData2,filaData3)
}
run(readFileByGenerator)
bingo!有了thunk的帮助,使用generator写异步就变得容易多了。现在已经有很多库可以帮我们实现generator的自动执行,比如co,具体使用不再赘述。
另,观察一下thunk+generator后的异步写法,有没有感觉特别熟悉?
2.3 async(ES2017)
前面我们讲解了generator,async函数实际上是ES2017下对generator函数封装的语法糖,其内部封装了generator执行器,使用上更便捷。
将generator函数的*改为async,yield改为await,就是async await的用法,仅此而已
下面是一个使用示例,我们先看看效果
// 我们先封装一个文件读取的promise
function readFileAsync(...args) {
return new Promise((resolve, reject) => {
fs.readFile(...args, (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
}
// async函数使用
async function readFileByAsync() {
const data1 = await readFileAsync(filenames[0], "utf-8");
const data2 = await readFileAsync(filenames[1], "utf-8");
const data3 = await readFileAsync(filenames[2], "utf-8");
return data1 + "\n" + data2 + "\n" + data3 + "\n";
}
readFileByAsync().then(data => {
console.log("三个文件共同的读取结果=>",data)
}).catch(error => console.log("error=>",error))
相较于generator返回值为一个Iterator,async函数返回的是一个promise,在then里我们可以取到async函数中return的值,相较于generator更便捷
3、对比与分析
由于,generator方案同async await方案极似且async await更为优化,下面我们将略过generator写法,着重讨论promise、async await二者的使用
3.1 简单异步使用及错误捕获
promise
readFileAsync(filenames[0], "utf-8")
.then((data) => {
console.log("promise-data=>", data);
})
.catch((err) => {
console.log("promise-err=>", err);
});
promise内部的错误会被promise.catch吞掉,我们无法使用try catch捕获到,使用时务必注意。
async await
async function readFileByAsync() {
try {
const asyncData = await readFileAsync(filenames[0], "utf-8")
console.log("async-data=>",asyncData)
}catch (err){
console.log("async-err=>", err);
}
}
readFileByAsync();
async await执行报错可以通过try catch捕获
3.2 异步并发
想象一下这个场景,我们需要获取3个文件中的内容,采用异步读取的方式,三个接口中都返回数据。
(1)、promise写法
Promise.all()方法用于将多个Promise实例包装成一个新的 Promise实例,其内部全部promise执行完毕后即执行Promise.all对应的then方法,取到里面的数据,如果中间有任何一个报错则直接执行catch方法。
filenames = filenames.map((item) => {
return readFileAsync(item, "utf-8");
});
Promise.all(filenames)
.then((data) => console.log("result=>", data))
.catch((err) => console.log("error=>", err));
我们通常用这个方法处理多个异步同时发出的情况,但是这里有一个问题是,Promise.all中的promise数组只要有一个报错就会影响其他promise的执行结果,如果想兼容这个问题,最好针对单个promsie添加catch捕获
filenames = filenames.map((item) => {
return readFileAsync(item, "utf-8").catch(err => {console.log("err=>",err)});
});
Promise.all(filenames)
.then((data) => {console.log("result=>", data)})
.catch((err) => {console.log("error=>", err)});
写法有点丑Orz
(2)、async await
function asyncLoop() {
filenames.forEach(async function (item) {
try {
let data = await readFileAsync(item, "utf-8");
console.log("data=>", data);
} catch (err) {
console.log("err=>", err);
}
});
}
asyncLoop();
3.3 异步继发
异步继发,就是异步请求之间存在结果的依赖关系,我们来看一下promise和async await对应的写法。
(1)、promise
function chainReadFilePromise() {
let cur = "";
p = Promise.resolve();
for (let file of filenames1) {
p = p.then((data) => {
if (data) {
cur = data;
}
console.log("data=>", data);
return readFileAsync(file, "utf-8");
});
}
return p
.catch((error) => {
console.log("error=>", error);
})
.then((data) => {
if (data) {
return data;
}
return cur;
});
}
chainReadFilePromise().then((data) => {
console.log("hahaha\n", data);
});
(2)、async await
async function chainReadFileAsync(filenames) {
let result = "";
for (const file of filenames) {
const data = await readFileAsync(file, "utf-8");
result = result + "\n" + data;
}
return result;
}
chainReadFileAsync(filenames).then((data) => {
console.log("finally-data =>", data);
});