深入浅出浏览器工作原理

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(主代码块)、setTimeoutsetInterval 、I/O 、UI rendering
      • 在nodejs中还有 setImmediate 也是宏任务
    • 微任务
      • PromiseObject.observeMutationObserver
      • 在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转为pxred转为#ff0000bold转为700等等
    • 使用继承和层叠规则计算每个节点的最终样式,生成CSSOM
      • 在计算完样式之后,所有的样式会被挂载到window.getComputedStyle当中,也就是说可以通过js来获取计算后的样式
  • 生成布局树
    • 遍历DOM树上的节点,并按照规则把它们添加到布局树中
      • 布局树只包含可见元素,比如head标签和设置了display: none的元素,将不会被放入其中
    • 确定布局树中节点的坐标位置
    • 布局树生成完毕之后触发DOMContentLoaded事件

渲染

  • 分层,构建图层树
    • 对于一些复杂的3D变换,或者使用z-index做z轴排序等,为了方便的实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一颗对应的图层树(LayerTree)
    • 接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,生成绘制列表
  • 分块和栅格化
    • 在渲染进程中绘制操作是由合成线程来完成的,合成线程要做的第一件事情就是将图层分块,它会优先将视口附近的图块生成位图,将图块转换为位图的过程就是栅格化
  • 绘制
    • 栅格化操作完成后,合成线程会生成一个绘制命令,并发送给浏览器进程
    • 浏览器进程根据这个命令,把页面内容生成到内存,然后把这部分内存发送给显卡,最后渲染到显示器上
    • 显示器上的画面有固定的刷新频率,一般是60HZ,即60帧,也就是一秒更新60张图片,一张图片停留的时间约为16.7ms

注意

  • js的加载会阻塞DOM解析,在它加载并且执行完之前,不会往下解析 DOM 树。因为js可以操作dom,这两者不能并行
  • css的加载不会阻塞DOM树的解析,但会阻塞DOM树的渲染,因为需要DOM树和CSSOM树共同合成render树

重绘和回流

重绘和回流是由渲染引擎操作的,渲染引擎和JS引擎是互斥的,不可同时进行,因此在重绘和回流时,JS引擎是无法工作的,我们应该尽量避免无意义的重绘和回流,减小浏览器资源浪费。

重绘(repaint)

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

回流(reflow)

Render Tree中元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。 回流比重绘的代价要更高。有时即使仅仅一个元素的尺寸或位置改变,它的父元素以及任何跟它相关的元素也会产生回流,牵一发动全身。

何时触发

  • 添加/删除dom元素
  • 元素的位置、尺寸发生变化时
  • 查询某些属性或调用某些方法时
    • clientWidthclientHeightclientTopclientLeft
    • offsetWidthoffsetHeightoffsetTopoffsetLeft
    • scrollWidthscrollHeightscrollTopscrollLeft
    • getComputedStyle()
    • getBoundingClientRect()
    • scrollTo()

如何避免

  • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次更改class属性
  • 避免频繁操作dom
    • 可以创建一个documentFragment,在它上面应用所有的dom操作,最后再把它添加到文档中
    • 也可以先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的dom操作不会引发回流和重绘
  • 避免频繁读取会引发回流的属性,如果确实需要多次使用,可以用一个变量缓存起来
  • 对具有复杂动画的元素使用绝对定位,使其脱离文档流,否则会引起父元素及后续元素频繁回流

内存管理及垃圾回收

V8垃圾回收机制

不同浏览器的垃圾回收机制有所不同,大部分浏览器采用的都是标记清除或者引用计数。

比如在chrome中,将堆内存分为了新生代和老生代,新生代中存放数量小存活时间短的对象,老生代中存放数量多存活时间长的对象。刚开始所有对象都在新生代,当达到一定条件会被晋升到老生代中,新生代和老生代采用的是不同的垃圾回收算法。

  • 新生代中采用scavenge算法,它将空间分成两半,每次只用一半,然后把其中活着的对象复制到另一半中,然后将这两个空间角色对换,当一个对象被复制两次以上还依然存活时,就会被晋升到老生代中。
  • 老生代中主要使用标记清除,它分为标记和清除两个阶段,在标记阶段遍历调用栈,在遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。然后在遍历过程中标记,标记完成后将垃圾数据进行清理。

内存泄露

不再用到的内存,没有及时释放,就是内存泄漏

常见原因

  • 意外的全局变量

    比如在函数中声明变量时没有写 let/const/var 关键字,这样等于声明了一个全局变量,在函数调用完之后不会被回收

    或者在函数中给this上挂载属性,当函数在全局作用域被调用时,属性就挂在了window上

  • 缓存

    有时候为了方便数据的快捷复用,我们通常会用对象键值对来缓存数据,但这种缓存通常没有过期策略,也没有大小限制,需要小心缓存超过 v8 的内存限制。

  • 闭包

    如果一个函数返回了一个具有父级函数作用域的中间函数,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时原始的作用域也不会释放,除非不再引用,才会逐步释放。

避免内存泄漏

  • 少用全局变量,避免意外产生全局变量
  • 使用闭包时注意及时清理
  • 使用 WeakSetWeakMap 弱引用结构,它们对于值的引用都是不计入垃圾回收机制的
相关推荐
乐多_L34 分钟前
使用vue3框架vue-next-admin导出表格excel(带图片)
前端·javascript·vue.js
南望无一1 小时前
React Native 0.70.x如何从本地安卓源码(ReactAndroid)构建
前端·react native
Mike_188702783511 小时前
1688代采下单API接口使用指南:实现商品采集与自动化下单
前端·python·自动化
鲨鱼辣椒️面1 小时前
HTML视口动画
前端·html
一小路一1 小时前
Go Web 开发基础:从入门到实战
服务器·前端·后端·面试·golang
堇舟1 小时前
HTML第一节
前端·html
纯粹要努力1 小时前
前端跨域问题及解决方案
前端·javascript·面试
小刘不知道叫啥1 小时前
React源码揭秘 | 启动入口
前端·react.js·前端框架
kidding7231 小时前
uniapp引入uview组件库(可以引用多个组件)
前端·前端框架·uni-app·uview
合法的咸鱼1 小时前
uniapp 使用unplugin-auto-import 后, vue文件报红问题
前端·vue.js·uni-app