浅谈几个前端异步解决方案

14,719 阅读16分钟

首先给朋友打个广告哈,圈内和即将入圈的大佬们都懂哈,某课以及各平台如Geek,Kaikeba 等low price资源,包更新,在这位一线开发哥们儿手中,同时针对即将入行者有面试指导技术指的服务,加他撒!

Javascript语言的执行环境是单线程。即一次只能完成一个任务。若有多个任务则需排队逐个执行——前一个任务完成,再执行后一个任务。

这种执行模式实现简单,执行环境相对单纯。但随着前端业务日渐复杂,事务和请求等日渐增多,这种单线程执行方式在复杂的业务下势必效率低下,只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为避免和解决这种问题,JS语言将任务执行模式分为异步和同步。同步模式”就是上一段的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;”异步模式”则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

“异步模式”非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,”异步模式”甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。

      1.回调函数 

      异步编程最基本方法。

      首先需要声明,回调函数只是一种实现,并不是异步模式特有的实现。回调函数同样可以运用到同步(阻塞)的场景下以及其他一些场景。

      回调函数的英文定义:A callback is a function that is passed as an argument to another function and is executed after its parent function has completed。

      字面上的理解,回调函数就是一个参数,将这个函数作为参数传到另一个函数里面,当那个函数执行完之后,再执行传进去的这个函数。这个过程就叫做回调。

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

      用一个通俗的生活例子比喻一下就是:约会结束后你送你女朋友回家,离别时,你肯定会说:“到家了给我发条信息,我很担心你。” 然后你女朋友回家以后还真给你发了条信息。其实这就是一个回调的过程。你留了个参数函数(要求女朋友给你发条信息)给你女朋友,然后你女朋友回家,回家的动作是主函数。她必须先回到家以后,主函数执行完了,再执行传进去的函数,然后你就收到一条信息了。

     假定有两个函数f1和f2,后者等待前者的执行结果。

f1();
f2(); 

     若f1是一个很耗时的任务,可以考虑改写f1,把f2写成f1的回调函数。

function f1(callback){setTimeout(function () {// f1的任务代码callback();}, 1000);}

     执行代码就变成下面这样:

f1(f2);

     采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。

    另一个例子:

//定义主函数,回调函数作为参数
function A(callback) {
    callback();  
    console.log('我是主函数');      
}

//定义回调函数
function B(){
    setTimeout("console.log('我是回调函数')", 3000);//模仿耗时操作  
}

//调用主函数,将函数B传进去
A(B);

//输出结果
我是主函数
我是回调函数

      上面的代码中,我们先定义了主函数和回调函数,然后再去调用主函数,将回调函数传进去。

  定义主函数的时候,我们让代码先去执行callback()回调函数,但输出结果却是后输出回调函数的内容。这就说明了主函数不用等待回调函数执行完,可以接着执行自己的代码。所以一般回调函数都用在耗时操作上面。比如ajax请求,比如处理文件等。

    再来一个更俗的例子:

<strong>问:你有事去隔壁寝室找同学,发现人不在,你怎么办呢?</strong><strong>方法1</strong>,每隔几分钟再去趟隔壁寝室,看人在不<strong>方法2</strong>,拜托与他同寝室的人,看到他回来时叫一下你 前者是轮询,后者是回调。 那你说,我直接在隔壁寝室等到同学回来可以吗? 可以啊,只不过这样原本你可以省下时间做其他事,现在必须浪费在等待上了。把原来的非阻塞的异步调用变成了阻塞的同步调用。 JavaScript的回调是在异步调用场景下使用的,使用回调性能好于轮询。

   对于回调函数,一般在同步情境下是最后执行的,而在异步情境下有可能不执行,因为事件没有被触发或者条件不满足,所以请忽略上上个例子中的小问题,并不是一定回调函数就要执行。

   同时补充回调函数应用场合和优缺点:

  • 资源加载:动态加载js文件后执行回调,加载iframe后执行回调,ajax操作回调,图片加载完成执行回调,AJAX等等。
  • DOM事件及Node.js事件基于回调机制(Node.js回调可能会出现多层回调嵌套的问题)。
  • setTimeout的延迟时间为0,这个hack经常被用到,settimeout调用的函数其实就是一个callback的体现。
  • 链式调用:链式调用的时候,在赋值器(setter)方法中(或者本身没有返回值的方法中)很容易实现链式调用,而取值器(getter)相对来说不好实现链式调用,因为你需要取值器返回你需要的数据而不是this指针,如果要实现链式方法,可以用回调函数来实现。
  • setTimeout、setInterval的函数调用得到其返回值。由于两个函数都是异步的,即:他们的调用时序和程序的主流程是相对独立的,所以没有办法在主体里面等待它们的返回值,它们被打开的时候程序也不会停下来等待,否则也就失去了setTimeout及setInterval的意义了,所以用return已经没有意义,只能使用callback。callback的意义在于将timer执行的结果通知给代理函数进行及时处理。

       回调函数这种方式的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以”去耦合“,有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。

    2.Promise对象

      随着ES6标准的发布,处理异步数据流的解决方案又有了新的变化。

promise

就是这其中的一个。我们都知道,在传统的ajax请求中,当异步请求之间的数据存在依赖关系的时候,就可能产生很难看的多层回调,这样会使代码逻辑很容易造成混乱不便于阅读和后期维护,俗称”回调地狱”(callback hell)。另一方面,往往错误处理的代码和正常的业务代码耦合在一起,造成代码会极其难看。为了让编程更美好,我们就需要引入promise来降低异步编程的复杂性。

      所以某种程度上说,promise是对上面说到的回调函数处理异步编程的一个进阶方案。首先Promise是CommandJS提出的一种规范,其目的是为异步编程提供统一接口。

      简单说,Promise的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。形如这种形式:

f1().then(f2);

     对于函数f1,使用Jquery实现以下改写:

function f1(){var dfd = $.Deferred();setTimeout(function () {// f1的任务代码dfd.resolve();}, 500);return dfd.promise;}

      这样写的优点在于,回调函数变成了链式写法,程序的流程可以看得很清楚,而且有一整套的配套方法,可以实现许多强大的功能。这也就是Promise处理异步编程的其中的一个方便之处。

      再举一个制定多个回调函数的例子,其形式为:

f1().then(f2).then(f3);

      当指定发生错误时的回调函数,其形式为:

f1().then(f2).fail(f3);

      在此补充一点,promise中,如果一个任务已经完成,再添加回调函数,该回调函数会立即执行。所以,你不用担心是否错过了某个事件或信号。这种方法的缺点就是编写和理解,都相对比较难。

      展开谈论一下Promise:Promise实际上就是一个特殊的Javascript对象,反映了”异步操作的最终值”。”Promise”直译过来有预期的意思,因此,它也代表了某种承诺,即无论你异步操作成功与否,这个对象最终都会返回一个值给你。

      代码示例

const promise = new Promise((resolve, reject) => {
  $.ajax('https://github.com/users', (value) =>  {
    resolve(value);
  }).fail((err) => {
    reject(err);
  });
});
promise.then((value) => {
  console.log(value);
},(err) => {
  console.log(err);
});
//也可以采取下面这种写法
promise.then(value => console.log(value)).catch(err => console.log(err));

上面的例子,会在Ajax请求成功后调用resolve回调函数来处理结果,如果请求失败则调用reject回调函数来处理错误。Promise对象内部包含三种状态,分别为pending,fulfilled和rejected。这三种状态可以类比于我们平常在ajax数据请求过程的pending,success,error。一开始请求发出后,状态是Pending,表示正在等待处理完毕,这个状态是中间状态而且是单向不可逆的。成功获得值后状态就变为fulfilled,然后将成功获取到的值存储起来,后续可以通过调用then方法传入的回调函数来进一步处理。而如果失败了的话,状态变为rejected,错误可以选择抛出(throw)或者调用reject方法来处理。

Promise基本语法如下

  • Promise实例必须实现then这个方法

  • then()必须可以接收两个函数作为参数

  • then()返回的必须是一个Promise实例

    eg
    <script src="https://cdn.bootcss.com/bluebird/3.5.1/bluebird.min.js"></script>//如果低版本浏览器不支持Promise,通过cdn这种方式
          <script type="text/javascript">
            function loadImg(src) {
                var promise = new Promise(function (resolve, reject) {
                    var img = document.createElement('img')
                    img.onload = function () {
                        resolve(img)
                    }
                    img.onerror = function () {
                        reject('图片加载失败')
                    }
                    img.src = src
                })
                return promise
            }
            var src = 'https://www.imooc.com/static/img/index/logo_new.png'
            var result = loadImg(src)
            result.then(function (img) {
                console.log(1, img.width)
                return img
            }, function () {
                console.log('error 1')
            }).then(function (img) {
                console.log(2, img.height)
            })
         </script>
    作者:浪里行舟
    链接:https://juejin.cn/post/6844903620878532616
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    

       Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。

       有了Promise,我们只需要简单地写job1.then(job2).then(job3).catch(handleError); 其中job1、job2和job3都是Promise对象。

       比如我们想实现第一个图片加载完成后,再加载第二个图片,如果其中有一个执行失败,就执行错误函数:

var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
        var result1 = loadImg(src1) //result1是Promise对象
        var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
        var result2 = loadImg(src2) //result2是Promise对象
        result1.then(function (img1) {
            console.log('第一个图片加载完成', img1.width)
            return result2  // 链式操作
        }).then(function (img2) {
            console.log('第二个图片加载完成', img2.width)
        }).catch(function (ex) {
            console.log(ex)
        })

      这里需注意的是:then 方法可以被同一个 promise 调用多次,then 方法必须返回一个 promise 对象。上例中result1.then如果没有明文返回Promise实例,就默认为本身Promise实例即result1,result1.then返回了result2实例,后面再执行.then实际上执行的result2.then。

 Promise的常用方法

      除了串行执行若干异步任务外,Promise还可以并行执行异步任务

      试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()实现如下:

var p1 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 600, 'P2');
});
// 同时执行p1和p2,并在它们都完成后执行then:
Promise.all([p1, p2]).then(function (results) {
    console.log(results); // 获得一个Array: ['P1', 'P2']
});

       有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()实现:

var p1 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
    setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
    console.log(result); // 'P1'
});

由于p1执行较快,Promise的then()将获得结果'P1'。p2仍在继续执行,但执行结果将被丢弃。

总结:Promise.all接受一个promise对象的数组,待全部完成之后,统一执行success;

Promise.race接受一个包含多个promise对象的数组,只要有一个完成,就执行success。

对上面的例子做下修改,加深对这两者的理解:

var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
     var result1 = loadImg(src1)
     var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
     var result2 = loadImg(src2)
     Promise.all([result1, result2]).then(function (datas) {
         console.log('all', datas[0])//<img src="https://www.imooc.com/static/img/index/logo_new.png">
         console.log('all', datas[1])//<img src="https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
     })
     Promise.race([result1, result2]).then(function (data) {
         console.log('race', data)//<img src="https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
     })

如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。

      Promise.reject(reason): 返回一个新的promise对象,用reason值直接将状态变为rejected

const promise2 = new Promise((resolve, reject) => {
  reject('Failed');
});

const promise2 = Promise.reject('Failed');

上面两种写法是等价的。

        Promise.resolve(value): 返回一个新的promise对象,这个promise对象是被resolved的。与reject类似,下面这两种写法也是等价的。

const promise2 = new Promise((resolve, reject) => {
  resolve('Success');
});

const promise2 = Promise.resolve('Success');

then 利用这个方法访问值或者错误原因。其回调函数就是用来处理异步处理返回值的。

catch 利用这个方法捕获错误,并处理。

3.Async/Await简介与用法

简介

  • async/await是写异步代码的新方式,以前的方法有回调函数Promise
  • async/await是基于Promise实现的,它不能用于普通的回调函数。
  • async/await与Promise一样,是非阻塞的。
  • async/await使得异步代码看起来像同步代码,这正是它的魔力所在。

语法

用promise示例和asyn/await示例两段代码演示:

promise

const makeRequest = () =>
  getJSON()
    .then(data => {
      console.log(data)
      return "done"
    })

makeRequest()

async/await

const makeRequest = async () => {
  console.log(await getJSON())
  return "done"
}

makeRequest()

它们有一些细微不同:

  • 函数前面多了一个aync关键字。await关键字只能用在aync定义的函数内。async函数会隐式地返回一个promise,该promise的reosolve值就是函数return的值。(示例中reosolve值就是字符串”done”)

  • 第1点暗示我们不能在最外层代码中使用await,因为不在async函数内。

    // 不能在最外层代码中使用await await makeRequest()

    // 这是会出事情的 makeRequest().then((result) => { // 代码 })

await getJSON()表示console.log会等到getJSON的promise成功reosolve之后再执行。

相对于promise,async/await的优势有哪些

1.简洁

       由示例可知,使用Async/Await明显节约了不少代码。我们不需要写.then,不需要写匿名函数处理Promise的resolve值,也不需要定义多余的data变量,还避免了嵌套代码。这些小的优点会迅速累计起来,这在之后的代码示例中会更加明显。

2.错误处理

        Async/Await让try/catch可以同时处理同步和异步错误。在下面的promise示例中,try/catch不能处理JSON.parse的错误,因为它在Promise中。我们需要使用.catch,这样错误处理代码非常冗余。并且,在我们的实际生产代码会更加复杂。

const makeRequest = () => {
  try {
    getJSON()
      .then(result => {
        // JSON.parse可能会出错
        const data = JSON.parse(result)
        console.log(data)
      })
      // 取消注释,处理异步代码的错误
      // .catch((err) => {
      //   console.log(err)
      // })
  } catch (err) {
    console.log(err)
  }
}

使用aync/await的话,catch能处理JSON.parse错误

const makeRequest = async () => {
  try {
    // this parse may fail
    const data = JSON.parse(await getJSON())
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}

3.条件语句

下面示例中,需要获取数据,然后根据返回数据决定是直接返回,还是继续获取更多的数据。

const makeRequest = () => {
  return getJSON()
    .then(data => {
      if (data.needsAnotherRequest) {
        return makeAnotherRequest(data)
          .then(moreData => {
            console.log(moreData)
            return moreData
          })
      } else {
        console.log(data)
        return data
      }
    })
}

这些代码看着就头痛。嵌套(6层),括号,return语句很容易让人感到迷茫,而它们只是需要将最终结果传递到最外层的Promise。

上面的代码使用async/await编写可以大大地提高可读性:

const makeRequest = async () => {
  const data = await getJSON()
  if (data.needsAnotherRequest) {
    const moreData = await makeAnotherRequest(data);
    console.log(moreData)
    return moreData
  } else {
    console.log(data)
    return data    
  }
}

4. 中间值

你很可能遇到过这样的场景,调用promise1,使用promise1返回的结果去调用promise2,然后使用两者的结果去调用promise3。你的代码很可能是这样的:

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      return promise2(value1)
        .then(value2 => {        
          return promise3(value1, value2)
        })
    })
}

如果promise3不需要value1,可以很简单地将promise嵌套铺平。如果你忍受不了嵌套,你可以将value 1 & 2 放进Promise.all来避免深层嵌套:

const makeRequest = () => {
  return promise1()
    .then(value1 => {
      return Promise.all([value1, promise2(value1)])
    })
    .then(([value1, value2]) => {      
      return promise3(value1, value2)
    })
}

这种方法为了可读性牺牲了语义。除了避免嵌套,并没有其他理由将value1和value2放在一个数组中。

使用async/await的话,代码会变得异常简单和直观。

const makeRequest = async () => {
  const value1 = await promise1()
  const value2 = await promise2(value1)
  return promise3(value1, value2)
}

5. 错误栈

下面示例中调用了多个Promise,假设Promise链中某个地方抛出了一个错误:

const makeRequest = () => {
  return callAPromise()
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => callAPromise())
    .then(() => {
      throw new Error("oops");
    })
}

makeRequest()
  .catch(err => {
    console.log(err);
    // output
    // Error: oops at callAPromise.then.then.then.then.then (index.js:8:13)
  })

Promise链中返回的错误栈没有给出错误发生位置的线索。更糟糕的是,它会误导我们;错误栈中唯一的函数名为callAPromise,然而它和错误没有关系。(文件名和行号还是有用的)。

然而,async/await中的错误栈会指向错误所在的函数:

const makeRequest = async () => {
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  await callAPromise()
  throw new Error("oops");
}

makeRequest()
  .catch(err => {
    console.log(err);
    // output
    // Error: oops at makeRequest (index.js:7:9)
  })

在开发环境中,这一点优势并不大。但是,当你分析生产环境的错误日志时,它将非常有用。这时,知道错误发生在makeRequest比知道错误发生在then链中要好。

6. 调试

最后一点,也是非常重要的一点在于,async/await能够使得代码调试更简单。2个理由使得调试Promise变得非常痛苦:

  • 不能在返回表达式的箭头函数中设置断点

    const markRequest = () => { return callAPromise () .then (() => callAPromise()) .then (() => callAPromise()) .then (() => callAPromise()) .then (() => callAPromise())

    }

  • 如果你在.then代码块中设置断点,使用Step Over快捷键,调试器不会跳到下一个.then,因为它只会跳过异步代码。使用await/async时,你不再需要那么多箭头函数,这样你就可以像调试同步代码一样跳过await语句。

    const markRequest = async () => {
        await callAPromise()
        await callAPromise()
        await callAPromise()
        await callAPromise()
        await callAPromise()
    }
    

总结

      对于常用的不同异步编程处理方案,个人观点是针对不同的业务场景可根据情况选择合适高效的方案,各有优势劣势,没必要顶一个踩一个,虽然技术不断发展优化,但有些技术不至于淘汰如此之快,存在即合理。