要理解JavaScript的运行机制,需要深入理解几个点:JavaScript的单线程机制、任务队列(同步任务和异步任务)、事件和回调函数、定时器、Event Loop(事件循环)。
JavaScript的单线程机制
JavaScript 的一个语言特性(也是该语言的核心)是单线程。 简单来说,单线程就是同一时间只能做一件事。 当有多个任务时,只能按照顺序完成一项任务,然后再执行下一项任务。
JavaScript 的单线程性与其语言的使用有关。 作为一种浏览器脚本语言,JavaScript的主要目的是完成用户交互和操作DOM。 这就决定了它只能是单线程的,否则会造成复杂的同步问题。
想象一下,JavaScript 同时有两个线程,一个线程需要向某个 DOM 节点添加内容,另一个线程的操作是删除这个节点,那么浏览器应该引用谁呢?
所以为了防止复杂性,JavaScript从诞生起就是单线程的。
为了提高CPU利用率,HTML5提出了Web Worker标准,该标准允许JavaScript脚本创建多个线程,但子线程完全由主线程控制,不得操作DOM。 所以这个标准并没有改变JavaScript的单线程本质。
任务队列
一项一项的完成任务,意味着需要完成的任务需要排队,那么为什么需要排队呢?
排队的原因通常有两个:
于是,JavaScript的设计者也意识到,此时可以先运行已经准备好的任务来提高运行效率,即先将等待的任务挂起并放在一边,然后再执行他们在得到他们需要的东西后。 就好像接电话时对方离开一段时间,此时又有一个来电,于是你挂断当前的通话,等通话结束后再接回上一个通话。 因此就出现了同步和异步的概念,任务也分为两种,一种是同步任务(Synchronous),一种是异步任务(Asynchronous)。
具体异步执行如下:
那么怎么知道主线程执行栈是空的呢? js引擎有一个监控进程,会不断检查主线程执行栈是否为空。 一旦为空,就会去Event Queue中检查是否有等待调用的函数。
使用下面的一个
一张图来说明主线程和任务队列。
如果用文字描述地图要表达的内容:
Storm事件和回调函数
“任务队列”就是一个风暴队列(也可以理解为消息队列)。 当IO设备完成一个任务时,它会向“任务队列”添加一个storm,表示相关的异步任务可以进入“执行栈”。 ”。然后主线程读取“任务队列”,看看出了什么问题。
“任务队列”的混乱php 执行js,除了IO设备的混乱外,还包括用户引起的一些混乱(比如键盘点击、页面滚动等)。 只要指定回调函数,当这些风暴发生时,它们就会进入“任务队列”等待主线程读取。
打回来
所谓“回调函数”(callback)就是会被主线程挂起的代码。 异步任务必须指定回调函数。 当主线程开始执行异步任务时,就会执行相应的回调函数。
“任务队列”是先进先出的数据结构,排在后面的storm首先被主线程读取。 主线程的读取过程基本上都是手动的。 一旦执行堆栈被清除,“任务队列”上的第一个风暴将手动进入主线程。 但是,如果包含了“定时器”,主线程必须首先检查执行时间,并且有些事件只有在指定时间之后才能返回到主线程。
事件循环
主线程从“任务队列”中读取storm。 这个过程是循环的,所以整个运行机制也被称为“Event Loop”(事件循环)。
为了更好地理解Event Loop,我们参考Philip Roberts演讲中的一张图片。
上图中,主线程运行时,会生成堆(heap)和栈(stack)。 堆栈中的代码调用各种外部API,并向“任务队列”添加各种干扰(点击、加载、完成)。 。 当栈中的代码执行完毕后,主线程会读取“任务队列”,并依次执行这些扰动对应的回调函数。
执行栈(同步任务)中的代码总是在读取“任务队列”(异步任务)之前执行。
let data = []; $.ajax({ url:www.javascript.com, data:data, success:() => { console.log('发送成功!'); } })console.log('代码执行结束');
登录复制
上面是一个简单的ajax请求代码:
定时器
“任务队列”除了放置异步任务的wave之外,还可以放置定时wave,即指定多长时间后执行个别代码。 这称为计时器函数,是定期执行的代码。
SetTimeout() 和 setInterval() 可用于注册在指定时间后调用一次或重复调用的函数。 它们的内部运行机制是完全相同的。 区别在于后者指定的代码执行一次,而后者会在指定的微秒内以数字的间隔重复调用:
setInterval(updateClock, 60000); //60秒调用一次updateClock()
登录复制
因为它们都是客户端 JavaScript 中重要的全局函数,所以它们被定义为 Window 对象。
但作为一个通用函数,它实际上并不对窗口做任何事情。
Window 对象的 setTImeout() 方法用于实现在指定微秒数后运行的函数。 所以它接受两个参数,第一个是回调函数,第二个是延迟执行的微秒数。 setTimeout() 和 setInterval() 返回一个值,该值可以传递给clearTimeout() 以取消该函数的执行。
console.log(1); setTimeout(function(){console.log(2);}, 1000);console.log(3);
登录复制
上面代码的执行结果是1,3,2,因为setTimeout()将第二行的执行延迟到了1000毫秒之后。
如果setTimeout()的第二个参数设置为0php 执行js,则表示当前代码执行完后(清空执行栈),立即执行指定的回调函数(间隔0毫秒)。
setTimeout(function(){console.log(1);}, 0);console.log(2)
登录复制
上面代码的执行结果始终是2, 1,因为只有执行完第二行之后,系统才会执行“任务队列”中的回调函数。
简而言之,setTimeout(fn,0)的意义就是指定一个任务在主线程最早可用空闲时间执行,即尽早执行。 它在“任务队列”的尾部添加了一个wave,因此只有在同步任务和“任务队列”的现有wave都被处理之后才会执行它。
HTML5标准规定setTimeout()第二个参数的最小值(最短间隔)不能高于4毫秒。 如果高于这个值,就会手动减少。
需要注意的是,setTimeout()只是将storm插入到“任务队列”中,主线程执行完当前代码(执行栈)后才会执行它指定的回调函数。 如果当前代码耗时较长,则可能会耗时较长,因此无法保证反弹函数一定会在setTimeout()指定的时间执行。
由于历史原因,setTimeout() 和 setInterval() 的第一个参数可以作为字符串传递。 如果是这样,则将在指定的超时或间隔后评估字符串(相当于 eval() )。
Node.js 的事件循环
Node.js也是单线程的Event Loop,但其运行机制与浏览器环境不同。
Node.js的运行机制如下。
除了setTimeout和setInterval这两个方法之外,Node.js还提供了另外两个与“任务队列”相关的方法:process.nextTick和setImmediate。 它们可以帮助我们加深对“任务队列”的理解。
process.nextTick方法可以在下一个Event Loop(主线程读取“任务队列”)之前在当前“执行堆栈”末尾触发回调函数。 也就是说,它指定的任务总是发生在所有异步任务之前。 setImmediate方法是在当前“任务队列”的末尾添加一个storm,即它指定的任务总是在下一个Event Loop中执行,这与setTimeout(fn, 0)非常相似。请参见下面的例子
process.nextTick(function A() {console.log(1);process.nextTick(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0)// 1// 2// TIMEOUT FIRED
登录复制
上面的代码中,由于process.nextTick指定的回调函数总是在当前“执行栈”末尾触发,所以函数A在setTimeout指定的回调函数超时之前执行,函数B也在超时之前执行。 这意味着如果有多个process.nextTick语句(无论是否嵌套),它们都会在当前“执行栈”上执行。
现在,再次查看 setImmediate。
setImmediate(function A() {console.log(1); setImmediate(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0);
登录复制
上面代码中setImmediate和setTimeout(fn,0)分别添加了回调函数A和timeout,这两个函数都是在下一个Event Loop中触发的。 那么,哪个回调函数会先执行呢? 答案并不确定。 操作的结果可能是 1–TIMEOUT FIRED–2 或 TIMEOUT FIRED–1–2。
令人费解的是,Node.js 文档指出 setImmediate 指定的回调函数总是落后于 setTimeout。 事实上,这只发生在递归调用中。
setImmediate(function (){setImmediate(function A() {console.log(1); setImmediate(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0); }); // 1 // TIMEOUT FIRED // 2
登录复制
上面的代码中,setImmediate和setTimeout被封装在一个setImmediate中,它的运行结果始终是1-TIMEOUT FIRED-2,并且函数A必须在超时时触发。 至于TIMEOUT FIRED旁边的2(即函数B在超时之前触发),是因为setImmediate总是将storm注册到下一轮Event Loop,所以函数A和timeout在同一轮Loop中执行,而函数B在下一轮循环执行中执行。
由此我们得到process.nextTick和setImmediate之间的一个重要区别:多个process.nextTick语句总是在当前“执行栈”中执行一次,而多个setImmediate可能需要多次循环才能执行。 事实上,这就是 Node.js 10.0 版本添加 setImmediate 方法的原因。 否则,像下面这样的对process.nextTick的递归调用将是无休止的,主线程根本不会读取“事件队列”!
process.nextTick(function foo() {process.nextTick(foo); });
登录复制
事实上,现在如果你递归地编写 process.nextTick,Node.js 会抛出警告,要求你更改为 setImmediate。
另外,由于process.nextTick指定的回调函数是在本次“事件周期”中触发的,而setImmediate是在上一个“事件周期”中指定的,所以显然前者总是早于前者发生,而执行效率也高(因为不需要检查“任务队列”)。
承诺
除了广义的同步任务和异步任务之外,任务还有更精细的定义:
事件循环、宏任务、微任务之间的关系如图所示:
根据宏任务和微任务的分类方法,JS的执行机制是
请看下面的反例:
setTimeout(function(){ console.log('定时器开始啦') }); new Promise(function(resolve){ console.log('马上执行for循环啦'); for(var i = 0; i < 10000; i++){ i == 99 && resolve(); } }).then(function(){ console.log('执行then函数啦') }); console.log('代码执行结束');
登录复制
所以最终的执行顺序是[立即执行for循环-代码执行结束-执行then函数-定时器启动]
我们来分析一段比较复杂的代码,看看你是否真正掌握了js的执行机制:
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5') }) })process.nextTick(function() { console.log('6'); }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8') }) setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12') }) })
登录复制
第一轮风暴周期过程分析如下:
宏任务事件队列 微任务事件队列
设置超时1
流程1
设置超时2
那么1
* 上表是第一轮风暴周期宏任务结束时各个Event Queue的情况。 此时1和7已经输出了。
好了,第一轮风暴循环即将结束,本轮结果输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:
宏任务事件队列 微任务事件队列
设置超时2
进程2
那么2
* 第二轮风暴周期宏任务结束,我们发现还有process2和then2两个微任务可以执行。
* 输出 3。
* 输出 5。
* 第二轮风暴周期结束,第二轮产出为2、4、3、5。
* 第三轮风暴周期开始,此时只剩下setTimeout2,执行它。
* 直接输出9。
* 将process.nextTick()分发到微任务事件队列。 将其表示为process3。
* 直接执行new Promise,输出11。
* 分发then到微任务事件队列,记为then3。
宏任务事件队列 微任务事件队列
流程3
然后3
* 第三轮风暴周期宏任务执行结束,执行两个微任务process3和then3。
* 输出 10。
* 输出 12。
* 第三轮风暴周期结束,第三轮产出为9、11、10、12。
整个代码中,一共进行了3个风暴周期,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,节点环境中的窃听依赖于libuv和后端环境不完全一样,输出顺序可能会有偏差)