浏览器的缓存
强缓存
检查强缓存,这个阶段不需要发送HTTP请求。
在HTTP/1.0
和HTTP/1.1
当中,这个字段是不一样的。在早期,也就是HTTP/1.0
时期,使用的是Expires,而HTTP/1.1
使用的是Cache-Control。
Expires
Expires
即过期时间,存在于服务端返回的响应头中,告诉浏览器在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求。过期了就得向服务端发请求。
缺点:服务器的时间和浏览器的时间可能并不一致,那服务器返回的这个过期时间可能就是不准确的。因此这种方式很快在后来的HTTP1.1版本中被抛弃了。
Cache-Control
在HTTP1.1中,采用了一个非常关键的字段:Cache-Control
。采用过期时长来控制缓存,对应的字段是max-age。除了max-age还可以配合一下字段
public: 客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器
最后才到达目标服务器,那么结果就是不仅仅浏览器可以缓存数据,中间的任何代理节点都可以进行缓存。
private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。
no-cache: 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段
。
no-store:非常粗暴,不进行任何形式的缓存。
值得注意的是,当Expires和Cache-Control同时存在的时候,Cache-Control会优先考虑。
当资源缓存时间超时了,也就是强缓存失效了,接下来怎么办?没错,这样就进入到第二级屏障——协商缓存了。
协商缓存
强缓存失效之后,浏览器在请求头中携带相应的缓存tag
来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存。这样的缓存tag分为两种: Last-Modified 和 ETag。
Last-Modified和If-Modified-Since
即最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。
浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since
字段,这个字段的值也就是服务器传来的最后修改时间。
服务器拿到请求头中的If-Modified-Since
的字段后,其实会和这个服务器中该资源的最后修改时间
对比:
- 如果请求头中的这个值小于最后修改时间,说明是时候更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
- 否则返回304,告诉浏览器直接用缓存。
ETag和If-None-Match
ETag
是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头
把这个值给浏览器。
浏览器接收到ETag
的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器。
服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对: - 如果两者不一样,说明要更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。 - 否则返回304,告诉浏览器直接用缓存。
如果两种方式都支持的话,服务器会优先考虑ETag
。
缓存位置
浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:
- Service Worker 主要用在离线缓存
- Memory Cache 内存缓存
- Disk Cache 磁盘缓存
- Push Cache 推送缓存
主要策略如下: 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存.内存使用率比较高的时候,文件优先进入磁盘
总结
首先通过 Cache-Control
验证强缓存是否可用 - 如果强缓存可用,直接使用 - 否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since
或者If-None-Match
字段检查资源是否更新 - 若资源更新,返回资源和200状态码 - 否则,返回304,告诉浏览器直接从缓存获取资源
浏览器的进程和线程
- 浏览器进程:主要负责tab页的管理。
- 浏览器渲染进程:负责页面的渲染,每个tab页都会有浏览器渲染进程
- GPU进程:主要用于3D绘制,例如使用canvas进行3D绘图
- 插件进程:插件运行在插件进程,每个不同的插件都会运行在一个新的进程
输入url到页面呈现
网络篇
- 构建请求
- 查找强缓存 如果命中则直接使用
- DNS解析 得到域名对应的ip地址,浏览器提供了DNS数据缓存功能。即如果一个域名已经解析过,那会把解析的结果缓存下来,下次处理直接走缓存,不需要经过
DNS解析
。 - 建立TCP连接
- 发送http请求
- 响应http 响应完成之后要判断
Connection
字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP
连接会一直保持,之后请求统一站点的资源会复用这个连接。
解析渲染篇
- 解析HTML,生成DOM树
- 解析css,生成渲染树(包含width,颜色等) CSSOM tree
- 将HTML DOM树与CSS规则树结合,生成生成Render tree
- 布局Render树(layout/reflow),负责各元素大小、位置的计算
- 绘制Render树(painting),绘制页面像素信息
- 浏览器将各层信息发送给GPU,GPU将各层合成,显示在屏幕上
重排和重绘
重排
重排也叫回流
简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流
的过程。
有以下的操作会触发回流:
- 一个 DOM 元素的几何属性变化,常见的几何属性有
width
、height
、padding
、margin
、left
、top
、border
等等, 这个很好理解。 - 使 DOM 节点发生
增减
或者移动
。 - 读写
offset
族、scroll
族和client
族属性的时候,浏览器为了获取这些值,需要进行回流操作。 - 调用
window.getComputedStyle
方法。
过程:如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。
重绘
当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘
(repaint
)。
由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程只会出发计算css样式并绘制
重绘不一定导致回流,但回流一定发生了重绘。
合成
还有一种情况,是直接合成。比如利用 CSS3 的transform
、opacity
、filter
这些属性就可以实现合成的效果,也就是大家常说的GPU加速。
实践
- 避免频繁使用 style,而是采用修改
class
的方式。 - 使用
createDocumentFragment
进行批量的 DOM 操作。 - 对于 resize、scroll 等进行防抖/节流处理。
- 添加 will-change: tranform ,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。当然这个变化不限于
tranform
, 任何可以实现合成效果的 CSS 属性都能用will-change
来声明。这行代码能够开启 GPU 加速页面渲染,从而大大降低了 CPU 的负载压力,达到优化页面渲染性能的目的
js和css阻塞
load事件:load 应该仅用于检测一个完全加载的页面 当一个资源及其依赖资源已完成加载时,将触发load事件。也就是说,页面的html、css、js、图片等资源都已经加载完之后才会触发 load 事件。
DOMContentLoaded事件:当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完成加载。也就是说,DOM 树已经构建完毕就会触发 DOMContentLoaded 事件。
js阻塞了什么
- js是否会阻塞dom树构建
因为js在执行的过程中可能会操作DOM,发生回流和重绘,所以GUI渲染线程与JS引擎线程是互斥的。
在解析HTML过程中,如果遇到 script 标签,渲染线程会暂停渲染过程,将控制权交给 JS 引擎。内联的js代码会直接执行,如果是js外部文件,则要下载该js文件,下载完成之后再执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染线程,继续 DOM 的解析。
因此,js会阻塞DOM树的构建。
- 是否会阻塞页面的显示呢?
1 |
|
上面的代码,页面中会先显示hello world,然后遇到script标签则导致阻塞渲染,运行js代码,js代码完成后会再显示hello world2
js不会阻塞位于它之前的dom元素的渲染。现代浏览器为了更好的用户体验,渲染引擎将尝试尽快在屏幕上显示的内容。它不会等到所有DOM解析完成后才布局渲染树。而是当js阻塞发生时,会将已经构建好的DOM元素渲染到屏幕上,减少白屏的时间。
这也是为什么我们会将script标签放到body标签的底部,因为这样就不会影响前面的页面的渲染。
css阻塞了什么
当我们解析 HTML 时遇到 link 标签或者 style 标签时,就会计算样式,构建CSSOM。
css不会阻塞dom树的构建,但是会阻塞页面的显示。
- 会不会阻塞DOM树的构建
css不会阻塞dom树的构建.但是浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSSOM 不没构建好,页面也不会显示内容。
只有当我们遇到 link 标签或者 style 标签时,才会构建CSSOM,所以如果 link 标签之前有dom元素,会加载css发生阻塞,如下面的代码
1 | <body> |
这样做会导致一个问题,就是页面闪烁,在css被加载之前,浏览器按照默认样式渲染
为了避免页面闪烁,通常 link 标签都放在head中。
下面的代码
1 |
|
上面的代码,DOMContentLoaded事件会在30ms左右就完成,也就是说DOM树会在30ms就构建完成,但是在30ms时页面此时依然是空白;而loaded事件会在2.92s发生,并且页面才出现内容.也就是说这时候资源全部加载完毕.由此可见,在head中的link标签加载css较费时,但是不会阻塞DOM树的构建,会阻塞页面的渲染.
- css会不会阻塞后面js执行
答案是会.
JS 的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。当在JS中访问了CSSDOM中某个元素的样式,那么这时候就需要等待这个样式被下载完成才能继续往下执行JS脚本。
1 |
|
这个例子,就会发现等css加载完成后,才会在控制台打印“this is a test”。
总结
(1)js
- js既会阻塞dom树的构建,也会阻塞页面的渲染
(2)css
css不会阻塞dom树的构建(DOM解析和CSS解析是两个并行的进程,所以这也解释了为什么CSS加载不会阻塞DOM的解析)
style不会阻塞页面的渲染(dom会先展示但是没有任何样式,当加载完css后才显示样式,因此会造成闪屏现象)
link会阻塞页面的渲染(dom树解析完也不会进行任何渲染,当加载完link后才显示合并cssom一起显示,因此会造成白屏现象)
css会阻塞后面js的执行
(3)link和@import的区别
- @import是CSS提供的语法规则,只有导入样式表的作用;link是HTML提供的标签,不仅可以加载CSS文件,还可以定义RSS,rel连接属性等;
- 加载页面时,link引入的CSS被同时加载,@import引入的CSS将在页面加载完毕后加载;因此可能造成闪屏现象
- link标签作为HTML元素,不存在兼容性问题,而@import是CSS2.1才有的语法,故老版本浏览器(IE5之前)不能识别;
- 可以通过JS操作DOM,来插入link标签改变样式;由于DOM方法是基于文档的,无法使用@import方式插入样式;
HTTPS
对称和非对称加密结合
演示一下整个流程: 1. 浏览器向服务器发送client_random
和加密方法列表。 2. 服务器接收到,返回server_random
、加密方法以及公钥。 3. 浏览器接收,接着生成另一个随机数pre_random
, 并且用公钥加密,传给服务器。(敲黑板!重点操作!) 4. 服务器用私钥解密这个被加密后的pre_random
。
现在浏览器和服务器有三样相同的凭证:client_random
、server_random
和pre_random
。然后两者用相同的加密方法混合这三个随机数,生成最终的密钥
。
然后浏览器和服务器尽管用一样的密钥进行通信,即使用对称加密
。
缺点:尽管通过两者加密方式的结合,能够很好地实现加密传输,但实际上还是存在一些问题。黑客如果采用 DNS 劫持,将目标地址替换成黑客服务器的地址,然后黑客自己造一份公钥和私钥,照样能进行数据传输。而对于浏览器用户而言,他是不知道自己正在访问一个危险的服务器的。
添加数字证书
添加了数字证书认证
的步骤。其目的就是让服务器证明自己的身份。为了获取这个证书,服务器运营者需要向第三方认证机构获取授权,这个第三方机构也叫CA
(Certificate Authority
), 认证通过后 CA 会给服务器颁发数字证书。
这个数字证书有两个作用: 1. 服务器向浏览器证明自己的身份。 2. 把公钥传给浏览器。
这个验证的过程发生在什么时候呢?
当服务器传送server_random
、加密方法的时候,顺便会带上数字证书
(包含了公钥
), 接着浏览器接收之后就会开始验证数字证书。如果验证通过,那么后面的过程照常进行,否则拒绝执行。
HTTPS加解密过程
垃圾回收机制
在JavaScript中,数据类型分为两类,简单类型和引用类型,对于简单类型,内存是保存在栈(stack)空间中,复杂数据类型,内存是保存在堆(heap)空间中。
- 基本类型:这些类型在内存中分别占有固定大小的空间,他们的值保存在栈空间,我们通过按值来访问的
- 引用类型:引用类型,值大小不固定,栈内存中存放地址指向堆内存中的对象。是按引用访问的。
由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是操作系统自动分配和释放回收的
由于堆内存所存大小不固定,系统无法自动释放回收
,所以需要JS引擎来手动释放这些内存
引用计数
在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要”简化成“对象有没有其他对象引用到它”,如果没有对象引用这个对象,那么这个对象将会被回收。上例子:
1 | let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1 |
但是引用计数有个最大的问题: 循环引用。
1 | function func() { |
当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。
要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。
标记清除
JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。
标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。
V8的垃圾回收算法
那么问题来了,对于存活周期短的,回收掉就算了,但对于存活周期长的,多次回收都回收不掉,明知回收不掉,却还不断地去做回收无用功,那岂不是很消耗性能?
对于这个问题,V8做了分代回收的优化方法,通俗点说就是:V8将堆分为两个空间,一个叫新生代,一个叫老生代,新生代是存放存活周期短对象的地方,老生代是存放存活周期长对象的地方
新生代通常只有1-8M
的容量,而老生代的容量就大很多了。对于这两块区域,V8分别使用了不同的垃圾回收器和不同的回收算法,以便更高效地实施垃圾回收
新生代
在JavaScript中,任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。在新生代中,主要使用Scavenge
算法进行垃圾回收,Scavenge
算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。
Scavange算法
将新生代堆分为两部分,分别叫from-space
和to-space
,工作方式也很简单,就是将from-space
中存活的活动对象复制到to-space
中,并将这些对象的内存有序的排列起来,然后将from-space
中的非活动对象的内存进行释放,完成之后,将from space
和to space
进行互换,这样可以使得新生代中的这两块区域可以重复利用。
新生代中的对象什么时候变成老生代的对象?
在新生代中,还进一步进行了细分。分为nursery子代
和intermediate子代
两个区域,一个对象第一次分配内存时会被分配到新生代中的nursery子代
,如果经过下一次垃圾回收这个对象还存在新生代中,这时候我们将此对象移动到intermediate子代
,在经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器
会将该对象移动到老生代中,这个移动的过程被称为晋升
老生代
老生代里,回收算法不宜使用Scavenge算法
,为啥呢,有以下原因:
Scavenge算法
是复制算法,反复复制这些存活率高的对象,没什么意义,效率极低Scavenge算法
是以空间换时间的算法,老生代是内存很大的空间,如果使用Scavenge算法
,空间资源非常浪费,得不偿失啊。。
所以老生代里使用了Mark-Sweep算法(标记清理)
和Mark-Compact算法(标记整理)
Mark-Sweep(标记清理)
Mark-Sweep
分为两个阶段,标记和清理阶段,之前的Scavenge算法
也有标记和清理,但是Mark-Sweep算法
跟Scavenge算法
的区别是,后者需要复制后再清理,前者不需要,Mark-Sweep
直接标记活动对象和非活动对象之后,就直接执行清理了。
- 标记阶段:对老生代对象进行第一次扫描,对活动对象进行标记
- 清理阶段:对老生代对象进行第二次扫描,清除未标记的对象,即非活动对象
由上图,我想大家也发现了,有一个问题:清除非活动对象之后,留下了很多零零散散的空位
。
Mark-Compact(标记整理)
这个时候Mark-Compact算法
出现了,他是Mark-Sweep算法
的加强版,在Mark-Sweep算法
的基础上,加上了整理阶段
,每次清理完非活动对象,就会把剩下的活动对象,整理到内存的一侧,整理完成后,直接回收掉边界上的内存
全停顿(Stop-The-World)
说完V8的分代回收,咱们来聊聊一个问题。JS代码的运行要用到JS引擎,垃圾回收也要用到JS引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行
,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿
由于新生代空间小,并且存活对象少,再配合Scavenge算法
,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象
。
Orinoco优化
orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题
,它提出了增量标记、懒性清理、并发、并行
的优化方法。
增量标记(Incremental marking)
咱们前面不断强调了先标记,后清除
,而增量标记就是在标记
这个阶段进行了优化。当垃圾少量时不会做增量标记优化,但是当垃圾达到一定数量时,增量标记就会开启:标记一点,JS代码运行一段
,从而提高效率
惰性清理(Lazy sweeping)
增量标记只是针对标记
阶段,而惰性清理就是针对清除
阶段了。在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理
,让JS代码先执行,或者只清理部分垃圾
,而不清理全部。这个优化就叫做惰性清理
整理标记和惰性清理的出现,大大改善了全停顿
现象。但是问题也来了:增量标记是标记一点,JS运行一段
,那如果你前脚刚标记一个对象为活动对象,后脚JS代码就把此对象设置为非活动对象,或者反过来,前脚没有标记一个对象为活动对象,后脚JS代码就把此对象设置为活动对象。总结起来就是:标记和代码执行的穿插,有可能造成对象引用改变,标记错误
现象。这就需要使用写屏障
技术来记录这些引用关系的变化
并发(Concurrent)
并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障
操作。
并行
并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。
V8当前的垃圾回收机制
2011年,V8应用了增量标记机制
。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent)
,同时在并发的基础上添加并行(Parallel)技术
,使得垃圾回收时间大幅度缩短。