JS异步知多少

193 阅读10分钟

作为一个成熟的前端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);
});