ES6指北【7】——从回调地狱到Promise和async/await

1 同步和异步

1.1 js同一时刻只能做一件事

首先,我们需要理解js是个单线程语言,同一时刻只能做一件事

我们可以通过js执行DOM渲染共用一个线程来理解这个原理:

  • js修改dom的时候,浏览器不会对dom进行渲染,即dom渲染被阻塞

而上面这种被阻塞的行为,我们称之为具有同步性

1.2 同步与异步的概念

同步

  • 英文:Synchronization,通常缩写为Sync
  • 定义:同步行为对应内存中顺序执行的处理器指令,如果这句话不理解,你就理解为顺序执行js代码(别以为这是废话,和下面的异步定义好好对比一下)
  • 其实就是代码要等待到结果,才能继续进行【你可以理解为同步阻塞了代码继续执行】

比如下面的例子,递归函数阻塞了最后一句的执行,因此打开控制台会发现2不是立刻输出的

function wait() {
    let start = new Date();
    while (new Date() - start < 4000) { // 阻塞4秒
        
    }
      console.log(2);
}

// 核心逻辑
console.log(1)
wait();
console.log(3); // 这一句的执行被阻塞了4秒

// 输入结果:1 2 3

异步

  • 英文:Asynchronization,通常缩写为Async
  • 定义:类似于操作系统中的中断,即当前进程外部的实体可以触发代码执行
  • 其实就是代码不用等待到结果,就能继续进行【你可以理解为异步不阻塞代码继续执行】

啥意思咧,举个例子

function wait() {
    setTimeout(() => console.log(2), 4000)
}
// 核心逻辑
console.log(1);
wait(); // 4秒钟后,得到结果
console.log(3); // 如果没有异步的话,这句得等4秒钟才能执行,所以,感谢异步

// 输入结果:1 3 2

2 异步的应用

2.1 常见的异步场景

1.网络请求
// Ajax
console.log(1);
$.get(url1, data1 => {
    console.log(data1);
      console.log(2);
})
console.log(3);
// 1 3 2
2.定时任务,如setTimeout和setTimeInterval
console.log(1);
setTimeout(() => console.log(2), 1000);
console.log(3);
console.log(1);
setInterval(() => console.log(2), 1000);
console.log(3);
3.事件监听
// 加载图片
console.log(1);

let img = document.createElement('img');
// onload是个回调,图片一加载才会触发
img.onload = () => console.log(2);
// src赋值之后图片就会开始加载
img.src = '/xxx.png';

console.log(3);
// 1 3 2

2.2 常见的异步问题

2.1 图片加载问题

// 前提条件:用户的浏览器第一次请求这个图片,也就是用户的浏览器未缓存
document.getElementsByTagNames('img')[0].width // 宽度为 0

为什么width会为0呢?
因为js运行的时候,img并没有下载完毕

解决方案

let imgNode = document.getElementsByTagName('img')[0]
imgNode.addEventListener('onload',function () {
  // 回调
  console.log(this.width)
})

2.2 面试题常考的异步问题

// 假设有5个li
let liList = document.querySelectorAll('li')
for (var i = 0; i < liList.length; i++) {
    liList[i].onclick = function () {
        console.log(i) // 5 5 5 5 5
    }
}

为什么呢?
因为onclick事件是异步处理的,用户触发onclick事件时,循环早已结束

又因为var,i被提升变成了全局变量,此时的i是5

因此有上述情况发生

解决方案一【立即执行函数创建独立作用域(不推荐)】

// 假设有5个li
let liList = document.querySelectorAll('li')
for (var i = 0; i < liList.length; i++) {
  !(function (j) {
    liList[j].onclick = function () {
      console.log(j) // 1 2 3 4 5
    }
  })(i)
}

解决方案二【使用let】

let让i变成了for循环的{}里的局部变量

// 假设有5个li
let liList = document.querySelectorAll('li')
for (let i = 0; i < liList.length; i++) {
  liList[i].onclick = function () {
    console.log(i) // 1 2 3 4 5
  }
}

3 拿到异步结果的方式 —— 回调

3.1 什么是回调?

回调:把函数作为其它函数的参数进行传递
function printInfo(info) {
    console.log(info);
}

function getInfo(fn) {
    fn('大汪');
}

// printInfo作为getInfo的参数进行了传递
getInfo(printInfo); // '大汪'
  1. printInfo就是回调函数
  2. printInfo这个函数存在的意义就是能够通过getInfo被调用
  3. 在getInfo函数中调用printInfo行为就是触发回调函数

3.2 常见的回调形式

Node.js 的 error-first 形式

先判断error是否存在,存在则说明出现了错误,不存在则成功

fs.readFile('./1.txt', (error, content) => {
  if (error) {
    // 失败
  } else {
    // 成功
  }
})
jQuery 的 success / error 形式
$.ajax({
    url: '/xxx',
    success: () => {
    },
    error: () => {
    }
})
jQuery 的 done / fail / always 形式
$.ajax({
    url: '/xxx',
}).done(() => {
}).fail(() => {
}).always(() => {
})
还有一种就是Prosmise的then形式,在第4节将详细阐述

3.3 嵌套异步回调——回调地狱(callback hell)

在js中,针对异步的问题,我们可以通过回调解决

但是,更大的困难就是如何解决串联异步的问题,在promise之前通用的解决方案就是嵌套异步回调,又称之为回调地狱

回调地狱:回调套回调套回调套回调套回调套回调套回调套回调套回调

下面的例子生动地展示了异步的结果通过回调的方式来处理

只不过一直嵌套回调

// 获取data1
$.get(url1, data1 => {
    console.log(data1);
        
    // data1获取后再获取data2
    $.get(url2, data2 => {
        console.log(data2);
        
        // data2获取后再获取data3
        $.get(url3, data3 => {
            console.log(data3);
                        
            // ...可以无限重复
        })
    })
})

4 Promise

4.1 先看一个例子

你需要知道

  1. axios是个库
  2. axios()返回一个Promise实例
  3. 你可以把axios()理解为$.ajax(),它们功能相近,只不过axios遵循promise规范

axios的文档请移步

axios({
    url: './xxx.json'
}).then((resolve) => {
    console.log(resolve)
    return '我是第二个then'
}, (reject) => {
    console.log(reject)
}).then((resolve_2) => {
    console.log(resolve_2) // '我是第二个then'
}, (reject_2) => { 
    console.log(reject_2)
})

为了防止你对这个链式调用看得眼花缭乱,我把这个给简化一下

axios({
  url: './xxx.json'
}).then(成功回调1, 失败回调1)
  .then(成功回调2, 失败回调2)

相信你对上面的代码一定还是看得一头雾水,因为你还不会promise

下面我们先来了解Promise的一些基本概念

4.2 Promise的基本概念

4.2.1 Promise的作用

Promise是专门用来解决异步编程问题的,避免了层层嵌套的回调函数[即Callback Hell]
下面是一个用传统方法Callback Hell来写的异步代码
可以非常明显地看出来,Callback Hell的方式让代码的可读性变得非常差

let src = 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'

function loadImg(src, callback, fail) {
    let img = new Image()
    // 成功回调
    img.onload = function () {
        callback(img)
    }
    // 失败回调
    img.onerror = fail
    img.src = src
}

// 下面代码其实就是loadImg(src1, 成功回调1, 失败回调1);
// 但在成功回调1执行时,再一次loadImg(src2, 成功回调2, 失败回调2);
loadImg(src1, function (img) {
    console.log(img.width)
    loadImg(src2, function (img) {
        console.log(img.width)
    }, function () {
        console.log('error2')
    })
}, function () {
    console.log('error1')
})

4.2.2 new Promise()

new Promise(myExecutorFunc)返回一个Promise实例, myExecutorFunc要求是一个函数,其形式要求为:

let myExecutorFunc = (resolve, reject) => {};

具体可以看MDN文档

我们这里主要了解new Promise()如何获取异步操作的结果

一般一个异步操作,比如axios({url:'./xxx.json'})我们肯定是希望获取一个json对象,再把这个对象传递出去

1.许多人常犯的错误就是以为return就可以拿到结果,然而这只是大错特错:

let p = new Promise(() => {
    let data = 3; // 这是我们想要获得的结果
    return data; // 这个return一点用都没有
});

在控制台输入p:
image.png

2.new Promise()必须通过向myExecutorFunc的两个回调函数resolvereject传参才可以

let p = new Promise((resolve, reject) => {
    let data = 3; // 这是我们想要获得的结果

    resolve(data);
    // 或者
    reject(data);
});

在控制台输入p:

image.png

当然,上述只是让大家看看promise实例p的情况,要想真正处理这个结果,需要通过then/catch等实例方法,这些之后会聊

let p = new Promise((resolve, reject) => {
    let data = 3; // 这是我们想要获得的结果

    resolve(data);
    // 或者
    reject(data);
});

p.then(data => {
    console.log(data); // 3
}, undefined);

4.2.3 Promise实例的三个状态

  1. pending是初始态
  2. fulfilled(又称resolved)异步操作成功,由pending转变而来
  3. rejected异步操作失败,由pending转变而来

Promise实例代表一个异步操作的结果,且只有异步操作的结果,可以决定当前是哪一种状态

任何其他操作都无法改变这个状态,peding可转化为fulfilled与rejected,但fulfilled与rejected不可相互转化

我们可以通过控制台看到这三个状态,注意:

  • Promise.resolve()返回一个fulfilled态的Promise实例
  • Promise.reject()返回一个rejected态的Promise实例【下图可以看到控制台报错,因为rejected本身就是一种异步错误类型

ES6指北【7】——从回调地狱到Promise和async/await_第1张图片

那知道这三个状态又有什么用咧?
OK,我们看下面的代码
axios({
  url: './xxx.json'
}).then(成功回调, 失败回调)

axios({url: '.'})而言,其返回一个Promise对象,即一个异步操作的结果
异步操作成功代表了pending -> fulfilled -> then里的第一个参数【成功回调】
异步操作失败代表了pending -> rejected -> then里的第二个参数【失败回调】

4.3 then和catch的链式调用

因为 Promise.prototype.thenPromise.prototype.catch方法 返回promise对象
所以它们可以被 链式调用

OK,下面让我们仔细看一下回调触发机制究竟怎样的过程

axios({
  url: './xxx.json'
}).then(成功回调1, 失败回调1)
  .then(成功回调2, 失败回调2)

ES6指北【7】——从回调地狱到Promise和async/await_第2张图片

是不是看的有点晕?没关系,下面我来根据axios的例子详细解释一下
ES6指北【7】——从回调地狱到Promise和async/await_第3张图片

你一定有一些疑问:

1.为什么第一个then都调用失败回调1了,第二个then也有可能调用成功回调2
答:因为第二个then调用进入哪个回调函数,完全是看第一个then返回的Promise是什么状态,换言之 —— 看异步操作成功与否

ES6指北【7】——从回调地狱到Promise和async/await_第4张图片

即使第一个then调用了成功回调1,第二个then仍有可能进入失败回调2,举一个栗子:

axios({
    url: './xxx.json'
})
.then((resolve_1) => {
    return Promise.reject();
    // 在这种情况下,then返回的Promise的状态是rejected
}, (reject_1) => {})
.then((resolve_2) => {
    console.log(1)
}, (reject_2) => {
    // 所以第二个then只会调用它的失败回调2,即在这里执行
    console.log(2)
})

2.你咋不提catch咧?
因为catch就是then的一个语法糖呀
catch等价于then只有第二个参数【失败回调】的形式
上面的例子用catch,可以这么写

axios({
    url: './xxx.json'
})
.then((resolve_1) => {
    return Promise.reject();
    // 在这种情况下,then返回的Promise的状态是rejected
}, (reject_1) => {})
.catch((reject_2) => {
    // 执行catch的回调
    console.log(2)
})

// 其等价于
axios({
    url: './xxx.json'
})
.then((resolve_1) => {
    return Promise.reject();
    // 在这种情况下,then返回的Promise的状态是rejected
}, (reject_1) => {})
.then(undefined, (reject_2) => {
    // 所以第二个then只会调用它的失败回调2,即在这里执行
    console.log(2)
})

4.4 自己使用Promise

上面,我们借用 axios学习了promise的基本使用,但只是在axios封装promise的基础上学习的

下面我来学习怎么自己封装一个promise

4.4.1 没有异步逻辑的Promise

第一步

// 声明一个函数,让这个函数返回一个Promise实例
let setPromise = function () {
    return new Promise();
}

第二步

let isSuccess = true; // 控制执行resolve还是reject

// new Promise()接受一个函数
// 规定这个函数必须要有两个参数,这两个参数必须是函数,即【成功回调,失败回调】
let setPromise = function () {
    // resolve是成功回调,reject是失败回调
    let fn = (resolve, reject) => {
        if (isSuccess) resolve('成功'); // 执行resolve,则promise状态会由pending->fulfilled
        else reject('失败'); // 执行reject,则promise状态会由pending->reject
    }
    return new Promise(fn)
}

大家可以猜一下,此时下面的promiseInstance的状态时什么,如果把isSuccess改为false,状态又是什么?自己试一试

let promiseInstance = setPromise();
console.log(promiseInstance); // ?

第三步:研究一下参数

仔细看我写的注释
let isSuccess = true; // 控制执行resolve还是reject

let setPromise = function () {
    let fn = (resolve, reject) => {
        // 这里传递的两个字符串'成功'和'失败'会成为promise实例第一个then里的参数
        if (isSuccess) resolve('成功');
        else reject('失败');
    }
    return new Promise(fn)
}

let promiseInstance = setPromise();

promiseInstance
.then(param => {
    console.log(param) // '成功'
    return '成功2' // 返回值会成为下一个then里的param
}, param => {
    console.log(param);
    return '失败2'
})
.then(param => {
    console.log(param) // '成功2'
}, param => {
    console.log(param)
})

同样的,如果把isSuccess改为false,输出又是什么?自己试一试吧

4.4.2 带异步逻辑的promise

下面用Promise改写4.2.1小节的例子
let src = 'https://avatar-static.segmentfault.com/198/713/1987139774-59c8bdbc3b36b_huge256' // 正确路径
// let src = 'xxx'; // 错误路径

let loadImg = new Promise((resolve, reject) => {
    let img = new Image();

    img.onload = () => {
        resolve(img);
    }

    img.onerror = () => {
        reject('图片加载失败');
    }

    img.src = src;
})

loadImg.then(param => {
    console.log('图片加载成功');
    let body = document.querySelector('body');
    body.appendChild(param);
}, param => {
    console.log(param);
})

4.5 处理多个Promise

4.5.1 Promise.all()

语法:看mdn文档

作用

对迭代器的元素用Promise.resolve()进行包装,再对包装的结果进行合成,但合成结果遵循以下原则:

  1. 如果有至少一个Promise的结果是pending,合成的结果就是pending
  2. 如果有至少一个Promise的结果是reject,合成的结果就是reject【该条规则优先级高于上一条
let p1 = new Promise(() => {});

// pending + reject === reject
Promise.all([p1, Promise.reject('error')])
.then(param => {
    console.log(param)
}, param => {
    console.log(param) // error
})

Promise.all的解决的值的传递:

  1. 如果结果是rejected,只会把第一个出现rejected状态的Promise实例的结果传递出去
  2. 如果结果是fulfilled,会把所有fulfilled状态的Promise实例的结果封装成数组传递出去
// 规则一
Promise.all([Promise.reject('error1'), Promise.reject('error2')])
.then(param => {
    console.log(param)
}, param => {
    console.log(param) // error1
})
// 规则二
Promise.all([Promise.resolve('success1'), Promise.resolve('success2')])
.then(param => {
    console.log(param) // [ 'success1', 'success2' ]
}, param => {
    console.log(param)
})

4.5.2 Promise.race()

语法:与Promise.all()一样

作用:

对迭代器的元素用Promise.resolve()进行包装,返回第一个fulfilledrejected的包装的结果

let p1 = new Promise((resolve) => {
    setTimeout(resolve, 1000, 'success');
})

let p2 = new Promise((resolve, reject) => {
    setTimeout(reject, 500, 'error');
})

// p2第一个结束,因此Promise.race()的结果是rejected,会把拒绝理由传递给then
Promise.race([p1, p2])
.then(param => {
    console.log(param);
}, param => {
    console.log(param); // error
})

4.6 用生活化语言理解promise

Promise的中文翻译是承诺(高程4th将其翻译为期约),then的中文翻译是然后
所以,你可以想象你去买橘子,结果店里没有进货,店员对你Promise,只要他店里到货(fulfilled)或者不再进货(rejected),then他就会通知你

5 async / await 语句

5.1 async

5.1.1 async关键字声明一个异步函数

先看一下async的用法:在函数声明前加上async就会让这个函数成为一个异步函数

// 下面的fn都被async声明为了异步函数
async function fn() {};
let fn = async () => {};

5.1.2 异步函数的特性

1.异步函数 始终返回一个Promise实例,其 本质将异步函数的返回值用Promise.resolve()包装
let fn = async () => {
    // 没写return,相当于return undefined
}

console.log(fn()); // Promise {: undefined}

上面的写法与下面的写法产生的效果几乎相同

let fn = () => {
    return Promise.resolve(undefined);
}

console.log(fn()); // Promise {: undefined}
2.异步函数 本质上是为await提供一个运行环境,如果不包含await关键字,其执行上与普通函数一样

什么意思呢?

async关键字其实只是告诉浏览器,这个函数是一个异步函数,里面有异步的代码,方便浏览器对其进行解析

但你如果不写异步代码(就像上面的例子一样),浏览器也拿你没办法,只是解析完后才发现这实际上只是个普通函数,浏览器为异步函数做的一些准备没派上用场,做了无用功。

从这点来看,async关键字其实就只是一个标识符

5.2 await

5.2.1 await的用法

作用: await操作符用于等待一个Promise实例的结果,本质上是让Promise.resolve()对await操作符之后的表达式进行包装

限制:只能在异步函数async function中使用

先来看一个例子

let result;
async function fn1() {
    let p = new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('10秒后');
            resolve(1);
        }, 10000);
    })
    result = await p;
      console.log(2);
}

fn1();

在运行代码的过程中,我们会发现一些问题:

  1. 如果你在控制台不断输入result,控制台会不断地提示:undefined,10s后再输入才会有结果
  2. console.log(2);这句也是在10s后才执行的

ES6指北【7】——从回调地狱到Promise和async/await_第5张图片

为啥会这样呢?
因为await在等待p这个Promise实例有结果后,才会执行result =这一句和下面的语句
也就是说await阻塞了之后代码的执行

5.2.2 await让异步代码变得像同步代码

这个标题有些绕,我们先对5.2.1小节的例子做个修改,去掉await,看看结果

let result;

async function fn1() {
    let p = new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('10秒后');
            resolve(1);
        }, 10000);
    })
    result = p; // 删去await
    console.log('result:', result); // 打印一下result
    console.log(2);
}

fn1();

ES6指北【7】——从回调地狱到Promise和async/await_第6张图片

我们可以很明显的看到,console.log('result:', result);console.log(2);立刻被执行了

但是!卧槽?result的结果怎么是个pending状态的Promise的实例呢?

其实很简单,因为result是一个Promise实例,是一个异步代码,而console.log('result:', result);是一个同步代码,因此只能获取到result产生最终结果之前的状态,也就是pending。

再结合await,我们通过result = await p;可以直接拿到p最后的结果,实际上是让p最终结果产生之后,即异步代码结束后再继续执行下面的代码

而这不就是同步的方式吗?

也就是说,await通过阻塞同步代码的执行,改变了整个代码的执行顺序,它可以让你用写同步代码的方式去写异步代码

5.3 await的使用细节

5.3.1 await一个fulfilled的Promise实例

没啥好说的,可以直接拿到结果
let p = new Promise((resolve) => {
    setTimeout(resolve, 1000, 1);
})

async function fn() {
    let result = await p;
    console.log('result:', result); // result: 1
}

fn();

5.3.2 await一个pending的Promise实例

注意,如果一个Promise实例永远是pending态,那么 await就会永远等待他进行转变

即:await后面的代码永远不会执行

let p = new Promise((resolve) => {});

async function fn() {
    let result = /* 前面和下面的代码将永远都不会执行 */ await p;
    console.log('result:', result);
}

fn();

5.3.3 await一个rejected的Promise实例

此时我们会发现,浏览器会直接报错,而且 await后面的代码又永远不会执行了
let p = new Promise((resolve, reject) => {
    reject('error');
})

async function fn() {
    let result = /* 前面和下面的代码将永远都不会执行 */ await p;
    console.log('result:', result);
}

fn();
根据文档,这个时候我们必须要使用 try...catch...进行错误处理
let p = new Promise((resolve, reject) => {
    reject('error');
})

async function fn() {
    try {
        let result = /* 前面和下面的代码将永远都不会执行 */ await p;
        console.log('result:', result);
    } catch (e) {
          // 下面这句会执行
        console.log(e); 
    }
}

fn();

等等!try...catch...不是只能抓同步代码的错吗,比如下面这样浏览器还是会报错

try {
    Promise.reject('error');
} catch (e) {
    console.log(e);
}

那为什么现在用了await又能抓异步代码的错误了呢?

其实很简单,之前已经解释过,await让异步代码变得像同步代码一样,因此try...catch...并非是抓了异步代码的错,仍然是在抓取同步代码的错误

5.4 await练习

我们在4.5小节举了多个关于Promise.all()Promise.race(),为了演示这两个方法的最终结果,我们使用了then方法

现在,我们完全可以用await来改写

5.4.1 演示Promise.all()

let p1 = new Promise(() => {});

// pending + reject === reject
Promise.all([p1, Promise.reject('error')])
.then(param => {
    console.log(param)
}, param => {
    console.log(param) // error
})

可改写为

async function fn() {
    let p1 = new Promise(() => {});

    // pending + reject === reject
    try {
        let result = await Promise.all([p1, Promise.reject('error')])
        console.log(result);
    } catch (e) {
        console.log(e); // error
    }
}

fn();

其它例子兄弟们可以自己练习,我就不演示了

5.4.2 演示Promise.race()

let p1 = new Promise((resolve) => {
    setTimeout(resolve, 1000, 'success');
})

let p2 = new Promise((resolve, reject) => {
    setTimeout(reject, 500, 'error');
})

// p2第一个结束,因此Promise.race()的结果是rejected,会把拒绝理由传递给then
Promise.race([p1, p2])
.then(param => {
    console.log(param);
}, param => {
    console.log(param); // error
})

可改写为

async function fn() {
    let p1 = new Promise((resolve) => {
        setTimeout(resolve, 1000, 'success');
    })

    let p2 = new Promise((resolve, reject) => {
        setTimeout(reject, 500, 'error');
    })

    // p2第一个结束,因此Promise.race()的结果是rejected
    try {
        let result = await Promise.race([p1, p2])
        console.log(result);
    } catch (e) {
        console.log(e); // error
    }
}

fn();

你可能感兴趣的:(ES6指北【7】——从回调地狱到Promise和async/await)