阅读 1203

JS异步开发总结

1 前言

众所周知,JS语言是单线程的。在实际开发过程中都会面临一个问题,就是同步操作会阻塞整个页面乃至整个浏览器的运行,只有在同步操作完成之后才能继续进行其他处理,这种同步等待的用户体验极差。所以JS中引入了异步编程,主要特点就是不阻塞主线程的继续执行,用户直观感受就是页面不会卡住。

2 概念说明

2-1 浏览器的进程和线程

首先可以确定一点是浏览器是多进程的,比如打开多个窗口可能就对应着多个进程,这样可以确保的是页面之间相互没有影响,一个页面卡死也并不会影响其他的页面。同样对于浏览器进程来说,是多线程的,比如我们前端开发人员最需要了解的浏览器内核也就是浏览器的渲染进程,主要负责页面渲染,脚本执行,事件处理等任务。为了更好的引入JS单线程的概念,我们将浏览器内核中常用的几个线程简单介绍一下:

  1. GUI渲染线程 负责渲染浏览器页面,解析html+css,构建DOM树,进行页面的布局和绘制操作,同事页面需要重绘或者印发回流时,都是该线程负责执行。

  2. JS引擎线程 JS引擎,负责解析和运行JS脚本,一个页面中永远都只有一个JS线程来负责运行JS程序,这就是我们常说的JS单线程。

    注意:JS引擎线程和GUI渲染线程永远都是互斥的,所以当我们的JS脚本运行时间过长时,或者有同步请求一直没返回时,页面的渲染操作就会阻塞,就是我们常说的卡死了

  3. 事件触发线程 接受浏览器里面的操作事件响应。如在监听到鼠标、键盘等事件的时候, 如果有事件句柄函数,就将对应的任务压入队列。

  4. 定时触发器线程 浏览器模型定时计数器并不是由JavaScript引擎计数的, 因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 它必须依赖外部来计时并触发定时。

  5. 异步http请求线程 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

2-2 JS单线程

因为只有JS引擎线程负责处理JS脚本程序,所以说JS是单线程的。可以理解的是js当初设计成单线程语言的原因是因为js需要操作dom,如果多线程执行的话会引入很多复杂的情况,比如一个线程删除dom,一个线程添加dom,浏览器就没法处理了。虽然现在js支持webworker多线线程了,但是新增的线程完全在主线程的控制下,为的是处理大量耗时计算用的,不能处理DOM,所以js本质上来说还是单线程的。

2-3 同步异步

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

2-4 任务队列

任务队列就是用来存放一个个带执行的异步操作的队列,在ES6中又将任务队列分为宏观任务队列和微观任务队列。

宏任务队列(macrotask queue)等同于我们常说的任务队列,macrotask是由宿主环境分发的异步任务,事件轮询的时候总是一个一个任务队列去查看执行的,"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。

微任务队列(microtask queue)是由js引擎分发的任务,总是添加到当前任务队列末尾执行。另外在处理microtask期间,如果有新添加的microtasks,也会被添加到队列的末尾并执行

2-5 事件循环机制

异步时间添加到任务队列中后,如何控制他们的具体执行时间呢?JS引擎一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。

ES5的JS事件循环参考图:

ES6的JS事件循环参考图:

理解了JS程序执行的基本原理,下面就可以步入正题,讨论一下我们在实际开发中,如何编写异步程序才能让自己的代码易读易懂bug少。

3 callback

在JavaScript中,回调函数具体的定义为:函数A作为参数(函数引用)传递到另一个函数B中,并且这个函数B执行函数A。我们就说函数A叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。

因此callback 不一定用于异步,一般同步(阻塞)的场景下也经常用到回调,比如要求执行某些操作后执行回调函数。

回调函数被广泛应用到JS的异步开发当中,下面分别列举几条开发中常用回调函数的情况,如:

  1. 时间延迟操作
setTimeout(function(){
    //该方法为回调方法
    //code
}, 1000)

setInterval(()=>{
    //该方法为匿名回调方法
    //code
}, 1000)
复制代码
  1. nodeapi
//node读取文件
fs.readFile(xxx, 'utf-8', function(err, data) { 
    //该方法为读取文件成功后出发的回调方法
    //code
});
复制代码
  1. ajax操作
$.ajax({
    type: "post",
    url: "xxx",
    success: function(data){
        //post请求成功回调方法
        //code
    },
    error: fucntion(e){
        //post请求错误回调方法
        //code
    }
})
复制代码

用回调函数的方法来进行异步开发好处就是简单明了,容易理解

回调函数的缺点, 用一个小的实例来说明一下:

method1(function(err, data) {
    //code1
    method2(function(err, data) { 
        //code2
        method3(function(err, data) { 
            //code3
            method4(D, 'utf-8', function(err, data) { 
                //code4 
            });
       });
   });
 });
复制代码

如果说异步方法之前有明确的先后顺序来执行,稍微复杂的操作很容易写出上面示例的代码结构,如果加上业务代码,程序就显得异常复杂,代码难以理解和调试,这种就是我们常说的回调地狱。

如果想要实现更加复杂的功能,回调函数的局限性也会凸显出来,比如同时执行两个异步请求,当两个操作都结束时在执行某个操作,或者同时进行两个请求,取优先完成的结果来执行操作,这种都需要在各自的回调方法中监控状态来完成。

随着ES6/ES7新标准的普及,我们应该寻求新的异步解决方案来替代这种传统的回调方式。

4 Promise

ES6新增Promise对象的支持,Promise提供统一的接口来获取异步操作的状态信息,添加不能的处理方法。

Promise对象只有三种状态:

  1. pendding: 初始状态,既不是成功,也不是失败状态。
  2. fulfilled: 意味着操作成功完成。
  3. rejected: 意味着操作失败。

Promise的状态只能由内部改变,并且只可以改变一次。

下面看看用Promise来实现多级回调能不能解决回调地狱的问题

 function read(filename){
    return new Promise((resolve, reject) => {
        //异步操作code, Example:
        fs.readFile(filename, 'utf8', (err, data) => { 
            if(err) reject(err);
            resolve(data);
        });
    })
 }
 
 read(filename1).then(data=>{
    return read(filename2)
 }).then(data => {
    return read(filename3)
 }).then(data => {
    return read(filename4)
 }).catch(error=>{
    console.log(error);
 })
复制代码

通过实践代码 我们发现用Promise可以像写同步代码一样实现异步功能,避免了层层嵌套的问题。

如何用Promise来实现同时发起多个异步操作的需求

  • 多个请求都完成后在执行操作
function loadData(url){
    return new Promise((resolve, reject)=>{
        $.ajax({
            type: "post",
            url: url,
            success: function(data){
                //post请求成功回调方法
                resolve(data)
            },
            error: fucntion(e){
                //post请求错误回调方法
                reject(e)
            }
        })
    })
}

Promise.all([loadData(url1), loadData(url2), loadData(url3)])
.then(data => {
    console.log(data)
}).catch(error => {
    console.log(error);
})
复制代码
  • 多个请求有一个完成后(成功或拒绝)就执行操作
function loadData(url){
    return new Promise((resolve, reject)=>{
        $.ajax({
            type: "post",
            url: url,
            success: function(data){
                //post请求成功回调方法
                resolve(data)
            },
            error: fucntion(e){
                //post请求错误回调方法
                reject(e)
            }
        })
    })
}

Promise.race([loadData(url1), loadData(url2), loadData(url3)])
.then(data => {
    console.log(data)
}).catch(error => {
    console.log(error);
})
复制代码

用Promise来写异步可以避免回调地狱,也可以轻松的来实现callback需要引入控制代码才能实现的多个异步请求动作的需求。

当然Promise也有自己的缺点:

  1. promise一旦新建,就会立即执行,无法取消
  2. 如果不设置回掉函数,promise内部抛出的错误就不会反应到外部
  3. 处于pending状态时,是不能知道目前进展到哪个阶段的 ( 刚开始?,即将结束?)

带着这些缺点,继续往下学习别的异步编程方案。

**关于Promise的详细文章可以阅读这篇你真的会用 Promise 吗

5 Generator

ES6新增Generator异步解决方案,语法行为与传统方法完全不一样。

Generator函数是一个状态机,封装了多个内部状态,也是一个遍历器对象生成函数,生成的遍历器对象可以一次遍历内部的每一个状态。

Generator用function*来声明,除了正常的return返回数据之外,还可以用yeild来返回多次。

调用一个Generator对象生成一个generator对象,但是还并没有去执行他,执行generator对象有两种方法:

  • next()方法,next方法回去执行generator方法,遇到yeild会返回一个{value:xx, done: true/fasle}的对象,done为true说明generator执行完毕
  • 第二个方法是用for....of循环迭代generator对象

Generator的用处很多,本文只讨论利用它暂停函数执行,返回任意表达式的值的这个特性来使异步代码同步化表达。从死路上来讲我们想达到这样的效果:

function loadData(url, data){
    //异步请求获取数据
    return new Promise((resolve, reject)=>{
        $.ajax({
            type: "post",
            url: url,
            success: function(data){
                //post请求成功回调方法
                resolve(data)
            },
            error: fucntion(e){
                //post请求错误回调方法
                reject(e)
            }
        })
    })
}
function*  gen() {
    yeild loadData(url1, data1);
    yeild loadData(url2, data2);
    yeild loadData(url3, data3);
}

for(let data of gen()){
    //分别输出每次加载数据的返回值
    console.log(data)
}
复制代码

但仅仅是这样来实现是不行的,因为异步函数没有返回值,必须通过重新包装的方式来传递参数值。co.js就是一个这种generator的执行库。使用它是我们只需要将我们的 gen 传递给它像这样 co(gen) 是的就这样。

function*  gen() {
    let data1 = yeild loadData(url1, data1);
    console.log(data1);
    let data2 = yeild loadData(url2, data2);
    console.log(data2);
    let data3 = yeild loadData(url3, data3);
    console.log(data3);
}
co(gen())
.then(data => {
    //gen执行完成
}).catch(err => {
    //code
})
复制代码

因为ES7中新增了对async/await的支持,所以异步开发有了更好的选择,基本上可以放弃用原生generator来写异步开发,所以我们只是有个简单的概念,下面我们着重介绍一下异步编程的最终方案 async/await。

6 async/await

asycn/await方案可以说是目前解决JS异步编程的最终方案了,async/await是generator/co的语法糖,同时也需要结合Promise来使用。该方案的主要特点如下:

  • 普通函数,即所有的原子型异步接口都返回Promise,Promise对象中可以进行任意异步操作,必须要有resolve();
  • async函数,函数声明前必须要有async关键字,函数中执行定义的普通函数,并且每个执行前都加上await关键字,标识该操作需要等待结果。
  • 执行async函数。asynch函数的返回值是Promise对象,可以用Promise对象的then方法来指定下一步操作。

还用用代码来说明问题,用async/await方案来实现最初的需求

//普通函数
function loadData(url){
    //异步请求获取数据
    return new Promise((resolve, reject)=>{
        $.ajax({
            type: "post",
            url: url,
            success: function(data){
                //post请求成功回调方法
                resolve(data)
            },
            error: fucntion(e){
                //post请求错误回调方法
                reject(e)
            }
        })
    })
}

//async函数
async function asyncFun(){
    //普通函数的调用
    let data1 = await loadData(url1);
    let data2 = await loadData(url2);
    let data3 = await loadData(url3)
}

asyncFun()
.then(data => {
    //async函数执行完成后操作
})
.catch(err => {
    //异常抓取
});
复制代码

loadData()函数虽然返回的是Promise,但是await返回的是普通函数resole(data)时传递的data值。

通过和generator方式来的实现对比来看,更加理解了async/await是generator/co方法的语法糖,从函数结构上来说完全一样。但是省略了一些外库的引入,一些通用方法的封装,使异步开发的逻辑更加清晰,更加接近同步开发。

处理完有先后顺序的请求处理,下面来个多个请求同时发起的例子

//普通函数
function loadData(url){
    //异步请求获取数据
    return new Promise((resolve, reject)=>{
        $.ajax({
            type: "post",
            url: url,
            success: function(data){
                //post请求成功回调方法
                resolve(data)
            },
            error: fucntion(e){
                //post请求错误回调方法
                reject(e)
            }
        })
    })
}

//async函数
async function asyncFun(){
    await Promise.all([loadData('url1'), loadData('url2')]).then(data => {
    console.log(data); //['data1', 'data2']
    })
}

asyncFun();

//配合Promise的race方法同样可以实现任意请求完成或异常后执行操作的需求

//async函数
async function asyncFun(){
    await Promise.race([loadData('url1'), loadData('url2')]).then(data => {
    console.log(data);
    })
}
复制代码

最佳实践

通过上面四种不同的异步实现方式的对比可以发现,async/await模式最接近于同步开发,即没有连续回调,也没有连续调用then函数的情况,也没有引入第三方库函数,所以就目前来说async/await+promise的方案为最佳实践方案。

社区以及公众号发布的文章,100%保证是我们的原创文章,如果有错误,欢迎大家指正。

文章首发在WebJ2EE公众号上,欢迎大家关注一波,让我们大家一起学前端~~~

再来一波号外,我们成立WebJ2EE公众号前端吹水群,大家不管是看文章还是在工作中前端方面有任何问题,我们都可以在群内互相探讨,希望能够用我们的经验帮更多的小伙伴解决工作和学习上的困惑,欢迎加入。

关注下面的标签,发现更多相似文章
评论