阅读 1858

重学前端(三)-聊聊我们的浏览器的那些事

前言

最近公司比较忙,加上重磅好剧隐秘的角落来袭,重学前端系落下了,最近闲来无事,续上!作为一名前端工程师,除了编辑器,浏览器当然使我们打交道最多东西,虽然我们每天都在用,但是对他却不慎了解,不信?接下来给你一些灵魂拷问!

  • 1、为啥写代码的时候要给css放前面,js 放后面

  • 2、浏览器到底是单线程还是多线程

  • 3、为什么会有冒泡和捕获的过程

  • 4、浏览器到底是怎么实现异步的

  • 5、为什么会有微任务和宏任务

  • 6、为什么说缓存是最重要的性能优化手段

  • 7、为什么说闭包会造成内存泄露,浏览器js引擎垃圾回收机制,为啥不回收他

  • 8、浏览器到底怎么工作的

    暂时先提这几个问题,不知道你接不接得住呢?其实,好多我也云里雾里,接下来我们慢慢分析,共同学习,一步步分析

浏览器是如何工作的

我们要解决这些问题,首要任务是要知道浏览器到底是怎么工作的?

要想看到页面,离不开网络,有了网络以后,当然得定个协议,那就是大名鼎鼎的http、或者https协议,那他到底是怎么处理的呢,

首先,在网络畅通的状态下,去输入一个网址,然后浏览器就会去解析我们当前的域名,然后将域名的不通部分传递给dns客户端,这时客户端就会向dns服务器发送查询报文,用来获取域名对应的服务器ip地址,接着,浏览器就会去发起http请求(具体nds解析过程不再赘述如有兴趣请移步:DNS解析的过程是什么)具体http协议相关在此也不再赘述如有需要请移步(HTTP灵魂之问,巩固你的 HTTP 知识体系

接下来,就是我们浏览器工作原理相关 我们先来弄明白几个名词

线程与进程

什么是进程和线程,其实用大佬的话概括起来就是(线程和进程的区别是什么):进程和线程都是一个时间段的描述,是CPU工作时间段的描述。

放在我们浏览器中我理解的是,首先浏览器有一个Browser进程, 主进程, 负责协调和主控, 只有唯一的一个,接下来还有一些插件进程、GPU进程等,我们开启一个页签去打开网页,这就是开了一个浏览器渲染进程,而多个不同的模块去处理这个网页,比如,js解析模块,http请求模块等,这就是多个线程,而且这多个线程还能协同工作,可以看到由于我开了很多个页签就会有很多进程,还有一个谷歌浏览器的主进程

那么浏览器有哪些重要的线程呢? 而在这其中js引擎线程,和渲染线程是互斥的,原因不是浏览器做不到多线程并发执行,而是由于如果在渲染dom的时候去用js 操作dom,到底应该听谁的呢?也正是由于互斥才导致如果我们的js执行时间过于长,浏览器就会一直无法渲染,产生卡顿的原因,这些问题,才成就了浏览器的运行机制,从而诞生了react fiber这样高明的让出机制,解决卡顿问题。

如此一来是不是对这些框架的原理有了更深层次的认识了呢!

这样是不是就可以解释第一个问题

我们之所以要给css放在前面,是由于浏览器内核(渲染进程)是可以让多个线程同时工作,于是,给css 放到前面,就能一边解析页面,一边请求css 同时解析css ,之所以js 放在后面,我们前面说了,js是单线程,如果放在前面,那么js 的解析会干扰页面的渲染,使得页面不能正常达到首次渲染的条件,影响用户体验,并且,由于js能够操作dom但是此时dom还未渲染,从而很可能出现诡异bug

什么叫状态机

理解完了进程与线程之后,我们就该开始了解浏览器的渲染过程了,那么浏览器到底是如何解析html代码,又是怎么构建dom的呢?先上一张图

html本质上就是一个含有标签的字符流,然后浏览器根据他的编码格式去编译成对应的字符串,比如使用utf-接下来,就是我们的主角状态机,在浏览器渲染引擎中,就是通过通过状态机将字符串解析成对应的词token(token是指编译原理里面的东西,指的是最小的有意义单元),在我们的html语法中我的理解就是标签的每一个部分就是一个token 比如说这样一段html

<div class="aa">好好学习,天天向上</div>
//他就会被拆分成
<div //标签开始
class="aa"//属性
>//开始标签的结束
好好学习,天天向上 // 文本内容
<div>// 结束标签
复制代码

而我们解析就需要用到状态机来实现,其实,我们每读入一个字符,浏览器就需要做一次决策,快速判断出来,这属于那种token,从而将字符拆分成独立状态的token,然后在将这些token串联起来,形成一个关联图(仅本人理解,不对之处,请大佬指正),我们拆分成一个个token之后,就可以构建dom树了,但是首先我们得了解一个名词--栈

栈:先进后出的数据结构,如下图所示,先进去的数据在底部,最后取出,后进去的数据在顶部,最先被取出。
复制代码

几乎所有的语言都有栈,这也是数据结构必不可少的一部分,我们的html词法分析器用的也是栈,那么他是怎么解析的呢?由于涉及到编译原理的知识,我也不是很了解,所以在此大致说一下我的理解不对之处请大佬指正, 遵循先进后出的原则,比如解析如下代码

<p><span>好好学习</span></p>
//首先
<p //入栈
<span//入栈,确定父子关系
好好学习//入栈
</span>// 转化为dom结构出栈
</p>// 已确定父子关系,出栈
复制代码

到此,通过栈的结构,dom树便可形成

到此为止浏览器渲染流程清晰可见,以下在总结一遍

  • 1、输入网址,发起http请求页面字符流
  • 2、解析html,形成dom树
  • 3、解析css成cssom(注意两者的解析互不影响,但渲染会有影响)
  • 4、遇见js停止渲染
  • 5、根据css属性进行逐个渲染,得到内存中的位图
  • 6、开始合成,调用gui线程绘制到界面(值得注意的是,浏览器不一定等页面所有的dom解析完,才会渲染,而是,达到渲染条件,就可开始第一次渲染,然而遍寻书籍文章没有详细的官方资料,如有兴趣请看有个大佬的实践对浏览器首次渲染时间点的探究

浏览器的eventloop到底是如何实现异步的

为什么浏览器需要实现异步

之所以浏览器是需要实现异步,是由于js是单线程的语言,且没有异步的特性。那么,在一些复杂的场景中,比如,定时任务,比如http等延时任务,必须通过异步来解决,于是,js 的宿主浏览器必须承担起这个责任,异步应运而生。

什么是异步

我们知道,js是单线程,在一段代码块中(就是一个script标签中)js一行一行执行,如果报错,代码块停止执行,但是不影响其他代码块,如果遇见异步任务,延时,变成回调函数,执行,到此,我们应该知道,所谓,异步在我们的浏览器中其实就是通过一个延时回调。而实现这个回调,就需要用到浏览器的eventloop来实现。

eventloop

eventloop是一个执行模型,也就是是一个概念,不同的地方有着不同的实现,比如node端和浏览器端的eventloop的实现方式不同,执行效果也不同比如:

同样一段代码在浏览器端的顺序如上

在node端打印内容如上,由于今天我们的主题是浏览器,那么我们就从浏览器的eventloop说起,如有对node感兴趣请移步浏览器与Node的事件循环(Event Loop)有何区别 开始之前,我么先来讲一些概念

栈(Stack)

在上面html解析中讲过。

栈在计算机科学中是限定仅在表尾进行插入或删除操作的线性表。 栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
栈是只能在某一端插入和删除的特殊线性表。
复制代码

队列(Queue)

特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。
进行插入操作的端称为队尾,进行删除操作的端称为队头。 队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)

复制代码

宏任务和微任务

所谓宏任务:setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)ajax 和dom事件

所谓微任务:Process.nextTick(Node独有)、Promise、async/await等

ok,接下来开始正式eventloop,先上一张图 这就是eventloop的流程图,那么我们应该怎么解析呢,我们拿浏览器点击事件举例。

  • 1、首先打开页面eventloop机制启动
  • 2、然后js解析引擎开始执行js代码
  • 3、遇见绑定点击事,将事件绑定
  • 4、点击事件,将事件回调推入宏任务队列,eventloop机制检测到有需要执行的任务,开始推入js 执行栈执行

到这里我们就能清晰的知道浏览器到底是怎么实现异步的了,而且顺带的不只是异步可以用eventloop实现的,甚至连事件也能用它实现,接下来,我们要来解答为啥要有微任务和宏任务的区分

要想知道为啥要有宏任务和微任务的区分,我们首先要去理解dom的渲染和eventloop的关系,首先我们知道js的执行和浏览器的dom渲染是互斥的,于是,在js 执行完了浏览器会先去执行在js执行中的微任务再尝试执行dom渲染,在渲染完成之后当次任务结束,开始下次任务,这时eventloop机制被触发,也就是eventloop的触发是在js同步代码执行之后也在dom渲染之后

接下来eventloop 中任务被一个个执行,然后遇见微任务放进微任务队列,遇见宏任务放进宏任务队列,再次开始下一次的eventloop,周而复始,循环往复,那么问题来了,为啥宏任务要在dom渲染之后,而微任务要在dom渲染之前呢?

遍寻资料,我的理解是,之所以有微任务和宏任务的区分,是由于像promise等微任务是es的规范,并不是浏览器的规范,而想settiemout是浏览器的api规范,此时,为什么就清晰明了了,由于微任务是es的规范,那么即使是异步,也是js解析处理,所以,不会被算在eventloop开始之后

浏览器事件

在理解浏览器的事件机制,我们首先要了解一些历史

事件历史

我们知道我们的输入设备,称作pointer设备,他是WIMP是图形界面电脑所采用的界面典范中重要的一部分

WIMP

WIMP最初有由施乐公司开发,后被window,和mac抄了去,在人机互动领域之中最普遍的电脑互动界面,WIMP堪称无人能出其右,举凡微软的Windows、苹果电脑的MacOS,甚至其它以X Window系统为基础的操作系统,均采用WIMP此一界面典范。WIMP是由“视窗”(Window)、“图标”(Icon)、“选单”(Menu)以及“指标”(Pointing device)所组成的缩写,其命名方式也指明了它所倚赖的四大互动元件。(摘抄百度百科,还有一本吴军博士的浪潮之巅著作,专门讲了苹果和微软怎么抄施乐实验室的成果,有兴趣可以看看)

WIMP的界面典范,使得操作系统沿用至今,以至于今天很多的前端工程师会有一个观点,认为我们能够“点击一个按钮”,实际上 并非如此,我们只能够点击鼠标上的按钮或者触摸屏,是操作系统和浏览器把这个信息对应到了一个逻辑上 的按钮,再使得它的视图对点击事件有反应。这就引出了我们第一个要讲解的机制:捕获与冒泡。

冒泡和捕获

我们都知道捕获过程是从外向内,冒泡过程是由内向外,然而,他为何会这样呢?

在上面的输入设备,我们拿鼠标点击事件为例,当鼠标点击时,其实,是操作系统将我们点击这个操作,对应成一个坐标,返回给浏览器,浏览器收到坐标就要将他对应到具体我点击在哪个元素上,这个过程我们发现其实就是由外向内的,这就是我理解的捕获过程(其实是大佬理解的,我看到的),而冒泡,就比较通俗易懂了,举个例子,我们在生活中用手去点桌子上的一个物品,是不是也相当于点了这个桌子呢!这是一个典型的人的逻辑思维被应用在计算机上,而我们在某个元素上绑定的事件其实就是我们捕获和冒泡的过程中,监听一类我们触发动作,去做一些事情而已,也就是说,只要你在输入设备中有相应动作,不管你是否监听,捕获冒泡都照常进行。

用大佬的话总结就是:捕获是计算机处理事件的逻辑,而冒泡是人类处理事件的逻辑。

事件

理解了冒泡和捕获,我们就知道了原来所谓的浏览器事件,就是在我们的输入设备在触发一些动作的时候,在冒泡过程中去做的监听,

具体有多少就不在列举 如有兴趣请移步 浏览器事件汇总

除了我们浏览器提供的事件,我们还可以自定义事件,只不过他无法通过,交互行为触发,我们必须在代码中手动触发!使用方式非常简单

//定义事件
var evt = new Event("look", {"bubbles":true, "cancelable":false}); 
//手动触发事件
document.dispatchEvent(evt);
复制代码

浏览器垃圾回收机制

首先我们要向了解浏览器的垃圾回收机制,就必须先要了解,一些名词

  • 引用计数:一个对象不被其他对象引用时会被回收
  • 内存泄露:不再用到的内存,没有及时释放,就叫做内存泄漏
  • 标记-清除:从根元素开始,周期性标记可被访问的对象,同时回收不可被访问的对象
  • 新生代-老生代:新生代的对象为存活时间较短的对象,老生代的对象为存活事件较长或常驻内存的对象。

垃圾回收原理

javascript垃圾回收机制原理:垃圾回收机制会定期(周期性)找出那些不再用到的内存(变量),然后释放其内存。在各大浏览器通常采用的垃圾回收机制有两种方法:标记清除,引用计数。而引用计数在碰到循环引用的时候就会造成内存永远无法释放问题,所以现在大多数浏览器都是标记清除的方法,只是每家的实现思路和策略略有不同具体详情请移步大佬聊聊V8引擎的垃圾回收

每个浏览器的实现思路不同,在此我们就以谷歌的v8引擎为例

V8的垃圾回收机制分为新生代和老生代。

新生代主要使用Scavenge进行管理,主要实现是Cheney算法,将内存平均分为两块,使用空间叫From,闲置空间叫To,新对象都先分配到From空间中,在空间快要占满时将存活对象复制到To空间中,然后清空From的内存空间,此时,调换From空间和To空间,继续进行内存分配,当满足那两个条件时对象会从新生代晋升到老生代。

老生代主要采用Mark-Sweep和Mark-Compact算法,一个是标记清除,一个是标记整理。两者不同的地方是,Mark-Sweep在垃圾回收后会产生碎片内存,而Mark-Compact在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另一侧内存,这样空闲的内存都是连续的,但是带来的问题就是速度会慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact两者共同进行管理的。(引用大佬原话)

内存泄露

内存泄漏是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
复制代码

用大白话说就是,内存泄露是指你用不到(访问不到)的变量,依然占居着内存空间,不能被再次利用起来。导致一些性能问题,在此我的理解是,出现了性能问题或者隐患,才能判定为内存泄露,而不是我有几个没有被释放的变量就是内存泄露了

那么,究竟哪些操作可能会造成内存泄露呢?

1、意外的全局变量

比如这样

function foo(arg) {
    bar = "aaaaa";
}
 
实际上等价于
function foo(arg) {
    window.bar = "aaaaa";
}
复制代码

2、暗中执行的定时器

说起定时器,造成的内存泄露,我当时在vue项目中遇见一个问题,在一页面中有一个定时器,当切换页面时,并没有销毁,而在切换过来时,又重新执行了一次定时器而之前的定时器,就会被留在内存中,导致页面越来越卡,出现内存泄露

还有一些dom相互引用啊,不规范的使用插件啊(比如之前我使用g2销毁组件没有注销,导致内存泄露)就不在赘述,ok目前我所知道的能引起内存泄露的都罗列在这里了(如有更多,请大佬告知),可以发现内存泄露不是浏览器本身的问题,而是程序写错了才会造成。

闭包到底会不会造成内存泄露

接下来重点来了,闭包究竟会不造成内存泄露,查询了很多资料,问了很多人,答案是不会,而之前一直广为流传的闭包会造成内存泄露导致页面卡死,其实是因为因为IE浏览器早期的垃圾回收机制,有 bug

  • IE浏览器中使用完闭包之后,依然回收不了闭包里面引用的变量。
  • 在IE浏览器中,由于BOM和DOM中的对象是使用C++以COM对象的方式实现的,而COM对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。

而我们很多人,会将内存使用和内存泄露搞混淆,认为内存中有几个没有被使用的变量就是内存泄露,其实,在现代浏览器中,如果你不是作死的循环生成很多闭包,一般情况下,是不会有内存泄露的,当然,你生成很多闭包,也就和内存泄露没有关系了,这是你程序写的有问题。

接下来就是一个比较有争议的问题了,查了红宝书,很多资料,都没有确切答案,如有大佬知道,希望告知: 附上正反方大佬们的回答

正方: 反方: 就是闭包中保存的变量,在使用完了之后,究竟会不会被释放?

为啥说缓存是性能优化的重要手段

说起浏览器缓存,我们首先想到性能问题,网上随处可见每天都有人讨论这性能问题,而且面试性能问题也是相当的热门,什么开启gzip,图片懒加载,dns预解析等等,给我的感觉对于缓存带来的用户体验来说,确实相形见绌,但是缓存也有个致命弱点,就是在第一次加载的时候无法缓存,所以这些手段在第一次加载,还是相当能打的。

还有一些代码上的性能优化,比如,按照按照有唯一key对象的方式存数据好还是按照数组的方式存数据好?在比如,是直接用数组的下标取值快,还是对象key的方式取值快,还比如,for-in非常耗性能、等等,在现在我看来,除了让你在写代码纠结来纠结去,或者公司代码走查的时候有可讨论的话题之外,并没有什么卵用。

而现在我总结的经验就是,除了那些比较消耗性能的问题在必要时,需要规避之外(例如深层递归),一切都是以写出更少的可维护的代码为主

浏览器换缓存

关于浏览器缓存附上大佬总结相当不错: 深入理解浏览器的缓存机制

总结起来,浏览器分为强缓存和协商缓存,强制缓存优先于协商缓存进行,若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,重新获取请求结果,再存入浏览器缓存中;生效则返回304,继续使用缓存。接下来一张图解释:

所以,由于浏览器的这种机制,导致我们可以快速的从内存或者硬盘中拿到资源,渲染页面,大大提升用户体验,但是在使用缓存时,需要注意的是:

  • 如果服务器文件更新,必须更改文件名字,不然可能造成缓存时间没有失效,不请求页面( 当然现在脚手架都已经给我们做了)
  • get接口有可能被缓存,所以如果全站设置cdn千万要注意,在返回的时候强制更改cdn默认的缓存策略
  • 在spa项目大行其道的今天,index页面也可能被缓存,所以保险起见可以给index页面加上mate头,当然一般情况下,并不会出现,服务器一般都会默认给这个文件强制不缓存,但是保不齐呢

最后

历时两周,断断续续终于写完了,自己也对浏览器有了更深的认识,也查了很多资料(如有需要红宝书,大犀牛等电子书,请私信),如有不对之处请大佬指点!后续重学前端系列,敬请期待

附: 在家办公之-重学前端(一)

重学前端(二)-你真的了解你JS的对象吗?