JavaScript 知识点(二)

1,240 阅读1小时+

浏览器

浏览器架构设计

进程和线程

一个进程就是一个程序的运行实例。

详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。

单线程与多线程的进程对比图

​ 单线程与多线程的进程对比图

线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。

进程和线程之间的关系有以下 4 个特点。

  • 进程中的任意一线程执行出错,都会导致整个进程的崩溃。

  • 线程之间共享进程中的数据。

    • ​ 线程之间共享进程中的数据示意图

      从上图可以看出,线程 1、线程 2、线程 3 分别把执行的结果写入 A、B、C 中,然后线程 2 继续从 A、B、C 中读取数据,用来显示执行结果。

  • 当一个进程关闭之后,操作系统会回收进程所占用的内存。

    • 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。比如之前的 IE 浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。
  • 进程之间的内容相互隔离。

    • 进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据。
    • 如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。
单进程浏览器时代

单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里

如此多的功能模块运行在一个进程里,是导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。

  • 不稳定
    • 早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。
    • 除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。
  • 不流畅
    • 页面的内存泄漏
    • 同一时刻只能有一个模块可以执行。
    • 所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的。
  • 不安全
多进程浏览器时代

​ chrome 进程架构

​ Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。

通过单独进程模式来解决单进程浏览器碰到 不稳定 不流畅的问题,通过sandbox(安全沙箱)来解决安全问题。

Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。

现代浏览器

​ 从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

  • 浏览器进程: 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程: 核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程: GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程: 主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程: 主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

负面问题

  • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。
未来面向服务的架构

为了解决这些问题,在 2016 年,Chrome 官方团队使用**"面向服务的架构"**(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。

也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。

​ Chrome“面向服务的架构”进程模型图

同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

​ 在资源不足的设备上,将服务合并到浏览器进程中

网络协议

IP

数据包要在互联网上进行传输,就要符合网际协议(Internet Protocol,简称 IP)标准。

计算机的地址就称为 IP 地址,访问任何网站实际上只是你的计算机向另外一台计算机请求信息。

UDP:把数据包送达应用程序

无需建立连接就可以发送封装的 IP 数据报的方法

UDP 协议基本上是IP协议与上层协议的接口。UDP协议适用端口分别运行在同一台设备上的多个应用程序。

IP 是非常底层的协议,只负责把数据包传送到对方电脑,但是对方电脑并不知道把数据包交给哪个程序,是交给浏览器还是交给xxx?因此,需要基于 IP 之上开发能和应用打交道的协议,最常见的是“用户数据包协议(User Datagram Protocol)”,简称 UDP。

UDP 中一个最重要的信息是端口号,端口号其实就是一个数字,每个想访问网络的程序都需要绑定一个端口号。通过端口号 UDP 就能把指定的数据包发送给指定的程序了,所以 IP 通过 IP 地址信息把数据包发送给指定的电脑,而 UDP 通过端口号把数据包分发给正确的程序。和 IP 头一样,端口号会被装进 UDP 头里面,UDP 头再和原始数据包合并组成新的 UDP 数据包。UDP 头中除了目的端口,还有源端口号等信息。

​ 简化的 UDP 网络四层传输模型

在使用 UDP 发送数据时,有各种因素会导致数据包出错,虽然 UDP 可以校验数据是否正确,但是对于错误的数据包,UDP 并不提供重发机制,只是丢弃当前的包,而且 UDP 在发送之后也无法知道是否能达到目的地。

虽说 UDP 不能保证数据可靠性,但是传输速度却非常快,所以 UDP 会应用在一些关注速度、但不那么严格要求数据完整性的领域,如在线视频、互动游戏等。

TCP: 把数据完整地送达应用程序

对于浏览器请求,或者邮件这类要求数据传输可靠性(reliability)的应用,如果使用 UDP 来传输会存在两个问题:

  • 数据包在传输过程中容易丢失;
  • 大文件会被拆分成很多小的数据包来传输,这些小的数据包会经过不同的路由,并在不同的时间到达接收端,而 UDP 协议并不知道如何组装这些数据包,从而把这些数据包还原成完整的文件。

基于这两个问题,我们引入 TCP 了。TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。相对于 UDP,TCP 有下面两个特点:

  • 对于数据包丢失的情况,TCP 提供重传机制;
  • TCP 引入了数据包排序机制,用来保证把乱序的数据包组合成一个完整的文件。

和 UDP 头一样,TCP 头除了包含了目标端口和本机端口号外,还提供了用于排序的序列号,以便接收端通过序号来重排数据包

​ 简化的 TCP 网络四层传输模型

TCP 单个数据包的传输流程和 UDP 流程差不多,不同的地方在于,通过 TCP 头的信息保证了一块大的数据传输的完整性。

一个完整的 TCP 连接的生命周期包括了“建立连接”“传输数据”和“断开连接”三个阶段。

​ 一个 TCP 连接的生命周期

  • 首先,建立连接阶段。这个阶段是通过“三次握手”来建立客户端和服务器之间的连接。TCP 提供面向连接的通信传输。面向连接是指在数据通信开始之前先做好两端之间的准备工作。所谓三次握手,是指在建立一个 TCP 连接时,客户端和服务器总共要发送三个数据包以确认连接的建立。
  • 其次,传输数据阶段。 在该阶段,接收端需要对每个数据包进行确认操作,也就是接收端在接收到数据包之后,需要发送确认数据包给发送端。所以当发送端发送了一个数据包之后,在规定时间内没有接收到接收端反馈的确认消息,则判断为数据包丢失,并触发发送端的重发机制。同样,一个大的文件在传输过程中会被拆分成很多小的数据包,这些数据包到达接收端后,接收端会按照 TCP 头中的序号为其排序,从而保证组成完整的数据。
  • **最后,断开连接阶段。**数据传输完毕之后,就要终止连接了,涉及到最后一个阶段“四次挥手”来保证双方都能断开连接。

TCP 为了保证数据传输的可靠性,牺牲了数据包的传输速度,因为“三次握手”和“数据包校验机制”等把传输过程中的数据包的数量提高了一倍。

http和TCP协议

HTTP协议和TCP协议都是TCP/IP协议簇的子集。

HTTP协议属于应用层,TCP协议属于传输层,HTTP协议位于TCP协议的上层。

请求方要发送的数据包,在应用层加上HTTP头以后会交给传输层的TCP协议处理,应答方接收到的数据包,在传输层拆掉TCP头以后交给应用层的HTTP协议处理。建立 TCP 连接后会顺序收发数据,请求方和应答方都必须依据 HTTP 规范构建和解析HTTP报文。

tcp协议是传输协议,如何运输,运输内容就是http协议中的报文。

HTTP协议请求流程
  • 构建请求

    • 首先,浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求。
  • 查找缓存

    • 在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术。
  • 准备 IP 地址和端口

    • 浏览器使用 HTTP 协议作为应用层协议,用来封装请求的文本信息;并使用 TCP/IP 作传输层协议将它发到网络上,所以在 HTTP 工作开始之前,浏览器需要通过 TCP 与服务器建立连接。也就是说 HTTP 的内容是通过 TCP 的传输数据阶段来实现的

    • ​ TCP 和 HTTP 的关系示意图

    • 把域名和 IP 地址做一一映射关系。这套域名映射为 IP 的系统就叫做“域名系统

    • 第一步浏览器会请求 DNS 返回域名对应的 IP。 (包含DNS 数据缓存服务)

    • 等待 TCP 队列。(同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。)

    • 建立 TCP 连接

    • 发送 HTTP 请求

      • ​ HTTP 请求数据格式

      • 首先浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。

  • 服务器端处理 HTTP 请求流程

    • 返回请求

      • ​ 服务器返回内容

    • 断开连接

      • 通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:Connection:Keep-Alive

        那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。比如,一个 Web 页面中内嵌的图片就都来自同一个 Web 站点,如果初始化了一个持久连接,你就可以复用该连接,以请求其他资源,而不需要重新再建立新的 TCP 连接。

    • 重定向

      • 服务器返回302时会进行重定向
  • 缓存

    • ​ 缓存查找流程示意图

  • 总结图

    • ​ HTTP 请求流程示意图

从输入URL到页面展示,这中间发生了什么?

​ 从输入 URL 到页面展示完整流程示意图

大致步骤

  • 首先,用户从浏览器进程里输入请求信息;
  • 然后,网络进程发起 URL 请求;
  • 服务器响应 URL 请求之后,浏览器进程就又要开始准备渲染进程了;
  • 渲染进程准备好之后,需要先向渲染进程提交页面数据,我们称之为提交文档阶段;
  • 渲染进程接收完文档信息之后,便开始解析页面和加载子资源,完成页面的渲染。

详细步骤

  • 用户输入

    • 如果判断输入内容符合 URL 规则,比如输入的是 time.geekbang.org,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL
  • URL 请求过程

    • 浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。

      • 首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接

      • ​ https和http区别

      • 接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。

    • 重定向

      • 在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
    • 响应数据类型处理

      • 在处理了跳转信息之后,我们继续导航流程的分析。URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面。
      • Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。
  • 准备渲染进程

    • Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance。
  • 提交文档

    • 这里的“文档”是指 URL 请求的响应体数据。相当于响应数据
    • “提交文档”的消息是由浏览器进程发出的,渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”。
    • 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程。
    • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
    • 更新内容如下图所示:
  • 渲染阶段

    一旦文档被提交,渲染进程便开始页面解析和子资源加载了

    • 构建 DOM 树

    • 样式计算(Recalculate Style)

      • 通过 link 引用的外部 CSS 文件
      • 通过 link 引用的外部 CSS 文件
      • 元素的 style 属性内嵌的 CSS

      样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

      • 把 CSS 转换为浏览器能够理解的结构

        • 当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。

        • ​ styleSheets

      • 转换样式表中的属性值,使其标准化

        CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

        • ​ 标准化属性值

      • 计算出 DOM 树中每个节点的具体样式

        • CSS 的继承规则

          • body { font-size: 20px }
            p {color:blue;}
            span {display: none}
            div {font-weight: bold;color:red}
            div p {color:green;}
            
          • ​ 计算后 DOM 的样式

        • 层叠规则

          css层叠

          层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。

        样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。

    • 布局

      计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。

      Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。

      • 创建布局树

        • ​ 布局树构造过程示意图

        • 为了构建布局树,浏览器大体上完成了下面这些工作:

          • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局中;
          • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。
      • 布局计算

        • 在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容。

          这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

        • 如果下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?

    • 分层

      页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

      ​ 布局树和图层树关系示意图

      通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层

      那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?

      通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

      • 拥有层叠上下文属性的元素会被提升为单独的一层。

        • 层叠上下文

        • 根元素 (HTML)

        • z-index值不为auto的绝对/相对定位元素

        • 固定(fixed) / 沾滞(sticky)定位(沾滞定位适配所有移动设备上的浏览器,但老的桌面浏览器不支持)

        • z-index值不为auto的flex(flexbox)子项 (flex item),即:父元素display: flex|inline-flex

        • z-index值不为auto的grid(grid)子项,即:父元素display:grid

        • opacity属性值小于1的元素

        • mix-blend-mode属性值不为normal的元素。

        • 以下任意属性值不为none的元素:

        • isolation属性被设置为isolate的元素。

        • -webkit-overflow-scrolling属性被设置为touch的元素

        • will-change中指定了任意CSS属性(参考这篇文章

        • contain属性值为layoutpaint,或者综合值(比如contain: strictcontain: content)。

          在层叠上下文中,其子元素同样也按照上面解释的规则进行层叠。 特别值得一提的是,其子元素的z-index值只在父级层叠上下文中有意义。子级层叠上下文被自动视为父级层叠上下文的一个独立单元。

          总结:

          • 层叠上下文可以包含在其他层叠上下文中,并且一起创建一个有层级的层叠上下文。
          • 每个层叠上下文完全独立于它的兄弟元素:当处理层叠时只考虑子元素。
          • 每个层叠上下文是自包含的:当元素的内容发生层叠后,整个该元素将会在父层叠上下文中按顺序进行层叠。
      • 需要剪裁(clip)的地方也会被创建为图层。

    • 图层绘制

      • 在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。

      • 渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

        ​ 绘制列表

        从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。

    • 栅格化操作(raster)

      • 绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:

        渲染进程中的合成线程和主线程

      • 如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程

      • 合成线程

        通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。

        在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

        基于这个原因,合成线程会将图层划分为图块(tile)

        • 合成线程会将图层划分为图块。这些图块的大小通常是 256x256 或者 512x512,如下图所示:

          图层被划分为图块示意图

          • 然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。**所谓栅格化,是指将图块转换为位图。**而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

            通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

            从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

    • 合成和显示

      • 一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
      • 浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
    • 总结

      • 完整的渲染流水线示意图

      • 结合上图,一个完整的渲染流程大致可总结为如下:

        • 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
        • 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
        • 创建布局树,并计算元素的布局信息。
        • 对布局树进行分层,并生成分层树。
        • 为每个图层生成绘制列表,并将其提交到合成线程。
        • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
        • 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
        • 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
  • 相关概念

    • “重排”

      • 更新了元素的几何属性(重排)

      • ​ 更新元素的几何属性

        从上图可以看出,如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

    • ”重绘“

      • ​ 更新元素背景

        从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

    • "合成"

      • 如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:

        ​ 避开重排和重绘

        在上图中,我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

javascript 引擎工作原理

执行上下文

变量提升(Hoisting)

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。

作用域(scope)

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

变量提升所带来的问题

  1. 变量容易在不被察觉的情况下被覆盖掉
  2. 穿透 if for循环

ES6 中的letconst

作用域块内声明的变量不影响块外面的变量。

JavaScript 是如何支持块级作用域的?

function foo(){
  var a = 1 
  let b = 2 
  { 
    let b = 3 
    var c = 4 
    let d = 5
    console.log(a)
    console.log(b) 
  }
  console.log(b) 
  console.log(c) 
  console.log(d)
}

foo()

第一步是编译并创建执行上下文:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
  • 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中。

第二步继续执行代码

当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:

​ 执行 foo 函数内部作用域块时的执行上下文

当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。

当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

这样一个变量查找过程就完成了:

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

​ 作用域执行完成示意图

作用域链&闭包

function bar() { 
  console.log(myName)
}

function foo() { 
  var myName = "极客邦" 
  bar()
}
var myName = "极客时间"
foo()

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,

比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。为了直观理解,你可以看下面这张图:

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:

foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。

词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。

块级作用域中的变量查找

function bar() {
 var myName = "极客世界" 
 let test1 = 100 
 if (1) { 
   let myName = "Chrome浏览器" 
   console.log(test) 
 }
}
function foo() {
  var myName = "极客邦"
  let test = 2 
  { 
    let test = 3 bar() 
  }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo()

​ 块级作用域中是如何查找变量的

闭包

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

闭包是怎么回收的

原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

This

  • 使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。
  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
  • 普通函数中的 this 默认指向全局对象 window
  • 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;
  • 当函数作为对象的方法调用时,函数中的 this 就是该对象;
  • 嵌套函数中的 this 不会继承外层函数的 this 值。

箭头函数

ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

**New **

var tempObj = {} 
CreateObj.call(tempObj) 
return tempObj

JavaScript 代码的执行流程

​ JavaScript 的执行流程图

  • 编译阶段

    • ​ JavaScript 执行流程细化图

    • 第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;

    • 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;

    • 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置

    • 执行上下文是 JavaScript 执行一段代码时的运行环境

      执行上下文

    • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。

    • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。

    • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

      调用栈

      调用栈就是用来管理函数调用关系的一种数据结构

      什么是函数调用

      var a = 2
      function add(){
        var b = 10
        return a+b
      }
      add()
      

      在执行到函数 add() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,你可以参考下图:

      ​ 全局执行上下文

    • 从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。

      执行上下文准备好之后,便开始执行全局代码,当执行到 add 这儿时,JavaScript 判断这是一个函数调用,那么将执行以下操作:

      • 首先,从全局执行上下文中,取出 add 函数代码。

      • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。

      • 最后,执行代码,输出结果。

        完整流程你可以参考下图:

        就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。

        也就是说在执行 JavaScript 时,可能会存在多个执行上下文,JavaScript 引擎是通过一种叫栈的数据结构来管理的上下文的。

      什么是栈

      关于栈,你可以结合这么一个贴切的例子来理解,一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来,这时这个堵住的单行线就可以被看作是一个栈容器,车子开进单行线的操作叫做入栈,车子倒出去的操作叫做出栈。

      在车流量较大的场景中,就会发生反复的入栈、栈满、出栈、空栈和再次入栈,一直循环。

      所以,栈就是类似于一端被堵住的单行线,车子类似于栈中的元素,栈中的元素满足后进先出的特点。你可以参看下图:

      什么是 JavaScript 的调用栈

      JavaScript 引擎正是利用栈的这种结构来管理执行上下文的。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈

      var a = 2
      function add(b,c){ 
        return b+c
      }
      function addAll(b,c){
        var d = 10
        result = add(b,c)
        return a+result+d
      }
      addAll(3,6)
      

      第一步,创建全局上下文,并将其压入栈底。如下图所示:

      全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。首先会执行 a=2 的赋值操作,执行该语句会将全局上下文变量环境中 a 的值设置为 2。设置后的全局上下文的状态如下图所示:

      第二步 调用 addAll 函数

      当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中,如下图所示:

      addAll 函数的执行上下文创建好之后,便进入了函数代码的执行阶段了,这里先执行的是 d=10 的赋值操作,执行语句会将 addAll 函数执行上下文中的 d 由 undefined 变成了 10。

      第三步,当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈,如下图所示:

      当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9。如下图所示:

      紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示:

      至此,整个 JavaScript 流程执行结束了。

      在开发中,如何利用好调用栈

  • 栈溢出(Stack Overflow)

    • 调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出

    • 递归代码例子:

    • function division(a,b){ 
        return division(a,b)
      }
      console.log(division(1,2))
      

  • 执行阶段

    • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。

    • 接下来打印“myname”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。

    • 接下来执行第 3 行,把“极客时间”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“极客时间”,变量环境如下所示:

    • VariableEnvironment: 
      			myname -> "极客时间", 
            showName -> function : {console.log(myname)
      

页面工作原理

栈空间和堆空间

内存空间

​ 对象类型(引用类型)是“堆”来存储

原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的

​ 调用栈中切换执行上下文状态

通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。

堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

闭包在堆中的存储
function foo() { 
  var myName = "aaa" 
  let test1 = 1 
  const test2 = 2 
  var innerBar = { 
    setName:function(newName){ 
      myName = newName 
    }, 
    getName:function(){ 
      console.log(test1) 
      return myName 
    } 
  }
  return innerBar
}
var bar = foo()
bar.setName("bbbb")
bar.getName()
console.log(bar.getName())
  1. 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“clourse(foo)”

深拷贝

 function deepCopy(o1, o2){
        // 取出第一个对象的每一个属性
        for(var key in o1){
            // 取出第一个对象当前属性对应的值
            var item = o1[key]; // dog
            // 判断当前的值是否是引用类型
            // 如果是引用类型, 我们就重新开辟一块存储空间
            if(item instanceof Object){
                var temp = new Object();
                /*
                {name: "wc",age: "3"}
                 */
               deepCopy(item, temp);   //递归
                o2[key] = temp;
            }else{
                // 基本数据类型
                o2[key] = o1[key];
            }
        }
    }
js中的垃圾回收

调用栈中的数据是如何回收的

function foo(){ 
  var a = 1 
  var b = {name:"极客邦"} 
  function showName(){ 
    var c = "极客时间" 
    var d = {name:"极客时间"} 
  }
  showName()
}
foo()

当执行到第 6 行代码时,其调用栈和堆空间状态图如下所示:

​ 执行到 showName 函数时的内存模型

从图中可以看出,原始类型的数据被分配到栈中,引用类型的数据会被分配到堆中。当 foo 函数执行结束之后,foo 函数的执行上下文会从堆中被销毁掉,那么它是怎么被销毁的呢?下面我们就来分析一下。

如果执行到 showName 函数时,那么 JavaScript 引擎会创建 showName 函数的执行上下文,并将 showName 函数的执行上下文压入到调用栈中,最终执行到 showName 函数时,其调用栈就如上图所示。与此同时,还有一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。

当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。

当 showName 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。

当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

堆中的数据是如何回收的

当上面那段代码的 foo 函数执行结束之后,ESP 应该是指向全局执行上下文的,那这样的话,showName 函数和 foo 函数的执行上下文就处于无效状态了,不过保存在堆中的两个对象依然占用着空间,如下图所示:

​ foo 函数执行结束后的内存状态

1003 和 1050 这两块内存依然被占用。要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了。

代际假说(The Generational Hypothesis)和分代收集

代际假说有以下两个特点:

  • 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问
  • 不死的对象,会活得更久。

在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。

新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

垃圾回收器的工作流程

不论什么类型的垃圾回收器,它们都有一套共同的执行流程。

  • 第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  • 第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  • 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,如下图所示:

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

由于老生区的对象比较大,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。下面我们来看看该算法是如何工作的。

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

当 showName 函数执行结束之后,ESP 向下移动,指向了 foo 函数的执行上下文,这时候如果遍历调用栈,是不会找到引用 1003 地址的变量,也就意味着 1003 这块数据为垃圾数据,被标记为红色。由于 1050 这块数据被变量 b 引用了,所以这块数据会被标记为活动对象。这就是大致的标记过程。

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:

上面的标记过程和清除过程就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact)

这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图:

​ 标记整理过程

全停顿

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:

​ 全停顿

在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。如果在执行垃圾回收的过程中,占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。


V8引擎

编译器和解释器

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

​ 编译器和解释器“翻译”代码

从图中你可以看出这二者的执行流程,大致可阐述为如下:

  1. 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
  2. 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。

V8 是如何执行一段 JavaScript 代码的

​ V8 执行一段代码流程图

从图中可以清楚地看到,V8 在执行过程中既有解释器 Ignition,又有编译器 TurboFan,那么它们是如何配合去执行一段 JavaScript 代码的呢? 下面我们就按照上图来一一分解其执行流程。

1. 生成抽象语法树(AST)和执行上下文

将源代码转换为抽象语法树,并生成执行上下文

AST

var myName = "极客时间"
function foo(){ return 23;}
myName = "geektime"
foo()

​ 抽象语法树(AST)结构

Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。 ESLint 其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

生成 AST 需要经过两个阶段。

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。你可以参考下图来更好地理解什么 token。

​ 分解 token 示意图

从图中可以看出,通过var myName = “极客时间”简单地定义了一个变量,其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“极客时间”四个都是 token,而且它们代表的属性还不一样。

第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

有了AST后,v8就会生成该段代码的执行上下文。

2. 生成字节码

有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。

其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

​ 字节码和机器码占用空间对比

  1. 执行代码

生成字节码之后,接下来就要进入执行阶段了。

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码

那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

其实字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)

具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

浏览器中的页面循环系统

每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统。

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。

消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

​ 队列 + 循环

由于是多个线程操作同一个消息队列,所以在添加任务和取出任务时还会加上一个同步锁。

​ 跨进程发送消息

渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程

通常我们把消息队列中的任务称为宏任务

每个宏任务中都包含了一个微任务队列

微任务

异步回调的概念,其主要有两种方式。

  • 第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数
  • 第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。

每个宏任务都关联了一个微任务队列。

在现代浏览器里面,产生微任务有两种方式。

  • MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

微任务队列是何时被执行的

  • 在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点
  • 如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

​ 微任务添加和执行流程示意图

结论

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行

setTimeout

  • setTimeout在被使用时会被推入 延迟队列, 延迟队列是个小顶堆,会根据时间将要执行的回调推入堆顶,宏任务结束后会去执行堆顶的内容

  • 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒(系统会认为阻塞)v8源码定义4ms出

  • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

  • 延时执行时间有最大值 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒

  • 使用 setTimeout 设置的回调函数中的 this 不一定指向当前环境

requestAnimationFrame

使用 requestAnimationFrame 不需要设置具体的时间,由系统来决定回调函数的执行时间,requestAnimationFrame 里面的回调函数是在页面刷新之前执行,它跟着屏幕的刷新频率走,保证每个刷新间隔只执行一次,内如果页面未激活的话,requestAnimationFrame 也会停止渲染,这样既可以保证页面的流畅性,又能节省主线程执行函数的开销

Promise
  • Promise 实现了回调函数的延时绑定。

  • 需要将回调函数 onResolve 的返回值穿透到最外层。

模拟promise


function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     //模拟实现resolve和then,暂不支持rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
          //setTimeout(()=>{ // 使用微任务延迟绑定
            onResolve_(value)
           // },0)
    }
    executor(resolve, null);
}
async await

async/await. 提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。

生成器

HTML CSS

优化network

​ 单个文件请求的时间线

Queuing

当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。导致请求处于排队状态的原因有很多。

  • 首先,页面中的资源是有优先级的,比如 CSS、HTML、JavaScript 等都是页面中的核心文件,所以优先级最高;而图片、视频、音频这类资源就不是核心资源,优先级就比较低。通常当后者遇到前者时,就需要“让路”,进入待排队状态。
  • 其次,我们前面也提到过,浏览器会为每个域名最多维护 6 个 TCP 连接,如果发起一个 HTTP 请求时,这 6 个 TCP 连接都处于忙碌状态,那么这个请求就会处于排队状态。
  • 网络进程在为数据分配磁盘空间时,新的 HTTP 请求也需要短暂地等待磁盘分配结束。

优化:

1. 排队(Queuing)时间过久

排队时间过久,大概率是由浏览器为每个域名最多维护 6 个连接导致的。那么基于这个原因,你就可以让 1 个站点下面的资源放在多个域名下面,比如放到 3 个域名下面,这样就可以同时支持 18 个连接了,这种方案称为域名分片技术。除了域名分片技术外,把站点升级到 HTTP2,因为 HTTP2 已经没有每个域名最多维护 6 个 TCP 连接的限制了。

2.第一字节时间(TTFB)时间过久

  • 服务器生成页面数据的时间过久

  • 网络的原因

  • 发送请求头时带上了多余的用户信息

3.Content Download 时间过久

如果单个请求的 Content Download 花费了大量时间,有可能是字节数太多的原因导致的。这时候你就需要减少文件大小,比如压缩、去掉源码中不必要的注释等方法。

Stalled

等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的 Stalled 上

Proxy Negotiation

如果你使用了代理服务器,还会增加一个 Proxy Negotiation 阶段,也就是代理协商阶段,它表示代理服务器连接协商所用的时间

**Initial connection/SSL **

服务器建立连接的阶段,这包括了建立 TCP 连接所花费的时间,如果你使用了 HTTPS 协议,那么还需要一个额外的 SSL 握手时间,这个过程主要是用来协商一些加密信息。

Request sent

和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络

Waiting (TTFB)

等待接收服务器第一个字节的数据

通常也称为“第一字节时间”,TTFB 时间越短,就说明服务器响应越快。

Content Download

接收到第一个字节之后,进入陆续接收完整数据的阶段

从第一字节时间到接收到全部响应数据所用的时间。

页面加载性能

DOM 树如何生成

在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块

HTML 解析器是等整个 HTML 文档加载完成之后开始解析的,还是随着 HTML 文档边加载边解析的?

网络进程加载了多少数据,HTML 解析器便解析多少数据。

网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。你可以把这个管道想象成一个“水管”,网络进程接收到的字节流像水一样倒进这个“水管”,而“水管”的另外一端是渲染进程的 HTML 解析器,它会动态接收字节流,并将其解析为 DOM。

第一个阶段,通过分词器将字节流转换为 Token。

第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。

HTML 解析器维护了一个 Token 栈结构

​ 生成的 Token 示意图

该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:

  • 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  • 如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。

​ 元素弹出 Token 栈示意图

​ 最终解析结果

预解析操作

当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。

XSSAuditor

渲染引擎还有一个安全检查模块叫 XSSAuditor,是用来检测词法安全的。在分词器解析出来 Token 之后,它会检测这些模块是否安全,比如是否引用了外部脚本,是否符合 CSP 规范,是否存在跨站点请求等。如果出现不符合规范的内容,XSSAuditor 会对该脚本或者下载任务进行拦截。

CSS 渲染

​ 含有 CSS 的页面渲染流水线

首先是发起主页面的请求,这个发起请求方可能是渲染进程,也有可能是浏览器进程,发起的请求被送到网络进程中去执行。网络进程接收到返回的 HTML 数据之后,将其发送给渲染进程,渲染进程会解析 HTML 数据并构建 DOM。这里你需要特别注意下,请求 HTML 数据和构建 DOM 中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。

当渲染进程接收 HTML 文件字节流时,会先开启一个预解析线程,如果遇到 JavaScript 文件或者 CSS 文件,那么预解析线程会提前下载这些数据。对于上面的代码,预解析线程会解析出来一个外部的 theme.css 文件,并发起 theme.css 的下载。这里也有一个空闲时间需要你注意一下,就是在 DOM 构建结束之后、theme.css 文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM。

和 HTML 一样,渲染引擎也是无法直接理解 CSS 文件内容的,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是** CSSOM**。和 DOM 一样,CSSOM 也具有两个作用,第一个是提供给 JavaScript 操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。**

​ 含有 JavaScript 和 CSS 的页面渲染流水线

在执行 JavaScript 脚本之前,如果页面中包含了外部 CSS 文件的引用,或者通过 style 标签内置了 CSS 内容,那么渲染引擎还需要将这些内容转换为 CSSOM,因为 JavaScript 有修改 CSSOM 的能力,所以在执行 JavaScript 之前,还需要依赖 CSSOM。也就是说 CSS 在部分情况下也会阻塞 DOM 的生成。

​ 含有 JavaScript 文件和 CSS 文件页面的渲染流水线

从图中可以看出来,在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求,需要注意的是,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。

不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。

影响页面展示的因素以及优化策略

通常情况下的瓶颈主要体现在下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript。

  • 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
  • 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
  • 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。
  • 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
分层(layer)和合成(合成线程)机制

显示器是怎么显示图像的

每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。

显卡

显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

帧 VS 帧率

我们把渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。

如何生成一帧图像

看完前面的内容应该知道渲染的效率 重排 < 重绘 < 合成

这里我们详解合成的方式生产一帧

分层和合成

在 Chrome 的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为层树(Layer Tree),层树是渲染流水线后续流程的基础结构。

合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。这就是为什么经常主线程卡住了,但是 CSS 动画依然能执行的原因。

分块

如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。

通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。

因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。不过有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到 GPU 内存的操作会比较慢。

为了解决这个问题,Chrome 又采取了一个策略:在首次合成图块的时候使用一个低分辨率的图片

比如可以是正常分辨率的一半,分辨率减少一半,纹理就减少了四分之三。在首次显示页面内容的时候,将这个低分辨率的图片显示出来,然后合成器继续绘制正常比例的网页内容,当正常比例的网页内容绘制完成后,再替换掉当前显示的低分辨率内容。这种方式尽管会让用户在开始时看到的是低分辨率的内容。

will-change

.box {
 will-change: transform, opacity;
}

这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是 CSS 动画比 JavaScript 动画高效的原因。

虚拟dom

dom的缺陷

比如,我们可以调用document.body.appendChild(node)往 body 节点上添加一个元素,调用该 API 之后会引发一系列的连锁反应。首先渲染引擎会将 node 节点添加到 body 节点之上,然后触发样式计算、布局、绘制、栅格化、合成等任务,我们把这一过程称为重排。除了重排之外,还有可能引起重绘或者合成操作,形象地理解就是“牵一发而动全身”。另外,对于 DOM 的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。因此,对于 DOM 的操作我们时刻都需要非常小心谨慎。

虚拟 DOM 特点

  • 将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上。
  • 变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了。
  • 在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。

​ 虚拟 DOM 执行流程

虚拟 DOM 怎么运行的

  • 创建阶段。首先依据 JSX 和基础数据创建出来虚拟 DOM,它反映了真实的 DOM 树的结构。然后由虚拟 DOM 树创建出真实 DOM 树,真实的 DOM 树生成完后,再触发渲染流水线往屏幕输出页面。
  • 更新阶段。如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟 DOM 树;然后 React 比较两个树,找出变化的地方,并把变化的地方一次性更新到真实的 DOM 树上;最后渲染引擎更新渲染流水线,并生成新的页面。

这里我们重点关注下比较过程,最开始的时候,比较两个虚拟 DOM 的过程是在一个递归函数里执行的,其核心算法是 reconciliation。通常情况下,这个比较过程执行得很快,不过当虚拟 DOM 比较复杂的时候,执行比较函数就有可能占据主线程比较久的时间,这样就会导致其他任务的等待,造成页面卡顿。为了解决这个问题,React 团队重写了 reconciliation 算法,新的算法称为 Fiber reconciler,之前老的算法称为 Stack reconciler。

双缓存

在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么就会造成一个后果,那就是在显示一个稍微复杂点的图像的过程中,你看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。

而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。

你可以把虚拟 DOM 看成是 DOM 的一个 buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到 DOM 上,这样就能减少一些不必要的更新,同时还能保证 DOM 的稳定输出。

MVC 模式

​ MVC 基础结构

其核心思想就是将数据和视图分离

基于 MVC 又能衍生出很多其他的模式,如 MVP、MVVM 等,不过万变不离其宗,它们的基础骨架都是基于 MVC 而来。

​ 基于 React 和 Redux 构建 MVC 模型

在该图中,我们可以把虚拟 DOM 看成是 MVC 的视图部分,其控制器和模型都是由 Redux 提供的。其具体实现过程如下:

  • 图中的控制器是用来监控 DOM 的变化,一旦 DOM 发生变化,控制器便会通知模型,让其更新数据;
  • 模型数据更新好之后,控制器会通知视图,告诉它模型的数据发生了变化;
  • 视图接收到更新消息之后,会根据模型所提供的数据来生成新的虚拟 DOM;
  • 新的虚拟 DOM 生成好之后,就需要与之前的虚拟 DOM 进行比较,找出变化的节点;
  • 比较出变化的节点之后,React 将变化的虚拟节点应用到 DOM 上,这样就会触发 DOM 节点的更新;
  • DOM 节点的变化又会触发后续一系列渲染流水线的变化,从而实现页面的更新。
webComponent
<!DOCTYPE html>
<html>


<body>
    <!--
            一:定义模板
            二:定义内部CSS样式
            三:定义JavaScript行为
    -->
    <template id="geekbang-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk
            }


            div {
                width: 200px;
                background-color: bisque;
                border: 3px solid chocolate;
                border-radius: 10px;
            }
        </style>
        <div>
            <p>time.geekbang.org</p>
            <p>time1.geekbang.org</p>
        </div>
        <script>
            function foo() {
                console.log('inner log')
            }
        </script>
    </template>
    <script>
        class GeekBang extends HTMLElement {
            constructor() {
                super()
                //获取组件模板
                const content = document.querySelector('#geekbang-t').content
                //创建影子DOM节点
                const shadowDOM = this.attachShadow({ mode: 'open' })
                //将模板添加到影子DOM上
                shadowDOM.appendChild(content.cloneNode(true))
            }
        }
        customElements.define('geek-bang', GeekBang)
    </script>


    <geek-bang></geek-bang>
    <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
    </div>
    <geek-bang></geek-bang>
</body>


</html>

影子 DOM 的作用是将模板中的内容与全局 DOM 和 CSS 进行隔离,这样我们就可以实现元素和样式的私有化了。你可以把影子 DOM 看成是一个作用域,其内部的样式和元素是不会影响到全局的样式和元素的,而在全局环境下,要访问影子 DOM 内部的样式或者元素也是需要通过约定好的接口的。

Shadow dom 的javascript 脚本不会被隔离

HTTP

HTTP/0.9

  • 只有一个请求行,并没有 HTTP 请求头和请求体
  • 服务器也没有返回头信息。
  • 返回的文件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的文件,所以使用 ASCII 字节码来传输是最合适的。

HTTP/1.0

​ HTTP/1.0 的请求流程

  • 新增了状态码
  • 提供了 Cache 机制
  • 加入了用户代理
  • 增加了请求头和返回头(实现多种类型文件下载)

HTTP/1.1

  • 改进持久连接 (keep-alive 目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。)
  • 增加客户端 Cookie
  • http请求cache
  • Chunk transfer 机制 (服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。)

缺点

  1. 同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽。

  2. tcp慢启动

  3. HTTP/1.1 队头阻塞的问题(阻塞的请求)

HTTP/2

  • 多路复用 ,HTTP/2 的思路就是一个域名只使用一个 TCP 长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,同时也避免了多个 TCP 连接竞争带宽所带来的问题。
  • 并行请求
  • 可以设置请求的优先级
  • 服务器推送
  • 头部压缩

​ HTTP/2 的多路复用

一个域名只使用一个 TCP 长连接和消除队头阻塞问题。

多路复用实现原理

​ HTTP/2 协议栈

  • 首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是 POST 方法,那么还要有请求体。
  • 这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器。
  • 服务器接收到所有帧之后,会将所有相同 ID 的帧合并为一条完整的请求信息。
  • 然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。
  • 同样,二进制分帧层会将这些响应数据转换为一个个带有请求 ID 编号的帧,经过协议栈发送给浏览器。
  • 浏览器接收到响应帧之后,会根据 ID 编号将帧的数据提交给对应的请求。

HTTP/3

tcp的队头阻塞

从一端发送给另外一端的数据会被拆分为一个个按照顺序排列的数据包,这些数据包通过网络传输到了接收端,接收端再按照顺序将这些数据包组合成原始数据,这样就完成了数据传输。

不过,如果在数据传输的过程中,有一个数据因为网络故障或者其他原因而丢包了,那么整个 TCP 的连接就会处于暂停状态,需要等待丢失的数据包被重新传输过来

在 TCP 传输过程中,由于单个数据包的丢失而造成的阻塞称为 TCP 上的队头阻塞

​ HTTP/2 多路复用

通过该图,我们知道在 HTTP/2 中,多个请求是跑在一个 TCP 管道中的,如果其中任意一路数据流中出现了丢包的情况,那么就会阻塞该 TCP 连接中的所有请求。这不同于 HTTP/1.1,使用 HTTP/1.1 时,浏览器为每个域名开启了 6 个 TCP 连接,如果其中的 1 个 TCP 连接发生了队头阻塞,那么其他的 5 个连接依然可以继续传输数据。

所以随着丢包率的增加,HTTP/2 的传输效率也会越来越差。有测试数据表明,当系统达到了 2% 的丢包率时,HTTP/1.1 的传输效率反而比 HTTP/2 表现得更好。

QUIC 协议

HTTP/3 选择了一个折衷的方法——UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为 QUIC 协议

​ HTTP/2 和 HTTP/3 协议栈

HTTP/3 中的 QUIC 协议集合了以下几点功能。

  • 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
  • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
  • 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。
  • 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。

​ QUIC 协议的多路复用

HTTP/3的困境

第一,从目前的情况来看,服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持。Chrome 虽然在数年前就开始支持 Google 版本的 QUIC,但是这个版本的 QUIC 和官方的 QUIC 存在着非常大的差异。

第二,部署 HTTP/3 也存在着非常大的问题。因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的一个重要原因。

第三,中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~7% 的丢包率。