从浏览器的进程开始一步一步的剖析JS的运行机制

644 阅读13分钟

前言

先来一句鸡血开启本文吧:比你优秀的大佬们比你还要努力,你还有什么资格下班回到家后就躺在那刷抖音,刷剧。二十几岁的年纪却过着六十几岁的生活...

笔者认为不论是身处在哪个行业,善于学习跟总结的人总能拥有各种各样的机会,如果兴趣不够,那就只能靠自律了

相信很多小伙伴不管是在面试,还是日常的工作当中一定会遇到这样的一些问题:

  • 面试官:说一下你对js的运行机制是如何理解的?
  • 改需求bug:我的setTimeout明明是写在console.log()之前,为啥console.log()却先执行呢?

面对上述问题,相信很多小伙伴通常都是一知半解,或者大概清楚什么是js事件循环,什么是异步任务跟同步任务,但是如果一旦深挖其背后的原理,可能就一脸懵逼了?正如下图的小哥哥一样...

本文大概从以下几个方面来深入的解析js在浏览器中的运行机制:

  • 浏览器多进程与js引擎线程之间的关系
  • 事件循环(Event Loop)中同步跟异步任务是如何分工及执行的顺序的不同
  • 结合常见的面试题来解析js的运行机制
  • 浏览器端跟Node端js运行的机制区别在哪

浏览器的多进程有哪些

在了解浏览器进程之前,我们先来科普一下什么是进程,具有什么特点?

  • 程序的最小运行环境进程:一个程序运行时,需要执行环境,包括执行上下文、代码、数据,和一个执行任务的主线程。在程序启动时,操作系统为程序会分配一块内存空间来初始化这样的运行环境,这样的一个运行环境称之为进程
  • 线程负责执行任务:程序的执行最终是在进程中的线程内执行的
  • 线程由进程管理:进程中的线程是不能独立存在的,由进程来管理
  • 线程阻塞:进程中的线程是阻塞的,任意线程执行出错都会导致进程崩溃
  • 数据共享:线程之间共享进程中的数据
  • 进程隔离:进程之间是相互隔离相互独立的内存空间,可以通过IPC通信
  • 内存回收:进程运行中,可以手动或自动控制内存的回收,或者在进程关闭之后,操作系统会回收内存,已达到内存循环利用的效果

我们打开chrome浏览器的任务管理器可以发现,当我们每次打开一个tab的时候都会新开启一个全新的进程。

浏览器是多进程的

通常浏览器每次新打开一个tab页都会自动开启一个新的进程,这些进程包括有哪些呢?

  • Browser进程 浏览器的主进程(负责协调、主控),只有一个
  • GPU进程 用于3D绘制
  • 第三方插件进程 当我们从chrome商店里下载浏览器插件并且使用的时候,就会开启一个相对应的进程
  • 浏览器渲染进程(浏览器的内核) 默认一个tab开启一个,互相之间不受影响,主要负责控制页面的渲染,同步、异步的事件处理

如图所示:

浏览器的内核是多线程的

浏览器渲染进程(浏览器内核)中到底会包含哪些线程呢?

大致可以分为以下几类:

  • GUI线程
  • 事件触发线程
  • JS引擎线程
  • 定时器线程
  • 网络请求线程

那么每一个线程又负责做什么工作呢?

GUI线程

  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和Render树,布局跟绘制。
  • 当我们修改元素的尺寸,页面就会回流(Reflow)
  • 当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)

注意:

GUI渲染线程与JS引擎线程是互斥的

  1. 当JS引擎执行时GUI线程会被挂起(相当于被冻结了)
  2. GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行

事件触发线程

  • 归属于浏览器内核进程,不受JS引擎线程控制。主要用于控制事件(例如鼠标,键盘等事件),当该事件被触发时候,事件触发线程就会把该事件的处理函数推进事件队列,等待JS引擎线程执行

JS引擎线程

  • JS引擎线程就是JS内核,负责处理Javascript脚本程序(例如V8引擎)
  • JS引擎线程负责解析Javascript脚本,运行代码
  • JS引擎一直等待着任务队列中任务的到来,然后加以处理
    • 浏览器同时只能有一个JS引擎线程在运行JS程序,所以js是单线程运行的
  • GUI渲染线程与JS引擎线程是互斥的,js引擎线程会阻塞GUI渲染线程
    • 我们常遇到的JS执行时间过长,造成页面的渲染不连贯,导致页面渲染加载阻塞(就是加载慢)
    • 例如浏览器渲染的时候遇到

定时器线程

  • 主要控制计时器setInterval和延时器setTimeout,用于定时器的计时,计时完毕,满足定时器的触发条件,则将定时器的处理函数推进事件队列中,等待JS引擎线程执行。

网络请求线程

  • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
  • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行
  • 简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行

js的本质是单线程的

为什么js是单线程的呢?

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

事件循环 Event Loop

同步任务&异步任务

刚才我们已经解释了js为什么是单线程的,那么单线程就意味着js在处理任务的时候是需要排队等候的,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。所以,js的执行任务可以分为两种:同步任务异步任务

  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
  • 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

两种任务的执行机制大概有如下几步

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  • 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  • 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  • 主线程不断重复上面的第三步。

当主线程执行完成之后,就会去读取"任务队列",然后重复的进行,这就是事件循环。

下面我们用一种图来理解一下事件循环:

  1. 整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
  2. 同步任务会直接进入主线程依次执行;
  3. 异步任务会再分为宏任务和微任务;
  4. 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  5. 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  6. 当主线程内的任务执行完毕,主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务;
  7. 上述过程会不断重复,这就是Event Loop事件循环;

宏任务 & 微任务

宏任务

可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:

(macro)task->渲染->(macro)task->...

常见的宏任务包括有哪些?

script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
setImmediate(Node.js 环境)

微任务

microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。

所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。

常见的微任务有哪些?

Promise.then
Object.observe
process.nextTick(Node.js 环境)

那么宏任务跟微任务之间的执行顺序又是什么样的呢?

当执行完宏任务以后,会首先判断还有没有微任务,如果有微任务则继续执行微任务,如果没有微任务就直接进行浏览器渲染。这样第一次的事件循环就执行完毕了,会继续下一次的事件循环,执行下一次宏任务。重复进行

灵魂拷问的面试题

梳理完上边的js事件循环机制以后,我们再撸几道面试题,加深一下印象。

第一题

console.log(1);
setTimeout(function(){
console.log(2)
}, 0);
console.log(3)

答案:1,3,2

解析:console.log()是同步任务,setTimeout是异步任务,异步任务会等同步任务执行完毕之后再执行。setTimeout虽然设置为了0,浏览器规定不低于4ms的延迟,console.log(2)在4ms后会被放入到任务队列中。当同步任务执行完成之后,打印1和3,主线程再从任务队列中取出任务,打印为2。

第二题

console.log('A')
while(true){}
console.log('B')

答案:A 解析:console.log是同步任务,会先执行打印出A,当执行到while时进入死循环,所以不再会往后执行。

console.log('A');
setTimeout(function(){
  console.log('B')
}, 0);
while(1){}

答案:A 解析:console.log是同步任务,会先执行打印出A,当执setTimeout时,为异步宏任务,会被放置入任务队列中,继续执行while,进入死循环,所以不会打印出B

第三题

for(var i=0; i<4; i++){
  setTimeout(function(){
    console.log(i)
  }, 0)
}

执行的顺序相当于

for(var i=0; i<4; i++){}
setTimeout(function(){
    console.log(i)
  }, 0)
setTimeout(function(){
    console.log(i)
  }, 0)
setTimeout(function(){
    console.log(i)
  }, 0)
setTimeout(function(){
    console.log(i)
  }, 0)

答案:4,4,4,4

解析:for循环是同步任务,会先执行,此时i的值为4。4ms后console.log(i) 被依次放入任务队列,此时如果执行栈中没有同步任务了,就从任务队列中依次取出任务,所以打印出 4 个 4。

那么如何才能按照期望打印出 0, 1,2,3 呢?有三个方法:

//方法1:把 var 换成 let
for(let i=0; i<4; i++){
  setTimeout(function(){
    console.log(i)
  }, 0)
}

//方法2:使用立即执行函数
for(let i=0; i<4; i++){
  (function(i){
    setTimeout(function(){
      console.log(i)
    }, 0)
  })(i)
}

//方法3:加闭包
for(let i=0; i<4; i++){
  var a = function(){
    var j = i;
    setTimeout(function(){
      console.log(j)
    }, 0)
  }
  a();
}

第四题

setTimeout(function(){
    console.log(1)
});
new Promise(function(resolve){
    console.log(2);
    for(var i = 0; i < 10000; i++){
        i == 9999 && resolve();
    }
}).then(function(){
    console.log(3)
});
console.log(4);

答案:2,4,3,1

解析:

  • setTimeout是异步,且是宏函数,放到宏函数队列中;
  • new Promise是同步任务,直接执行,打印2,并执行for循环;
  • promise.then是微任务,放到微任务队列中;
  • console.log(4)同步任务,直接执行,打印4;
  • 此时主线程任务执行完毕,检查微任务队列中,有promise.then,执行微任务,打印3;
  • 微任务执行完毕,第一次循环结束;从宏任务队列中取出第一个宏任务到主线程执行,打印1;

第五题

console.log(1);

setTimeout(function() {
  console.log(2);
}, 0);

Promise.resolve().then(function() {
  console.log(3);
}).then(function() {
  console.log('4.我是新增的微任务');
});

console.log(5);

答案:1,5,3,'4.我是新增的微任务',2

分析:

  • console.log(1)是同步任务,直接执行,打印1;
  • setTimeout是异步,且是宏函数,放到宏函数队列中;
  • Promise.resolve().then是微任务,放到微任务队列中;
  • console.log(5)是同步任务,直接执行,打印5;
  • 此时主线程任务执行完毕,检查微任务队列中,有Promise.resolve().then,执行微任务,打印3;
  • 此时发现第二个.then任务,属于微任务,添加到微任务队列,并执行,打印4.我是新增的微任务;
  • 微任务执行过程中,发现新的微任务,会把这个新的微任务添加到队列中,微任务队列依次执行完毕后,才会执行下一个循环;
  • 微任务执行完毕,第一次循环结束;取出宏任务队列中的第一个宏任务setTimeout到主线程执行,打印2;

第六题

function add(x, y) {
  console.log(1)
  setTimeout(function() { // timer1
    console.log(2)
  }, 1000)
}
add();

setTimeout(function() { // timer2
  console.log(3)
})

new Promise(function(resolve) {
  console.log(4)
  setTimeout(function() { // timer3
    console.log(5)
  }, 100)
  for(var i = 0; i < 100; i++) {
    i == 99 && resolve()
  }
}).then(function() {
  setTimeout(function() { // timer4
    console.log(6) 
  }, 0)
  console.log(7)
})

console.log(8)

答案:1,4,8,7,3,6,5,2

分析:

  • add()是同步任务,直接执行,打印1;
  • add()里面的setTimeout是异步任务且宏函数,记做timer1放到宏函数队列;
  • add()下面的setTimeout是异步任务且宏函数,记做timer2放到宏函数队列;
  • new Promise是同步任务,直接执行,打印4;
  • Promise里面的setTimeout是异步任务且宏函数,记做timer3放到宏函数队列;
  • Promise里面的for循环,同步任务,执行代码;
  • Promise.then是微任务,放到微任务队列;
  • console.log(8)是同步任务,直接执行,打印8;
  • 此时主线程任务执行完毕,检查微任务队列中,有Promise.then,执行微任务,发现有setTimeout是异步任务且宏函数,记做timer4放到宏函数队列;
  • 微任务队列中的console.log(7)是同步任务,直接执行,打印7;
  • 微任务执行完毕,第一次循环结束;
  • 检查宏任务Event Table,里面有timer1、timer2、timer3、timer4,四个定时器宏任务,按照定时器延迟时间得到可以执行的顺序,即Event Queue:timer2、timer4、timer3、timer1,取出排在第一个的timer2;
  • 取出timer2执行,console.log(3)同步任务,直接执行,打印3;
  • 没有微任务,第二次Event Loop结束;
  • 取出timer4执行,console.log(6)同步任务,直接执行,打印6;
  • 没有微任务,第三次Event Loop结束;
  • 取出timer3执行,console.log(5)同步任务,直接执行,打印5;
  • 没有微任务,第四次Event Loop结束;
  • 取出timer1执行,console.log(2)同步任务,直接执行,打印2;
  • 没有微任务,也没有宏任务,第五次Event Loop结束;

第七题

setTimeout(function() { // timer1
  console.log(1);
  setTimeout(function() {  // timer3
    console.log(2);
  })
}, 0);
setTimeout(function() {  // timer2
  console.log(3);
}, 0);

答案:1,3,2

分析:

  • 第一个setTimeout是异步任务且宏函数,记做timer1放到宏函数队列;
  • 第三个setTimeout是异步任务且宏函数,记做timer2放到宏函数队列;
  • 没有微任务,第一次Event Loop结束;
  • 取出timer1,console.log(1)同步任务,直接执行,打印1;
  • timer1里面的setTimeout是异步任务且宏函数,记做timer3放到宏函数队列;
  • 没有微任务,第二次Event Loop结束;
  • 取出timer2,console.log(3)同步任务,直接执行,打印3;
  • 没有微任务,第三次Event Loop结束;
  • 取出timer3,console.log(2)同步任务,直接执行,打印2;
  • 没有微任务,也没有宏任务,第四次Event Loop结束;

Node 中的事件循环(Event Loop)

Node.js是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎,V8引擎执行Javascript的速度非常快,性能非常好。将libuv作为跨平台抽象层,libuv是用c/c++写成的高性能事件驱动的程序库。nodejs的原理类似c/c++系统编程中的epoll

Node.js的运行机制

  • V8引擎解析JavaScript脚本
  • 解析后的代码,调用Node API
  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎
  • V8引擎再将结果返回给用户

Node中的Event Loop也跟浏览器端的Event Loop之间有很大的差异化。

node.js的主进程和Event Loop是分开的,主进程执行完同步代码就不执行了,剩下的就交给事件循环去做了。

Node中的事件循环

node事件循环执行的顺序为

执行整体代码(同步宏任务)--> 执行微任务(process.nextTick()、promise.then)--> 轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)

特殊的process.nextTick

process.nextTick是Node.js中的一个非常特殊的微任务,它通常会单独开启一个任务队列,并且它的执行优先级要大于其它的微任务,如果process.nextTick跟Promise.then同时存在,会优先去执行前者。

Promise.resolve().then(function() {
   console.log('promise1')
 })
 
process.nextTick(() => {
    console.log('nextTick')
})

// nextTick promise1

需要注意的setTimeout 和 setImmediate

setTimeout跟setImmediate在没有I/O处理的情况下执行的结果是不确定的,setTimeout(fn, 0) 具有几毫秒的不确定性. 无法保证进入timers阶段, 定时器能够立即执行处理程序。

var fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
// immediate timeout

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

浏览器端的Event Loop跟Node环境中Event Loop之间的差异化在哪

我们可以通过一道Node端运行的练习题来解析一下差异化:

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
// start end promise3  timer1  timer2 promise1  promise2
  • 从上到下,会首先执行执行栈中的同步任务打印出start跟end,并且将setTimeou这个宏任务依次放入到任务队列中,然后再执行微任务打印出promise3,这一点跟浏览器是一样的。
  • 当执行到timers1阶段的时候,会先打印出timers1,然后将promise.then放入到微任务的队列中,同样执行到timers1阶段的时候,打印出timers2,这点跟浏览器端相差比较大,timers阶段有几个setTimeout/setInterval都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务

结语

文章也参考了其他大佬的一些见解,如果对本文有什么疑问,欢迎评论区留言,一起交流探讨。

如果大家对微前端感兴趣,欢迎大家阅读我之前写的这篇从0到1去搭建一个适合自己公司的微前端架构

images.jpeg