为什么要了解浏览器原理?
当面试官问你输入url到渲染发生了什么这种问题你不知所措?
页面中到底能承载多少个元素,取决于什么条件?如果一个页面在2s内打不开,你应该如何优化?
DOM是什么,javascript操作的是DOM还是html?
回流和重绘又是什么?浏览器架构是什么样的?
当你能够细化的了解整个浏览器工作原理的时候,你就能很好的处理这些问题
浏览器我们常用的有谷歌 IE Safari 火狐等等,目前开发者心中的浏览器只有一个,那就是谷歌浏览器,它的市场份额稳居第一,从未被超越
image.png
以工程师的维度,或者开发者的维度怎么看浏览器,它是一套标准,这套标准可以运行html、css、javascript代码,这些内容可以通过超文本传输协议进行传输,通过浏览器的标准进行展现,能够让世界上任何使用浏览器的人都能够看到网页内容
页面的标准有W3C,语言标准有ECMAScript,当然还有网络标准,等各种标准由浏览器统一管理。这些标准共同作用,即便是不同的浏览器,它们的标准也是一致的。当然并不是完全一致,一些浏览器还是有自己的想法的,就是这些想法给前端程序员带来了巨大的工作量,比如工程师们闻之色变的兼容IE,主要原因就是IE浏览器有自己的想法,搞了很多和别人不太一样的东西,导致同样的代码和逻辑,在IE浏览器上展示就有问题
image.png
在深入研究浏览器架构之前要掌握的另一个概念是进程和线程。通常一个好的应用程序会把自己划分为几个独立的模块和几个相互配合的模块,浏览器本身也是这样,谷歌浏览器为例,它由多个线程和多个进程组成,进程之间相互协作完成浏览器整体的大功能,进程又包含很多线程,一个进程内的线程也会相互配合完成这个进程的工作职责
对于前端同学来说,这些概念是模糊的,概念上讲什么进程是资源分配的最小单位,线程是CPU调度的最小单位,很多同学听了可能还是有些懵懂,我们用图来简单讲述一下
以工厂为例,我们可以把工厂理解成进程,工人理解成线程,工人只能在工厂工作,一个工厂可以有很多工人,同一个工厂内的工人很容易交流,不同的工厂内的工人不容易交流,只是会比较费劲,一个工厂不会影响另一个工厂,但是工厂内的工人会影响这个工厂的运行
当启动一个应用程序时,会创建一个进程或者多个进程,该程序可能会创建线程来帮助它工作。操作系统为每个进程提供了可用的内存,所有应用程序状态都保存在内存空间中。当您关闭应用程序时,程序创建的进程也会消失,占有的内存也会被释放
了解了进程和线程的关系之后,我们可以看一下启动chrome浏览器需要占用多少进程
谷歌浏览器自带了一个任务管理器,点击浏览器【更多工具】→【任务管理器】
image.png
可以看到对应的浏览器进程
image.png
这张图其实就表明浏览器其实是多进程的架构,当然这是不断演化的结果,并不是一蹴而就的,简单来看一下这些进程的作用
image.png
为什么每个标签或者每个插件都要一个进程呢?其实思考一下不难理解,如果我们打开的某一个网页无响应了,或者崩溃了,这个时候每个进程是隔离的,并不会影响我们其它的页面,这种设计其一是保证了标签页的稳定性。同样的如果某个页面死循环不流畅,其它页面也是感知不到的,内存泄漏也一样,当前页面由于内存泄漏关闭这个标签页之后,对应的内存资源就会被回收,变相解决了内存泄漏导致整个浏览器崩溃的问题
多进程还有一个好处就是安全性,因为操作系统有一种限制进程权限的方法,所以浏览器可以通过某些功能对某些进程采取沙箱处理,比如浏览器限制了渲染进程的任意文件的访问。沙箱处理隔离了渲染进程,这样即使在渲染进程里面执行恶意程序,恶意程序也无法突破沙箱获取系统的权限
当然并不是多进程都是好的,也有其不足之处,比如我打开了多个百度页面的选项卡,这个时候浏览器分配了多个进程,导致更多的资源占用,虽然这两个内存中的东西完全一致
image.png
当然谷歌浏览器针对这一点也做了对应的优化,它的内部限制了可以启动的进程数量,这个限制取决于你的设备的内存和cpu的功率,并不是固定死的,设备越好可启动的进程数量就越多。当达到它所限制的数量时,它会优化打开的标签页,比如相同站点的标签页合并为同一个进程
当然多个标签跟开启多个浏览器类似,谷歌浏览器也在不断优化,将浏览器中的各个部分作为一项服务,从多进程模型到多服务模型,可以轻松的进行进程拆分或者合并。也就是说当你的硬件性能足够,它可以将每个服务拆分到不同的进程,当你的硬件资源有限,它会将这些服务合并到一个进程
前面说我们每个标签页一个进程,但是这个标签页当中有可能通过iframe嵌入了另一个页面,这个时候如果共用一个进程的话可能就会带来风险。所以谷歌浏览器为跨站点的iframe页面也开启了一个单独的渲染进程。要考虑浏览器同源策略的影响,一个站点无法在未经允许的情况下访问其它站点的数据,进程隔离是分割站点的最有效的方式
站点隔离并不是我们想象的这么简单,它改变了iframe和页面的交互方式,即便是多个渲染进程,当你打开devtools的时候,它们看起来还是那么完美,并且当你用ctrl+f对页面进行搜索的时候,多个渲染进程之间的搜索工程师们也优化的你看不出丝毫破绽
了解了浏览器的架构,来讲一个面试官最喜欢问的面试题,当浏览器输入url之后发生了什么?
我们使用浏览器的主要目的就是为了搜索或者访问某些网站,就让我们从浏览器的角度,来看看我们是如何进行搜索或者网站的访问的
image.png
从浏览器的架构中我们可以得知,我们输入url或者搜索的这一栏是由浏览器进程控制的,其中浏览器进程下面有一些线程,比如控制搜索栏交互和展示的UI线程,当你输入网址或者文字之后,UI线程便开始了工作
当你在地址栏输入了内容之后,UI线程要做的操作就是需要进行辨别,判断你输入的是url还是搜索的字段,如果是url,则相应的转到对应的站点。如果是搜索的字段,则通过浏览器中设置的使用那种搜索引擎,进行对应的站点跳转
image.png
不论是搜索还是站点访问,最终都会走站点访问的逻辑,当你在地址栏输入【你好】之后,回车,它也会变成相应的站点url
image.png
要判断是否是URL就要知道什么是URL(「U」niform 「R」esource 「L」ocator)翻译过来为统一资源定位符,俗称网址
它的标准格式为:
❝
[协议类型]://[服务器地址]:[端口号]/[资源层级UNIX文件路径][文件名]?[查询]#[片段ID]
❞
一个简单的URL组成如下所示
一个完整的URL如下所示
image.png
只要输入的内容满足这个规则,就认为是一个有效的URL,直接跳转到对应的网站,否则就执行搜索逻辑(本质还是跳转到对应的URL)
拿到URL之后,是不是立刻就会发送请求?其实不是,浏览器还会进行额外的一些检查。
比如安全性检查,检查要访问的内容在本地是不是有缓存,缓存是否过期?一个较为完整的前置流程判断大致如下
这张图其实画的有点多,但是主要是想让大家了解在获取资源之前,其实是有一个缓存的判断的,否则就不会发送对应的请求
image.png
在控制台能够看到一些资源虽然返回了200的状态码,但是实际是来自缓存,并没有从服务器获取数据,抓包的话也是没有对应的请求的
上面其实讲的是强缓存,强缓存是有对应的过期时间的,时间是响应标头expires控制,当然图中还有标注cache-control这个字段,这两个字段有啥区别呢?
expires是http1.0的产物,cache-control是1.1的产物,两个同时存在,cache-control的优先级更高
image.png
图中还有一个字段,是last-modified,这个主要用于协商缓存,如果强缓存生效,则直接走强缓存,不生效则走协商缓存,协商缓存会在请求头中添加if-Modified-Since的字段,这个字段的值就是第一次请求文件的时候返回头中的last-modified,服务收到请求后,对比服务中文件修改的时间,如果没变化,状态码返回304,浏览器直接从缓存中获取文件,如果有更新,则服务端会返回200状态,并返回新的文件,最后浏览器再将新的响应报文和对应的缓存标识缓存起来
协商缓存还有一个字段是etag,图中也有,它是根据文件内容是否有修改来决定是否使用缓存数据
上述的缓存有些内容可能还未涉及,但是大家先了解一个大概,像请求报文之类的我们后续会详细说明,因为这里涉及到缓存,大家有个概念即可,我们看一下正常的请求流程是什么样的,不走缓存的这种
如果缓存都未命中,我们就需要浏览器去发送请求,请求网址对应的资源,但是我们都知道,服务器的地址都是一段ip地址,但是我们明明输入的是一个URL,URL怎么能够知道我们访问的是哪一个服务器呢?
这就引出了DNS的概念,DNS其实就是用于实现域名和IP相互映射的一个分布式数据库,它可以将域名翻译成计算机可识别的IP地址
借用网上的一张图,来看看DNS的查询流程是怎样的
image.png
image.png
DNS是极其重要的一环,这个环节出了问题就无法进行后续的操作,它是客户端访问互联网的关键所在
通过DNS解析我们已经获取到了文件所在的服务器的IP,有了这个IP之后,我们就需要发送请求获取对应的文件了,但是在获取文件的第一步,首先要做的就是建立TCP连接
一个页面的性能的影响因素首要的就是首屏渲染时间,而首屏渲染时间其中的一个影响因素就是网络加载速度,决定性的内容就是文件的返回时间
如果你对网络有充分的了解,那肯定知道网络的基础是网络协议,网站则是基于http协议,http又是基于TCP/IP的。计算机底层是101010这种二进制数据,文件传输也是二进制数据,那这些数据是如何到我们的浏览器的?第一步就是要建立服务器与客户端之间的连接
image.png
上面我们已经获取到了服务端的IP地址,每个计算机都有一个独立的IP地址,客户端和服务端有了各自的地址之后就能够精准传输了,整个过程如下图所示
建立连接的过程就是三次握手的过程,表示整个过程要发送三次包
image.png
连接建立成功,就可以传输数据了
数据传输完成,有一个断开的过程
断开连接被称为四次挥手过程,表示整个断开过程要发送四次包,需要双向连接和双向关闭,不管是客户端还是服务端,任何一方都可以发起断开的过程
image.png
上一步已经建立好了连接,那就开始传输数据了。在该阶段,客户端需要对数据包进行确认操作,在接收到数据包之后,需要发送确认数据包给发送端。所以发送了一个数据包之后,在规定时间内没有接收到客户端端反馈的确认消息,则判断为数据包丢失,并触发发送端的重发机制。同样,一个大的文件在传输过程中会被拆分成很多小的数据包,这些数据包到达接收端后,接收端会按照TCP头中的序号为其排序,从而保证组成完整的数据。
通过TCP以及UDP共同作用,这个时候浏览器的网络线程是能够收到服务器的完整数据,在获取数据的时候,我们会添加一系列的请求头,比如我们必须指定请求方法到底是GET还是POST,或者是其它,之前我们也提到了If-Modified-Since,用于缓存,还有Cookie、User-Agent、Referer等头信息
用这些请求头数据去告诉服务器我们当前需要什么内容,以及告诉服务端客户端的一些信息
image.png
当服务端同意或者拒绝给客户端返回内容之后,客户端都会收到一些反馈,反馈的内容除了正常我们想要的数据以外,还有response头信息。可以看到对应的状态代码为200,表示成功,说明获取到了服务端返回的对应信息。
这个域名对应的IP+端口就是图中的远程地址,除了状态为200还有其它的一些状态,比如301告知服务器正在重定向,然后网络线程发起另一个URL请求,针对http状态码各个阶段的含义大家可以自行了解一下
上面的图中可以看到响应头里面包含了一些信息或者执行了一些操作,比如Set-Cookie响应头可以往浏览器里面设置一些cookie,Conetnt-Type、Cache-Control等相关
网络线程在查看了头部的这些字节之后,因为传输过程有可能会出现异常,比如丢失或者错误,所以在这里会完成MIME类型嗅探(也就是检查一个字节流的内容,试图推断其中文件的数据格式)
image.png
上面我们在访问百度的时候,Conetnt-Type为html类型的文件,浏览器检测到如果文件类型是html的话,之后就会把数据交给渲染进程。当然这里不仅仅只有html文件类型,如果是其它文件类型,比如zip或者其它的内容,则浏览器会交给下载管理器进行对应资源的下载
这个时候通常也会进行浏览器的安全检查,两方面检查
image.png
所以我们要明确的一点是,跨域是浏览器的安全策略,是浏览器拦截的,如果你用抓包工具的话,会发现数据其实已经给到我们了,当然post请求还会存在一个预检的过程,防止抓到数据,在发正式请求之前,预检服务端是否做了跨域的处理
当前已经准备好了对应的数据,也就是html文件。并且完成了前置的所有信息检查,那么网络线程就会告诉UI线程数据已经准备就绪,UI线程要做的就是找一个渲染进程用于html的渲染
但是这个过程是有优化的空间的,因为网络线程请求数据的过程是需要时间的,所以在网络线程发送URL的请求的时候,它已经知道当前是要访问哪个站点,UI线程将会并行查找并启动渲染进程,这个时候请求到数据的时候,渲染进程已经是待命的状态,可用于直接渲染
这个时候需要浏览器进程跟渲染进程通过IPC进行通信,通信过程还需要传递数据流,方便渲染进程可以持续接收html数据,一旦渲染进程渲染完毕,便会通知浏览器进程当前完毕,导航阶段就完成了,就开始了加载文档的过程
这个时候,地址栏更新。安全指示器和站点设置的UI反应站点的信息,选项卡的历史记录会被更新,前进后退等历史记录逐步被更新,历史记录同样也会在磁盘上存储一份,方便进行整个历史浏览的检索
image.png
我们知道,当页面进行加载的时候,浏览器UI上tab标签页上会有一个加载中的loading标志,一旦渲染进程完成渲染,渲染进程会将回调通过IPC发送到浏览器进程(onload事件完成的时候,包含所有子页面(frame)),浏览器UI上loading标志消失,显示完成状态,但是这个结束并不代表页面渲染就完成了,有可能还有JavaScript在加载额外的资源或者新的视图
这个时候渲染进程便开始渲染,具体是如何渲染的我们之后详细讲述,我们再看一下在这基础如何访问另一个页面
在当前标签页,我们进行另一个页面访问的时候,浏览器进程会重复上面的过程。但是开始的时候,浏览器会确认当前的站点是否关心beforeunload这个事件,如果对这个事件做了监听,当访问另一个网站或者刷新的时候,就会弹出下选项进行确认
window.addEventListener('beforeunload', (event) => {
// 显示确认对话框
event.preventDefault();
// 为了兼容处理,Chrome需要设置returnValue
event.returnValue = '';
});
当然,这只是众多生命周期中的一个节点,还有比如unload,pagehide,pageshow等等,感兴趣的可以参照https://developers.google.com/web/updates/2018/07/page-lifecycle-api
这里我们再插入一个知识点,service worker,它的作用是什么呢?其实就是服务器和浏览器之间的一个中间人。目的是为了拦截网站的所有请求,可以进行相应的判断,如果一些接口可以直接使用缓存就直接返回缓存
service worker独立于当前网页的线程,所以执行大量的操作也不会阻塞主线程
上面的过程把html文件已经交给了渲染进程,渲染进程负责页签的显示,在一个渲染进程中,主线程负责解析,编译代码,运行等工作,它的核心就是将HTML、CSS和JavaScript转换成用户可以与之交互的网页
当然渲染进程是一个多线程架构,它主要有以下线程:GUI线程、JavaScript引擎线程、定时器触发线程、事件触发线程、http请求线程、合成线程和IO线程
拿到数据之后,GUI渲染线程就开始解析HTML并将其转换成DOM(文档对象模型),DOM是浏览器对页面的内部表示,javascript获取和操作的页面元素本质是浏览器提供的DOM数据,同时当页面发生重绘和回流的时候,该线程也会执行
在解析过程中,即便是你的html语法有一些异常,比如没有关闭标签,匹配错误等,浏览器也不会抛出异常,比如如下代码,在浏览器上会自动解析成功
<body>
<div>
</p>
</body>
image.png
这里还需要注意一点的是,GUI渲染线程和JavaScript引擎线程是互斥的,当JavaScript引擎线程执行的时候,GUI线程是被挂起的,相当于是冻结状态,GUI的更新会被保存在一个队列中等JavaScript引擎空闲的时候立刻执行。之所以这样是因为JS代码可能会改变DOM结构,所以JavaScript引擎执行时间过长是会阻塞页面的渲染的,了解这一点也就知道为什么fiber架构为什么能够让大型应用看起来不卡顿
在解析html的过程中,其实还有一些其它的资源,比如img或者link,这个时候就会给浏览器进程的网络线程发送信息,GUI线程会根据这些额外的资源是否会阻塞转换过程而决定是不是需要资源加载完毕。比如碰到script标签有可能就会阻塞,但是也有例外,script标签添加了async或者defer属性
负责解析JavaScript脚本,运行代码
比如我们的点击事件,滚动事件,异步请求,或者执行setTimeout等这些事件时,会将对应的任务添加到事件触发线程,当这个事件被触发的时候,则把触发的事件回调添加到待处理队列的队尾。由于javascript是单线程的,所以处理这些事件都必须排队
setInterval与setTimeout所在线程,计数通过上面的内容,可以得到不可能通过javascript线程计数,否则会阻塞,因此会有一个单独的线程进行计数的处理,等待时间达到后,将回调函数添加到事件队列
在XMLHttpRequest连接后是通过浏览器新开一个线程请求,监测到获取了对应的内容后,将回调函数添加到事件队列,再由javascript引擎执行
合成线程后面会讲,IO线程主要用于和其它的线程通信
当前DOM已经有了,但是精美的页面光有DOM是不够的,只有DOM是不会出现我们的五彩斑斓的页面,需要CSS让页面变得更美观,GUI线程会解析CSS并决定每个DOM元素的样式
image.png
如果你没有设置对应的样式,浏览器也有自己的内置的一些标签样式,比如h1-h6
有了样式,渲染进程已经知道了每个节点呈现的效果,但是节点的位置信息怎么来,这个时候需要布局树,渲染进程会遍历DOM结构(包含样式),布局树只包含在页面中显示的元素,当一个元素被设置为display: none的时候布局树中是没有这个元素的。同理如果div::before { content: 'Hi!' },则布局树中是存在这个Hi的,DOM树javascript能够获取,但是布局树获取不到
布局树的描述非常具有挑战性,因为你需要对整个页面进行精确的描绘。布局中存在浮动、定位、固定、文字换行,自动伸缩,各种元素的结合,可以想象这个任务多么繁重
我们有了布局树的信息之后是不是就能绘制了,其实并不是,虽然你知道了每个元素的位置,但是它们绘制的顺序是怎样的其实还是不清楚,到底哪个元素先,哪个元素后,了解PS的同学,肯定知道图层的概念,哪个元素应该在哪个元素的顶部?CSS有控制元素层级的一个属性,叫做z-index,用过的同学应该都了解
image.png
这个阶段会通过布局树形成绘制记录,绘制记录本质就是绘制的一系列步骤,比如我要先干什么,再干什么(先绘制背景,再绘制元素内容,再绘制形状等等)
渲染的过程开销是很大的,任何一个小的变化都会引起一系列的变化,当布局树发生变化的时候,绘制需要重新构建页面变化,页面有动画的效果的时候,每一帧都需要更新动画内容,如果无法保证帧动画,给用户感官上就会出现卡顿
image.png
image.png
image.png
javascript也会阻塞页面的渲染,导致卡顿的发生,可以将 Javascript 操作优化成小块,然后使用requestAnimationFrame()
目前我们已经有了所有的信息,文档结构-元素样式-元素几何-布局树-绘制记录,最终将绘制记录转换到屏幕上的像素称之为光栅化
之前的方式是可视区域进行光栅化,滚动的时候再次进行光栅化,如下所示
但是现在浏览器有着更好的处理方式,这个方式被叫做合成
合成会将一个页面拆成很多层,每个层在不同的的合成线程中进行光栅化,然后组合成一个新的页面。滚动过程中如果这个层已经光栅化,则使用已经光栅化的层进行合成
那这个时候问题就来了,一个层中要包含哪些元素呢?主线程需要遍历布局树,作为开发者,想要创建一个新的层,可以使用css 属性will-change让浏览器创建层
光栅化各个层之后,将其存储在GPU的缓存中,合成线程也能够决定相应的优先级,保证用户看到的部分最先被光栅化,每当有交互发生变化,合成线程就会创建更多的合成帧然后通过 GPU 将新的部分渲染出来
事件是什么?比如按钮的点击,input输入框的内容输入,鼠标滚轮和拖拽,都是事件
交互的时候浏览器进程最先接收到事件,浏览器关心的只有当前事件发生在哪个页签,然后将事件位置信息和事件类型发送到当前页签的渲染进程,渲染进程会找到事件发生的元素和对应的事件
image.png
但是前面也说到,页面是被光栅化的,在合成线程处理页面的时候,合成线程会标记有事件监听的区域,有这些信息,合成线程就会将触发的事件发送给主线程处理
浏览器的多进程架构,根据不同的功能划分了不同的进程,进程内不同的使命划分了不同的线程,当用户开始浏览网页时候,浏览器进程进行处理输入、开始导航请求数据、请求响应数据,查找新建渲染进程,提交导航,之后渲染又进行了解析HTML构建DOM、构建过程加载子资源、下载并执行JS代码、样式计算、布局、绘制、合成,一步一步的构建出一个可交互的WEB页面,浏览器进程又接受页面的交互事件信息,并将其交给渲染进程,渲染进程内主进程进行命中测试,查找目标元素并执行绑定的事件,完成页面的交互。
刚踏上开发之路时,我几乎只关注怎样去写代码、怎样提升自己的生产效率。诚然,这些事情很重要,但与此同时我们也应当思考浏览器会怎么去处理我们书写的代码。现代浏览器一直致力探索如何提供更好的用户体验。书写对浏览器友好的代码,反过来也能提供友好的用户体验。希望能够通过这节课让大家了解浏览器的运行机制和原理,构建出对浏览器更为友好的代码。同时也能够不断优化我们的业务,让用户体验更上一层楼,这就是本节课全部内容
最后,感谢大家阅读,码字不易,一键三连