最近在跟团队内的小伙伴们一块儿学习和研究Vue.js的源码,其中有一块是nextTick函数的实现,这个函数的主要做用就是使用宏任务或微任务来异步执行界面的渲染更新操做等等,因此我原本是打算深刻研究一下JavaScript的宏任务和微任务的,可是后来我发现我连JavaScript基本的运行机制都没太搞懂。javascript
带着这些疑问,我开始了长达一个多月的探索之旅。幸运的是,我找到了一些很是棒的学习资料,它们让我受益不浅,也解答了个人很大一部分问题,这就是我今天要分享给你们的个人学习成果,我会在这一篇博客里把我看过的这些学习资料中所讲到的重点知识所有都包含进来,以帮助你们更快更全面地理解JavaScript基本的运行机制。html
话很少说,咱们进入正文。java
在一个经典的计算机系统架构中,程序在运行时会把分配到的内存划分红四个区块,分别是:Code区块、Static/Global区块、Stack区块以及Heap区块。ajax
Code区块:用于装载程序运行的指令,其实就是你编写的代码最终编译成的机器指令;编程
Static/Global区块(如下简称:Static区块):用于存放全局变量。定义在函数内的变量只能在该函数内可见,在函数外是没法直接访问到的,可是定义在这里的变量能够在任何函数中都可以访问获得;数组
Stack区块:即Call Stack,用于存放函数运行时的数据信息。包括:函数调用时的参数、函数内定义的变量、函数运行结束后返回的地址等等;浏览器
Heap区块:函数运行时的基本数据类型的数据会直接保存在Stack中,而对象类型的数据则会在Heap区块中分配内存进行存储,而后返回分配内存的起始地址以保存在Stack中声明的变量中以便后续访问。网络
咱们目前只须要关注Stack区块便可。Stack是一个典型的栈类型数据结构(FILO:First In Last Out)。当JavaScript中的函数运行时,会往Stack栈中Push一段数据,这段数据咱们称之为Stack Frame,当函数运行结束后,会将该函数对应的Stack Frame数据段Pop出栈。因此,函数间的嵌套调用就会在Stack栈中堆叠一摞的Stack Frame数据段。为了让你有一个更清晰直观的认识,接下来咱们来看一段代码(示例一):数据结构
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
function baz() {
bar();
console.log('baz');
}
baz();
复制代码
这段代码很简单,它的运行结果就是依次打印出:foo、bar和baz。咱们来看一下这段代码在运行过程当中Stack区块的变化状况。闭包
第0步:程序准备执行,分配并划份内存空间,将代码指令装载进Code区块并开始执行。假设此时代码块的执行函数名为main,那么JavaScript Runtime会先将Stack Frame(main)压入Stack栈中,而后开始调用baz函数。
第1步:调用baz函数,将Stack Frame(baz)压入Stack栈中。
第2步:baz调用bar函数。将Stack Frame(bar)压入Stack栈中。
第3步:bar调用foo函数。将Stack Frame(foo)压入Stack栈中。
第4步:foo调用console.log函数。将Stack Frame(log)压入Stack栈中。
第5步:console.log函数在控制台打印出‘foo’,执行完毕后将Stack Frame(log)推出Stack栈。
第6步:foo函数执行完毕,将Stack Frame(foo)推出Stack栈。
第7步:bar调用console.log函数。将Stack Frame(log)压入Stack栈中。
第8步:console.log函数在控制台打印出‘bar’,执行完毕后将Stack Frame(log)推出Stack栈。
第9步:bar函数执行完毕,将Stack Frame(bar)推出Stack栈。
第10步:baz调用console.log函数。将Stack Frame(log)压入Stack栈中。
第11步:console.log函数在控制台打印出‘baz’,执行完毕后将Stack Frame(log)推出Stack栈。
第12步:baz函数执行完毕,将Stack Frame(baz)推出Stack栈。
第13步:程序运行结束,将Stack Frame(main)推出Stack栈,Code区块和Stack区块均使用完毕等待被GC回收。
看到这里,你应该已经对JavaScript的Call Stack有了一个更清晰直观的认识了。
接下来,咱们来聊一聊JavaScript中的“报错”。相信你们在浏览器中开发时都碰到过报错的状况,这时候浏览器终端会输出一段报错信息,里面包含了错误发生时的Stack栈中的函数调用链路状况。例如,我把上面的代码改为这样(示例二):
function foo() {
throw new Error('error from foo');
}
function bar() {
foo();
}
function baz() {
bar();
}
baz();
复制代码
代码执行后,会在浏览器终端打印出下面这样的报错信息。
基于Stack这样的设计,编译器就可以很轻松地定位发生错误时的函数调用链路状况,咱们也就可以很方便地排查发生错误的缘由了。
不少人也碰到过栈溢出(Stack Overflow)的问题。那么为何会有栈溢出的状况发生呢?由于Stack栈的大小是在程序运行开始前就已经肯定下来不可变动的,因此当你往栈中存放的数据超出栈的最大容量时,就会发生栈溢出的状况。一般的缘由都是由于代码的Bug致使函数无限循环嵌套调用,如同下面这个示例(示例三)所示:
咱们都知道,JavaScript是一门单线程(single-threaded)的语言,单线程就意味着“JavaScript Runtime只有一个Call Stack”,也意味着“JavaScript Runtime同一时间只能作一件事情”,来看看下面这段代码(示例四):
let arr = [0, 1, 2, 3, 4, 5];
/* 平方值 */
function square(arr) {
return arr.map((item) => item * item);
}
let res1 = square(arr);
console.log(res1); // [0, 1, 4, 9, 16, 25]
/* 立方值 */
function cube(arr) {
return arr.map((item) => item * item * item);
}
let res2 = cube(arr);
console.log(res2); // [0, 1, 8, 27, 64, 125]
复制代码
这段代码很简单,给定一个arr数组,分别计算输出数组中每个数值求平方和求立方以后的结果数组。这段代码在JavaScript中必然是顺序执行的,先求平方再求立方,可是咱们不妨设想一下,由于square和cube函数作的事情互不相干,那么咱们能不能让它们并行执行以提升运行效率呢?在这里由于arr数组很短,两个函数的计算逻辑也很简单,因此这段代码运行起来很是地快,可是若是arr数组很是地大,square和cube方法又进行了一些很是耗时的复杂计算的话,那么咱们的设想就变得很是地有意义了。可是,可行吗?答案是:No。以前我说过,JavaScript Runtime是单线程的,它同一时间只能作一件事情。因此咱们写的JavaScript代码只能单向串行执行,没法并行执行(这里暂不考虑Web Workers等技术)。
可是,若是是这样的话,那么咱们在代码中使用setTimeout函数时,就必须等待setTimeout指定的延迟时长事后执行回调函数,而后才能继续执行后面的代码,使用ajax发送请求也是一样的状况,咱们必须等到请求结果返回后执行回调函数,代码才能继续日后走。可是咱们知道这些都不是真实的状况,那么为何会存在这样的矛盾点呢?
先不着急揭晓答案,咱们先来研究一下setTimeout函数。
setTimeout函数基本的功能,就是接收一个回调函数和一个delay延迟时长(默认为0),而后在delay时长事后执行回调函数。来看一下下面的这段代码和它的运行结果(示例五):
function foo () {
console.log('one');
setTimeout(function inner() {
console.log('two')
}, 0);
console.log('three');
}
foo();
复制代码
也许有些同窗会对运行结果感到很意外,'three'居然在'two'以前被打印出来,咱们都知道setTimeout能够延迟执行一段函数,可是为何延迟时长设置为0都不能让inner函数当即被执行呢?为了探究这个问题,咱们来看一下这段代码在运行过程当中Stack区块的变化状况:
咱们重点关注上面的第4步、第5步和第9步。能够看到,当第4 ~ 5步调用setTimeout函数后,Stack Frame(setTimeout)莫名消失了,它接收的回调函数inner在此时并无被执行,程序继续日后走从而打印出'three'。当第8步foo函数执行完毕,也就是看似整段代码执行结束后,第9步inner函数又莫名出如今了Stack栈中并开始执行,inner函数运行完毕后整段代码才真正地运行结束。
咱们再来看看另一个例子(示例六):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 2000);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
复制代码
这段代码的运行结果有两点值得咱们关注。第一点,foo函数由于包含了一行空while语句而执行了1500ms,可是setTimeout中的inner函数彷佛并无受到任何影响,仍然在2秒钟以后开始执行,说明foo函数的执行和setTimeout的计时操做是在并行执行的。第二,inner函数打印的时间差并非刚恰好等于2000ms,而是2002ms,并且若是你运行这段代码的话你就会发现,你打印的结果极可能跟我不同,可是必定是大于等于2000ms的一个值。
著名的v8引擎是Chrome和NodeJS背后使用的JavaScript Runtime引擎,而你在它的源码里是搜不到setTimeout、DOM、Ajax等字样的,由于它自己只包含了heap和stack,其余的setTimeout、DOM、Ajax等相关的功能都是由浏览器基于v8引擎之上所构建和提供的WebAPIs功能。这些WebAPIs和v8引擎同样都是用C++编写的,它们会以独立的线程的方式提供服务,因此咱们的JavaScript Runtime是单线程的没错,可是当咱们调用这些WebAPIs时,它们就会另起一个独立的线程来完成各自的工做,这样咱们的JavaScript代码才有了并发的效果。
v8引擎的结构图以下所示:
而浏览器的全貌图是这样子的:
首先介绍一下WebAPIs部分,浏览器会维护一个事件映射表(Event Table),它记录着事件与回调函数之间的映射关系。若是你想监听某个DOM的click事件的话,那你就必须先在该DOM上注册click事件,而后当该DOM接收到click事件时才会有回调函数被执行,若是某个事件没有被绑定回调函数的话,那么该事件发生时就如同石沉大海同样什么也不会发生。Ajax也是同样,若是不添加返回响应时的回调函数的话,那么就会变成单纯的发送一个HTTP请求,也不会有后续的回调函数处理响应内容了。setTimeout自没必要说,它必需要设置一个回调函数才有意义。总而言之,这些事件与回调函数之间的映射关系都会被浏览器记录在Event Table表里,以便当对应事件发生时能执行对应的回调函数。
接下来是消息队列Message Queue(简称MQ),有些文章称之为Event Queue或者Callback Queue,说的都是同一个东西。MQ是一个典型的FIFO(First In First Out)的消息队列,新消息会被加入到队列的尾部,消息的执行顺序与加入队列的顺序相同。每一条消息都有与之绑定的一个函数,当队首的消息被处理时,消息对应的函数就会把消息当作输入参数并开始执行。刚刚Event Table中记录的事件发生时,就会往MQ队列中加入一条消息,而后等待被执行。
接下来咱们就要触及到整篇文章的重点和核心了,那就是Event Loop。刚刚咱们说到,消息已经被加入到MQ队列中,那么消息何时会被处理呢?这时候就该Event Loop登场了。
Event Loop实际作的事情很是地简单:它会持续不断地检查Call Stack是否为空,若是为空的话就检查MQ队列是否有待处理的消息,若是有的话就从MQ队列的队首取出一条消息并执行消息绑定的函数,若是没有的话就同步监控MQ队列是否有新的消息加入,一旦发现就当即取出并执行消息绑定的函数。整个过程不断重复。
知道了Event Loop的运行机制以后,以前的几个疑问就迎刃而解了。
首先看下示例五的setTimeout神秘消失和离奇闪现事件。我如今把第4步、第5步、第8步和第9步的完整截图发出来给你们看看:
第5步中,咱们调用了浏览器提供的setTimeout方法,随即启动一个单独的线程作计时操做,而后往Event Table中加入一条记录。这里因为delay参数设置为0,因此事件会被当即触发,而后往MQ队列中加入一条消息,因为此时Call Stack还不为空,因此消息会在MQ队列中等待。第8步中,foo函数执行完毕,Call Stack被清空,Event Loop发现Call Stack为空以后当即检查MQ队列,发现有一条待处理的消息,因而从队列中取出消息并开始执行消息绑定的函数,也就是inner函数,最后inner函数执行完毕,至此整个程序运行结束。你们能够在这儿看到完整的过程。
再来看下示例六的两个问题点。第一点答案已经揭晓了,咱们的JavaScript Runtime和setTimeout是在两个独立的线程上并行执行的。关于第二点,我相信有些同窗已经知道答案了,由于添加在setTimeout中的回调函数在倒计时结束以后并不会被当即执行(即使delay参数被设置为0),而是须要先将消息添加到MQ队列的队尾,而后等待排在前面的消息所有被处理完毕后才能开始执行,这个过程总归要花点时间,因此一般setTimeout回调函数执行时的实际delay时长都要大于指定的delay时长。一样给出示例六的完整运行过程。
顺便提一下,浏览器的每个tab(iframe标签和Web Workers一样如此)都拥有本身独立的Event Loop以及一整套的Runtime运行环境,包括Call Stack、Heap、Message Queue、Render Queue(后面会提到)等等,这样就保证了即使某一个tab由于执行了某种耗时的操做被阻塞,其余的tab也可以正常运做,而不会说直接致使整个浏览器被卡死。不一样Runtime之间的通信方式能够看这里。
JavaScript号称是一门“single-threaded(单线程)、non-blocking(非阻塞)、asynchronous(异步的)、concurrent(并发的)”编程语言。这确实是事实但也不尽然。说它是事实是由于浏览器将网络请求、文件操做(NodeJS)等几乎全部耗时的操做都以独立线程(concurrent)和异步回调(asynchronous)的形式提供给咱们使用,因此咱们的JavaScript Runtime主线程能够持续高效不间断地执行咱们的JS代码,这就是非阻塞(non-blocking)的含义。单线程(single-threaded)的JavaScript Runtime是优点也是劣势。优点在于它简化了咱们编写代码的方式,使得咱们能够不用考虑复杂的并发问题。劣势在于一旦有耗时的操做占据了JavaScript Runtime主线程的话,就会致使MQ队列中的消息没法获得及时的处理,还会阻塞UI渲染线程的执行,进而影响到页面的流畅性。
咱们将上面的示例六稍做改动,此次咱们把setTimeout的delay参数设置为500ms,来看看会发生些什么(示例七):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 500);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
复制代码
能够看到,总体代码的运行耗时依然是1500ms不变,可是咱们发现inner函数执行时时间也过去了1500ms,而并无像咱们指望的那样在500ms后就执行,缘由就是由于while((Date.now() - start) < 1500);
是一句同步的操做,它的执行会占据JavaScript Runtime主线程和Call Stack调用栈,进而致使即使inner函数对应的消息在500ms以后就已经在MQ队列中等待,可是因为此时Call Stack并不为空,因此inner函数就没法被Event Loop及时Pick进入Call Stack执行,它不得不等到1500ms事后Call Stack被清空,而后才能被执行。实际的运行效果请你们自行查看。
刚刚咱们有提到,若是JavaScript Runtime主线程被阻塞的话,一样会影响到UI渲染线程的执行,而一旦UI渲染线程被阻塞,用户就没法在页面上执行点击、滑动等操做了。这到底是为何呢?
原来,在浏览器的实现中,UI渲染操做(或者说是DOM更新操做)一样是以队列的形式处理的。相似于Message Queue,浏览器会维护一个Render Queue(简称RQ)来专门存放UI渲染消息,并且它跟MQ同样,必须等到Call Stack为空时才能被处理,不一样的是,它的处理优先级是要高于MQ的。界面刷新的频次通常是每秒钟60次,也就是每16.67ms会执行一次,因此Event Loop每隔16.67ms就查看一下RQ队列是否有待处理的消息,若是有的话就检查Call Stack是否为空,为空就从RQ队列取出消息并处理,不然就继续等待直至Call Stack被清空,而后再处理RQ队列中的UI渲染消息。
我相信你们都碰到过页面卡顿的状况,缘由就在这里了。我以前发的连接工具叫作loupe,是一个专门用来观察JavaScript Runtime的工具网站,打开它并点击左上角的图标就能够展开设置面板,里面能够设置代码运行时停顿的时长,还能够模拟UI渲染操做,勾中以后就能够查看当主线程代码运行时,UI渲染消息被阻塞的过程了。咱们仍是以示例六为例,来看看实际的运行效果:
咱们已经知道,JavaScript Runtime主线程的阻塞会致使RQ队列和MQ队列中的消息没法被及时处理,因此咱们要尽可能避免执行一些同步耗时的操做,要给到这些队列中的消息被处理的机会。
一样,会阻碍队列消息被及时处理的还有队列自己被阻塞的状况。比较典型的场景是在document的onscroll事件上绑定了回调函数,因为onscroll事件触发的频次一样是每秒60次,因此当用户滚动页面时,很容易就会把MQ队列塞满,若是回调函数里还执行了一些UI渲染等耗时的操做的话,那简直就是灾难性的,毕竟UI渲染线程和JavaScript Runtime主线程是没法并行执行的(运行效果传送门)。
至此个人分享就结束了,感谢Philip Roberts在2014年欧洲JSConf上精彩的演讲,是他让我真正搞明白了JavaScript的Event Loop到底是如何工做的,以前提到的loupe也是他的杰做,附上油管连接和优酷连接以供各位看官享用:)
最近又看了一篇博客(The JavaScript Event Loop: Explained),引起了我对于闭包的思考,考虑以下代码:
function foo() {
let a = {
name: 'Chris, Z',
gender: 'Man',
};
let b = 'Baby';
let c = 1024;
let d = true;
setTimeout(function inner() {
console.log(a);
console.log(b);
console.log(c);
console.log(d);
});
}
foo();
复制代码
按照咱们以前所说的,当foo执行完毕后它对应的stack frame(foo)就被移出Call Stack栈而不复存在了,可是咱们也知道inner函数执行时是可以访问到foo函数内定义的abcd变量的,这不是矛盾了吗? 我其实也没找到具体的资料解释这一块Runtime引擎是怎么处理的,因此我大胆地设想了几种可能的作法:
stack frame(foo)出栈时确实被内存回收了,可是Runtime引擎在这里作了优化,inner函数会将abcd变量的值拷贝下来保存到某个地方,因为a变量指向了堆中的一个对象,b变量指向了堆中的一个字符串常量,它们都是引用值,因此当inner函数将ab变量的引用地址值保存下来时,stack frame(foo)中声明的ab变量自己就能够被放心地回收了,ab变量所指向的堆地址因为仍然被inner函数所引用而不会被GC回收,进而能够在inner函数执行时被引用到。而cd变量就更简单了,它们只是原始类型而已,直接被inner函数拷贝保存下来就能够了,既不会影响stack frame(foo)的内存回收,也不会影响inner函数执行时引用到cd变量的值。
stack frame(foo)并不会真正出栈(逻辑上已经出栈,但物理上仍然占据栈内存),inner函数也无需在执行前保存它引用的变量值。那么此时Call Stack在内存空间上就会造成“空洞”,只不过Runtime引擎会很好地处理这种状况,不会让后续的stack frame入栈和出栈感觉到“空洞”的存在而已。
前面的跟作法二同样,只不过Call Stack会直接用跳过stack frame(foo)的一个新地址做为起始地址开始构建,这样就不会造成“空洞”了。
固然,上面的这些都只是我我的的猜测而已,若是谁有确切的答案还望不吝赐教。