一个进程就是一个程序的运行实例。javascript
详细解释就是,启动一个程序的时候,操做系统会为该程序建立一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,咱们把这样的一个运行环境叫进程。css
单线程与多线程的进程对比图html
线程是依附于进程的,而进程中使用多线程并行处理能提高运算效率。java
进程和线程之间的关系有如下 4 个特色。node
进程中的任意一线程执行出错,都会致使整个进程的崩溃。webpack
线程之间共享进程中的数据。web
线程之间共享进程中的数据示意图算法
从上图能够看出,线程 一、线程 二、线程 3 分别把执行的结果写入 A、B、C 中,而后线程 2 继续从 A、B、C 中读取数据,用来显示执行结果。chrome
当一个进程关闭以后,操做系统会回收进程所占用的内存。promise
进程之间的内容相互隔离。
单进程浏览器是指浏览器的全部功能模块都是运行在同一个进程里
如此多的功能模块运行在一个进程里,是致使单进程浏览器不稳定、不流畅和不安全的一个主要因素。
chrome 进程架构
Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是经过 IPC 机制进行通讯(如图中虚线部分)。
经过单独进程模式来解决单进程浏览器碰到 不稳定 不流畅的问题,经过sandbox(安全沙箱)来解决安全问题。
Chrome 把插件进程和渲染进程锁在沙箱里面,这样即便在渲染进程或者插件进程里面执行了恶意程序,恶意程序也没法突破沙箱去获取系统权限。
从图中能够看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
打开 1 个页面至少须要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;若是打开的页面有运行插件的话,还须要再加上 1 个插件进程。
负面问题:
为了解决这些问题,在 2016 年,Chrome 官方团队使用**"面向服务的架构"**(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。
也就是说 Chrome 总体架构会朝向现代操做系统所采用的“面向服务的架构” 方向发展,原来的各类模块会被重构成独立的服务(Service),每一个服务(Service)均可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,经过 IPC 来通讯,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。
Chrome“面向服务的架构”进程模型图
同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,可是若是在资源受限的设备上(以下图),Chrome 会将不少服务整合到一个进程中,从而节省内存占用。
在资源不足的设备上,将服务合并到浏览器进程中
数据包要在互联网上进行传输,就要符合网际协议(Internet Protocol,简称 IP)标准。
计算机的地址就称为 IP 地址,访问任何网站实际上只是你的计算机向另一台计算机请求信息。
无需创建链接就能够发送封装的 IP 数据报的方法
IP 是很是底层的协议,只负责把数据包传送到对方电脑,可是对方电脑并不知道把数据包交给哪一个程序,是交给浏览器仍是交给xxx?所以,须要基于 IP 之上开发能和应用打交道的协议,最多见的是“用户数据包协议(User Datagram Protocol)”,简称 UDP。
UDP 中一个最重要的信息是端口号,端口号其实就是一个数字,每一个想访问网络的程序都须要绑定一个端口号。经过端口号 UDP 就能把指定的数据包发送给指定的程序了,因此 IP 经过 IP 地址信息把数据包发送给指定的电脑,而 UDP 经过端口号把数据包分发给正确的程序。和 IP 头同样,端口号会被装进 UDP 头里面,UDP 头再和原始数据包合并组成新的 UDP 数据包。UDP 头中除了目的端口,还有源端口号等信息。
简化的 UDP 网络四层传输模型
在使用 UDP 发送数据时,有各类因素会致使数据包出错,虽然 UDP 能够校验数据是否正确,可是对于错误的数据包,UDP 并不提供重发机制,只是丢弃当前的包,并且 UDP 在发送以后也没法知道是否能达到目的地。
虽然说 UDP 不能保证数据可靠性,可是传输速度却很是快,因此 UDP 会应用在一些关注速度、但不那么严格要求数据完整性的领域,如在线视频、互动游戏等。
对于浏览器请求,或者邮件这类要求数据传输可靠性(reliability)的应用,若是使用 UDP 来传输会存在两个问题:
基于这两个问题,咱们引入 TCP 了。TCP(Transmission Control Protocol,传输控制协议)是一种面向链接的、可靠的、基于字节流的传输层通讯协议。相对于 UDP,TCP 有下面两个特色:
和 UDP 头同样,TCP 头除了包含了目标端口和本机端口号外,还提供了用于排序的序列号,以便接收端经过序号来重排数据包
简化的 TCP 网络四层传输模型
TCP 单个数据包的传输流程和 UDP 流程差很少,不一样的地方在于,经过 TCP 头的信息保证了一块大的数据传输的完整性。
一个完整的 TCP 链接的生命周期包括了“创建链接”“传输数据”和“断开链接”三个阶段。
一个 TCP 链接的生命周期
TCP 为了保证数据传输的可靠性,牺牲了数据包的传输速度,由于“三次握手”和“数据包校验机制”等把传输过程当中的数据包的数量提升了一倍。
HTTP协议和TCP协议都是TCP/IP协议簇的子集。
HTTP协议属于应用层,TCP协议属于传输层,HTTP协议位于TCP协议的上层。
请求方要发送的数据包,在应用层加上HTTP头之后会交给传输层的TCP协议处理,应答方接收到的数据包,在传输层拆掉TCP头之后交给应用层的HTTP协议处理。创建 TCP 链接后会顺序收发数据,请求方和应答方都必须依据 HTTP 规范构建和解析HTTP报文。
tcp协议是传输协议,如何运输,运输内容就是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 链接。
重定向
缓存
缓存查找流程示意图
总结图
HTTP 请求流程示意图
从输入 URL 到页面展现完整流程示意图
大体步骤
详细步骤
用户输入
URL 请求过程
浏览器进程会经过进程间通讯(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。
首先,网络进程会查找本地缓存是否缓存了该资源。若是有缓存资源,那么直接返回资源给浏览器进程;若是在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。若是请求协议是 HTTPS,那么还须要创建 TLS 链接
https和http区别
接下来就是利用 IP 地址和服务器创建 TCP 链接。链接创建以后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,而后向服务器发送构建的请求信息。
重定向
响应数据类型处理
准备渲染进程
提交文档
渲染阶段
一旦文档被提交,渲染进程便开始页面解析和子资源加载了
构建 DOM 树
样式计算(Recalculate Style)
样式计算的目的是为了计算出 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 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。
样式计算阶段的目的是为了计算出 DOM 节点中每一个元素的具体样式,在计算过程当中须要遵照 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每一个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
布局
计算出 DOM 树中可见元素的几何位置,咱们把这个计算过程叫作布局。
Chrome 在布局阶段须要完成两个任务:建立布局树和布局计算。
建立布局树
布局树构造过程示意图
为了构建布局树,浏览器大致上完成了下面这些工做:
布局计算
在执行布局操做的时候,会把布局运算的结果从新写回布局树中,因此布局树既是输入内容也是输出内容。
这是布局阶段一个不合理的地方,由于在布局阶段并无清晰地将输入内容和输出内容区分开来。针对这个问题,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
,
opacity
属性值小于1
的元素
mix-blend-mode
属性值不为normal
的元素。
如下任意属性值不为none
的元素:
isolation
属性被设置为isolate
的元素。
-webkit-overflow-scrolling
属性被设置为touch
的元素
在will-change
中指定了任意CSS属性(参考这篇文章)
contain
属性值为layout
、paint
,或者综合值(好比contain: strict
、contain: content
)。
在层叠上下文中,其子元素一样也按照上面解释的规则进行层叠。 特别值得一提的是,其子元素的
z-index
值只在父级层叠上下文中有意义。子级层叠上下文被自动视为父级层叠上下文的一个独立单元。总结:
- 层叠上下文能够包含在其余层叠上下文中,而且一块儿建立一个有层级的层叠上下文。
- 每一个层叠上下文彻底独立于它的兄弟元素:当处理层叠时只考虑子元素。
- 每一个层叠上下文是自包含的:当元素的内容发生层叠后,整个该元素将会在父层叠上下文中按顺序进行层叠。
须要剪裁(clip)的地方也会被建立为图层。
图层绘制
在完成图层树的构建以后,渲染引擎会对图层树中的每一个图层进行绘制。
渲染引擎实现图层的绘制与之相似,会把一个图层的绘制拆分红不少小的绘制指令,而后再把这些指令按照顺序组成一个待绘制列表,以下图所示:
绘制列表
从图中能够看出,绘制列表中的指令其实很是简单,就是让其执行一个简单的绘制操做,好比绘制粉色矩形或者黑色的线等。而绘制一个元素一般须要好几条绘制指令,由于每一个元素的背景、前景、边框都须要单独的指令去绘制。因此在图层绘制阶段,输出的内容就是这些待绘制列表。
栅格化操做(raster)
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操做是由渲染引擎中的合成线程来完成的。你能够结合下图来看下渲染主线程和合成线程之间的关系:
渲染进程中的合成线程和主线程
如上图所示,当图层的绘制列表准备好以后,主线程会把该绘制列表提交(commit)给合成线程
合成线程
一般一个页面可能很大,可是用户只能看到其中的一部分,咱们把用户能够看到的这个部分叫作视口(viewport)。
在有些状况下,有的图层能够很大,好比有的页面你使用滚动条要滚动很久才能滚动到底部,可是经过视口,用户只能看到页面的很小一部分,因此在这种状况下,要绘制出全部图层内容的话,就会产生太大的开销,并且也没有必要。
基于这个缘由,合成线程会将图层划分为图块(tile)
合成线程会将图层划分为图块。这些图块的大小一般是 256x256 或者 512x512,以下图所示:
图层被划分为图块示意图
而后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操做是由栅格化来执行的。**所谓栅格化,是指将图块转换为位图。**而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,全部的图块栅格化都是在线程池内执行的,运行方式以下图所示:
一般,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
从图中能够看出,渲染进程把生成图块的指令发送给 GPU,而后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。
合成和显示
总结
完整的渲染流水线示意图
结合上图,一个完整的渲染流程大体可总结为以下:
相关概念
“重排”
更新了元素的几何属性(重排)
更新元素的几何属性
从上图能够看出,若是你经过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发从新布局,解析以后的一系列子阶段,这个过程就叫重排。无疑,重排须要更新完整的渲染流水线,因此开销也是最大的。
”重绘“
更新元素背景
从图中能够看出,若是修改了元素的背景颜色,那么布局阶段将不会被执行,由于并无引发几何位置的变换,因此就直接进入了绘制阶段,而后执行以后的一系列子阶段,这个过程就叫重绘。相较于重排操做,重绘省去了布局和分层阶段,因此执行效率会比重排操做要高一些。
"合成"
若是你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操做,咱们把这个过程叫作合成。具体流程参考下图:
避开重排和重绘
在上图中,咱们使用了 CSS 的 transform 来实现动画效果,这能够避开重排和重绘阶段,直接在非主线程上执行合成动画操做。这样的效率是最高的,由于是在非主线程上合成,并无占用主线程的资源,另外也避开了布局和绘制两个子阶段,因此相对于重绘和重排,合成能大大提高绘制效率。
变量提高(Hoisting)
所谓的变量提高,是指在 JavaScript 代码执行过程当中,JavaScript 引擎把变量的声明部分和函数的声明部分提高到代码开头的“行为”。变量被提高后,会给变量设置默认值,这个默认值就是咱们熟悉的 undefined。
实际上变量和函数声明在代码里的位置是不会改变的,并且是在编译阶段被 JavaScript 引擎放入内存中。
做用域(scope)
做用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,做用域就是变量与函数的可访问范围,即做用域控制着变量和函数的可见性和生命周期。
在 ES6 以前,ES 的做用域只有两种:全局做用域和函数做用域。
变量提高所带来的问题
ES6 中的let和const
做用域块内声明的变量不影响块外面的变量。
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()
复制代码
第一步是编译并建立执行上下文:
第二步继续执行代码
当执行到代码块里面时,变量环境中 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
箭头函数
ES6 中的箭头函数并不会建立其自身的执行上下文,因此箭头函数中的 this 取决于它的外部函数。
**New **
var tempObj = {}
CreateObj.call(tempObj)
return tempObj
复制代码
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())
复制代码
当执行到 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];
}
}
}
复制代码
调用栈中的数据是如何回收的
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 任务中间执行,这样当执行上述动画效果时,就不会让用户由于垃圾回收任务而感觉到页面的卡顿了。
编译器和解释器
编译型语言在程序执行以前,须要通过编译器的编译过程,而且编译以后会直接保留机器能读懂的二进制文件,这样每次运行程序时,均可以直接运行该二进制文件,而不须要再次从新编译了。好比 C/C++、GO 等都是编译型语言。而由解释型语言编写的程序,在每次运行时都须要经过解释器对程序进行动态解释和执行。好比 Python、JavaScript 等都属于解释型语言。
编译器和解释器“翻译”代码
从图中你能够看出这两者的执行流程,大体可阐述为以下:
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 和机器码之间的一种代码。可是与特定类型的机器码无关,字节码须要经过解释器将其转换为机器码后才能执行。
字节码和机器码占用空间对比
生成字节码以后,接下来就要进入执行阶段了。
一般,若是有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程当中,若是发现有热点代码(HotSpot),好比一段代码被重复执行屡次,这种就称为热点代码。
那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,而后当再次执行这段被优化的代码时,只须要执行编译后的机器码就能够了,这样就大大提高了代码的执行效率。
其实字节码配合解释器和编译器是最近一段时间很火的技术,好比 Java 和 Python 的虚拟机也都是基于这种技术实现的,咱们把这种技术称为即时编译(JIT)
具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了以后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。
每一个渲染进程都有一个主线程,而且主线程很是繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还须要处理 JavaScript 任务以及各类输入事件。要让这么多不一样类型的任务在主线程中有条不紊地执行,这就须要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统。
要想在线程运行过程当中,能接收并执行新的任务,就须要采用事件循环机制。
消息队列是一种数据结构,能够存放要执行的任务。它符合队列“先进先出”的特色,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。
队列 + 循环
因为是多个线程操做同一个消息队列,因此在添加任务和取出任务时还会加上一个同步锁。
跨进程发送消息
渲染进程专门有一个 IO 线程用来接收其余进程传进来的消息,接收到消息以后,会将这些消息组装成任务发送给渲染主线程
一般咱们把消息队列中的任务称为宏任务
每一个宏任务中都包含了一个微任务队列
异步回调的概念,其主要有两种方式。
微任务就是一个须要异步执行的函数,执行时机是在主函数执行结束以后、当前宏任务结束以前。
当 JavaScript 执行一段脚本的时候,V8 会为其建立一个全局执行上下文,在建立全局执行上下文的同时,V8 引擎也会在内部建立一个微任务队列。
每一个宏任务都关联了一个微任务队列。
在现代浏览器里面,产生微任务有两种方式。
微任务队列是什么时候被执行的
微任务添加和执行流程示意图
结论
setTimeout
setTimeout在被使用时会被推入 延迟队列, 延迟队列是个小顶堆,会根据时间将要执行的回调推入堆顶,宏任务结束后会去执行堆顶的内容
若是 setTimeout 存在嵌套调用,那么系统会设置最短期间隔为 4 毫秒(系统会认为阻塞)v8源码定义4ms出
未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
延时执行时间有最大值 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒
使用 setTimeout 设置的回调函数中的 this 不必定指向当前环境
requestAnimationFrame
使用 requestAnimationFrame 不须要设置具体的时间,由系统来决定回调函数的执行时间,requestAnimationFrame 里面的回调函数是在页面刷新以前执行,它跟着屏幕的刷新频率走,保证每一个刷新间隔只执行一次,内若是页面未激活的话,requestAnimationFrame 也会中止渲染,这样既能够保证页面的流畅性,又能节省主线程执行函数的开销
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. 提供了在不阻塞主线程的状况下使用同步代码实现异步访问资源的能力。
生成器
单个文件请求的时间线
Queuing
当浏览器发起一个请求的时候,会有不少缘由致使该请求不能被当即执行,而是须要排队等待。致使请求处于排队状态的缘由有不少。
优化:
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 会被按照顺序压到这个栈中。具体的处理规则以下所示:
经过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将全部字节流分词完成。
元素弹出 Token 栈示意图
最终解析结果
预解析操做
当渲染引擎收到字节流以后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件以后,预解析线程会提早下载这些文件。
XSSAuditor
渲染引擎还有一个安全检查模块叫 XSSAuditor,是用来检测词法安全的。在分词器解析出来 Token 以后,它会检测这些模块是否安全,好比是否引用了外部脚本,是否符合 CSP 规范,是否存在跨站点请求等。若是出现不符合规范的内容,XSSAuditor 会对该脚本或者下载任务进行拦截。
含有 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。
显示器是怎么显示图像的
每一个显示器都有固定的刷新频率,一般是 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的缺陷
好比,咱们能够调用document.body.appendChild(node)往 body 节点上添加一个元素,调用该 API 以后会引起一系列的连锁反应。首先渲染引擎会将 node 节点添加到 body 节点之上,而后触发样式计算、布局、绘制、栅格化、合成等任务,咱们把这一过程称为重排。除了重排以外,还有可能引发重绘或者合成操做,形象地理解就是“牵一发而动全身”。另外,对于 DOM 的不当操做还有可能引起强制同步布局和布局抖动的问题,这些操做都会大大下降渲染效率。所以,对于 DOM 的操做咱们时刻都须要很是当心谨慎。
虚拟 DOM 特色
虚拟 DOM 执行流程
虚拟 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 提供的。其具体实现过程以下:
<!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/0.9
HTTP/1.0
HTTP/1.0 的请求流程
HTTP/1.1
缺点
同时开启了多条 TCP 链接,那么这些链接会竞争固定的带宽。
tcp慢启动
HTTP/1.1 队头阻塞的问题(阻塞的请求)
HTTP/2
HTTP/2 的多路复用
一个域名只使用一个 TCP 长链接和消除队头阻塞问题。
多路复用实现原理
HTTP/2 协议栈
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 协议集合了如下几点功能。
QUIC 协议的多路复用
HTTP/3的困境
第一,从目前的状况来看,服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持。Chrome 虽然在数年前就开始支持 Google 版本的 QUIC,可是这个版本的 QUIC 和官方的 QUIC 存在着很是大的差别。
第二,部署 HTTP/3 也存在着很是大的问题。由于系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的一个重要缘由。
第三,中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~7% 的丢包率。