javascript单线程与浏览器的一些事

本次研究源于一次window.open引发的浏览器崩溃血案,虽替换方案解决了问题,但有必要深入摸索学习浏览器更深层的机制,加深自我对写js代码的辅助灵活运用。

1.进程和线程

进程和线程是操作系统里的概念。
进程是CPU进行资源分配的基本单位。线程是CPU调度的最小单位。
概念类比比较形象生动的解释,请移步这里(阮一峰大神的文章)

2.浏览器

浏览器架构图:
15994566582384

3.浏览器使用进程、线程

从浏览器的架构图,研究浏览器是如何使用进程和线程完成任务。
现在主流浏览器都使用多进程方式,结合浏览器架构图,现以chrome为例,对浏览器的进程工作进行比对:
给出下图chrome的进程架构图
15997197913507

由上图的chrome架构图可以看出,chrome的进程主要包含4种:
1:浏览器进程:也就是通用浏览器架构中的用户界面。
2:渲染进程:选项卡内,网站的渲染。渲染进程有多个,但并不是一个渲染进程对应一个选项卡,可以处理多个选项卡。此进程开启的多个线程包括GUI渲染线程,JS引擎线程,事件触发线程,定时器触发线程,异步HTTP请求线程
3:GPU进程:负责GPU任务,如:3D绘制和硬件加速,这个进程独立,方便他处理来自不同选项卡的渲染请求,并把它在展示到同一界面。
4:插件进程:也是多个,网站的插件,如flash,java等。
5:扩展进程:chrome的各个扩展也有独立的进程。
6:工具进程
进程种类5和6的理解,可以直接打开chrome的任务管理器(更多工具->任务管理器),截图如下:
15998031983027

15998034487863

15998034918383

4.单次地址栏输入后回车,浏览器做了什么

第一步:用户在地址栏输入后,地址栏--浏览器--UI线程解析判断是需要搜索还是直接跳转。
第二步:按回车后--浏览器--UI线程让网络线程发起网络请求,获取内容。
第三步:浏览器--读取响应,响应主体是html,就把响应数据交给渲染进程,如果是文件,就交给下载管理器处理。
第四步:浏览器--ui线程在收到网络线程数据准备妥当的确认后,安排寻找一个渲染进程来渲染界面。
第五步:浏览器--渲染进程提交本次导航,浏览器进程一旦收到提交,本次导航过程就结束啦。导航栏会更新
第六步:渲染进程开始解析并加载资源和渲染页面,一旦完成,渲染进程就会告知浏览器进程页面的onload事件已触发。

5.浏览器的多线程

浏览器的多进程中,与js关系比较大的就是渲染进程,如上面第3小标题讲述的,渲染进程包含多个线程。下面分别介绍一下常驻线程的功能。
1)GUI渲染线程;负责渲染HTML元素,重绘以及用户或者js程序操作DOM改变的渲染。值得注意的是,它和js引擎线程是互斥的,至于原由,讲解js单线程时解释。
2)js引擎线程;负责javascript脚本程序,包括解析脚本,运行代码。
3)定时触发器线程;setIntervalsetTimeout定时器,在js单线程的初衷设计下,在js引擎线程中,如出现阻塞线程状态会影响计时的准确性,所以单独开一个线程是合理的设计。
4)事件触发线程;也是单独一个线程,用来控制事件的轮询,js代码块执行如有鼠标点击类、异步请求类代码块等任务,会将对应任务扔到此线程下,顺序是,对应事件被触发,此线程就会把被触发的事件加到待处理队列队尾,等待js引擎处理,这些事件通过排队等待方式,在js引擎闲暇时间去执行。
5)异步http请求线程;这是http请求连接后,浏览器单独给开的一个线程,此线程通过检测请求的状态变更,来产生变更事件放到js引擎的处理队列中去处理。

6.js单线程

js作为浏览器脚本语言,主要作用是用于处理页面中用户的交互,并操作DOM树,以达到给用户呈现丰富的交互体验以及服务器逻辑的交互。
如果用多线程的方式处理,可能出现UI处理冲突。
最简单的增删改查中,如果用多线程去处理,一个线程处理增,一个线程处理删,最后呈现结果需要浏览器去判定如何生效最后的结果,大大增加了浏览器的复杂性,所以js设计之初即采用单线程。同样GUI渲染线程和JS引擎线程不同时进行也是考虑上面的处理冲突问题。

7.异步处理

当进行复杂页面逻辑,调用栈有函数执行时,浏览器被阻塞,不能渲染页面,不能执行其他的代码,那么就会造成页面卡住,并且长时间得不到响应,就会被大多数浏览器抛异常,弹出异常提醒。异步处理能很好的解决这个问题。
接下来就讨论一下,js中比较多被用到的异步函数。主要包括async/await, promise, setTimeout, nextTick
在此之前,看一下这些函数分别属于宏任务还是微任务。
宏任务:setTimeout, setInterval,
微任务: async/await, promise, nextTick
在事件触发线程中的事件队列被处理时,每次循环,对不同类别的任务执行顺序还不太一样,比如上述的宏任务和微任务,执行顺序是:
①一个宏任务内执行,过程中遇到微任务,添加到微任务队列;②等当前宏任务执行完,马上执行全部微任务;③当前宏任务执行完毕,检查渲染,GUI线程接管渲染;④渲染完毕,js线程继续,开始下一个宏任务。
所以总体来说,执行顺序是先宏任务,再微任务。但是嵌套时,因为所有微任务都被执行了,所以就会感觉是微任务先执行了。
验证代码走起~

<script>
    console.log('script start')
     new Promise((resolve) => {
       resolve()
     }).then(() => {
       console.log(1)
     })
     setTimeout(() => {
       console.log(2)
     }, 0)
     console.log(3)
</script>

上述代码可以直接在浏览器cosole里打印,也可以找个熟悉的框架环境去运行。按照上面一段理论的初步理解,大概会推算为script start321。那说明理解的还不够深入。
进一步分析一下:
任何一个事件线程需要事件触发,所以最开始就有一个初始事件来开始宏任务,即script标签,算是js引擎发起start事件,那么遇到的Promise微任务是算在script宏任务的事件循环中,setTimeout就会变成下一下宏任务。所以上面所说的宏任务和微任务的执行顺序就符合理论,并且能合理的印证实践结果。所以结果打印顺序就是script start312

上述代码是简单的,再来一个需要讨论的代码:

<script>
     setTimeout(() => {
       console.log(2)
       this.$nextTick(() => {
         console.log(11)
       })
       new Promise((resolve) => {
         resolve()
       }).then(() => {
         console.log(1)
       })
     }, 0)
     new Promise((resolve) => {
       resolve()
     }).then(() => {
       console.log(4)
     })
     this.$nextTick(() => {
       console.log(41)
     })
     setTimeout(() => {
       console.log(5)
     }, 0)
     console.log(3)
</script>

这段代码是跑在vue环境,vue版本是2.6.12。先给出结果,再跑非常多的次数情况下,出现过一次打印顺序与其他大多数情况下不一样的结果。
下图是某次非常态结果:
16002450394784

再来一张常态跑出来的结果:
16002451130604
其中1、11的事件循环要在4、41后面一个。他们分别在2个宏任务队列里,会按照一个宏任务完成再去执行下一个宏任务的规则,这个没有疑虑。有疑虑的事,2次微任务,采取在2次宏任务的队列写得时候顺序调换一下,去测试执行结果。然后本来是想验证vue.$nextTick不管写法顺序如何,都会优先Promise执行,但在多日跑测试的情况下,出现了一次非常态结果。
猜测主要原因是vue.$nextTick用了宏任务和微任务组合的方式,在每次调用的时候,可能走入某个不同逻辑了。它具体的优先级顺序是Promise > setImmediate > MessageChannel > setTimeout,这是vue2版本的,我看到最新的2.6.12源码也是这个逻辑的,源码一览传送门(点我),要是有克隆源码的,查看目录src/core/util/next-tick.js文件即可。vue3中就是单一的使用Promise任务,相当于对他的一个封装。
贴一下vue-next的nextTick源码

export function nextTick(fn?: () => void): Promise<void> {
  return fn ? p.then(fn) : p
}

从最后的表现上,代码写在同一层级下,微任务总是先比宏任务执行。当然直接这样说可能会造成误解,要在充分理解前面所说的原理基础上这样去记忆才好。再说一下async/await,可以理解为await之前的代码都是同步进行的,await之后的代码可以理解为都是在Promise.then中的回调。

8.window.open

前面讲到,chrome浏览器的每个选项卡,就是一个独立的浏览器进程,这是大多数时候的。但window.open方法,在chrome中,他是和前一页共用进程的。所以当window.open打开的页面放个定时器,并且隔一段时间循环此定时器,这个循环不仅会阻塞当前页面,还会阻塞父页面的js进程,小的问题只是页面卡顿,但只要当前页面开销放开到足够大,父页面直接无法响应任何操作了,无法交互。这个是chrome才有的问题,是为了2个页面共享DOM信息,在一个进程中好控制。问题存在了,就会有绕开他的解决方案的,不管是临时用用还是可持续的,我在使用window.open时,大多数情况下,并没有坑到我,但是一旦遇上,就非常尴尬。奉上一种解决方案:

function createSuperLabel(url, id) {      
  let a = document.createElement("a");           
  a.setAttribute("href", url);      
  a.setAttribute("target", "_blank");  
  // noreferrer是关键
  a.setAttribute("rel", "noreferrer");      
  a.setAttribute("id", id);       
  // 防止反复添加      
  if(!document.getElementById(id)) {                               
      document.body.appendChild(a);      
  }      
  a.click();    
}

动态产生A标签去模拟跳转。当然条条大路通罗马,还有很多其他的方案,google大法万能,必须人手一份,道路千千万。

总结

浏览器精深奥妙,这只是我的初学探讨,是给学习感悟做的一个印记,望大家都学有所成,有所悟。