浏览器工作原理与实践
进程:一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
线程:依附于进程的,在进程中使用多线程并行处理能提升运算效率
进程与线程关系:
1.进程中的任意一线程执行出错,都会导致整个进程的崩溃。
2.线程之间共享进程中的数据。
3.当一个进程关闭之后,操作系统会回收进程所占用的内存
4.进程之间的内容相互隔离。
**单进程浏览器:**不稳定(插件和渲染引擎),不流畅(同一时刻只有一个模块运行),不安全(插件和脚本)
缺点:占用资源多(吃内存),架构复杂(各模块耦合性高、扩展性差)
各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。
(Internet Protocol 网际协议)***把数据包送达目的主机(靠IP辨认)
(User Datagram Protocol 用户数据包协议): 把数据包送达应用程序(靠端口号辨认目的地)
特点:不能保证数据可靠性,速度快
(Transmission Control Protocol):把数据完整地送达应用程序(一种面向连接的、可靠的、基于字节流的传输层通信协议)
特点:提供重传机制防止数据包丢失;引入数据包排序机制,确保数据恢复
建立连接:三次握手
数据传输:接收端需要对每个数据包进行确认操作
断开连接:四次挥手
建立在TCP连接基础之上,一种允许浏览器向服务器获取资源的协议。
1.构建请求:构建请求行信息
GET /index.html HTTP1.1
2.查找缓存:查看浏览器缓存是否有资源副本
3.准备IP和端口:请求DNS返回域名指向的IP(DNS也有缓存)
4.等待TCP队列:同一域名同时最多只能建立6个TCP连接,超过的连接会处于等待状态,直到前面的请求完成
5.建立TCP连接:
6.发送HTTP请求:
1.返回请求:
2.断开连接:一般服务器返回数据就会关闭TCP连接,除非
Connection:Keep-Alive
保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。
3.重定向:
1.用户输入:生成请求的URL(搜索内容拼接或直接就是URL)
2.URL请求过程:浏览器进程通过进程通信(IPC)把URL给网络进程,网络进程先找缓存,有缓存返回无缓存请求
2.1重定向
2.2响应数据类型处理:Content-Type
3.准备渲染进程:默认每一个页面一个渲染进程,但如果从一个页面打开另一个新页面且二者属于同一站点(根域名和协议都相同)那么新页面复用父页面的渲染进程(即process-per-site-instance)
4.提交文档:此处文档指URL请求的响应体数据
1.把CSS转换为浏览器能够理解的结构(styleSheets)
2.转换样式表中的属性值,使其标准化
3.计算出DOM树中每个节点的具体样式(根据继承规则和层叠规则)
计算DOM树中可见元素的几何位置
1.创建布局树
2.布局计算
:为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree),图层叠加合成最终页面(不是每个节点都包含一个图层,如果一个节点没有对应的层,那该节点姐从属于父节点所在图层)
创建型层的要求:
1)拥有层叠上下文属性的元素会被提升为单独的一层(关于层叠上下文)
2)需要裁剪(clip)的地方也会被创建为图层
<style>
div {
width: 200;
height: 200;
overflow:auto;
background: gray;
}
style>
<body>
<div >
<p> 所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:p>
<p> 从上图我们可以看到,document 层上有 A 和 B 层,而 B 层之上又有两个图层。这些图层组织在一起也是一颗树状结构。p>
<p> 图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。p>
div>
body>
上面文字显示区域会超出div,产生剪裁,渲染引擎会把剪裁文字内容的一部分用于显示在div区域
渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层
:把一个图层绘制拆分Wie很多小的绘制指令,按顺序组成待绘制列表
绘制操作实际由渲染引擎中的合成线程来完成
视口:屏幕上页面的可见区域(ViewPort)
合成线程会将图层分为图块(tile),通常为256X256或512X512。按照视口附近的图块来优先生成位图,实际生成位图的操作由栅格化(将图块转化为位图)来执行。图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的
栅格化都会使用GPU来加速(快速栅格化),生成的位图保存在GPU内存中
图块光栅化结束,合成线程生成绘制图块的命令——“DrawQuad”,将该命令交给浏览器进程。浏览器进程中viz组件接收命令根据命令将页面内容绘制到内存中,再显示到屏幕上。
:更新了元素的几何属性(重排需要更新完整的流水线,开销最大)
**:**修改既不布局也不绘制的属性,渲染引擎会跳过布局和绘制只执行后续的合成操作。
JS代码执行中,JS引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量提升后,会给变量设置默认值为undefined
实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被JavaScript引擎放入内存中。
编译结果:执行上下文(Execution context)和可执行代码。
执行上下文是JS执行一段代码时的运行环境。执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容。
用来管理函数调用关系的一种数据结构
即运行一个函数——即,函数名()
var a = 2
function add(){
var b = 10
return a+b
}
add()
在执行到add()之前,JS引擎会创建全局执行上下文,包含声明的函数和变量
首先,从全局执行上下文中,取出add函数代码。其次,对add函数进行编译,并创建该函数的执行上下文和可执行代码。最后,执行代码,输出结果。
在执行上下文创建好后,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)
1.创建全局上下文,并将其压入栈底
执行全局代码
2.调用addAll函数
执行d=10
3.执行到add函数
add函数返回,并出栈
addAll出栈
1.利用浏览器查看调用栈的信息:
加断点,call stack
console.trace()
2.栈溢出(Stack Overflow)
当入栈的执行上下文超过一定的数目,JS引擎就会报错,即栈溢出
程序中定义变量的区域,该位置决定了变量的生命周期。作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
1.变量容易在不被察觉的情况下被覆盖掉
2.本应该销毁的变量没有被销毁
引入let和const关键字
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
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()
2.继续执行代码
在词法环境内维护一个小型栈结构,栈底是函数最外层的变量。当执行到console.log(a)时,需要在词法环境和变量环境中查找变量a的值,沿词法环境栈顶向下查询,如果在词法环境中的某个块中查找到了就返回,如果没有就在变量环境中查找。
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出
报错,因为let在作用域内的声明被提升但是初始化没有被提上去,会形成暂时性死区
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
function bar() {
console.log(myName)
}
function foo() {
var myName = " 极客邦 "
bar()
}
var myName = " 极客时间 "
foo()
每个执行上下文的变量环境中,都包含了一个外部引用(outer),用来指向外部的执行上下文。当一段代码使用了一个变量时,JS引擎先在当前的执行上下文中查找该变量,如果没有找到,JS引擎会继续在outer所指的执行上下文中查找。
从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链
JS执行过程中,其作用域链是由词法作用域决定的。词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
词法作用域是在代码阶段就决定好的,和函数是怎么调用的没有关系
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()
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())
根据词法作用域链的规则,内部函数getName和setName总是可以访问他们的外部函数foo中的变量。所以当innerBar对象返回giee全局变量bar时,虽然foo函数已经执行结束,但是getName和setName函数依然可以使用foo函数中的变量myName和test1.
foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。
之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
var bar = {
myName:"time.geekbang.com",
printName: function () {
console.log(myName)
}
}
function foo() {
let myName = " 极客时间 "
return bar.printName
}
let myName = " 极客邦 "
let _printName = foo()
_printName()
bar.printName()
此处输出“极客邦”,为了输出bar内部的myName,引入了this机制
为了在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。
this是和执行上下文绑定的,即每一个执行上下文都有一个this
#### 全局上下文中的this
全局执行上下文中的 this 是指向 window 对象的。
默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的可以进行设置
1.通过函数的 call 方法设置(还有bind和apply)
let bar = {
myName : " 极客邦 ",
test1 : 1
}
function foo(){
this.myName = " 极客时间 "
}
foo.call(bar)
console.log(bar)
console.log(myName)
2. 通过对象调用方法设置
使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。
var myObj = {
name : " 极客时间 ",
showThis: function(){
console.log(this)
}
}
myObj.showThis()
在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window
3. 通过构造函数中设置
function CreateObj(){
this.name = " 极客时间 "
}
var myObj = new CreateObj()
其实,当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:
var myObj = {
name : " 极客时间 ",
showThis: function(){
console.log(this)
function bar(){console.log(this)}
bar()
}
}
myObj.showThis()
函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。
var myObj = {
name : " 极客时间 ",
showThis: function(){
console.log(this)
var self = this
function bar(){
self.name = " 极客邦 "
}
bar()
}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
上面方法的本质是把this体系转换为了作用域体系
也可以使用 ES6 中的箭头函数来解决这个问题
var myObj = {
name : " 极客时间 ",
showThis: function(){
console.log(this)
var bar = ()=>{
this.name = " 极客邦 "
console.log(this)
}
bar()
}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。
在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。
可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。
在使用之前就需要确认其变量数据类型的称为静态语言。相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言。JS为动态语言。
支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。在这点上,C 和 JavaScript 都是弱类型语言。
弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。
动态,意味着你可以使用同一个变量保存不同类型的数据。
var bar
console.log(typeof bar) //undefined
bar = 12
console.log(typeof bar) //number
bar = " 极客时间 "
console.log(typeof bar)//string
bar = true
console.log(typeof bar) //boolean
bar = null
console.log(typeof bar) //object
bar = {name:" 极客时间 "}
console.log(typeof bar) //object
注意:
1.使用 typeof 检测 Null 类型时,返回的是 Object。遗留bug()
2.Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key-value 对的数据类型。
3.把前面的 7 种数据类型称为原始类型,把最后一个对象类型称为引用类型
JS内存模型
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。
栈空间即调用栈,是来存储执行上下文的。
function foo(){
var a = " 极客时间 "
var b = a
var c = {name:" 极客时间 "}
var d = c
}
foo()
执行到第三行
第4行,对象类型存放在堆空间,在栈空间只保留了对象的引用地址。
原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的。
因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率
。所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。
在 JavaScript 中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。
d=c
function foo() {
var myName = " 极客时间 "
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(" 极客邦 ")
bar.getName()
console.log(bar.getName())
执行到return innerBar时
当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName
或者bar.getName
时,创建的执行上下文中就包含了“clourse(foo)”。
产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。
手动回收和自动回收
C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的
// 在堆中分配内存
char* p = (char*)malloc(2048); // 在堆空间中分配 2048 字节的空间,并将分配后的引用地址保存到 p 中
// 使用 p 指向的内存
{
//....
}
// 使用结束后,销毁这段内存
free(p);
p = NULL;
如果这段数据已经不再需要了,但是又没有主动调用 free 函数来销毁,那么这种情况就被称为内存泄漏。
JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。
function foo(){
var a = 1
var b = {name:" 极客邦 "}
function showName(){
var c = 1
var d = {name:" 极客时间 "}
}
showName()
}
foo()
当执行到第6行时,
有一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。接着,当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。
代际假说(The Generational Hypothesis)
在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。
1.标记空间中活动对象和非活动对象
2.回收非活动对象所占据的内存
3.内存整理(处理内存碎片)
新生代中用Scavenge 算法来处理。即把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的。
比如最开始的那段代码,当 showName 函数执行退出之后,这段代码的调用栈和堆空间如下图所示
如果遍历调用栈,是不会找到引用 1003 地址的变量,也就意味着 1003 这块数据为垃圾数据,被标记为红色。由于 1050 这块数据被变量 b 引用了,所以这块数据会被标记为活动对象。这就是大致的标记过程。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:
对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,称全停顿(Stop-The-World)。
在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。
为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。
使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。
编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。
而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。
V8 在执行过程中既有解释器 Ignition,又有编译器 TurboFan
高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。
var myName = " 极客时间 "
function foo(){
return 23;
}
myName = "geektime"
foo()
AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。
除了 Babel 外,还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。
生成AST:
1)分词(tokenize),又称为词法分析:将一行行的源码拆解成一个个 token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。
2)**解析(parse),又称为语法分析:**将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。
其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。
字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
**
通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
V8 的解释器和编译器的取名也很有意思。解释器 Ignition 是点火器的意思,编译器 TurboFan 是涡轮增压的意思,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。
其实字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。
对于 JavaScript 工作引擎,除了 V8 使用了“字节码 +JIT”技术之外,苹果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了该技术。
void MainThread(){
int num1 = 1+2; // 任务 1
int num2 = 20/5; // 任务 2
int num3 = 7*8; // 任务 3
print(" 最终计算的值为:%d,%d,%d",num,num2,num3); // 任务 4
}
要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。
//GetInput
// 等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){
int input_number = 0;
cout<<" 请输入一个数:";
cin>>input_number;
return input_number;
}
// 主线程 (Main Thread)
void MainThread(){
for(;;){
int first_num = GetInput();
int second_num = GetInput();
result_num = first_num + second_num;
print(" 最终计算的值为:%d",result_num);
}
}
相较于第一版的线程,这一版的线程做了两点改进。
那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?一个通用模式是使用消息队列。
有了队列之后,就可以继续改造线程模型了,改造方案如下
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程
内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。
Chrome在确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了,那么就直接中断当前的所有任务,退出线程。
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){
for(;;){
Task task = task_queue.takeTask();
ProcessTask(task);
if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
break;
}
}
为了权衡效率和实时性,使用微任务。
通常把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。
等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。
因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。
如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
打开开发者工具,点击“Performance”标签,选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况
要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
延迟队列
DelayedIncomingQueue delayed_incoming_queue;
当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下
struct DelayTask{
int64 id;
CallBackFunction cbf;
int start_time;
int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); // 获取当前时间
timerTask.delay_time = 200;// 设置延迟执行时间
创建好回调任务之后,再将该任务添加到延迟执行队列中
delayed_incoming_queue.push(timerTask);
消息循环系统是怎么触发延迟队列的呢?
void ProcessTimerTask(){
// 从 delayed_incoming_queue 中取出已经到期的定时器任务
// 依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
for(;;){
// 执行消息队列中的任务
Task task = task_queue.takeTask();
ProcessTask(task);
// 执行延迟队列中的任务
ProcessDelayTask()
if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
break;
}
}
设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用clearTimeout 函数,并传入需要取消的定时器的 ID。其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。
也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间
在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。
Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。
如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。
var name= 1;
var MyObj = {
name: 2,
showName: function(){
console.log(this.name);
}
}
setTimeout(MyObj.showName,1000)
// 输出是1
解决方案
1)将MyObj.showName
放在匿名函数中执行
// 箭头函数
setTimeout(() => {
MyObj.showName()
}, 1000);
// 或者 function 函数
setTimeout(function() {
MyObj.showName();
}, 1000)
2)使用 bind 方法,将 showName 绑定在 MyObj 上面
setTimeout(MyObj.showName.bind(MyObj), 1000)
setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。比如要使用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。
在 XMLHttpRequest 出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
cb()
console.log('end do work')
}
doWork(callback)
回调函数 callback 是在主函数 doWork 返回之前执行的,把这个回调过程称为同步回调。
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
setTimeout(cb,1000)
console.log('end do work')
}
doWork(callback)
使用了 setTimeout 函数让 callback 在 doWork 函数执行结束后,又延时了 1 秒再执行,这次 callback 并没有在主函数 doWork 内部被调用,把这种回调函数在主函数外部执行的过程称为异步回调。
消息队列和主线程循环机制保证了页面有条不紊地运行。
循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。这个系统调用栈类似于 JavaScript 的调用栈,只不过系统调用栈是 Chromium 的开发语言 C++ 来维护的,其完整的调用栈信息你可以通过 chrome://tracing/ 来抓取。当然,你也可以通过 Performance 来抓取它核心的调用信息
异步回调是指回调函数在主函数之外执行,一般有两种方式:
function GetWebData(URL){
/**
* 1: 新建 XMLHttpRequest 请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2: 注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: // 请求未初始化
console.log(" 请求未初始化 ")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
xhr.ontimeout = function(e) { console.log('ontimeout') }
xhr.onerror = function(e) { console.log('onerror') }
/**
* 3: 打开请求
*/
xhr.open('Get', URL, true);// 创建一个 Get 请求, 采用异步
/**
* 4: 配置参数
*/
xhr.timeout = 3000 // 设置 xhr 请求的超时时间
xhr.responseType = "text" // 设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
/**
* 5: 发送请求
*/
xhr.send();
}
执行流程
1.创建 XMLHttpRequest 对象
2.为 xhr 对象注册回调函数
3.配置基础的请求信息
4.发起请求
默认情况下,跨域请求是不被允许的
HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。
通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求。
不过随着浏览器的应用领域越来越广泛,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个有效的权衡。
页面中的大部分任务都是在主线程上执行的,这些任务包括了:
为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。把这些消息队列中的任务称为宏任务。
WHATWG 规范定义的大致流程:
宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了。宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如监听 DOM 变化的需求。
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以无法通过 JavaScript 直接访问的。
第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务
通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。
直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。
为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。
首先,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。
MutationObserver 采用了“异步 + 微任务”的策略。
Web 页面的单线程架构决定了异步回调,而异步回调影响到了编码方式,回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉。
//makeRequest 用来构造 request 对象
function makeRequest(request_url) {
let request = {
method: 'Get',
url: request_url,
headers: '',
body: '',
credentials: false,
sync: true,
responseType: 'text',
referrer: ''
}
return request
}
将所有的请求细节封装进 XFetch 函数
//[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 函数需要一个 request 作为输入,然后还需要两个回调函数 resolve 和 reject,当请求成功时回调 resolve 函数,当请求出现问题时回调 reject 函数
XFetch(makeRequest('https://time.geekbang.org'),
function resolve(data) {
console.log(data)
}, function reject(e) {
console.log(e)
})
用 Promise 来重构 XFetch
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)
}
利用 XFetch 来构造请求流程
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)
})
**Promise 实现了回调函数的延时绑定,**回调函数的延时绑定在代码上体现就是先创建 Promise 对象 x1,通过 Promise 的构造函数 executor 来执行业务逻辑;创建好 Promise 对象 x1 之后,再使用 x1.then 来设置回调函数。
// 创建 Promise 对象 x1,并在 executor 函数中执行业务逻辑
function executor(resolve, reject){
resolve(100)
}
let x1 = new Promise(executor)
//x1 延迟绑定回调函数 onResolve
function onResolve(value){
console.log(value)
}
x1.then(onResolve)
其次,需要将回调函数 onResolve 的返回值穿透到最外层。根据 onResolve 函数的传入值来决定创建什么类型的 Promise 任务,创建好的 Promise 对象需要返回到最外层,这样就可以摆脱嵌套循环了。
Promise 通过回调函数延迟绑定和回调函数返回值穿透的技术,解决了循环嵌套。
function executor(resolve, reject) {
let rand = Math.random();
console.log(1)
console.log(rand)
if (rand > 0.5)
resolve()
else
reject()
}
var p0 = new Promise(executor);
var p1 = p0.then((value) => {
console.log("succeed-1")
return new Promise(executor)
})
var p3 = p1.then((value) => {
console.log("succeed-2")
return new Promise(executor)
})
var p4 = p3.then((value) => {
console.log("succeed-3")
return new Promise(executor)
})
p4.catch((error) => {
console.log("error")
})
console.log(2)
Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个 Promise 对象中单独捕获异常了。
由于 Promise 采用了回调函数延迟绑定技术,所以在执行 resolve 函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行
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);
}
function executor(resolve, reject) {
resolve(100)
}
// 将 Promise 改成我们自己的 Bromsie
let demo = new Bromise(executor)
function onResolve(value){
console.log(value)
}
demo.then(onResolve)
使用定时器的效率并不是太高,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。
fetch('https://www.geekbang.org')
.then((response) => {
console.log(response)
return fetch('https://www.geekbang.org/test')
}).then((response) => {
console.log(response)
}).catch((error) => {
console.log(error)
})
从这段 Promise 代码可以看出来,使用 promise.then 也是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的 then 函数,使得代码依然不是太容易阅读。基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。
async function foo(){
try{
let response1 = await fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = await fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}catch(err) {
console.error(err)
}
}
foo()
生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。
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 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,把 A 协程称为 B 协程的父协程。
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
//foo 函数
function* foo() {
let response1 = yield fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}
// 执行 foo 函数的代码
let gen = foo()
function getGenPromise(gen) {
return gen.next().value
}
getGenPromise(gen).then((response) => {
console.log('response1')
console.log(response)
return getGenPromise(gen)
}).then((response) => {
console.log('response2')
console.log(response)
})
foo 函数是一个生成器函数,在 foo 函数里面实现了用同步代码形式来实现异步操作;
let gen = foo()
,创建了 gen 协程。以上就是协程和 Promise 相互配合执行的一个大致流程。不过通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架)
function* foo() {
let response1 = yield fetch('https://www.geekbang.org')
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://www.geekbang.org/test')
console.log('response2')
console.log(response2)
}
co(foo());
通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
async function foo() {
return 2
}
console.log(foo()) // Promise {: 2}
调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
起过滤功能,有时候一个页面有太多内容在详细列表区域中展示了,而你可能只想查看 JavaScript 文件或者 CSS 文件,这时候就可以通过过滤器模块来筛选你想要的文件类型。.
抓图信息区域,可以用来分析用户等待页面加载时间内所看到的内容,分析用户实际的体验情况。比如,如果页面加载 1 秒多之后屏幕截图还是白屏状态,这时候就需要分析是网络还是代码的问题了。(勾选面板上的“Capture screenshots”即可启用屏幕截图。)
时间线,主要用来展示 HTTP、HTTPS、WebSocket 加载的状态和时间的一个关系,用于直观感受页面的加载过程。如果是多条竖线堆叠在一起,那说明这些资源被同时被加载。
这个区域是最重要的,它详细记录了每个资源从发起请求到完成请求这中间所有过程的状态,以及最终请求完成的数据信息。通过该列表,你就能很容易地去诊断一些网络问题。
下载信息概要中,要重点关注下 DOMContentLoaded 和 Load 两个事件,以及这两个事件的完成时间。
列表的属性比较多,比如 Name、Status、Type、Initiator 等等。可以按照列表的属性来给列表排序,默认情况下,列表是按请求发起的时间来排序的,最早发起请求的资源在顶部。当然也可以按照返回状态码、请求类型、请求时长、内容大小等基础属性排序,只需点击相应属性即可。
如果选中详细列表中的一项,右边就会出现该项的详细信息
时间线面板
**Queuing,**当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。
等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的Stalled上,它表示停滞的意思。如果使用了代理服务器,还会增加一个Proxy Negotiation阶段,也就是代理协商阶段,它表示代理服务器连接协商所用的时间。接下来,就到了Initial connection/SSL 阶段了,也就是和服务器建立连接的阶段,这包括了建立 TCP 连接所花费的时间;不过如果你使用了 HTTPS 协议,那么还需要一个额外的 SSL 握手时间,这个过程主要是用来协商一些加密信息的。和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络,这就是Request sent 阶段。通常这个阶段非常快,因为只需要把浏览器缓冲区的数据发送出去就结束了,并不需要判断服务器是否接收到了,所以这个时间通常不到 1 毫秒。数据发送出去了,接下来就是等待接收服务器第一个字节的数据,这个阶段称为 Waiting (TTFB),通常也称为“第一字节时间”。 TTFB 是反映服务端响应速度的重要指标,对服务器来说,TTFB 时间越短,就说明服务器响应越快。接收到第一个字节之后,进入陆续接收完整数据的阶段,也就是Content Download 阶段,这意味着从第一字节时间到接收到全部响应数据所用的时间。
排队时间过久,大概率是由浏览器为每个域名最多维护 6 个连接导致的。那么基于这个原因,你就可以让 1 个站点下面的资源放在多个域名下面,比如放到 3 个域名下面,这样就可以同时支持 18 个连接了,这种方案称为域名分片技术。除了域名分片技术外,还建议把站点升级到 HTTP2,因为 HTTP2 已经没有每个域名最多维护 6 个 TCP 连接的限制了。
如果单个请求的 Content Download 花费了大量时间,有可能是字节数太多的原因导致的。这时候就需要减少文件大小,比如压缩、去掉源码中不必要的注释等方法。
DOM 提供了对 HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用
在渲染引擎内部,有一个叫HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。
至于后续的第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。
HTML 解析器维护了一个Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中,并创建一个 html 的 DOM 节点,添加到 document 上
然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:
接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点
再接下来,分词器解析出来第一个 EndTag div,这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div,如果是则从栈顶弹出 StartTag div
按照同样的规则,一路解析,最终结果如下图所示
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>
解析到标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。
HTML 解析器暂停工作,JavaScript 引擎介入,并执行 script 标签中的这段脚本,因为这段 JavaScript 脚本修改了 DOM 中第一个 div 中的内容,所以执行这段脚本之后,div 节点内容已经修改为 time.geekbang 了。脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM。
执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。这里需要重点关注下载环境,因为JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。
不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
引入 JavaScript 线程会阻塞 DOM,不过也有一些相关的策略来规避,比如使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码
<script async type="text/javascript" src='foo.js'>script>
<script defer type="text/javascript" src='foo.js'>script>
async 和 defer 虽然都是异步的,不过还有一些差异,使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
//theme.css
div{
color : coral;
background-color:black
}
<html>
<head>
<link href="theme.css" rel="stylesheet">
head>
<body>
<div>geekbang comdiv>
body>
html>
和 HTML 一样,渲染引擎也是无法直接理解 CSS 文件内容的,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是 CSSOM。和 DOM 一样,CSSOM 也具有两个作用,第一个是提供给 JavaScript 操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。这个 CSSOM 体现在 DOM 中就是document.styleSheets
。
等 DOM 和 CSSOM 都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制 DOM 树的结构,不同之处在于 DOM 树中那些不需要显示的元素会被过滤掉,如 display:none 属性的元素、head 标签、script 标签等。复制好基本的布局树结构之后,渲染引擎会为对应的 DOM 元素选择对应的样式信息,这个过程就是样式计算。样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。
渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验。
从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历的三个阶段
对于第二阶段,通常情况下的瓶颈主要体现在**下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript,**应对策略有:
每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。
显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。
大多数设备屏幕的更新频率是 60 次 / 秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新 60 张图片到显卡的后缓冲区。
把渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)。
由于用户很容易观察到那些丢失的帧,如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。要解决卡顿问题,就要解决每帧生成时间过久的问题,为此 Chrome 对浏览器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分层和合成机制。
任意一帧的生成方式,有重排、重绘和合成三种方式
这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多。比如重排,它需要重新根据 CSSOM 和 DOM 来计算布局树,这样生成一幅图片时,会让整个渲染流水线的每个阶段都执行一遍,如果布局复杂的话,就很难保证渲染的效率了。而重绘因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。
相较于重排和重绘,合成操作的路径就显得非常短了,并不需要触发布局和绘制两个阶段,如果采用了 GPU,那么合成的效率会非常高。
所以,关于渲染引擎生成一帧图像的几种方式,按照效率我们推荐合成方式优先,若实在不能满足需求,那么就再退后一步使用重绘或者重排的方式。
为了提升每帧的渲染效率,Chrome 引入了分层和合成的机制。将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。
考虑到一个页面被划分为两个层,当进行到下一帧的渲染时,上面的一帧可能需要实现某些变换,如平移、旋转、缩放、阴影或者 Alpha 渐变,这时候合成器只需要将两个层进行相应的变化操作就可以了,显卡处理这些操作驾轻就熟,所以这个合成过程时间非常短。
在 Chrome 的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为层树(Layer Tree),层树是渲染流水线后续流程的基础结构。
层树中的每个节点都对应着一个图层,下一步的绘制阶段就依赖于层树中的节点。绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表,比如一个图层要设置的背景为黑色,并且还要在中间画一个圆形,那么绘制过程会生成|Paint BackGroundColor:Black | Paint Circle|
这样的绘制指令列表,绘制过程就完成了。
有了绘制列表之后,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区。这就是一个大致的分层、合成流程。
需要重点关注的是,合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的。这就是为什么经常主线程卡住了,但是 CSS 动画依然能执行的原因。
如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。
通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。
因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。不过有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到 GPU 内存的操作会比较慢。
为了解决这个问题,Chrome 又采取了一个策略:在首次合成图块的时候使用一个低分辨率的图片。比如可以是正常分辨率的一半,分辨率减少一半,纹理就减少了四分之三。在首次显示页面内容的时候,将这个低分辨率的图片显示出来,然后合成器继续绘制正常比例的网页内容,当正常比例的网页内容绘制完成后,再替换掉当前显示的低分辨率内容。这种方式尽管会让用户在开始时看到的是低分辨率的内容,但是也比用户在开始时什么都看不到要好。
在写 Web 应用的时候,可能经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作,如果使用 JavaScript 来写这些效果,会牵涉到整个渲染流水线,所以 JavaScript 的绘制效率会非常低下。这时可以使用 will-change 来告诉渲染引擎你会对该元素做一些特效变换
.box {
will-change: transform, opacity;
}
这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是 CSS 动画比 JavaScript 动画高效的原因。
所以,如果涉及到一些可以使用合成线程来处理 CSS 特效或者动画的情况,就尽量使用 will-change 来提前告诉渲染引擎,让它为该元素准备独立的层。但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要外的内存,所以你需要恰当地使用 will-change。
让页面更快地显示和响应
把能阻塞网页首次渲染的资源称为关键资源。基于关键资源,可以继续细化出来三个影响页面首次渲染的核心因素。
第一个是关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。
第二个是关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。
第三个是请求关键资源需要多少个 RTT(Round Trip Time)。RTT 就是往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。
总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数
在交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,通常是由 JavaScript 触发交互动画的。
交互阶段,大部分情况下,生成一个新的帧都是由 JavaScript 通过修改 DOM 或者 CSSOM 来触发的。还有另外一部分帧是由 CSS 来触发的。如果在计算样式阶段发现有布局信息的修改,那么就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。同样如果在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,那么就不会涉及到布局相关的调整,所以可以跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。还有另外一种情况,通过 CSS 实现一些变形、渐变、动画等特效,这是由 CSS 触发的,并且是在合成线程上执行的,这个过程称为合成。因为它不会触发重排或者重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。
优化原则就是让单个帧的生成速度变快。
2.避免强制同步布局
所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中。
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
// 由于要获取到 offsetHeight,
// 但是此时的 offsetHeight 还是老的数据,
// 所以需要立即执行布局操作
console.log(main_div.offsetHeight)
}
优化
function foo() {
let main_div = document.getElementById("mian_div")
// 为了避免强制同步布局,在修改 DOM 之前查询相关值
console.log(main_div.offsetHeight)
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}
3.避免布局抖动
所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。
function foo() {
let time_li = document.getElementById("time_li")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById("mian_div").appendChild(new_node);
}
}
一个 for 循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局。
4.合理利用CSS动画
合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。
另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。
5.避免频繁的垃圾回收
JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。
所以要尽量避免产生那些临时垃圾数据。那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。
“牵一发而动全身”
在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么就会造成一个后果,那就是在显示一个稍微复杂点的图像的过程中,你看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。
而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。
在这里,你可以把虚拟 DOM 看成是 DOM 的一个 buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到 DOM 上,这样就能减少一些不必要的更新,同时还能保证 DOM 的稳定输出。
MVC 的整体结构比较简单,由模型、视图和控制器组成,其核心思想就是将数据和视图分离,也就是说视图和模型之间是不允许直接通信的,它们之间的通信都是通过控制器来完成的。通常情况下的通信路径是视图发生了改变,然后通知控制器,控制器再根据情况判断是否需要更新模型数据。当然还可以根据不同的通信路径和控制器不同的实现方式,基于 MVC 又能衍生出很多其他的模式,如 MVP、MVVM 等,不过万变不离其宗,它们的基础骨架都是基于 MVC 而来。
比如在分析 React 项目时,我们可以把 React 的部分看成是一个 MVC 中的视图,在项目中结合 Redux 就可以构建一个 MVC 的模型结构,
在该图中,可以把虚拟 DOM 看成是 MVC 的视图部分,其控制器和模型都是由 Redux 提供的。其具体实现过程如下:
PWA,全称是 Progressive Web App,翻译过来就是渐进式网页应用,需要从下面两个方面来理解。
PWA 采取的是非常一个缓和的渐进式策略,不再像以前那样激进,动不动就是取代本地 App、取代小程序。与之相反,而是要充分发挥 Web 的优势,渐进式地缩短和本地应用或者小程序的距离。
它是一套理念,渐进式增强 Web 的优势,并通过技术手段渐进式缩短和本地应用或者小程序的距离。基于这套理念之下的技术都可以归类到 PWA。
web应用缺什么?
PWA的方案:通过引入 Service Worker 来试着解决离线存储和消息推送的问题,通过引入 manifest.json 来解决一级入口的问题。
主要思想是在页面和网络之间增加一个拦截器,用来缓存和拦截请求。
1.架构
让其运行在主线程之外”就是 Service Worker 来自 Web Worker 的一个核心思想。不过 Web Worker 是临时的,每次 JavaScript 脚本执行完成之后都会退出,执行结果也不能保存下来,如果下次还有同样的操作,就还得重新来一遍。所以 Service Worker 需要在 Web Worker 的基础之上加上储存功能。
另外,由于 Service Worker 还需要会为多个页面提供服务,所以还不能把 Service Worker 和单个页面绑定起来。在目前的 Chrome 架构中,Service Worker 是运行在浏览器进程中的,因为浏览器进程生命周期是最长的,所以在浏览器的生命周期内,能够为所有的页面提供服务。
2.消息推送
消息推送也是基于 Service Worker 来实现的。因为消息推送时,浏览器页面也许并没有启动,这时就需要 Service Worker 来接收服务器推送的消息,并将消息通过一定方式展示给用户。
3.安全
在设计之初,就考虑对 Service Worker 采用 HTTPS 协议,因为采用 HTTPS 的通信数据都是经过加密的,即便拦截了数据,也无法破解数据内容,而且 HTTPS 还有校验机制,通信双方很容易知道数据是否被篡改。
除了必须要使用 HTTPS,Service Worker 还需要同时支持 Web 页面默认的安全策略、储入同源策略、内容安全策略(CSP)等
对内高内聚,对外低耦合。对内各个元素彼此紧密结合、相互依赖,对外和其他组件的联系最少且接口简单。
除了 CSS 的全局属性会阻碍组件化,DOM 也是阻碍组件化的一个因素,因为在页面中只有一个 DOM,任何地方都可以直接读取和修改 DOM。所以使用 JavaScript 来实现组件化是没有问题的,但是 JavaScript 一旦遇上 CSS 和 DOM,那么就相当难办了。
WebComponent 给出了解决思路,它提供了对局部视图封装能力,可以让 DOM、CSSOM 和 JavaScript 运行在局部环境中,这样就使得局部的 CSS 和 DOM 不会影响到全局。
WebComponent 是一套技术的组合,具体涉及到了Custom elements(自定义元素)、Shadow DOM(影子 DOM)和HTML templates(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.orgp>
<p>time1.geekbang.orgp>
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.orgp>
<p>time1.geekbang.orgp>
div>
<geek-bang>geek-bang>
body>
html>
要使用 WebComponent,通常要实现下面三个步骤。
首先,使用 template 属性来创建模板。利用 DOM 可以查找到模板的内容,但是模板元素是不会被渲染到页面上的,也就是说 DOM 树中的 template 节点不会出现在布局树中,所以我们可以使用 template 来自定义一些基础的元素结构,这些基础的元素结构是可以被重复使用的。一般模板定义好之后,我们还需要在模板的内部定义样式信息。
其次,我们需要创建一个 GeekBang 的类。在该类的构造函数中要完成三件事:
上面最难理解的是影子 DOM,其实影子 DOM 的作用是将模板中的内容与全局 DOM 和 CSS 进行隔离,这样我们就可以实现元素和样式的私有化了。你可以把影子 DOM 看成是一个作用域,其内部的样式和元素是不会影响到全局的样式和元素的,而在全局环境下,要访问影子 DOM 内部的样式或者元素也是需要通过约定好的接口的。
总之,通过影子 DOM,我们就实现了 CSS 和元素的封装,在创建好封装影子 DOM 的类之后,我们就可以使用 customElements.define 来自定义元素了(可参考上述代码定义元素的方式)。
最后,就很简单了,可以像正常使用 HTML 元素一样使用该元素,如上述代码中的
。
影子 DOM 的作用:
HTTP/0.9 是于 1991 年提出的,主要用于学术交流,需求很简单——用来在网络之间传递 HTML 超文本的内容,所以被称为超文本传输协议。整体来看,它的实现也很简单,采用了基于请求响应的模式,从客户端发出请求,服务器返回数据。
GET /index.html
用来获取 index.html。首先在浏览器中展示的不单是 HTML 文件了,还包括了 JavaScript、CSS、图片、音频、视频等不同类型的文件。因此支持多种类型的文件下载是 HTTP/1.0 的一个核心诉求,而且文件格式不仅仅局限于 ASCII 编码,还有很多其他类型编码的文件。
HTTP/1.0 引入了请求头和响应头,它们都是以为 Key-Value 形式保存的,在 HTTP 发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。
请求头:
accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh
响应头:
content-encoding: br
content-type: text/html; charset=UTF-8
其他特性:
HTTP/1.1 中增加了持久连接的方法,它的特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持。
持久连接在 HTTP/1.1 中是默认开启的,所以不需要专门为了持久连接去 HTTP 请求头设置信息,如果你不想要采用持久连接,可以在 HTTP 请求头中加上Connection: close
。目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。
持久连接虽然能减少 TCP 的建立和断开次数,但是它需要等待前面的请求返回之后,才能进行下一次请求。如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的队头阻塞的问题。
HTTP/1.1 中试图通过管线化的技术来解决队头阻塞的问题。HTTP/1.1 中的管线化是指将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求。
FireFox、Chrome 都做过管线化的试验,但是由于各种原因,它们最终都放弃了管线化技术。
在 HTTP/1.0 中,每个域名绑定了一个唯一的 IP 地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个 IP 地址。
因此,HTTP/1.1 的请求头中增加了Host 字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理。
在设计 HTTP/1.0 时,需要在响应头中设置完整的数据大小,如Content-Length: 901
,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。
HTTP/1.1 通过引入Chunk transfer 机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。
HTTP/1.1 还引入了客户端 Cookie 机制和安全机制
HTTP/1.1 为网络效率做了大量的优化,最核心的有如下三种方式:
HTTP/1.1对带宽的利用率却并不理想,带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。
原因:
**第一个原因,TCP 的慢启动。**一旦一个 TCP 连接建立之后,就进入了发送数据状态,刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,我们把这个过程称为慢启动。慢启动是 TCP 为了减少网络拥塞的一种策略。而之所以说慢启动会带来性能问题,是因为页面中常用的一些关键资源文件本来就不大,如 HTML 文件、CSS 文件和 JavaScript 文件,通常这些文件在 TCP 连接建立好之后就要发起请求的,但这个过程是慢启动,所以耗费的时间比正常的时间要多很多,这样就推迟了宝贵的首次渲染页面的时长了。
第二个原因,同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽。
**第三个原因,HTTP/1.1 队头阻塞的问题。**在 HTTP/1.1 中使用持久连接时,虽然能公用一个 TCP 管道,但是在一个管道中同一时刻只能处理一个请求,在当前的请求没有结束之前,其他的请求只能处于阻塞状态。这意味着我们不能随意在一个管道中发送请求和接收内容。
HTTP/2 的思路就是一个域名只使用一个 TCP 长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,同时也避免了多个 TCP 连接竞争带宽所带来的问题。
另外,就是队头阻塞的问题,等待请求完成后才能去请求下一个资源,这种方式无疑是最慢的,所以 HTTP/2 需要实现资源的并行请求,也就是任何时候都可以将请求发送给服务器,而并不需要等待其他请求的完成,然后服务器也可以随时返回处理好的请求资源给浏览器。
总结为:一个域名只使用一个 TCP 长连接和消除队头阻塞问题。
该图就是 HTTP/2 最核心、最重要且最具颠覆性的多路复用机制。从图中你会发现每个请求都有一个对应的 ID,如 stream1 表示 index.html 的请求,stream2 表示 foo.css 的请求。这样在浏览器端,就可以随时将请求发送给服务器了。
服务器端接收到这些请求后,会根据自己的喜好来决定优先返回哪些内容。之所以可以随意发送,是因为每份数据都有对应的 ID,浏览器接收到之后,会筛选出相同 ID 的内容,将其拼接为完整的 HTTP 响应数据。
HTTP/2 协议栈
HTTP/2 添加了一个二进制分帧层,通过引入二进制分帧层,就实现了 HTTP 的多路复用技术。
HTTP/2 提供了请求优先级,可以在发送请求时,标上该请求的优先级,这样服务器接收到请求之后,会优先处理优先级高的请求。
除了设置请求的优先级外,HTTP/2 还可以直接将数据提前推送到浏览器。
无论是 HTTP/1.1 还是 HTTP/2,它们都有请求头和响应头,这是浏览器和服务器的通信语言。HTTP/2 对请求头和响应头进行了压缩。
HTTP/1.1 协议栈中 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 表现得更好。
网络延迟又称为 RTT(Round Trip Time)。把从浏览器发送一个数据包到服务器,再从服务器返回数据包到浏览器的整个往返时间称为 RTT(如下图)。RTT 是反映网络性能的一个重要指标。
HTTP/1 和 HTTP/2 都是使用 TCP 协议来传输的,而如果使用 HTTPS 的话,还需要使用 TLS 协议进行安全传输,而使用 TLS 也需要一个握手过程,这样就需要有两个握手延迟过程。
总之,在传输数据之前,需要花掉 3~4 个 RTT。如果浏览器和服务器的物理距离较近,那么 1 个 RTT 的时间可能在 10 毫秒以内,也就是说总共要消耗掉 30~40 毫秒。这个时间也许用户还可以接受,但如果服务器相隔较远,那么 1 个 RTT 就可能需要 100 毫秒以上了,这种情况下整个握手过程需要 300~400 毫秒,这时用户就能明显地感受到“慢”了。
1.中间设备的僵化。
互联网是由多个网络互联的网状结构,为了能够保障互联网的正常工作,需要在互联网的各处搭建各种设备,这些设备就被称为中间设备。
这些中间设备有很多种类型,并且每种设备都有自己的目的,这些设备包括了路由器、防火墙、NAT、交换机等。它们通常依赖一些很少升级的软件,这些软件使用了大量的 TCP 特性,这些功能被设置之后就很少更新了。
所以,如果我们在客户端升级了 TCP 协议,但是当新协议的数据包经过这些中间设备时,它们可能不理解包的内容,于是这些数据就会被丢弃掉。这就是中间设备僵化,它是阻碍 TCP 更新的一大障碍。
除了中间设备僵化外,操作系统也是导致 TCP 协议僵化的另外一个原因。因为 TCP 协议都是通过操作系统内核来实现的,应用程序只能使用不能修改。通常操作系统的更新都滞后于软件的更新,因此要想自由地更新内核中的 TCP 协议也是非常困难的。
HTTP/2 存在一些比较严重的与 TCP 协议相关的缺陷,但由于 TCP 协议僵化,几乎不可能通过修改 TCP 协议自身来解决这些问题,那么解决问题的思路是绕过 TCP 协议,发明一个 TCP 和 UDP 之外的新的传输协议。但是这也面临着和修改 TCP 一样的挑战,因为中间设备的僵化,这些设备只认 TCP 和 UDP,如果采用了新的协议,新协议在这些设备同样不被很好地支持。
因此,HTTP/3 选择了一个折衷的方法——UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,称为QUIC 协议。
HTTP/3 中的 QUIC 协议集合了以下几点功能
第一,从目前的情况来看,服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持。Chrome 虽然在数年前就开始支持 Google 版本的 QUIC,但是这个版本的 QUIC 和官方的 QUIC 存在着非常大的差异。
第二,部署 HTTP/3 也存在着非常大的问题。因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的一个重要原因。
第三,中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~7% 的丢包率。
如果两个 URL 的协议、域名和端口都相同,我们就称这两个 URL 同源浏览器默认两个相同的源之间是可以相互访问资源和操作 DOM 的。两个不同的源之间若想要相互访问资源或者操作 DOM,那么会有一套基础的安全策略的制约。
同源策略主要表现在 DOM、Web 数据和网络这三个层面
第一个,DOM 层面。同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。
第二个,数据层面。同源策略限制了不同源的站点读取当前站点的 Cookie、IndexDB、LocalStorage 等数据。
第三个,网络层面。同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。
不过安全性和便利性是相互对立的,让不同的源之间绝对隔离,无疑是最安全的措施,但这也会使得 Web 项目难以开发和使用。因此我们就要在这之间做出权衡,出让一些安全性来满足灵活性;而出让安全性又带来了很多安全问题,最典型的是 XSS 攻击和 CSRF 攻击。
览器出让了同源策略的哪些安全性:
将不同的资源部署到不同的 CDN 上时,CDN 上的资源就部署在另外一个域名上,因此我们就需要同源策略对页面的引用资源开一个“口子”,让其任意引用外部文件。
所以最初的浏览器都是支持外部引用资源文件的,不过这也带来了很多问题。之前在开发浏览器的时候,遇到最多的一个问题是浏览器的首页内容会被一些恶意程序劫持,劫持的途径很多,其中最常见的是恶意程序通过各种途径往 HTML 文件中插入恶意脚本。
这段 HTML 文件的数据被送达浏览器时,浏览器是无法区分被插入的文件是恶意的还是正常的,这样恶意脚本就寄生在页面之中,当页面启动时,它可以修改用户的搜索结果、改变一些内容的连接指向,等等。
除此之外,它还能将页面的的敏感数据,如 Cookie、IndexDB、LoacalStorage 等数据通过 XSS 的手段发送给服务器。
以上就是一个非常典型的 XSS 攻击。为了解决 XSS 攻击,浏览器中引入了内容安全策略,称为 CSP。CSP 的核心思想是让服务器决定浏览器能够加载哪些资源,让服务器决定浏览器是否能够执行内联 JavaScript 代码。通过这些手段就可以大大减少 XSS 攻击。
跨域资源共享(CORS),使用该机制可以进行跨域访问控制,从而使跨域数据传输得以安全进行。
在实际应用中,经常需要两个不同源的 DOM 之间进行通信,于是浏览器中又引入了跨文档消息机制,可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。
XSS 全称是 Cross Site Scripting,为了与“CSS”区分开来,故简称 XSS,翻译过来就是“跨站脚本”。XSS 攻击是指黑客往 HTML 文件中或者 DOM 中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。
最开始的时候,这种攻击是通过跨域来实现的,所以叫“跨域脚本”。但是发展到现在,往 HTML 文件中注入恶意代码的方式越来越多了,所以是否跨域注入脚本已经不是唯一的注入手段了,但是 XSS 这个名字却一直保留至今。
当页面被注入了恶意 JavaScript 脚本时,浏览器无法区分这些脚本是被恶意注入的还是正常的页面内容,所以恶意注入 JavaScript 脚本也拥有所有的脚本权限。
恶意脚本能作甚?
在一个反射型 XSS 攻击过程中,恶意 JavaScript 脚本属于用户发送给网站请求中的一部分,随后网站又把恶意 JavaScript 脚本返回给用户。当恶意 JavaScript 脚本在用户页面中被执行时,黑客就可以利用该脚本做一些恶意操作。在现实生活中,黑客经常会通过 QQ 群或者邮件等渠道诱导用户去点击这些恶意链接,所以对于一些链接我们一定要慎之又慎。
另外需要注意的是,Web 服务器不会存储反射型 XSS 攻击的恶意脚本,这是和存储型 XSS 攻击不同的地方。
基于 DOM 的 XSS 攻击是不牵涉到页面 Web 服务器的。具体来讲,黑客通过各种手段将恶意脚本注入用户的页面中,比如通过网络劫持在页面传输过程中修改 HTML 页面的内容,这种劫持类型很多,有通过 WiFi 路由器劫持的,有通过本地恶意软件来劫持的,它们的共同点是在 Web 资源传输过程或者在用户使用页面的过程中修改 Web 页面的数据
通过阻止恶意 JavaScript 脚本的注入和恶意消息的发送来实现。
实施严格的 CSP 可以有效地防范 XSS 攻击,具体来讲 CSP 有如下几个功能:
因此,利用好 CSP 能够有效降低 XSS 攻击的概率。
由于很多 XSS 攻击都是来盗用 Cookie 的,因此还可以通过使用 HttpOnly 属性来保护我们 Cookie 的安全。
通常服务器可以将某些 Cookie 设置为 HttpOnly 标志,HttpOnly 是服务器通过 HTTP 响应头来设置的。。顾名思义,使用 HttpOnly 标记的 Cookie 只能使用在 HTTP 请求过程中,所以无法通过 JavaScript 来读取这段 Cookie。
由于 JavaScript 无法读取设置了 HttpOnly 的 Cookie 数据,所以即使页面被注入了恶意 JavaScript 脚本,也是无法获取到设置了 HttpOnly 的数据。因此一些比较重要的数据我们建议设置 HttpOnly 标志。
首先 David 发起登录 Gmail 邮箱请求,然后 Gmail 服务器返回一些登录状态给 David 的浏览器,这些信息包括了 Cookie、Session 等,这样在 David 的浏览器中,Gmail 邮箱就处于登录状态了。
接着黑客通过各种手段引诱 David 去打开他的链接,比如 hacker.com,然后在 hacker.com 页面中,黑客编写好了一个邮件过滤器,并通过 Gmail 提供的 HTTP 设置接口设置好了新的邮件过滤功能,该过滤器会将 David 所有的邮件都转发到黑客的邮箱中。
最后的事情就很简单了,因为有了 David 的邮件内容,所以黑客就可以去域名服务商那边重置 David 域名账户的密码,重置好密码之后,就可以将其转出到黑客的账户了。
CSRF 英文全称是 Cross-site request forgery,所以又称为“跨站请求伪造”,是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。简单来讲,CSRF 攻击就是黑客利用了用户的登录状态,并通过第三方的站点来做一些坏事。
黑客有三种方式去实施 CSRF 攻击
<html>
<body>
<h1> 黑客的站点:CSRF 攻击演示 h1>
<img src="https://time.geekbang.org/sendcoin?user=hacker&number=100">
body>
html>
<html>
<body>
<h1> 黑客的站点:CSRF 攻击演示 h1>
<form id='hacker-form' action="https://time.geekbang.org/sendcoin" method=POST>
<input type="hidden" name="user" value="hacker" />
<input type="hidden" name="number" value="100" />
form>
<script> document.getElementById('hacker-form').submit(); script>
body>
html>
<div>
<img width=150 src=http://images.xuejuzi.cn/1612/1_161230185104_1.jpg> img> div> <div>
<a href="https://time.geekbang.org/sendcoin?user=hacker&number=100" taget="_blank">
点击下载美女照片
a>
div>
和 XSS 不同的是,CSRF 攻击不需要将恶意代码注入用户的页面,仅仅是利用服务器的漏洞和用户的登录状态来实施攻击。
发起 CSRF 攻击的三个必要条件:
第一个,目标站点一定要有 CSRF 漏洞;
第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;
第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。
要让服务器避免遭受到 CSRF 攻击,通常有以下几种途径
黑客会利用用户的登录状态来发起 CSRF 攻击,而Cookie 正是浏览器和服务器之间维护登录状态的一个关键数据,因此要阻止 CSRF 攻击,首先就要考虑在 Cookie 上来做文章。
通常 CSRF 攻击都是从第三方站点发起的,要防止 CSRF 攻击,我们最好能实现从第三方站点发送请求时禁止 Cookie 的发送,因此在浏览器通过不同来源发送 HTTP 请求时,有如下区别:
如果是从第三方站点发起的请求,那么需要浏览器禁止发送某些关键 Cookie 数据到服务器;
如果是同一个站点发起的请求,那么就需要保证 Cookie 数据正常发送。
在 HTTP 响应头中,通过 set-cookie 字段设置 Cookie 时,可以带上 SameSite 选项
set-cookie: 1P_JAR=2019-10-20-06; expires=Tue, 19-Nov-2019 06:36:21 GMT; path=/; d
SameSite 选项通常有 Strict、Lax 和 None 三个值。
Referer 是 HTTP 请求头中的一个字段,记录了该 HTTP 请求的来源地址。虽然可以通过 Referer 告诉服务器 HTTP 请求的来源,但是有一些场景是不适合将来源 URL 暴露给服务器的,因此浏览器提供给开发者一个选项,可以不用上传 Referer 值,具体可参考Referrer Policy。
但在服务器端验证请求头中的 Referer 并不是太可靠,因此标准委员会又制定了Origin 属性,在一些重要的场合,比如通过 XMLHttpRequest、Fecth 发起跨站请求或者通过 Post 方法发送请求时,都会带上 Origin 属性
采用 CSRF Token 来验证,这个流程比较好理解,大致分为两步。
第一步,在浏览器向服务器发起请求时,服务器生成一个 CSRF Token。CSRF Token 其实就是服务器生成的字符串,然后将该字符串植入到返回的页面中。
第二步,在浏览器端如果要发起转账的请求,那么需要带上页面中的 CSRF Token,然后服务器会验证该 Token 是否合法。如果是从第三方站点发出的请求,那么将无法获取到 CSRF Token 的值,所以即使发出了请求,服务器也会因为 CSRF Token 不正确而拒绝请求。
所有的网络资源都是通过浏览器内核来下载的,下载后的资源会通过 IPC 将其提交给渲染进程(浏览器内核和渲染进程之间都是通过 IPC 来通信的)。然后渲染进程会对这些资源进行解析、绘制等操作,最终生成一幅图片。但是渲染进程并不负责将图片显示到界面上,而是将最终生成的图片提交给浏览器内核模块,由浏览器内核模块负责显示这张图片。
由于渲染进程需要执行 DOM 解析、CSS 解析、网络图片解码等操作,如果渲染进程中存在系统级别的漏洞,那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限,进而又获取操作系统的控制权限,这对于用户来说是非常危险的。
因为网络资源的内容存在着各种可能性,所以浏览器会默认所有的网络资源都是不可信的,都是不安全的。但谁也不能保证浏览器不存在漏洞,只要出现漏洞,黑客就可以通过网络内容对用户发起攻击。
基于以上原因,需要在渲染进程和操作系统之间建一道墙,即便渲染进程由于存在漏洞被黑客攻击,但由于这道墙,黑客就获取不到渲染进程之外的任何操作权限。将渲染进程和操作系统隔离的这道墙就是安全沙箱。
浏览器中的安全沙箱是利用操作系统提供的安全技术,让渲染进程在执行过程中无法访问或者修改操作系统中的数据,在渲染进程需要访问系统资源的时候,需要通过浏览器内核来实现,然后将访问的结果通过 IPC 转发给渲染进程。
安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用。
安全沙箱最小的保护单位是进程,并且能限制进程对操作系统资源的访问和修改,这就意味着如果要让安全沙箱应用在某个进程上,那么这个进程必须没有读写操作系统的功能,比如读写本地文件、发起网络请求、调用 GPU 接口等。
由于安全沙箱需要负责确保渲染进程无法直接访问用户的文件系统,但是在渲染进程内部有访问 Cookie 的需求、有上传文件的需求,为了解决这些文件的访问需求,所以现代浏览器将读写文件的操作全部放在了浏览器内核中实现,然后通过 IPC 将操作结果转发给渲染进程。
具体地讲,如下文件内容的读写都是在浏览器内核中完成的:
同样有了安全沙箱的保护,在渲染进程内部也是不能直接访问网络的,如果要访问网络,则需要通过浏览器内核。不过浏览器内核在处理 URL 请求之前,会检查渲染进程是否有权限请求该 URL,比如检查 XMLHttpRequest 或者 Fetch 是否是跨站点请求,或者检测 HTTPS 的站点中是否包含了 HTTP 的请求。
通常情况下,如果你要实现一个 UI 程序,操作系统会提供一个界面给你,该界面允许应用程序与用户交互,允许应用程序在该界面上进行绘制,比如 Windows 提供的是 HWND,Linux 提供的 X Window,我们就把 HWND 和 X Window 统称为窗口句柄。应用程序可以在窗口句柄上进行绘制和接收键盘鼠标消息。
不过在现代浏览器中,由于每个渲染进程都有安全沙箱的保护,所以在渲染进程内部是无法直接操作窗口句柄的,这也是为了限制渲染进程监控到用户的输入事件。
由于渲染进程不能直接访问窗口句柄,所以渲染进程需要完成以下两点大的改变。
第一点,渲染进程需要渲染出位图。为了向用户显示渲染进程渲染出来的位图,渲染进程需要将生成好的位图发送到浏览器内核,然后浏览器内核将位图复制到屏幕上。
第二点,操作系统没有将用户输入事件直接传递给渲染进程,而是将这些事件传递给浏览器内核。然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件,如果当前焦点位于浏览器地址栏中,则输入事件会在浏览器内核内部处理;如果当前焦点在页面的区域内,则浏览器内核会将输入事件转发给渲染进程。
所谓站点隔离是指 Chrome 将同一站点(包含了相同根域名和相同协议的地址)中相互关联的页面放到同一个渲染进程中执行。
最开始 Chrome 划分渲染进程是以标签页为单位,也就是说整个标签页会被划分给某个渲染进程。但是,按照标签页划分渲染进程存在一些问题,原因就是一个标签页中可能包含了多个 iframe,而这些 iframe 又有可能来自于不同的站点,这就导致了多个不同站点中的内容通过 iframe 同时运行在同一个渲染进程中。
目前所有操作系统都面临着两个 A 级漏洞——幽灵(Spectre)和熔毁(Meltdown),这两个漏洞是由处理器架构导致的,很难修补,黑客通过这两个漏洞可以直接入侵到进程的内部,如果入侵的进程没有安全沙箱的保护,那么黑客还可以发起对操作系统的攻击。
将标签级的渲染进程重构为 iframe 级的渲染进程,然后严格按照同一站点的策略来分配渲染进程,这就是 Chrome 中的站点隔离。
对发起 HTTP 请求的数据进行加密操作和对接收到 HTTP 的内容进行解密操作。
对称加密是指加密和解密都使用的是相同的密钥。
- 浏览器发送它所支持的加密套件列表和一个随机数 client-random,这里的**加密套件是指加密的方法**,加密套件列表就是指浏览器能支持多少种加密方法列表。
- 服务器会从加密套件列表中选取一个加密套件,然后还会生成一个随机数 service-random,并将 service-random 和加密套件列表返回给浏览器。
- 最后浏览器和服务器分别返回确认消息。
虽然这个版本能够很好地工作,但是其中传输 client-random 和 service-random 的过程却是明文的,这意味着黑客也可以拿到协商的加密套件和双方的随机数,由于利用随机数合成密钥的算法是公开的,所以黑客拿到随机数之后,也可以合成密钥,这样数据依然可以被破解,那么黑客也就可以使用密钥来伪造或篡改数据了。
非对称加密算法有 A、B 两把密钥,如果你用 A 密钥来加密,那么只能使用 B 密钥来解密;反过来,如果你要 B 密钥来加密,那么只能用 A 密钥来解密。
在 HTTPS 中,服务器会将其中的一个密钥通过明文的形式发送给浏览器,我们把这个密钥称为公钥,服务器自己留下的那个密钥称为私钥。顾名思义,公钥是每个人都能获取到的,而私钥只有服务器才能知道,不对任何人公开。
- 首先浏览器还是发送加密套件列表给服务器。
- 然后服务器会选择一个加密套件,不过和对称加密不同的是,使用非对称加密时服务器上需要有用于浏览器加密的公钥和服务器解密 HTTP 数据的私钥,由于公钥是给浏览器加密使用的,因此服务器会将加密套件和公钥一道发送给浏览器。
- 最后就是浏览器和服务器返回确认消息。
采用非对称加密,就能保证浏览器发送给服务器的数据是安全的了,这看上去似乎很完美,不过这种方式依然存在两个严重的问题。
第一个是非对称加密的效率太低。这会严重影响到加解密数据的速度,进而影响到用户打开页面的速度。
第二个是无法保证服务器发送给浏览器的数据安全。虽然浏览器端可以使用公钥来加密,但是服务器端只能采用私钥来加密,私钥加密只有公钥能解密,但黑客也是可以获取得到公钥的,这样就不能保证服务器端数据的安全了。
在传输数据阶段依然使用对称加密,但是对称加密的密钥采用非对称加密来传输。
改造后的流程是这样的:
极客时间要证明这个服务器就是极客时间的,也需要使用权威机构颁发的证书,这个权威机构称为CA(Certificate Authority),颁发的证书就称为数字证书(Digital Certificate)。
数字证书有两个作用:一个是通过数字证书向浏览器证明服务器的身份,另一个是数字证书里面包含了服务器公钥。
有了 CA 签名过的数字证书,当浏览器向极客时间服务器发出请求时,服务器会返回数字证书给浏览器。
浏览器接收到数字证书之后,会对数字证书进行验证。首先浏览器读取证书中相关的明文信息,采用 CA 签名时相同的 Hash 函数来计算并得到信息摘要 A;然后再利用对应 CA 的公钥解密签名数据,得到信息摘要 B;对比信息摘要 A 和信息摘要 B,如果一致,则可以确认证书是合法的,即证明了这个服务器是极客时间的;同时浏览器还会验证证书相关的域名信息、有效时间等信息。
这时候相当于验证了 CA 是谁,但是这个 CA 可能比较小众,浏览器不知道该不该信任它,然后浏览器会继续查找给这个 CA 颁发证书的 CA,再以同样的方式验证它上级 CA 的可靠性。通常情况下,操作系统中会内置信任的顶级 CA 的证书信息(包含公钥),如果这个 CA 链中没有找到浏览器内置的顶级的 CA,证书也会被判定非法。
在申请和使用证书的过程中,还需要注意以下三点: