《游览器工作原理与实践》笔记(上)

整理自极客时间《游览器工作原理和实践》

宏观视角下的游览器

Chrome架构

  • 并行处理

    • 同一时刻处理多个任务

    • 并行处理简少了程序执行步长,提升了执行效率

  • 线程

    • 单线程,逐行处理任务

    • 多线程,并行处理任务

  • 进程

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

    • 因此,一个进程就是一个程序运行实例。

    • 线程不能单独存在,它需要进程来启动和管理

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

  • 线程和进程的关系特点

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

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

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

    • 进程之间的内容相互隔离

  • 单进程浏览器时代

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

    • 这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等

    • 存在的问题

      • 不稳定

        • 一个模块的意外崩溃会引起整个浏览器的崩溃
      • 不流畅

        • 同一时刻只能有一个模块可以执行,一个模块(js)的阻塞会影响其他模块执行

        • 因为共享进程,运行一个复杂页面再关闭页面,会存在内存不能完全回收的情况

      • 不安全

        • 游览器插件可以操作系统的任意资源

        • 页面脚本可以通过浏览器漏洞获取系统权限,进行恶意操作

  • 多进程浏览器时代

    • 游览器结构

    • 各部分功能

      • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

      • 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下

      • GPU 进程。Chrome刚开始发布的时候是没有 GPU 进程的。而GPU的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程

      • 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程

      • 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响

  • 对单进程游览器问题的解决

    • 不稳定

      • 由于进程隔离,当一个页面或插件崩溃时,影响的仅仅是当前页面进程或插件进程,并不会影响到浏览器和其他页面
    • 不流畅

      • 同样因为进程隔离,即时js阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中

      • 对于内存泄漏就更简单了,当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收。

    • 不安全

      • 采用了安全沙箱。即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取到系统权限
  • 未来面向服务的架构

    • 现有架构的问题

      • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源

      • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了

    • 面向服务的架构

      • 采用现代操作系统所采用面向服务的架,原来的各种模块会被重构成独立的服务,每个服务都可以在独立的进程中运行,访问服务必须使用定义好的接口,通过IPC来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统

      • Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome“面向服务的架构”的进程模型图

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

      • 鉴于目前架构的复杂性,要完整过渡到面向服务架构,估计还需要好几年时间才能完成。不过 Chrome 开发是一个渐进的过程,新的特性会一点点加入进来,这也意味着我们随时能看到 Chrome 新的变化
  • 留言区问题

    • 多进程架构下,出现的单个页面卡死崩溃导致所有页面崩溃的情况

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

      • 直白的讲,就是如果几个页面符合同一站点,那么他们将被分配到一个渲染进程里面去。

      • 所以,这种情况下,一个页面崩溃了,会导致同一站点的页面同时崩溃,因为他们使用了同一个渲染进程

Tcp协议

  • IP:把数据包送达目的主机

    • 网际协议标准(Internet Protocol,简称 IP),属于网络层,负责把数据包传送到对方电脑

    • 互联网中的数据是通过数据包来传输的,数据包要在互联网上传输,就要符合IP标准

      • 如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输
    • 计算机地址被称为IP地址,访问任何网站实际上只是你的计算机向另外一台计算机请求信息

      • 互联网上不同的在线设备都有唯一的地址,这类似于邮寄包裹时的接收地址
    • 如果要想把一个数据包从主机A发送到主机B,那么在传输之前,数据包上会被附加上主机B 的IP地址信息,这样在传输过程中才能正确寻址。额外地,数据包上还会附加上主机A本身的 IP 地址,有了这些信息主机B才可以回复信息给主机A

      • 附加的信息会被装进一个叫IP头的数据结构里
    • 简化后的网络传输三层结构

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

    • 用户数据包协议(User Datagram Protocol,简称UDP),属于传输层,负责将数据送达到应用程序

    • UDP中一个最重要的信息是端口号,端口号其实就是一个数字,每个想访问网络的程序都需要绑定一个端口号

    • IP通过IP地址信息把数据包发送给指定的电脑,而UDP通过端口号把数据包分发给正确的程序

    • 和IP头一样,端口号会被装进UDP头里面,UDP头再和原始数据包合并组成新的UDP数据包

    • 简化后的UDP网络传输四层结构

    • UDP协议的优缺点

      • 优点 => 传输速度却非常快

      • 缺点 => 存在数据包丢失,无法保证数据包完整性;无法将小的数据包重组成完整的文件

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

    • 传输控制协议(Transmission Control Protocol,简称TCP)是一种面向连接的、可靠的、基于字节流的传输层通信协议

    • 该协议解决了UDP协议的两个问题——数据完整性 + 数据包的组装

    • 相对于UDP,它有下面两个特点:

      • 对于数据包丢失的情况,TCP 提供重传机制

      • 引入了数据包排序机制,用来保证把乱序的数据包组合成一个完整的文件

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

    • 简化后的TCP网络传输四层结构

    • TCP的连接过程——保证数据完整地传输

      • 建立连接阶段。通过“三次握手”来建立客户端和服务器的连接。TCP 提供面向连接的通信传输,也就是在数据通信开始之前先做好两端之间的准备工作

      • 传输数据阶段。接收端对每个数据包进行确认操作,在接收到数据包之后,需要发送确认数据包给发送端。所以当发送端发送了一个数据包之后,在规定时间内没有接收到接收端反馈的确认消息,则判断为数据包丢失,并触发发送端的重发机制。同样,一个大的文件在传输过程中会被拆分成很多小的数据包,这些数据包到达接收端后,接收端会按照TCP头中的序号为其排序,从而保证组成完整的数据

      • 断开连接阶段。数据传输完毕之后,就要终止连接了,涉及到最后一个阶段“四次挥手”来保证双方都能断开连接

      • 为了保证数据传输的可靠性,牺牲了数据包的传输速度,因为“三次握手”和“数据包校验机制”等把传输过程中的数据包的数量提高了一倍。因此,对于不那么严格要求数据完整性的应用领域,如在线视频、互动游戏等,UDP协议更适合。

  • 补充内容

    • 为什么需要三次握手

      • 为了实现可靠数据传输,TCP协议的通信双方,都必须维护一个序列号,以标识发送出去的数据包中,哪些是已经被对方收到的

      • 三次握手的过程即是通信双方相互告知序列号起始值并确认对方已经收到了序列号起始值的必经步骤

      • 如果只是两次握手,至多只有连接发起方的起始序列号能被确认,另一方选择的序列号则得不到确认

    • 为什么需要四次挥手

      • 在三次握手的过程中,SYN和ACK是一起发送的,但是在四次挥手的时候FIN和ACK却不是一起发送的而是分开发送的

      • TCP连接是全双工的,也就是说接收到FIN只是说没有数据再发过来,但是还是可以发送数据的。也就是说接受到一个FIN只是关闭了一个方向的数据传输,另一个方向还可以继续发送数据

      • 前俩次挥手,结束一个方向上的链接;后俩次挥手,结束另外一个方向上的链接

Http请求流程

  • 1.构建请求

    • 浏览器构建请求行信息,准备发起网络请求

      GET /index.html HTTP1.1
      
  • 2.查找缓存

    • 在发起请求前,浏览器会先读取游览器缓存文件

    • 如发现请求资源已在缓存中,则会拦截请求,返回资源副本,并结束请求

    • 如果缓存查找失败,就会进入网络请求过程,从源服务器重新下载

    • 对于网站来说,缓存是实现快速资源加载的重要组成部分,缓解了服务器端压力,提升了性能

  • 3.DNS解析

    • 浏览器使用HTTP作为应用层协议,用来封装请求的文本信息;使用TCP作为传输层协议,将它发到网络上。

    • 所以在HTTP工作开始之前,浏览器需要通过TCP与服务器建立连接,也就是说HTTP的内容是通过TCP传输数据阶段来实现的

    • 建立TCP连接的第一步就是需要拿到IP地址和端口号DNS服务负责把域名URL和IP地址进行映射转换,端口如果url没有指定,则默认为80

    • 浏览器提供了DNS数据缓存服务,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求

  • 4.建立TCP连接

    • Chrome有个机制,同一个域名同时最多只能建立6个TCP连接,超过的进入排队等待状态,直至进行中的请求完成

    • 低于6个连接请求或者排队等待结束之后,就开始进行TCP连接,“三次握手”后,数据传输通道就建立好了,HTTP数据在通信过程中被传输

  • 5.发送HTTP请求

    • 浏览器会向服务器发送请求行,它包括请求方法、请求UR和HTTP版本协议

    • 发送请求行,就是告诉服务器浏览器需要什么资源,最常用的请求方法是Get

    • 另外一个常用的请求方法是 POST,它用于发送一些数据给服务器

    • HTTP请求数据格式

  • 6.服务器端处理HTTP请求

    • 首先服务器会返回响应行,包括协议版本和状态码

    • 服务器会通过请求行的状态码来告诉浏览器它的处理结果

    • 正如浏览器会随同请求发送请求头一样,服务器也会随同响应向浏览器发送响应头

    • 发送完响应头后,服务器就可以继续发送响应体的数据,通常响应体就包含了HTML的实际内容

    • 服务器响应的数据格式

    • 如果浏览器或服务器加入了Connection:Keep-Alive头,那TCP连接会在发送后仍然保持打开状态,这样浏览器就可以继续通过同一个TCP连接发送请求

    • 保持TCP连接可以省去下次请求时需要建立连接的时间,提升资源加载速度

    • HTTP/1.1之前的HTTP版本默认连接都是非持久连接,想维持持续连接,则需要指定Connection首部字段的值为Keep-Alive,HTTP1.1以上默认为持久链接

  • 流程阶段图示

  • 相关问题

    • 为什么很多站点第二次打开速度会很快?

      • 主要原因是第一次加载页面过程中,缓存了一些耗时的数据,主要是DNS缓存页面资源缓存

      • DNS被游览器缓存后,下次请求时就节省了DNS查询时间

      • 页面资源缓存主要是通过响应头中的Cache-Control字段来设置是否缓存该资源

        Cache-Control:Max-age=2000
        
      • 如果缓存过期了,浏览器则会继续发起网络请求,并且在HTTP请求头中带上

        If-None-Match:"4f80f-13c-3a1xb12a"
        
      • 服务器收到请求头后,会根据If-None-Match值来判断请求的资源是否有更新

      • 如果没有更新,就返回304状态码(相当于服务器告诉浏览器:这个缓存可以继续使用,这次就不重复发送数据给你了)

      • 如果资源有更新,服务器就直接返回最新资源给浏览器

      • 缓存处理过程

    • 登录状态是如何保持的?

      • 游览器端通过HTTP请求发送用户账号密码给服务端

      • 服务端验证成功后,生成这个用户独有的身份信息字符串

      • 服务端将这个身份信息字符串通过响应头set-Cookie头加入到HTTP响应报文

      • 游览器接受到响应后,根据set-Cookie响应头将字符串作为cookie存在游览器

      • 下次HTTP请求时,游览器会自动携带存入的cookie身份信息

      • 服务端查询后台,发现存在该用户信息,返回登陆状态下的响应数据给游览器

Url导航流程

  • 用户发出URL请求,到页面开始解析之前的这个过程,就叫做导航

  • 流程包括

    • 用户输入 => URL请求 => 接受响应数据 => 准备渲染进程 => "提交文档"确认 => 开始渲染
  • 流程步骤

    • 首先,浏览器进程接收到用户输入的URL请求后,便将该URL转发给网络进程。

    • 然后,在网络进程中发起真正的URL请求。

    • 接着网络进程通过HTTP请求流程,接收到响应头数据,便解析并将转发给浏览器进程。

    • 浏览器进程接收响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程(准备渲染进程)。

    • 渲染进程接收到“提交导航”的消息之后,便开始准备接收HTML数据,与网络进程建立数据管道;

    • 然后,渲染进程向浏览器进程“确认提交”,告知浏览器进程已准备就绪:“已经准备好接受和解析页面数据了”。

    • 浏览器进程接收到渲染进程“提交确认”后,便开始移除之前的旧文档,更新浏览器进程中的页面状态(界面状态,安全状态、地址栏URL和前进后退的历史状态等)

  • 流程图示

  • 详细步骤

    • 1.用户输入

      • 当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容还是请求的URL

      • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的URL。

      • 如果识别为请求的URL,比如输入的是time.geekbang.org,那么地址栏会根据规则,把这段内容加上协议,合成为完整的URL,如https://time.geekbang.org。

      • 当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行beforeunload事件的机会,beforeunload事件允许页面在退出之前执行一些数据清理操作。

      • 当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换为新的页面。因为需要等待提交文档阶段,页面内容才会被替换。

    • 2.URL请求

      • 首先,网络进程会查找本地缓存是否缓存了该资源。如果有,直接返回给浏览器进程;否则,进入网络请求流程。

        • 浏览器进程会通过进程间通信(IPC)把URL请求发送至网络进程
      • 进入请求流程时,第一步是进行DNS解析,以获取请求域名的服务器IP地址。

        • 如果存在DNS缓存,直接从缓存里面查到IP地址

        • 如果请求协议是HTTPS,那么还需要建立SSL/TLS连接

      • 然后,通过IP地址和服务器建立TCP连接。

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

      • 服务器接收到请求信息后,会根据请求信息生成响应数据,发给网络进程。

      • 等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。

      • 其他补充

        • 重定向。在导航过程中,如果服务器响应行的状态码包含了301、302一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是200,那么表示浏览器可以继续处理该请求

        • 响应数据类型处理。Content-Type是HTTP头中一个非常重要的字段,它告诉浏览器服务器返回的响应体数据是什么类型。浏览器会根据Content-Type的值来决定如何显示响应体的内容

    • 3.准备渲染进程

      • URL请求得到响应后,网络进程会转发响应头信息给游览器进程,然后游览器进程开始渲染进程准备

      • 渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段

      • 默认情况下,Chrome会为每个页面分配一个渲染进程,但如果两个页面都属于同一站点的话,那么复用一个渲染进程。

    • 4.提交文档

      • 首先,当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;

      • 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的管道;

      • 等网络进程的文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程,开始进行渲染;

      • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态(包括了安全状态、地址栏的URL、前进后退的历史状态,并更新Web页面——页面的内容显示则由渲染进程的进度决定)

    • 5.渲染阶段

      • ...

页面渲染流程

  • 渲染流水线

  • 流水线步骤

    • 构建DOM树

    • 样式计算

      • css文本转换为样式表
      • 标准化css属性值
      • 计算出具体样式
    • 布局

      • 创建布局树
      • 计算布局信息
    • 分层

      • 基于布局树,生成图层树
    • 绘制

      • 根据图层,生成绘制列表任务
    • 合成

      • 光栅化,又称栅格化,将图层分成图块,将图块转换成位图
      • 发送绘制图块命令“DrawQuad”给游览器进程
    • 显示

  • 流水线过程

    • 渲染进程将HTML内容转换为能够读懂的DOM树结构。

    • 渲染引擎将CSS样式表转化为浏览器可以理解的styleSheets,计算出DOM节点的样式。

    • 创建布局树,计算元素的布局信息。

    • 对布局树进行分层,生成图层树

    • 为每个图层生成绘制列表,将其提交到合成线程。

    • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图

    • 合成线程发送绘制图块DrawQuad命令给浏览器进程。

    • 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。

  • 流水线图示

  • 构建DOM树(Dom)

    • 浏览器无法直接理解和使用HTML,所以需要将HTML转换为浏览器能够理解的结构

    • DOM树的构建过程

  • 样算计算(Style)

    • 浏览器也无法直接理解纯文本的CSS样式,需要通过渲染引擎将接收到CSS文本转换为浏览器可以理解的结构——styleSheets

    • 样式计算的目的是为了计算出DOM节点中每个元素的具体样式

    • (谷歌游览器源码里面并没有CSSOM这个词,可能是行业对样式计算的称呼)

    • 计算步骤

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

        • 体现为document.styleSheets结果中的数据结构

        • 转换类型包括:外链link样式,style标签内样式,直接样式

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

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

        • 计算具体样式涉及CSS的层叠规则继承规则

        • CSS层叠,对来自多个源的CSS属性值的合并叠加

        • CSS继承,就是每个DOM节点都包含有父节点的样式

        • 计算样式的查看

  • 布局(Layout)

    • DOM树元素样式还不足以显示页面,还需要知道DOM元素的几何位置信息,计算位置信息的这个过程就叫做布局

    • 任务步骤

      • 1.创建布局树(LayoutTree)

        • 遍历DOM树中的所有可见节点,并把这些节点加到布局树中

        • 不可见的节点会被布局树忽略掉,如head标签和属性包含dispaly:none的标签

        • (渲染树是16年之前的东西,现在的代码完全重构了,可以把LayoutTree看成是渲染树,不过和之前的渲染树还是有一些差别)

      • 2.布局计算

        • 计算每个DOM元素的几何坐标位置,并将这些信息保存在布局树中。

        • 在执行把布局运算结果重新写回布局树中的过程里,布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。

        • 针对这个问题,Chrome团队正在重构布局代码,下一代布局系统叫LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

  • 分层(Layer)

    • 布局完成后,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树

    • 渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面

    • 图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建图层树(LayerTree)

    • 图层和布局树节点之间的关系

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

    • 满足创建新图层的条件

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

        • 使用定位属性、透明属性、CSS滤镜的元素都拥有层叠上下文属性
      • 需要剪裁(clip)的地方也会被创建为图层

        • 以文字裁剪为例

        • 如果出现滚动条,滚动条也会被提升为单独的层(如上图)

  • 绘制(Paint)

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

    • 渲染引擎会把每个绘制动作步骤化,以线性方式排列任务,形成一个待绘制列表

  • 合成(Synthesis)

    • Synthesis = prepare tiles(划分图块) + raster(栅格化) + draw quad(绘制提交)

    • 绘制列表只是用来记录绘制顺序和绘制指令的列表,实际绘制操作是由渲染引擎中的合成线程来完成的。

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

    • 合成线程的工作方式

      • 合成线程会将图层划分为图块(tiles),这些图块的大小通常是256x256或者512x512。

      • 合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。

      • 所谓栅格化(raster),是指将图块转换为位图,而图块是栅格化执行的最小单位。

      • 渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。

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

      • GPU操作是运行在GPU进程中,如果栅格化操作使用了GPU,那么最终生成位图的操作是在GPU中完成的,这就涉及到了跨进程操作。

      • GPU栅格化示意图

      • 一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程

  • 显示(Display)

    • 浏览器进程里面有一个叫viz的组件,用来接收合成线程发过来的DrawQuad命令,然后根据DrawQuad命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
  • 相关概念

    • 重排——更新元素的几何属性

      • 如果你通过JavaScript或者CSS修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排

      • 重排需要更新完整的渲染流水线,所以开销也是最大的。

    • 重绘——更新元素的绘制属性

      • 如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘

      • 相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

    • 直接合成阶段

      • 如果修改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成

      • 因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

游览器中的js执行机制

变量提升

  • 变量提升

    • 指在js代码执行过程中,js引擎把变量和函数的声明部分提升到代码开头的行为。

    • 变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的undefined。

    • 模拟示意图

    • 当出现相同的变量或者函数时,后面的定义会覆盖前面的。

    • 函数提升要比变量提升的优先级要高一些,且不会被变量声明覆盖,但是会被之后的变量赋值覆盖

  • 执行流程

    • 变量提升从概念上看,似乎是变量和函数的声明会在物理层面移动到代码的最前面,但这其实这并不准确。

    • 实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被js引擎放入内存中,因此变量提升的存在是基于js需要编译。

    • js执行流程简图

    • js执行流程细化图

    • 从上图可以看到,经过编译后,会生成两部分内容:执行上下文可执行代码

    • 在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为undefined。

    • 在执行阶段,js引擎会从变量环境中去查找自定义的变量和函数。

  • 执行上下文

    • 执行上下文是js执行一段代码时的运行环境,其中存在变量环境词法环境

    • 执行上下文分为:全局执行上下文,函数执行上下文,eval执行上下文

    • 变量环境,var声明的变量(变量提升)就被放置在这个地方,用于js执行时的查找。

    • 词法环境,let和const声明的变量就被放置在这个地方,也是用于js执行查找,也作为一个块级作用域的栈结构环境而存在,用于块级作用域变量的入栈和出栈。

    • 其他网文称呼的变量对象,也可称为词法环境对象,可以说是变量环境和词法环境里包含的全部变量声明,函数,参数的总和。

    • js引擎会把声明以外的代码编译为字节码,然后转换为可执行代码。

调用栈

  • 函数调用

    var a = 2;
    // 函数
    function add() {
      var b = 10;
      return a+b
    }
    add(); // 调用
    
    • 在执行add()之前,js引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量。

    • 在执行add函数时,js引擎再对add函数的这段代码进行编译,创建该函数执行上下文

    • 从该例子可以看出,程序中可以存在多个上下文,而这些上下文的管理则是通过一种叫的数据结构来管理的。

  • 栈结构

    • 栈就是类似于一端被堵住的单行线,车子类似于栈中的元素,栈中的元素满足后进先出的特点。

  • 执行上下文栈(调用栈)

    • 调用栈,就是用来管理函数调用关系的一种数据结构,js引擎利用这种栈结构来管理执行上下文的。

    • 在执行上下文创建好后,js引擎会将其压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈

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

    • 调用栈的大小有两个指标,最大栈容量和最大调用深度,满足其中任意一个就会栈溢出。

    • 如果某个执行上下文中存在闭包现象时,并不会影响出栈销毁,因为内部函数引用的变量会保存在堆上,所以不会影响栈的操作。

    • 调用栈是js引擎追踪函数执行的一个机制,我们以一个复杂例子来看执行过程和调用栈变化。

  • 执行上下文的运作

    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);
    
    • 第一步,创建全局上下文,并将其压入栈底。

    • 第二步,执行全局代码,进行a=2的赋值操作。

    • 第三步,执行全局代码,调用addAll函数。

    • 第四步,当执行到add函数。

    • 第五步,当add数返回时,该函数的执行上下文就会从栈顶弹出。

    • 第六步,紧接着addAll执行最后一个相加操作后返回并从栈顶部弹出。

    • 第七步,整个JavaScript流程执行结束,调用栈清空。

块级作用域

  • 作用域(scope)

    • 作用域,是指在程序中定义变量的区域,是变量与函数的可访问范围,控制着变量和函数的可见性和生命周期。

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

    • 在ES6之后,引入了let和const关键字,实现了块级作用域

  • 块级作用域

    • 理解js对块级作用域的支持,需要站在执行上下文的角度。

    • js中的块级作用域,是通过词法环境的栈结构来实现的。

    • js中的变量提升,是通过变量环境来实现。

    • 以一段代码作为讲解

      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声明的变量,在编译阶段会被存放到词法环境中。

      • 函数内的块级代码内,通过let声明的变量并没有被存放到词法环境中。

    • 第二步,继续执行代码

      • 在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

      • 在执行到区块代码时,新的let声明变量会被追加到词法作用域里的栈结构中,作为新的独立存在,这个区域中的变量并不影响区块外面的let变量。

      • 只有通过let或者const声明的变量,才会进入这种词法栈结构的变量。通过var声明的只会进入变量环境内。

      • 当js执行代码时,会从词法环境和变量环境中查找变量,查找顺序方式为:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给js引擎,如果没有查找到,那么继续在变量环境中查找。

    • 第三步,当作用域块执行结束之后,内部定义的变量从词法环境栈顶弹出

  • 知识补充

    • var,let和const创建的变量都需要经历3个阶段:创建初始化赋值

    • 通过var和function的声明值,在编译后被存储到了变量环境;通过let和const声明的变量,除了编译时会被存储到词法环境,执行时也会根据新的局部let和const声明被追加进去。

    • function的创建、初始化和赋值均会被提升;var的创建和初始化会被提升,赋值不会被提升;let和const的创建被提升,初始化和赋值不会被提升。

    • 暂时性死区,是指通过let和const声明的变量绑定了这个代码区块,不再受外部的影响。

    • 暂时性死区的影响在于,在let和const声明变量之前,该变量都是不可用的,提前使用会报Cannot access 'b' before initialization(初始化前不能访问b)。

    • 通过谷歌f12实践后发现

      • 在let和const声明之前使用变量,谷歌工具栏的scope无法看到其变量项,并且执行到时会报“Cannot access 'b' before initialization”。

      • 在let和const声明之前不使用变量,谷歌工具栏的scope看到其变量项,并且值为undefind。

      • 因此可以得出以下结论

        • 暂时性死区的截断发生在初始化之前。

        • 进入let和const声明代码的作用域时,如果未发生暂时性死区,被创建时提升到词法环境的变量会被赋值上undefind。

    • ES6规定,块级作用域内部声明的函数和通过let和const声明变量的行为类似,无法被提升。但是为了向下兼容,各大游览器将块内的函数声明理解成var声明定义的函数,因此块级外的调用会返回undefind。

作用域链和闭包

  • 作用域链

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

    • 当一段代码使用了一个变量时,js引擎首先会在当前的执行上下文中查找,如果没有找到,则会往outer变量所指向的执行上下文中查找。

    • 以一段代码为例

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

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

    • 至于这个查询位置outer和链条则是由词法作用域决定的。

  • 词法作用域

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

    • 图示

    • 从图中可以看出,词法作用域规定的作用域查找规则为,先从本身作用域查找,如果没找到,就往函数定义的外层进行查找,逐层向上。

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

  • 块级作用域中的变量查找

    • 在单个的执行上下文中,词法环境的优先级高于变量,因此是从右至左。上级查找的方式则不用多说,因此环境查找顺序为1、2、3、4、5。
  • 闭包

    • 以一段代码为例

      function foo() {
          var myName = "极客时间"
          let test1 = 1
          const test2 = 2
          var innerBar = {
              getName:function(){
                  console.log(test1)
                  return myName
              },
              setName:function(newName){
                  myName = newName
              }
          }
          return innerBar
      }
      var bar = foo()
      bar.setName("极客邦")
      bar.getName()
      console.log(bar.getName())
      
    • 当执行到return innerBar的位置时

    • 根据词法作用域的规则,内部函数getName和setName总是可以访问它们的外部函数 foo中的变量。

    • 因此,当执行setName和getName时,整个调用栈的状态如下

    • 从上图可以看出,foo函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的setName和getName方法中使用了foo函数内部的变量myName和test1,所以这两个变量依然保存在内存中,这两个变量的集合就叫做闭包

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

    • 当存在闭包时,js引擎的变量查找顺序为:当前执行上下文–>特定函数的闭包–> 全局执行上下文

  • 闭包回收

    • 如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭。

    • 如果引用闭包的函数是个局部变量,等函数销毁后,下次js引擎执行垃圾回收时,判断闭包这块内容不再被用了,那么js引擎的垃圾回收器就会将其回收。

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

This

  • this是和执行上下文绑定的,属于执行上下文的一部分。

  • 作用域链和this是两套不同的系统,它们之间基本没太多联系。

  • 执行上下文分为3种:全局执行上下文、函数执行上下文和eval执行上下文。

  • 全局执行上下文

    • 全局执行上下文中的this是指向window对象。

    • 作用域链的最底端包含window对象,这是this和作用域链的唯一交点。

  • 函数执行上下文

    • 在全局环境中调用一个函数,函数内部的this指向的是全局变量window。

    • 通过一个对象来调用其内部的一个方法,函数内部的this指向对象本身。

  • 设置函数执行上下文this的3种方法

    • 通过函数的call方法设置

      let bar = {
        myName : "极客邦",
        test1 : 1
      }
      function foo(){
        this.myName = "极客时间"
      }
      foo.call(bar) // 还可以使用bind和apply
      console.log(bar)
      console.log(myName)
      
    • 通过对象调用方法设置

      var myObj = {
        name : "极客时间", 
        showThis: function(){
          console.log(this)
        }
      }
      myObj.showThis() // 使用对象来调用其内部方法,this指向对象本身
      
    • 通过构造函数中设置

      function CreateObj(){
        this.name = "极客时间"
      }
      var myObj = new CreateObj();
      
      // new CreateObj()的过程
      
      var tempObj = {} 
      CreateObj.call(tempObj);
      tempObj.__proto__ = CreateObj.prototype;
      return tempObj
      
  • this的设计缺陷

    • 嵌套函数中的this,不会从外层函数中继承。

      • 解决办法:

        • 箭头函数

          • 因为ES6中的箭头函数不会创建自身的执行上下文,所以箭头函数中的this取决于它的外部函数
        • this作为变量使用

    • 普通函数中的this,默认指向全局对象window。

      • 解决办法:

        • 使用call或apply进行绑定调用

        • 设置严格模式(this默认为undefined)

V8工作原理

栈空间和堆空间

  • 语言类型

    • 使用之前就需要确定其变量数据类型的语言,被称为静态语言

    • 运行过程中才进行变量数据类型检查的语言,被称为动态语言

    • 支持隐式类型转换的语言,被称为弱类型语言

    • 不支持隐式类型转换的语言,被称为强类型语言

    • 在进行代码赋值时,引擎自动将变量的数据类型转换为赋值数据的类型行为,被称为隐式类型转换

    • JavaScript是一种弱类型的、动态的语言。

  • 数据类型

    • 原始类型

      • Boolean,Null,Undefind,Number,BigInt,String,Symbol
    • 引用类型

      • Object

  • 内存空间

    • 代码空间

      • 存储可执行代码
    • 栈空间

      • 存储原始类型数据

      • 存储引用类型数据的地址

      • 存储执行上下文

      • 空间设置较小

      • 也称调用栈

    • 堆空间

      • 存储引用类型数据

      • 存储闭包数据

      • 空间设置很大(能存放很多大的数据)

    • 设计考虑

      • 控制栈空间大小,保证上下文切换效率,从而保证程序执行效率。
    • 示例代码

      function foo(){
          var a = "极客时间"
          var b = a
          var c = {name:"极客时间"}
          var d = c
      }
      foo()
      
    • 示意图

  • 再谈闭包

    function foo() {
        var test2 = 2
        var innerBar = { 
            getTest: function(){
                return test2;
            },
        }
        return innerBar
    }
    var bar = foo(); // 第一步
    bar.getTest(); //第二步
    

    • 第一步,执行foo()前,创建执行上下文。引擎通过词法扫描,发现getTest引用了外部函数的变量,判断出这是闭包,于是在堆空间创建换一个closure(foo)对象。

    • 第二步,执行getTest时,foo上下文出栈,堆空间保存closure(foo)对象,然后引擎创建getTest的执行上下文,拿到闭包数据中的test2变量进行返回。

垃圾回收

  • 垃圾回收策略

    • 手动回收

    • 自动回收

  • 栈数据的垃圾回收

    • 以一段代码为例

      function foo(){
          var a = 1
          var b = {name:"极客邦"}
          function showName(){
            var c = "极客时间"
            var d = {name:"极客时间"}
          }
          showName()
      }
      foo()
      
    • 当showName函数执行到最后一行时,其调用栈和堆空间状态如下所示

    • 当showName函数执行完成之后

    • 从图中可以看到,其实在执行入栈出栈的过程中,还有一个记录当前执行状态的指针(称为 ESP)

    • 在调用栈内容出栈的时候,js引擎会将ESP下移到foo函数的执行上下文,这个下移操作就是销毁showName函数执行上下文的过程。

    • 因此,可以得出当一个函数执行结束之后,js引擎会通过向下移动ESP销毁该函数保存在栈中的执行上下文

    • 当foo函数执行结束后,ESP指向全局执行上下文,不过保存在堆中的两个对象依然占用着空间。

  • 堆数据的垃圾回收

    • 还是以栈垃圾数据回收的那个代码为例,要消除剩余的堆数据垃圾,需要用到js引擎中的垃圾回收器,而垃圾回收的策略都是建立在代际假说的基础之上,这个假说适用于大多数的动态语言,js也是。

    • 代际假说的两个特点

      • 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;

      • 第二个是不死的对象,会活得更久。

    • 在V8中,会把堆分为新生代老生代两个区域

      • 新生代,存放的是生存时间短的对象,通常只支持1~8M的容量,由副垃圾回收器负责回收。

      • 老生代,存放的生存时间久的对象,支持的容量也大很多,由主垃圾回收器负责回收。

    • 垃圾回收器的工作流程

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

      • 第一步,标记空间中活动对象和非活动对象。

      • 第二步,回收非活动对象所占据的内存。

      • 第三步,内存整理。

        • 有些垃圾回收器会产生内存碎片,影响后续内存分配,比如主垃圾回收器

        • 有些则不会,比如副垃圾回收器

    • 副垃圾回收器

      • 主要负责新生区的垃圾回收,大多数小的对象都会被分配到新生区。

      • 新生代中用Scavenge算法来处理,把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。

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

      • 在垃圾回收过程中,首先要对对象区域中的垃圾做标记,标记完成之后,就进入垃圾清理阶段

      • 副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,这样就完成了内存整理,复制后的空闲区域就没有内存碎片了。

      • 完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域,这样就完成了垃圾回收

      • 这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

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

    • 主垃圾回收器

      • 主要负责老生区中的垃圾回收,除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。

      • 主垃圾回收器是采用标记-清除的算法进行垃圾回收。

      • 在垃圾回收过程中,首先从一组根元素开始,递归遍历这组根元素进行标记

      • 在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据(当函数上下文出栈后,它作用域中的堆引用就无法被遍历到了,因此被标记为垃圾数据)。

      • 接下来就是垃圾的清除过程,它和副垃圾回收器的垃圾清除过程完全不同,可以理解为清除红色标记数据的过程。

      • 基于这种算法的垃圾清除容易产生大量不连续的内存碎片,又衍生了另一种算法,叫标记-整理

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

    • 全停顿

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

      • 主垃圾回收器执行一次完整的垃圾回收流程如下图所示

      • 在V8新生代的垃圾回收(副垃圾回收器)中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代(主垃圾回收器)就不一样了,这势必会出现体验问题。

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

      • 通过把一个完整的垃圾回收任务拆分为很多小的任务,而这些小任务执行时间比较短,可以穿插在其他js任务中间执行,这样就解决垃圾回收造成的体验问题。

编译器和解释器

  • 编译器和解释器

    • 之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。

    • 按语言的执行流程,可以把语言划分为编译型语言解释型语言

    • 编译型语言,在程序执行之前,需要经过编译器的编译,之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,不需要再次重新编译了。

    • 解释型语言,在每次运行时都需要通过解释器对程序进行动态解释和执行。

    • 编译器和解释器的执行图示

  • V8是如何执行一段js代码的

    • 第一步,将源代码转换为抽象语法树(AST),并生成执行上下文

      • 高级语言是开发者可以理解的语言,AST是编译器或者解释器可以理解的东西。

      • 无论你使用的是解释型语言还是编译型语言,在编译过程中它们都会生成一个AST。

      • AST转换示例

        var myName = "极客时间"
        function foo(){
          return 23;
        }
        myName = "geektime"
        foo()
        
      • 经过词法/语法分析后,生成的AST结构如下

      • 可以把AST看成代码的结构化的表示,编译器或解释器的后续工作都需要依赖于它,而不是源代码。

      • AST是非常重要的一种数据结构,在很多项目中有着广泛的应用,比如Babel和ESLint,它们都是利用的AST转化进行实现的。

      • AST的生成要经历的2个阶段(先分词,再解析)

        • 第一阶段是分词(tokenize),又称为词法分析

          • 其作用是将一行行的源码拆解成一个个token。

          • 所谓token,则是指语法上不可能再分的、最小的单个字符或字符串。

          • token示意图如下

          • 其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“极客时间”四个都是token,而且它们代表的属性还不一样。

        • 第二阶段是解析(parse),又称为语法分析

          • 其作用是将上一步生成的token数据,根据语法规则转为抽象语法树(AST)

          • 如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

    • 第二步,生成字节码

      • 解释器(Ignition)根据AST生成字节码。

      • 字节码是介于AST和机器码之间的一种代码,与特定类型的机器码无关。

      • 字节码需要通过解释器将其转换为机器码后才能执行。

      • 字节码的产生,源于最初直接编译成机器码的方式在手机端的内存占用很高,V8团队因此优化了引擎结构,引入了字节码。

      • 高级代码、字节码和机器码对比图

      • 从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

    • 第三步,执行代码

      • 解释器(Ignition)除了负责生成字节码之外,也负责解释执行字节码。

      • 具体的执行涉及到了即时编译(JIT)技术,也就是字节码配合解释器和编译器一起配合工作。

      • 具体到V8,就是指解释器在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变(被重复执行多次)之后,TurboFan编译器便参与进来,把热点字节码转换为机器码并保存起来,以备下次使用。

      • 这种转换为机器码的方式,去掉了字节码“翻译”为机器码的过程,大大提升了代码的执行效率,也解决了最初所有代码都编译为机器码时对内存的过高占用问题。

      • 简单来说,就是一部分代码通过解释器Ignition解释执行,一部分代码通过TurboFan编译器编译执行。编译的这部分代码属于被频繁使用的热点代码,因此被编译为机器码后保存了起来,以备重复使用。

      • 即时编译(JIT)技术图示

  • JavaScript性能优化方向

    • 由于引擎的优化,现在的js优化中心应该聚焦在单次脚本的执行时间和脚本的网络下载上。

    • 主要关注以下三点内容

      • 提升单次脚本的执行速度,避免js长任务霸占主线程,加速页面的响应交互。

      • 避免大的内联脚本,因为在解析HTML的过程中,js的解析和编译也会占用主线程。

      • 减少js文件的体量,因为更小的文件会提升下载速度,并且占用更低的内存。

游览器中的页面循环系统

消息队列和事件循环

  • 单线程处理流水任务

    • 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务

    • 并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的,那么这种模型就行不通了

  • 线程运行期间处理新任务

    • 要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制,这里以循环语句和事件系统来模拟

    • 这种模型所有的任务都是来自于线程内部,如果另外一个线程想让主线程执行一个任务就行不通了

  • 处理其他线程发来的任务

    • 以游览器为例来看线程之间的信息通信

    • 为了便于接收其他线程发送的消息,通常会采用消息队列

    • 消息队列是一种数据结构,可以存放要执行的任务,里头的任务遵循“先进先出”的特点

    • 基于线程通信和消息队列实现的线程模型如下

  • 处理其他进程发来的任务

    • 在游览器中,进程间的通信会应用到游览器的IPC通信机制

    • 游览器的渲染进程中,专门有一个IO线程用来接收其他进程传进来的消息

    • IO线程接收到消息之后,会将这些消息组装成任务发送给渲染主线程

  • 主线程如何安全退出

    • Chrome是这样解决的,在确定要退出当前页面时,页面主线程会设置一个退出标志的变量

    • 在每次执行完一个任务时,判断是否有设置退出标志,如果设置了,就直接中断当前的所有任务,退出线程

  • 页面使用单线程的缺点

    • 页面线程所有执行的任务都来自于消息队列,消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。

    • 鉴于这个属性,就有如下两个问题需要解决:

      • 如何处理高优先级的任务

        • 以DOM更新为例,如果DOM发生变化

          • 如果采用同步的方式,会影响当前任务的执行效率

          • 如果采用异步的方式,会影响到任务监控的实时性

        • 如何权衡效率和实时性

          • 微任务

          • 微任务的设计

            • 通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列

            • 在执行宏任务的过程中,如果DOM有变化(MutationObserver),那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题

            • 当宏任务中的主要功能都直接完成之后,这时候渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为DOM变化的事件都保存在这些微任务队列中,这样也就解决了实时性的问题

      • 如何解决单个任务执行时长过久的问题

        • 因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态,如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

        • 从图中可以看到,如果在执行动画过程中,其中有个js任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。

        • 对这种情况,js通过回调功能来规避这种问题,也就是让要执行的js任务滞后执行。

  • 补充知识

    • 排版引擎blink和js引擎v8都工作在渲染进程的主线程上,所以同时只能执行一个,因此两者是互斥的。

    • 因为js引擎是运行在页面主线程上,所以游览器的事件循环机制交互的对象是页面主线程,而不是所谓的js引擎线程,这种说法是错误的,js并没有单独的运行线程。

    • 因为JavaScript引擎是运行在渲染进程的主线程上的,所以我们说JavaScript是单线程执行的。

setTimeout

  • 浏览器怎么实现setTimeout

    • Chrome针对延迟任务,独立维护了一个消息队列,本质上是一个hashmap结构,用于存储需要延迟执行的任务。

    • 在游览器的事件循环中,每执行完一个消息队列的任务,就会遍历延迟队列里的任务列表,取出到期的回调任务执行,然后进入下一个事件循环。

    • setTimeout的取消很简单,直接在延迟队列里通过定时器ID查找到对应的任务,然后再将其从队列中删除掉就可以了。

  • 使用setTimeout的一些注意事项

    • 当事件循环中的某个消息任务执行过久,会影响定时器任务执行的时间准确性。

    • 如果setTimeout存在嵌套调用,时间间隔为0,调用超过五次以上,那么系统会设置最短时间间隔为4毫秒。

    • 未激活的页面,setTimeout执行最小间隔是1000毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

    • 延时执行时间有最大值,超过最大值就会溢出,延迟时间被重置为0,回调因此被很快地执行了,最大值为2147483647毫秒,大约24.8天。

    • 使用setTimeout设置的回调函数中的this不符合直觉,回调中的this默认被绑定到window。

XMLHttpRequest

  • 回调函数VS系统调用栈

    • 浏览器页面是通过事件循环机制来驱动的,每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,消息队列和主线程循环机制保证了页面有条不紊地运行。

    • 当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈,这个系统调用栈类似于js的调用栈,只不过系统调用栈是Chromium的开发语言C++来维护的。

    • 循环系统中的每个任务在执行过程中都有自己的调用栈,同步回调就是在当前主函数的上下文中执行的回调函数。

    • 异步回调是指回调函数在主函数之外执行,一般有两种方式:

      • 第一种是把异步函数做成一个任务,添加到信息队列尾部

      • 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务

  • XMLHttpRequest运作机制

    • XMLHttpRequest工作流程图

      • 渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用IPC来通知渲染进程

      • 渲染进程接收到消息之后,会将xhr的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数

        • 如果网络请求出错了,就会执行xhr.onerror

        • 如果超时了,就会执行xhr.ontimeout

        • 如果是正常的数据接收,就会执行onreadystatechange来反馈相应的状态

    • XMLHttpRequest使用过程中的坑

      • 跨域问题

        • 因为游览器的同源安全策略,不同域名之间跨域请求是不被允许的
      • HTTPS混合内容的问题

        • HTTPS混合内容是指,HTTPS页面中包含了不符合HTTPS安全要求的内容。比如通过HTTP加载的图像、视频、样式表、脚本等,都属于混合内容。

        • 如果HTTPS请求页面中使用混合内容,浏览器会针对HTTPS混合内容显示警告,用来向用户表明此HTTPS页面包含不安全的资源。

        • 对这种混合内容的资源请求只会显示警告,但是如果是XMLHttpRequest请求,游览器会认为这种请求可能是攻击者发起的,请求会被阻止。

宏任务和微任务

  • 宏任务

    • 定义

      • 保存在渲染进程的消息队列里的任务
    • 执行

      • 通过事件循环系统来执行

      • 执行过程

        • 取出最老任务(oldestTask)

        • 记录任务开始时间,将其标记为正在执行

        • 执行完后,删除这个任务,在消息队列中清除它

        • 统计执行完成的时长

    • 不足

      • 时间粒度比较大,执行的时间间隔不能精确控制,对一些高实时性的需求就不太符合

      • 原因

        • 页面涉及的消息任务很多,各类任务的插入让js无法准确掌控自身任务的队列位置,所以很难控制开始执行任务的时间
  • 微任务

    • 定义

      • 一个需要异步执行的函数任务
    • 创建

      • 由js引擎创建,使用和维护

      • 产生于宏任务的执行过程中

      • 在创建全局执行上下文时,内部会创建一个微任务队列

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

    • 执行

      • 时机

        • 主函数执行结束之后,当前宏任务结束之前
      • 特点

        • 微任务队列中的任务会一次执行完,过程中新增的也会被一次执行完,不会留待到下个宏任务
    • 总结

      • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列

      • 微任务的执行时长会影响到当前宏任务的时长

      • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行

  • 监听DOM变化的方法演变

    • setTimeout/setInterval

      • 时间间隔设置过长,DOM变化响应不够及时

      • 间隔设置过短,会浪费很多无用的工作量去检查DOM,会让页面变得低效

    • Mutation Event

      • 当DOM有变动时就会立刻触发相应的事件——同步回调

      • 每次DOM变动,渲染引擎都会去调用js,造成了严重的性能问题

      • 当更新量过大时,js占用的执行时间过长,造成页面失去交互

    • MutationObserver

      • 监视DOM变化,包括属性变化、节点增减、内容变化等。

      • MutationObserver的响应函数采用异步调用,不时每次DOM变化都触发异步调用,而是等多次DOM变化后一次触发。

      • MutationObserver会使用一个数据结构来记录这期间所有的DOM变化,这样即使频繁地操纵DOM,也不会对性能造成太大的影响。

      • MutationObserver通过异步调用和减少触发次数,缓解了性能问题;通过加入微任务设计,解决了及时性问题。

Promise

  • 异步编程的问题:代码逻辑不连续

    • Web应用的异步编程模型

      • 页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。

      • 当该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。

      • 排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。

      • Web页面的单线程架构决定了异步回调,而异步回调影响到了我们的编码方式

    • 异步编程模式代码

      //执行状态
      function onResolve(response){console.log(response) }
      function onReject(error){console.log(error) }
      
      let xhr = new XMLHttpRequest()
      xhr.ontimeout = function(e) { onReject(e)}
      xhr.onerror = function(e) { onReject(e) }
      xhr.onreadystatechange = function () { onResolve(xhr.response) }
      
      //设置请求类型,请求URL,是否同步信息
      let URL = 'https://time.geekbang.com'
      xhr.open('Get', URL, true);
      
      //设置参数
      xhr.timeout = 3000 //设置xhr请求的超时时间
      xhr.responseType = "text" //设置响应返回的数据格式
      xhr.setRequestHeader("X_TEST","time.geekbang")
      
      //发出请求
      xhr.send();
      
      • 短短的一段代码里面竟然出现了五次回调,这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,这就是异步回调影响到我们的编码方式。
  • 封装异步代码,让处理流程变得线性

    • 重点关注输入内容(请求信息)和输出内容(回复信息)

    • 整体思路如图

    • 代码实现如下

      //makeRequest用来构造request对象
      function makeRequest(request_url) {
          let request = {
              method: 'Get',
              url: request_url,
              headers: '',
              body: '',
              credentials: false,
              sync: true,
              responseType: 'text',
              referrer: ''
          }
          return request
      }
      
      //[in] request,请求信息,请求头,延时值,返回类型等
      //[out] resolve, 执行成功,回调该函数
      //[out] reject  执行失败,回调该函数
      function XFetch(request, resolve, reject) {
          let xhr = new XMLHttpRequest()
          xhr.ontimeout = function (e) { reject(e) }
          xhr.onerror = function (e) { reject(e) }
          xhr.onreadystatechange = function () {
              if (xhr.status = 200)
                  resolve(xhr.response)
          }
          xhr.open(request.method, URL, request.sync);
          xhr.timeout = request.timeout;
          xhr.responseType = request.responseType;
          //补充其他请求信息
          //...
          xhr.send();
      }
      
      XFetch(makeRequest('https://time.geekbang.org'),
          function resolve(data) {
              console.log(data)
          }, function reject(e) {
              console.log(e)
          })
      
    • 代码已经比较符合人的线性思维,简单场景下表现很好

    • 当面临一些复杂场景时,嵌套太多的回调函数陷入回调地狱

      XFetch(makeRequest('https://time.geekbang.org/?category'),
        function resolve(response) {
            console.log(response)
            XFetch(makeRequest('https://time.geekbang.org/column'),
                function resolve(response) {
                    console.log(response)
                    XFetch(makeRequest('https://time.geekbang.org')
                        function resolve(response) {
                            console.log(response)
                        }, function reject(e) {
                            console.log(e)
                        })
                }, function reject(e) {
                    console.log(e)
                })
        }, function reject(e) {
            console.log(e)
        })
      
    • 上述代码的问题在于

      • 嵌套调用——下个任务依赖上个任务的结果

      • 任务的不确定性——每个任务都有两种可能的结果(成功或失败)

    • 解决问题的方向在于

      • 消灭嵌套调用

      • 合并多个任务的错误处理

  • Promise:消灭嵌套调用和多次错误处理

    • 用Promise改写代码如下

      function XFetch(request) {
        function executor(resolve, reject) {
            let xhr = new XMLHttpRequest()
            xhr.open('GET', request.url, true)
            xhr.ontimeout = function (e) { reject(e) }
            xhr.onerror = function (e) { reject(e) }
            xhr.onreadystatechange = function () {
                if (this.readyState === 4) {
                    if (this.status === 200) {
                        resolve(this.responseText, this)
                    } else {
                        let error = {
                            code: this.status,
                            response: this.response
                        }
                        reject(error, this)
                    }
                }
            }
            xhr.send()
        }
        return new Promise(executor)
      }
      
      var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
      var x2 = x1.then(value => {
          console.log(value)
          return XFetch(makeRequest('https://www.geekbang.org/column'))
      })
      var x3 = x2.then(value => {
          console.log(value)
          return XFetch(makeRequest('https://time.geekbang.org'))
      })
      x3.catch(error => {
          console.log(error)
      })
      
      • 改成后的代码非常线性了,也非常符合人的直觉

      • 对嵌套回调的解决

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

        • 将回调函数onResolve的返回值(Promise)穿透到最外层

      • 对异常错误的合并处理

        • Promise对象的错误具有“冒泡”性质,会一直向后传递,直到被onReject函数处理或catch语句捕获为止
  • Promise与微任务

    • Promise的模拟实现

      function Promise(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);
      }
      
      function executor(resolve, reject) {
          resolve(100)
      }
      let demo = new Promise(executor)
      
      function onResolve(value){
          console.log(value)
      }
      demo.then(onResolve)
      
    • 考虑到.then是延迟绑定的,onResolve应该延迟执行,所以通过定时器去实现

    • 我们都晓得使用定时器的效率并不是太高,属于宏任务。因此,V8引擎将其实现为微任务,这样既实现了延时调用,又提升了代码的执行效率。

    • 在Promise中,微任务的添加是在执行resolve或reject时,then和catch只是微任务关联的回调动作。

Async/Await

  • Promise的问题

    • 虽然Promise解决回调地狱,但当业务复杂时,频繁的then代码让代码语义化非常不明显,不能很好地表示执行流程。

    • 基于这个原因,ES7引入了async/await,这是js异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。

  • 生成器VS协程

    • 生成器

      // 带星号的函数即是生成器函数
      function* genDemo() {
          console.log("开始执行第一段")
          yield 'generator 2'
      
          console.log("开始执行第二段")
          yield 'generator 2'
      
          console.log("开始执行第三段")
          yield 'generator 2'
      
          console.log("执行结束")
          return 'generator 2'
      }
      
      console.log('main 0')
      let gen = genDemo()
      console.log(gen.next().value)
      console.log('main 1')
      console.log(gen.next().value)
      console.log('main 2')
      console.log(gen.next().value)
      console.log('main 3')
      console.log(gen.next().value)
      console.log('main 4')
      
    • 协程

      • 协程是一种比线程更加轻量级的存在,可以把它看成是跑在线程上的任务。

      • 一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。

      • 跑在主线程上的多个协程需要配合工作,协程与协程之间实现对主线程控制权的转交,以此实现一个完整的程序任务。

      • 如果从A协程启动B协程,我们就把A协程称为B协程的父协程

      • 协程不是被操作系统内核所管理,而完全是由程序所控制,这样好处就是性能得到提升,不会像线程切换那样消耗资源。

      • 以上面生成器函数代码为例,协程执行流程图如下

      • 从图中可以看出来协程的四点规则:

        • 通过调用生成器函数genDemo来创建一个协程gen,创建之后,gen协程并没有立即执行。

        • 要让gen协程执行,需要通过调用gen.next。

        • 当协程正在执行的时候,可以通过yield关键字来暂停gen协程的执行,并返回主要信息给父协程。

        • 如果协程在执行期间,遇到了return关键字,那么js引擎会结束当前协程,并将return后面的内容返回给父协程。

      • 协程与协程之间的切换

      • 从图中可以看出

        • gen协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过yield和gen.next来配合完成

        • 当在gen协程中调用了yield方法时,js引擎会保存gen协程当前的调用栈信息,并恢复父协程的调用栈信息。

        • 同样,当在父协程中执行gen.next时,js引擎会保存父协程的调用栈信息,并恢复gen协程的调用栈信息。

    • 与async/await的关系

      • 在js中,生成器函数就是协程的一种实现方式。

      • async/await技术就是Promise和生成器应用,往低层说就是微任务和协程应用。

  • async/await

    • async

      • async是一个通过异步执行隐式返回Promise作为结果的函数。

      • async函数的隐式返回体现在,不用刻意return Promise和任何值,函数执行后默认返回一个Promise。

    • await

      async function foo() {
          console.log(1)
          let a = await 100
          console.log(a)
          console.log(2)
      }
      console.log(0)
      foo()
      console.log(3)
      
    • 结合协程的概念,上述代码执行图示如下

    • 重点分析await过程

      • 当执行到await 100时,内部会创建一个Promise对象,默认执行resolve(100),添加一个微任务到微任务队列。

      • 然后js引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将这个promise返回给父协程。

      • 父协程拿到主线程的控制权后,内部实现了promise.then来监控promise状态的改变,然后继续执行父协程的流程,执行console.log(3)。

      • 随后父协程将执行结束,在结束之前进入微任务的检查点,然后执行微任务队列,将之前的resolve(100)微任务,触发微任务关联的内部then回调。

      • 当then回调被激活后,会将主线程的控制权交给foo函数的协程,并同时将value 值传给该协程。

      • foo协程激活之后,会把刚才的value值赋给变量a,然后foo协程继续执行后续语句,执行完成之后,将控制权归还给父协程。

游览器中的页面

Dom树

  • 什么是DOM

    • 从页面视角来看,DOM是生成页面的基础数据结构,用于表述HTML文档

    • 从js视角来看,DOM是提供给js操作的接口,通过这套接口对DOM结构进行访问和修改

    • 从安全视角来看,DOM是一道安全防护线,一些不安全内容在DOM解析阶段都被拒之门外

  • DOM树生成之前

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

    • 具体流程

      • 网络进程接收到响应头之后,会根据响应头中的content-type字段来判断文件的类型

      • 如果判断这是一个HTML类型的文件,就会为该请求选择或者创建一个渲染进程

      • 渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道

      • 网络进程接收到数据后,就会往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给HTML解析器

  • DOM树生成

    • 简览图

    • 详览图

    • 生成流程

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

        • 接受到从网络进程传递过来的字节流后,将其还原来成HTML文档字符。

        • 接着分词器通过词法分析,将这些字符转换为一个个Token。

        • 从图中可以看出,有Tag Token和文本Token,tag Token又分StartTag和EndTag。

      • 第二阶段,将Token解析为DOM节点并加入DOM树

        • HTML解析器维护了一个Token栈结构,该Token栈主要用来计算节点之间的父子关系,在第一个阶段中生成的Token会被按照顺序压到这个栈中。

        • 如果压入到栈中的是StartTag标签,HTML解析器会为该Token创建一个DOM节点,然后将该节点加入到DOM树中,然后将该节点加入 DOM树中,它的父节点就是栈中相邻的那个元素生成的节点。

        • 如果分词器解析出来是文本Token,那么会生成一个文本节点,然后将该节点加入到 DOM树中,文本Token是不需要压入到栈中,它的父节点就是当前栈顶Token所对应的 DOM节点。

        • 如果分词器解析出来的是EndTag标签,比如是EndTag div,HTML解析器会查看 Token栈顶的元素是否是StarTag div,如果是,就将StartTag div从栈中弹出,表示该div元素解析完成。

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

    • 生成案例

      • 以下面代码为例

        <html>
        <body>
            <div>1</div>
            <div>test</div>
        </body>
        </html>
        
      • 从网络进程接受到的上面的数据后,以字节流的形式传给了HTML解析器。

      • HTML解析器开始工作,默认创建了一个根为document的空DOM结构,同时会将一个 StartTag document的Token压入栈底。

      • 接着,经过分词器解析出来的第一个StartTag html Token被压入栈中,并创建出一个 html的DOM节点,添加到document上。

      • 然后,按照同样的流程解析出来StartTag body和StartTag div。

      • 接下来,解析出来的是第一个div的文本Token,渲染引擎会为该Token创建一个文本节点,并将该Token添加到DOM中,它的父节点就是当前Token栈顶元素对应的节点。

      • 再接下来,分词器解析出来第一个EndTag div,这时HTML解析器会去判断当前栈顶的元素是否是StartTag div,如果是,则从栈顶弹出StartTag div。

      • 按照同样的规则,一路解析,最终结果如下图所示

      • 在实际生产环境中,HTML源文件中既包含CSS和JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个Demo复杂。

  • JS对DOM解析的影响

    • script标签代码的插入

      • 当DOM解析过程中,遇到script标签代码,HTML解析器暂停工作,JS引擎介入,代码执行完毕后,HTML解析器恢复工作,继续完成后续内容的解析,直至生成最终的 DOM。
    • script标签引入外部js脚本

      • 增加了一个下载过程,这个过程同样占用了时间,阻塞了HTML的解析工作。

      • 在资源加载方面,Chrome浏览器做了预解析优化,通过预解析线程分析HTML关联的资源,做了提前下载的工作。

    • js脚本对css样式表的依赖

      • 因为JS有修改CSS的能力,CSSOM的形成又影响到布局渲染的构建,因此游览器规定,如果JS脚本之前存在CSS,那么在JS执行之前,需保证CSS的下载和解析完成。

      • V8引擎在解析JS之前,并不知道是否操作了在它之前的css样式表,所以渲染引擎在遇到JS脚本时,不管该脚本是否操作了CSS,都会先执行CSS文件下载和解析操作,再行JS脚本。

      • 这样一来,CSS的下载/解析就阻塞了JS的执行,而JS又会阻塞DOM,这样CSS就间接的阻塞了DOM的解析。

      • 如果JS代码中引入了外部CSS文件,则这个阻塞过程还要包括CSS的下载完成时间。

  • 引入async/defer后

    • (蓝色为DOM解析,紫色为脚本下载,黄色为脚本执行,绿色为DOMContentLoaded触发)

    • 普通script

      • 脚本的下载和执行,都会阻塞DOM解析。

      • 脚本的下载速度存在不同,但是后引入的脚本执行,依赖于先引入脚本的下载和执行完成。

    • defer

      • 脚本下载与DOM解析并行,只有当DOM解析完毕,才会执行JS内容。

      • 脚本的下载速度存在不同,但是后引入的脚本执行,依赖于先引入脚本的下载和执行完成。

    • async

      • 脚本下载与DOM解析并行,不管DOM是否解析完毕,只要脚本一旦下载完毕,就会执行JS内容。

      • 脚本的下载速度存在不同,脚本的执行没有依赖关系,哪个先下载完,就哪个先执行。

渲染流水线

  • 仅含CSS的渲染构建

    • 首先是发起主页面请求动作,发起方可能是渲染进程,也可能是浏览器进程,该指令被送到网络进程中去执行。

    • 网络进程接收到返回的HTML数据之后,将其发送给渲染进程,渲染进程会解析HTML数据并构建DOM。

    • 当这个HTML解析过程中,会先开启一个预解析线程,如果遇到JS和CSS等资源文件,那么预解析线程会提前下载这些数据。

  • 渲染流水线中的CSSOM

    • 和HTML一样,渲染引擎也无法直接理解CSS文件内容,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是CSSOM。

    • CSSOM的两个作用

      • 提供JavaScript操作样式表的能力

      • 为布局树的合成提供基础的样式信息

    • 布局树的合成需要依赖CSSOM,只有通过CSSOM才能进行布局计算,才能完成布局树的构建。

  • 包含CSS和JS的渲染构建(Script代码插入DOM结构)

    • DOM解析中,遇到了JS脚本,会先暂停DOM解析去执行JS,因为JS有可能会修改当前状态下的DOM。

    • 又因为JS有修改CSSOM的能力,所以在执行JS之前,还需要依赖CSSOM的构建完成。

  • 包含CSS和JS的渲染构建(Script外链脚本插入DOM结构)

    • 预解析线程同时发起CSS和JS文件的下载请求,这两个文件的下载过程是重叠的,下载时间按照最久的那个文件来算。

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

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

    • 渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验

    • 从URL发起到页面展示所经历的三个阶段

      • 第一阶段,从URL导航,到网络请求发起,到接受数据准备渲染。

      • 第二阶段,提交数据之后,渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,然后经过一系列步骤准备首次渲染。

      • 第三阶段,首次渲染完成之后,就开始进入完整页面的生成阶段,页面一点点被绘制出来。

    • 优化的重点在白屏时间

      • 这里的瓶颈主要体现在CSS和JS文件的下载和解析,因为它们影响布局树的构建,进而影响页面的渲染和绘制。

      • 相关优化策略

        • 通过内联CSS和JS来移除文件下载,直接开始渲染流程,但并不是所有场景都适合内联。

        • 尽量减少文件大小,通过构建工具移除一些不必要的注释并压缩文件。

        • 将一些不需要在解析HTML阶段使用的JS标记上aync或defer属性。

        • 对于大的CSS文件,可以通过媒体查询属性,将其拆分为多个不同用途的CSS文件,这样只有在特定的场景下才会加载特定的CSS文件。

分层和合成机制

  • 显示器是怎么显示图像的

    • 显卡硬件上基本都有两个缓冲区,显示器上见到的图像在前缓冲区,接下来将要显示的图像在后缓冲区

    • 显示器都有固定的刷新频率,通常是60HZ,也就是每秒更新60张图片,更新的图片都来自于前缓冲区。

    • 显卡的职责就是,合成新的图像并其保存到后缓冲区中,一旦新图象写入后缓冲区后,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到显卡中最新合成的图像。

    • 显示器的职责则是,每秒固定读取60次前缓冲区中的图像,并将读取的图像显示到显示器上。

    • 通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。

  • 帧VS帧率

    • 在页面渲染时,渲染引擎会通过渲染流水线生成新的图片,并发送到显卡的后缓冲区。

    • 大多数设备屏幕的更新频率是60次/秒,为了实现流畅的动画效果,渲染引擎需要每秒更新60张图片到显卡的后缓冲区。

    • 我们把渲染流水线生成的每一副图片称为一,把渲染流水线每秒更新了多少帧称为帧率

    • 由于用户很容易观察到那些丢失的帧,如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。

  • 如何生成一帧图像

    • 重排(布局),需要走一遍完整渲染流水线

    • 重绘,没有布局阶段,操作效率略高,但依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作

    • 合成,省去了布局和绘制两个阶段,效率最高

  • 分层和合成

    • 如果任何细小改动都触发重排或重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率,为了提升每帧的渲染效率,Chrome引入了分层和合成的机制。

    • 分层和合成的对渲染效率的解决体现在于,各个图层是单独绘制的,造成的变化互不影响,其次很多绘制操作可以直接利用合成线程来完成,不必走布局排列流程。

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

    • 层树中的每个节点都对属于一个图层,下一步的绘制阶段就依赖于层树中的节点。

    • 绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表,然后就会进入光栅化阶段,按照绘制列表中的指令生成图片。

    • 每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区。

    • 合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。

  • 分块

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

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

    • 因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。

    • 不过有时候即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到GPU内存的操作会比较慢。

    • 为了解决这个问题,Chrome采取的策略是:在首次合成图块的时候先生成一个低分辨率的图片,然后合成器继续绘制正常比例的网页内容,当正常比例的网页内容绘制完成后,再替换掉当前显示的低分辨率内容。

  • 如何利用分层技术优化代码

    • 当需要作一些几何变换和透明度操作时,考虑到js的操作会触发布局重排的流水线工作,所以绘制效率会非常低下。

    • 可以利用css的will-change属性,它会让渲染引擎为其准备独立的层,它的优化体现在三个方面:

      • 它对应的元素变换(transform)运行在独立的图层,脱离文档流,不会触发重排。

      • 它通过合成线程直接去处理变换,没有涉及主线程,大大提升了渲染效率。

      • 它启动了GPU加速,对应的元素变换由GPU管理,降低了CPU的负担。

    • 但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要额外的内存,所以你需要恰当地使用will-change。

    • 能直接在合成线程中完成的任务都不会改变图层的内容,如文字信息的改变,布局的改变,颜色的改变,统统不会涉及,涉及到这些内容的变化就要牵涉到重排或者重绘。

    • 能直接在合成线程中实现的是整个图层的几何变换,透明度变换,阴影等,这些变换都不会影响到图层的内容,因此跳过了重绘(Paint)阶段。

页面性能

  • 页面阶段

    • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和JavaScript脚本。

    • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是JavaScript脚本。

    • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

  • 加载阶段

    • 在一个渲染流水线中,图片、音频和视频等资源不会阻塞页面的首次渲染,而只有与页面渲染直接相关的HTML,CSS和JS才会阻塞渲染,我们把这些资源称之为关键资源

    • 优化维度

      • 关键资源个数,关键资源个数越多,首次页面的加载时间就会越长。

      • 关键资源大小,关键资源的内容越小,整个资源的下载时间也就越短,阻塞的时间也就越短。

      • 关键资源的请求RTT(数据包的往返时延)个数,一个资源文件通常是需要拆分为多个数据包来传输的,而1个HTTP数据包在14KB左右,大于它的数据包需要被拆分。

    • 优化方式

      • 减少关键资源个数

        • JS和CSS改成内联形式,减少个数

        • JS设置async/defer,避免成为首次关键资源

        • CSS设置合理的媒体显示类型,避免所有样式都成为首次关键资源

      • 降低关键资源大小

        • 压缩HTML,CSS和JS资源,去注释
      • 减少关键资源请求RTT个数

        • 通过CDN来减少每次RTT时长

        • 通过减少关键资源个数和大小,也降低了RTT时间

  • 交互阶段

    • 谈交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。

    • 交互阶段的渲染流水线

      • 交互阶段的帧产生,主要是由JS操作DOM和CSS产生,另外一部分则是由CSS单独触发。

      • 如果JS操作中有布局信息的修改,那么就会触发重排。

      • 如果只是修改了颜色一类的信息,就不会涉及布局调整,就只会触发重绘。

      • 如果只是CSS实现了一些变形、渐变、动画等特效,这只是在合成线程上执行的,这个过程称为合成。

      • 优化的重点在于,如何让单个帧的生成速度变快

    • 优化方式

      • 减少JS执行时间

        • 避免JS执行对主线程的长期占用

        • 将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。

        • 利用Web Workers制造后台独立的线程处理额外的JS任务。

      • 避免强制同步布局

        • 通过DOM接口执行DOM操作后,是需要重新计算样式和布局的,正常情况下这些操作都是在另外的任务中异步完成的。

        • 当JS进行DOM操作,更改DOM信息后,立马对DOM信息进行访问,就会强制游览器重新布局,这个操作被称为强制同步布局,这样的任务一次占用了主线程过长的时间。

        • 应当在更改之前,提前读取和存储DOM信息。

      • 避免布局抖动

        • 指在一次JS执行过程中,多次执行强制布局和抖动操作。

        • 这样情况通常出现在一个操作DOM的循环语句中。

        • 应该尽量避免在修改DOM结构时,再去查询一些相关值。

      • 合理利用CSS合成动画

        • 合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,即使此时主线程上运行着JS任务,也不会影响到合成线程上的任务。

        • 所以要尽量利用好CSS合成动画,如果能让CSS处理动画,就尽量交给CSS来操作。

      • 避免频繁的垃圾回收

        • 如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。

        • 这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。

        • 所以要尽量避免产生那些临时垃圾数据,尽可能优化储存结构,尽可能避免小颗粒对象的产生。

虚拟Dom

  • DOM的缺陷

    • 通过JS操纵DOM是会影响整个渲染流水线

    • JS操作DOM后,会触发样式计算、布局、绘制、栅格化、合成等任务,我们把这一过程称为重排,还有可能引起重绘或者合成操作,形象地理解就是牵一发而动全身

    • 对于DOM的不当操作,还有可能引发强制同步布局布局抖动的问题,这些操作都会大大降低渲染效率。

  • 什么是虚拟DOM

    • 虚拟DOM解决的事情

      • 将页面改变应用到虚拟DOM上,而不是直接应用到DOM上。

      • 变化被应用到虚拟DOM上时,虚拟DOM并不急着去渲染页面,而仅仅是调整虚拟DOM的内部状态,这样操作代价就变得非常轻了。

      • 在虚拟DOM收集到足够的改变时,再把这些变化一次性应用到真实的DOM上。

    • 虚拟DOM的运行

      • 以React为例

        • 创建阶段。首先依据JSX和基础数据创建出虚拟DOM,它反映了真实的DOM树结构。然后由虚拟DOM树创建出真实DOM树,真实DOM树生成完后,再触发渲染流水线往屏幕输出页面。

        • 更新阶段。如果数据发生改变,会根据新数据创建一个新的虚拟DOM树;然后比较两个树,找出变化的地方,并把变化的地方一次性更新到真实的DOM树上;最后渲染引擎更新渲染流水线,并生成新的页面。

    • 虚拟DOM的更新算法

      • 最开始的时候,比较两个虚拟DOM的过程是在一个递归函数里执行的,其核心算法是reconciliation

      • 通常情况下,这个比较过程执行得很快,不过当虚拟DOM比较复杂的时候,执行比较函数就有可能占据主线程比较久的时间,这样就会导致其他任务的等待,造成页面卡顿。

      • 为了解决这个问题,React团队重写了reconciliation算法,新的算法称为Fiber reconciler,之前老的算法称为Stack reconciler

      • Fiber reconciler中Fiber其实是协程的另一个称呼,通过利用协程的特性,在执行算法的过程中出让主线程,这样就解决了Stack reconciler函数占用时间过久的问题。

  • 双缓存

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

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

  • MVC模式

    • 示意图

    • MVC的整体结构比较简单,由模型、视图和控制器组成,其核心思想就是将数据和视图分离,也就是说视图和模型之间是不允许直接通信的,它们之间的通信都是通过控制器来完成的。

    • 根据不同的通信路径和控制器不同的实现方式,基于MVC又能衍生出很多其他的模式,如MVP、MVVM等,不过万变不离其宗,它们的基础骨架都是基于MVC而来。

    • 在分析React项目时,我们可以把React的部分看成是一个MVC中的视图,在项目中结合Redux就可以构建一个MVC的模型结构,如下图所示

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

      • 图中的控制器是用来监控DOM的变化,一旦DOM发生变化,控制器便会通知模型,让其更新数据

      • 模型数据更新好之后,控制器会通知视图,告诉它模型的数据发生了变化

      • 视图接收到更新消息之后,会根据模型所提供的数据来生成新的虚拟DOM

      • 新的虚拟DOM生成好之后,就需要与之前的虚拟DOM进行比较,找出变化的节点

      • 比较出变化的节点之后,React将变化的虚拟节点应用到DOM上,这样就会触发DOM更新

      • DOM节点的变化又会触发后续一系列渲染流水线的变化,从而实现页面的更新

渐进式网页应用PWA

  • 如何理解渐进式web应用

    • 对于开发者,它提供了非常温和的方式,让开发者将普通的站点逐步过渡到Web应用。

    • 对于技术本身而言,它是渐进式演进,逐渐将Web技术发挥到极致的同时,也逐渐缩小和本地应用的差距。

  • Web应用VS本地应用

    • 首先,Web应用缺少离线使用能力,在离线或者在弱网环境下基本上是无法使用的。而用户需要的是沉浸式的体验,在离线或者弱网环境下能够流畅地使用是用户对一个应用的基本要求。

    • 其次,Web应用还缺少消息推送能力,作为一个App厂商,需要有将消息送达到应用的能力。

    • 最后,Web应用缺少一级入口,也就是将Web应用安装到桌面,在需要的时候直接从桌面打开Web应用,而不是每次都需要通过浏览器来打开。

  • 如何理解PWA

    • 它是一套理念,渐进式增强Web的优势,并通过技术手段渐进式缩短和本地应用或者小程序的距离,基于这套理念之下的技术都可以归类到PWA。

    • 针对能力缺陷,PWA通过引入Service Worker来试着解决离线存储和消息推送的问题,通过引入manifest.json来解决一级入口的问题。

  • 什么是Service Worker

    • Service Worker理念的主要思想是,在页面和网络之间增加一个拦截器,用来缓存和拦截请求。

    • 在没有安装Service Worker 之前,WebApp都是直接通过网络模块来请求资源的。

    • 安装了Service Worker模块之后,WebApp请求资源时,会先通过Service Worker,让它判断是返回Service Worker缓存的资源还是重新去网络请求资源。

  • Service Worker的设计思路

    • 架构

      • 浏览器实现的Web Worker,虽然避免js过多占用页面主线程时长的情况,但是其只能执行一些和DOM无关的js任务,它的生命周期也是和页面关联的。

      • 让其运行在主线程之外就是Service Worker来自Web Worker的一个核心思想,由于需要会为多个页面服务,不能让其和单个页面绑定起来。

      • Web Worker是临时的,每次js脚本执行完成之后都会退出,执行结果也不能保存下来,如果下次还有同样的操作,还得重新来一遍。所以Service Worker在Web Worker的基础之上加上了储存功能

      • 在目前的Chrome架构中,Service Worker是运行在浏览器进程中的,因为浏览器进程生命周期是最长的,所以在浏览器的生命周期内,能够为所有的页面提供服务

    • 消息推送

      • 消息推送也是基于Service Worker来实现的。

      • 消息推送时,浏览器页面也许并没有启动,这时就需要Service Worker来接收服务器推送的消息,并将消息通过一定方式展示给用户。

    • 安全

      • 我们知道,HTTP采用的是明文传输信息,存在被窃听、被篡改和被劫持的风险,在项目中使用HTTP来传输数据无疑是“裸奔”。

      • Service Worker采用HTTPS协议,通信数据都经过了加密,即便被拦截数据,也无法破解数据内容,而且HTTPS还有校验机制,通信双方很容易知道数据是否被篡改。

      • 所以要使站点支持Service Worker,第一步就是要将站点升级到HTTPS,除此之外还需要同时支持Web页面默认的安全策略、储入同源策略、内容安全策略(CSP)等。

WebComponent

  • 什么是组件化

    • 对内高内聚,对外低耦合。对内各个元素彼此紧密结合、相互依赖,对外和其他组件的联系最少且接口简单。

    • 通过组件化,可以降低整个系统的耦合度,同时也降低程序员之间沟通复杂度,让系统变得更加易于维护。

  • 阻碍前端组件化的因素

    • 样式污染,样式之间会相互覆盖和影响。

    • dom公用,任何地方都可以读取和修改。

  • WebComponent组件化开发

    • 对前端组件化的解决

      • 提供了对局部视图封装能力,可以让DOM、CSSOM和JavaScript运行在局部环境中,这样就使得局部的CSS和DOM不会影响到全局。

      • WebComponent是一套技术的组合,涉及到Custom elements(自定义元素)、Shadow DOM(影子DOM)和HTML templates(HTML模板)。

    • 代码示例

      <!DOCTYPE html>
      <html>
      <body>
          <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>
      
      • 使用template属性创建的模板元素不会被渲染到页面上,这些基础的元素结构可以被重复使用。

      • 需要创建一个组件类,其中的影子DOM的作用是将模板中的内容与全局DOM和CSS进行隔离,实现元素和样式的私有化。

      • 在全局环境下,要访问影子DOM内部的样式或者元素是需要通过约定好的接口的。

      • 需要注意的是,影子DOM的js是不会被隔离的,可以被外部访问到。

    • 输出效果

  • 浏览器如何实现影子DOM

    • 示意图

    • 每个影子DOM都可以看成一个独立的DOM,它有自己的样式、属性,内部样式不会影响到外部样式,外部样式也不会影响到内部样式。

    • 当通过DOM接口查找元素时,渲染引擎会去判断组件属性下的shadow-root元素是否是影子DOM,如果是,就直接跳过shadow-root元素的查询,这样domAPI就无法直接查询到影子DOM的内部元素了。

    • 当生成布局树的时,渲染引擎也会判断组件属性下的shadow-root元素是否是影子DOM,如果是,那么在影子DOM内部元素的节点选择CSS样式的时候,会直接使用影子DOM内部的CSS属性,这样最终渲染出来的效果就是影子DOM内部定义的样式。

posted @ 2020-01-26 15:48  戡玉  阅读(496)  评论(0)    收藏  举报