chrome多进程架构
进程和线程的关系
- 进程是操作系统为应用程序分配的独立空间和资源,一个进程中可能包含多个线程
- 当一个进程关闭之后,操作系统会回收进程所占用的内存
- 进程中任意一个线程执行出错,都会导致整个进程的崩溃
- 线程之间共享进程中的数据
- 进程之间的内容互相隔离,一个进程崩溃或挂起,不会影响到其他进程
chrome多进程架构
- 1个浏览器主进程(负责各个进程的调度)
- 1个GPU进程(负责硬件加速)
- 1个网络进程(负责网络请求)
- 多个插件进程(负责chrome插件的运行)
- 多个渲染进程
chrome中每一个标签页都是一个独立的渲染进程,渲染进程负责tab内网页展示的所有工作,排版引擎blink和JavaScript引擎v8都是运行在该进程中,该进程包含以下线程:- GUI渲染线程(负责页面渲染)
- JavaScript引擎线程(负责执行js脚本)
- 事件触发线程(负责管理事件监听)
- 定时器触发线程(负责管理定时器任务)
chrome为什么要让每一个标签页是一个独立的进程呢?
主要是为了隔离,当某一个标签页崩溃或内存泄漏时不会影响到其他页面;如果所有标签页都在同一个进程中,一个页面崩溃将会导致所有页面都看不了了,是非常不好的用户体验。
事件循环(Event Loop)
我们都知道JS是单线程、非阻塞的,单线程指的是JS引擎在执行脚本的时候同一时间只能处理一件事情,比如有两个监听事件同时被触发了,但他们的回调函数不会同时执行,必须进行排队处理。非阻塞指的是异步任务(可能非常耗时)不会阻塞同步任务的执行,那么这里的非阻塞就是利用事件循环来实现的。
事件循环其实就是浏览器对js事件/任务的处理机制,能够让各种任务有条不紊的排队执行:
- JavaScript中的代码分为同步任务和异步任务,其中异步任务又分为宏任务和微任务
- 同步任务优先在主线程上执行,形成执行栈
- 当遇到异步任务,js引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步任务返回结果后,会将它的回调函数添加到执行栈之外的任务队列中,其中,任务队列分为宏任务队列和微任务队列,js会根据异步事件的类型,将其回调函数添加到对应的队列当中。
- 当执行栈空闲时,就会从任务队列的队首读取一个宏任务执行,执行过程中如果遇到微任务就将它添加到微任务队列中,在本次循环的宏任务执行完之后立即执行微任务队列
- 浏览器为了能够使得JS引擎线程与GUI渲染线程有序切换,会在当前宏任务结束之后,下一个宏任务开始之前,对页面进行重新渲染
- 所以浏览器中任务的执行顺序是:
- 宏任务 > 微任务队列 > 渲染 > 宏任务 > 微任务队列 。。。
- 异步任务分类
- 宏任务
- script(主代码块)、
setTimeout
、setInterval
、I/O 、UI rendering - 在nodejs中还有
setImmediate
也是宏任务
- script(主代码块)、
- 微任务
Promise
、Object.observe
、MutationObserver
- 在nodejs中还有
process.nextTick
也是宏任务,它是所有任务中执行时机最快的,快于 promise
- 宏任务
一个很经典的题:
javascript
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
}).then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('script end')
依次输出:script start、async2 end、Promise、script end、async1 end、promise1、promise2、setTimeout
浅析:
- 首先,同步任务优先逐行执行,所以首先打印出 script start、async2 end、Promise、script end,这里要注意:
- await之前的代码都是同步执行的,await之后的代码相当于写在promise.then中,属于微任务
- Promise构造函数中的代码是同步执行的,then里的代码才是异步执行
- 主代码块是一个宏任务,在其执行过程中产生的微任务会在当前宏任务执行完后立即执行。所以接着依次打印出async1 end、promise1、promise2,它们都属于微任务。
- 一轮循环结束,主执行栈继续去任务队列读取新一轮的宏任务,输出setTimeout
页面的加载解析及渲染过程
又名:从输入url到页面加载完成都发生了什么? 这是一个很经典的面试题,能够全方位的考察知识面,主要分为三个阶段:网络请求、解析、渲染
网络请求
- 浏览器首先解析输入的内容是url还是搜索关键字
- DNS解析
- 将域名解析为ip地址
- 为了减少时间消耗,DNS解析会走多级缓存,包括浏览器缓存、hosts文件、本地dns服务器缓存,任何一环有命中的话就直接返回,否则才会由本地DNS服务器发出迭代请求去解析(根域名服务器、顶级域名服务器、权威域名服务器)
- 客户端发出的DNS解析请求是一个递归请求,本地DNS服务器发出的解析请求是一个迭代请求
- 建立TCP连接
- chrome要求同一个域名下同时最多只能有6个TCP连接,超过6个的话剩下的请求就得等待
- 进行三次握手
- 发送HTTP请求
- 构建请求报文,包括请求行、请求头、请求体
- 服务器处理请求并返回响应
- 响应完成之后,判断Connection字段,如果请求头或响应头中包含
Connection: Keep-Alive
,表示建立了持久连接,这样TCP连接会一直保持,之后请求同一站点的资源会复用这个连接。否则进行四次挥手,断开TCP连接。
解析
- 将html解析为DOM树
- DOM树本质上是一个以document为根节点的多叉树,它能够通过js操作
- 样式计算
- 将三种css设置方式(内联、外链、style标签)合并为styleSheets
- 标准化样式属性,如
em
转为px
,red
转为#ff0000
,bold
转为700等等 - 使用继承和层叠规则计算每个节点的最终样式,生成CSSOM
- 在计算完样式之后,所有的样式会被挂载到
window.getComputedStyle
当中,也就是说可以通过js来获取计算后的样式
- 在计算完样式之后,所有的样式会被挂载到
- 生成布局树
- 遍历DOM树上的节点,并按照规则把它们添加到布局树中
- 布局树只包含可见元素,比如head标签和设置了
display: none
的元素,将不会被放入其中
- 布局树只包含可见元素,比如head标签和设置了
- 确定布局树中节点的坐标位置
- 布局树生成完毕之后触发DOMContentLoaded事件
- 遍历DOM树上的节点,并按照规则把它们添加到布局树中
渲染
- 分层,构建图层树
- 对于一些复杂的3D变换,或者使用z-index做z轴排序等,为了方便的实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一颗对应的图层树(LayerTree)
- 接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,生成绘制列表
- 分块和栅格化
- 在渲染进程中绘制操作是由合成线程来完成的,合成线程要做的第一件事情就是将图层分块,它会优先将视口附近的图块生成位图,将图块转换为位图的过程就是栅格化
- 绘制
- 栅格化操作完成后,合成线程会生成一个绘制命令,并发送给浏览器进程
- 浏览器进程根据这个命令,把页面内容生成到内存,然后把这部分内存发送给显卡,最后渲染到显示器上
- 显示器上的画面有固定的刷新频率,一般是60HZ,即60帧,也就是一秒更新60张图片,一张图片停留的时间约为16.7ms
注意
- js的加载会阻塞DOM解析,在它加载并且执行完之前,不会往下解析 DOM 树。因为js可以操作dom,这两者不能并行
- css的加载不会阻塞DOM树的解析,但会阻塞DOM树的渲染,因为需要DOM树和CSSOM树共同合成render树
重绘和回流
重绘和回流是由渲染引擎操作的,渲染引擎和JS引擎是互斥的,不可同时进行,因此在重绘和回流时,JS引擎是无法工作的,我们应该尽量避免无意义的重绘和回流,减小浏览器资源浪费。
重绘(repaint)
当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color
、background-color
、visibility
等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。
回流(reflow)
当Render Tree
中元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。 回流比重绘的代价要更高。有时即使仅仅一个元素的尺寸或位置改变,它的父元素以及任何跟它相关的元素也会产生回流,牵一发动全身。
何时触发
- 添加/删除dom元素
- 元素的位置、尺寸发生变化时
- 查询某些属性或调用某些方法时
clientWidth
、clientHeight
、clientTop
、clientLeft
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
getComputedStyle()
getBoundingClientRect()
scrollTo()
如何避免
- 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次更改class属性
- 避免频繁操作dom
- 可以创建一个
documentFragment
,在它上面应用所有的dom操作,最后再把它添加到文档中 - 也可以先为元素设置
display: none
,操作结束后再把它显示出来。因为在display属性为none的元素上进行的dom操作不会引发回流和重绘
- 可以创建一个
- 避免频繁读取会引发回流的属性,如果确实需要多次使用,可以用一个变量缓存起来
- 对具有复杂动画的元素使用绝对定位,使其脱离文档流,否则会引起父元素及后续元素频繁回流
内存管理及垃圾回收
V8垃圾回收机制
不同浏览器的垃圾回收机制有所不同,大部分浏览器采用的都是标记清除或者引用计数。
比如在chrome中,将堆内存分为了新生代和老生代,新生代中存放数量小存活时间短的对象,老生代中存放数量多存活时间长的对象。刚开始所有对象都在新生代,当达到一定条件会被晋升到老生代中,新生代和老生代采用的是不同的垃圾回收算法。
- 新生代中采用scavenge算法,它将空间分成两半,每次只用一半,然后把其中活着的对象复制到另一半中,然后将这两个空间角色对换,当一个对象被复制两次以上还依然存活时,就会被晋升到老生代中。
- 老生代中主要使用标记清除,它分为标记和清除两个阶段,在标记阶段遍历调用栈,在遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。然后在遍历过程中标记,标记完成后将垃圾数据进行清理。
内存泄露
不再用到的内存,没有及时释放,就是内存泄漏
常见原因
-
意外的全局变量
比如在函数中声明变量时没有写 let/const/var 关键字,这样等于声明了一个全局变量,在函数调用完之后不会被回收
或者在函数中给this上挂载属性,当函数在全局作用域被调用时,属性就挂在了window上
-
缓存
有时候为了方便数据的快捷复用,我们通常会用对象键值对来缓存数据,但这种缓存通常没有过期策略,也没有大小限制,需要小心缓存超过 v8 的内存限制。
-
闭包
如果一个函数返回了一个具有父级函数作用域的中间函数,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时原始的作用域也不会释放,除非不再引用,才会逐步释放。
避免内存泄漏
- 少用全局变量,避免意外产生全局变量
- 使用闭包时注意及时清理
- 使用
WeakSet
和WeakMap
弱引用结构,它们对于值的引用都是不计入垃圾回收机制的