大家好,我是编程小6,很高兴遇见你,有问题可以及时留言哦。
作为一个合格的前端工程师,浏览器相关的工作原理是我们进行性能优化的基石,我之前也强调过知识体系的重要性,这部分原理性的内容就是知识体系中的重要部分,必须牢牢掌握才能面对瞬息万变的实际场景,针对性地给出实际方案,而不是背诵各种开发军规和性能优化的条例,这样很难发现真正的问题所在, 更无法真正地解决问题。
内容会涵盖浏览器工作原理
、浏览器安全
和性能监控和分析
。文章会分上下两次来发,今天这一篇是整个系列的上篇。
缓存是性能优化中非常重要的一环,浏览器的缓存机制对开发也是非常重要的知识点。接下来以三个部分来把浏览器的缓存机制说清楚:
浏览器中的缓存作用分为两种情况,一种是需要发送HTTP
请求,一种是不需要发送。
首先是检查强缓存,这个阶段不需要
发送HTTP请求。
如何来检查呢?通过相应的字段来进行,但是说起这个字段就有点门道了。
在HTTP/1.0
和HTTP/1.1
当中,这个字段是不一样的。在早期,也就是HTTP/1.0
时期,使用的是Expires,而HTTP/1.1
使用的是Cache-Control。让我们首先来看看Expires。
Expires
即过期时间,存在于服务端返回的响应头中,告诉浏览器在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求。比如下面这样:
Expires: Wed, 22 Nov 2019 08:41:00 GMT
表示资源在2019年11月22号8点41分
过期,过期了就得向服务端发请求。
这个方式看上去没什么问题,合情合理,但其实潜藏了一个坑,那就是服务器的时间和浏览器的时间可能并不一致,那服务器返回的这个过期时间可能就是不准确的。因此这种方式很快在后来的HTTP1.1版本中被抛弃了。
在HTTP1.1中,采用了一个非常关键的字段:Cache-Control
。这个字段也是存在于
它和Expires
本质的不同在于它并没有采用具体的过期时间点
这个方式,而是采用过期时长来控制缓存,对应的字段是max-age。比如这个例子:
Cache-Control:max-age=3600
代表这个响应返回后在 3600 秒,也就是一个小时之内可以直接使用缓存。
如果你觉得它只有max-age
一个属性的话,那就大错特错了。
它其实可以组合非常多的指令,完成更多场景的缓存判断, 将一些关键的属性列举如下: public: 客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器
最后才到达目标服务器,那么结果就是不仅仅浏览器可以缓存数据,中间的任何代理节点都可以进行缓存。
private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。
no-cache: 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段
。
no-store:非常粗暴,不进行任何形式的缓存。
s-maxage:这和max-age
长得比较像,但是区别在于s-maxage是针对代理服务器的缓存时间。
值得注意的是,当Expires和Cache-Control同时存在的时候,Cache-Control会优先考虑。
当然,还存在一种情况,当资源缓存时间超时了,也就是强缓存
失效了,接下来怎么办?没错,这样就进入到第二级屏障——协商缓存了。
强缓存失效之后,浏览器在请求头中携带相应的缓存tag
来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存。
具体来说,这样的缓存tag分为两种: Last-Modified 和 ETag。这两者各有优劣,并不存在谁对谁有绝对的优势
,跟上面强缓存的两个 tag 不一样。
即最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。
浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since
字段,这个字段的值也就是服务器传来的最后修改时间。
服务器拿到请求头中的If-Modified-Since
的字段后,其实会和这个服务器中该资源的最后修改时间
对比:
ETag
是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头
把这个值给浏览器。
浏览器接收到ETag
的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器。
服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对:
精准度
上,ETag
优于Last-Modified
。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。而 Last-Modified 就不一样了,它在一些特殊的情况并不能准确感知资源变化,主要有两种情况:Last-Modified
优于ETag
,也很简单理解,Last-Modified
仅仅只是记录一个时间点,而 Etag
需要根据文件的具体内容生成哈希值。另外,如果两种方式都支持的话,服务器会优先考虑ETag
。
前面我们已经提到,当强缓存
命中或者协商缓存中服务器返回304的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢?
浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:
Service Worker 借鉴了 Web Worker的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM
。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存
、消息推送
和网络代理
等功能。其中的离线缓存
就是 Service Worker Cache。
Service Worker 同时也是 PWA 的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA 的分享中详细介绍。
Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。
Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。稍微有些计算机基础的应该很好理解,就不展开了。
好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:
即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2
中的内容,虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用越来越广泛。关于 Push Cache,有非常多的内容可以挖掘,不过这已经不是本文的重点,大家可以参考这篇扩展文章。
对浏览器的缓存机制来做个简要的总结:
首先通过 Cache-Control
验证强缓存是否可用
If-Modified-Since
或者If-None-Match
字段检查资源是否更新
浏览器的本地存储主要分为Cookie
、WebStorage
和IndexedDB
, 其中WebStorage
又可以分为localStorage
和sessionStorage
。接下来我们就来一一分析这些本地存储方案。
Cookie
最开始被设计出来其实并不是来做本地存储的,而是为了弥补HTTP
在状态管理上的不足。
HTTP
协议是一个无状态协议,客户端向服务器发请求,服务器返回响应,故事就这样结束了,但是下次发请求如何让服务端知道客户端是谁呢?
这种背景下,就产生了 Cookie
.
Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储(在chrome开发者面板的Application
这一栏可以看到)。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。
Cookie 的作用很好理解,就是用来做状态存储的,但它也是有诸多致命的缺陷的:
容量缺陷。Cookie 的体积上限只有4KB
,只能用来存储少量的信息。
性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。
安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly
为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。
localStorage
有一点跟Cookie
一样,就是针对一个域名,即在同一个域名下,会存储相同的一段localStorage。
不过它相对Cookie
还是有相当多的区别的:
容量。localStorage 的容量上限为5M,相比于Cookie
的 4K 大大增加。当然这个 5M 是针对一个域名的,因此对于一个域名是持久存储的。
只存在客户端,默认不参与与服务端的通信。这样就很好地避免了 Cookie 带来的性能问题和安全问题。
接口封装。通过localStorage
暴露在全局,并通过它的 setItem
和 getItem
等方法进行操作,非常方便。
接下来我们来具体看看如何来操作localStorage
。
let obj = { name: "sanyuan", age: 18 };
localStorage.setItem("name", "sanyuan");
localStorage.setItem("info", JSON.stringify(obj));
接着进入相同的域名时就能拿到相应的值:
let name = localStorage.getItem("name");
let info = JSON.parse(localStorage.getItem("info"));
从这里可以看出,localStorage
其实存储的都是字符串,如果是存储对象需要调用JSON
的stringify
方法,并且用JSON.parse
来解析成对象。
利用localStorage
的较大容量和持久特性,可以利用localStorage
存储一些内容稳定的资源,比如官网的logo
,存储Base64
格式的图片资源,因此利用localStorage
sessionStorage
以下方面和localStorage
一致:
sessionStorage
名字有所变化,存储方式、操作方式均和localStorage
一样。但sessionStorage
和localStorage
有一个本质的区别,那就是前者只是会话级别的存储,并不是持久化存储。会话结束,也就是页面关闭,这部分sessionStorage
就不复存在了。
sessionStorage
就再合适不过了。事实上微博就采取了这样的存储方式。IndexedDB
是运行在浏览器中的非关系型数据库
, 本质上是数据库,绝不是和刚才WebStorage的 5M 一个量级,理论上这个容量是没有上限的。
关于它的使用,本文侧重原理,而且 MDN 上的教程文档已经非常详尽,这里就不做赘述了,感兴趣可以看一下使用文档。
接着我们来分析一下IndexedDB
的一些重要特性,除了拥有数据库本身的特性,比如支持事务
,存储二进制数据
,还有这样一些特性需要格外注意:
对象仓库
存放数据,在这个对象仓库中数据采用键值对的方式来存储。浏览器中各种本地存储和缓存技术的发展,给前端应用带来了大量的机会,PWA 也正是依托了这些优秀的存储方案才得以发展起来。重新梳理一下这些本地存储方案:
cookie
并不适合存储,而且存在非常多的缺陷。Web Storage
包括localStorage
和sessionStorage
, 默认不会参与和服务器的通信。IndexedDB
为运行在浏览器上的非关系型数据库,为大型数据的存储提供了接口。这是一个可以无限难的问题。出这个题目的目的就是为了考察你的 web 基础深入到什么程度。由于水平和篇幅有限,在这里我将把其中一些重要的过程给大家梳理一遍,相信能在绝大部分的情况下给出一个比较惊艳的答案。
这里我提前声明,由于是一个综合性非常强的问题,可能会在某一个点上深挖出非常多的细节,我个人觉得学习是一个循序渐进的过程,在明白了整体过程后再去自己研究这些细节,会对整个知识体系有更深的理解。同时,关于延申出来的细节点我都有参考资料,看完这篇之后不妨再去深入学习一下,扩展知识面。
好,正题开始。
此时此刻,你在浏览器地址栏输入了百度的网址:
https://www.baidu.com/
浏览器会构建请求行:
// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1
先检查强缓存,如果命中直接使用,否则进入下一步。关于强缓存,如果不清楚可以参考上一篇文章。
由于我们输入的是域名,而数据包是通过IP地址
传给对方的。因此我们需要得到域名对应的IP地址
。这个过程需要依赖一个服务系统,这个系统将域名和 IP 一一映射,我们将这个系统就叫做DNS(域名系统)。得到具体 IP 的过程就是DNS
解析。
当然,值得注意的是,浏览器提供了DNS数据缓存功能。即如果一个域名已经解析过,那会把解析的结果缓存下来,下次处理直接走缓存,不需要经过 DNS解析
。
另外,如果不指定端口的话,默认采用对应的 IP 的 80 端口。
这里要提醒一点,Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。
假设现在不需要等待,我们进入了 TCP 连接的建立阶段。首先解释一下什么是 TCP:
TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
建立 TCP连接
经历了下面三个阶段:
确认
, 如果发送方没有接到这个确认
的消息,就判定为数据包丢失,并重新发送该数据包。当然,发送的过程中还有一个优化策略,就是把大的数据包拆成一个个小包
,依次传输到接收方,接收方按照这个小包的顺序把它们组装
成完整数据包。读到这里,你应该明白 TCP 连接通过什么手段来保证数据传输的可靠性,一是三次握手
确认连接,二是数据包校验
保证数据到达接收方,三是通过四次挥手
断开连接。
当然,如果再深入地问,比如为什么要三次握手,两次不行吗?第三次握手失败了怎么办?为什么要四次挥手等等这一系列的问题,涉及计算机网络的基础知识,比较底层,但是也是非常重要的细节,希望你能好好研究一下,另外这里有一篇不错的文章,点击进入相应的推荐文章,相信这篇文章能给你启发。
现在TCP连接
建立完毕,浏览器可以和服务器开始通信,即开始发送 HTTP 请求。浏览器发 HTTP 请求要携带三样东西:请求行、请求头和请求体。
首先,浏览器会向服务器发送请求行,关于请求行, 我们在这一部分的第一步就构建完了,贴一下内容:
// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1
结构很简单,由请求方法、请求URI和HTTP版本协议组成。
同时也要带上请求头,比如我们之前说的Cache-Control、If-Modified-Since、If-None-Match都由可能被放入请求头中作为缓存的标识信息。当然了还有一些其他的属性,列举如下:
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: keep-alive
Cookie: /* 省略cookie信息 */
Host: www.baidu.com
Pragma: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1
最后是请求体,请求体只有在POST
方法下存在,常见的场景是表单提交。
HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是返回网络响应。
跟请求部分类似,网络响应具有三个部分:响应行、响应头和响应体。
响应行类似下面这样:
HTTP/1.1 200 OK
由HTTP协议版本
、状态码
和状态描述
组成。
响应头包含了服务器及其返回数据的一些信息, 服务器生成数据的时间、返回的数据类型以及对即将写入的Cookie信息。
举例如下:
Cache-Control: no-cache
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Wed, 04 Dec 2019 12:29:13 GMT
Server: apache
Set-Cookie: rsv_i=f9a0SIItKqzv7kqgAAgphbGyRts3RwTg%2FLyU3Y5Eh5LwyfOOrAsvdezbay0QqkDqFZ0DfQXby4wXKT8Au8O7ZT9UuMsBq2k; path=/; domain=.baidu.com
响应完成之后怎么办?TCP 连接就断开了吗?
不一定。这时候要判断Connection
字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP
连接会一直保持,之后请求统一站点的资源会复用这个连接。
否则断开TCP
连接, 请求-响应流程结束。
到此,我们来总结一下主要内容,也就是浏览器端的网络请求过程:
完成了网络请求和响应,如果响应头中Content-Type
的值是text/html
,那么接下来就是浏览器的解析
和渲染
工作了。
首先来介绍解析部分,主要分为以下几个步骤:
DOM
树样式
计算布局树
(Layout Tree
)由于浏览器无法直接理解HTML字符串
,因此将这一系列的字节流转换为一种有意义并且方便操作的数据结构,这种数据结构就是DOM树
。DOM树
本质上是一个以document
为根节点的多叉树。
那通过什么样的方式来进行解析呢?
首先,我们应该清楚把握一点: HTML 的文法并不是上下文无关文法
。
这里,有必要讨论一下什么是上下文无关文法
。
在计算机科学的编译原理学科中,有非常明确的定义:
若一个形式文法G = (N, Σ, P, S) 的产生式规则都取如下的形式:V->w,则叫上下文无关语法。其中 V∈N ,w∈(N∪Σ)* 。
其中把 G = (N, Σ, P, S) 中各个参量的意义解释一下:
通俗一点讲,上下文无关的文法
就是说这个文法中所有产生式的左边都是一个非终结符。
看到这里,如果还有一点懵圈,我举个例子你就明白了。
比如:
A -> B
这个文法中,每个产生式左边都会有一个非终结符,这就是上下文无关的文法
。在这种情况下,xBy
一定是可以规约出xAy
的。
我们下面看看看一个反例:
aA -> B
Aa -> B
这种情况就是不是上下文无关的文法
,当遇到B
的时候,我们不知道到底能不能规约出A
,取决于左边或者右边是否有a
存在,也就是说和上下文有关。
关于它为什么是非上下文无关文法
,首先需要让大家注意的是,规范的 HTML 语法,是符合上下文无关文法
的,能够体现它非上下文无关
的是不标准的语法。在此我仅举一个反例即可证明。
比如解析器扫描到form
标签的时候,上下文无关文法的处理方式是直接创建对应 form 的 DOM 对象,而真实的 HTML5 场景中却不是这样,解析器会查看 form
的上下文,如果这个 form
标签的父标签也是 form
, 那么直接跳过当前的 form
标签,否则才创建 DOM 对象。
常规的编程语言都是上下文无关的,而HTML却相反,也正是它非上下文无关的特性,决定了HTML Parser
并不能使用常规编程语言的解析器来完成,需要另辟蹊径。
HTML5 规范详细地介绍了解析算法。这个算法分为两个阶段:
对应的两个过程就是词法分析和语法分析。
这个算法输入为HTML文本
,输出为HTML标记
,也成为标记生成器。其中运用有限自动状态机来完成。即在当当前状态下,接收一个或多个字符,就会更新到下一个状态。
<html>
<body>
Hello sanyuan
</body>
</html>
通过一个简单的例子来演示一下标记化
的过程。
遇到<
, 状态为标记打开。
接收[a-z]
的字符,会进入标记名称状态。
这个状态一直保持,直到遇到>
,表示标记名称记录完成,这时候变为数据状态。
接下来遇到body
标签做同样的处理。
这个时候html
和body
的标记都记录好了。
现在来到<body>中的>,进入数据状态,之后保持这样状态接收后面的字符hello sanyuan。
接着接收 </body> 中的<
,回到标记打开, 接收下一个/
后,这时候会创建一个end tag
的token。
随后进入标记名称状态, 遇到>
回到数据状态。
接着以同样的样式处理 </body>。
之前提到过,DOM 树是一个以document
为根节点的多叉树。因此解析器首先会创建一个document
对象。标记生成器会把每个标记的信息发送给建树器。建树器接收到相应的标记时,会创建对应的 DOM 对象。创建这个DOM对象
后会做两件事情:
DOM对象
加入 DOM 树中。闭合标签
意思对应)元素的栈中。还是拿下面这个例子说:
<html>
<body>
Hello sanyuan
</body>
</html>
首先,状态为初始化状态。
接收到标记生成器传来的html
标签,这时候状态变为before html状态。同时创建一个HTMLHtmlElement
的 DOM 元素, 将其加到document
根对象上,并进行压栈操作。
接着状态自动变为before head, 此时从标记生成器那边传来body
,表示并没有head
, 这时候建树器会自动创建一个HTMLHeadElement并将其加入到DOM树
中。
现在进入到in head状态, 然后直接跳到after head。
现在标记生成器传来了body
标记,创建HTMLBodyElement, 插入到DOM
树中,同时压入开放标记栈。
接着状态变为in body,然后来接收后面一系列的字符: Hello sanyuan。接收到第一个字符的时候,会创建一个Text节点并把字符插入其中,然后把Text节点插入到 DOM 树中body元素
的下面。随着不断接收后面的字符,这些字符会附在Text节点上。
现在,标记生成器传过来一个body
的结束标记,进入到after body状态。
标记生成器最后传过来一个html
的结束标记, 进入到after after body的状态,表示解析过程到此结束。
讲到HTML5
规范,就不得不说它强大的宽容策略, 容错能力非常强,虽然大家褒贬不一,不过我想作为一名资深的前端工程师,有必要知道HTML Parser
在容错方面做了哪些事情。
接下来是 WebKit 中一些经典的容错示例,发现有其他的也欢迎来补充。
if (t->isCloseTag(brTag) && m_document->inCompatMode()) {
reportError(MalformedBRError);
t->beginTag = true;
}
全部换为<br>的形式。
<table>
<table>
<tr><td>inner table</td></tr>
</table>
<tr><td>outer table</td></tr>
</table>
WebKit
会自动转换为:
<table>
<tr><td>outer table</td></tr>
</table>
<table>
<tr><td>inner table</td></tr>
</table>
这时候直接忽略里面的form
。
关于CSS样式,它的来源一般是三种:
首先,浏览器是无法直接识别 CSS 样式文本的,因此渲染引擎接收到 CSS 文本之后第一件事情就是将其转化为一个结构化的对象,即styleSheets。
这个格式化的过程过于复杂,而且对于不同的浏览器会有不同的优化策略,这里就不展开了。
在浏览器控制台能够通过document.styleSheets
来查看这个最终的结构。当然,这个结构包含了以上三种CSS来源,为后面的样式操作提供了基础。
有一些 CSS 样式的数值并不容易被渲染引擎所理解,因此需要在计算样式之前将它们标准化,如em
->px
,red
->#ff0000
,bold
->700
等等。
样式已经被格式化
和标准化
,接下来就可以计算每个节点的具体样式信息了。
其实计算的方式也并不复杂,主要就是两个规则: 继承和层叠。
每个子节点都会默认继承父节点的样式属性,如果父节点中没有找到,就会采用浏览器默认样式,也叫UserAgent样式
。这就是继承规则,非常容易理解。
然后是层叠规则,CSS 最大的特点在于它的层叠性,也就是最终的样式取决于各个属性共同作用的效果,甚至有很多诡异的层叠现象,看过《CSS世界》的同学应该对此深有体会,具体的层叠规则属于深入 CSS 语言的范畴,这里就不过多介绍了。
不过值得注意的是,在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle
当中,也就是可以通过JS来获取计算后的样式,非常方便。
现在已经生成了DOM树
和DOM样式
,接下来要做的就是通过浏览器的布局系统确定元素的位置
,也就是要生成一棵布局树
(Layout Tree)。
布局树生成的大致工作如下:
布局树中
。值得注意的是,这棵布局树值包含可见元素,对于 head
标签和设置了display: none
的元素,将不会被放入其中。
有人说首先会生成Render Tree
,也就是渲染树,其实这还是 16 年之前的事情,现在 Chrome 团队已经做了大量的重构,已经没有生成Render Tree
的过程了。而布局树的信息已经非常完善,完全拥有Render Tree
的功能。
之所以不讲布局的细节,是因为它过于复杂,一一介绍会显得文章过于臃肿,不过大部分情况下我们只需要知道它所做的工作是什么即可,如果想深入其中的原理,知道它是如何来做的,我强烈推荐你去读一读人人FED团队的文章从Chrome源码看浏览器如何layout布局。
梳理一下这一节的主要脉络:
上一节介绍了浏览器解析
的过程,其中包含构建DOM
、样式计算
和构建布局树
。
接下来就来拆解下一个过程——渲染
。分为以下几个步骤:
图层树
(Layer Tree
)绘制列表
图块
并栅格化
如果你觉得现在DOM节点
也有了,样式和位置信息也都有了,可以开始绘制页面了,那你就错了。
因为你考虑掉了另外一些复杂的场景,比如3D动画如何呈现出变换效果,当元素含有层叠上下文时如何控制显示和隐藏等等。
为了解决如上所述的问题,浏览器在构建完布局树
之后,还会对特定的节点进行分层,构建一棵图层树
(Layer Tree
)。
那这棵图层树是根据什么来构建的呢?
一般情况下,节点的图层会默认属于父亲节点的图层(这些图层也称为合成层)。那什么时候会提升为一个单独的合成层呢?
有两种情况需要分别讨论,一种是显式合成,一种是隐式合成。
下面是显式合成
的情况:
一、 拥有层叠上下文的节点。
层叠上下文也基本上是有一些特定的CSS属性创建的,一般有以下情况:
二、需要剪裁的地方。
比如一个div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。
接下来是隐式合成
,简单来说就是层叠等级低
的节点被提升为单独的图层之后,那么所有层叠等级比它高
的节点都会成为一个单独的图层。
这个隐式合成其实隐藏着巨大的风险,如果在一个大型应用中,当一个z-index
比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。这里有一个具体的例子,点击打开。
值得注意的是,当需要repaint
时,只需要repaint
本身,而不会影响到其他的层。
接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框......然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一波计划。
这里我以百度首页为例,大家可以在 Chrome 开发者工具中在设置栏中展开 more tools
, 然后选择Layers
面板,就能看到下面的绘制列表:
现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程。
绘制列表准备好了之后,渲染进程的主线程会给合成线程
发送commit
消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。
首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 * 256 或者 512 * 512 这个规格。这样可以大大加速页面的首屏展示。
因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。
顺便提醒一点,渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据。
然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。
生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程
。
栅格化操作完成后,合成线程会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。
浏览器进程中的viz组件
接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。
无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区
和后缓冲区
对换位置,如此循环更新。
看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。
到这里,我们算是把整个过程给走通了,现在重新来梳理一下页面渲染的流程。
我们首先来回顾一下渲染流水线
的流程:
接下来,我们将来以此为依据来介绍重绘和回流,以及让更新视图的另外一种方式——合成。
首先介绍回流
。回流
也叫重排
。
简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流
的过程。
具体一点,有以下的操作会触发回流:
一个 DOM 元素的几何属性变化,常见的几何属性有width
、height
、padding
、margin
、left
、top
、border
等等, 这个很好理解。
使 DOM 节点发生增减
或者移动
。
读写 offset
族、scroll
族和client
族属性的时候,浏览器为了获取这些值,需要进行回流操作。
调用 window.getComputedStyle
方法。
依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。
相当于将解析和合成的过程重新又走了一篇,开销是非常大的。
当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘
(repaint
)。
由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程如下:
跳过了生成布局树
和建图层树
的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。
可以看到,重绘不一定导致回流,但回流一定发生了重绘。
还有一种情况,是直接合成。比如利用 CSS3 的transform
、opacity
、filter
这些属性就可以实现合成的效果,也就是大家常说的GPU加速。
在合成的情况下,会直接跳过布局和绘制流程,直接进入非主线程
处理的部分,即直接交给合成线程
处理。交给它处理有两大好处:
能够充分发挥GPU
的优势。合成线程生成位图的过程中会调用线程池,并在其中使用GPU
进行加速生成,而GPU 是擅长处理位图数据的。
没有占用主线程的资源,即使主线程卡住了,效果依然能够流畅地展示。
知道上面的原理之后,对于开发过程有什么指导意义呢?
class
的方式。createDocumentFragment
进行批量的 DOM 操作。tranform
, 任何可以实现合成效果的 CSS 属性都能用will-change
来声明。这里有一个实际的例子,一行will-change: tranform
拯救一个项目,点击直达。XSS
全称是 Cross Site Scripting
(即跨站脚本
),为了和 CSS 区分,故叫它XSS
。XSS 攻击是指浏览器中执行恶意脚本(无论是跨域还是同域),从而拿到用户的信息并进行操作。
这些操作一般可以完成下面这些事情:
Cookie
。通常情况,XSS 攻击的实现有三种方式——存储型、反射型和文档型。原理都比较简单,先来一一介绍一下。
存储型
,顾名思义就是将恶意脚本存储了起来,确实,存储型的 XSS 将脚本存储到了服务端的数据库,然后在客户端执行这些脚本,从而达到攻击的效果。
常见的场景是留言评论区提交一段脚本代码,如果前后端没有做好转义的工作,那评论内容存到了数据库,在页面渲染过程中直接执行
, 相当于执行一段未知逻辑的 JS 代码,是非常恐怖的。这就是存储型的 XSS 攻击。
反射型XSS
指的是恶意脚本作为网络请求的一部分。
比如我输入:
http://sanyuan.com?q=<script>alert("你完蛋了")</script>
这杨,在服务器端会拿到q
参数,然后将内容返回给浏览器端,浏览器将这些内容作为HTML的一部分解析,发现是一个脚本,直接执行,这样就被攻击了。
之所以叫它反射型
, 是因为恶意脚本是通过作为网络请求的参数,经过服务器,然后再反射到HTML文档中,执行解析。和存储型
不一样的是,服务器并不会存储这些恶意脚本。
文档型的 XSS 攻击并不会经过服务端,而是作为中间人的角色,在数据传输过程劫持到网络数据包,然后修改里面的 html 文档!
这样的劫持方式包括WIFI路由器劫持
或者本地恶意软件
等。
明白了三种XSS
攻击的原理,我们能发现一个共同点: 都是让恶意脚本直接能在浏览器中执行。
那么要防范它,就是要避免这些脚本代码的执行。
为了完成这一点,必须做到一个信念,两个利用。
千万不要相信任何用户的输入!
无论是在前端和服务端,都要对用户的输入进行转码或者过滤。
如:
<script>alert('你完蛋了')</script>
转码后变为:
<script>alert('你完蛋了')</script>
这样的代码在 html 解析的过程中是无法执行的。
当然也可以利用关键词过滤的方式,将 script 标签给删除。那么现在的内容只剩下:
什么也没有了:)
CSP,即浏览器中的内容安全策略,它的核心思想就是服务器决定浏览器加载哪些资源,具体来说可以完成以下功能:
很多 XSS 攻击脚本都是用来窃取Cookie, 而设置 Cookie 的 HttpOnly 属性后,JavaScript 便无法读取 Cookie 的值。这样也能很好的防范 XSS 攻击。
XSS
攻击是指浏览器中执行恶意脚本, 然后拿到用户的信息进行操作。主要分为存储型
、反射型
和文档型
。防范的措施包括:
CSRF(Cross-site request forgery), 即跨站请求伪造,指的是黑客诱导用户点击链接,打开黑客的网站,然后黑客利用用户目前的登录状态发起跨站请求。
举个例子, 你在某个论坛点击了黑客精心挑选的小姐姐图片,你点击后,进入了一个新的页面。
那么恭喜你,被攻击了:)
你可能会比较好奇,怎么突然就被攻击了呢?接下来我们就来拆解一下当你点击了链接之后,黑客在背后做了哪些事情。
可能会做三样事情。列举如下:
黑客网页里面可能有一段这样的代码:
<img src="https://xxx.com/info?user=hhh&count=100">
进入页面后自动发送 get 请求,值得注意的是,这个请求会自动带上关于 xxx.com 的 cookie 信息(这里是假定你已经在 xxx.com 中登录过)。
假如服务器端没有相应的验证机制,它可能认为发请求的是一个正常的用户,因为携带了相应的 cookie,然后进行相应的各种操作,可以是转账汇款以及其他的恶意操作。
黑客可能自己填了一个表单,写了一段自动提交的脚本。
<form id='hacker-form' action="https://xxx.com/info" method="POST">
<input type="hidden" name="user" value="hhh" />
<input type="hidden" name="count" value="100" />
</form>
<script>document.getElementById('hacker-form').submit();</script>
同样也会携带相应的用户 cookie 信息,让服务器误以为是一个正常的用户在操作,让各种恶意的操作变为可能。
在黑客的网站上,可能会放上一个链接,驱使你来点击:
<a href="https://xxx/info?user=hhh&count=100" taget="_blank">点击进入修仙世界</a>
点击后,自动发送 get 请求,接下来和自动发 GET 请求
部分同理。
这就是CSRF
攻击的原理。和XSS
攻击对比,CSRF 攻击并不需要将恶意代码注入用户当前页面的html
文档中,而是跳转到新的页面,利用服务器的验证漏洞和用户之前的登录状态来模拟用户进行操作。
CSRF攻击
中重要的一环就是自动发送目标站点下的 Cookie
,然后就是这一份 Cookie 模拟了用户的身份。因此在Cookie
上面下文章是防范的不二之选。
恰好,在 Cookie 当中有一个关键的字段,可以对请求中 Cookie 的携带作一些限制,这个字段就是SameSite
。
SameSite
可以设置为三个值,Strict
、Lax
和None
。
a. 在Strict
模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com
网站只能在sanyuan.com
域名当中请求才能携带 Cookie,在其他网站请求都不能。
b. 在Lax
模式,就宽松一点了,但是只能在 get 方法提交表单
况或者a 标签发送 get 请求
的情况下可以携带 Cookie,其他情况均不能。
c. 在None
模式下,也就是默认模式,请求会自动携带上 Cookie。
这就需要要用到请求头中的两个字段: Origin和Referer。
其中,Origin只包含域名信息,而Referer包含了具体
的 URL 路径。
当然,这两者都是可以伪造的,通过 Ajax 中自定义请求头即可,安全性略差。
Django
作为 Python 的一门后端框架,如果是用它开发过的同学就知道,在它的模板(template)中, 开发表单时,经常会附上这样一行代码:
{% csrf_token %}
这就是CSRF Token
的典型应用。那它的原理是怎样的呢?
首先,浏览器向服务器发送请求时,服务器生成一个字符串,将其植入到返回的页面中。
然后浏览器如果要发送请求,就必须带上这个字符串,然后服务器来验证是否合法,如果不合法则不予响应。这个字符串也就是CSRF Token
,通常第三方站点无法拿到这个 token, 因此也就是被服务器给拒绝。
CSRF(Cross-site request forgery), 即跨站请求伪造,指的是黑客诱导用户点击链接,打开黑客的网站,然后黑客利用用户目前的登录状态发起跨站请求。
CSRF
攻击一般会有三种方式:
防范措施: 利用 Cookie 的 SameSite 属性
、验证来源站点
和CSRF Token
。
谈到HTTPS
, 就不得不谈到与之相对的HTTP
。HTTP
的特性是明文传输,因此在传输的每一个环节,数据都有可能被第三方窃取或者篡改,具体来说,HTTP 数据经过 TCP 层,然后经过WIFI路由器
、运营商
和目标服务器
,这些环节中都可能被中间人拿到数据并进行篡改,也就是我们常说的中间人攻击。
为了防范这样一类攻击,我们不得已要引入新的加密方案,即 HTTPS。
HTTPS
并不是一个新的协议, 而是一个加强版的HTTP
。其原理是在HTTP
和TCP
之间建立了一个中间层,当HTTP
和TCP
通信时并不是像以前那样直接通信,直接经过了一个中间层进行加密,将加密后的数据包传给TCP
, 响应的,TCP
必须将数据包解密,才能传给上面的HTTP
。这个中间层也叫安全层
。安全层
的核心就是对数据加解密
。
接下来我们就来剖析一下HTTPS
的加解密是如何实现的。
首先需要理解对称加密
和非对称加密
的概念,然后讨论两者应用后的效果如何。
对称加密
是最简单的方式,指的是加密
和解密
用的是同样的密钥。
而对于非对称加密
,如果有 A、 B 两把密钥,如果用 A 加密过的数据包只能用 B 解密,反之,如果用 B 加密过的数据包只能用 A 解密。
接着我们来谈谈浏览器
和服务器
进行协商加解密的过程。
首先,浏览器会给服务器发送一个随机数client_random
和一个加密的方法列表。
服务器接收后给浏览器返回另一个随机数server_random
和加密方法。
现在,两者拥有三样相同的凭证: client_random
、server_random
和加密方法。
接着用这个加密方法将两个随机数混合起来生成密钥,这个密钥就是浏览器和服务端通信的暗号
。
如果用对称加密
的方式,那么第三方可以在中间获取到client_random
、server_random
和加密方法,由于这个加密方法同时可以解密,所以中间人可以成功对暗号进行解密,拿到数据,很容易就将这种加密方式激活成功教程了。
既然对称加密
这么不堪一击,我们就来试一试非对称
加密。在这种加密方式中,服务器手里有两把钥匙,一把是公钥
,也就是说每个人都能拿到,是公开的,另一把是私钥
,这把私钥只有服务器自己知道。
好,现在开始传输。
浏览器把client_random
和加密方法列表传过来,服务器接收到,把server_random
、加密方法
和公钥
传给浏览器。
现在两者拥有相同的client_random
、server_random
和加密方法。然后浏览器用公钥将client_random
和server_random
加密,生成与服务器通信的暗号
。
这时候由于是非对称加密,公钥加密过的数据只能用私钥
解密,因此中间人就算拿到浏览器传来的数据,由于他没有私钥,照样无法解密,保证了数据的安全性。
这难道一定就安全吗?聪明的小伙伴早就发现了端倪。回到非对称加密
的定义,公钥加密的数据可以用私钥解密,那私钥加密的数据也可以用公钥解密呀!
服务器的数据只能用私钥进行加密(因为如果它用公钥那么浏览器也没法解密啦),中间人一旦拿到公钥,那么就可以对服务端传来的数据进行解密了,就这样又被激活成功教程了。而且,只是采用非对称加密,对于服务器性能的消耗也是相当巨大的,因此我们暂且不采用这种方案。
可以发现,对称加密和非对称加密,单独应用任何一个,都会存在安全隐患。那我们能不能把两者结合,进一步保证安全呢?
其实是可以的,演示一下整个流程:
client_random
和加密方法列表。server_random
、加密方法以及公钥。pre_random
, 并且用公钥加密,传给服务器。(敲黑板!重点操作!)pre_random
。现在浏览器和服务器有三样相同的凭证:client_random
、server_random
和pre_random
。然后两者用相同的加密方法混合这三个随机数,生成最终的密钥
。
然后浏览器和服务器尽管用一样的密钥进行通信,即使用对称加密
。
这个最终的密钥是很难被中间人拿到的,为什么呢? 因为中间人没有私钥,从而拿不到pre_random,也就无法生成最终的密钥了。
回头比较一下和单纯的使用非对称加密, 这种方式做了什么改进呢?本质上是防止了私钥加密的数据外传。单独使用非对称加密,最大的漏洞在于服务器传数据给浏览器只能用私钥
加密,这是危险产生的根源。利用对称和非对称
加密结合的方式,就防止了这一点,从而保证了安全。
尽管通过两者加密方式的结合,能够很好地实现加密传输,但实际上还是存在一些问题。黑客如果采用 DNS 劫持,将目标地址替换成黑客服务器的地址,然后黑客自己造一份公钥和私钥,照样能进行数据传输。而对于浏览器用户而言,他是不知道自己正在访问一个危险的服务器的。
事实上HTTPS
在上述结合对称和非对称加密
的基础上,又添加了数字证书认证
的步骤。其目的就是让服务器证明自己的身份。
为了获取这个证书,服务器运营者需要向第三方认证机构获取授权,这个第三方机构也叫CA
(Certificate Authority
), 认证通过后 CA 会给服务器颁发数字证书。
这个数字证书有两个作用:
这个验证的过程发生在什么时候呢?
当服务器传送server_random
、加密方法的时候,顺便会带上数字证书
(包含了公钥
), 接着浏览器接收之后就会开始验证数字证书。如果验证通过,那么后面的过程照常进行,否则拒绝执行。
现在我们来梳理一下HTTPS
最终的加解密过程:
浏览器拿到数字证书后,如何来对证书进行认证呢?
首先,会读取证书中的明文内容。CA 进行数字证书的签名时会保存一个 Hash 函数,来这个函数来计算明文内容得到信息A
,然后用公钥解密明文内容得到信息B
,两份信息做比对,一致则表示认证合法。
当然有时候对于浏览器而言,它不知道哪些 CA 是值得信任的,因此会继续查找 CA 的上级 CA,以同样的信息比对方式验证上级 CA 的合法性。一般根级的 CA 会内置在操作系统当中,当然如果向上找没有找到根级的 CA,那么将被视为不合法。
HTTPS并不是一个新的协议, 它在HTTP
和TCP
的传输中建立了一个安全层,利用对称加密
和非对称加密
结合数字证书认证的方式,让传输过程的安全性大大提高。
节流的核心思想: 如果在定时器的时间范围内再次触发,则不予理睬,等当前定时器完成
,才能启动下一个定时器任务。这就好比公交车,10 分钟一趟,10 分钟内有多少人在公交站等我不管,10 分钟一到我就要发车走人!
代码如下:
function throttle(fn, interval) {
let flag = true;
return function(...args) {
let context = this;
if (!flag) return;
flag = false;
setTimeout(() => {
fn.apply(context, args);
flag = true;
}, interval);
};
};
写成下面的方式也是表达一样的意思:
const throttle = function(fn, interval) {
let last = 0;
return function (...args) {
let context = this;
let now = +new Date();
// 还没到时间
if(now - last < interval) return;
last = now;
fn.apply(this, args)
}
}
核心思想: 每次事件触发则删除原来的定时器,建立新的定时器。跟王者荣耀的回城功能类似,你反复触发回城功能,那么只认最后一次,从最后一次触发开始计时。
function debounce(fn, delay) {
let timer = null;
return function (...args) {
let context = this;
if(timer) clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
}
}
现在我们可以把防抖
和节流
放到一起,为什么呢?因为防抖有时候触发的太频繁会导致一次响应都没有,我们希望到了固定的时间必须给用户一个响应,事实上很多前端库就是采取了这样的思路。
function throttle(fn, delay) {
let last = 0, timer = null;
return function (...args) {
let context = this;
let now = new Date();
if(now - last < delay){
clearTimeout(timer);
setTimeout(function() {
last = now;
fn.apply(context, args);
}, delay);
} else {
// 这个时候表示时间到了,必须给响应
last = now;
fn.apply(context, args);
}
}
}
首先给图片一个占位资源:
<img src="default.jpg" data-src="http://www.xxx.com/target.jpg" />
接着,通过监听 scroll 事件来判断图片是否到达视口:
let img = document.getElementsByTagName("img");
let num = img.length;
let count = 0;//计数器,从第一张图片开始计
lazyload();//首次加载别忘了显示图片
window.addEventListener('scroll', lazyload);
function lazyload() {
let viewHeight = document.documentElement.clientHeight;//视口高度
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;//滚动条卷去的高度
for(let i = count; i <num; i++) {
// 元素现在已经出现在视口中
if(img[i].offsetTop < scrollHeight + viewHeight) {
if(img[i].getAttribute("src") !== "default.jpg") continue;
img[i].src = img[i].getAttribute("data-src");
count ++;
}
}
}
当然,最好对 scroll 事件做节流处理,以免频繁触发:
// throttle函数我们上节已经实现
window.addEventListener('scroll', throttle(lazyload, 200));
现在我们用另外一种方式来判断图片是否出现在了当前视口, 即 DOM 元素的 getBoundingClientRect
API。
上述的 lazyload 函数改成下面这样:
function lazyload() {
for(let i = count; i <num; i++) {
// 元素现在已经出现在视口中
if(img[i].getBoundingClientRect().top < document.documentElement.clientHeight) {
if(img[i].getAttribute("src") !== "default.jpg") continue;
img[i].src = img[i].getAttribute("data-src");
count ++;
}
}
}
这是浏览器内置的一个API
,实现了监听window的scroll事件
、判断是否在视口中
以及节流
三大功能。
我们来具体试一把:
let img = document.getElementsByTagName("img");
const observer = new IntersectionObserver(changes => {
//changes 是被观察的元素集合
for(let i = 0, len = changes.length; i < len; i++) {
let change = changes[i];
// 通过这个属性判断是否在视口中
if(change.isIntersecting) {
const imgElement = change.target;
imgElement.src = imgElement.getAttribute("data-src");
observer.unobserve(imgElement);
}
}
})
Array.from(img).forEach(item => observer.observe(item));
这样就很方便地实现了图片懒加载,当然这个IntersectionObserver
也可以用作其他资源的预加载,功能非常强大。
以上文章在均在开源项目神三元的博客首发,旨在打造完整前端知识体系,如果对你些许的帮助,请帮项目点一个star,非常感谢!
另外,本人的掘金小册《React Hooks与Immutable数据流实战》最近已经上线,虽然是关于React的实战教程,但也包含了诸多浏览器和性能优化相关的实践手段,一共有36
个小节,后期打算将 hooks 源码解析的系列文章直接放到小册中,不断给小册增值,相信能对正在进阶的各位有所帮助,望多多支持!小册链接
参考文献:
极客时间浏览器专栏
浏览器层合成与页面渲染优化
浏览器是如何工作的
山月——实现图片懒加载的几种方案比较
掘金小册——前端性能优化原理与实践