该文章是以国外一篇文章,关于JS事件轮训和调用栈(JavaScript Event Loop And Call Stack Explained)为基础。同时加上其他资料的所编写的。
如果想直接根据原文学习,可以忽略此文。但是不要忘记点赞+关注。
如果你觉得可以,请多点赞,鼓励我写出更精彩的文章🙏。
如果你感觉有问题,也欢迎在评论区评论,三人行,必有我师焉
概要
JS如何在浏览器中运行
要了解一件事本来的面貌特征,就需要一种高屋建瓴的方式来从整体把握整个事情的来龙去脉和发展脉络。
而如下的图片就展示了,JS是如何在浏览器中运行的轨迹。
而需要注意的一点:在图片中大部分所涉及的数据结构和部件都不是JS所提供的。
Web APIs、回调队列、事件循环都是浏览器友情赞助的。
调用栈
在我们第一次接触JS的时候,无论从课本上还是其他辅助资料,首先眼入眼帘的概念就是: JS是一个单线程语言。 那这个单线程又是在何种 限定条件 下才会这么说呢?
JS在某个时刻只能做一件事,因为JS 有且仅有 一个调用栈。
调用栈是帮助 JS编译器 用于追踪函数被调用的动向(顺序)的一种机制。
当 script
或者函数
调用另外一个函数的时候,被调用的函数就会被 添加到调用栈的顶部。(借用数据结构中的知识来讲,就是讲数据入栈)。
当函数退出(执行return
或者到函数最底部),编辑器就讲该函数从调用栈顶部移除。
让我们通过一个简单的例子,来跟踪一下,函数调用是如何和调用栈
合作的。
const addOne = value => value + 1; const addTwo = value => addOne(value + 1); const addThree = value => addTwo(value + 1); const calculation = () => addThree(1) + addTwo(2); // 函数调用 calculation(); 复制代码
每当一个函数调用(A)另外一个函数 (B),被调用函数被 push
到调用栈的顶部(在刚才调用函数(A)的上面)。
调用栈处理被调用函数的顺序遵循 LIFO (Last in,First Out)原则。
上面的示例,按照如下步骤进行操作:
- 1. 该文件被加载并且
mian
函数(代表整个文件)被调用,并且该函数被push
到调用栈中。(此时,该函数为栈底也为栈顶) - 2.
mian
函数调用calculation()
,同理,该函数也被push
到栈顶。 - 3.
calculation()
函数调用addThree()
,同理,该函数也被push
到栈顶。
....重复操作,直到调用addOne()
- 6.
addOne()
没有调用任何函数。所以当addOne()
退出(执行return
或者到函数最底部),它将会从调用栈-栈顶被pop
出。 - 7. 在
addtTwo()
中,处理addtOne()
的返回结果,也经历着同addtOne()
一样的命运-- 退出(执行return
或者到函数最底部),它将会从调用栈-栈顶被pop
出。
........重复操作,直到调用calculation()
- 14. 此时在该文件中不存在被调用的语句或者函数。所以
main()
执行退出
操作。
我们上文中,将函数执行环境称为main()
,其他在浏览器中,它是一个匿名函数-anonymous
堆栈溢出
在我们平时,开发代码的时候,总是一个不小心,就会产生了 死循环,而产生死循环的原因很简单,就是某一个函数或者代码,总是不停的迭代循环-无穷无尽,有一种到黄河不死心的决心和毅力。
而我们通过上文介绍的 调用栈的运行机制得知,当存在函数调用的时候,被调用的函数就会被 push
到调用栈-栈顶。而我们知道,无论何种语言,针对某一个数据都存在一个 MAX
存储空间。
所以,可想而知,堆栈溢出,就是因为被调用函数无穷无尽的被 push
到 调用栈,在某个时刻,被加入的函数,超过了栈的容忍上限
。
所以,浏览器会给你一个提示:
Uncaught RangeError: Maximum call stack size exceeded 复制代码
我们通过错误信息的提示可知。它表示,是函数调用导致了这个错误。在这个例子中,错误发生在函数b
中,而b
又被函数a
调用。
如果你在开发中遇到该错误,你通过错误信息的展示会发现,某一个函数被调用了很多次。调用栈最大范围为 10,000- 50,000 次。 如果你的函数被调用这么多次,也就意味着你代码中存在一个死循环。
浏览器通过限定调用栈的存储大小来避免你无意中写出的死循环从而导致页面挂掉。
总结: 调用栈用于 跟踪你代码中函数调用的顺序。它遵循 LIFO(后进先出)的原则---意味着,它总是处理被存放在栈顶的函数。JS有且仅有一个调用栈--->意味着JS在某一个时刻只能做一件事。
Web APIs
从上文所了解的概念得知,JS在某一个时刻只能做一件事。
但是这有一个很大的限定条件--->就是该条规则只适用于在了JS范围内。而这个条件一旦扩大,扩大到浏览器
范围内,就会产生不一样的效果。在浏览器范围内,在JS运行的同时,也可以通过浏览器提供的API并行做其他的事情。
浏览器为我们提供了,一些可以在JS代码中调用
的API。然后,这些API的执行
是由平台(浏览器,Node...)所控制的,这也就是为什么这些API不会阻塞调用栈的执行。
另外一个优点就是,这些API是由底层语言(C语言)所写,它们能做JS所不能做的事情。
这些API能赋予你所写的JS代码进行 AJAX请求、操作DOM、访问local storage、使用worker等等。
回调队列
通过使用浏览器为我们提供的API,我们能轻松的实现--->在JS运行的同时,做其他额外的事。但是,该如何在我们维护的JS代码中获取并使用Web API返回的结果?
此时,callback
(回调函数)粉墨登场。通过callback,web API允许我们在API调用并且返回对应结果后触发指定的回调函数。
callback 是个啥? callback 是一个 做为参数被传入到其他函数的 函数。callback一般在调用函数被执行完,才被执行。 高阶函数:接收一个函数作为参数的函数
const a = () => console.log('a'); const b = () => setTimeout(() => console.log('b'), 100); const c = () => console.log('c'); a(); b(); c(); 复制代码
由于setTimeout
属于web API。 所以在执行到setTimeout
的时候,触发了web API的执行流程。而此时JS解释器 继续 执行剩下的语句。
当timeout
已经到了并且调用栈为空,被传入到setTimeout
的回调函数将被执行。
输出结果如下:
a c b 复制代码
什么是回调队列
虽然setTimeout
被执行,但是它的回调函数不会立即被调用。
还记得JS在某一个时刻只能做一件事吗?
我们将一个函数作为参数传入到setTimeout
中,其作为setTimeout
的回调函数是用JS所写的。因此,JS解释器需要运行这段代码,也就意味着,这段代码需要被 push
到调用栈中。而满足被push
的条件就是调用栈是空的。所以,我们需要等待。
调用setTimeout
,触发了web API的执行,然后将callback(回调函数) enqueue
(入队)。 当调用栈为空时,event loop(事件循环)从callback queue中dequeue
出刚才被入队的callback,并将其 push
到调用栈中。
和调用栈不同的是,回调队列遵循 FIFO(First In, First Out),意味着处理回调的顺序是按照入队的顺序来进行。
事件循环
JS事件循环总是取出callback queue队首的元素,并且在call stack为空的时候,将其 push
到call stack中。
JS代码遵循一种从一而终的运行模式,也就是说,如果call stack中存在代码,事件循环将会被阻塞直到call stack为空的时候,才会再次执行 --->从callback queuedequeue
--->push
到call stack的流程。
不要通过运行计算密集型任务来阻塞调用堆栈。 如果在自己维护的代码中执行过多的耗时代码,此时,事件循环一直处于阻塞状态,网站将会变得卡顿。
setTimeout
如果我们想在不阻塞主线程太久的情况下执行某些任务,我们可以利用上面描述的行为。
将异步代码放在回调中并将setTimeout设置为0ms,该操作将允许浏览器在继续执行回调之前执行诸如更新DOM之类的操作。
工作队列和异步代码
除了callback queue,浏览器中还存在另外一个用于专门接收promises
的队列 ---job queue(工作队列)。
Promises
ES6第一次引入promises
,它可以通过Babel转换被主流浏览器所识别和使用。
promises
是另外一种不同于使用callback
处理异步代码的方式。它们允许你轻松地将异步函数链在一起,而不会在所谓的回调地狱中结束。
setTimeout(() => { console.log('Print this and wait'); setTimeout(() => { console.log('Do something else and wait'); setTimeout(() => { // ... }, 100); }, 100); }, 100) 复制代码
如果存在很多异步依赖,那就会陷入到回调地狱中。
而使用promises
,上述代码就会变得更加可读。
const timeout = (time) => new Promise(resolve => setTimeout(resolve, time)); timeout(1000) .then(() => { console.log('Hi after 1 second'); return timeout(1000); }) .then(() => { console.log('Hi after 2 seconds'); }); 复制代码
如果使用ES7的async/await
语法,更加简明。
const logDelayedMessages = async () => { await timeout(1000); console.log('Hi after 1 second'); await timeout(1000); console.log('Hi after 2 seconds'); }; logDelayedMessages(); 复制代码
promises适合在哪里
promises
的行为与回调略有不同,因为它们有自己的队列。
工作队列,也被称为promise队列,该队列拥有比callback queue更高的优先级。
也就意味着,event loop会优先遍历promise queue,在promise queue为空的时候,才会遍历 callback queue。
console.log('a'); setTimeout(() => console.log('b'), 0); new Promise((resolve, reject) => { resolve(); }) .then(() => { console.log('c'); }); console.log('d'); 复制代码
对应的输出结果为
a d c b 复制代码