Javascript 异步编程
前言
异步编程的语法目标,就是怎样让它更像同步编程。——阮一峰 《深入掌握 ECMAScript 6 异步编程》
我们知道Javascript语言的执行环境是"单线程"。也就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。
这种模式虽然实现起来比较简单,执行环境相对单纯,但是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步和异步。本文主要介绍异步编程几种办法,并通过比较,得到最佳异步编程的解决方案!
可以说JavaScript 的异步编程发展经过了四个阶段:
-
回调函数、发布订阅
-
Promise
-
co 自执行的 Generator 函数
-
async / await
这也是这次我们主要要讲的四种异步解决方案
同步与异步
我们可以通俗理解为异步就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有”堵塞“效应。比如,有一个任务是读取文件进行处理,异步的执行过程就是下面这样
这种不连续的执行,就叫做异步。相应地,连续的执行,就叫做同步
回调函数
"异步模式"非常重要。在浏览器端,耗时很长的操作都应该异步执行,回调函数是异步操作最基本的方法。以下的定时器就是一个异步函数简单例子:
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----可以理解为失败的状态
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,代码运行到这里就直接在控制台报错了,不往下运行了。但是在这里,会得到这样的结果:
也就是说进到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,报出“图片请求超时”的信息。运行结果如下:
生成器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的方式接受函数的返回值并进行处理!