# 浏览器与原理

# 浏览器缓存机制

# 强缓存

HTTP/1.0 中,使用 Expires 指定过期时间,原理是使用客户端的时间与服务端返回的时间做对比。

HTTP/1.1 中,Expires 被 Cache-Control 代替。

Cache-Control

  • public:所有内容都将被缓存(客户端和代理服务器都可缓存)

  • private:所有内容只有客户端可以缓存,Cache-Control的默认取值

  • no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定

  • no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存

  • max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效

# 协商缓存

命中协商缓存的条件有两个

  • max-age=xxx 缓存过期
  • Cache-Control: no-cache

HTTP 1.0 使用 Last-Modified 和 If-Modified-Since

  • Last-Modified:资源的最后修改时间。
  • If-Modified-Since:客户端在请求中带上 Last-Modified 时间,服务器根据该时间判断资源是否已更改。

HTTP 1.1 新增 Etag 判断协商缓存

ETag 是服务器根据当前文件的内容,给文件生成的唯一标识(文件hash),只要里面的内容有改动,这个值就会变。服务器通过响应头把这个值给浏览器。

浏览器接收到 ETag 的值,会在下次请求时,将这个值作为 If-None-Match 这个字段的内容,并放到请求头中,然后发给服务器。

服务器接收到 If-None-Match 后,会跟服务器上该资源的 ETag 进行比对:

  • 如果两者不一样,说明要更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
  • 否则返回304,告诉浏览器直接用缓存。

img

协商缓存流程

  1. 首次请求
    • 客户端向服务器请求资源。
    • 服务器返回资源,并在响应头中包含 ETagLast-Modified
    • 客户端将资源存储在缓存中,并记录 ETagLast-Modified
  2. 后续请求
    • 客户端再次请求同一资源。
    • 客户端在请求头中包含 If-None-Match(如果使用 ETag)或 If-Modified-Since(如果使用 Last-Modified)。
    • 服务器检查请求头中的 If-None-MatchIf-Modified-Since,并与当前资源的 ETag 或 Last-Modified 进行比较。
    • 如果资源未更改,服务器返回 304 Not Modified 状态码,客户端使用缓存中的资源。
    • 如果资源已更改,服务器返回 200 OK 状态码和新的资源,客户端更新缓存。

# 缓存位置

前面我们已经提到,当强缓存命中或者协商缓存中服务器返回304的时候,我们直接从缓存中获取资源。

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  • Service Worker(类似Web Worker)
  • Memory Cache(内存)
  • Disk Cache(本地磁盘)
  • Push Cache

# 浏览器存储

存储方式 存储大小 生命周期 可访问性 用途 特点
Cookies 4KB 会话或持久 所有页面 会话管理、个性化设置 随HTTP请求发送,安全性较低,适合小数据量
LocalStorage 5MB 永久 同源的所有页面 保存用户偏好、应用状态 不随HTTP请求发送,适合大量数据存储
SessionStorage 5MB 页面会话(关闭标签页后清除) 同源的同一会话页面 临时数据存储,如表单数据 数据仅在当前会话中有效,关闭标签页后清除
IndexedDB 无限制(取决于磁盘空间) 永久 同源的所有页面 复杂数据存储,如离线应用、数据库 支持结构化查询,适合大量复杂数据存储
Web SQL Database 5MB 永久 同源的所有页面 复杂数据存储,如离线应用、数据库 已废弃,不再推荐使用,部分浏览器仍支持

# 输入 URL 后发生了什么

(1)解析URL: 首先会对 URL 进行解析,分析所需要使用的传输协议和请求的资源的路径。如果输入的 URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,如果存在非法字符,则对非法字符进行转义后再进行下一过程。

(2)缓存判断: 浏览器会判断所请求的资源是否在缓存里,如果请求的资源在缓存里并且没有失效,那么就直接使用,否则向服务器发起新的请求。

(3)DNS解析: 下一步首先需要获取的是输入的 URL 中的域名的 IP 地址,首先会判断本地是否有该域名的 IP 地址的缓存,如果有则使用,如果没有则向本地 DNS 服务器发起请求本地 DNS 服务器也会先检查是否存在缓存,如果没有就会先向根域名服务器发起请求,获得负责的顶级域名服务器的地址后,再向顶级域名服务器请求,然后获得负责的权威域名服务器的地址后,再向权威域名服务器发起请求最终获得域名的 IP 地址后,本地 DNS 服务器再将这个 IP 地址返回给请求的用户。用户向本地 DNS 服务器发起请求属于递归请求,本地 DNS 服务器向各级域名服务器发起请求属于迭代请求。

(4)获取MAC地址(选说) 当浏览器得到 IP 地址后,数据传输还需要知道目的主机 MAC 地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的 IP 地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的 MAC 地址,本机的 MAC 地址作为源 MAC 地址,目的 MAC 地址需要分情况处理。通过将 IP 地址与本机的子网掩码相与,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 APR 协议获取到目的主机的 MAC 地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP 协议来获取网关的 MAC 地址,此时目的主机的 MAC 地址应该为网关的地址。

(5)TCP三次握手:确认客户端与服务器的接收与发送能力,下面是 TCP 建立连接的三次握手的过程,首先客户端向服务器发送一个 SYN 连接请求报文段和一个随机序号,服务端接收到请求后向服务器端发送一个 SYN ACK报文段,确认连接请求,并且也向客户端发送一个随机序号。客户端接收服务器的确认应答后,进入连接建立的状态,同时向服务器也发送一个ACK 确认报文段,服务器端接收到确认后,也进入连接建立状态,此时双方的连接就建立起来了。

(6)HTTPS握手(选说): 如果使用的是 HTTPS 协议,在通信前还存在 TLS 的一个四次握手的过程。首先由客户端向服务器端发送使用的协议的版本号、一个随机数和可以使用的加密方法。服务器端收到后,确认加密的方法,也向客户端发送一个随机数和自己的数字证书。客户端收到后,首先检查数字证书是否有效,如果有效,则再生成一个随机数,并使用证书中的公钥对随机数加密,然后发送给服务器端,并且还会提供一个前面所有内容的 hash 值供服务器端检验。服务器端接收后,使用自己的私钥对数据解密,同时向客户端发送一个前面所有内容的 hash 值供客户端检验。这个时候双方都有了三个随机数,按照之前所约定的加密方法,使用这三个随机数生成一把秘钥,以后双方通信前,就使用这个秘钥对数据进行加密后再传输。

(7)发送HTTP请求

服务器处理请求,返回HTTP报文(响应)(文件)

(8)页面渲染: 浏览器首先会根据 html 文件(响应) 建 DOM 树,根据解析到的 css 文件构建 CSSOM 树,如果遇到 script 标签,则判端是否含有 defer 或者 async 属性,要不然 script 的加载和执行会造成页面的渲染的阻塞。当 DOM 树和 CSSOM 树建立好后,根据它们来构建渲染树。渲染树构建好后,会根据渲染树来进行布局。布局完成后,最后使用浏览器的 UI 接口对页面进行绘制。这个时候整个页面就显示出来了。

(9)TCP四次挥手: 最后一步是 TCP 断开连接的四次挥手过程。若客户端认为数据发送完成,则它需要向服务端发送连接释放请求。服务端收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明客户端到服务端的连接已经释放,不再接收客户端发的数据了。但是因为 TCP 连接是双向的,所以服务端仍旧可以发送数据给客户端。服务端如果此时还有没发完的数据会继续发送,完毕后会向客户端发送连接释放请求,然后服务端便进入 LAST-ACK 状态。客户端收到释放请求后,向服务端发送确认应答,此时客户端进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有服务端的重发请求的话,就进入 CLOSED 状态。当服务端收到确认应答后,也便进入 CLOSED 状态。

网络过程

img

渲染过程

img

# 阻塞浏览器渲染的因素&优化

在HTML文件中,某些资源和元素可能会阻塞浏览器的渲染过程,导致页面加载变慢。

  1. 外部样式表(External Stylesheets)
  • 阻塞原因:浏览器在解析HTML时,遇到 <link rel="stylesheet"> 标签时会暂停渲染,直到样式表完全加载和解析完毕。这是因为样式表可能会影响页面的布局和外观,浏览器需要确保在渲染之前获取所有样式信息。
  1. 外部脚本(External Scripts)
  • 阻塞原因:默认情况下,<script> 标签会阻塞浏览器的解析和渲染,直到脚本执行完毕。这是因为脚本可能会修改DOM,浏览器需要确保在执行脚本之前完成当前的解析工作。
  1. 内联脚本(Inline Scripts)
  • 阻塞原因:内联脚本也会阻塞浏览器的解析和渲染,直到脚本执行完毕。虽然内联脚本不需要网络请求,但它们仍然会阻塞后续内容的解析和渲染。
  1. 同步的 DOMContentLoaded 事件处理程序
  • 阻塞原因:如果在 DOMContentLoaded 事件处理程序中执行耗时的操作,会阻塞页面的进一步渲染。

  • 示例

    <script>
      document.addEventListener('DOMContentLoaded', function() {
        // 耗时操作
      });
    </script>
    
  1. 大型图像和其他媒体资源
  • 阻塞原因:虽然图像和其他媒体资源本身不会直接阻塞渲染,但如果它们位于关键渲染路径上(例如,位于视口内的大图像),浏览器在加载这些资源时可能会延迟渲染。
  • 示例
    <img src="large-image.jpg" alt="Large Image">
    

优化建议

  1. 异步加载外部脚本

    • 使用 asyncdefer 属性来异步加载脚本,避免阻塞渲染。
  2. 非阻塞外部样式表

    • 使用媒体查询或其他技术来延迟加载非关键样式表。
    • 示例
      <link rel="stylesheet" href="print.css" media="print">
      
  3. 内联关键CSS

    • 将关键CSS内联到HTML文件中,以确保浏览器在开始渲染时已经有必要的样式信息。
    • 示例
      <style>
        /* 关键CSS */
        body { font-family: Arial, sans-serif; }
        h1 { color: #333; }
      </style>
      
  4. 懒加载图像

    • 使用 loading="lazy" 属性来延迟加载不在视口内的图像。
    • 示例
      <img src="large-image.jpg" alt="Large Image" loading="lazy">
      
  5. 优化DOM操作

    • 尽量减少在 DOMContentLoaded 事件处理程序中的耗时操作,或将这些操作移到异步任务中。
    • 示例
      <script>
        document.addEventListener('DOMContentLoaded', function() {
          setTimeout(function() {
            // 耗时操作
          }, 0);
        });
      </script>
      

# 回流和重绘

# 回流

当 Render Tree 中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。

img

会导致回流的操作:

  • 页面首次渲染

  • 浏览器窗口大小发生改变

  • 元素尺寸或位置发生改变元素内容变化(文字数量或图片大小等等)

  • 元素字体大小变化

  • 添加或者删除可见的 DOM 元素

  • 激活 CSS 伪类(例如::hover)

  • 查询某些属性或调用某些方法

# 重绘

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

img

跳过了生成布局树建图层树的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。

# 如何优化

  • 避免使用 table 布局。
  • 批量修改样式
  • 某些CSS属性(如 topleft)会引起回流。使用 transform 属性可以将动画效果交给GPU处理,减少回流。
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  • 频繁地访问布局相关的属性会触发回流。使用 getBoundingClientRect 方法一次性获取多个布局信息,减少回流次数。
  • 懒加载图像
  • 避免频繁操作 DOM,创建一个 documentFragment,在它上面应用所有 DOM 操作,最后再把它添加到文档中。
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。
  • 对于 resize、scroll 等进行防抖/节流处理。

# DOMContentLoaded 与 load 的区别

  • 当 DOMContentLoaded 事件触发时,仅当 DOM 解析完成后,不包括样式表,图片。我们前面提到 CSS 加载会阻塞 Dom 的渲染和后面 js 的执行,js 会阻塞 Dom 解析,所以我们可以得到结论: 当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded 事件。如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等 CSSOM 构建完成才能执行。在任何情况下,DOMContentLoaded 的触发不需要等待图片等其他资源加载完成。

  • 当 onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片等资源已经加载完毕。

  • DOMContentLoaded -> load。

# 浏览器安全

# XSS

# 存储型

  • HTML 中的代码做好充分的转义

  • 使用 CSP ,CSP 的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行,从而防止恶意代码的注入攻击。

  • 对一些敏感信息进行保护,比如 cookie 使用 http-only,使得脚本无法获取

# 反射型

  • GET 类型的 CSRF 攻击,比如在网站中的一个 img 标签里构建一个请求,当用户打开这个网站的时候就会自动发起提交。

  • POST 类型的 CSRF 攻击,比如构建一个表单,然后隐藏它,当用户进入页面时,自动提交这个表单。

  • 链接类型的 CSRF 攻击,比如在 a 标签的 href 属性里构建一个请求,然后诱导用户去点击。

防护:

  • 进行同源检测
  • 使用 CSRF Token 进行验证
  • 对 Cookie 进行双重验证
  • 在设置 cookie 属性的时候设置 Samesite ,限制 cookie 不能作为被第三方使用

# CSRF

# 中间人攻击

中间⼈ (Man-in-the-middle attack, MITM) 是指攻击者与通讯的两端分别创建独⽴的联系, 并交换其所收到的数据, 使通讯的两端认为他们正在通过⼀个私密的连接与对⽅直接对话, 但事实上整个会话都被攻击者完全控制。在中间⼈攻击中,攻击者可以拦截通讯双⽅的通话并插⼊新的内容。

攻击过程如下:

  • 客户端发送请求到服务端,请求被中间⼈截获
  • 服务器向客户端发送公钥
  • 中间⼈截获公钥,保留在⾃⼰⼿上。然后⾃⼰⽣成⼀个伪造的公钥,发给客户端
  • 客户端收到伪造的公钥后,⽣成加密hash值发给服务器
  • 中间⼈获得加密hash值,⽤⾃⼰的私钥解密获得真秘钥,同时⽣成假的加密hash值,发给服务器
  • 服务器⽤私钥解密获得假密钥,然后加密数据传输给客户端

防范:

  • 使用 https
  • 不要使用公用网络发送一些敏感的信息
  • 不要去点击一些不安全的连接或者恶意链接或邮件信息

# 图片懒加载、预加载

# 垃圾回收

# 垃圾回收机制

「硬核JS」你真的了解垃圾回收机制吗 (opens new window)

JavaScript 垃圾回收机制的原理说白了也就是定期找出那些不再用到的内存(变量),然后释放其内存。

这个流程就涉及到了一些算法策略,有很多种方式,我们简单介绍两个最常见的

  • 标记清除算法
  • 引用计数算法

# 标记清除算法

  • 从根对象(如全局对象、DOM节点等)开始,递归地遍历所有可达对象,并将它们标记为活动对象。
  • 未被标记的对象被认为是垃圾对象,可以被回收。

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  • 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  • 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  • 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

垃圾回收过程中还需要处理全局变量和局部变量。全局变量的生命周期会持续到页面卸载,而局部变量在函数执行结束后就不再被使用,它们的内存空间会被释放。但值得注意的是,当局部变量被外部函数使用时(如闭包情况),它们依然会被视为在使用中,因此不会被回收。

优点:实现简单

缺点

内存碎片化,清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的。

分配速度慢,新分配内存的时候,由于内存不连续。需要找到大于等于 size 的块立即返回。

归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)】

img

# 引用计数算法

引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多,不过我们还是需要了解一下

let a = new Object() 	// 此对象的引用计数为 1(a引用)
let b = a 		// 此对象的引用计数是 2(a,b引用)
a = null  		// 此对象的引用计数为 1(b引用)
b = null 	 	// 此对象的引用计数为 0(无引用)
...			// GC 回收此对象

优点

引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾

而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了

缺点

引用计数的缺点想必大家也都很明朗了,首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。

# V8 优化

当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区

当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理

# 哪些操作会造成内存泄漏?

  • 第一种情况是由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
  • 第二种情况是设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
  • 第三种情况是获取一个 DOM 元素的引用,而后面这个元素被删除,由于我们一直保留了对这个元素的引用,所以它也无法被回收。
  • 第四种情况是不合理的使用闭包,从而导致某些变量一直被留在内存当中。

# 事件循环

# Event Loop 执行顺序:

  • 首先执行同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码

# 宏任务和微任务分别有哪些

  • 微任务包括: promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。

  • 宏任务包括: script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。

# Node 中的 Event Loop 和浏览器中的有什么区别

img

对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列。

Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })

对于以上代码,永远都是先把 nextTick 全部打印出来。

上次更新: 11/6/2024, 4:10:52 PM