Javascript 异步编程 | 青训营

53 阅读13分钟

Javascript 异步编程

前言

异步编程的语法目标,就是怎样让它更像同步编程。——阮一峰 《深入掌握 ECMAScript 6 异步编程》

我们知道Javascript语言的执行环境是"单线程"。也就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。

这种模式虽然实现起来比较简单,执行环境相对单纯,但是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步和异步。本文主要介绍异步编程几种办法,并通过比较,得到最佳异步编程的解决方案!

可以说JavaScript 的异步编程发展经过了四个阶段:

  1. 回调函数、发布订阅

  2. Promise

  3. co 自执行的 Generator 函数

  4. async / await

这也是这次我们主要要讲的四种异步解决方案

同步与异步

我们可以通俗理解为异步就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有”堵塞“效应。比如,有一个任务是读取文件进行处理,异步的执行过程就是下面这样

img

这种不连续的执行,就叫做异步。相应地,连续的执行,就叫做同步

img

回调函数

"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,回调函数是异步操作最基本的方法。以下的定时器就是一个异步函数简单例子:

setTimeout(function(){
     // 处理逻辑
},2000)//在2s后执行处理逻辑里的代码

这可以说是异步编程最简单的应用。所以说异步编程听起来很高大尚,,但是其实它的原理并没有那么高大上。上面这些说这么多例子大家想必同步和异步有一些大致的概念了吧,来上代码

//假设有四个人,分别叫小红,小绿,小紫,还有超哥一起抢三张凳子
//先让我们用同步的方式展现
console.log("小红发现凳子");
console.log("小红抢到凳子");
console.log("小绿发现凳子");
console.log("小绿抢到凳子");
console.log("超哥发现凳子");
setTimeout(function(){
  console.log("超哥抢到凳子");
}, 20);//定时器在20ms后执行回调函数,也就是超哥会在20ms后抢到凳子
console.log("小紫发现凳子");
console.log("小紫抢到凳子");

可以看出以上就是一种同步队列中夹杂着异步,console.log()为同步输出语句,当队列执行到有定时器时,单线程的Js并不会等待异步编程的结束,而是让后面同步队列先执行完后再去执行异步操作。

我们之前学习的事件监听回调也是一个最基本的异步函数。

var btn = document.querySelector(".button");
btn.addEventListener("click", function(){
  console.log("我是异步函数块中的同步,并不在浏览器同步队列中")
});

回调地狱问题

回调函数固然好,可以处理js浏览器中很多需要等待的任务,增加浏览器执行代码的效率,提高用户的使用试验。但是当有一个函数中,嵌套了一个回调函数然后在里面又嵌套了一个,无穷的嵌套也就造成了回调地狱问题。

举一个例子,定时器嵌套

function demo(num) {
  setTimeout(function () {
    console.log("1");
    if (num > 5) {
      setTimeout(function () {
        console.log(num);
      }, 500);
    } else {
      setTimeout(function () {
        console.log("3");
        setTimeout(function () {
          console.log("4");
          setTimeout(function () {
            console.log("5");
          }, 3000);
        }, 500);
      }, 500);
    }
  }, 500);
}
demo(6);

这时候原让人赏析悦目的代码有序执行的过程俨然变得更加难理解。

回调的嵌套会使得代码的可读性下降,对开发者项目后期改bug调试和维护造成很大的困难!

所以以下就是js的一些新特性可以解决以上回调地狱问题。

!!Promise

Promise本意是承诺,在程序中的意思就是承诺我过一段时间后会给你一个结果。 什么时候会用到过一段时间?答案是异步操作,异步是指可能比较长时间才有结果的才做,例如网络请求、读取本地文件等

1.Promise构造函数

Promise本质上就是一种构造函数,他能够像模板一样创建带有Promise功能的实例,其中参数会立即执行。

2.Promise的三种状态

  • Pending----Promise对象实例创建时候的初始状态

  • Fulfilled----可以理解为成功的状态

  • Rejected----可以理解为失败的状态 img

3.Promise使用

当我们在构造 Promise 的时候,构造函数内部的代码是立即执行的

new Promise((resolve, reject) => {
  console.log('new Promise')
  resolve('success')
})
console.log('end')
// new Promise => end

执行一个new Promise构造函数

我们可以利用Promise构造函数的特性进行实例化

let p = new Promise((resolve, reject) => {
    //做一些异步操作
    setTimeout(() => {
        console.log('执行完成');
        resolve('我是成功!!');
    }, 2000);
    p.then(()=>{})
});

也可以使用返回实例函数的方式接收(推荐)

let step = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("在学技术太有趣了!");
    }, 1000);
  });
};
let p = step();
p.then((res)=>{
	console.log(res);
})

Promise的构造函数接收一个参数:函数,并且这个函数需要传入两个参数:

  • resolve :异步操作执行成功后的回调函数

  • reject:异步操作执行失败后的回调函数

而resolve和reject则和下面的then链式回调的状态息息相关

4.then链式调用

所以,从表面上看,Promise只是能够简化层层回调的写法,而实质上,Promise的精髓是“状态”,用维护状态、传递状态的方式来使得回调函数能够及时调用,它比传递回调函数要简单、灵活的多。而且then函数本身为promise构造函数的实例,故接受链式调用,所以使用Promise的正确场景是这样的:

let p = new Promise((resolve, reject) => {
  //做一些异步操作
  setTimeout(() => {
    console.log("执行完成");
    resolve("好耶");
  }, 2000);
})
  .then(
    (data) => {
      console.log(data);
      return data;
      //此时输出data为resolve传入的参数
    },
    (error) => {
      console.log(error);
      //此时输出error为reject传入的参数
    }
  )
  .then((data) => {
    console.log(data);
    return data;
    //好耶
  })
  .then((data) => {
    console.log(data);
    //好耶
  })
  .then((data) => {
    console.log(data);
    //undefined
  })
  .catch((error) => {
    console.log(data);
  });

then链式调用

当resolve执行后,promise状态指定为resolved,执行成功的回调。每一次then的执行中参数的data都为上一次异步函数执行的返回值。若上一次无返回值,则输出undefined.错误同理。

在结尾加上catch进行错误捕获,用来中断链条,并且捕获错误原因。

我们可以看出,用then执行的函数每一步的执行都会去等待上一步的结果,在视觉上通过then来维系,可读性好,同时还能解决令人眼花缭乱的回调地狱问题,可以说是很优美的代码流程了!

5.catch的用法

我们知道Promise对象除了then方法,还有一个catch方法,它是做什么用的呢?其实它和then的第二个参数一样,用来指定reject的回调。用法是这样:

p.then((data) => {
    console.log('resolved',data);
}).catch((err) => {
    console.log('rejected',err);
});

效果和写在then的第二个参数里面一样。不过它还有另外一个作用:在执行resolve的回调(也就是上面then中的第一个参数)时,如果抛出异常了(代码出错了),那么并不会报错卡死js,而是会进到这个catch方法中。请看下面的代码:

p.then((data) => {
    console.log('resolved',data);
    console.log(somedata); //此处的somedata未定义
})
.catch((err) => {
    console.log('rejected',err);
});

在resolve的回调中,我们console.log(somedata);而somedata这个变量是没有被定义的。如果我们不用Promise,代码运行到这里就直接在控制台报错了,不往下运行了。但是在这里,会得到这样的结果:

img

也就是说进到catch方法里面去了,而且把错误原因传到了reason参数中。即便是有错误的代码也不会报错了,这与我们的try/catch语句有相同的功能

6.all的用法:谁跑的慢,以谁为准执行回调。

all接收一个数组参数,里面的值最终都算返回Promise对象

Promise的all方法提供了并行执行异步操作的能力,并且在所有异步操作执行完后才执行回调。看下面的例子:

let Promise1 = new Promise(function(resolve, reject){})
let Promise2 = new Promise(function(resolve, reject){})
let Promise3 = new Promise(function(resolve, reject){})

let p = Promise.all([Promise1, Promise2, Promise3])

p.then(funciton(){
  // 三个都成功则成功  
}, function(){
  // 只要有失败,则失败 
})

有了all,你就可以并行执行多个异步操作,并且在一个回调中处理所有的返回数据,是不是很酷?有一个场景是很适合用这个的,一些游戏类的素材比较多的应用,打开网页时,预先加载需要用到的各种资源如图片、flash以及各种静态文件。所有的都加载完后,我们再进行页面的初始化。

7.race的用法:谁跑的快,以谁为准执行回调

race的使用场景:比如我们可以用race给某个异步请求设置超时时间,并且在超时后执行相应的操作,代码如下:

 //请求某个图片资源
    function requestImg(){
        var p = new Promise((resolve, reject) => {
            var img = new Image();
            img.onload = function(){
                resolve(img);
            }
            img.src = '图片的路径';
        });
        return p;
    }
    //延时函数,用于给请求计时
    function timeout(){
        var p = new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('图片请求超时');
            }, 5000);
        });
        return p;
    }
    Promise.race([requestImg(), timeout()]).then((data) =>{
        console.log(data);
    }).catch((err) => {
        console.log(err);
    });

requestImg函数会异步请求一张图片,我把地址写为"图片的路径",所以肯定是无法成功请求到的。timeout函数是一个延时5秒的异步操作。我们把这两个返回Promise对象的函数放进race,于是他俩就会赛跑,如果5秒之内图片请求成功了,那么遍进入then方法,执行正常的流程。如果5秒钟图片还未成功返回,那么timeout就跑赢了,则进入catch,报出“图片请求超时”的信息。运行结果如下:

img

生成器Generators/ yield

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同,Generator 最大的特点就是可以控制函数的执行。

语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

在我们了解Generators前,让我们先来了解一下迭代

了解迭代

迭代的意思是按照顺序反复多次的执行一段程序,并且通常会有明确的终止条件.

以下的计数循环就是一次最简单的迭代.

for (let i =0;i<10;i++) {
  console.log(i);
}

实际上,这种循环的使用虽然说基础但是也很麻烦,因为很多时候我们对数组的遍历都是在已经知道数组的长度上,在这里还需要写上array.length作为判断依据,而在JavaScript中在一些特定的类型中安装的迭代器属性。只需要一个特定的操作符就可以调用身上对应的属性,从而返回我们所需要的值

迭代器和迭代器协议

实现了Iterable接口的数据结构都可以被Iterator类似的迭代器函数使用

而很多基础的数据类型都实现了迭代器接口,例如String,Arrary,Map,Set等几个基本数据类型

而实现了迭代器接口的数据都具有[Symbol.iterator]属性,调用以后会生成对应数据的迭代器函数

最基本的就是or of方法

for (let i of [1, 2, 3]) {
  console.log(i);
}
//自动挡开累了?来上手动档
let arr = [1, 2, 3];
let fn = arr[Symbol.iterator]();
console.log(fn.next());//{ value: 1, done: false }
console.log(fn.next());//{ value: 2, done: false }
console.log(fn.next());//{ value: 3, done: false }
console.log(fn.next());//{ value: undefined, done: true }
//还有...操作符
console.log(...[1,2,3,4,5])//1,2,3,4,5
const {name} = {
	id:"7777777"
	name:"niubi",
}
console.log(name);//niubi

其他类似的方法还有Array.from(),数组解构[]

我们可以得出结论,其实of对数组的遍历生成正是通过不断的执行[Symbol.iterator]生成的迭代函数然后获取返回对象的value值,并且将值给保存然后下一次继续执行,直到值为undefined还有done为true为止.

字符串和对象也有[Symbol.iterator]属性故也可以使用上述方法迭代.

调用生成器函数会产生一个生成器对象,在一开始处于暂停执行的状态.

而以上的[Symbol.iterator]正是担当了Generator 的作用!

Generator/yield 初体验

  • Generator 函数除了状态机,还是一个遍历器对象生成函数

  • 可暂停函数, yield可暂停,next方法可启动,每次返回的是yield后的表达式结果

  • yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。 可能结果跟你想象不一致,接下来我们逐行代码分析:

//我们可以把yield想象成为一个代码运行中的断点
function *foo(x) {
  let y = 2 * (yield (x + 1))
  let z = yield (y / 3)
  return (x + y + z)
}
let it = foo(5)
console.log(it.next())   // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8, done: false}
console.log(it.next(13)) // => {value: 42, done: true}
    • 首先 Generator 函数调用和普通函数不同,它会返回一个迭代器
    • 当执行第一次 next 时,传参会被忽略,并且函数暂停在 yield (x + 1) 处,所以返回 5 + 1 = 6
    • 当执行第二次 next 时,传入的参数12就会被当作上一个yield表达式的返回值,如果你不传参,yield 永远返回 undefined。此时 let y = 2 12,所以第二个 yield 等于 2 12 / 3 = 8
    • 当执行第三次 next 时,传入的参数13就会被当作上一个yield表达式的返回值,所以 z = 13, x = 5, y = 24,相加等于 42

接下来我们通过上述的等待机制可以打造一个自己的异步队列,靠异步执行完调用next去调用下一个异步函数,从而实现一个自己的异步队列。

let step = function (time) {
  setTimeout(() => {
    it2.next(`在${time}s后执行`);
  }, time);
};
function* demo() {
  //*别忘了
  let step1 = yield step(1000);
  console.log(step1);
  let step2 = yield step(1500);
  console.log(step2);
  let step3 = yield step(2500);
  console.log(step3);
}
let it2 = demo();
it2.next();

//这是我们手写实现的迭代器异步

从上例中我们看出手动迭代Generator 函数很麻烦,每次函数的执行需要自己去手动执行一个next,实现逻辑有点绕。

CO的使用

**co**是一个为Node.js和浏览器打造的基于生成器的流程控制工具,借助于Promise,依托于Generator 函数,你可以使用更加优雅的方式编写非阻塞代码

安装co库只需:npm install co

//当我们用到co库的时候
//注意他只对Promise实例函数有效,故需要将上述函数promisfy
let co = require("co");
let step = function (time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("学技术真的太有趣了!!!");
    }, time);
  });
};
function* demo() {
  //*别忘了
  let step1 = yield step(1000);
  console.log(step1);
  let step2 = yield step(1500);
  console.log(step2);
  let step3 = yield step(2500);
  console.log(step3);
  return "函数执行完毕";
}
co(demo()).then(function (data) {
  console.log(data);
});

//自动调用.next到下一个,并且借用了promise的链式回调实现,且代码结构美观

!!async/await

前面可能你听Generator生成器听的特别的费劲,但是接下来要讲的非常重要,之前的学Generator也就当听听,真要实现完美的异步还是得看async/await,这也是目前开发环境中最普遍也是个人觉得最好用的

async和await是基于promise实现的,可以完美的解决回调地狱问题,并且可以轻松做到上面引入了co的才能做的事。可以说是集成了上述所有的优点,而且优秀的封装也让它实现上述功能所用代码更少,并且可读性更强!

let step = (time) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("学技术太有趣了!");
    }, time);
  });
};
async function demo() {
  let word = await step(500);
  console.log(word);//学技术太有趣了!
}
demo();

注意:记住await后面一定要接promise风格的函数且有resolve返回值,否则返回undefined

当我们不断增加这种异步操作时,整个代码得结构反而如同同步一样清晰,使得回调地狱问题得到了完美的解决

async function demo(){
	let word1 = await step(500);
	let word2 = await step(1000);
	let word3 = await step(1500);
}

我们也可以对step进行Promise封装从而达到异步函数也像同步的执行顺序一样执行,非常人性化!

**注意:**在await之前的代码属于同步调用,在await之后的代码则会进入异步队列,会在前面的await得到返回值以后再执行后续的代码

let step = (time) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("在学技术太有趣了!");
    }, time);
  });
};
async function demo() {
  let word1 = await step(500);
  console.log(word1); //学技术太有趣了!
  let word2 = await step(1000);
  console.log(word2);//学技术太有趣了!
  let word3 = await step(1500);
  return "函数执行完成";
}
demo().then((res) => {
  console.log(res);//函数执行完成
});

async也是基于promise封装的函数,可以调用以后返回一个实例可以通过then的方式接受函数的返回值并进行处理!