浅聊异步--回调函数
为什么会想写一个关于JS的异步的一个系列呢?
在不断的学习之中,越发的让我觉得,异步以及原型,可以说是JS最为重要的两个部分,或者说是,JS最与其他的编程语言不一样的特性(当然JS还有很多其他的特性,虽然说很多特性本质是都是设计缺陷。。。)
这两个特性也导致了JS很多令人难以理解的部分,比如原型链中一直在扯的继承。。。
很多人都会说js是一个十多天设计出来的语言,很多东西都没有考虑到,所以js中存在很多难以理解的东西也就不足为奇了(或者说js这么烂是很有理由的),可是经过这么久的发展,其实js现在也在弥补一些以前的错误,特别是在ES6发展以后,很多缺陷都已经被补足,比如块作用域,我们很多时候已经不需要再去使用那些难以理解的东西了
可是,因为JS这门语言的特殊性质(或者说浏览器的特殊性质),很多东西其实是在做增量,比如class,本质也就是一个语法糖而已,如果我们不去理解一些底层的东西,不去理解那11天里为什么会做出这样的设计,那么我们依旧会很难驾驭JS,即使现在的异步已经有了Promise以及async这样的优秀的解决方案,我们依旧可以看到很多这样的代码
const getPromise1 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 1000);
})
}
const getPromise2 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 1000);
})
}
const test = () => {
getPromise1().then((res) => {
console.log(res);
getPromise2().then(res2 => {
console.log(res2);
})
})
}
依旧是是回调地狱的写法,并不知道为什么非要使用Promise
或者这样
const test2 = async () => {
await getPromise1().then(res => {
console.log(res);
})
}
这段代码当时看的我很惊讶。。。。
或者是
const sleep = () => {
setTimeout(() => {
console.log(1)
}, 1000);
}
const test3 = async () => {
console.log(0)
await sleep()
console.log(2)
}
test3()
期望输出0 1 2 且认为1会在0输出后1000ms后才进行输出的
ES6还可以称为ES2015,也就是说promise正式成为标准到我写这篇文章的时候已经过去了4年多了,而被称为异步的终极解决方案的async跟awiat也是在es2017中就被加入了进来,可是依旧有蛮蛮多的学习者对他们的使用方式是错误的,这里面不乏已经在工作了的人,这也是我为什么突然想写这样一个系列的文章的原因。
不过这篇文章对于那些已经能够很好的处理异步流程的同学来说可能没有什么帮助了,毕竟语言是在不断的发展的,如果只是要用得话,其实在条件允许的情况下,async跟awiat,以及promise,已经基本上成为了毫无争议的选择了,特别是async跟awiat,用起来真的很简单,而promise,我们更多的是使用它来生成可以给async使用的函数,或者用来做性能优化。
而这个系列的文章,主要是在探寻,为什么JavaScript的异步会发展成现在这样子,以及那些藏在现代异步手段下的东西,这样我们才不至于迷迷糊糊的犯下错误。
在讲解异步的处理的之前,我们需要先问自己一个问题
什么是异步?
一个比较通俗易懂的解释是
当JS引擎执行到同步代码的时候,同步代码会立即执行
当JS引擎执行到异步代码的时候,异步代码不会立即执行
比如
let a=1
const b=()=>console.log(a)
const run=(fnc)=>fnc()
run(b)
当JS引擎执行这句代码的时候,会立即执行里面的代码,立刻打印出来1
而对于如下的异步代码
setTimeout(()=>{
console.log(1)
},1000)
console.log(1)
这句代码并不会立即执行,而是等待1000ms后才执行
并且
在同步代码后面的代码必须等到同步代码执行完毕以后才会执行,而异步代码后面的代码则不会
常见的异步情况有
- 定时器
- 网络请求
- 事件
写在这些情况里面的回调函数代码,都不会立即执行
接下来我们来看下异步处理的最基本的方式,回调函数
在看回调函数之前
我们要先有的概念
1、JS在一开始的时候开发目的是运行在浏览器上面的
2、JS是单线程的(不应该说js是单线程的,这里所属的js是单线程的其实是说的是js引擎是单线程的)
3、JS支持函数式,或者说函数在JS中是一等公民,可以进行传递
这几点使得js与我们平时的时候接触的一些语言会有所不同,比如在java中不管我们做什么都要先声明一个类,而且函数也不能作为值进行传递
而在js中这三个内容是相辅相成的,因为js是运行在浏览器里面的,所以js必须是单线程的,所以js也就必须要使用异步的方式,因此必须使用回调函数
为什么呢,因为js创建出来的一个目的就是为了操作DOM,如果js引擎允许多线程得话,那么有可能出现线程a在获取某个元素的高度后,使用此高度进行计算,而在这两个步骤之间,线程b修改该元素的高度,因此计算结果将会不正确,也就无法进行正确的渲染,即使是server work这些加入多线程机制的内容里面,也是不允许对dom进行操作的,对dom的操作永远会是单线程,因此js也就是必须采用回调函数的方来对异步进行处理(这里可能存在一些误差,因为我确实不是很了解太多的语言),为什么这样说呢?假如是初学者,可能对异步以及回调函数没有太多的理解,所以接下来我们来聊聊异步。
我相信大部分的人学习js的时候应该很早的时候就接触到异步或者说回调函数了。比如settimeout,比如事件绑定,当然这个时候,很多人可能还是迷迷糊糊的,只知道我在这里(settimeout的参数)传入一个函数,那么过了一段时间以后,就会执行这段代码,我给dom事件绑定一个函数,那么当我点击按钮的时候(或者其他)的时候这个函数就会执行。
而当我们开始对异步以及回调函数产生疑惑的时候,我觉得可能大部分的人都是在第一次使用ajax或者说请求数据的时候,我们怎么才能获取到数据呢?
是这样吗?
let res=request.get('http://localhost:8888/',{})
console.log(res);
xxxx
这个其实真的蛮形象的,因为他很符合我们的认知,那就是我请求别人给我东西,别人给了我东西,然后我就可以用这个东西了,并且在python之类的代码中,这样写是完全没有问题的,可是这是JS。
在前面的论述中,我们已经根据1得出了2,JS必须是单线程的,那么我们来看下,假如JS允许这样的情况发生,那么会是怎么样的呢?
单线程的意思是在一段时间内,只能做一个事情,那么假如以上的的做法可以达到获取数据的办法,那么就会发生这样的事情,在我们点击一个按钮发送一个请求之后,这个时候请求正在返回,他很慢,很慢,我们等得无聊了,这个时候我想点一下旁边的按钮,刚刚我点了,他会给我一个反馈比如发射一发烟火,可是现在不管我怎么点都没有用,因为JS是单线程的,他现在在做其他的事情--等待。我们可以认为有一个人,这个人一次只能做一个事情,他刚刚送了一封信,为了获取回信,他现在在一直等着,等着,他没有办法来做,其他的事情,哈你说为什么他不能等下再去看看信有没有回复,现在怎么不去做其他的事情偏偏要等着?拜托,看下你写的代码,假如他不等拿到内容以后再去做其他的事情,他怎么能做其他的时候,或者说,他怎么知道xxxx中,那部分的内容是需要信里面的内容才能做得呢,哪些是不需要信里面的内容才能去做的?此外,假如他现在不等了,那么他什么时候才会知道信会到呢?
这种方案在python可信,是因为python有多线程啊,他完全可以让另外一个人去做这个事情,而可怜的js
只有一个人,那么我们肯定就要思考一下,有没有其他的办法来处理这种情况,毕竟傻傻的等着,实在是太过于愚蠢了。
那么应该怎么进行设计呢?
其实在前面的时候我们已经说过了怎么进行设计了,在等待的时间去做其他的事情了。那么我们可以这样做
- 发出信件(不管怎么说发信这个操作总是在我们当前的流程里面的)
- 提前安排好信到了以后要做什么(所以我们需要写一段代码,对这段代码进行特殊的处理,以跟我们正常的流程进行区分,为什么说不是等到信件到来再进行处理,这个我觉得你可以看一下自己的代码就明白为什么我要这样没说了,代码毕竟不是人)
- 继续做接下来安排好的事情
- 当把当前做完的事情做完后去看看是否有回信,如果有回信得话就开始做前面安排好的事情
这里安排好的事情就是回调函数
当然这里的描写其实还是有部分的偏差,不过我们已经知道了我们大致上要的效果,而实际上,JS的这套机制被称为事件轮询(event loop)
那么回归正题,浏览器对异步的处理机制到底是什么样子的呢?
首先我们要了解的是,js是单线程的,可是浏览器并不是单线程的,在这里我们说的js也可以说是js引擎。
不然下面的话说起来可能就会有一些歧义了。
在现代浏览器中一般是多进程的。主要有
- 渲染进程
- 网络进程
- 浏览器进程
- 插件进程
而js引擎线程程是属于渲染进程的一部分
我们可以认为JS单线程是说JS引擎是单线程的。而浏览器则是可以负责多个人去做事情的那个人
因为如果观测我们前面做的设计以及JS是单线程的,很容易就让人想到一些问题,那就是
1、假如没有信得话怎么办?
2、假如在我做事情的时候来了多封信,我应该先做那封信里面的事情,这里我们可以想到给信进行排顺序,那么问题就来了,谁来负责这个信的排序?
这个人就是浏览器啦
对于渲染进程至少有以下几个线程
1、GUI渲染线程
2、JS引擎线程
3、事件触发线程
4、定时器触发线程
5、异步http请求线程
而后面这三个,我们就可以很明显的发现,他们都是可以产生异步的操作的线程
那么我们来走一下JS执行的流程
1、先从整个JS脚本添加到任务队列中开始顺序执行、此时是跟GUI渲染线程是互斥的,会阻塞GUI的绘制
2、如果遇到了3、4、5这些情况,则使用异步线程,例如在当JS引擎线程执行到setTimeOut\setTimeInterval 关键词时,会把定时器任务添加到定时触发器线程中,定时触发器线程开始执行倒数,当倒数之间到了后,将回调任务添加到task queue任务队列中,等待JS引擎线程来执行
3、当当前任务队列中的内容执行完毕后,检查异步任务队列中是否存在任务,如果有,则依次执行异步任务队列中的内容
4、依次类推
这里又有了一个新的概念为事件轮询,在看这个概念之前我们可以先看一段代码
这段代码来自<<你不知道的js>>
// eventLoop是一个消息队列
// (先进,先出)
var eventLoop = [ ];
var event;
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件
try {
event();
}
catch (err) {
reportError(err);
}
}
}
eventLoop这个数组里面有什么?我们可以认为有一些函数,而这些函数里面的代码,就是我们在事件轮询中添加进去的。
所以说,事件轮询其实就是一个永远不会终止的循环,这个循环会检查一个数组(或者说函数调用栈)里面是否存在可执行的代码,如果有则进行执行
而什么时候这个数组里面才会被添加代码进去呢?
最开始的一次添加是在整个JS代码第一次执行的时候,整个JS代码被添加到了eventLoop队列中去了,而后当检测到异步操作的时候,浏览器就会存储好这些异步操作对应的回调函数,当异步操作完成的时候,则会被添加到event loop中去,等到当前的event loop执行完成了以后,就会进行下一步的执行。
具体流程可以参考图片(极客时间--浏览器原理与实践)
图源:极客时间--浏览器原理与实践以上的部分是如何实现的呢?
下面我们以setTimeout的实现为例子来看具体的实现方案.
如果要实现settimeout,我们不能仅需要一个消息队列,还需要一个队列,这个队列维护了需要延迟执行的任务列表。可想而知,在每次循环执行完毕以后,我们需要检查一下这个延迟队列中是否有任务达到了执行的条件,如果达到了,那么就应该把该任务放到任务队列中去,那么在以后任务队列中前面的任务都执行完毕了以后,那么这个任务就会执行。
一般来说,这个延迟队列中的任务应该包括
- 回调函数
- 当前发起时间
- 延迟执行时间
- id 用于取消执行等
参考代码
// eventLoop是一个消息队列
// (先进,先出)
var eventLoop = [ ];
var delayLoop=[];
var event;
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件
try {
event();
if delayLoop中存在任务可以执行了
for
event.loop.push(task)
}
catch (err) {
reportError(err);
}
}
}
从这里我们也可以看出来,即使你设置了settimeout的时间是1000ms,实际上最后的执行时间很可能会大于1000ms,一是因为即使轮到这个任务了但是因为前面还有任务在执行,需要等到前面的任务执行完毕了才会添加进任务队列,二是在添加到任务队列以后,浏览器也会执行一些自己的操作,导致最后的世界控制并不会很准确。
最后我们再来回顾一下这篇文章的内容
- 一些场景的异步的错误
- 为什么JS引擎是单线程了
- 事件轮询是怎么回事,是怎么实现的
文章参考
极客时间--浏览器工作原理与实践
「前端进阶」从多线程到Event Loop全面梳理
《你不知道的JS》