Thanks to visit codestin.com
Credit goes to xianghui-ma.github.io

0%

jsonp(JSON with padding——又称填充式JSON或参数式JSON)。是使用JSON的一种新方法——用来跨域。

jsonp原理

我们知道——凡是带有src属性的html标签,他们都能跨域访问资源,比如:img、script、iframe等。

jsonp也正是利用了这个特点,通过动态创建script标签,并设置其src属性来实现跨域访问资源的

jsonp由两部分组成:回调函数与服务器端返回的数据。其中,回调函数名可在请求url中通过相应的查询参数(如cb、callback、jsonp)自定义(究竟采用什么查询参数指定回调函数名还需要由服务器端决定,服务器端通过哪个查询参数来获取回调函数名那么就采用那个回调函数)。然后服务器返回数据时会将数据作为参数放入你指定的函数名中,以函数调用的方式返回数据——如:
handleResponse(json形式的数据)。该函数调用被放入创建的script标签中,然后执行调用页面中的handleResponse函数,对数据进行处理

jsonp需要前后端配合才行,并不是所有资源链接都能使用jsonp获取到,只有后端对jsonp也做了支持,那么通过jsonp才能获取到数据。当然,如果资源链接中能指定回调函数名,那么一般都能使用jsonp,如下面的例子

另外需要注意的地方是:对于<script>元素而言,服务器返回的json编码数据会被自动解码执行。因此,虽然服务器返回的是json形式的回调,但浏览器会自动解码执行函数调用

只有将创建的script元素插入页面时才会触发HTTP请求

我们来看一个跨域访问的url的形式(做了简化,来源于百度搜索框联想)

1
script.src = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=西部世界'&cb=handleResponse'

其中cb=handleResponse就是我们指定的回调函数名(wd=为你输入的关键字)

下面我们写一个简单的jsonp跨域请求

1
2
3
4
5
6
7
var script = document.createElement('script');
script.src = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=西部世界'&cb=handleResponse';
document.body.appendChild(script);

function handleResponse(data){
console.log(data);
}

响应回来的内容是:

1
handleResponse({q:"西部世界",p:false,s:["西部世界第二季","西部世界第一季","西部世界第二季下载","西部世界2","西部世界第二季百度云","西部世界第一季下载","西部世界第一季百度云","西部世界下载","西部世界未删减版","西部世界百度云"]});

可见——是一个函数调用的形式

应用

我们简单的模仿一个百度搜索框的词语联想功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<input type="text" id="word"/>
<ul id="list"></ul>
<script>
var input = document.getElementById('word');
input.timer = null;
var list = document.getElementById('list');
var script = null;
input.addEventListener('input', handle, false);

function handle(e){
clearTimeout(input.timer);
input.timer = setTimeout(function(){
script && script.remove();
script = document.createElement('script');
script.src = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=' + input.value + '&cb=' + 'handleResponse';
document.body.appendChild(script);
}, 300);
}
function handleResponse(data){
var arr = [];
console.log(data.s);
data.s.forEach(function(item){
arr.push('<li><a href="https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=0&rsv_idx=1&tn=baidu&wd=' + item + '">' + item + '</a></li>');
});
list.innerHTML = arr.join('');
list.style.display = data.s.length !== 0 ? 'block' : 'none';
}
</script>

注意:ajax与jsonp的区别在于——ajax是通过xhr对象来访问数据的,且ajax受同源策略的限制。而jsonp是通过动态创建script标签来访问数据,可以跨域

XSS攻击

XSS 全称是 Cross Site Scripting,为了与“CSS”区分开来,故简称 XSS,翻译过来就是“跨站脚本”。XSS 攻击是指黑客往 HTML 文件中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段

通过恶意脚本的注入方式不同,可以将XSS攻击分为如下三类:

存储型XSS攻击

黑客利用站点漏洞将一段恶意 JavaScript 代码提交到网站的数据库中,然后用户向网站请求包含了恶意 JavaScript 脚本的页面;当用户浏览该页面的时候,恶意脚本就会将用户的 Cookie 信息等数据上传到服务器

如喜马拉雅的一个存储型XSS漏洞:

p1

p2

由于未对用户输入进行处理,所以恶意脚本被保存至数据库,当页面显示专辑名称时便会将恶意代码注入HTML运行

反射型XSS

其是指恶意脚本作为请求参数的一部分被发送到服务器(如http://localhost:3000/?xss=<script>...</script>),然后服务器将这段脚本返回给客户端(如将脚本通过后台渲染注入HTML),浏览器发现是JS脚本便运行了恶意代码

需要注意的是,Web 服务器不会存储反射型 XSS 攻击的恶意脚本,这是和存储型 XSS 攻击不同的地方

基于DOM的XSS攻击

这种方式的工作原理是在数据的传输过程中劫持数据并向HTML页面中注入恶意代码。该种方式不经过web服务器

上述介绍了三种不同的XSS攻击,那么下面就来谈谈如何阻止XSS攻击:

1、服务器对输入内容进行过滤或转码(不要相信任何输入)

如下述代码:

1
2
3
4
5
输入:<script>alert('你被xss攻击了')</script>

过滤:......什么都没有了,直接将script过滤掉

转码:<script>alert('你被xss攻击了')</script>

2、为cookie设置HttpOnly(浏览器端措施)

通常服务器可以将某些 Cookie 设置为 HttpOnly 标志,HttpOnly 是服务器通过 HTTP 响应头来设置的。如下:

1
set-cookie: NID=......; expires=Sat, 18-Apr-2020 06:52:22 GMT; path=/; domain=.google.com; HttpOnly

使用 HttpOnly 标记的 Cookie 只能在浏览器发送 HTTP 请求时自动携带,而无法通过 JavaScript 来读取这段 Cookie。这样就能防止恶意脚本读取cookie

3、充分利用CSP(浏览器端措施)

实施严格的 CSP 可以有效地防范 XSS 攻击,具体来讲 CSP 有如下几个功能:

  • 限制加载其他域下的资源文件,这样即使黑客插入了一个 JavaScript 文件,这个 JavaScript 文件也是无法被加载的
  • 禁止向第三方域提交数据,这样用户数据也不会外泄
  • 禁止执行内联脚本和未授权的脚本
  • 还提供了上报机制,这样可以帮助我们尽快发现有哪些 XSS 攻击,以便尽快修复问题

CSRF攻击

CSRF 英文全称是 Cross-site request forgery,即“跨站请求伪造”

CSRF攻击的核心在于利用了浏览器的这个特性————用户访问某个站点时,浏览器会自动将它存储的该站点的cookie等信息添加到本次请求头中

基于上述特性,CSRF的攻击原理如下:如你登陆了A站点,黑客引诱你点击恶意链接到黑客站点的网页,黑客在该网页中构造了一条请求A站点某个接口(如转账接口)的HTTP请求,当浏览器打开黑客网页并发送HTTP请求,利用浏览器的上述特性便能伪造成是你在访问该接口

CSRF攻击的三种方式

1、自动发起GET请求

1
2
3
4
5
6
7
<!DOCTYPE html>
<html>
<body>
<h1>黑客的站点:CSRF攻击演示</h1>
<img src="https://time.geekbang.org/sendcoin?user=hacker&number=100">
</body>
</html>

这是黑客页面的 HTML 代码,在这段代码中,黑客将转账的请求接口隐藏在 img 标签内,欺骗浏览器这是一张图片资源。当该页面被加载时,浏览器会自动发起 img 的资源请求。从而实现转账

2、自动发起 POST 请求

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<body>
<h1>黑客的站点:CSRF攻击演示</h1>
<form id='hacker-form' action="https://time.geekbang.org/sendcoin" method=POST>
<input type="hidden" name="user" value="hacker" />
<input type="hidden" name="number" value="100" />
</form>
<script> document.getElementById('hacker-form').submit(); </script>
</body>
</html>

在这段代码中,我们可以看到黑客在他的页面中构建了一个隐藏的表单,该表单的内容就是极客时间的转账接口。当用户打开该站点之后,这个表单会被自动执行提交;当表单被提交之后,服务器就会执行转账操作

3、引诱用户点击链接

这种方式通常出现在论坛或者恶意邮件上。黑客会采用很多方式去诱惑用户点击黑客站点上的链接

1
2
3
4
5
6
<div>
<img width=150 src=http://images.xuejuzi.cn/1612/1_161230185104_1.jpg> </img> </div> <div>
<a href="https://time.geekbang.org/sendcoin?user=hacker&number=100" taget="_blank">
点击下载美女照片
</a>
</div>

这段黑客站点代码,页面上放了一张美女图片,下面放了图片下载地址,而这个下载地址实际上是黑客用来转账的接口,一旦用户点击了这个链接,那么他的钱就被转到黑客账户上了

防止CSRF攻击

1、利用好 Cookie 的 SameSite 属性

SameSite 选项通常有 Strict、LaxNone 三个值

  • Strict 最为严格。如果 SameSite 的值是 Strict,那么浏览器会完全禁止第三方 Cookie。即除非你在A站点内访问A站点的某个接口,如果你在第三方站点访问A站点的某个接口的话,浏览器是不会吧cookie等信息自动添加到请求头中的
  • Lax 相对宽松一点。在跨站点的情况下,从第三方站点的链接打开和从第三方站点提交 Get 方式的表单这两种方式都会携带 Cookie。但如果在第三方站点中使用 Post 方法,或者通过 imgiframe 等标签加载的 URL,这些场景都不会携带 Cookie
  • 而如果使用 None 的话,在任何情况下都会发送 Cookie 数据。默认值
1
set-cookie: 1P_JAR=2019-10-20-06; expires=Tue, 19-Nov-2019 06:36:21 GMT; path=/; domain=.google.com; SameSite=none

2、验证请求的来源站点

即在服务器端通过请求头中的OriginReferrer字段来验证请求来源是否来自第三方站点

p3

从上图可以看出,Origin 属性只包含了域名信息,并没有包含具体的 URL 路径,这是 OriginReferer 的一个主要区别

但我们可通过Ajax中的自定义请求头来伪造这两个字段的值,因此此种方法安全性较差

3、CSRF Token

该种方式分为两部

第一步,在浏览器向服务器发起请求时,服务器生成一个 CSRF Token。CSRF Token 其实就是服务器生成的字符串,然后将该字符串植入到返回的页面中。你可以参考下面示例代码:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<body>
<form action="https://time.geekbang.org/sendcoin" method="POST">
<input type="hidden" name="csrf-token" value="nc98P987bcpncYhoadjoiydc9ajDlcn">
<input type="text" name="user">
<input type="text" name="number">
<input type="submit">
</form>
</body>
</html>

第二步,在浏览器端如果要发起请求,那么需要带上页面中的 CSRF Token,然后服务器会验证该 Token 是否合法。如果是从第三方站点发出的请求,那么将无法获取到 CSRF Token 的值,所以即使发出了请求,服务器也会因为 CSRF Token 不正确而拒绝请求

HTTPS

HTTPS产生的原因

由于HTTP采用的是明文传输,那么在数据的传输过程中便可能遭遇中间人攻击,如下:

p4

具体来讲,在将 HTTP 数据提交给 TCP 层之后,数据会经过用户电脑、WiFi 路由器、运营商和目标服务器,在这中间的每个环节中,数据都有可能被窃取或篡改。比如用户电脑被黑客安装了恶意软件,那么恶意软件就能抓取和篡改所发出的 HTTP 请求的内容。或者用户一不小心连接上了 WiFi 钓鱼路由器,那么数据也都能被黑客抓取或篡改

HTTPS

HTTPS并不是一个新的协议。其只是在HTTP协议栈中加入了安全层。如下:

p5

安全层有两个主要的职责:对发起 HTTP 请求的数据进行加密操作和对接收到 HTTP 的内容进行解密操作

下面我们便一步一步的构建HTTPS

第一版HTTPS——使用对称加密

所谓对称加密是指加密和解密都使用的是相同的密钥

p6

由上图可知这一版本的HTTPS工作原理如下:

  1. 浏览器发送它所支持的加密套件列表和一个随机数 client-random,这里的加密套件是指加密的方法,加密套件列表就是指浏览器能支持多少种加密方法列表
  2. 服务器会从加密套件列表中选取一个加密套件,然后还会生成一个随机数 service-random,并将 service-random 和所选的加密套件列表返回给浏览器
  3. 至此,浏览器和服务器便都有了service-randomclient-random。然后他们利用这两个随机数生成密钥
  4. 然后浏览器便用生成的密钥加密数据,服务器收到数据后再用密钥解密

该种方式的问题在于:传输 client-random 和 service-random 的过程却是明文的,这意味着黑客也可以拿到协商的加密套件和双方的随机数,由于利用随机数合成密钥的算法是公开的,所以黑客拿到随机数之后,也可以合成密钥,这样数据依然可以被破解,那么黑客也就可以使用密钥来伪造或篡改数据了。为此我们继续改进得到第二版HTTPS

第二版HTTPS——使用非对称加密

非对称加密是指:非对称加密算法有 A、B 两把密钥,如果你用 A 密钥来加密,那么只能使用 B 密钥来解密;反过来,如果你要 B 密钥来加密,那么只能用 A 密钥来解密

p7

由上图可知这一版本的HTTPS工作原理如下:

  1. 浏览器发送它所支持的加密套件列表到服务器
  2. 服务器返回它所选的加密套件和公钥(以明文的形式传输),私钥则留在服务器端
  3. 浏览器和服务器双方进行确认
  4. 之后浏览器便可以使用公钥加密数据了,而由于私钥在服务器端,所以只有服务器能用私钥解密数据

服务器自己留下的那个密钥称为私钥,只有服务器才能知道,不对任何人公开。明文的形式发送给浏览器的那个密钥被称为公钥

该种方式的问题在于:

  • 非对称加密的效率太低。这会严重影响到加解密数据的速度,进而影响到用户打开页面的速度
  • 无法保证服务器发送给浏览器的数据安全。由于浏览器使用的是公钥,因此服务器只能用私钥加密数据,这样浏览器才能用公钥解密。但是由于公钥是明文传输,因此黑客可以获取到,那么黑客就能解密服务端发给浏览器的数据

为此我们继续改进得到第三版HTTPS

第三版HTTPS——对称加密和非对称加密搭配使用

p8

由上图可知这一版本的HTTPS工作原理如下:

  1. 首先浏览器向服务器发送对称加密套件列表、非对称加密套件列表和随机数 client-random
  2. 服务器保存随机数 client-random,选择对称加密和非对称加密的套件,然后生成随机数 service-random,向浏览器发送选择的对称和非对称加密套件、service-random 和公钥
  3. 浏览器保存公钥,并生成随机数 pre-master,然后利用公钥对 pre-master 加密,并向服务器发送加密后的数据。由于私钥仅有服务器才有,因此pre-master是不能被黑客破解的
  4. 最后服务器拿出自己的私钥,解密出 pre-master 数据,并返回确认消息
  5. 到此为止,服务器和浏览器就有了共同的 client-random、service-random 和 pre-master,然后服务器和浏览器会使用这三组随机数生成对称密钥master secret,因为服务器和浏览器使用同一套方法来生成密钥,所以最终生成的密钥也是相同的
  6. 有了对称加密的密钥之后,双方就可以使用对称加密的方式来传输数据了

不过这种方式依然存在着问题,比如我要打开极客时间的官网,但是黑客通过 DNS 劫持将极客时间官网的 IP 地址替换成了黑客的 IP 地址,这样我访问的其实是黑客的服务器了,黑客就可以在自己的服务器上实现公钥和私钥,而对浏览器来说,它完全不知道现在访问的是个黑客的站点

为此我们继续改进得到最终版HTTPS

最终版HTTPS——添加数字证书

对于浏览器来说,数字证书有两个作用:一个是通过数字证书向浏览器证明服务器的身份,以防DNS劫持;另一个是数字证书里面包含了服务器公钥

数字证书由权威机构 CA(Certificate Authority)颁发,并放于服务器端

p9

由上图可知,相较于第三版HTTPS,最终版HTTPS仅多了数字证书验证。注意这儿公钥是包含在数字证书中的

本文主要讲述HTTP强制缓存、HTTP协商缓存、强制缓存与协商缓存间的执行优先级及流程

在前端项目中,为了实现资源的快速加载以增加渲染的流畅度和提升用户体验,我们需要使用缓存技术对已经获取的资源实现重用。缓存的原理就是:在浏览器端保存一份所请求资源的副本,用户下次请求相同的资源时直接拿取浏览器保存的副本而不需要再次向服务器端请求

强制缓存

强制缓存的含义是浏览器判断缓存资源是否已经过期(过期时间由服务器端返回的ExpiresCache-Control字段指定)。如果没过期则直接从缓存中拿取,如果过期了则直接向服务器请求该资源

与强制缓存相关的字段如下:

1、Expires

指定过期时间,由服务器端指定后通过响应头告知浏览器,浏览器在看到该响应头后便会对响应体进行缓存。之后浏览器再次发起相同的资源请求时,便会判断是否超过了Expires指定的过期时间,超过了就重新请求服务器,没超过直接从浏览器缓存中拿

  • 这是HTTP/1.0中使用的字段
  • 该方式的缺点是浏览器通过比较本地时间与Expires来判断是否过期,但很可能本地时间与服务端的时间不同步,那么对于缓存过期与否的判定将不能达到预期,因此在HTTP/1.1中被抛弃

示例:Expires: Wed, 22 Nov 2019 08:41:00 GMT表示资源在2019年11月22号8点41分过期,过期了就得向服务端发请求

2、Cache-Control

由于Expires的上述缺点,在HTTP/1.1中采用Cache-Control来代替ExpiresCache-Control采用的是保鲜时长(而Expires采用的是制定具体的过期时间点)。如Cache-Control:max-age=3600表示响应体将保鲜3600秒(即一个小时),之后便过期需要重新请求服务器

Cache-Control下的字段有:

  • max-age=?。指定响应体保鲜时长,单位为s
  • public。客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器最后才到达目标服务器,那么结果就是不仅仅浏览器可以缓存数据,中间的任何代理节点都可以进行缓存
  • private。只有浏览器能缓存了,中间的代理服务器不能缓存。publicprivate互斥
  • no-cache:。跳过强缓存,直接进入协商缓存
  • no-store。非常粗暴,不对响应体进行任何形式的缓存
  • s-maxage=?。这和 max-age 长得比较像,但是区别在于 s-maxage 是针对代理服务器的缓存时间(仅当设置了public时才生效)

示例:Cache-Control:max-age=3600 public表示响应体将保鲜3600秒且客户端和代理服务器都可以缓存

ExpiresCache-Control 同时存在的时候,Cache-Control 会优先考虑

协商缓存

协商缓存的含义是浏览器先向服务器发送一次请求(携带If-Modified-Since/ETag),以询问服务器缓存的响应体是否过期,如果服务器判断没过期则返回304状态码,浏览器见到是304便直接从缓存中拿取响应体;如果服务器判断过期,则直接返回新的响应体

与协商缓存相关的字段如下:

1、Last-ModifiedIf-Modified-Since

Last-Modified指定服务器端资源的最后修改时间,由服务器端指定后通过响应头告知浏览器

之后浏览器再次发起相同的资源请求时,会在请求头中携带If-Modified-Since字段,这个字段的值也就是服务器传来的最后修改时间

服务器拿到请求头中的If-Modified-Since字段后,会和这个服务器中该资源的最后修改时间对比,如果两者相等则表示资源还未被修改过,服务器直接返回304,告诉浏览器直接用缓存;如果两者不等则直接返回新的资源

示例如下:

1
2
3
4
5
6
7
8
9
/*第一次服务器端响应*/
Last-Modified:Fri, 30 Apr 2021 03:58:55 GMT
Cache-Control:no-cache

/*浏览器再次请求相同资源时的请求头*/
If-Modified-Since:Fri, 30 Apr 2021 03:58:55 GMT

/*服务器端的再次响应头(这儿假设没过期)*/
Status Code:304 Not Modified

Last-Modified的缺点是:(1)、他只是根据资源的最后修改时间进行判定。但如果修改前后资源的内容并没有发生变化,则最后修改时间也会更新,这就会导致重新请求资源。(2)、他能感知的修改单位是秒,如果修改文件所花的时间不足一秒,那么最后修改时间依旧不变(即使确实已经做了修改)

2、ETagIf-None-Match

为了解决Last-Modified的上述缺点,在HTTP/1.1中引入了ETag来代替Last-Modified。其值是服务器根据资源内容计算出的哈希值,由服务器端指定后通过响应头告知浏览器。因此ETag能更好的感知资源变化

浏览器接收到ETag的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器

服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对。如果两者相等则返回304,告诉浏览器直接用缓存;若不等则直接返回新的资源

ETage的缺点是:由于要计算哈希值,因此会加重服务器端负担

Last-ModifiedETage 同时存在的时候,ETage 会优先考虑

示例:

1
2
3
//响应头
ETag:"c39046a19cd8354c2de0c32ce7ce3"
Last-Modified:Fri, 12 Jul 2019 03:58:55 GMT

强制缓存与协商缓存的优先级

强制缓存与协商缓存是可以同时存在的,强制缓存的优先级高于协商缓存

浏览器缓存的整体运作流程如下:

p1

缓存在哪儿

前面我们说到,当强缓存命中或者协商缓存中服务器返回304的时候,浏览器直接从缓存中取资源。那么这些缓存在什么位置呢?

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker 后面细说

Memory Cache 指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了

Disk Cache 就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长

Push Cache 即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2 中的内容,虽然现在应用的并不广泛。主要特性如下:(1)、Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。(2)、不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache

浏览器本地存储主要分为:

  • cookie
  • WebStoragelocalStoragesessionStorage统称为WebStorage
  • IndexedDB。H5的新增特性

本节将详述上述内容

cookie

cookie的由来和工作原理

HTTP是无状态的。但是我们可以通过cookie使你在下一次访问同一个服务器时,服务器能够认识你

cookie是这样工作的:当用户浏览某个使用cookie的网站时,该网站的服务器就为用户产生一个唯一的识别码,并以此作为索引在服务器的后端数据库中产生一个项目。接着在用户的HTTP响应报文中添加一个叫做Set-cookie的首部行,后面的值就是赋予该用户的识别码(当然,还可以包含其他任何内容)。例如这个首部行是这样的Set-cookie:12345678。当用户收到这个响应时,其浏览器就在他管理的特定Cookie文件中添加一行,其中包括这个服务器的主机名和Set-cookie后面的识别码。当用户继续浏览这个网站时,每发送一个HTTP请求报文,其浏览器就会从其cookie文件中取出这个网站的识别码,并放到HTTP请求报文的首部行中Cookie:12345678。于是这个网站就能追踪该用户了

cookie的有效期

cookie的默认有效期是:一旦用户关闭浏览器,cookie保存的数据就会丢失。此时cookie仅存在于内存中,不会被写入磁盘。此时Expires/Max-Age的值为Session,如下图:

p1

特别强调: cookie与整个浏览器进程同生共死。所以即使cookie所在页面的标签页被关闭了,cookie依然有效

除了默认有效期,我们也可以通过cookieExpiresMax-Age属性(见下面)自定义cookie的有效期。一旦设置了有效期,浏览器就会将cookie数据存储到磁盘文件中,直到过了有效期便会将其删除

cookie的作用域

cookie的作用域由文档源(即Domain属性)和文档路径(即Path属性)共同决定。判断cookie的作用域可用下面的公式

1
(A页面的域名与cookie的Domain相同 or A页面的域名是cookie的Domain的子域名) + (A页面的Path与cookie的Path相同 or A页面的Path是B页面的Path的子路径) = A页面可以得到该cookie

所谓子域名就是计算机网络中的二/三…级域名(具体可参见计网第6章——应用层)

1
2
3
4
cookie的Domain为 .example.com,则下述域名就是其子域名

1、order.example.com
2、catalog.example.com

特别注意: cookie的域只能设置为当前服务器(即页面URL中的域名表示的服务器)的域。如在www.baidu.com下的cookie的域不能为xueshu.baidu.com,反之亦然

当访问cookie能作用到的任何一个网页时,浏览器都会将cookie发送给服务器

cookie的属性

  • Expires

设置cookie过期的时间点,如Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

  • Max-Age

设置cookie经过多少秒后失效,如Set-Cookie: id=a3fWa; Max-Age=604800;

max-Age 属性为正数时,浏览器会将其持久化,即写到对应的 Cookie 文件中

max-Age 属性为负数,则表示该 Cookie 只是一个会话性 Cookie

max-Age 为 0 时,则会立即删除这个 Cookie

假如 ExpiresMax-Age 都存在,Max-Age 优先级更高

  • Secure

Boolean值,为true时表示cookie仅能通过HTTPS传递

  • HTTPOnly

Boolean值,为true时表示不能用脚本操作cookie,可以防止XSS攻击

cookie的缺点

  • 容量缺陷。cookie 的体积上限只有4KB,只能用来存储少量的信息

  • 性能缺陷。不管cookie能作用到的页面需不需要这个 cookie ,请求都会携带上完整的 cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容

  • 安全缺陷。由于 cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 cookie 的有效期内重新发送给服务器,这是相当危险的

操纵cookie

可以通过document.cookie读取或设置cookie。如document.cookie='myname=laihuamin;path=/;domain=www.baidu.com';便可以设置一个cookie

localStorage与sessionStorage

浏览器在Window对象上定义了两个属性——localStoragesessionStorage。它们以键值对的形式存储信息,且值只能是字符串

有效期

localStorage是永久存储,除非Web应用刻意删除存储的数据,或者用户通过浏览器删除数据

sessionStorage不是永久存储,与页面所在标签页同生共死,也与浏览器同生共死。即如果关闭标签页或关闭浏览器,sessionStorage也随之灰飞烟灭

作用域

localStorage的作用域遵循同源策略。同源的页面共享同样的localStorage数据,他们可以互相读取对方的数据,甚至可以覆盖对方的数据。下述URL便不属于同源,因此不能共享localStorage数据

1
2
3
4
http://www.example.com
https://www.example.com
http://static.example.com
http://www.example.com:8000

sessionStorage的作用域受同源策略与标签页共同限制。即不同源的页面间不能共享sessionStorage数据,同源的文档渲染在不同的标签页中也无法共享sessionStorage数据

API

localStoragesessionStorage的API是一样的

  • setItem(key, value),存储数据
  • getItem(key)获取数据
  • removeItem(key)删除数据
  • clear()删除全部数据

应用场景

  • localStorage

可以利用localStorage存储一些内容稳定的资源,比如官网的logo,存储Base64格式的图片资源

  • sessionStorage

可以用它对表单信息进行维护,将表单信息存储在里面,可以保证页面即使刷新也不会让之前的表单信息丢失

可以用它存储本次浏览记录。如果关闭页面后不需要这些记录,用sessionStorage就再合适不过了。事实上微博就采取了这样的存储方式

localStoragesessionStorage的容量上限为5M,且都不参与同浏览器的通讯(不同于cookie

IndexedDB

作用域

IndexedDB的作用域遵循同源策略。两个同源的页面互相之间可以访问对方的数据

有效期

除非被清理,否则一直存在

indexedDB的基本使用

在此仅阐述基本的操作!不会深入的讲解

下面我们按照操作步骤依次按序阐述:

1、创建数据库(打开数据库)

创建数据库的语法为:let openRequest = indexedDB.open(name, version);,如果数据库存在则打开对应数据库,不存在则创建一个数据库

  • name为数据库名字
  • version为数据库版本号,是一个正整数

open方法会返回一个openRequest对象,该对象上有如下事件:

  • success:数据库准备就绪,openRequest.result 中有了一个数据库对象(Database Object)实例(下一步我们会使用它)
  • error:创建或打开数据库失败
  • upgradeneeded:仅在本地数据库版本小于open打开数据库时指定的版本时才会触发该事件。注意: 如果你打开的数据库在本地还不存在(即第一次创建该数据库),则版本默认为0(即使数据库还不存在)

删除数据库的语法为let deleteRequest = indexedDB.deleteDatabase(name)deleteRequest对象上也有successerror事件,用于监听删除成功与否

1
2
3
4
5
6
7
8
9
10
let openRequest = indexedDB.open('TEST', 1);
openRequest.onsuccess = () => {
console.log('数据库创建成功');//数据库创建成功
}
openRequest.onerror = () => {
console.log('数据库创建失败');
}
openRequest.onupgradeneeded = () => {
console.log('数据库版本升级');//数据库版本升级
}

2、建表

数据库创建好后,接下来就是在数据库中建表了!注意: 在indexedDB中,表又被称为对象库(Object Store)。如下图所示:

p2

由上图可知,在indexedDB中,表中的记录是以键值对的形式存储的(与localStorage/sessionStorage一样)。每一个键都必须是唯一的;键的类型必须为数字、日期、字符串、二进制或数组;值可以是任意类型

表会按键对值进行内部排序

创建表的语法如下:db.createObjectStore(name[, keyOptions]);,其中db就是第一步中的openRequest.result

  • name 是表名,如students表来存储学生信息
  • keyOptions 是具有以下两个属性之一的可选对象:keyPath —— 存储对象(即键值对中的值)的某个属性的名字,例如 id,表会将该属性的值作为记录的键;autoIncrement —— 如果为 true,则自动生成新存储的对象的键,键是一个不断递增的数字。如果我们没有提供keyOptions,则添加记录时需要自己传入键
1
2
3
4
5
6
7
8
9
10
11
12
let openRequest = indexedDB.open('TEST', 1);
openRequest.onsuccess = () => {
console.log('数据库创建成功');//数据库创建成功
}
openRequest.onerror = () => {
console.log('数据库创建失败');
}
openRequest.onupgradeneeded = () => {
let db = openRequest.result;
let students = db.createObjectStore('students', { keyPath: 'id' });
//let index = students.createIndex('name_index', 'name');
}

注意: 对表(对象库)的增/删/改操作都必须在onupgradeneeded事件中进行

删除表的语法为db.deleteObjectStore('表名')

3、事务

通过事务添加记录

表建好了,接下来就是操作表中的记录了!

所有记录的操作都必须在 IndexedDB 中的事务内进行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const add=()=>{
let openRequest2=indexedDB.open('TEST', 1);
openRequest2.onsuccess=()=>{
let db=openRequest2.result;
let transaction=db.transaction('students', 'readwrite');
let students=transaction.objectStore('students');
let stu={
id: 1234,
name:'德洛丽丝'
}
let request=students.add(stu);
request.onsuccess=()=>{
console.log("添加成功");
}
request.onerror=()=>{
console.log("添加失败");
}
}
}

调用add方法便可向表中添加记录。由上述代码可知添加记录的步骤为

  • db.transaction(store[, type])创建一个事务,store就是要操作的表名,type表示事务类型,有两个值——readonly表示只能读记录,默认值。readwrite表示只能读取和写入记录
  • 使用 transaction.objectStore(name),获取存储对象
  • 调用存储对象的add方法添加记录
  • 监听事件

存储对象所支持的添加记录方法有:

  • put(value, [key])value 添加到表中。仅当对象库没有 keyPathautoIncrement 时,才提供 key。如果已经存在具有相同键的值,则将替换该值。
  • add(value, [key])put 相同,但是如果已经有一个值具有相同的键,则请求失败,并生成一个名为 ConstraInterror 的错误

通过事务查找记录

有两种查找方式,如下:

1、通过键查找

我们可通过精确的键值来查找记录,也可通过键范围来查找(因为前面说了,记录会按照键排序)

使用以下调用函数创建键范围:

  • IDBKeyRange.lowerBound(lower, [open]) 表示:≥lower(如果 opentrue,表示 >lower
  • IDBKeyRange.upperBound(upper, [open]) 表示:≤upper(如果 opentrue,表示 <upper
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) 表示: 在 lowerupper 之间。如果 opentrue,则相应的键不包括在范围中
  • IDBKeyRange.only(key) —— 仅包含一个键的范围 key,很少使用

查找记录的方法如下:

存储对象.get(query) —— 按键或范围搜索第一个值
存储对象.getAll([query], [count]) —— 搜索所有值。如果 count 给定,则按 count 进行限制
存储对象.getKey(query) —— 搜索满足查询的第一个键,通常是一个范围
存储对象.getAllKeys([query], [count]) —— 搜索满足查询的所有键,通常是一个范围。如果 count 给定,则最多为 count
存储对象.count([query]) —— 获取满足查询的键的总数,通常是一个范围

其中查询参数 query可以是精确键或者键范围

1
2
3
4
5
6
7
8
9
10
11
12
const find = () => {
let openRequest3 = indexedDB.open('TEST', 1);
openRequest3.onsuccess = () => {
let db = openRequest3.result;
let transaction = db.transaction('students', 'readwrite');
let students = transaction.objectStore('students');
let request = students.get(1234);
request.onsuccess=()=>{
console.log(request.result);//{id: 1234, name: "德洛丽丝"}
}
}
}

2、通过带索引的字段查找

通过索引搜索是为了解决通过记录中的对象的其他字段进行搜索的问题。如我们不通过id搜索学生而通过name搜索学生

要想通过带索引的字段查找,那么就必须在建表时通过objectStore.createIndex(name, keyPath, [options])来创建索引(如第二步中注释的内容)。其中:

  • name —— 索引名称
    keyPath —— 索引应该跟踪的对象字段的路径(我们将根据该字段进行搜索)
    option —— 具有以下属性的可选对象:unique —— 如果为true,则存储中只有一个对象在 keyPath 上具有给定值。如果我们尝试添加重复项,索引将生成错误;multiEntry —— 只有 keypath 上的值是数组时才使用。这时,默认情况下,索引将默认把整个数组视为键。但是如果 multiEntrytrue,那么索引将为该数组中的每个值保留一个存储对象的列表。所以数组成员成为了索引键

然后便可使用查找记录的方法用索引搜索

1
2
3
4
5
6
7
8
9
10
11
let nameIndex = books.index("name_index");

let request = nameIndex.getAll('德洛丽丝');

request.onsuccess = function() {
if (request.result !== undefined) {
console.log(request.result); // {id: 1234, name: "德洛丽丝"}
} else {
console.log("查找失败");
}
};

删除记录

delete(query) —— 删除匹配的记录
students.clear() —— 删除全部记录

关于indexedDB的其他内容可见 IndexedDB

本篇将阐述浏览器中XMLHttpRequest的运作机制

XMLHttpRequest的运作流程如下图所示

p1

由上图可知,渲染主线程会将网络请求交给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程中的IO线程;IO线程程接收到消息之后,会将网络进程返回的数据封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数

由此可见,XMLHttpRequest是宏任务

CSS动画比JS动画效率更高的原因是:CSS动画是在合成线程上完成的,因此不会经历重绘和重排,也不会阻塞渲染主线程,同时还能使用GPU加速。综上CSS动画效率更高

所以如果我们要对元素进行动画、变换等CSS特效时,就要为其加上will-change属性,这样渲染引擎会为该元素单独分配一个图层。然后合成线程仅对该图层做相应操作就行

在“浏览器的页面渲染进程”一篇中我们阐述了浏览器将HTML、CSS、JS转化为页面的完整流程。本篇我们将更进一步讨论,在“构建DOM树”阶段JS和CSS如何影响DOM树的构建

JS对构建DOM树的影响

在此我们会先阐述HTML解析器如何将HTML文档解析为DOM树,再讨论JS代码对构建DOM树的影响

不过在此之前,先明晰一个问题————HTML 解析器是等整个 HTML 文档加载完成之后开始解析的,还是随着 HTML 文档边加载边解析的?

答案是:HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。具体过程如下

网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是text/html,那么浏览器进程就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程便经由渲染进程的IO线程向渲染进程的消息队列添加一个DOM解析任务,同时网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。你可以把这个管道想象成一个“水管”,网络进程接收到的字节流像水一样倒进这个“水管”,而“水管”的另外一端是渲染进程的 HTML 解析器,它会动态接收字节流,并将其解析为 DOM

HTML解析器的工作原理

HTML解析器将HTML字节流解析为DOM的过程如下图:

p1

由上图可知,解析过程分为三个阶段。下面分别阐述:

1、通过分词器将字节流转换为 Token

同“编译、解释和执行代码”一篇中一样,这儿也需要解析为Token

这儿的Token分为 Tag Token 和文本 Token。上述 HTML 代码通过词法分析生成的 Token 如下所示

p2

由图可以看出,Tag Token 又分 StartTag 和 EndTag,比如就是 StartTag ,就是EndTag,分别对于图中的蓝色和红色块,文本 Token 对应的绿色块

2、第二和第三阶段

二和三阶段是同步进行的,这儿一起阐述

HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:

  • 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点
  • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点
  • 如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成

通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到DOM树构建完成

下面举一个示例

1
2
3
4
5
6
7

<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>

p3

HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构

JS如何影响DOM树的构建

内嵌script标签的JS代码如何影响

如下代码所示,我们通过script标签内嵌了一段JS代码,它又会怎么影响DOM树的构建呢?

1
2
3
4
5
6
7
8
9
10
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>

通过前面“HTML解析器的工作原理”我们很容易知道当解析到script标签时,DOM树结构如下所示:

p4

解析到script标签时,HTML解析器判断这是一段脚本,因此解析器便会暂停解析,然后JS引擎执行这段脚本(因为JS代码可能要更改当前生成的DOM结构)。因为这段 JavaScript 脚本修改了 DOM 中第一个 div 中的内容,所以执行这段脚本之后,div 节点内容已经修改为 time.geekbang 了。脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM

从外部引入JS代码如何影响

如下述代码所示,我们从外部引入JS代码,那么此时这段JS代码又将如何影响DOM的构建呢?

1
2
3
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
1
2
3
4
5
6
7
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>

在此之前,先明晰一点————现在chrome浏览器(还包括其他很多浏览器)实现了HTML预解析,即渲染引擎收到HTML字节流时便会开启一个预解析HTML的线程,目的是查找HTML中的外链脚本、样式表和其他如图片等网络资源以提前下载它们

明晰了预解析,我们再来看上述代码。同内嵌script标签的JS代码一样,当HTML解析器解析到外链script标签时也会暂停解析,然后执行这段脚本。但不同的是,在执行前需要先下载这段JS脚本。如果发现预解析已经下载好了这段脚本,那么就暂停DOM解析执行JS,如果预解析还没下载好了这段脚本,则暂停DOM解析等待脚本下载(除非使用defer/async)并执行完成再继续解析DOM。因此在这种情况下,DOM解析会被执行JS代码所阻塞,可能会被下载JS代码所阻塞

对于外部引入JS造成的阻塞有什么优化办法呢?如下:

1、使用 CDN 来加速 JavaScript 文件的加载以避免出现解析到script时,预解析还没下载好脚本,而导致下载JS所造成的阻塞

2、压缩 JavaScript 文件的体积,同样是为了预解析能在解析到该脚本前下载好JS,以免下载JS所造成的阻塞

3、如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 asyncdefer 来标记代码,以避免下载JS造成的阻塞。不过值得注意的是——由于async脚本加载完成后会被立即执行,因此执行时可能阻塞DOM解析;而defer等DOM构建好后才执行,因此其执行不会阻塞DOM解析

1、使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。2、DOMContentLoaded事件发生后,说明页面已经构建好 DOM 了,这意味着构建 DOM 所需要的 HTML 文件、JavaScript 文件、CSS 文件都已经下载完成了

CSS如何影响DOM树的构建

这一小节从两方面进行阐述——在页面没有JS的情况下CSS如何影响DOM树的构建、在页面有JS的情况下CSS如何影响DOM树的构建

在页面没有JS的情况下CSS如何影响DOM树的构建

如下代码所示

1
2
3
4
5
//theme.css
div{
color : coral;
background-color:black
}
1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<link href="theme.css" rel="stylesheet">
<style>
......
</style>
</head>
<body>
<div>geekbang com</div>
</body>
</html>

上述页面仅有CSS文件,没有JS。这种情况下的页面渲染流程如下:

p5

注意上图中的两个空闲时间:1、请求 HTML 数据和构建 DOM 中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。2、就是在 DOM 构建结束之后,预解析的CSS文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要 CSSOM 和 DOM,所以这里需要等待 CSS 加载结束并解析成 CSSOM

由上图可见,当HTML字节流一到便开始构建DOM且预解析也会请求外链CSS。当DOM构建完毕后便结合内嵌CSS与外链CSS(如果外链CSS还未下载好则等待)创建CSSOM(即styleSheets),然后执行后续步骤

在页面有JS的情况下CSS如何影响DOM树的构建

这是各种情况的集大成者,因为页面中既有CSS,又有JS,是开发中的真实情况。如下代码所示

1
2
3
4
5
//theme.css
div{
color : coral;
background-color:black
}
1
2
//foo.js
console.log('time.geekbang.org')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

<html>
<head>
<link href="theme.css" rel="stylesheet">
<style>
......
</style>
</head>
<body>
<div>geekbang com</div>
<script src='foo.js'></script>
<script type="text/javascript">
......
</script>
<div>geekbang com</div>
</body>
</html>

这种情况下的页面渲染流程如下:

p6

由上图可知,预解析同样会先下载外链CSS与JS。在HTML解析器构建DOM的过程中如果遇见了script标签(无论是外链还是内嵌),都会暂停解析,但不会立即执行JS,因为JS代码可能操纵了CSS。所以当待所有外链CSS下载好后,再结合内嵌CSS构建CSSOM。待CSSOM构建好后才开始执行当前解析到的JS代码。如果后面还有script标签,则下载(如果是外链的话)执行即可(即按照“JS如何影响DOM树的构建”进行)

1、为什么必须要构建好CSSOM后才执行JS呢?因为JS可能操纵了CSS样式。2、在既有JS又有CSS的情况下记住一个原则————遇见JS不会马上执行、必须等CSSOM构建好后才执行JS

优化策略

上述所述的渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验,所以我们分析渲染流水线的目的就是为了找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。策略如下

  • 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了
  • 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件
  • 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 async 或者 defer
  • 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件

本篇包含的内容为浏览器消息队列、事件循环、宏任务和微任务

消息队列和事件循环运行原理

浏览器的消息队列和事件循环机制如下图所示

p1

下面先分别对消息队列和IO线程进行讲解(渲染主线程已经说过了)

1、消息队列

消息队列是一个队列数据结构,用于存储等待被主线程执行的任务。它符合队列先进先出的原则,也就是说要添加任务的话,添加到队尾;要取出任务的话,从队头去取

2、IO线程

他是渲染进程中的一个线程。专门用来接收其他进程传进来的消息,并将接收到的消息组装成任务放入消息队列以待主线程执行

其他进程通过IPC(跨进程通讯)的方式与IO线程交互

下面我们总体描述上图流程:

由渲染进程外的其他进程产生的任务则通过IPC交由渲染进程中的IO线程,再由IO线程将这些任务放入消息队列以待主线程执行

由渲染进程内的其他线程产生的任务直接由该线程本身交给消息队列以待主线程执行

1、消息队列中包含的任务包括:事件、文件操作、websocket、JS执行、DOM解析、样式计算、布局计算、CSS动画等等。2、渲染主线执行的所有任务都来自于消息队列,包括DOM解析、样式计算等

宏任务与微任务

宏任务与微任务产生的原因

由于消息队列先进先出的特性,后面的任务必须要等到前面的任务执行完毕才能被执行。由此便带来了下述问题:

1、如何处理高优先级任务

比如一个典型的场景是监控DOM节点的变化

有两种不太友好的处理方法:(1)、当高优先级任务产生后便立即中断主线程正在执行的任务,转而执行高优先级任务。待高优先级任务执行完后再接着执行被中断的任务。这样做的问题是当前任务的执行时间被拉长,从而导致执行效率下降。(2)、将高优先级任务放入消息队列末尾。这么做的问题是,倘若前面有很多任务在等着被执行那么就不能及时处理高优先级任务

因此为了解决高优先级任务的处理问题,便诞生了宏任务与微任务————我们将消息队列中的任务称为宏任务,而每个宏任务都有一个自己的微任务队列。当有高优先级任务产生时,便将高优先级任务放入主线程正在执行的那个宏任务的微任务队列中。待主线程将当前任务执行完毕后,再执行当前任务的微任务队列中的高优先级任务。这样既保证了当前任务的执行效率,又保证了高优先级任务能被及时处理

2、如何处理单个任务执行时长过久的问题(与宏/微任务无关)

若某个任务要执行很长的时间,那么后面的任务要等待很久才能被执行,从而造成页面卡顿

针对这种情况,JS可通过回调功能来规避这种问题,也就是让要执行的JS任务滞后执行(我们在后面详细讨论回调功能)

详解微任务

宏任务比较简单——消息队列中的任务就是宏任务(比如定时器和Ajax都是宏任务)下面我们来详细讨论微任务

主要从两方面聚焦微任务:微任务的产生、微任务的执行时机

1、微任务的产生

常见的微任务有MutationObserverPromise.then(或.reject/resolve) 以及以 Promise 为基础开发的其他技术(比如fetch API), 还包括 V8 的垃圾回收过程

2、微任务的执行时机

当当前宏任务执行完成,会检查其中的微任务队列,如果为空则直接执行下一个宏任务,如果不为空,则依次执行微任务,执行完成才去执行下一个宏任务。

下面举一个例子来加深对宏任务与微任务的理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log('start');
setTimeout(() => {
console.log('timeout');
});
Promise.resolve().then(() => {
console.log('resolve');
});
console.log('end');

/***打印结果为***/
start
end
resolve
timeout
  • 刚开始整个脚本作为一个宏任务来执行,因此先打印start和end
  • setTimeout 作为一个宏任务放入宏任务队列
  • Promise.then作为一个为微任务放入到微任务队列
  • 当本次宏任务执行完,检查微任务队列,发现一个Promise.then, 执行
  • 接下来进入到下一个宏任务——setTimeout, 执行

本节我们详述JS的垃圾回收机制。我们知道原始类型存在栈中,引用类型存在堆中,所以我们从调用栈中的垃圾回收和堆中的垃圾回收两个方面进行阐释

调用栈中的垃圾回收

调用栈中的垃圾回收机制是这样的:当一个函数被调用执行时,其执行上下文便被入栈。此时栈顶指针ESP便会指向被压入栈的这个函数的执行上下文,表示正在执行此函数。函数执行完毕后ESP便“–”,这样当后面有其他的函数执行上下文入栈时,ESP++便覆盖掉了上一个函数执行上下文所用的空间,从而实现垃圾回收

1
2
3
4
5
6
7
8
9
10
function foo(){
var a = 1
var b = {name:"极客邦"}
function showName(){
var c = 2
var d = {name:"极客时间"}
}
showName()
}
foo()

当执行到第六行代码时,调用栈和堆空间的状态如下:

p1

showName函数执行完毕后的调用栈如图所示:

p2

堆中的垃圾回收

V8将堆分为新生区和老生区两个区。新生区存放的是生存时间短的对象和内存占用小的对象。老生区中存放的是生存时间久的对象和内存占用大的对象

p3

对于新生区我们用副垃圾回收器进行垃圾回收;对于老生区我们用主垃圾回收器进行回收

而无论是主垃圾回收器还是副垃圾回收器,他们的工作流程都是一样的。如下:

1、标记。标记空间中的活动对象(即还在使用的对象)和非活动对象(即未使用的可回收对象)

2、清理。标记完成后回收掉所有非活动对象的空间

3、整理。由于回收的区域可能是零散分布的,这样便会形成大量不连续的空间,我们把这些不连续的空间称为内存碎片。当内存中出现大量的内存碎片后,如果需要分配较大连续内存时便可能出现内存不足的情况。所以我们要整理这些零散的空闲区域为一片连续区域(这一步是可选的,如副垃圾回收器便没有这一步)

下面分别详述新生区的副垃圾回收器和老生区的主垃圾回收器

副垃圾回收器

副垃圾回收器的工作原理如下:

其采用Scavenge算法,该算法将新生区分为“对象区域”和“空闲区域”两个区域

p4

新来的数据首先被放入对象区,待对象区快满时便启动副垃圾回收器进行回收操作。垃圾回收器首先执行标记阶段。然后进入整理阶段——将活动对象复制到空闲区并同时将他们依次有序排列(这相当于整理碎片)。复制完成后便对调对象区与空闲区(即原来的对象区变为空闲区,空闲区变为对象区)

还应知道的是 经过两次垃圾回收依然存活的对象会被移入老生区(这被称为对象晋升策略)

主垃圾回收器

主垃圾回收器工作原理如下:

1、标记阶段

遍历调用栈中的各执行上下文,并以执行上下文为根节点向下遍历该执行上下文中的引用值对堆区间的引用,能到达的引用为活动对象并标记

如最开始的那段代码,当showName函数执行推出之后,调用栈和堆空间如下所示

p5

2、清理和整理阶段

经标记阶段后,未被标记的对象便为非活动对象。然后移动所有非活动对象到一端而清理掉边界以外的内存

p6

本篇主要阐述代码是如何被V8执行的。涉及的内容包括:编译器、解释器、抽象语法树(AST)、字节码(Bytecode)、即时编译(JIT)

在此我们先给出V8执行代码的整体流程,然后再分别阐述流程中的每一步。流程图如下:

p1

下面我们对上图进行分步阐述:

生成AST和执行上下文

执行上下文已经说过了便不再赘述,仅详细说明AST

什么是AST

编译器或解释器(Ignition)是不能直接理解高级语言的,它们仅理解AST,所以我们要先将代码转换为AST。你可以通过下述代码和图来直观感受什么是AST

1
2
3
4
5
6
var myName = "极客时间"
function foo(){
return 23;
}
myName = "geektime"
foo()

p2

1、Babel的工作原理就是将ES6源码转换为AST,然后再将ES6语法的AST转换为ES5语法的AST,最后利用ES5的AST生成JS源码。2、语法检查工具ESlint也是利用AST来检查代码规范化问题

如何生成AST

AST的生成步骤如下

1、分词(词法分析)

首先进行分词,即将一行行的源码拆解成一个个token,所谓token是指语法上不可再分的、最小的单个字符或字符串。如下图:

p3

2、语法分析

现将上一步生成的token数据根据语法规则转换为AST。如果源码符合语法规则,这一步就会顺利完成。如果源码存在语法错误,这一步就会终止并抛出语法错误(这就是在控制台看到的语法报错)

生成字节码

V8中的解释器Ignition根据AST生成字节码

什么是字节码呢?字节码是介于AST和机器码之间的一种代码。解释器将字节码转换为机器码后便可执行

相比于机器码,字节码占用的空间更小。如下图所示:

p4

执行代码

生成字节码后,Ignition便开始逐行解释字节码为机器码并交由计算机执行(解释一行,执行一行)

Ignition既负责生成字节码,又负责解释字节码为机器码

在Ignition解释执行字节码的过程中,如果发现有热点代码(Hotspot)(一段代码被重复执行多次就被称为热点代码),那么V8后台的编译器TurboFan就会将该段热点代码编译为机器码,等下次再执行这段代码时,便直接执行机器码而不用再由Ignition解释为机器码。这样优化便可提升代码执行效率

这种字节码配合解释器(Ignition)并由编译(TurboFan)后台优化的组合便被称为即时编译(JIT)。JIT工作流程如下:

p5