2020-01-11
53 min read

浏览器相关

进程和线程

进程是 CPU 资源分配的最小单位 线程是 CPU 资源调度的最小单位

进程和线程之间的关系如下

  • 一个进程可以创建多个线程,这些线程共享同一个地址空间和资源,能够并发执行任务
  • 线程在进程内部创建和销毁的,他们与进程共享进程的上下文,包括打开的文件,全局变量和堆内存等
  • 每个进程至少包含一个主线程,主线程用于执行进程的主要业务逻辑,其它线程可以作为辅助线程来完成特定任务

多进程:在同一个时间里,同一个计算机系统中允许两个或两个以上的进程处于运行状态 多线程:程序包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,就是说允许单个程序创建多个并行执行的线程来完成各自的任务

  • 堆内存
    • js 中对象、数组、函数等复杂数据类型都存储在堆内存中
    • 使用 new 关键字或对象字面量语法创建对象时,会在堆内存中分配相应的内存空间
    • 堆内存的释放由垃圾回收机制自动处理,当一个对象不在被引用时,垃圾回收机制会自动回收器占用的内存,释放资源
  • 栈内存
    • js 中的基本数据类型,数字、布尔值、字符串以及函数的局部变量保存在栈内存中
    • 栈内存的分配时静态的,编译器在编译阶段就确定了变量的内存空间大小
    • 当函数被调用时,会在栈内存中创建一个称为栈帧 stack frame 的数据结构,用于存储函数的参数、局部变量、返回地址等信息
    • 当函数执行完毕或从函数中返回时,对应的栈帧会被销毁,栈内存中的数据也随之释放

浏览器架构

多进程架构

  • 浏览器主进程(Browser):负责浏览器的整体运行,显示和交互;负责各页面的管理,创建和销毁;网络的资源管理、下载等
  • GPU进程:最多一个,用于3D绘制
  • 渲染进程:默认每个Tab页面一个进程,互不影响,负责渲染网页,执行 JavaScript 代码,处理 HTML、CSS 等
  • 网络进程:负责处理网络请求,如发送HTTP请求、接收响应等。与渲染进程分离,以提高网络性能和安全性
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建

多进程的优势

  • 避免单个页面崩溃影响整个浏览器
  • 避免第三方插件崩溃影响整个浏览器
  • 多进程充分利用多核 CPU 优势
  • 方便使用沙箱隔离插件等进程,提高浏览器稳定性和安全性

渲染进程

  • GUI渲染线程
    • 负责渲染浏览器页面,解析HTML、CSS,构建DOM树和Render树,布局和绘制等
    • 当界面需要重绘(Repaint)或由于某种操作引发重排(Reflow),该线程就会执行相应的渲染工作
    • 注意:GUI线程和JS线程是互斥的,当JS线程执行时,GUI线程会挂起,GUI更新会被保存在一个队列中等待JS线程空闲时立即执行
  • JS引擎线程
    • JS引擎,也称JS内核,负责处理JS脚本
    • JS引擎线程负责解析JS脚本,运行代码
    • JS引擎一直等待任务队列中任务的到来,然后处理,一个Tab页中无论什么时候只会有一个js线程
    • 注意:由于和GUI线程互斥,因此如果一个js任务执行事时间太长,就会导致页面渲染不连贯,卡顿
  • 事件触发线程
    • 归属于浏览器而不是JS引擎,用于控制事件循环
    • 当JS引擎执行代码块如setTimeout时(也可来自浏览器内核的其它线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
    • 由于JS单线程的缘故,这些待处理队列的事件都得排队等待JS引擎处理
  • 定时器触发线程
    • setInterval与setTimeout所在线程
    • 浏览器定时计数器并不是由JS引擎计数的(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确),通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎执行)
  • 异步HTTP请求线程
    • 在XMLHttpRequest连接后通过浏览器开一个线程请求
    • 当检测到状态变更时,如果设置有回调函数,异步线程就会产生状态变更事件,将这个回调函数添加到任务队列中,等待 JS 引擎执行

浏览器组成

  • 用户界面 地址栏、前进后退、书签等
  • 浏览器引擎 在用户界面和渲染引擎之间传递指令
  • 渲染引擎 负责显示请求的内容
  • 网络 用于网络调用,如HTTP请求
  • JavaScript 解释器 解释和执行JavaScript代码
  • 数据存储 这是一个层,用于记录所有类型的数据
  • 用户界面后端 用于绘制基本的窗口小部件,如下拉框和窗口

浏览器安全(CSRF、XSS)

XSS(Cross-Site Scripting) 跨站脚本攻击

XSS是一种常见的Web安全漏洞。他允许开发者将恶意代码注入到受害者访问的网页中,从而在用户的浏览器中执行这些代码,XSS攻击可以窃取用户的敏感信息,如 Cookie、会话令牌 等;或者执行其他恶意操作,如重定向到钓鱼网站、修改页面内容等

原理:利用网站漏洞,将恶意的js代码注入到网页中,当受害者访问被注入恶意代码的网页时,浏览器会执行这些恶意代码(恶意代码可以访问和操作受害者的浏览器,窃取敏感信息)

  • 反射型攻击
    • 攻击者将恶意代码作为请求的一部分发送给服务器
    • 服务器将恶意代码未经过滤地嵌入到响应中返回给用户。当用户的浏览器接收到响应并渲染页面时,恶意代码被执行。
    • 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
  • 存储型攻击
    • 攻击者将恶意代码提交到服务器,并存储在服务器端的数据库或文件中
    • 用户打开目标网站,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器
    • 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行
    • 恶意代码窃取用户数据并发送至攻击者网站或服务器
  • 基于 dom 的 xss 攻击
    • 攻击者利用客户端JavaScript代码中的漏洞来执行恶意代码
    • 恶意代码通过修改页面的DOM(文档对象模型)来实现攻击
    • DOM型XSS不涉及服务器端,完全在客户端浏览器中发生
    • 攻击者通过在 URL 插入恶意代码,客户端脚本取出 URL 中的恶意代码并执行

xss 防范措施

  • 输入验证和过滤
  • 预防存储型和反射型 xss 攻击
    • 改成纯前端渲染,把代码和数据分隔开
    • 对 HTML 做充分转义
  • 预防 dom 的 xss 攻击
  • Content Security Policy(内容安全策略)
    • 严格的 CSP 在 XSS 的防范可以起到下面几个作用
    • 禁止加载外域代码,防止复杂的攻击逻辑
    • 禁止外域提交,网站被攻击后,用户的数据不会泄露到外域
    • 禁止内联脚本执行
    • 禁止未授权的脚本执行
    • 合理使用上报可以及时发现 XSS,利于尽快修复问题
  • HTTP-only Cookie
    • 禁止 js 读取某些敏感 cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie

CSRF(Cross-Site request forgery) 跨站请求伪造

攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击网站执行某项操作的目的

  • get 类型的 csrf
    • get 类型的 csrf 非常简单,只需要一个 http 请求
  • post 类型的 csrf
    • 这种类型的 csrf 利用起来通常使用的是一个自动提交的表单
  • 链接类型的 csrf
    • 链接类型的 CSRF 并不常见,比起其他两种用户打开页面就中招的情况,这种需要用户点击链接才会触发。这种类型通常是在论坛中发布的图片中嵌入恶意链接,或者以广告的形式诱导用户中招,攻击者通常会以比较夸张的词语诱骗用户点击

csrf 防范措施

  • 同源检测
    • 利用 http 请求中的 Origin Header 和 Referer Header,服务器可以通过解析这两个 header 中的域名,来确认请求的来源域
  • CSRF token
    • 服务器给用户生成一个 Token,这个 Token 通过加密算法进行过加密
    • 客户端页面提交请求时,把 token 加入到请求数据或者头信息中,一起传给后端
    • 后端验证前端传来的 token 和 session 是否一致
  • 给 Cookie 设置合适的 SameSite,限制Cookie的跨站访问

其它

  • 点击劫持
  • HTTP 严格传输安全
  • CDN 劫持
  • 内容安全策略

浏览器缓存策略

浏览器在加载资源时,会先根据这个资源的 header 判断它是否命中缓存,强缓存如果命中,浏览器直接在自己的缓存中读取资源,不会发请求到服务器 当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些 header 字段验证是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回,但是不会返回这个请求的资源数据,而是告诉客户端可以直接从缓存中加载这个资源 强缓存和协商缓存的共同点是:如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源数据;区别是:强缓存不发请求到服务器,协商缓存会发请求到服务器 当协商缓存也没命中时,浏览器直接从服务器拉取资源

强缓存

当浏览器对某个资源的请求命中了强缓存时,返回的 HTTP 状态码为 200,在谷歌浏览器的开发者工具的 network 里面 size 会显示 disk cache 或 memory cache

  • Cache-Control 的 max-age 的优先级高于 Expires,以秒为单位,是一个相对时间
    • 第一次请求某个资源,服务器返回这个资源同时,在 response 的 header 加上了 Cache-control 的 header,浏览器接收到这个资源后,会把这个资源连同 header 缓存下
    • 浏览器再次请求这个资源,先从缓存中寻找,找到这个资源后,根据它第一次的请求时间和 Cache-control 设定的有效期,计算出一个资源过期时间,再拿这个过期时间和当前请求时间比较,如果请求时间再过期时间之前,就命中缓存
    • 属性值
      • max-age 过期时长
      • public 客户端和代理服务器都可以缓存
      • private 只有浏览器能缓存了,中间的代理服务器不能缓存
      • no-cache 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段
      • no-store 非常粗暴,不进行任何形式的缓存
  • Expires 是 http1.0 提出的一个表示资源过期时间的 header,值是一个绝对时间,由服务器返回
    • 缺点:由于它是服务器返回的一个绝对时间,在服务器时间和客户端时间相差较大时,缓存会出问题,还有就是客户端可以随意更改时间

协商缓存

强缓存失效之后,浏览器在请求头中携带相应的 缓存tag 来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存

  • Last-Modified最后修改时间 Last-Modified(服务端响应携带) & If-Modified-Since (客户端请求携带) ,其优先级低于 Etag
    • 浏览器第一次跟服务器请求一个资源,服务器返回资源的同时,会在 response 的 header 加上 Last-Modified,表示资源在服务器上的最后修改时间
    • 浏览器再次请求这个资源,在 request header 上加上 If-Modified-Since 的 header,这个 header 就是上一次请求时返回的 Last-Modified
    • 服务器再次受到请求,根据浏览器传过来的 If-Modified-Since 和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回 304 Not Modified
    • 缺点:有时候服务器上资源有变化,但是最后修改时间却没有变化,就会影响缓存安全性
  • ETag Etag(服务端响应携带) & If-None-Match(客户端请求携带)
    • 这个唯一标识是一个字符串,只要资源有变化这个值就不同

服务端判断值是否一致,如果一致,则直接返回 304 通知浏览器使用本地缓存,如果不一致则返回新的资源

两者对比

  • 精度上:Etag优于Last-Modified,Last-Modified的感知单位是秒,Etag是按照内容给资源生成的唯一标识,能准确感知资源的变化
  • 性能上:Last-Modified优于Etag,Last-Modified仅仅只是记录一个时间点,而 Etag需要根据文件的具体内容生成哈希值

image

缓存位置

  • Service Worker
    • 借鉴webworker思路,独立于浏览器窗口,因此无法直接访问DOM
    • 离线缓存 Service Worker 可以拦截网络请求并决定如何响应。这使得开发者可以在用户离线时提供自定义的离线页面,或者从缓存中提供之前存储的资源
    • 消息推送 即使在应用未运行的情况下,Service Worker 也可以接收来自服务器的推送消息,并显示通知给用户
    • 网络代理 Service Worker 可以拦截网络请求并根据需要修改请求和响应,这使得开发者可以实现例如请求重试、请求重定向等复杂的网络功能
  • Memory Cache
    • 内存缓存,从效率上来讲它是最快的,但存活时间又是最短的 当渲染进程结束后,内存缓存也就不存在了
  • Disk Cache
    • 存储在磁盘中的缓存,存取效率上来讲比内存缓存慢,但是存储容量大&存活时间久
  • Push Cache
    • 推送缓存,它是HTTP/2的特性
    • 在 HTTP/2 中,服务器可以在客户端需要之前就主动将资源推送到客户端的缓存中,这样当客户端需要这些资源时就可以直接从缓存中获取,而不需要再向服务器发送请求

浏览器存储

http协议是一个无状态协议,客户端向服务器发送请求,服务器返回响应,如何让服务端知道客户端是谁,如何跟踪用户状态?这种背景下,产生了Cookie

Cookie本质上是一个文本文件(内部以键值对的方式来存储),保存在客户端的硬盘上,当浏览器访问服务器时,会将Cookie信息自动发送给服务器。

  • 内容缺陷 体积上限只有4kb,只能用来存储少量数据
  • 性能缺陷 Cookie紧跟域名,不管域名下的某一个地址需不需要这个Cookie,请求都会携带上完整的Cookie,造成性能浪费
  • 安全缺陷 由于Cookie以纯文本方式在浏览器和服务器之间传递,很容易被非法用户截取,然后进行一系列篡改,这是非常危险的

生存周期

  • Expires 过期时间
  • Max-Age 用的是一点时间间隔,单位是s,从浏览器接收到报文开始计算

作用域(给 Cookie 绑定了域名和路径,在发送请求之前,发现域名或者路径和这两个属性不匹配,那么就不会带上 Cookie)

  • Domain 域名
  • Path 路径(/表示域名下的任意路径都允许使用 Cookie)

SameSite

  • Strict:浏览器完全禁止第三方请求携带Cookie
  • Lax:只能在get方法提交表单或者a标签发送get请求的情况下带Cookie
  • None:默认模式,请求会自动携带Cookie

localStorage和sessionStorage

localStorage

  • 容量:上限为5MB,针对同域名,对于一个域名来说是持久存储的
  • 只存储在客户端,默认不参与服务端的通讯
  • 接口封装,操作方便

sessionStorage

  • 容量。容量上限也为 5M
  • 只存在客户端,默认不参与与服务端的通信。
  • sessionStorage和localStorage有一个本质的区别,那就是前者只是会话级别的存储,并不是持久化存储。会话结束,也就是页面关闭,这部分sessionStorage就不复存在了

IndexedDB

IndexedDB 是 HTML5 中新增的数据库,它允许在浏览器端存储大量结构化数据。

  • 键值对存储 使用键值对来存储数据,每个键值对被存储在一个对象存储空间
  • 异步操作 不会阻塞浏览器主线程
  • 大数据存储
  • 支持事务 意味着你可以在一个操作失败时回滚其它操作,保证数据一致性
  • 二进制数据存储 支持存储二进制数据,如 Blob 对象或 TypedArray 对象
  • 受同源策略限制

浏览器垃圾回收

在 js 内存管理中有一个概念叫做可达性,就是那些以某种方式可访问或者说可用的值,他们被保证存储在内存中,反之不可访问则需回收

垃圾回收策略

  • 标记清除 (Mark-sweep)
    • 此算法分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则是把没有标记(也就是非活对象)销毁
    • 过程
      • 垃圾收集器在运行时会给内存中的所有变量加上一个标记,假设内存中所有对象都是垃圾,全部标记为 0
      • 然后从各个根对象开始遍历,把不是垃圾的节点改成 1
      • 清理所有标记为 0 的垃圾,销毁并回收他们所占用的内存空间
      • 最后,把所有内存中对象标记修改为 0,等待下一轮垃圾回收
    • 优点:实现简单
    • 缺点:清除之后,剩余的对象内存位置是不变的,会导致空闲内存空间是不连续的,出现内存碎片,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,就会造成内存分配问题
      • 分配速度慢 即使是 first-fit 策略,其操作仍是一个 O(n)的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会变慢
      • 内存碎片化:空闲内存块是不连续的,容易出现很多空闲内存块,还可能出现所分配的内存较大找不到合适的块
    • 标记整理算法(Mark-Compact)算法可以有效解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象向内存的一端移动,最后清理掉边界的内存
  • 引用计数 (Reference Counting)
    • 它把 对象是否不再需要 简化定义为 对象有没有被其它对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收
    • 问题: 循环引用,引用计数的计数器需要占很大的位置

v8 中的垃圾回收

分代式垃圾回收:V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两个区域,采用不同的垃圾回收器也就是不同的策略来进行垃圾回收

  • 新生代
    • 新生代对象为存活时间较短的对象,简单来说就是新产生的对象(通常只支持 1-8mb 容量)
    • 新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge 算法主要采用了一种复制式的方法(Cheney)
      • Cheney 算法将堆内存一分为二,一个是处于使用状态的空间 使用区,一个是处于闲置状态的空间 空闲区
      • 新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作
      • 当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变为空闲区,原来的空闲区变为使用区
      • 当一个对象经过多次复制还存活,它将被认为时生命周期较长的对象,随后会被移动到老生代中
      • 另外一种情况:如果复制一个对象到空闲区,空闲区的空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中(设置为 25%的原因是:当完成 Scavenge 回收后,空闲区翻转成使用区,继续进行对象的内存分配,若占比过大,会影响后续内存分配)
  • 老生代
    • 老生代对象为存活时间较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活的对象(容量通常比较大)
    • 老生代流程采取的是 标记清除算法(标记整理算法解决连续内存问题)

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间久的对象作为老生代,使其很少接受检查,老生代的回收机制及频率是不同的,此机制的出现很大程度提高了垃圾回收机制的频率

垃圾回收其它

增量标记与惰性清理

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,交替多次后完成一轮 GC 标记

缺点:首先是并没有减少主线程的总暂停的时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量

  • 三色标记法(暂停与恢复)
    • 白色:未被标记对象
    • 灰色:自身被标记,成员变量(该对象的引用对象)未被标记
    • 黑色:自身和成员变量皆被标记
  • 写屏障(增量中修改引用)
    • 一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称为强三色不变性

惰性清理

增量标记只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理

  • 老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
  • 标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
  • 同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行

参考

v8 垃圾回收总结

将内存中不再使用的数据进行清理,释放出内存空间。V8 将内存分成 新生代空间 和 老生代空间。

  • 新生代空间: 用于存活较短的对象
    • 又分成两个空间: from 空间 与 to 空间
    • Scavenge GC 算法: 当 from 空间被占满时,启动 GC 算法
      • 存活的对象从 from space 转移到 to space
      • 清空 from space
      • from space 与 to space 互换
      • 完成一次新生代 GC
  • 老生代空间: 用于存活时间较长的对象
    • 从 新生代空间 转移到 老生代空间 的条件
      • 经历过一次以上 Scavenge GC 的对象
      • 当 to space 体积超过 25%
    • 标记清除算法: 标记存活的对象,未被标记的则被释放
      • 增量标记: 小模块标记,在代码执行间隙执,GC 会影响性能
      • 并发标记(最新技术): 不阻塞 js 执行
    • 压缩算法: 将内存中清除后导致的碎片化对象往内存堆的一端移动,解决 内存的碎片化

内存泄漏

  • 意外的全局变量, 无法被回收
  • 被遗忘的计时器或回调函数
  • 事件监听: 没有正确销毁 (低版本浏览器可能出现)
  • 闭包,会导致父级中的变量无法被释放
  • 没有清理的 DOM 元素引用
// 意外的全局变量
function foo() {
  bar = "this is a hidden global variable"; // bar没被声明,会变成一个全局变量,在页面关闭之前不会被释放
}
function foo1() {
  this.variable = "potential accidental global"; // this 指向了全局对象(window)
}
foo();
foo1();
// 被遗忘的计时器或回调函数
var someResource = getData();
setInterval(function() {
  var node = document.getElementById("Node");
  if (node) {
    node.innerHTML = JSON.stringify(someResource);
  }
}, 1000);
// 如果id为Node的元素从DOM中移除,该定时器仍会存在,同时,因为回调函数中包含对someResource的引用,定时器外面的someResource也不会被释放

如何避免

  • 减少不必要的全局变量,或者生命周期较长的对象
  • 注意程序逻辑,避免死循环
  • 避免创建过多的对象

从输入URL到页面呈现发生了什么 (网络)

网络请求

  • 构建请求:浏览器会构建请求行
  • 查找强缓存:先检查强缓存,如果命中直接使用,否则进入下一步
  • DNS解析
    • 由于我们输入的是域名,而数据包是通过IP地址传给对方的,因此我们需要知道域名对应的IP地址,这个过程需要依赖一个服务系统,这个系统将域名和IP一一映射,也就是所称的DNS(域名系统)
    • 如果浏览器或操作系统缓存了这个域名的解析结果,就直接使用这个结果,否则向DNS服务器发送请求解析域名
    • 如果不指定端口的话,默认采用对应的80端口
  • 建立TCP连接 (Transmission Control Protocol,传输控制协议)
    • 通过三次握手(即总共发送3个数据包确认已经建立连接)建立客户端和服务器之间的连接
    • 进行数据传输:这里有一个重要机制,接收方接收到数据包后必须要向发送方确认,如果发送方没有收到这个确认消息,就判定为数据包失败,并重新发送该数据包。优化策略:大数据包拆成一个个小包,依次传输到接收方,接收方按照小包顺序组装成完整的数据包
    • 断开连接阶段:数据传输完成,通过4次挥手来断开连接
  • 发送HTTP请求
    • TCP连接建立完毕,浏览器可以和服务器通信,即开始发送HTTP请求,浏览器发送HTTP请求要携带3样东西:请求行、请求头、请求体
  • 网络响应
    • HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是返回网络响应;跟请求部分类似,网络响应具有三个部分:响应行、响应头和响应体

从输入URL到页面呈现发生了什么 (渲染)

render

构建DOM树

处理HTML标记并构建DOM树

字节(Bytes) -> 字符(Characters) -> 令牌(tokens) -> 节点(Nodes) -> 对象模型(DOM)

过程

  • 浏览器从磁盘或网络读取HTML原始字节,并根据文件的指定编码(比如utf-8)将他们转换为字符
  • 生成令牌:浏览器将字符串转换成标准规定的各种令牌
  • 词法分析:将令牌转换成DOM节点
  • 构建DOM:浏览器会将这些节点按照HTML标签的嵌套关系,组织成一棵DOM树

DOM 构建是增量的。HTML 响应变成令牌(token),令牌变成节点,而节点又变成 DOM 树。单个 DOM 节点以 startTag 令牌开始,以 endTag 令牌结束。节点包含有关 HTML 元素的所有相关信息。该信息是使用令牌描述的。节点根据令牌层次结构连接到 DOM 树中。如果另一组 startTag 和 endTag 令牌位于一组 startTag 和 endTag 之间,则你在节点内有一个节点,这就是我们定义 DOM 树层次结构的方式

样式计算

DOM 构造是增量的,CSSOM 却不是。CSS 是渲染阻塞的:浏览器会阻塞页面渲染直到它接收和执行了所有的 CSS

  • 格式化样式表
    • 将字节数据转化为浏览器可以理解的结构 stylesheet
    • 浏览器控制台能够通过document.styleSheets来查看这个最终的结构
  • 标准化样式表
    • 将一些浏览器不理解的数值转化为标准数值(比如 em->px, bold-> 700)
  • 计算每个DOM节点的具体样式
    • 继承:每个子节点默认去继承父节点样式,如果父节点找不到,就会采用浏览器默认样式也叫UserAgent样式
    • 层叠:样式层叠,是CSS的一个基本特征,它定义如何合并来自多个源的属性值的算法

布局阶段

  • 创建布局树(Layout Tree)
    • 在DOM树上不可见的元素(head、meta等),以及使用display: none的元素,最后都不会出现在布局树上,所以浏览器布局系统需要额外构建一颗只包含可见元素的布局树
  • 布局计算
    • 遍历生成的DOM树节点,并把他们添加到布局树中
    • 计算布局树节点的位置信息

渲染树包括了内容和样式:DOM 和 CSSOM 树结合为渲染树。为了构造渲染树,浏览器检查每个节点,从 DOM 树的根节点开始,并且决定哪些 CSS 规则被添加

分层

  • 生成图层树(Layer Tree)
    • 一般情况下,节点的图层会默认属于父节点的图层(这些图层也被称为合成树)
    • 有时候会提升为一个单独的合成层:一种是显式合成,一种是隐式合成
    • 显示合成
      • 拥有层叠上下文的节点
      • 需要剪裁(clip)的地方(比如文字超出容器,超出的部分就需要被裁剪。如果出现了滚动条,那么滚动条也会单独提升为一个图层)
    • 隐式合成
      • 简单来说就是层叠等级低的节点被提升为单独的图层之后,那么所有层叠等级比它高的节点都会成为一个单独的图层
      • 隐式合成如果一个大型项目,z-index比较低的元素被提升为单独图层之后,层叠在它上面的元素统统都会被提升为单独的图层
  • 拥有层叠上下文属性的元素会被提升为单独一层
  • 需要裁剪的地方也会创建图层
  • 图层绘制

绘制

把一个复杂的图层拆分为很小的绘制指令,然后再按照这些指令的顺序组成一个绘制列表(比如先画背景、再描绘边框......然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一波计划)

分块

绘制列表准备好之后,渲染进程的主进程会给 合成线程 发送 commit 消息,把绘制列表提交给合成线程

合成线程首先要做的就是将图层分块,这些块的大小一般不会特别大,通常是256*256512*512,这样可以大大加速首页的首屏展示

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段

光栅化

有了图块之后,合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图

  • 图块是栅格化执行的最小单位
  • 渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据
  • 合成线程会选择视口附近的图块(tile),把它交给栅格化线程池生成位图
  • 生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程

合成阶段

栅格化操作完成之后,合成线程会生成一个绘制命令,即 DrawQuad,并发送给浏览器进程

浏览器进程的 viz组件 接收到这个命令时,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡

每次更新的图片都来自显卡的前缓冲区,而显卡接收到浏览器进程传来的页面数据后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新

渲染流程相关问题

为什么js是单线程的

因为js会处理页面中用户的交互、DOM操作、CSS样式的操作,如果是多线程方式,那么可能会出现某一个线程删除了一个DOM,而另一个线程还在修改这个DOM,那么就会出现冲突

为什么 JS 阻塞页面加载(GUI渲染线程与JS引擎线程互斥原因)

由于js是可操纵DOM的,如果在修改这些元素的同时渲染页面(即JS线程和GUI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了;因此为了防止渲染出现不可预期的结果,浏览器设置GUI线程和JS线程为互斥的关系,当JS引擎执行时GUI会挂起;所以要尽量避免JS执行时间过长。

渲染过程中遇到 JS 文件如何处理

js的加载、解析和执行会阻塞文档的解析,也就是说,在构建dom时,html解析器遇到js,那么它会暂停文档的解析,将控制权交给js引擎,等js引擎运行完毕,浏览器再从中断的地方恢复继续解析文档。也就是说,想要首屏渲染的越快,就应该把js文件放在body表签底部,也可以给script标签添加defer或async属性

文档预解析

文档预解析是浏览器的一种特性,可以使浏览器预先获取用户可能需要的数据,从而提高网页加载速度和性能;预解析虽然可以提高性能,但也会增加服务器的负载和用户的带宽

当执行js脚本时,另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源。这种方式可以使资源并行加载从而使整体速度更快;需要注意的是:预解析并不改变DOM树,它将这个工作留给主解析过程,自己只解析外部资源的引用,比如外部脚本、样式和图片

CSS如何阻塞文档解析

在HTML文档中遇到 <link rel="stylesheet" href="style.css"> 这样的标签时,它会暂停HTML文档的解析,等待CSS文件加载完成并解析,然后再继续解析HTML文档

css加载是否会阻塞dom树渲染

  • css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
  • 但会阻塞render树的渲染(渲染时需要等css加载完毕,因为render树需要css信息)

这样做的原因可能是:加载css的时候,如果css加载不阻塞render树渲染时,可能会修改下面DOM节点的样式,那么当css加载完成后,渲染线程前后获得的元素数据就可能不一致了,render树又要重新去渲染

这是因为 CSS 可能会影响后续 HTML 元素的样式和布局,所以浏览器需要先获取并解析 CSS,以确保正确地应用样式

阻塞 JavaScript 执行: CSS 的加载和解析也会阻塞后续 JavaScript 代码的执行。当浏览器遇到一个 <script> 标签时,如果之前还有未加载完成的 CSS 文件,浏览器会等待 CSS 加载完成后再执行 JavaScript 代码。这是因为 JavaScript 可能会查询和操作 DOM 元素的样式,所以需要确保 CSS 已经加载并应用完毕

如何优化关键渲染路径(Critical Rendering Path)

关键渲染路径是浏览器将HTML,CSS,JS转换为屏幕上呈现的像素的过程。优化关键渲染路径意味着使页面尽快呈现,这对性能至关重要

  • 最小化关键资源
  • 优化关键资源的加载顺序
  • 异步加载非关键资源
  • 优化服务器响应时间: 使用CDN,减少服务器响应时间
  • 利用浏览器缓存: 通过设置HTTP缓存头,使浏览器缓存关键资源
  • 减少重绘和重排: 避免频繁的DOM操作,减少重绘和重排
  • 使用Web字体的优化策略: Web字体可能会阻塞文本的渲染,可以使用font-display属性或者使用系统字体作为备选
  • 代码分割: 将代码分割成多个小的、按需加载的包,可以减少首次加载的时间
  • 使用预加载和预渲染 预加载可以提前加载关键资源,预渲染可以提前渲染页面

什么情况下会阻塞渲染

什么是渲染层合并(Composite)

在浏览器的渲染过程中,渲染层合并是最后一个步骤,这个阶段,浏览器会将所有的层(Layer)按照正确的顺序合并在一起,然后显示在屏幕上

有一些CSS属性,如transform和opacity,会触发Composite阶段,而不会触发Layout和Paint阶段,这样可以提高渲染性能。因为Composite阶段只涉及到图层的移动和合并,而不涉及布局和绘制,所以性能开销较小