「系统回顾」异步JavaScript

392 阅读11分钟

前言

这篇文章涵盖了之前几篇博客的核心内容,并且新增一些新的API(例如fetch、async等)和新的代码示例,如果有兴趣,可以先阅读入门的几篇博客

AJAX

Promise

异步

最近在整理以前的博客时,顺便查阅MDN做一些记录,以保证以前的学习知识并没有发生错误认知。

在此背景下,我动笔写下这篇博客。我一直认为,写博客能帮助我不断思考和学习,事实上我也是这么做的。希望我在写博客帮助自己学习的同时,也能帮助到别人。

同步和异步的比较

同步

要深入了解什么是异步,需要先知道同步。同步就是按照顺序执行代码,要马上看到结果。下面我们用一个例子来理解。

    const btn = document.querySelector('button');
    btn.addEventListener('click', () => {
      alert('You clicked me!');

      let pElem = document.createElement('p');
      pElem.textContent = 'This is a newly-added paragraph.';
      document.body.appendChild(pElem);
    });  

如果觉得代码长懒得看,可以运行一下这个示例

上面的代码主要获取了一个button,然后给它加了一个点击事件,当我们点击时,会触发alert,然后再生成一个p元素,里面的内容为'This is a newly-added paragraph.',最后把这个p元素渲染到页面中。

毫无意外,当我们点击后,会直接运行alert,产生代码阻塞效果,当我们点击确定后,才会继续运行下面的代码,生成p元素。

这个例子说明了同步代码有个缺陷:当有个别代码产生阻塞时,会影响后续的代码。

产生阻塞的原因很多,有可能是需要完成一个非常耗时的任务,比如运行一百万次运算,比如产生一个网络请求等等都可能导致代码阻塞现象。

这是一种非常令人沮丧的体验,因为在目前计算机多核能力下,我们应当在同一时间让计算机完成更多的任务,而不是一件一件完成。由于JS是单线程的,而且这个设计未来也不一定会修改,所以要在单线程下不阻塞完成多个耗时的任务,就需要增加JS的线程辅助能力,而JS设计者是采用辅助线程web workers来帮助完成耗时的任务。但是本质上,由于其他线程不能修改DOM,所以JS还是单线程。

那么虽然增加了多线程来帮助运算,但这样做依然只是减少了代码阻塞问题,web workers虽然有用,但是没有更新DOM的权限,这是主线程的权利。

想象一个,假设我们需要让web workers去做一个获取图片的任务,而我在主线程上需要用到这张图片来更新dom呢?这样的情况下很可能就就报错了,因为很有可能web workers没有获取到图片,主线程的更新dom的操作就已经运行了。

为了解决这个问题,浏览器运行异步进行某些操作。

异步

理解了什么是同步,那么我们修改一下上面的代码,让它变成异步的

    const btn = document.querySelector('button');
    btn.addEventListener('click', () => {
      setTimeout(()=>{alert('You clicked me!');},2000)
     // alert('You clicked me!') 删除了这行
      let pElem = document.createElement('p');
      pElem.textContent = 'This is a newly-added paragraph.';
      document.body.appendChild(pElem);
    });  

这时候就会发现先渲染了,再执行alert,因为setTimeout就是浏览器认可异步操作。

callbacks和promise

异步分为两种学派,一种是callbacks,还一种是使用promise。

callbacks

callbacks就是使用callback函数来手动安排代码执行顺序。

上面示例中btn.addEventListener('click', () => {})中的第二个参数就是callback函数,它的意思是,点击事件后再回头来执行这个代码。

当我们把回调函数作为一个参数传递给另一个函数时,仅仅是把回调函数定义作为参数传递过去 — 回调函数并没有立刻执行,回调函数会在包含它的函数的某个地方异步执行,包含函数负责在合适的时候执行回调函数。

function fn(callback){
   callback()
}
function fn1(){}
fn(fn1)

上面的代码就是一个最简单的回调。

我们可以自己来写一个最简单的AJAX示例

function ajax(url,method,callback){
   const request=new XMLHttpRequest()
   request.open(method,url)
   request.onReadyStatechange=()=>{
      if(request.status===200 && request.readyState===4){
         callback(request.response)
   }
}
   request.send()
}

function fn(X){
   console.log(X)
}

ajax('5.json','GET',fn)

上面的代码先定一个一个ajax的函数,后定一个一个fn的函数,当我运行ajax并传递了参数时,会在ajax内部执行ajax的操作,最后再回调fn来打印出request.response

要注意不是所有的callback都是异步的,比如Array.prototype.forEach就是同步回调

Promise

promise是新派的异步代码,它主要有三种状态

创建promise时,它既不是成功也不是失败状态。这个状态叫作pending(待定)。

当promise返回时,称为 resolved(已解决).

一个成功resolved的promise称为fullfilled(实现)。它返回一个值,可以通过将.then()块链接到promise链的末尾来访问该值。 .then()块中的执行程序函数将包含promise的返回值。

一个不成功resolved的promise被称为rejected(拒绝)了。它返回一个原因(reason),一条错误消息,说明为什么拒绝promise。可以通过将.catch()块链接到promise链的末尾来访问此原因。

promise只会产生两种状态转化结果:

  • 从未完成到成功
  • 从未完成到失败
let promise = new Promise((resolve,reject)=>{
   if('成功'){
     resolve('Success!')
   }else{
     reject(reason)
   }
})

Promise 构造函数接收一个函数为参数,函数中又接收两个函数作为参数,当异步操作成功时,执行 resolve 函数,不成功则执行 reject。

promise.then((message)=>{console.log(message)},(error)=>{console.error(error)})

上面的代码是异步之后的操作,通过then方法来执行异步的操作,(then之前是同步的)then接收两个callback,第一个是promise成功后的,第二个是promise失败后执行的。

它也可以写成这样

promise.then((message)=>{console.log(message)}).catch((error)=>{console.error(error)})

then 里的参数是可选的,catch(failureCallback) 是 then(null, failureCallback) 的缩略形式

宏任务、微任务

setTimeout是宏任务的典型,Promise是微任务的典型。

下面的代码中,总是由promise先执行,再由setTimeout执行

setTimeout(()=>{console.log('宏任务后执行')})
Promise.resolve('微任务先执行').then((r)=>{console.log(r)})
"微任务先执行"
"宏任务后执行"

链式调用

连续执行两个或者多个异步操作是一个常见的需求,.then方法返回一个promise对象,所以可以链式调用。

promise.then(()=>{})
.then(()=>{})
.catch(()=>{console.error()})

通常,一遇到异常抛出,浏览器就会顺着 Promise 链寻找下一个 onRejected 失败回调函数或者由 .catch() 指定的回调函数。

也就是说,不管前面多少个then,只要一步出问题了,就会执行.catch方法

promise.all

如果想在一大堆Promises全部完成之后运行一些代码呢,显然.then方法不太够用。

你可以使用巧妙命名的Promise.all()静态方法完成此操作。这将一个promises数组作为输入参数,并返回一个新的Promise对象,只有当数组中的所有promise都满足时才会满足。它看起来像这样:

Promise.all([a, b, c]).then(values => {
  ...
});

如果它们都实现,那么数组中的结果将作为参数传递给.then()块中的执行器函数。如果传递给Promise.all()的任何一个 promise 拒绝,整个块将拒绝。

如果参数中包含非 promise 值,这些值将被忽略,但仍然会被放在返回数组中

Promise.all([1,2]).then((s)=>{console.log(s)})

promise.allsettled

上面的Promise.all只有在全部成功后才能返回全部结果,如果一个失败了就停下来并且返回一个失败的response,很明显不太符合我们的意愿,我们很多时候肯定要把所有结果都收集起来呀,这就有了这个API

const p=[Promise.reject(0),Promise.resolve(1)]
Promise.allSettled(p)
.then((response)=>{console.log(response)})
/*
[{
  reason: 0,
  status: "rejected"
},{
  status: "fulfilled",
  value: 1
}]
*/

这个API存在兼容性问题,很多时候旧的浏览器不一定能用,所以我们就需要采取一些方法来模拟它。

模拟方法就是采用Promise.all不返回失败不就行了

const p=[Promise.reject(0)
         .then((r)=>{return {'ok':r}},(r)=>{return {'no ok':r}}),
         Promise.resolve(1)
         .then((r)=>{return {'ok':r}},(r)=>{return {'no ok':r}})]
Promise.all(p).then((r)=>{console.log(r)})
/*
[{
  no ok: 0
},{
  ok: 1
}]
*/

上面的方法可以封装优化,这里就不多介绍了

promise.finally

在promise完成后,你可能希望运行最后一段代码,无论它是否已实现(fullfilled)或被拒绝(rejected)。此前,你必须在.then()和.catch()回调中包含相同的代码,例如

myPromise
.then(response => {
  doSomething(response);
  runFinalCode();//重复
})
.catch(e => {
  returnError(e);
  runFinalCode(); //重复
});

在现代浏览器中,.finally() 方法可用,它可以链接到常规promise链的末尾,允许你减少代码重复并更优雅地执行操作。上面的代码现在可以写成如下

myPromise
.then(response => {
  doSomething(response);
})
.catch(e => {
  returnError(e);
})
.finally(() => {
  runFinalCode();
});

async和await:异步语法糖

简单来说,它们是基于promises的语法糖,使异步代码更易于编写和阅读。通过使用它们,异步代码看起来更像是老式同步代码,因此它们非常值得学习。

async关键字

async function hello() { return "Hello" };
hello();
//Promise {<fulfilled>: "Hello"}

话不多说,上面的代码增加async关键字后,这个函数会返回promise。这是异步的基础,可以说,现在的异步JS就是使用promise。

箭头函数写法

let hello=async ()=>{return 'hello'}

现在我们可以使用.then方法啦

hello().then((mes)=>{console.log(mes)})

await关键字

await只在异步函数里面才起作用,它的主动作用是会暂停代码在该行上,直到promise完成,然后返回结果值,await后面应该放promise对象

我们可以造一个实际的例子

const p=new Promise((resolve,reject)=>{
  setTimeout(resolve,3000,'doing')
})
const r=new Promise((resolve,reject)=>{
  setTimeout(resolve,0,p)
})
const o=r.then((mes)=>{
  return mes+'=>done'
})
o.then((mes)=>{console.log(mes)}).catch((error)=>{console.log(error)})
//doing => done

上面代码中,r会拿到p的结果,然后链式调用下去。

我们可以使用async+await进行封装

    function promise(ms, mes) {
      return new Promise((resolve, reject) => {
        setTimeout(resolve, ms, mes);
      });
    }
    async function fn() {
      const p = await promise(3000, "doing");
      console.log(p); // doing
      const r = await promise(0, p);
      console.log(r); //doing
      const o = await (r + "=>done");
      console.log(o); //doing =>done
    }
    fn();

可以看到上面的异步代码就跟同步代码的写法一样。

asyncawait要同时使用才会有异步的效果,单单使用async依然是同步代码,只是返回promise对象

错误处理

在使用async/await关键字的时候,错误处理是关键,一般我们会这么写来捕捉错误

function ajax(){
   return Promise.reject(1)
}
async function fn(){
   try{
      const result=await ajax()
      console.log(result)
   }catch(error){
      console.log(error)
   }
}
fn() 

下面我们可以使用更好的方法

function ajax(){
   return Promise.reject(1)
}
function ErrorHandler(error){
    throw Error(error)
}
async function fn(){
   const result=await ajax().then(null,(error)=>{ErrorHandler(error)})
   console.log('result',result)
}
fn()

这里要注意的就是ErrorHandler时不要用return,以免把结果返回给result,使用throw Error就可以抛出一个错误。那么后续的代码就不会执行了

await的传染性

function async2(){
console.log('async2')
}
async function fn(){
   console.log('fn')
   await async2() //同步的
   console.log('我是异步?')
}
fn()
console.log('end')
//fn
//async2
//end
//我是异步?

最后的console.log('我是什么步?')是后于await关键字的,说明它是异步的,如果我们想执行同步代码,最好都放在await的上面,因为有时候await会带给我们疑惑,会误以为没有写await关键字的代码是同步的。

也许你会怀疑是否第一行log也是异步的,下面这个代码可以告诉你答案,并非写了async关键字就代表这是异步函数。

let a=0
async function fn(){
   console.log(a)
   await Promise.resolve(333).then((r)=>{console.log(r)})
   console.log('我是什么步?')
}
fn()
console.log(++a)
//结果
/*
0
1
333
"我是什么步?"
*/

串行和并行

await天生是串行的,所谓串行,就是按照顺序执行。

function async2(delay){
  return new Promise((resolve)=>{
    setTimeout(()=>{
      console.log('执行')
      resolve()
    },delay)
  })
}                  
async function fn(){
  await async2(5000)
  await async2(2000)
  await async2(1000)
}
fn()

由于async跟setTimeout同时用没有效果,所以我使用上面的代码做实验,log台五秒钟后会分别打印,这说明默认就是按照顺序执行await的

如果想要并行,就可以使用Promise.all或者forEach方法

function fn(){
  await Promise.all([async2(5000),async2(2000),async2(1000)])
}
function async2(delay){
  return new Promise((resolve)=>{
    setTimeout(()=>{
      console.log('执行')
      resolve()
    },delay)
  })
}
                     
function fn3(ms){
  return function fn(){
    async2(ms)
  }
}

[fn3(5000),fn3(2000),fn3(1000)].forEach(async (v)=>{
  await v()
})

与fetch相结合

fetch就是使用promise版本的XMLHttpRequest

fetch('products.json').then(function(response) {
  return response.json();
}).then(function(json) {
  console.log(json)
}).catch(function(err) {
  console.log('Fetch problem: ' + err.message);
});

上面的代码的意思是通过fetch申请一个json数据,然后得到数据后将其json化,再打印出来。

转化成async和await方法

const promise=()=>{
  try{
     const j=await fetch('products.json')
     const result=await j.json()
     console.log(result)
  }catch(error){
     console.log(error)
  }
}
promise()

总结

异步JS中,最多的是使用ajax来产生带动网页工作,理解Promise是我们学习的基础,现代JS就是基于Promise来工作的。

在了解Promise后,通过async和await语法糖,我们能够更加简单地实现promise,避免大量then的嵌套。

参考文档

异步JavaScript

使用fetch

Promise

XMLHttpRequest