说到JAVAScript的运行原理,自然绕不开JS引擎,运行上下文,单线程,事件循环,事件驱动,回调函数等概念。本文主要参考文章[1,2]。
为了更好的理解JavaScript如何工作的,首先要理解以下几个概念。
1.JS Engine
简单来说,JS引擎主要是对JS代码进行词法、语法等分析,通过编译器将代码编译成可执行的机器码让计算机去执行。
目前最流行的JS引擎非V8莫属了,Chrome浏览器和Node.js采用的引擎就是V8引擎。引擎的结构可以简单由下图表示:
就如JVM虚拟机一样,JS引擎中也有堆(Memory Heap)和栈(Call Stack)的概念。
此外,JS中存在闭包的概念,对于基本类型变量如果存在与闭包当中,那么也将存储在堆中。详细可见此处1,3
关于闭包的情况,就涉及到Captured Variables。我们知道Local Variables是最简单的情形,是直接存储在栈中的。而Captured Variables是对于存在闭包情况和with,try catch情况的变量。
function foo () { var x; // local variables var y; // captured variable, bar中引用了y function bar () { // bar 中的context会capture变量y use(y); } return bar; } 复制代码
如上述情况,变量y存在与bar()的闭包中,因此y是captured variable,是存储在堆中的。
2.RunTime
JS在浏览器中可以调用浏览器提供的API,如window对象,DOM相关API等。这些接口并不是由V8引擎提供的,是存在与浏览器当中的。因此简单来说,对于这些相关的外部接口,可以在运行时供JS调用,以及JS的事件循环(Event Loop)和事件队列(Callback Queue),把这些称为RunTime。有些地方也把JS所用到的core lib核心库也看作RunTime的一部分。
同样,在Node.js中,可以把Node的各种库提供的API称为RunTime。所以可以这么理解,Chrome和Node.js都采用相同的V8引擎,但拥有不同的运行环境(RunTime Environments)[4]。
3.Call Stack
JS被设计为单线程运行的,这是因为JS主要用来实现很多交互相关的操作,如DOM相关操作,如果是多线程会造成复杂的同步问题。因此JS自诞生以来就是单线程的,而且主线程都是用来进行界面相关的渲染操作 (为什么说是主线程,因为html5 提供了Web Worker,独立的一个后台JS,用来处理一些耗时数据操作。因为不会修改相关DOM及页面元素,因此不影响页面性能),如果有阻塞产生会导致浏览器卡死。
如果一个递归调用没有终止条件,是一个死循环的话,会导致调用栈内存不够而溢出,如:
function foo() { foo(); } foo(); 复制代码
例子中foo函数循环调用其本身,且没有终止条件,浏览器控制台输出调用栈达到最大调用次数。
JS线程如果遇到比较耗时操作,如读取文件,AJAX请求操作怎么办?这里JS用到了Callback回调函数来处理。
对于Call%20Stack中的每个方法调用,都会形成它自己的一个执行上下文Execution%20Context,关于执行上下文的详细阐述请看这篇文章
4.Event%20Loop%20&%20Callback
JS通过回调的方式,异步处理耗时的任务。一个简单的例子:
var%20result%20=%20ajax('...'); console.log(result); 复制代码
此时并不会得到result的值,result是undefined。这是因为ajax的调用是异步的,当前线程并不会等到ajax请求到结果后才执行console.log语句。而是调用ajax后请求的操作交给回调函数,自己是立刻返回。正确的写法应该是:
ajax('...',%20function(result)%20{ %20console.log(result); }) 复制代码
此时才能正确输出请求返回的结果。
JS引擎其实并不提供异步的支持,异步支持主要依赖于运行环境(浏览器或Node.js)。
So,%20for%20example,%20when%20your%20JavaScript%20program%20makes%20an%20Ajax%20request%20to%20fetch%20some%20data%20from%20the%20server,%20you%20set%20up%20the%20“response”%20code%20in%20a%20function%20(the%20“callback”),%20and%20the%20JS%20Engine%20tells%20the%20hosting%20environment:%20“Hey,%20I’m%20going%20to%20suspend%20execution%20for%20now,%20but%20whenever%20you%20finish%20with%20that%20network%20request,%20and%20you%20have%20some%20data,%20please%20call%20this%20function%20back.”
The%20browser%20is%20then%20set%20up%20to%20listen%20for%20the%20response%20from%20the%20network,%20and%20when%20it%20has%20something%20to%20return%20to%20you,%20it%20will%20schedule%20the%20callback%20function%20to%20be%20executed%20by%20inserting%20it%20into%20the%20event%20loop.
上面这两段话摘自于How%20JavaScript%20works,以通俗的方式解释了JS如何调用回调函数实现异步处理。
所以什么是Event%20Loop?
Event%20Loop只做一件事情,负责监听Call%20Stack和Callback%20Queue。当Call%20Stack里面的调用栈运行完变成空了,Event%20Loop就把Callback%20Queue里面的第一条事件(其实就是回调函数)放到调用栈中并执行它,后续不断循环执行这个操作。
一个setTimeout的例子以及对应的Event%20Loop动态图:
console.log('Hi'); setTimeout(function%20cb1()%20{%20 %20console.log('cb1'); },%205000); console.log('Bye'); 复制代码
setTimeout有个要注意的地方,如上述例子延迟5s执行,不是严格意义上的5s,正确来说是至少5s以后会执行。因为Web API会设定一个5s的定时器,时间到期后将回调函数加到队列中,此时该回调函数还不一定会马上运行,因为队列中可能还有之前加入的其他回调函数,而且还必须等到Call Stack空了之后才会从队列中取一个回调执行。
所以常见的setTimeout(callback, 0) 的做法就是为了在常规的调用介绍后马上运行回调函数。
console.log('Hi'); setTimeout(function() { console.log('callback'); }, 0); console.log('Bye'); // 输出 // Hi // Bye // callback 复制代码
在说一个容易犯错的栗子:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000 * i); } // 输出:5 5 5 5 5 复制代码
上面这个栗子并不是输出0,1,2,3,4,第一反应觉得应该是这样。但梳理了JS的时间循环后,应该很容易明白。
调用栈先执行 for(var i = 0; i < 5; i++) {...}方法,里面的定时器会到时间后会直接把回调函数放到事件队列中,等for循环执行完在依次取出放进调用栈。当for循环执行完时,i的值已经变成5,所以最后输出全都是5。
关于定时器又可以看看这篇有意思的文章
最后关于Event Loop,可以参考下这个视频。到目前为止说的event loop是前端浏览器中的event loop,关于Nodejs的Event Loop的细节阐述,请看我的另一篇文章Node.js design pattern : Reactor (Event Loop)。两者的区别对比可查看这篇文章你不知道的Event Loop,对两种event loop做了相关总结和比较。
总结
最后总结一下,JS的运行原理主要有以下几个方面: