基础

241 阅读48分钟

浏览器与网络

一.浏览器缓存 Cache-Control

强缓存:

HTTP/1.0时期,使用的是Expires: Wed, 22 Nov 2019 08:41:00 GMT,而HTTP/1.1使用的是Cache-Control:max-age=3600,如果强缓存可用,直接使用,不发送HTTP请求,200。

协商缓存:

Cache-Control: no-cache 在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段 ETag(Last-Modified)如果再次请求,会在请求头中携带If-None-Match(If-Modified-Since)字段,如果两个值改变返回新的资源200,否则返回304,告诉浏览器直接用缓存

private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。

no-cache: 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段

no-store:非常粗暴,不进行任何形式的缓存。

s-maxage:这和max-age长得比较像,但是区别在于s-maxage是针对代理服务器的缓存时间。

值得注意的是,当ExpiresCache-Control同时存在的时候,Cache-Control会优先考虑。

两者对比

  1. 精准度上,ETag优于Last-Modified。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。而 Last-Modified 就不一样了,它在一些特殊的情况并不能准确感知资源变化,主要有两种情况:
  • 编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效。
  • Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。
  1. 在性能上,Last-Modified优于ETag,也很简单理解,Last-Modified仅仅只是记录一个时间点,而 Etag需要根据文件的具体内容生成哈希值。

  2. 另外,如果两种方式都支持的话,服务器会优先考虑ETag

缓存位置

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

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache
Service Worker

Service Worker 借鉴了 Web Worker的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存消息推送网络代理等功能。其中的离线缓存就是 Service Worker Cache

Service Worker 同时也是 PWA 的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA 的分享中详细介绍。

原理:允许用户拦截网络请求,并通过CacheStorage API有条件地将项目存储在一个特殊的缓存中,此缓存与浏览器本地缓存分开,使用它即可在用户离线时,从CacheStorage缓存向用户提供内容。

将CacheStorage与Service Worker的fetch事件配合使用,拦截和缓存网络请求。 断网时,通过一个接口拦截网络请求并从CacheStorage缓存中读取或写入数据。

离线缓存之资源更新的问题

  1. 每次都要取最新index.html(并缓存他),如果返回失败(断线情况),用缓存的index.html
  2. 如果有资源更新,直接更新资源的名字就行
  3. 资源更新后,需处理失效的资源,把cacheVersion 变量从 v1 变成 v2,删除( .delete() )不在白名单中的所有缓存
Memory Cache 和 Disk Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。稍微有些计算机基础的应该很好理解,就不展开了。

好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:

  • 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存
  • 内存使用率比较高的时候,文件优先进入磁盘
Push Cache

即推送缓存,这是浏览器缓存的最后一道防线。在 HTTP/2 当中,只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂。服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待。 大家可以参考这篇扩展文章

二.浏览器的本地存储

cookie和localSrorage、session、indexDB 的区别
特性cookielocalStoragesessionStorageindexDB
数据生命周期一般由服务器生成,可以设置过期时间除非被清理,否则一直存在页面关闭就清理除非被清理,否则一直存在
数据存储大小4K5M5M无限
与服务端通信每次都会携带在 header 中,对于请求性能影响不参与不参与不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

HTTP 是一个无状态的协议,每次 http 请求都是独立、无关的,默认不需要保留状态信息。但有时候需要保存一些状态,怎么办呢?HTTP 为此引入了 Cookie。

  • cookie 的有效期可以通过ExpiresMax-Age两个属性来设置。

  • Expires过期时间

  • Max-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算。

  • 若 Cookie 过期,则这个 Cookie 会被删除,并不会发送给服务端。

  • 关于作用域也有两个属性: Domainpath, 给 Cookie 绑定了域名和路径,在发送请求之前,发现域名或者路径和这两个属性不匹配,那么就不会带上 Cookie。值得注意的是,对于路径来说,/表示域名下的任意路径都允许使用 Cookie。

  • 浏览器针对 cookie 会有一些默认行为,比如服务端可以通过响应头中出现set-cookie字段时,浏览器会自动保存 cookie 的值;再比如,浏览器发送请求时,会附带匹配的 cookie 到请求头中,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。这些默认行为,使得 cookie 长期以来担任着维持登录状态的责任。与此同时,也正是因为浏览器的默认行为,给了恶意攻击者可乘之机,CSRF 攻击。

  • 对于 cookie,我们还需要注意安全性。Set-Cookie字段的属性

属性作用
value如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only只能通过 HTTP 协议传输,不能通过 JS 访问,这也是预防 XSS 攻击
secure只能在协议为 HTTPS 的请求中携带
same-site规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击
  • SameSite可以设置为三个值,StrictLaxNone

    • Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求test.com网站只能在test.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
    • Lax 相对宽松一点。在跨站点的情况下,从第三方站点的链接打开和从第三方站点提交 Get 方式的表单这两种方式都会携带 Cookie。但如果在第三方站点中使用 Post 方法,或者通过 img、iframe 等标签加载的 URL,这些场景都不会携带 Cookie。
    • 而如果使用 None 的话,在任何情况下都会发送 Cookie 数据。
  • Cookie 的缺点

    • 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。
    • 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。但可以通过DomainPath指定作用域来解决。
    • 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

cookie详细参考 juejin.cn/post/684490…

IndexedDB

IndexedDB是运行在浏览器中的非关系型数据库, 本质上是数据库,绝不是和刚才WebStorage的 5M 一个量级,理论上这个容量是没有上限的。

关于它的使用,本文侧重原理,而且 MDN 上的教程文档已经非常详尽,这里就不做赘述了,感兴趣可以看一下使用文档

接着我们来分析一下IndexedDB的一些重要特性,除了拥有数据库本身的特性,比如支持事务存储二进制数据,还有这样一些特性需要格外注意:

  1. 键值对存储。内部采用对象仓库存放数据,在这个对象仓库中数据采用键值对的方式来存储。
  2. 异步操作。数据库的读写属于 I/O 操作, 浏览器中对异步 I/O 提供了支持。
  3. 受同源策略限制,即无法访问跨域的数据库。

三.输入URL到页面呈现发生了什么

image.png

导航
  • 2016年,Chrome使用面向服务的架构,每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过IPC来通信。打开1个页面至少需要1个网络进程、1个浏览器进程、1个GPU进程以及1个渲染进程
    • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
    • 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
    • GPU进程。其实,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程。
    • 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
    • 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
  • 用户发出URL请求到页面开始解析的这个过程,就叫做导航
    • 首先,用户从浏览器进程里输入请求信息;
    • 然后,网络进程发起URL请求;
      • 浏览器进程会通过进程间通信(IPC)把URL请求发送至网络进程,网络进程接收到URL请求后,会在这里发起真正的URL请求流程
      • 网络进程会查找本地缓存是否缓存了该资源,如果没有,进行DNS解析,利用IP地址和服务器建立TCP连接,构建请求发送请求,接收响应。
      • 接收到服务器返回的响应头后,网络进程开始解析响应头,发现返回的状态码是301或者302,那么服务器需要浏览器重定向到其他URL,这时网络进程会从响应头的Location字段里面读取重定向的地址,然后再发起新的HTTP或者HTTPS请求,一切又重头开始;响应行是200,那么表示浏览器可以继续处理该请求。不同Content-Type的后续处理流程也截然不同。如果Content-Type字段的值被浏览器判断为下载类型(application/octet-stream),那么该请求会被提交给浏览器的下载管理器,同时该URL请求的导航流程就此结束。但如果是HTML(text/html),那么浏览器则会继续进行导航流程。由于Chrome的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了
    • 服务器响应URL请求之后,浏览器进程就又要开始准备渲染进程了;
      • Chrome的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话(协议根域名相同),那么新页面会复用父页面的渲染进程。
    • 渲染进程准备好之后,需要先向渲染进程提交页面数据,我们称之为提交文档阶段;这里的“文档”是指URL请求的响应体数据
      • 提交文档”的消息是由浏览器进程发出的,渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”。
      • 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程。
      • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的URL、前进后退的历史状态,并更新Web页面。
    • 渲染进程接收完文档信息之后,便开始解析页面和加载子资源,完成页面的渲染。一旦页面生成完成,渲染进程会发送一个消息给浏览器进程,浏览器接收到消息后,会停止标签图标上的加载动画

image.png

1. 构建请求

浏览器使用HTTP协议作为应用层协议,用来封装构建请求报文,请求行(方法 + 路径 + http版本)请求头,请求体。

// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1
2. 查找强缓存

先检查强缓存,若命中缓存则直接使用缓存,不再发出请求,否则进入下一步。关于强缓存参考上文。

3. DNS解析

image.png

  • DNS解析即将域名解析成ip地址
    • 浏览器访问了某个域名,首先会查找浏览器缓存、本地 hosts 文件、DNS 缓存,没有找到的话再去请求本地 DNS 服务器,由它负责完成域名的解析。
    • 本地 DNS 会依次请求根域名服务器拿到对应的顶级域名服务器的地址,然后请求顶级域名服务器,拿到权威域名服务器的地址,之后权威域名服务器会返回最终的 IP 给本地 DNS 服务器,由它再返给浏览器。
    • 比如说 baidu.com 这个域名,根域名是 .,顶级域名(也叫一级域名)是 com,而二级域名是 baidu.com,那会先向根域名服务器查找 com 的顶级域名服务器的地址,然后再向 com 的顶级域名服务器查找 baidu.com 的权威域名服务器的地址。
    • image.baidu.com 或者 xx.yy.zz.baidu.com 二级域名和更多级的域名都在权威域名服务器解析,域名服务器只有三级。
    • 因为域名服务器之所以这样分级是为了通过负载均衡来分散压力,具体的域名解析都是由各自的权威域名服务器来处理的,根域名和顶级域名服务器只是做了个转发。
    • 由于我们输入的是域名,而数据包是通过IP地址传给对方的。因此我们需要得到域名对应的IP地址,浏览器提供了DNS数据缓存功能。每当Chrome浏览器启动的时候,就会自动的快速解析浏览器最近一次启动时记录的前10个域名。所以经常访问的网址就不存在DNS解析的延迟,进而打开速度更快。而dns-prefetch 相当于在浏览器缓存之后,在本地操作系统中做了DNS缓存。
    • <link rel="dns-prefetch" href="//baidu.com">,dns-prefetch 仅对跨域域名上的 DNS查找有效,因此请避免使用它来指向相同域。页面下所有的 a 标签的 href 都会自动去启用 DNS Prefetch(dns 解析时间被优化了)
    • 第二次访问a 标签图片时约等于<link rel="preconnect" href="https://lf-cdn-tos.bytescm.com/">preconnect(dns 解析、建立连接时间(Socket) + SSL认证时间都提前)
    • 如果站点是通过HTTPS服务的,dns-prefetch 与 preconnect(预连接)两者的组合会涵盖DNS解析,建立TCP连接以及执行TLS握手。将两者结合起来可提供进一步减少跨域请求的感知延迟。
4. 建立 TCP 连接

这里要提醒一点,http1.1 Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。

  • 若使用了HTTPS协议,则还会进行TLS握手,建立加密信道。使用TLS握手时,会确定是否使用HTTP2
  • 浏览器决定要附带哪些cookie到请求头中
  • 浏览器自动设置好请求头、协议版本、cookie,发出GET请求

TCP详细参考:juejin.cn/post/684490…

5.发送 HTTP 请求

现在TCP连接建立完毕,浏览器可以和服务器开始通信,即开始发送 HTTP 请求。浏览器发 HTTP 请求要携带三样东西:请求行请求头请求体

HTTP详细参考: juejin.cn/post/684490…

6.网络响应

HTTP 请求到达服务器,服务器进行对应的处理。完成处理后,服务器响应一个HTTP报文给浏览器,也就是返回网络响应。

  • 跟请求部分类似,网络响应具有三个部分:响应行响应头响应体
  • 浏览器根据使用的协议版本,以及Connection字段的约定,HTTP/1.1这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。
  • 浏览器根据响应头的其他内容完成缓存、cookie的设置
  • 浏览器根据响应状态码决定如何处理这一次响应
  • 浏览器根据响应头中的Content-Type字段识别响应类型,如果是text/html,则对响应体的内容进行HTML解析,否则做其他处理
  • Http 状态码
    • 100 首位是1表示中间状态,http1.1按照规范POST请求会先提交HEAD信息,如果服务器返回100,才将数据信息提交。
    • 206 Partial Content顾名思义,表示部分内容,它的使用场景为 HTTP 分块下载和断点续传,当然也会带上相应的响应头字段Content-Range
    • 301 永久重定向,浏览器会把重定向后的地址缓存起来,将来用户再次访问原始地址时,直接引导用户访问新地址
    • 302 临时重定向,浏览器会引导用户进入新地址,但不会缓存原始地址,下一次用户访问源地址时,浏览器仍然要请求原地址的服务器
    • 304 资源未修改,当协商缓存命中时会返回这个状态码。服务器通过该状态码告诉客户端,请求的资源和过去一样,并没有任何变化,建议自行使用过去的缓存。通常,304 状态码的响应中,服务器不会附带任何的响应体。
    • 403 不允许访问。服务器通过该状态码告诉客户端,这个资源目前不允许访问。这种状态码通常出现在权限不足的情况下。
    • 400 Bad Request: 请求报文出错。请求未到服务端
    • 403 Forbidden: 这实际上并不是请求报文出错,而是服务器禁止访问,原因有很多,比如法律禁止、信息敏感。
    • 404 Not Found: 资源未找到,表示没在服务器上找到相应的资源。
    • 405 Method Not Allowed: 请求方法不被服务器端允许。
    • 406 Not Acceptable: 资源无法满足客户端的条件。
    • 500 Internal Server Error: 仅仅告诉你服务器出错了
    • 502 Bad Gateway: 服务器自身是正常的,但访问的时候出错了,网关错误。
    • 503 Service Unavailable: 表示服务器当前很忙,暂时无法响应服务。

image.png

网络响应,渲染HTML详细参考:juejin.cn/post/684490…

7.渲染HTML

完成了网络请求和响应,如果响应头中Content-Type的值是text/html,那么接下来就是浏览器的解析渲染工作了。

浏览器工作流程:构建 DOM树 -> 样式计算构建CSSOM -> 生成布局树 -> 布局 -> 绘制。

  • 构建DOM树(浏览器无法直接理解和使用HTML字节流)和HTML不同的是,DOM是保存在内存中树状结构,可以通过JavaScript来查询或修改其内容。
    • DOM 树是一个以document为根节点的多叉树。因此HTML解析器首先会创建一个document对象。标记生成器词法分析)会把每个标记的信息发送给建树器语法分析)。建树器接收到相应的标记时,会创建对应的 DOM 对象。创建这个DOM对象后会做两件事情:将DOM对象加入 DOM 树中。将对应标记压入存放开放(与闭合标签意思对应)元素的栈中。
  • 样式计算(浏览器无法直接理解和使用css字节流)
    • 渲染引擎接收到CSS 样式文本之后第一件事情就是将其格式化为一个结构化的对象,即styleSheets。在浏览器控制台能够通过document.styleSheets来查看这个最终的结构。当然,这个结构包含了以上三种CSS来源link标签style标签内嵌style属性
    • 标准化样式属性有一些 CSS 样式的数值并不容易被渲染引擎所理解,因此需要在计算样式之前将它们标准化,如em->px,red->#ff0000,bold->700等等。
    • 计算每个节点的具体样式,主要就是两个规则: 继承层叠。在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以通过JS来获取计算后的样式
      • CSS继承就是每个DOM节点都包含有父节点的样式
      • 层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法
  • 生成布局树,生成了DOM树DOM样式,接下来要做的就是通过浏览器的布局系统确定元素的位置,也就是要生成一棵布局树(Layout Tree)
    • 遍历DOM树中的所有可见节点,并把他们添加到布局树中
    • 值得注意的是,这棵布局树值包含可见元素,对于 head标签和设置了display: none的不可见元素,将不会被放入其中。
    • 计算布局树节点的坐标位置。
  • 建图层树,复杂的3D变换、页面滚动,或者使用z-index做z轴排序等。浏览器在构建完布局树之后,染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。
    • 显式合成
      • HTML根元素本身就具有层叠上下文。
      • 普通元素设置position不为static并且设置了z-index属性,会产生层叠上下文。
      • 元素的 opacity 值不是 1
      • 元素的 transform 值不是 none
      • 元素的 filter 值不是 none
      • 元素的 isolation 值是isolate
      • will-change指定的属性值为上面任意一个。(will-change的作用后面会详细介绍)
      • div里文字很多超出面积,需要剪裁(clip)情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
    • 隐式合成,在一个大型应用中,当一个z-index比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。
  • 生成绘制列表,渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框......然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了计划。并将其提交到合成线程
  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  • 合成线程发送绘制图块命令DrawQuad给浏览器进程。
  • 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上

image.png

image.png

注意:CSS匹配HTML元素是一个相当复杂和有性能问题的事情。所以,DOM树要小,CSS尽量用id和class,千万不要过渡层叠下去

浏览器如果渲染过程中遇到JS文件怎么处理

渲染过程中,如果遇到<script>就停止渲染,执行 JS 代码。因为浏览器有GUI渲染线程与JS引擎线程,为了防止渲染出现不可预期的结果,这两个线程是互斥的关系。 JavaScript的加载、解析与执行会阻塞DOM的构建,也就是说,在构建DOM时,HTML解析器若遇到了JavaScript,那么它会暂停构建DOM,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建。 不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

image.png

JS优化: <script> 标签加上defer属性(异步下载,延迟执行,使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行) 和 async属性 (异步下载,立即执行。使用 async 标志的脚本文件一旦加载完成,会立即执行;)用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积
CSS优化: <link> 标签的rel属性中的属性值设置为preload预加载,prefetch空闲加载,用户代理应检索该资源;crossorigin跨域请求。

所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。这又多了一个阻塞过程)通常情况下DOM和CSSOM是并行构建的,但是当浏览器遇到一个不带defer或async属性的script标签时,DOM构建将暂停,如果此时又恰巧浏览器尚未完成CSSOM的下载和构建,由于JavaScript可以修改CSSOM,所以需要等CSSOM构建完毕后再执行JS,最后才重新DOM构建,浏览器解析完文档便能触发 DOMContentLoaded 事件,而所有资源加载完成之后,load 事件才会被触发。

image.png

image.png

  • 要想缩短白屏时长,可以有以下策略
    • 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
    • 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
    • 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。
    • 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
    • 在加载阶段,核心的优化原则是:优化关键资源的加载速度,减少关键资源的个数和关键资源大小,降低关键资源的 RTT 次数。表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。(可以使用 CDN 来减少每次 RTT 时长)
    • 在交互阶段,核心的优化原则是:尽量减少一帧的生成时间。可以通过减少单次 JavaScript 的执行时间、避免强制同步布局(在修改DOM之前查询DOM相关值减少一次布局)、避免布局抖动(一个 for 循环语句里面不断操作dom读取dom属性值,每次读取属性值之前都要进行计算样式和布局)、尽量采用 CSS 的合成动画、避免频繁的垃圾回收(避免在一些函数中频繁创建临时对象,回收时占用主线程)等方式来减少一帧生成的时长。

8.回流(Reflow)和 重绘(Repaint)

image.png

回流是布局或者几何属性需要改变就称为回流,从构建DOM树开始;一个 DOM 元素的几何属性变化,常见的几何属性有widthheightpaddingmarginlefttopborder 字体,内容变化,比如用户在input框中输入文字,读写 offset族、scroll族和client族属性的时候,浏览器为了获取这些值,设置 style 属性的值,调用 window.getComputedStyle 方法,使 DOM 节点发生增减或者移动

image.png

重绘是当节点需要更改外观而不会影响布局的,当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,比如改变 color 就叫称为重绘,从计算样式开始,跳过了生成布局树建图层树的阶段

image.png

回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流

直接合成阶段 image.png

DOM优化

知道上面的原理之后,对于开发过程有什么指导意义呢?

  1. 避免频繁使用 style,而是采用修改class的方式。
  2. 使用createDocumentFragment进行批量的 DOM 操作。
  3. 对于 resize、scroll 等进行防抖/节流处理requestAnimationFrame,避免js频繁操作DOM,CSS。
  4. 分层技术优化。添加 will-change: tranform,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。当然这个变化不限于tranform, 任何可以实现合成效果的 CSS 属性(CSS3 的transformopacityf ilter)都能用will-change来声明。这里有一个实际的例子,一行will-change: tranform拯救一个项目, 点击直达
  5. GPU加速。在合成的情况下,会直接跳过布局和绘制流程,直接进入非主线程处理的部分,即直接交给合成线程处理,并在其中使用GPU进行加速生成,没有占用主线程的资源。
  6. position: absolute || fixed脱离文档流避免外部发生回流重绘,切换成staitc会回流, 浮动float结合overflow: hidden;
  7. 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)使用display控制DOM显隐,将DOM离线化
  8. 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video 标签来说,浏览器会自动将该节点变为图层。
  9. CSS 选择符从右往左匹配查找,避免 DOM 深度过深
  10. 异步p批量更新策略异步任务中修改DOM时把其包装成微任务
  11. 双缓存,在内存中找出变化构建完再提交渲染

四.浏览器EventLoop,垃圾回收,异步编程

事件循环流程
  • 背景:页面中的大部分任务都是在主线程上执行的,为了协调这些任务有条不紊地在主线程上执行,渲染进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。
    • 渲染事件(如解析 DOM、计算布局、绘制);
    • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
    • JavaScript 脚本执行事件;
    • 网络请求完成、文件读写完成事件。
  1. 从任务队列中取出一个宏任务并执行。(每个宏任务都关联了一个微任务队列,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列)

  2. 检查微任务队列,执行并清空微任务队列,如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。

    • 使用 MutationObserver监控某个 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JavaScript 引擎按照顺序保存到微任务队列中。
    • Promise 之所以要使用微任务是由 Promise 回调函数延迟绑定技术导致的
    • 微任务的执行时长会影响到当前宏任务的时长。
    • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
  3. 进入更新渲染阶段,判断是否需要渲染,这里有一个 rendering opportunity 的概念,也就是说不一定每一轮 event loop 都会对应一次浏览 器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)

    • 浏览器会尽可能的保持帧率稳定,例如页面性能无法维持 60fps(每 16.66ms 渲染一次)的话,那么浏览器就会选择 30fps 的更新速率,而不是偶尔丢帧。
    • GUI渲染线程JS线程是互斥的,当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局样式绘制了(时间切片),页面掉帧,造成卡顿。
    • 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
    • 浏览器判断更新渲染不会带来视觉上的改变(跳过渲染);
    • map of animation frame callbacks 为空,也就是帧动画回调为空(跳过渲染),可以通过 requestAnimationFrame 来请求帧动画。
  4. 如果上述的判断决定本轮不需要渲染,那么下面的几步也不会继续运行

  5. 对于需要渲染的文档,如果窗口的大小发生了变化,执行监听的 resize 方法。

  6. 对于需要渲染的文档,如果页面发生了滚动,执行 scroll 方法。

    • 对于resizescroll来说,并不是到了这一步才去执行滚动和缩放,那岂不是要延迟很多?浏览器当然会立刻帮你滚动视图,根据CSSOM 规范所讲,浏览器会保存一个 pending scroll event targets,等到事件循环中的 scroll这一步,去派发一个事件到对应的目标上,驱动它去执行监听的回调函数而已。resize也是同理。
    • resizescroll事件其实自带节流,它只在 Event Loop 的渲染阶段去派发事件到 EventTarget 上
  7. 对于需要渲染的文档,执行帧动画回调,也就是 requestAnimationFrame 的回调。(官方推荐的用来做一些流畅动画)

    • 在重新渲染前调用。rAF在浏览器决定渲染之前给你最后一个机会去改变 DOM 属性。
    • 很可能在宏任务之后不调用。(而如果在渲染之后再去更改 DOM,那就只能等到下一轮渲染机会的时候才能去绘制出来了,这显然是不合理的)
    • 有时候浏览器希望两次「定时器任务」是合并的,他们之间只会穿插着 microTask的执行,而不会穿插屏幕渲染相关的流程(比如requestAnimationFrame,下面会写一个例子)。(宏任务之间不一定会伴随着浏览器绘制,如果你依赖这个宏任务API来做动画,那么就很可能会造成「掉帧」)
    • 定时器合并,定时器宏任务可能会直接跳过渲染
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})

queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))

// 浏览器会合并这两个定时器任务mic mic sto sto rAF rAF

8. 对于需要渲染的文档, 执行 IntersectionObserver 的回调,实现了监听window的scroll事件判断是否在视口中以及节流三大功能。

  1. 对于需要渲染的文档,重新渲染绘制用户界面。

  2. 判断 task队列microTask队列是否都为空,如果是的话,则进行 Idle 空闲周期的算法,判断是否要执行 requestIdleCallback 的回调函数。(空闲调度算法,意图是让我们把一些计算量较大但是又没那么紧急的任务放到空闲时间去执行。不要去影响浏览器中优先级较高的任务,比如动画绘制、用户输入等)requestIdleCallback在渲染屏幕之后执行,并且是否有空执行要看浏览器的调度,如果你一定要它在某个时间内执行,请使用 timeout参数。

    • 当浏览器判断这个页面对用户不可见时,这个回调执行的频率可能被降低到 10 秒执行一次,甚至更低。

    • 如果浏览器的工作比较繁忙的时候,不能保证它会提供空闲时间去执行 rIC 的回调,而且可能会长期的推迟下去。所以如果你需要保证你的任务在一定时间内一定要执行掉,那么你可以给 rIC 传入第二个参数 timeout。要谨慎使用,因为它会打断浏览器本身优先级更高的工作。

    • 闲置截止期限deadline 设置为 50ms 意味着即使在闲置任务开始后立即发生用户输入,浏览器仍然有剩余的 50ms 可以在其中响应用户输入而不会产生用户可察觉的滞后

    • 每次调用 timeRemaining() 函数判断是否有剩余时间的时候,如果浏览器判断此时有优先级更高的任务,那么会动态的把这个值设置为 0,否则就是用预先设置好的 deadline - now 去计算。这个 timeRemaining() 的计算非常动态,会根据很多因素去决定,所以不要指望这个时间是稳定的。

    • 如果我鼠标不做任何动作和交互,直接在控制台通过 rIC 去打印这次空闲任务的剩余时间deadline.timeRemaining(),一般都稳定维持在 49.xx ms,因为此时浏览器没有什么优先级更高的任务要去处理。而如果我不停的滚动浏览器,不断的触发浏览器的重新绘制的话,这个时间就变的非常不稳定了。感受到什么样叫做「繁忙」,什么样叫做「空闲」。

    • 渲染有序进行 image.png

    • 渲染长期空闲 image.png

  • 多任务队列,事件循环中可能会有一个或多个任务队列,这些队列分别为了处理:
    • 鼠标和键盘事件
    • 其他的一些 Task
    • 浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。

详细参考 juejin.cn/post/684490…

  • setTimeout渲染进程会将该定时器的回调任务添加到消息队列中

    • 如果当前执行栈中的任务执行时间过久,会影延迟到期定时器任务的执行
    • 渲染引擎插在两个定时器宏任务中间的任务执行时间过久的话,就会影响到后面任务的执行了。所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制
    • 定时器函数里面嵌套调用定时器,定时器被嵌套调用 5 次以上系统会设置最短时间间隔为 4 毫秒,用 setTimeout 来实现 JavaScript 动画就不是一个很好的主意。
    • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
    • 延时执行时间有最大值2147483647 毫秒
    • setTimeout 设置的回调函数中的 this 不符合直觉,箭头函数解决
  • XMLHttpRequest背后的实现机制,具体工作过程

    • 渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数
    • 不是同一个源,所以就涉及到了跨域
    • 混合资源是指https页面中请求http内容。通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求。 image.png

备注api:

Element.getBoundingClientRect()  方法返回元素的大小及其相对于视口的位置。

IntersectionObserver接口 提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法。

Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值,搭配getPropertyValue可以获取到具体样式。

  • MutationObserver 是一个内建对象,它观察 DOM 元素,并在检测到更改时触发回调。
    • MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。
    • MutationObserver 采用了“异步 + 微任务”的策略通过异步调用和减少触发次数来缓解了性能问题。通过微任务解决了实时性的问题。渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

宏任务postMessage,Web Worker,Service Worker使用 juejin.cn/post/684490…

垃圾回收
  • V8 栈空间和堆空间
    • 栈空间就是调用栈,是用来存储JavaScript 引擎会创建执行上下文的
    • 原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的
    • 对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的
    • 为什么一定要分“堆”和“栈”两个存储空间呢?因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。比如文中的 foo 函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo 函数执行上下文栈区空间全部回收。
    • 引用类型d=c的操作就是把 c 的引用地址赋值给 d,变量 c 和变量 d 都指向了同一个堆中的对象。改变时相互影响。
    • 原始类型b=a的操作就是值的拷贝,改变时互不影响。
    • 产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中clourse(foo),由于内部函数引用堆中的变量,不会被回收。(递归死循环栈内存溢出,while死循环创建对象数组堆内存溢出) image.png
  • V8 内存限制
    • V8只能使用系统的一部分内存,具体来说,在64位系统下, V8最多只能分配1.4G, 在 32 位系统中,最多只能分配0.7G。对于后端而言,nodejs如果遇到 一个2G多的文件,那么将无法全部将其读入内存进行各种操作了。
    • 对于栈内存而言,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。也就是上下文切换之后,栈顶的空间会自动被回收。
  • 所有的对象类型的数据在JS中都是通过堆进行空间分配的。当我们构造一个对象进行赋值操作的时候,其实相应的内存已经分配到了堆上。
  • V8 为什么要给它设置内存上限?
    • JS是单线程运行的,这意味着一旦进入到垃圾回收,那么其它的各种运行逻辑都要暂停
    • 垃圾回收是非常耗时的,以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上。可见其耗时之久,造成应用卡顿,导致应用性能和响应能力直线下降。V8 做了一个简单粗暴的 选择,那就是限制堆内存。
    • node --max-old-space-size=2048 xxx.js 调整老生代这部分的内存,单位是MB
    • node --max-new-space-size=2048 xxx.js 调整新生代这部分的内存,单位是KB
  • V8把堆内存分成了两部分进行处理——新生代内存和老生代内存。顾名思义,新生代就是临时分配的内存,存活时间短,老生代是常驻内存,存活的时间长。V8 的堆内存,也就是两个内存 之和。

image.png image.png

  • 新生代内存的回收,在 64 位和 32 位系统下分别为 32MB 和 16MB。新生代中的变量存活时间短,来了马上就走,不容易产生太大的内存负担,因此可以将它设的足够小。
    • 首先将新生代内存空间一分为二:其中From部分表示正在使用的内存,To 是目前闲置的内存。
    • 当进行垃圾回收时,V8 将From部分的对象检查一遍,如果是存活对象那么复制到To内存中(在To内存中按照顺序从头放置的),如果是非存活对象直接回收即可。
    • 当所有的From中的存活对象按照顺序进入到To内存之后,From 和 To 两者的角色对调,From现在被闲置,To为正在使用,如此循环。
    • 深色的小方块代表存活对象,白色部分表示待分配的内存,由于堆内存是连续分配的,这样零零散散的空间可能会导致稍微大一点的对象没有办法进行空间分配, 这种零散的空间也叫做内存碎片
    • 在To内存中按照顺序从头放置的,Scavenge 算法主要就是解决内存碎片的问题,在进行一顿复制之后,To空间变成一列有序的。不过Scavenge 算法的劣势也非常明显,就是内存只能使用新生代内存的一半

image.png

image.png

  • 老生代内存的回收,新生代中的变量经过两次垃圾回收依然还存活的对象。或者To(闲置)空间的内存占用超过25%。触发晋升,会被放入到老生代内存中。
    • 主要分成两个阶段,即标记阶段和整理阶段。标记阶段就是递归遍历整个调用栈,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。整理阶段让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
    • 引发内存碎片的问题,存活对象的空间不连续对后续的空间分配造成障碍。而碎片过多会导致大对象无法分配到足够的连续内存。整理内存碎片事实上也是整个过程中最耗时间的部分。
    • 增量标记 由于JS的单线程机制,V8 在进行垃圾回收的时候,不可避免地会阻塞业务逻辑的执行。V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记,直到标记阶段完成才进入内存碎片的整理上面来。其实这个过程跟React Fiber的思路有点像,经过增量标记之后,垃圾回收过程对JS应用的阻塞时间减少到原来了1 / 6。

详细参考: juejin.cn/post/684490…

异步编程

Promise实现

class Promise{
  constructor(excutorCallBack){
    // Promise/A+的规范
    // Promise本质是一个状态机,且状态只能为以下三种`Pending(等待态)`、
    // `Fulfilled(执行态)`、`Rejected(拒绝态)`,状态的变更是单向的
    // `then方法`接收两个可选参数,分别对应状态改变时触发的回调。then方法返回一个promise。
    this.status = 'pending';
    this.value = undefined;
    this.fulfillAry = [];
    this.rejectedAry = [];
    // 在 excutor 函数中调用 resolve 函数时,会触发 promise.then 设置的回调函数;
    // 而调用 reject 函数时,会触发 promise.catch 设置的回调函数
    // 由于 Promise 采用了回调函数延迟绑定技术,所以在执行 resolve 函数的时候,
    // 回调函数还没有绑定,那么只能推迟回调函数的执行
    // resolve和reject函数Promise的默认实现是放进了微任务队列,
    // 用setTimeout模拟微任务,兼容excutorCallBack是同步任务时。
    let resolveFn = result => {
      if(this.status !== 'pending') return;
      let timer = setTimeout(() => {
        this.status = 'fulfilled';
        this.value = result;
        this.fulfillAry.forEach(item => item(this.value));
      }, 0);
    };
    let rejectFn = reason => {
      if(this.status !== 'pending')return;
      let timer = setTimeout(() => {
        this.status = 'rejected';
        this.value = reason;
        this.rejectedAry.forEach(item => item(this.value))
      })
    };
    try{
      //执行这个异步函数
      excutorCallBack(resolveFn, rejectFn);
    } catch(err) {
      //=>有异常信息按照rejected状态处理
      rejectFn(err);
    }
  }
  then(fulfilledCallBack, rejectedCallBack) {
    //保证两者为函数
    typeof fulfilledCallBack !== 'function' ? fulfilledCallBack = result => result:null;
    typeof rejectedCallBack !== 'function' ? rejectedCallBack = reason => {
      throw new Error(reason instanceof Error? reason.message:reason);
    } : null
    //返回新的Promise对象,后面称它为“新Promise”
    return new Promise((resolve, reject) => {
      //注意这个this指向目前的Promise对象,而不是新的Promise
      //目前的Promise(不是这里return的新Promise)的resolve和reject函数其实一个作为微任务
      //因此他们不是立即执行,而是等then调用完成后执行
      this.fulfillAry.push(() => {
        try {
          //把then里面的方法拿过来执行,执行的目的已经达到
          let x = fulfilledCallBack(this.value);
          //下面执行之后的下一步,也就是记录执行的状态,决定新Promise如何表现 
          //如果返回值x是一个Promise对象,就执行then操作 
          //如果不是Promise,直接调用新Promise的resolve函数, 
          //新Promise的fulfilAry现在为空,在新Promise的then操作后.新Promise的resolve执行
          x instanceof Promise ? x.then(resolve, reject):resolve(x);
        }catch(err){
          reject(err)
        }
      });
      //以下同理
      this.rejectedAry.push(() => {
        try {
          let x = rejectedCallBack(this.value);
          x instanceof Promise ? x.then(resolve, reject):resolve(x);
        }catch(err){
          reject(err)
        }
      })
    }) ;
  }
  //catch方法其实就是执行一下then的第二个回调,返回一个promise,并且处理拒绝的情况。
  
  catch(rejectedCallBack) {
    return this.then(null, rejectedCallBack);
  }
  
  // `finally()方法`返回一个Promise。在promise结束时,无论结果是fulfilled
  // 或者是rejected,都会执行指定的回调函数。在finally之后,还可以继续then。
  
  finally(callback) {
    return this.then(
      value => Promise.resolve(callback()).then(() => value),             
      // Promise.resolve执行回调,并在then中return结果传递给后面的Promise
      reason => Promise.resolve(callback()).then(() => { throw reason })  
      // reject同理
      )
  }

  // `Promise.all(iterable)`方法返回一个 Promise 实例,
  // 此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 
  // promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),
  // 此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。

  static all(promiseAry = []) {
    let index = 0, 
        result = [];
    return new Promise((resolve, reject) => {
      for(let i = 0; i < promiseAry.length; i++){
        promiseAry[i].then(val => {
          index++;
          result[i] = val;
          if( index === promiseAry.length){
            resolve(result)
          }
        }, reject);
      }
    })
  }
  
  // `Promise.race(iterable)`方法返回一个 promise,
  // 一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。
  
 static race(promiseAry) {
  return new Promise((resolve, reject) => {
    if (promiseAry.length === 0) {
      return;
    }
    for (let i = 0; i < promiseAry.length; i++) {
      promiseAry[i].then(val => {
        resolve(val);
        return;
      }, reject);
    }     
  })
}
static resolve (value) {
    if (value instanceof Promise) return value
    return new Promise(resolve => resolve(value))
}
static reject (value) {
    return new Promise((resolve, reject) => reject(value))
}
}

module.exports = Promise;

async/await实现

  • juejin.cn/post/684490…
  • async/await实际上是对Generator(生成器)和 Promise的封装,是一个语法糖(babel转化)。
  • run方法里我们把执行下一步的操作封装成_next(),每次Promise.then()的时候都去执行_next(),实现自动迭代的效果。在迭代的过程中,我们还把resolve的值传入gen.next(),使得yield得以返回Promise的resolve的值
function run(gen) {
  //把返回值包装成promise
  return new Promise((resolve, reject) => {
    var g = gen()

    function _next(val) {
      //错误处理
      try {
        var res = g.next(val) 
      } catch(err) {
        return reject(err); 
      }
      if(res.done) {
        return resolve(res.value);
      }
      //res.value包装为promise,以兼容yield后面跟基本类型的情况
      Promise.resolve(res.value).then(
        val => {
          _next(val);
        }, 
        err => {
          //抛出错误
          g.throw(err)
        });
    }
    _next();
  });
}

实现一个简单的Generator

  • await是如何实现暂停执行?
  • 我们定义的function* 生成器函数被转化为以下代码
  • 转化后的代码分为三大块:
    • gen$(_context)由yield分割生成器函数代码而来
    • context对象用于储存函数执行上下文
    • invoke()方法定义next(),用于执行gen$(_context)来跳到下一步
  • 当我们调用g.next(),就相当于调用invoke()方法,执行gen$(_context),进入switch语句,switch根据context的标识,执行对应的case块,return对应结果
  • 当生成器函数运行到末尾(没有下一个yield或已经return),switch匹配不到对应代码块,就会return空值,这时g.next()返回{value: undefined, done: true}
  • Generator实现的核心在于上下文的保存,函数并没有真的被挂起,每一次yield,其实都执行了一遍传入的生成器函数,只是在这个过程中间用了一个context对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行,看起来就像函数被挂起了一样
// 生成器函数根据yield语句将代码分割为switch-case块,
// 后续通过切换_context.prev和_context.next来分别执行各个case
function gen$(_context) {
  while (1) {
    switch (_context.prev = _context.next) {
      case 0:
        _context.next = 2;
        return 'result1';

      case 2:
        _context.next = 4;
        return 'result2';

      case 4:
        _context.next = 6;
        return 'result3';

      case 6:
      case "end":
        return _context.stop();
    }
  }
}

// 低配版context  
var context = {
  next:0,
  prev: 0,
  done: false,
  stop: function stop () {
    this.done = true
  }
}

// 低配版invoke
let gen = function() {
  return {
    next: function() {
      value = context.done ? undefined: gen$(context)
      done = context.done
      return {
        value,
        done
      }
    }
  }
} 

// 测试使用
var g = gen() 
g.next()  // {value: "result1", done: false}
g.next()  // {value: "result2", done: false}
g.next()  // {value: "result3", done: false}
g.next()  // {value: undefined, done: true}

五.浏览器的网络

一个数据包的“旅程”
  • IP:把数据包送达目的主机
    • 数据包要在互联网上进行传输,就要符合网际协议(Internet Protocol,简称IP)标准。互联网上不同的在线设备都有唯一的地址,计算机的地址就称为IP地址,访问任何网站实际上只是你的计算机向另外一台计算机请求信息
    • 如果要想把一个数据包从主机A发送给主机B,那么在传输之前,数据包上会被附加上主机B的IP地址信息,这样在传输过程中才能正确寻址。额外地,数据包上还会附加上主机A本身的IP地址,有了这些信息主机B才可以回复信息给主机A。这些附加的信息会被装进一个叫IP头的数据结构里。IP头是IP数据包开头的信息,包含IP版本、源IP地址、目标IP地址、生存时间等信息
  • UDP:把数据包送达应用程序
    • IP是非常底层的协议,只负责把数据包传送到对方电脑,但是对方电脑并不知道把数据包交给哪个程序。因此,需要基于IP之上开发能和应用打交道的协议,最常见的是“用户数据包协议(User Datagram Protocol)”,简称UDP。
    • UDP中一个最重要的信息是端口号,端口号其实就是一个数字,每个想访问网络的程序都需要绑定一个端口号。通过端口号UDP就能把指定的数据包发送给指定的程序了,所以IP通过IP地址信息把数据包发送给指定的电脑,而UDP通过端口号把数据包分发给正确的程序。和IP头一样,端口号会被装进UDP头里面,UDP头再和原始数据包合并组成新的UDP数据包。UDP头中除了目的端口,还有源端口号等信息
    • 下面我们一起来看下一个数据包从主机A旅行到主机B的路线:
      • 上层将含有“极客时间”的数据包交给传输层;
      • 传输层会在数据包前面附加上UDP头,组成新的UDP数据包,再将新的UDP数据包交给网络层;
      • 网络层再将IP头附加到数据包上,组成新的IP数据包,并交给底层;
      • 数据包被传输到主机B的网络层,在这里主机B拆开IP头信息,并将拆开来的数据部分交给传输层;
      • 在传输层,数据包中的UDP头会被拆开,并根据UDP中所提供的端口号,把数据部分交给上层的应用程序;
      • 最终,含有“极客时间”信息的数据包就旅行到了主机B上层应用程序这里
    • 在使用UDP发送数据时,有各种因素会导致数据包出错,虽然UDP可以校验数据是否正确,但是对于错误的数据包,UDP并不提供重发机制,只是丢弃当前的包,而且UDP在发送之后也无法知道是否能达到目的地。虽说UDP不能保证数据可靠性,但是传输速度却非常快。
  • TCP:把数据完整地送达应用程序
    • 数据包在传输过程中容易丢失;大文件会被拆分成很多小的数据包来传输,这些小的数据包会经过不同的路由,并在不同的时间到达接收端,而UDP协议并不知道如何组装这些数据包,从而把这些数据包还原成完整的文件。基于这两个问题,我们引入TCP了。TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。- 对于数据包丢失的情况,TCP提供重传机制;TCP提供了用于排序的序列号,用来保证把乱序的数据包组合成一个完整的文件。
    • 传输数据阶段。在该阶段,接收端需要对每个数据包进行确认操作,也就是接收端在接收到数据包之后,需要发送确认数据包给发送端。所以当发送端发送了一个数据包之后,在规定时间内没有接收到接收端反馈的确认消息,则判断为数据包丢失,并触发发送端的重发机制。同样,一个大的文件在传输过程中会被拆分成很多小的数据包,这些数据包到达接收端后,接收端会按照TCP头中的序号为其排序,从而保证组成完整的数据。
    • TCP下的单个数据包的传输流程,与UDP不同的地方在于,通过TCP头的信息保证了一块大的数据传输的完整性。

image.png

TCP
  • 网络的OSI七层模型

    • 应用层(HTTP协议,文件传输、电子邮件)、表示层、会话层(TLS1.2)、传输层(TCP,UDP)、网络层(IP协议负责寻址和路由选择)、数据链路层(物理层面上结点之间的通信传输)、物理层(负责0、1比特流(0、1序列)与电压的高低、光的闪灭之间的互换)
  • TCP 和 UDP 的区别

    • TCP是一个面向连接的、可靠的、基于字节流的传输层协议。
      • 面向连接指的是客户端和服务器的连接,在双方互相通信之前,TCP 需要三次握手建立连接,而 UDP 没有相应建立连接的过程。
      • 可靠性。TCP 花了非常多的功夫保证连接的可靠,一个是有状态,另一个是可控制。TCP 会精准记录哪些数据发送了,哪些数据被对方接收了,哪些没有被接收到,而且保证数据包按序到达,不允许半点差错。这是有状态。当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。这是可控制
    • UDP是一个面向无连接的基于报文的传输层协议。(无状态不可控
      • *UDP 的数据传输是基于数据包的,这是因为仅仅只是继承了 IP 层的特性,而 TCP 为了维护状态,将一个个 IP 包变成了字节流。
  • TCP 的三次握手,也是需要确认双方的两样能力: 发送的能力接收的能力 image.png

    • 注意的是,从图中可以看出,SYN 是需要消耗一个序列号的,下次发送对应的 ACK 序列号要加1,为什么呢?只需要记住一个规则:凡是需要对端确认的,一定消耗TCP报文的序列号。SYN 需要对端的确认, 而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要。
    • 第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。
  • 为什么不是两次?

    • 根本原因: 无法确认客户端的接收能力。
    • 分析如下:如果是两次,你现在发了 SYN 报文想握手,但是这个包滞留在了当前的网络中迟迟没有到达,TCP 以为这是丢了包,于是重传,两次握手建立好了连接。看似没有问题,但是连接关闭后,如果这个滞留在网路中的包到达了服务端呢?这时候由于是两次握手,服务端只要接收到然后发送相应的数据包,就默认建立连接,但是现在客户端已经断开了
  • 为什么不是四次?

    • 三次握手的目的是确认双方发送接收的能力,那四次握手可以嘛?
    • 当然可以,100 次都可以。但为了解决问题,三次就足够了,再多用处就不大了。
  • 三次握手过程中可以携带数据么?

    • 第三次握手的时候,可以携带。前两次握手不能携带数据。
    • 如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的 SYN 报文中放大量数据,那么服务器势必会消耗更多的时间内存空间去处理这些数据,增大了服务器被攻击的风险。
    • 第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。
  • 如果已经建立了连接,但是客户端突然出现故障了该怎么办?

    • TCP 还有一个保活计时器
    • 当客户端出现故障时,服务器肯定是不会一直等待下去,白白浪费资源的。
    • 服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常设置为 2h ,若 2h 还没有收到客户端的任何数据,服务器就会发送一个 探测报文段 给客户端,以后每隔 75s 发送一次。
    • 若一连发送 10个探测报文段 仍没有反应,服务器就认为客户端出了故障,紧接着就会关闭连接。(10x75=750s=12.5min)
  • TCP 四次挥手的过程

image.png

  • 等待2MSL的意义

    • 如果不等待会怎样?如果不等待,客户端直接跑路,当服务端还有很多数据包要给客户端发,且还在路上的时候,若客户端的端口此时刚好被新的应用占用,那么就接收到了无用数据包,造成数据包混乱。所以,最保险的做法是等服务器发来的数据包都死翘翘再启动新的应用。
    • 那,照这样说一个 MSL 不就不够了吗,为什么要等待 2 MSL?
      • 1 个 MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
      • 1 个 MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达
  • 为什么是四次挥手而不是三次?

    • 因为服务端在接收到FIN, 往往不会立即返回FIN, 必须等到服务端所有的报文都发送完毕了,才能发FIN。因此先发一个ACK表示已经收到客户端的FIN,延迟一段时间才发FIN。这就造成了四次挥手。
  • 如果是三次挥手会有什么问题?

    • 等于说服务端将ACKFIN的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为FIN没有到达客户端,从而让客户端不断的重发FIN
  • 半连接队列和 SYN Flood 攻击的关系

    • 当客户端发送SYN到服务端,服务端收到以后回复ACKSYN,状态由LISTEN变为SYN_RCVD,此时这个连接就被推入了SYN队列,也就是半连接队列
    • 当客户端返回ACK, 服务端接收后,三次握手完成。这个时候连接等待被具体的应用取走,在被取走之前,它会被推入另外一个 TCP 维护的队列,也就是全连接队列(Accept Queue)
    • SYN Flood 属于典型的 DoS/DDoS 攻击。其攻击的原理很简单,就是用客户端在短时间内伪造大量不存在的 IP 地址,并向服务端疯狂发送SYN。1. 处理大量的SYN包并返回对应ACK, 势必有大量连接处于SYN_RCVD状态,从而占满整个半连接队列,无法处理正常的请求。1. 由于是不存在的 IP,服务端长时间收不到客户端的ACK,会导致服务端不断重发数据,直到耗尽服务端的资源。
    • 应对 增加 SYN 连接,减少 SYN + ACK 重试次数,避免大量的超时重发。1. 利用 SYN Cookie 技术,在服务端接收到SYN后不立即分配连接资源,而是根据这个SYN计算出一个Cookie,连同第二次握手回复给客户端,在客户端回复ACK的时候带上这个Cookie值,服务端验证 Cookie 合法之后才分配连接资源。
  • TCP 报文头部的字段 image.png

    • 源端口、目标端口
      • 如何标识唯一标识一个连接?答案是 TCP 连接的四元组——源 IP、源端口、目标 IP 和目标端口。
      • 那 TCP 报文怎么没有源 IP 和目标 IP 呢?这是因为在 IP 层就已经处理了 IP 。TCP 只需要记录两者的端口即可。
    • 序列号Sequence number, 指的是本报文段第一个字节的序列号。序列号是一个长为 4 个字节,也就是 32 位的无符号整数,表示范围为 0 ~ 2^32 - 1。如果到达最大值了后就循环到0。
      • 在 SYN 报文中交换彼此的初始序列号。
      • 保证数据包按正确的顺序组装。
    • ISNInitial Sequence Number(初始序列号),在三次握手的过程当中,双方会用过SYN报文来交换彼此的 ISN。ISN 并不是一个固定的值,而是每 4 ms 加一,溢出则回到 0,这个算法使得猜测 ISN 变得很困难。
    • 确认号ACK(Acknowledgment number)。用来告知对方下一个期望接收的序列号,小于ACK的所有字节已经全部收到。
    • 标记位常见的标记位有SYN,ACK,FIN,RST,PSH
      • SYN 和 ACK 已经在上文说过,后三个解释如下: FIN: 即 Finish,表示发送方准备断开连接。
      • RST:即 Reset,用来强制断开连接。
      • PSH: 即 Push, 告知对方这些数据包收到后应该马上交给上层的应用,不能缓存。
    • 窗口大小占用两个字节,也就是 16 位,但实际上是不够用的。因此 TCP 引入了窗口缩放的选项,作为窗口缩放的比例因子,这个比例因子的范围在 0 ~ 14,比例因子可以将窗口的值扩大为原来的 2 ^ n 次方。
    • 校验和占用两个字节,防止传输过程中数据包有损坏,如果遇到校验和有差错的报文,TCP 直接丢弃之,等待重传。
    • TimeStamp: TCP 时间戳,计算往返时延 RTT(表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延)。防止序列号回绕问题,因为每次发包的时候都是将发包机器当时的内核时间记录在报文中,那么两次发包序列号即使相同,时间戳也不可能相同,这样就能够区分开两个数据包了。
    • MSS: 指的是 TCP 允许的从对方接收的最大报文段。
    • SACK: 选择确认选项。(已经接受成功的标记)
    • Window Scale: 窗口缩放选项。
  • TCP 快速打开的原理(TFO)

    • 首轮三次握手
      • 客户端发送SYN给服务端,服务端接收到。
      • 现在服务端不是立刻回复 SYN + ACK,而是通过计算得到一个SYN Cookie, 将这个Cookie放到 TCP 报文的 Fast Open选项中,然后才给客户端返回。
      • 客户端拿到这个 Cookie 的值缓存下来。后面正常完成三次握手。
    • 后面的三次握手
      • 客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。
      • TFO 的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie 并验证通过以后,可以直接返回 HTTP 响应,充分利用了1 个RTT(Round-Trip Time,往返时延)的时间提前进行数据传输,积累起来还是一个比较大的优势。

image.png

  • 说一说 TCP 的流量控制

    • 对于发送端和接收端而言,TCP 需要把发送的数据放到发送缓存区, 将接收的数据放到接收缓存区。而流量控制所要做的事情,就是在通过接收缓存区的大小,控制发送端的发送。如果对方的接收缓存区满了,就不能再继续发送了。
    • 首先双方三次握手,初始化各自的窗口大小,均为 200 个字节。
    • 假如当前发送端给接收端发送 100 个字节,那么此时对于发送端而言,SND.NXT 当然要右移 100 个字节,也就是说当前的可用窗口减少了 100 个字节。
    • 现在这 100 个到达了接收端,被放到接收端的缓冲队列中。不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理 40 个字节,剩下的 60 个字节被留在了缓冲队列中。
    • 此时接收端的情况是处理能力不够用啦,你发送端给我少发点,所以此时接收端的接收窗口应该缩小,具体来说,缩小 60 个字节,由 200 个字节变成了 140 字节,因为缓冲队列还有 60 个字节没被应用拿走。因此,接收端会在 ACK 的报文首部带上缩小后的滑动窗口 140 字节,发送端对应地调整发送窗口的大小为 140 个字节。
    • 此时对于发送端而言,已经发送且确认的部分增加 40 字节,也就是 SND.UNA 右移 40 个字节,同时发送窗口缩小为 140 个字节。
  • 说说 TCP 的拥塞控制

    • 上一节所说的流量控制发生在发送端跟接收端之间,并没有考虑到整个网络环境的影响,如果说当前网络特别差,特别容易丢包,那么发送端就应该注意一些了。
    • 对于拥塞控制来说,TCP 每条连接都需要维护两个核心状态,拥塞窗口慢启动阈值
    • 发送窗口大小 = min(接收端的接收窗口, 发送端的拥塞窗口),取两者的较小值。而拥塞控制,就是来控制拥塞窗口cwnd的变化。
    • 刚开始进入传输数据的时候,你是不知道现在的网路到底是稳定还是拥堵的,如果做的太激进,发包太急,那么疯狂丢包。拥塞控制首先就是要采用一种保守的算法来慢慢地适应整个网路,这种算法叫慢启动
      • 首先,三次握手,双方宣告自己的接收窗口大小
      • 双方初始化自己的拥塞窗口(cwnd)大小
      • 在开始传输的一段时间,发送端每收到一个 ACK,拥塞窗口大小加 1,也就是说,每经过一个 RTT,cwnd 翻倍。如果说初始窗口为 10,那么第一轮 10 个报文传完且发送端收到 ACK 后,cwnd 变为 20,第二轮变为 40,第三轮变为 80,依次类推。
      • 它的阈值叫做慢启动阈值,当 cwnd 到达这个阈值之后,好比踩了下刹车。在到达阈值后,拥塞避免来控制 cwnd 的大小,
    • 拥塞避免,原来每收到一个 ACK,cwnd 加1,现在到达阈值了,cwnd 只能加这么一点: 1 / cwnd。那你仔细算算,一轮 RTT 下来,收到 cwnd 个 ACK, 那最后拥塞窗口的大小 cwnd 总共才增加 1。当然,慢启动拥塞避免是一起作用的,是一体的。
    • 快速重传,在 TCP 传输的过程中,如果发生了丢包,即接收端发现数据段不是按序到达的时候,接收端的处理是重复发送之前的 ACK。比如第 5 个包丢了,即使第 6、7 个包到达的接收端,接收端也一律返回第 4 个包的 ACK。当发送端收到 3 个重复的 ACK 时,意识到丢包了,于是马上进行重传,不用等到一个 RTO 的时间到了才重传。
    • 选择性重传,在收到发送端的报文后,接收端回复一个 ACK 报文,那么在这个报文首部的可选项中,就可以加上SACK这个属性,通过left edgeright edge告知发送端已经收到了哪些区间的数据报。因此,即使第 5 个包丢包了,当收到第 6、7 个包之后,接收端依然会告诉发送端,这两个包到了。剩下第 5 个包没到,就重传这个包。这个过程也叫做选择性重传
    • 快速恢复,发送端收到三次重复 ACK 之后,发现丢包,觉得现在的网络已经有些拥塞了,自己会进入快速恢复阶段。
      • 拥塞阈值降低为 cwnd 的一半
      • cwnd 的大小变为拥塞阈值
      • cwnd 线性增加
HTTP
  • GET 和 POST 的区别

    • 缓存的角度,GET 请求会被浏览器主动缓存下来,留下历史记录,而 POST 默认不会。
    • 编码的角度,GET 只能进行 URL 编码,只能接收 ASCII 字符,而 POST 没有限制。
    • 参数的角度,GET 一般放在 URL 中,因此不安全,POST 放在请求体中,更适合传输敏感信息。
    • 幂等性的角度,GET幂等的,而POST不是。(幂等表示执行相同的操作,结果也是相同的)
    • TCP的角度,GET 请求会把请求报文一次性发出去,而 POST 会分为两个 TCP 数据包,http1.1 支持只发送header信息首先发 header 部分,如果服务器响应 100(continue), 然后发 body 部分。(火狐浏览器除外,它的 POST 请求只发一个 TCP 包)
  • 简要概括一下 HTTP 的特点?HTTP 有哪些缺点?

    • HTTP 的特点概括如下:
      • 灵活可扩展,主要体现在两个方面。一个是语义上的自由,只规定了基本格式,比如空格分隔单词,换行分隔字段,其他的各个部分都没有严格的语法限制。另一个是传输形式的多样性,不仅仅可以传输文本,还能传输图片、视频等任意数据,非常方便。
      • 可靠传输。HTTP 基于 TCP/IP,因此把这一特性继承了下来。
      • 请求-应答。也就是一发一收有来有回, 当然这个请求方和应答方不单单指客户端和服务器之间,如果某台服务器作为代理来连接后端的服务端,那么这台服务器也会扮演请求方的角色。
      • 无状态。这里的状态是指通信过程的上下文信息,而每次 http 请求都是独立、无关的,默认不需要保留状态信息。
    • HTTP 缺点
      • 无状态所谓的优点和缺点还是要分场景来看的,对于 HTTP 而言,最具争议的地方在于它的无状态。在需要长连接的场景中,需要保存大量的上下文信息,以免传输大量重复的信息,那么这时候无状态就是 http 的缺点了。但与此同时,另外一些应用仅仅只是为了获取一些数据,不需要保存连接上下文信息,无状态反而减少了网络开销,成为了 http 的优点。
      • 明文传输即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式。这当然对于调试提供了便利,但同时也让 HTTP 的报文信息暴露给了外界,给攻击者也提供了便利。WIFI陷阱就是利用 HTTP 明文传输的缺点,诱导你连上热点,然后疯狂抓你所有的流量,从而拿到你的敏感信息。
      • 队头阻塞问题,当 http 开启长连接时,共用一个 TCP 连接,同一时刻只能处理一个请求-应答,那么当前请求耗时过长的情况下,其它的请求只能处于阻塞状态,也就是著名的队头阻塞问题。
        • 域名分片,一个域名可以并发 6 个长连接,那我就多分几个域名。比如 content1.test.com 、content2.test.com。这样一个test.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器,能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。
  • http1.0

HTTP/1.0 的方案是通过请求头和响应头来进行协商,在发起请求时候会通过 HTTP 请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。引入了状态码,提供了Cache 机制。它的特点是每次http请求和响应完毕后都会销毁 TCP 连接,同时规定前一个响应完成后才能发送下一个请求。这样做有两个问题:

  1. 无法复用连接,每次请求都要创建新的 TCP 连接,完成三次握手和四次挥手,网络利用率低
  2. 队头阻塞,如果前一个请求被某种原因阻塞了,会导致后续请求无法发送。
  • http1.1

http1.1 是 http1.0 的改进版,它做出了以下改进:

  1. 长连接,http1.1 允许在请求时增加请求头connection:keep-alive,持久连接在 HTTP/1.1 中是默认开启的。这样便允许后续的客户端http请求在一段时间内复用之前的 TCP 连接,减少了三次握手和四次挥手的次数,一定程度上提升了网络利用率。实际项目中往往把静态资源,比如图片,分发到不同域名下的资源服务器,以便实现真正的并行传输。但是它需要等待前面的请求返回之后,才能进行下一次请求。如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的http队头阻塞的问题。
  2. 管道化,基于长连接的基础,管道化可以不等第一个请求响应继续发送后面的请求,但响应的顺序还是按照请求的顺序返回。(队头阻塞原因)
  3. 缓存处理,新增响应头 cache-control,用于实现客户端缓存。
  4. 断点传输,请求头的 Range,在上传/下载资源时,如果资源过大,将其分割为多个部分,分别上传/下载,如果遇到网络故障,可以从已经上传/下载好的地方继续请求,不用从头开始,提高效率
  5. 通过文本进行传输。由于没有流的概念,不能进行多路复用,无状态,队头阻塞
  6. Chrome同一个域名下中是同时并发维护 6 个 TCP 持久连接。(使用 CDN 的实现域名分片机制)
  7. http1.1 支持只发送header信息(不带任何 body 信息),如果服务器认为客户端有权限请求服务器,则返回100,否则返回401(节约带宽)
  8. 引入了客户端 Cookie 机制和安全机制
  • HTTP/1.1 的主要问题

    • HTTP/1.1对带宽的利用率却并不理想。带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。
    • 比如我们常说的 100M 带宽,实际的下载速度能达到 12.5M/S,而采用 HTTP/1.1 时,也许在加载页面资源时最大只能使用到 2.5M/S,主要是由以下三个原因导致的:
      • 慢启动是 TCP 为了减少网络拥塞的一种策略, 刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态
      • 同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽。系统同时建立了多条 TCP 连接,当带宽充足时,每条连接发送或者接收速度会慢慢向上增加;而一旦带宽不足时,这些 TCP 连接又会减慢发送或者接收的速度。多条 TCP 连接之间又不能协商让哪些关键资源优先下载,这样就有可能影响那些关键资源的下载速度。
      • HTTP/1.1 队头阻塞
  • http2.0

    • HTTP/2 浏览器只需要为每个域名维护 1 个 TCP 持久连接,同时还解决了 HTTP/1.1 队头阻塞的问题。多路复用技术能充分利用带宽,最大限度规避了 TCP 的慢启动所带来的问题。
    • HTTP/2 使用了多路复用技术,可以将请求分成一帧一帧的数据去传输,这样带来了一个额外的好处,就是当收到一个优先级高的请求时,比如接收到 JavaScript 或者 CSS 关键资源的请求,服务器可以暂停之前的请求来优先处理关键资源的请求。服务器端接收到这些请求后,会根据自己的喜好来决定优先返回哪些内容,比如服务器可能早就缓存好了 index.html 和 index.js 的响应头信息,那么当接收到请求的时候就可以立即把 index.html 和 index.js 的响应头信息返回给浏览器,然后再将 index.html 和 index.js 的响应体数据返回给浏览器。
  1. 二进制帧和流,HTTP/2在TCP和TLS上添加了一个二进制分帧层。数据经过二进制分帧层处理之后,会被转换为一个个带有ID 编号的帧。将传输的消息分为更小的二进制帧,每帧有自己的标识序号Stream ID,每个帧的Stream ID标识出该帧属于哪个流,即便被随意打乱也能在另一端正确组装成完整的请求报文响应报文

    • 所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。
    • 并发性。一个 HTTP/2 连接上可以同时发多个流,这一点和 HTTP/1 不同。这也是实现多路复用的基础。
    • 自增性。流 ID 是不可重用的,而是会按顺序递增,达到上限之后又新开 TCP 连接从头开始。
    • 双向性。客户端和服务端都可以创建流,互不干扰,双方都可以作为发送方或者接收方。所谓的,其实就是二进制帧的双向传输的序列
    • 可设置优先级。可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。
  2. 多路复用,基于二进制分帧,在同一域名下所有访问都是从同一个 tcp 连接中走,并且不再有队头阻塞问题,也无须遵守响应顺序,解决HTTP队头阻塞,实现资源的并行传输。(TCP队头阻塞看hTTP3.0)

    • 原来Headers + Body的报文格式如今被拆分成了一个个二进制的帧,用Headers帧存放头部字段,Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的 HTTP 请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了 HTTP 的队头阻塞问题。
    • 通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做(Stream)。HTTP/2 用来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。
    • 所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。二进制帧到达后对方会将 Stream ID 相同的二进制帧组装成完整的请求报文响应报文。当然,在二进制帧当中还有其他的一些字段,实现了优先级流量控制等功能
  3. 头部压缩,HTTP/2 对请求头和响应头进行了压缩。http2.0 服务器和客户端之间建立哈希表,将用到的字段存放在这张表中,那么在传输的时候对于之前出现过的值,只需要把索引(比如0,1,2,...)传给对方即可,对方拿到索引查表就行了。这种传索引的方式,可以说让请求头字段得到极大程度的精简和复用。

  4. 服务器推送,在 http2.0 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,HTTP/2 还可以直接将数据提前推送到浏览器,当用户请求一个 HTML 页面之后,服务器知道该 HTML 页面会引用几个重要的 JavaScript 文件和 CSS 文件,那么在接收到 HTML 请求之后,附带将要使用的 CSS 文件和 JavaScript 文件一并发送给浏览器。

  5. HTTP/2 提供了请求优先级,可以在发送请求时,标上该请求的优先级,这样服务器接收到请求之后,会优先处理优先级高的请求。

  • TCP 的主要问题

    • TCP 上的队头阻塞。可以把 TCP 连接看成是一个按照顺序传输数据的管道,管道中的任意一个数据丢失了,那么整个 TCP 的连接就会处于暂停状态,那之后的数据都需要等待该数据的重新传输。当系统达到了 2% 的丢包率时,HTTP/1.1(6)的传输效率反而比 HTTP/2(1)表现得更好。
    • TCP 协议建立连接的网络延迟问题(RTT)。三次握手来确认连接成功(花1.5RTT),进行 TLS 连接,TLS 有两个版本——TLS1.2 和 TLS1.3(花1~2RTT)在传输数据之前,我们需要花掉 3~4 个 RTT。如果浏览器和服务器的物理距离较近,那么 1 个 RTT 的时间可能在 10 毫秒以内,也就是说总共要消耗掉 30~40 毫秒。
    • 中间设备僵化,使用了大量的 TCP 特性,这些功能被设置之后就很少更新了,操作系统也是导致 TCP 协议僵化的另外一个原因。因为 TCP 协议都是通过操作系统内核来实现的,应用程序只能使用不能修改。通常操作系统的更新都滞后于软件的更新,因此要想自由地更新内核中的 TCP 协议也是非常困难的
  • http3.0

    • 因为中间设备的僵化,这些设备只认 TCP 和 UDP,HTTP/3 选择了一个折衷的方法——UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为QUIC 协议。
      • 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
      • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
      • 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。
      • 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。
      • QUIC协议问题
        • 服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持
        • 部署 HTTP/3 也存在着非常大的问题。因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度
        • 中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~7% 的丢包率。

image.png

  • 对 Accept 系列字段了解
    • 对于Accept系列字段的介绍分为四个部分: 数据格式MIME type压缩方式gzip支持语言zh-CN, zh, en字符集charset=utf-8。 左边接收方,右边发送方

image.png

  • 对于定长和不定长的数据,HTTP 是怎么传输的?

    • 对于定长包体而言,发送端在传输的时候一般会带上 Content-Length, 来指明包体的长度。如果设置不当可以直接导致传输失败。
    • 对于不定长包体而言是,Transfer-Encoding: chunked;表示分块传输数据,设置这个字段后会自动产生两个效果, Content-Length 字段会被忽略,基于长连接Connection: keep-alive持续推送动态内容
  • HTTP 如何处理大文件的传输Content-Range

    • 对于客户端而言,它需要指定请求哪一部分,通过Range这个请求头字段确定,格式为bytes=x-y
    • 服务器收到请求之后,首先验证范围是否合法,如果越界了那么返回416错误码,否则读取相应片段,返回206状态码。
    • 同时,服务器需要添加Content-Range字段,这个字段的格式根据请求头中Range字段的不同而有所差异。如下。

单段数据

对于单段数据的请求,返回的响应如下:

HTTP/1.1 206 Partial Content
Content-Length: 10
Accept-Ranges: bytes
Content-Range: bytes 0-9/100

i am xxxxx

值得注意的是Content-Range字段,0-9表示请求的返回,100表示资源的总大小,很好理解。

多段数据

接下来我们看看多段请求的情况。得到的响应会是下面这个形式:

HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000010101
Content-Length: 189
Connection: keep-alive
Accept-Ranges: bytes


--00000010101
Content-Type: text/plain
Content-Range: bytes 0-9/96

i am xxxxx
--00000010101
Content-Type: text/plain
Content-Range: bytes 20-29/96

eex jspy e
--00000010101--

这个时候出现了一个非常关键的字段Content-Type: multipart/byteranges;boundary=00000010101,它代表了信息量是这样的:

  • 请求一定是多段数据请求
  • 响应体中的分隔符是 00000010101

因此,在响应体中各段数据之间会由这里指定的分隔符分开,而且在最后的分隔末尾添上--表示结束。

以上就是 http 针对大文件传输所采用的手段。

  • HTTP 中如何处理表单数据的提交?

    • 在 http 中,有两种主要的表单提交的方式,体现在两种不同的Content-Type取值

    • application/x-www-form-urlencoded对于application/x-www-form-urlencoded格式的表单内容,有以下特点:

      • 其中的数据会被编码成以&分隔的键值对
      • 字符以URL编码方式编码。
      • 如:
      // 转换过程: {a: 1, b: 2} -> a=1&b=2 -> 如下(最终形式)
      "a%3D1%26b%3D2"
      
    • multipart/form-data对于multipart/form-data而言:

      • 请求头中的Content-Type字段会包含boundary,且boundary的值有浏览器默认指定。例: Content-Type: multipart/form-data;boundary=----WebkitFormBoundaryRRJKeWfHPGrS4LKe

      • 数据会分为多个部分,每两个部分之间通过分隔符来分隔,每部分表述均有 HTTP 头部描述子包体,如Content-Type,在最后的分隔符会加上--表示结束。

      • 在实际的场景中,对于图片等文件的上传,基本采用multipart/form-data而不用application/x-www-form-urlencoded,因为没有必要做 URL 编码,带来巨大耗时的同时也占用了更多的空间。

      • 相应的请求体是下面这样:

      Content-Disposition: form-data;name="data1";
      Content-Type: text/plain
      data1
      ----WebkitFormBoundaryRRJKeWfHPGrS4LKe
      Content-Disposition: form-data;name="data2";
      Content-Type: text/plain
      data2
      ----WebkitFormBoundaryRRJKeWfHPGrS4LKe--
      
  • 跨域CORS

    • 什么是跨域?

      • 浏览器遵循同源政策(scheme(协议)host(主机)port(端口)都相同则为同源)。进而实现 Web 页面的安全性。非同源站点有这样一些限制:
        • DOM 层面。不能读取和修改对方的 DOM(可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。)
        • 数据层面。 不读访问对方的 Cookie、IndexDB 和 LocalStorage
        • 网络层面。限制 XMLHttpRequest 请求。
      • 当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求
      • 浏览器会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。
      • 跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。浏览器主进程检查到跨域,且没有cors(后面会详细说)响应头,将响应体全部丢掉,并不会发送给渲染进程,达到了拦截数据的目的。
    • 浏览器出让了同源策略的哪些安全性?

      • 页面中可以嵌入第三方资源,暴露了很多诸如 XSS和CSRF的安全问题,因此又在这种开放的基础之上引入了 CSP 来限制其自由程度。
      • (xhr不能跨域)跨域资源共享
      • (不同源DOM不能通信)跨文档消息机制window.postMessage
    • CORS 其实是 W3C 的一个标准,全称是跨域资源共享。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS

    • 简单请求:求方法为 GET、POST 或者 HEAD,请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain)。其他请求为非简单请求。

    • 简单请求

      • 浏览器会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。
      • Access-Control-Allow-Credentials。这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置xhr.withCredentials = true;
    • 非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段

      • 预检请求的方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:
      • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
      • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头
      • 接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。
      • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。
      • Access-Control-Allow-Methods: 表示允许的请求方法列表。
      • Access-Control-Allow-Credentials: 简单请求中已经介绍。
      • Access-Control-Allow-Headers: 表示允许发送的请求头字段
      • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。
      • 在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequestonerror方法,当然后面真正的CORS请求也不会发出去了。
      • CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。
    • 使用JSNOP

image.png

  • 使用HTML5新引进的window.postMessage方法

image.png

六.浏览器安全问题

  • 站点隔离

    • 指 Chrome 将同一站点(包含了相同根域名和相同协议的地址)中相互关联的页面放到同一个渲染进程中执行。
    • 实现了站点隔离,就可以将恶意的 iframe 隔离在恶意进程内部,使得它无法继续访问其他 iframe 进程的内容,因此也就无法攻击其他站点了。(最开始 Chrome 划分渲染进程是以标签页为单位,这些 iframe 又有可能来自于不同的站点,这就导致了多个不同站点中的内容通过 iframe 同时运行在同一个渲染进程中。漏洞)
  • 安全沙箱

    • 渲染进程需要执行 DOM 解析、CSS 解析、网络图片解码等操作,如果渲染进程中存在系统级别的漏洞,那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限,进而又获取操作系统的控制权限,这对于用户来说是非常危险的,在渲染进程和操作系统之间建一道墙。
    • 浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,需要通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程。
    • 安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用。
    • 持久存储,存储 Cookie 数据的读写。通常浏览器内核会维护一个存放所有 Cookie 的 Cookie 数据库,然后当渲染进程通过 JavaScript 来读取 Cookie 时,渲染进程会通过 IPC 将读取 Cookie 的信息发送给浏览器内核,浏览器内核读取 Cookie 之后再将内容返回给渲染进程。
    • 如果要访问网络,则需要通过浏览器内核。不过浏览器内核在处理 URL 请求之前,会检查渲染进程是否有权限请求该 URL,比如检查 XMLHttpRequest 或者 Fetch 是否是跨站点请求,或者检测 HTTPS 的站点中是否包含了 HTTP 的请求。
    • 用户交互,渲染进程内部是无法直接操作窗口句柄的,这也是为了限制渲染进程监控到用户的输入事件。
      • 第一点,渲染进程需要渲染出位图。为了向用户显示渲染进程渲染出来的位图,渲染进程需要将生成好的位图发送到浏览器内核,然后浏览器内核将位图复制到屏幕上。
      • 操作系统没有将用户输入事件直接传递给渲染进程,而是将这些事件传递给浏览器内核。然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件,如果当前焦点位于浏览器地址栏中,则输入事件会在浏览器内核内部处理;如果当前焦点在页面的区域内,则浏览器内核会将输入事件转发给渲染进程。
      • 为了限制渲染进程有监控到用户输入事件的能力,所以所有的键盘鼠标事件都是由浏览器内核来接收的,然后浏览器内核再通过 IPC 将这些事件发送给渲染进程。

image.png

  • 什么是 XSS 攻击?

    • XSS 全称是 Cross Site Scripting(即跨站脚本),XSS 攻击是指黑客往 HTML 文件中或者 DOM 中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。

    • 可以窃取 Cookie 信息。恶意 JavaScript 可以通过“document.cookie”获取 Cookie 信息,拿到用户的 Cookie 信息之后,就可以在其他电脑上模拟用户的登录,然后进行转账等操作;

    • 可以监听用户行为。恶意 JavaScript 可以使用“addEventListener”接口来监听键盘事件;可以通过修改 DOM伪造假的登录窗口,用来欺骗用户输入用户名和密码等信息。

    • 存储型 XSS 攻击,利用站点漏洞将一段恶意 JavaScript 代码提交到网站的数据库中;用户向网站请求包含了恶意 JavaScript 脚本的页面;用户浏览该页面的时候,恶意脚本执行。

    • 基于 DOM 的 XSS 攻击,通过网络劫持在页面传输过程中修改 HTML 页面的内容,这种劫持类型很多,有通过 WiFi 路由器劫持的,有通过本地恶意软件来劫持的,它们的共同点是在 Web 资源传输过程或者在用户使用页面的过程中修改 Web 页面的数据

  • 防范措施

    • 无论是在前端和服务端,都要对用户的输入进行转码html或者过滤script标签
    • 利用 CSP(CSP的核心思想是让服务器决定浏览器能够加载哪些资源,限制加载其他域下的资源文件。让服务器决定浏览器是否能够执行内联 JavaScript 代码),
    • HttpOnly是服务器通过 HTTP 响应头set-cookie来设置的。利用 Cookie 的 HttpOnly 属性,无法通过 document.cookie 是来读取的。。secure设置为true时,只能在 HTTPS 连接中被浏览器传递到服务器端进行会话验证。
  • 什么是CSRF攻击?

    • CSRF 英文全称是 Cross-site request forgery,所以又称为“跨站请求伪造”,是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。简单来讲,CSRF 攻击就是黑客利用了用户的登录状态,并通过第三方的站点来做一些坏事

黑客网页里面可能有一段这样的代码:

<img src="https://xxx.com/info?user=hhh&count=100">

进入页面后自动发送 get 请求,值得注意的是,这个请求浏览器会自动发送带上关于 xxx.com 的 cookie 信息(这里是假定你已经在 xxx.com 中登录过)。

假如服务器端没有相应的验证机制,它可能认为发请求的是一个正常的用户,因为携带了相应的 cookie,然后进行相应的各种操作,可以是转账汇款以及其他的恶意操作。

CSRF 攻击并不需要将恶意代码注入用户当前页面的html文档中,而是跳转到新的页面,利用服务器的验证漏洞用户之前的登录状态来模拟用户进行操作。

  • 防范措施
    • 利用Cookie的SameSite属性,在Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求test.com网站只能在test.com域名当中请求才能携带 Cookie,在其他网站请求都不能
    • 在服务器端验证请求来源的站点,这就需要要用到请求头中的两个字段: OriginReferer,Origin 属性只包含了域名信息,并没有包含具体的 URL 路径,这是 Origin 和 Referer 的一个主要区别。服务器的策略是优先判断 Origin。这两者都是可以伪造的,通过 Ajax 中自定义请求头即可,安全性略差
    • CSRF Token 浏览器向服务器发送请求时,服务器生成一个字符串,将其植入到返回的页面中,然后浏览器如果要发送请求,就必须带上这个字符串,然后服务器来验证是否合法
DDOS攻击

向服务器提出大量的服务请求、使服务器超负荷。阻断某一用户访问服务器。- 阻断某服务与特定系统或个人的通讯。

  • DDOS 清洗:对用户请求的数据进行实时监控,及时发现 DOS 攻击等异常流量,在不影响正常业务开展的情况下清洗掉这些异常流量。
  • 黑名单。
http劫持

我们使用 HTTP 明文传输的内容很容易被中间人窃取、伪造和篡改,通常我们把这种攻击方式称为中间人攻击具体来讲,在将 HTTP 请求响应数据提交给 TCP 层之前,数据会经过用户电脑、WiFi 路由器、运营商和目标服务器,在这中间的每个环节中,数据都有可能被窃取或篡改。比如用户电脑被黑客安装了恶意软件,那么恶意软件就能抓取和篡改所发出的 HTTP 请求的内容。或者用户一不小心连接上了 WiFi 钓鱼路由器,那么数据也都能被黑客抓取或篡改。

DNS 劫持

是指攻击者劫持了 DNS 服务器,获得了修改 DNS 解析记录的权限,从而导致客户端请求的域名被解析到了错误的 IP 地址,攻击者通过这种方式窃取用户资料或破坏原有正常服务。

HTTPS 中间人攻击

https 有防篡改的特点,只要浏览器证书验证过程是正确的,很难在用户不察觉的情况下进行攻击。但若能够更改浏览器的证书验证过程,便有机会实现 https 中间人攻击。所以,要劫持 https,首先要伪造一个证书,并且要想办法让用户信任这个证书

针对 HTTPS 攻击主要有 SSL 劫持攻击和 SSL 剥离攻击两种。

SSL 劫持攻击是指攻击者劫持了客户端和服务器之间的连接,将服务器的合法证书替换为伪造的证书,从而获取客户端和服务器之间传递的信息。这种方式一般容易被用户发现,浏览器会明确的提示证书错误,但某些用户安全意识不强,可能会点击继续浏览,从而达到攻击目的。

SSL 剥离攻击是指攻击者劫持了客户端和服务器之间的连接,攻击者保持自己和服务器之间的 HTTPS 连接,但发送给客户端普通的 HTTP 连接,由于 HTTP 连接是明文传输的,即可获取客户端传输的所有明文数据。

HTTPS为什么让数据传输更安全?

HTTPS并不是一个新的协议, 它在HTTPTCP的传输中建立了一个安全层TLS,安全层有两个主要的职责:对发起 HTTP 请求的数据进行加密操作对接收到 HTTP 的内容进行解密操作。利用对称加密非对称加密结合数字证书认证的方式,让传输过程的安全性大大提高。

SSL 即安全套接层(Secure Sockets Layer),在 OSI 七层模型中处于会话层(第 5 层)。之前 SSL 出过三个大版本,当它发展到第三个大版本的时候才被标准化,成为 TLS(传输层安全,Transport Layer Security),并被当做 TLS1.0 的版本,准确地说,TLS1.0 = SSL3.1,在 2018 年推出了更加优秀的 TLS1.3。

首先需要理解对称加密非对称加密的概念,然后讨论两者应用后的效果如何。

对称加密是最简单的方式,指的是加密解密用的是同样的密钥

而对于非对称加密,如果有 A、 B 两把密钥,如果用 A 加密过的数据包只能用 B 解密,反之,如果用 B 加密过的数据包只能用 A 解密。

对称加密和非对称加密的结合

可以发现,对称加密和非对称加密,单独应用任何一个,都会存在安全隐患。那我们能不能把两者结合,进一步保证安全呢?

其实是可以的,演示一下整个流程:

  • 首先浏览器向服务器发送对称加密套件列表、非对称加密套件列表和随机数 client-random;
  • 服务器保存随机数 client-random,选择对称加密和非对称加密的套件,然后生成随机数 service-random,向浏览器发送选择的加密套件、service-random 和返回了数字证书,而公钥正是包含在数字证书中的;
  • 浏览器端多了一个证书验证的操作,验证了证书之后,浏览器保存公钥,并利用 client-random 和 service-random 计算出来 pre-master,然后利用公钥对 pre-master 加密,并向服务器发送加密后的数据;
  • 最后服务器拿出自己的私钥,解密出 pre-master 数据,并返回确认消息。
  • 服务器和浏览器就有了共同的 client-random、service-random 和 pre-master,然后服务器和浏览器会使用这三组随机数生成对称密钥,因为服务器和浏览器使用同一套方法来生成密钥,所以最终生成的密钥也是相同的。然后浏览器和服务器尽管用一样的密钥进行通信,即使用对称加密
  • pre-master 是经过公钥加密之后传输的,所以黑客无法获取到 pre-master,这样黑客就无法生成密钥,也就保证了黑客无法破解传输过程中的数据了

添加数字证书

尽管通过两者加密方式的结合,能够很好地实现加密传输,但实际上还是存在一些问题。黑客如果采用 DNS 劫持,将目标地址替换成黑客服务器的地址,然后黑客自己造一份公钥和私钥,照样能进行数据传输。而对于浏览器用户而言,他是不知道自己正在访问一个危险的服务器的。

事实上HTTPS在上述结合对称和非对称加密的基础上,又添加了数字证书认证的步骤。其目的一个是通过数字证书向浏览器证明服务器的身份,另一个是数字证书里面包含了服务器公钥。

为了获取这个证书,服务器运营者需要向第三方认证机构获取授权,这个第三方机构也叫CA(Certificate Authority), 认证通过后 CA 会给服务器颁发数字证书。通过引入数字证书,我们就实现了服务器的身份认证功能,这样即便黑客伪造了服务器,但是由于证书是没有办法伪造的,所以依然无法欺骗用户。

现在我们来梳理一下HTTPS最终的加解密过程:

image.png

  • 浏览器拿到数字证书后,如何来对证书进行认证呢?
    • 浏览器接收到CA 签名过的数字证书之后,会对数字证书进行验证。首先浏览器读取证书中相关的明文信息,采用 CA 签名时相同的 Hash 函数来计算并得到信息摘要 A;然后再利用对应 CA 的公钥解密签名数据,得到信息摘要 B;对比信息摘要 A 和信息摘要 B,如果一致,则可以确认证书是合法的。同时浏览器还会验证证书相关的域名信息、有效时间等信息。
    • 相当于验证了 CA 是谁,但是这个 CA 可能比较小众,浏览器不知道该不该信任它,然后浏览器会继续查找给这个 CA 颁发证书的 CA,再以同样的方式验证它上级 CA 的可靠性。通常情况下,操作系统中会内置信任的顶级 CA 的证书信息(包含公钥),如果这个 CA 链中没有找到浏览器内置的顶级的 CA,证书也会被判定非法。

七.性能优化

1.CDN

内容分发网络简称CDN,指一组分布在各地存储数据副本并可根据 就近原则(DNS服务器及上级的CDN调度服务器计算解析就近ip) 满足数据请求的服务器。其核心特征是缓存回源,缓存是把资源复制到CDN服务器里,回源是资源过期/不存在就向上层服务器请求并复制到CDN服务器里。

  • 所有静态资源走CDN:开发阶段确定哪些文件属于静态资源
  • 把静态资源与主页面置于不同域名下:避免请求带上Cookie

image.png

2.HTTP缓存

善用缓存,不重复加载相同的资源 cache-control

  • 频繁变动资源:设置Cache-Control:no-cache,使浏览器每次都发送请求到服务器,配合Last-Modified/ETag验证资源是否有效

  • 不常变化资源:设置Cache-Control:max-age=31536000,对文件名哈希处理,当代码修改后生成新的文件名,当HTML文件引入文件名发生改变才会下载最新文件

减少HTTP请求,使用HTTP2 多路复用,头部压缩,服务器推送

3.图片优化

图片延迟加载,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站TinyPNG进行压缩。

降低图片质量,JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别。WebP优化的最好。同样图片效果,WebP体积最小,WebP如何做兼容。

<picture> 
    <source srcset='img/xx-small.webp' type="image/webp"> 
    <img src="img/xx-small.jpg"> 
</picture>

不支持<picture> 标签的话,需要polyfill

媒体查询响应式图像优化,不同的机器尺寸,准备不同的图片大小

使用 requestAnimationFrame 来实现视觉变化

使用 Web Workers

Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面,适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。

4.webpack优化

压缩文件

在 webpack 可以使用如下插件进行压缩:JavaScript:UglifyPlugin,CSS :MiniCssExtractPlugin,HTML:HtmlWebpackPlugin

代理服务器开启gzip 压缩

将路由页面/触发性功能单独打包为一个文件按需加载,好处是减轻首屏渲染的负担webpack v4提供模块按需切割加载功能,配合import()可做到首屏渲染减包的效果,从而加快首屏渲染速度。只有当触发某些功能时才会加载当前功能的JS代码

webpack v4提供魔术注解命名切割模块,若无注解则切割出来的模块无法分辨出属于哪个业务模块,所以一般都是一个业务模块共用一个切割模块的注解名称。

const Login = () => import( /* webpackChunkName: "login" */ "../../views/login");
const Logon = () => import( /* webpackChunkName: "logon" */ "../../views/logon");

运行起来控制台可能会报错,在package.jsonbabel相关配置里接入@babel/plugin-syntax-dynamic-import即可。

{
    ...
    "babel": {
       ...
        "plugins": [
            ...
            "@babel/plugin-syntax-dynamic-import"
        ]
    }
}

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。

当你设置 mode 是 production,那么 webpack 4 就会自动开启 Code Splitting,可以完成将某些公共模块去重,打包成一个单独的chunk。提取库分包,将 webpack的runtimeChunk代码拆分为一个单独的‘manifest’chunk使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

你设置 mode 是 production,那么 webpack 4 就会自动开启。Tree shaking只对ESM规范生效,对其他模块规范失效。摇树优化针对静态结构分析,只有import/export才能提供静态的导入/导出功能。因此在编写业务代码时必须使用ESM规范才能让摇树优化移除重复代码和未使用代码,所以如果你使用了 babel 插件的时候,如:babel-preset-env,它默认会将模块打包成commonjs,这样就会让Tree Shaking失效了。

性能优化的9大策略和6大指标 juejin.cn/post/698167…

5.前端性能监控

juejin.cn/post/701797… juejin.cn/post/695565…

  • navigator.userAgent 终端环境数据

  • performance.timing 提供页面加载过程的性能数据。

image.png

  • performance.getEntriesByType('resource') 提供页面所包含的脚本、样式表、图片等资源加载的性能数据

  • 一般我们都以NavigationStart或者FetchStart的值作为页面加载的开始时间,以ResponseEnd作为分隔点

    • 在 ResponseEnd 之前的时间一般归属到网络相关的消耗
    • 在 ResponseEnd 之后的时间一般归属到终端渲染的消耗,最后以LoadEventEnd作为结束时间。
目标指标计算方式定义
DNS解析时间domainLookupEnd - domainLookupStart从发起页面域名解析至完成
TCP建立连接时间connectEnd - connectStart从发起TCP连接至三次握手完成
请求等待时间responseStart - requestStart从发起页面请求至服务器返回第一个字节
文档下载时间responseEnd - responseStart从接收服务器返回第一个字节至主页面下载完成
备注:以上都是各时间段耗时,以下都是total_time,以StartTime为基准
首字节时间TTFBresponseStart - StartTime
domContentLoaded时间(html解析完,css和img不一定解析完了)domContentLoadedEventEnd - StartTime比较接近白屏的时间
onload页面加载完成时间(所有的资源都解析完了)loadEventEnd - StartTime
页面白屏时间window.chrome.loadTimes().firstPaintTime*1000 - StartTime
  • 数据上报方式
    • 发起一个同步 XMLHttpRequest 来发送数据。
    • 创建一个<img>(1*1的.gif最小) 元素并设置 src(跨域),大部分用户代理会延迟卸载(unload)文档以加载图像。
    • 上述的所有方法都会迫使用户代理延迟卸载文档,并使得下一个导航出现的更晚。下一个页面对于这种较差的载入表现无能为力。
    • 在许多情况下(尤其是移动设备)浏览器不会产生 unloadbeforeunload 或 pagehide 事件,如用户切换到了其它应用程序,而不是关闭选项卡。
    • navigator.sendBeacon(),数据异步发送是可靠的,不影响下一导航的载入
document.addEventListener('visibilitychange', function logData() {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/log', analyticsData);
  }
});
  • 错误数据采集
    • 资源加载失败和js运行时错误,window.addEventListener('error', e => {}, true)
    • js运行时错误,window.onerror = (...args) => {}
    • promise错误,window.addEventListener('unhandledrejection', e => {}, true)当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。
    • Vue,Vue.config.errorHandler = (err, vm, info) => {}。能捕获vue组件编译语法的错误,不能捕获原生事件,异步回调,自身的错误。
    • React,高阶组件的componentDidCatch捕获所有下层组件错误。错误边界的作用范围:仅捕获 渲染阶段(render phase)的错误,包括: render 方法; 生命周期方法(如 componentDidMount); 子组件构造函数。 不覆盖的阶段:事件处理、异步回调、自身的错误(需向上冒泡),服务端渲染。需结合 try/catch、全局监听window.addEventlistener('error |unhandledrejection')及分层错误边界设计兜底方案。
    • sourcemap对这些压缩过的代码报错信息进行还原。
// error为onerror事件的e对象,有url,line,column等属性
async function parse(error) {
    const mapObj = JSON.parse(getMapFileContent(error.url))
    const consumer = await new sourceMap.SourceMapConsumer(mapObj)
    // 将 webpack://source-map-demo/./src/index.js 文件中的 ./ 去掉
    const sources = mapObj.sources.map(item => format(item))
    // 根据压缩后的报错信息得出未压缩前的报错行列数和源码文件
    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
    // sourcesContent 中包含了各个文件的未压缩前的源码,根据文件名找出对应的源码
    const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
    return {
        file: originalInfo.source,
        content: originalFileContent,
        line: originalInfo.line,
        column: originalInfo.column,
        msg: error.msg,
        error: error.error
    }
}

function format(item) {
    return item.replace(/(./)*/g, '')
}

function getMapFileContent(url) {
    return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')
}

VitePress搭建一个vue3组件库 juejin.cn/post/702444…

使用Dumi快速搭建React组件库 juejin.cn/post/690479…

原生JS

原生JS灵魂之问(上)juejin.cn/post/684490…

原生JS灵魂之问(中)juejin.cn/post/684490…

原生JS灵魂之问(下)juejin.cn/post/684490…

56个JavaScript高级的手写:juejin.cn/post/702390…

一篇够用的TypeScript总结: juejin.cn/post/698172…