浏览器

# 跨域

浏览器出于安全考虑,有同源策略。也就是说,如果协议域名或者端口有一个不同就是跨域,AJAX 请求会失败。

同源策略(Same origin policy) 是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。

同源策略又分为以下两种:

  • DOM 同源策略:禁止对不同源页面 DOM 进行操作。这里主要场景是 iframe 跨域的情况,不同域名的 iframe 是限制互相访问的。
  • XMLHttpRequest 同源策略:禁止使用 XHR 对象向不同源的服务器地址发起 HTTP 请求。

下面介绍

# CORS

CORS(Cross-origin resource sharing,跨域资源共享) 是一个 W3C 标准,定义了在必须访问跨域资源时,浏览器与服务器应该如何沟通。CORS 背后的基本思想,就是使用自定义的 HTTP 头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功,还是应该失败。

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。

浏览器将 CORS 请求分成两类:简单请求(simple request)非简单请求(not-so-simple request)

只要同时满足以下两大条件,就属于简单请求

  1. 请求方法是以下三种方法之一:
    • HEAD
    • GET
    • POST
  2. HTTP 的头信息不超出以下几种字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type: 只限于三个值 application/x-www-form-urlencoded, multipart/form-data, text/plain

凡是不同时满足上面两个条件,就属于非简单请求

浏览器对这两种请求的处理,是不一样的。

# 简单请求

  1. 在请求中需要附加一个额外的 Origin 头部,其中包含请求页面的源信息(协议、域名和端口),以便服务器根据这个头部信息来决定是否给予响应。例如:Origin: https://www.zqianduan.com
  2. 如果服务器认为这个请求可以接受,就在 Access-Control-Allow-Origin 头部中回发相同的源信息(如果是公共资源,可以回发 *),例如:Access-Control-Allow-Origin: https://www.zqianduan.com
  3. 没有这个头部或者有这个头部但源信息不匹配,浏览器就会驳回请求。正常情况下,浏览器会处理请求。注意,请求和响应都不包含 cookie 信息。
  4. 如果需要包含 cookie 信息,ajax 请求需要设置 xhr 的属性 withCredentialstrue,服务器需要设置响应头部 Access-Control-Allow-Credentials: true

# 非简单请求

浏览器在发送真正的请求之前,会先发送一个 Preflight 请求给服务器,这种请求使用 OPTIONS 方法,发送下列头部:

  • Origin: 与简单的请求相同
  • Access-Control-Request-Method: 请求自身使用的方法
  • Access-Control-Request-Headers: (可选)自定义的头部信息,多个头部以逗号分隔

例如:

Origin: https://www.zqianduan.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: NCZ

发送这个请求后,服务器可以决定是否允许这种类型的请求。服务器通过在响应中发送如下头部与浏览器进行沟通:

  • Access-Control-Allow-Origin: 与简单的请求相同
  • Access-Control-Allow-Methods: 允许的方法,多个方法以逗号分隔
  • Access-Control-Allow-Headers: 允许的头部,多个方法以逗号分隔
  • Access-Control-Max-Age: 应该将这个 Preflight 请求缓存多长时间(以秒表示)

例如:

Access-Control-Allow-Origin: https://www.zqianduan.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: NCZ
Access-Control-Max-Age: 1728000

一旦服务器通过 Preflight 请求允许该请求之后,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样了。

优点

  • CORS 通信与同源的 AJAX 通信没有差别,代码完全一样,容易维护
  • 支持所有类型的 HTTP 请求

缺点

  • 存在兼容性问题,特别是 IE10 以下的浏览器
  • 第一次发送非简单请求时会多一次请求

在请求的首部中若携带了 Cookie 信息,则响应首部应符合以下特征,否则请求将会失败:

  • 服务器不能将 Access-Control-Allow-Origin 的值设为通配符 *,而应将其设置为特定的域,如:Access-Control-Allow-Origin: https://example.com
  • 服务器不能将 Access-Control-Allow-Headers 的值设为通配符 *,而应将其设置为首部名称的列表,如:Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
  • 服务器不能将 Access-Control-Allow-Methods 的值设为通配符 *,而应将其设置为特定请求方法名称的列表,如:Access-Control-Allow-Methods: POST, GET

# JSONP

由于 script 标签不受浏览器同源策略的影响,允许跨域引用资源。因此可以通过动态创建 script 标签,然后利用 src 属性进行跨域,这也就是 JSONP 跨域的基本原理。

直接通过下面的例子来说明 JSONP 实现跨域的流程:

// 1. 定义一个 回调函数 handleResponse 用来接收返回的数据
function handleResponse(data) {
    console.log(data);
};

// 2. 动态创建一个 script 标签,并且告诉后端回调函数名叫 handleResponse
var body = document.getElementsByTagName('body')[0];
var script = document.gerElement('script');
script.src = 'https://www.zqianduan.cn/json?callback=handleResponse';
body.appendChild(script);

// 3. 通过 script.src 请求 `https://www.zqianduan.cn/json?callback=handleResponse`,
// 4. 后端能够识别这样的 URL 格式并处理该请求,然后返回 handleResponse({"name": "zqianduan"}) 给浏览器
// 5. 浏览器在接收到 handleResponse({"name": "zqianduan"}) 之后立即执行 ,也就是执行 handleResponse 方法,获得后端返回的数据,这样就完成一次跨域请求了。

封装成通用方法:

/**
 * JSONP 跨域通用方法封装
 * @param url 接口域名及路径
 * @param params 参数
 * @param callback 回调函数
 */
const loadJsonp = (function() {
    const seq = +new Date();
    const head = document.getElementByTag('head');
    const script = document.createElement('script');

    return (url, params = {}, callback) => {
        const funName = `XJsonp-${seq++}`;
        params.callback = funName;

        for (let key in params) {
            url = `${url}${/\?/.test(url) ? '&' : '?'}${key}=${encodeURIComponent(params[key])}`;
            // url += (/\?/.test(url) ? '&' : '?') + key + '=' + encodeURIComponent(params[key]);
        }

        window[funName] = function (resp) {
            window[funName] = undefined;

            try {
                delete window[funName];
            } catch (err) {}

            if (head) {
                head.removeChild(script);
            }

            callback(resp);
        }

        script.src = url;
        script.charset = 'UTF-8';
        head.appendChild(script);
    }
}());

优点

  • 使用简便,没有兼容性问题,目前最流行的一种跨域方法。

缺点

  • 只支持 GET 请求
  • 由于是从其它域中加载代码执行,因此如果其他域不安全,很可能会在响应中夹带一些恶意代码
  • 要确定 JSONP 请求是否失败并不容易。虽然 HTML5 给 script 标签新增了一个 onerror 事件处理程序,但是存在兼容性问题。

# window.name + iframe

window.name 在一个窗口(标签)的生命周期之内是共享的,利用这点结合 iframe:当在 iframe 中加载新页面时,name 的属性值依旧保持不变。

总结起来即:iframesrc 属性由外域转向本地域,跨域数据即由 iframewindow.name 从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

需要3个文件:a.com/a.html, a.com/proxy.html, b.com/b.html

<!-- a.com/a.html -->
<script type="text/javascript">
    var state = 0,
        iframe = document.createElement('iframe'),
        loadfn = function() {
            if (state === 1) {
                var data = iframe.contentWindow.name;    // 读取数据
                alert(data);    //弹出'I was there!'
            }
            else if (state === 0) {
                state = 1;
                iframe.contentWindow.location = 'http://a.com/proxy.html';    // 设置的代理文件
            }
        };

    iframe.src = 'http://b.com/b.html';

    if (iframe.attachEvent) {
        iframe.attachEvent('onload', loadfn);
    }
    else {
        iframe.onload  = loadfn;
    }

    document.body.appendChild(iframe);
</script>
<!-- b.com/b.html -->
<script type="text/javascript">
    window.name = 'I was there!';
    // 这里是要传输的数据,大小一般为 2M,IE 和 firefox 下可以大至 32M 左右
    // 数据格式可以自定义,如 json、字符串
</script>

proxy 是一个代理文件,空的就可以,需要和 a 在同一域下

# location.hash + iframe

这个办法比较绕,把数据的变化显示在 url 的 hash 里面。原理是利用 location.hash 来进行传值。但是由于 chrome 和 IE 不允许修改 parent.location.hash 的值,所以需要再加一层。

a.htmlb.html 进行数据交换:

<!-- a.com/a.html -->
<script>
    function startRequest() {
        var ifr = document.createElement('iframe');
        ifr.style.display = 'none';
        ifr.src = 'http://b.com/b.html#paramdo';
        document.body.appendChild(ifr);
    }

    function checkHash() {
        try {
            var data = location.hash ? location.hash.substring(1) : '';
            if (console.log) {
                console.log('Now the data is '+data);
            }
        }
        catch(e) {};
    }
    setInterval(checkHash, 2000);
</script>
<!-- b.com/b.html -->
<script>
    // 模拟一个简单的参数处理操作
    switch(location.hash){
        case '#paramdo':
            callBack();
            break;
        case '#paramset':
            //do something……
            break;
    }

    function callBack() {
        try {
            parent.location.hash = 'somedata';
        }
        catch (e) {
            // ie、chrome 的安全机制无法修改 parent.location.hash,
            // 所以要利用一个中间域下的代理 iframe
            var ifrproxy = document.createElement('iframe');
            ifrproxy.style.display = 'none';
            ifrproxy.src = 'http://a.com/c.html#somedata'; // 注意该文件在 a.com 域下
            document.body.appendChild(ifrproxy);
        }
    }
</script>
<!-- a.com/c.html -->
<script>
    // 因为 parent.parent 和自身属于同一个域,所以可以改变其 location.hash 的值
    parent.parent.location.hash = self.location.hash.substring(1);
</script>

# document.domain + iframe

对于主域相同而子域不同的例子,可以通过设置 document.domain 的办法来解决。具体的做法如下:

a.com/a.html1.a.com/b.html 两个文件中分别加上 document.domain = 'a.com'; 然后通过 a.html 文件中创建一个 iframe,去控制 iframecontentDocument,当然这种办法只能解决主域相同而二级域名不同的情况。

<!-- a.com/a.html -->
<iframe id='i' src="1.a.com" onload="do()"></iframe>
<script>
    document.domain = 'a.com';
    document.getElementById('i').contentWindow;
</script>
<!-- 1.a.com/b.html -->
<script>
    document.domain = 'a.com';
</script>

这样,就可以解决问题了。值得注意的是:document.domain 的设置是有限制的,只能设置为页面本身或者更高一级的域名。利用这种方法是极其方便的,但是如果一个网站被攻击之后另外一个网站很可能会引起安全漏洞。

# postMessage

window.postMessage(message,targetOrigin) 方法是 HTML5 新引进的特性,可以使用它来向其它的 window 对象发送消息,无论这个 window 对象是属于同源或不同源。这个应该就是以后解决 dom 跨域通用方法了。

调用 postMessage 方法的 window 对象是指要接收消息的那一个 window 对象,该方法的第一个参数 message 为要发送的消息,类型只能为字符串;第二个参数 targetOrigin 用来限定接收消息的那个 window 对象所在的域,如果不想限定域,可以使用通配符 *

需要接收消息的 window 对象,可是通过监听自身的 message 事件来获取传过来的消息,消息内容储存在该事件对象的 data 属性中。

页面 https://www.zqianduan.com/a.html 的代码:

<iframe src="https://zqianduan.com/b.html" id="myIframe" onload="test()" style="display: none;">
<script>
    // 1. iframe载入 "https://zqianduan.com/b.html 页面后会执行该函数
    function test() {
        // 2. 获取 https://zqianduan.com/b.html 页面的 window 对象,
        // 然后通过 postMessage 向 https://zqianduan.com/b.html 页面发送消息
        var iframe = document.getElementById('myIframe');
        var win = iframe.contentWindow;
        win.postMessage('我是来自 https://www.zqianduan.cn/a.html 页面的消息', '*');
    }
</script>

页面 https://zqianduan.com/b.html 的代码:

<script type="text/javascript">
    // 注册 message 事件用来接收消息
    window.onmessage = function(e) {
        e = e || event; // 获取事件对象
        console.log(e.data); // 通过 data 属性得到发送来的消息
    }
</script>

# 服务器代理

浏览器有跨域限制,但是服务器不存在跨域问题,所以可以由服务器请求所有域的资源再返回给客户端。

服务器代理是万能的。

# 参考资料

# 存储

Cookie 是小甜饼的意思。顾名思义,cookie 确实非常小,它的大小限制为 4KB 左右,是网景公司的前雇员 Lou Montulli 在1993年3月的发明。它的主要用途有保存登录信息,比如你登录某个网站市场可以看到「记住密码」,这通常就是通过在 Cookie 中存入一段辨别用户身份的数据来实现的。

Cookie 曾一度用于客户端数据的存储,因当时并没有其它合适的存储办法而作为唯一的存储手段,但现在随着现代浏览器开始支持各种各样的存储方式,Cookie 已经不建议用于存储。由于服务器指定 Cookie 后,浏览器的每次请求都会携带 Cookie 数据,会带来额外的性能开销(尤其是在移动环境下)。

生成方式

  1. http response header 中的 set-cookie
  2. js 中可以通过 document.cookie 可以读写 cookie,以键值对的形式展示

属性

属性 作用
name Cookie 名称
value 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
path 允许访问此 Cookie 的页面路径
domain 允许访问此 Cookie 的域名
expires/max-age 指定 Cookie 的生存期
http-only 不能通过 JS 访问 Cookie,减少 XSS 攻击
secure 只能在协议为 HTTPS 的请求中携带
same-site 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

如果不设置 expires/max-age 这个 cookie 默认是 Session 的,也就是关闭浏览器该 cookie 就消失了

其中 sameSite 可以设置 3 类值:

  • Strict: 严格模式,完全禁止第三方 Cookie,跨站点时,任何情况下都不会发送 Cookie
  • Lax: 宽松模式,大多数情况不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外
  • None: Chrome 计划将 Lax 变为默认设置。这时,网站可以选择显式关闭 SameSite 属性,将其设为 None。不过,前提是必须同时设置 Secure 属性(Cookie 只能通过 HTTPS 协议发送),否则无效

示例:

Set-Cookie: sessionid=aes7a8; HttpOnly; Path=/
document.cookie = "KMKNKK=1234;Sercure"

参考资料:

# localStorage

localStorage 是 HTML5 标准中新加入的技术,这个特性主要是用来作本地存储来的,解决了 cookie 占用带宽和存储空间不足的问题。

API 使用示例:

// 存
localStorage.setItem('myCat', 'Tom');

// 取
let cat = localStorage.getItem('myCat');

// 删
localStorage.removeItem('myCat');

// 清空
localStorage.clear();

# sessionStorage

sessionStorage 与 localStorage 的接口类似,但保存数据的生命周期与 localStorage 不同。做过后端开发的同学应该知道 Session 这个词的意思,直译过来是「会话」。而 sessionStorage 是一个前端的概念,它只是可以将一部分数据在当前会话中保存下来,刷新页面数据依旧存在。但当页面关闭后,sessionStorage 中的数据就会被清空。

sessionStorage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享;localStorage 在所有同源窗口中都是共享的;cookie 也是在所有同源窗口中都是共享的。

# indexDB

IndexedDB 是一种低级 API,用于客户端存储大量结构化数据(包括文件和 blobs)。该 API 使用索引来实现对该数据的高性能搜索。IndexedDB 是一个运行在浏览器上的非关系型数据库。既然是数据库了,那就不是 5M、10M 这样小打小闹级别了。理论上来说,IndexedDB 是没有存储上限的(仅受限于硬盘大小)。它不仅可以存储字符串,还可以存储二进制数据。

特点

  • 键值对存储:IndexedDB 内部采用对象仓库(object store)存放数据,所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以「键值对」的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误;
  • 异步:IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现;
  • 支持事务:IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况;
  • 同源限制:IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库;
  • 储存空间:IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限;
  • 支持二进制存储:IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

基本用法示例:

function openDB(name) {
    var request = window.indexedDB.open(name); // 建立打开 IndexedDB

    request.onerror = function (e) {
        console.log('open indexdb error');
    }

    request.onsuccess = function (e) {
        myDB.db = e.target.result;  // 这是一个 IDBDatabase 对象,这就是 IndexedDB 对象
        console.log(myDB.db);       // 此处就可以获取到 db 实例
    }
}

// 关闭 indexedDB
function closeDB(db) {
    db.close();
}

// 删除 indexedDB
function deleteDB(name) {
    indexedDB.deleteDatabase(name);
}

var myDB = {
    name: 'testDB',
    version: '1.0.0',
    db: null
}

openDB(myDB.name);

参考资料

# 区别及对比

Web 存储的 API 包括 cookie, localStorage, sessionStorage, indexDB 等,下面具体讨论他们的异同。

共同点:都是保存在浏览器端,且同源的。

区别

特性 cookie localStorage sessionStorage indexDB
生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面关闭就清理 除非被清理,否则一直存在
存储大小 4K 5M 5M 无限
数据类型 String String String Any
查询速度 200-300ms 200-300ms 200-300ms 300-350ms
与服务端通信 每次都会携带在 header 中,对于请求性能有影响 不参与 不参与 不参与

表中,存储大小为「无限」,实则是该 API 本身未做限制,但仍受限于硬盘的大小

# 缓存

# DNS 缓存

DNS,全称 Domain Name System,即域名系统。

万维网上作为域名和 IP 地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的 IP 数串。DNS 协议运行在 UDP 协议之上,使用端口号 53。

DNS 解析:通过域名,最终得到该域名对应的 IP 地址的过程叫做域名解析(或主机名解析)

www.zqianduan.com (域名) -> DNS 解析 -> 111.222.33.444 (IP地址)

有 DNS 的地方,就有缓存。浏览器、操作系统、Local DNS、根域名服务器,它们都会对 DNS 结果做一定程度的缓存。

DNS 查询过程如下:

  1. 浏览器的 DNS 缓存;
  2. 操作系统的 DNS 缓存;
  3. 路由器的 DNS 缓存;
  4. 向服务商的 DNS 服务器查询;
  5. 向全球 13 台根域名服务器查询;

# CDN 缓存

CDN,全称 Content Delivery Network,即内容分发网络。

当我们发送一个请求时,浏览器本地缓存失效的情况下,CDN 会帮我们去计算哪得到这些内容的路径短而且快。

比如在广州,请求广州的服务器比请求新疆的服务器响应速度快得多,然后向最近的 CDN 节点请求数据,CDN 会判断缓存数据是否过期,如果没有过期,则直接将缓存数据返回给客户端,从而加快了响应速度。如果 CDN 判断缓存过期,就会向服务器发出回源请求,从服务器拉取最新数据,更新本地缓存,并将最新数据返回给客户端。

CDN 不仅解决了跨运营商和跨地域访问的问题,大大降低访问延时的同时,还起到了分流的作用,减轻了源服务器的负载。

关于 CDN 缓存,在浏览器本地缓存失效后,浏览器会向 CDN 边缘节点发起请求。类似浏览器缓存,CDN 边缘节点也存在着一套缓存机制。CDN 边缘节点缓存策略因服务商不同而不同,但一般都会遵循 http 标准协议,通过 http 响应头中的 Cache-control: max-age 字段来设置 CDN 边缘节点数据缓存时间。

当浏览器向 CDN 节点请求数据时,CDN 节点会判断缓存数据是否过期,若缓存数据并没有过期,则直接将缓存数据返回给客户端;否则,CDN 节点就会向服务器发出回源请求,从服务器拉取最新数据,更新本地缓存,并将最新数据返回给客户端。CDN 服务商一般会提供基于文件后缀、目录多个维度来指定 CDN 缓存时间,为用户提供更精细化的缓存管理。

CDN 优势

  • CDN 节点解决了跨运营商和跨地域访问的问题,访问延时大大降低
  • 大部分请求在 CDN 边缘节点完成,CDN 起到了分流作用,减轻了源服务器的负载

参考资料

# HTTP 缓存

HTTP 缓存,也称浏览器缓存,其实是浏览器将 HTTP 请求获取的资源存储在本地,再次加载时直接从缓存中获取而不用请求服务器,从而获得更快响应。下图是缓存策略:

浏览器缓存策略

缓存位置

浏览器存储了资源,那它把资源存储在哪里呢?从 chrome 网络面板看,我们能看到从缓存获取的资源,sizememory cache 或者 disk cache

  • MemoryCache: 顾名思义,就是将资源缓存到内存中,等待下次访问时不需要重新下载资源,而直接从内存中获取
  • DiskCache: 顾名思义,就是将资源缓存到磁盘中,等待下次访问时不需要重新下载资源,而直接从磁盘中获取

简单来说,memory cache 是指资源从内存中被取出,disk cache 是指资源从磁盘中被取出;从内存中读取比从磁盘中快很多,但资源能不能分配到内存要取决于当下的系统状态。通常来说,刷新页面会使用内存缓存,关闭后重新打开会使用磁盘缓存

缓存类型

浏览器缓存主要有两类:协商缓存强缓存。浏览器在第一次请求发生后,再次请求时:

  1. 浏览器会先获取该资源缓存的 header 信息,根据其中的 expirescahe-control 判断是否命中强缓存,若命中则直接从缓存中获取资源,包括缓存的 header 信息,本次请求不会与服务器进行通信;
  2. 如果没有命中强缓存,浏览器会发送请求到服务器,该请求会携带第一次请求返回的有关缓存的 header 字段信息(Last-Modified/IF-Modified-SinceEtag/IF-None-Match),由服务器根据请求中的相关 header 信息来对比结果是否命中协商缓存,若命中,则服务器返回新的响应 header 信息更新缓存中的对应 header 信息,但是并不返回资源内容,它会告知浏览器可以直接从缓存获取;否则返回最新的资源内容。

# 强缓存

强缓存是利用 http 的返回头中的 Expires 或者 Cache-Control 两个字段来控制的,用来表示资源的缓存时间。

Expires:

该字段是 http1.0 时的规范,它的值为一个绝对时间的 GMT 格式的时间字符串,比如:

Expires: Mon, 18 Oct 2066 23:59:59 GMT

这个时间代表着这个资源的失效时间,在此时间之前,即命中缓存。这种方式有一个明显的缺点,由于失效时间是一个绝对时间,所以当服务器与客户端时间偏差较大时,就会导致缓存混乱

Cache-Control:

Cache-Control 是 http1.1 时出现的 header 信息,主要是利用该字段的 max-age 值来进行判断,它是一个相对时间,例如 Cache-Control: max-age=3600,代表着资源的有效期是 3600 秒。

Cache-Control 请求头常见属性:

字段 / s 说明
max-age=300 拒绝接受长于 300 秒的资源,为 0 时表示获取最新资源
max-stale=100 缓存过期之后的 100 秒内,依然拿来用
min-fresh=50 缓存到期时间还剩余 50 秒开始,就不给拿了,不新鲜了
no-cache 协商缓存验证 no-store 不使用缓存
only-if-cached 只使用缓存,没有就报 504 错误
no-transform 不得对资源进行转换或转变,Content-Encoding, Content-Range, Content-Type 等 HTTP 头不能由代理修改

Cache-Control 响应头常见属性:

字段 / s 说明
max-age=300 缓存有效期 300 秒
s-maxage=500 有效期 500 秒,优先级高于 max-age,适用于共享缓存(如 CDN)
public 可以被任何终端缓存,包括代理服务器、CDN 等
private 只能被用户的浏览器终端缓存(私有缓存)
no-cache 先和服务端确认资源是否发生变化,没有就使用
no-store 不缓存
no-transform 与上面请求指令中的一样
must-revalidate 客户端缓存过期了就向源服务器验证
proxy-revalidate 代理缓存过期了就去源服务器重新获取

字段单位为「秒」,值可自定义,此处只做示例说明用。

ExpiresCache-Control 的区别

  • Expires 是 HTTP/1.0 中的,Cache-Control 是 HTTP/1.1 中的
  • Expires 是为了兼容,在不支持 HTTP/1.1 的情况下才会发生作用
  • 两者同时存在的话 Cache-Control 优先级高于 Expires

# 协商缓存

协商缓存就是由服务器来确定缓存资源是否可用,所以客户端与服务器端要通过某种标识来进行通信,从而让服务器判断请求资源是否可以缓存访问,这主要涉及到下面两组 header 字段,这两组搭档都是成对出现的,即第一次请求的响应头带上某个字段(Last-Modified 或者 Etag),则后续请求则会带上对应的请求字段(If-Modified-Since 或者 If-None-Match),若响应头没有 Last-Modified 或者 Etag 字段,则请求头也不会有对应的字段。

Last-Modify/If-Modify-Since:

浏览器第一次请求一个资源的时候,服务器返回的 header 中会加上 Last-ModifyLast-modify 是一个时间标识该资源的最后修改时间,例如 Last-Modify: Thu, 31 Dec 2037 23:59:59 GMT

当浏览器再次请求该资源时,request 的请求头中会包含 If-Modify-Since,该值为缓存之前返回的 Last-Modify。服务器收到 If-Modify-Since 后,根据资源的最后修改时间判断是否命中缓存。

如果命中缓存,则返回 304,并且不会返回资源内容,并且不会返回 Last-Modify

ETag/If-None-Match:

Last-Modify/If-Modify-Since 不同的是,Etag/If-None-Match 返回的是一个校验码。ETag 可以保证每一个资源是唯一的,资源变化都会导致 ETag 变化。服务器根据浏览器上送的 If-None-Match 值来判断是否命中缓存。

Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

为什么要有 Etag

你可能会觉得使用 Last-Modified 已经足以让浏览器知道本地的缓存副本是否足够新,为什么还需要 Etag 呢?HTTP1.1 中 Etag 的出现主要是为了解决几个 Last-Modified 比较难解决的问题:

  • 一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新 GET;
  • 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说 1s 内修改了 N 次),If-Modified-Since 能检查到的粒度是s级的,这种修改无法判断(或者说 UNIX 记录 MTIME 只能精确到秒);
  • 某些服务器不能精确的得到文件的最后修改时间。

Last-Modified 与 ETag 是可以一起使用的,服务器会优先验证 ETag,一致的情况下,才会继续比对 Last-Modified,最后才决定是否返回 304。

强缓存与协商缓存的区别可以用下表来表示:

缓存类型 获取资源形式 状态码 发送请求到服务器
强缓存 从缓存取 200(from cache) 否,直接从缓存取
协商缓存 从缓存取 304(Not Modified) 否,通过服务器来告知缓存是否可用

# 启发式缓存

如果一个可以缓存的请求没有设置 ExpiresCache-Control,但是响应头有设置 Last-Modified 信息,这种情况下浏览器会有一个默认的缓存策略:(Date - Last-Modified)*0.1,这就是启发式缓存

注意:只有在服务端没有返回明确的缓存策略时才会激活浏览器的启发式缓存策略。

启发式缓存会引起什么问题吗

考虑一个情况,假设你有一个文件没有设置缓存时间,在一个月前你更新了上个版本。这次发版后,你可能得等到 3 天后用户才看到新的内容了。如果这个资源还在 CDN 也缓存了,则问题会更严重。

所以,要给资源设置合理的缓存时间。不要不设置缓存,也不要设置过长时间的缓存。强缓存时间过长,则内容要很久才会覆盖新版本,缓存时间过短,则可能浪费大量的带宽资源。一般带 hash 的文件缓存时间可以长一点。

# 用户行为对缓存的影响

用户操作 Expires/Cache-Control Last-Modied/Etag
地址栏回车 有效 有效
页面链接跳转 有效 有效
新开窗口 有效 有效
前进回退 有效 有效
F5 刷新 无效 有效
Ctrl+F5 强制刷新 无效 无效

# 事件机制

# 事件触发

W3C 标准的事件模型,一共有3个过程:事件捕获阶段事件处理阶段事件冒泡阶段

  1. window 往事件触发处传播,遇到注册的捕获事件会触发
  2. 传播到事件触发处时触发注册的事件
  3. 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个目标节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。比如:

// 对于同时绑定了`捕获`&`冒泡`事件的元素本身,事件的执行顺序只取决于事件添加的先后顺序
// 以下会先打印冒泡然后是捕获
node.addEventListener(
    'click',
    event => {
        console.log('冒泡');
    },
    false
);

node.addEventListener(
    'click',
    event => {
        console.log('捕获');
    },
    true
);

# 事件注册

通常我们使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 falseuseCapture 决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性:

  • capture: 布尔值,和 useCapture 作用一样
  • once: 布尔值,值为 true 表示该回调只会调用一次,调用后会移除监听
  • passive: 布尔值,表示永远不会调用 preventDefault

一般来说,我们只希望事件只触发在目标上,这时候可以使用 stopPropagation 来阻止事件的进一步传播。通常我们认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation 同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。

node.addEventListener(
    'click',
    event => {
        event.stopImmediatePropagation()
        console.log('冒泡')
    },
    false
);

// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
    'click',
    event => {
        console.log('捕获 ')
    },
    true
);

# 事件代理

如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上:

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<script>
    let ul = document.querySelector('#ul');
    ul.addEventListener('click', event => {
        console.log(event.target);
    });
</script>

事件代理是利用事件冒泡的原理,让父元素代理子元素的事件监听,对于直接给目标注册事件来说,有以下优点:

  • 节省内存
  • 不需要给子节点注销事件

# 自定义事件

事件本质是一种通信方式,是一种消息,只有在多对象多模块时,才有可能需要使用事件进行通信。在多模块化开发时,可以使用自定义事件进行模块间通信。

目前实现自定义事件的两种主要方式是:

  • Event()
  • CustomEvent()

在介绍这两种方式之前,我们还得简单了解一下另外两个方法 —— addEventListenerdispatchEvent

addEventListener:

顾名思义,这个方法的作用就是添加事件监听。当我们生成新的事件之后,得有这个方法进行监听,系统才能知道我们有没有触发该事件。

target.addEventListener(type, listener, options);
  • target: 事件目标,可以是一个文档上的元素 Element, DocumentWindow 或者任何其他支持事件的对象
  • type: 表示监听事件类型的字符串,必选项
  • listener: 一个函数,再事件被触发之后,必选项
  • options: 一个指定有关 listener 属性的可选参数对象

具体可参考文档 (opens new window)

dispatchEvent:

触发被我们定义的事件。

target.dispatchEvent(event)
  • target: 可以是一个文档上的元素 Element, DocumentWindow 或者任何其他支持事件的对象
  • event: 要被派发的事件对象

具体可参考文档 (opens new window)

# Event() (opens new window)

语法及参数:

const myEvent = new Event(eventName, eventOptions);
  • eventName: String 类型,必选项,表示事件的名称
  • eventOptions: Object 类型,事件的可选配置项

eventOptions:

字段 说明 类型 默认值
bubbles 事件是否冒泡 Boolean false
cancelable 事件是否能被取消 Boolean false
composed 事件是否会在影子 DOM 根节点之外触发侦听器 Boolean false

示例:

// 创建一个支持冒泡且不能被取消的 look 事件
const ev = new Event('look', { 'bubbles': true, 'cancelable': false });

document.addEventListener('look', print);
// 事件可以在任何元素触发,不仅仅是 document
document.dispatchEvent(ev);

function print() {
    console.log('内容色情低俗');
}
// result:内容色情低俗。

# CustomEvent() (opens new window)

语法及参数:

const myEvent = new CustomEvent(eventName, eventOptions);
  • eventName: String 类型,必选项,表示事件的名称
  • eventOptions: Object 类型,事件的可选配置项

eventOptions:

字段 说明 类型 默认值
detail 事件中需要被传递的数据 Any null
bubbles 事件是否冒泡 Boolean false
cancelable 事件是否能被取消 Boolean false

示例:

const ev = new CustomEvent('cat', { detail: { name: '奥特曼' } });

document.addEventListener('cat', print);
document.dispatchEvent(ev);

function print(e) {
    console.log(e.detail.name);
}

EventCustomEvent 最大的区别在于,CustomEvent 可以传递数据。

# 实际运用

事件模式本质上和观察者模式是相同的,所以凡是能够用到观察者这种模式的情形,就可以使用自定义事件来解决。

举个例子,假设有一个数组 arr,每当这个数组添加新的有效元素之后,我们都要重新打印它一次:

let arr = [];

function add(detail) {
    if (!detail) return;

    arr.push(detail);
    document.dispatchEvent(
        new CustomEvent('onAdd', {
            detail: arr
        })
    );
}

// 监听添加事件
document.addEventListener('onAdd', (e) => {
    console.log(e.detail);
});

// 触发事件
add();
add(10);

在不需要监听这个事件时,需记得通过 removeEventListener(用法和 addEventListener 相同)取消对这个事件的监听。

利用 CustomEvent 实现观察者模式:

// Internet Explorer 9 and higher
_CustomEventPolyfill();

const TARGET = window;
const EVENTS = {};

/**
 * @param {String} eventName
 * @param {Object} detail
 */
function _dispatchEvent(eventName, detail) {
    const event = new CustomEvent(eventName, {
        detail: detail,
    });

    TARGET.dispatchEvent(event);
}

function _CustomEventPolyfill() {
    if (typeof window.CustomEvent === 'function') {
        return;
    }

    function CustomEvent(event, params) {
        const evt = document.createEvent('CustomEvent');

        params = params || { bubbles: false, cancelable: false, detail: undefined };
        evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);

        return evt;
    }

    CustomEvent.prototype = window.Event.prototype;
    window.CustomEvent = CustomEvent;
}

module.exports = {
    /**
     * @param {String} eventName
     * @param {Function} callback
     */
    on: function (eventName, callback) {
        if (EVENTS[eventName]) {
            EVENTS[eventName].callbacks.push(callback);
        } else {
            EVENTS[eventName] = {
                callbacks: [callback],
            };
        }

        TARGET.addEventListener(eventName, callback);
    },

    /**
     * @param {String} eventName
     */
    off: function (eventName, callback) {
        if (!EVENTS[eventName]) {
            return;
        }

        if (callback) {
            TARGET.removeEventListener(eventName, callback);

            EVENTS[eventName].callbacks.length && delete EVENTS[eventName];
            return;
        }

        EVENTS[eventName].callbacks.forEach((callback) => {
            TARGET.removeEventListener(eventName, callback);
        });

        delete EVENTS[eventName];
    },

    /**
     * @param {String} eventName
     * @param {Object} detail
     */
    dispatch: function (eventName, detail) {
        _dispatchEvent(eventName, detail || null);
    },
};

# 渲染机制

浏览器是多进程的工作的,「从 URL 输入到渲染」会主要涉及到的,是浏览器进程网络进程渲染进程这三个:

  • 浏览器进程负责处理、响应用户交互,比如点击、滚动;
  • 网络进程负责处理数据的请求,提供下载功能;
  • 渲染进程负责将获取到的 HTML、CSS、JS 处理成可以看见、可以交互的页面;

「从 URL 输入到页面渲染」整个过程可以分成网络请求浏览器渲染两个部分,分别由网络进程渲染进程去处理。

# 网络请求

网络请求部分进行了这几项工作:

  1. URL 的解析
  2. 检查资源缓存
  3. DNS 解析
  4. 建立 TCP 连接
  5. TLS 协商密钥
  6. 发送请求&接收响应
  7. 关闭 TCP 连接

接下来会一一展开。

# URL的解析

浏览器首先会判断输入的内容是一个 URL 还是搜索关键字。

如果是 URL,会把不完整的 URL 合成完整的 URL。一个完整的URL应该是:协议+主机+端口+路径[+参数][+锚点]。比如我们在地址栏输入 www.baidu.com,浏览器最终会将其拼接成 https://www.baidu.com/,默认使用 443 端口。

如果是搜索关键字,会将其拼接到默认搜索引擎的参数部分去搜索。这个流程需要对输入的不安全字符编码进行转义(安全字符指的是数字、英文和少数符号)。因为 URL 的参数是不能有中文的,也不能有一些特殊字符,比如 = ? &,否则当我搜索 1+1=2,假如不加以转义,url 会是 /search?q=1+1=2&source=chrome,和 UR L本身的分隔符 = 产生了歧义。

URL 对非安全字符转义时,使用的编码叫百分号编码,因为它使用百分号加上两位的 16 进制数表示。这两位 16 进制数来自 UTF-8 编码,将每一个中文转换成 3 个字节,比如我在 google 地址栏输入「中文」,url 会变成 /search?q=%E4%B8%AD%E6%96%87,一共 6 个字节。

我们在写代码时经常会用的 encodeURIencodeURIComponent 正是起这个作用的,它们的规则基本一样,只是 = ? & ; / 这类 URI 组成符号,这些在 encodeURI 中不会被编码,但在 encodeURIComponent 中统统会。因为 encodeURI 是编码整个 URL,而 encodeURIComponent 编码的是参数部分,需要更加严格把关。

# 检查缓存

检查缓存一定是在发起真正的请求之前进行的,只有这样缓存的机制才会生效。如果发现有对应的缓存资源,则去检查缓存的有效期。

在有效期内的缓存资源直接使用,称之为强缓存,从 chrome 网络面板看到这类请求直接返回 200sizememory cache 或者 disk cachememory cache 是指资源从内存中被取出,disk cache 是指资源从磁盘中被取出;从内存中读取比从磁盘中快很多,但资源能不能分配到内存要取决于当下的系统状态。通常来说,刷新页面会使用内存缓存关闭后重新打开会使用磁盘缓存

超过有效期的,则携带缓存的资源标识向服务端发起请求,校验是否能继续使用,如果服务端告诉我们,可以继续使用本地存储,则返回 304,并且不携带数据;如果服务端告诉我们需要用更新的资源,则返回 200,并且携带更新后的资源和资源标识缓存到本地,方便下一次使用。

# DNS解析

如果没有成功使用本地缓存,则需要发起网络请求了。首先要做的是 DNS 解析。

会依次搜索:

  • 浏览器的 DNS 缓存;
  • 操作系统的 DNS 缓存;
  • 路由器的 DNS 缓存;
  • 向服务商的 DNS 服务器查询;
  • 向全球 13 台根域名服务器查询;

为了节省时间,可以在 HTML 头部去做 DNS 的预解析:

<link rel="dns-prefetch" href="http://www.baidu.com" />

为了保证响应的及时,DNS 解析使用的是 UDP 协议

# 建立 TCP 连接

我们发送的请求是基于 TCP 协议的,所以要先进行连接建立。建立连接的通信是打电话,双方都在线;无连接的通信是发短信,发送方不管接收方,自己说自己的。

这个确认接收方在线的过程就是通过 TCP 的三次握手完成的。

  • 客户端发送建立连接请求;
  • 服务端发送建立连接确认,此时服务端为该 TCP 连接分配资源;
  • 客户端发送建立连接确认的确认,此时客户端为该 TCP 连接分配资源;

TCP 三次握手

为什么要三次握手才算建立连接完成?

可以先假设建立连接只要两次会发生什么。把上面的状态图稍加修改,看起来一切正常。

假设 TCP 链接只有2次握手

但假如这时服务端收到一个失效的建立连接请求,我们会发现服务端的资源被浪费了 —— 此时客户端并没有想给它传送数据,但它却准备好了内存等资源一直等待着。

所以说,三次握手是为了保证客户端存活,防止服务端在收到失效的超时请求造成资源浪费

# TLS 协商密钥

为了保障通信的安全,我们使用的是 HTTPS 协议,其中的 S 指的就是 TLS。TLS 使用的是一种非对称+对称的方式进行加密。

对称加密就是两边拥有相同的秘钥,两边都知道如何将密文加密解密。这种加密方式速度很快,但是问题在于如何让双方知道秘钥。因为传输数据都是走的网络,如果将秘钥通过网络的方式传递的话,秘钥被截获,就失去了加密的意义。

非对称加密,每个人都有一把公钥和私钥,公钥所有人都可以知道,私钥只有自己知道,将数据用公钥加密,解密必须使用私钥。这种加密方式就可以完美解决对称加密存在的问题,缺点是速度很慢。

我们采取非对称加密的方式协商出一个对称密钥,这个密钥只有发送方和接收方知道的密钥,流程如下:

  1. 客户端发送一个随机值以及需要的协议和加密方式;
  2. 服务端收到客户端的随机值,发送自己的数字证书,附加上自己产生一个随机值,并根据客户端需求的协议和加密方式使用对应的方式;
  3. 客户端收到服务端的证书并验证是否有效,验证通过会再生成一个随机值,通过服务端证书的公钥去加密这个随机值并发送给服务端;
  4. 服务端收到加密过的随机值并使用私钥解密获得第三个随机值,这时候两端都拥有了三个随机值,可以通过这三个随机值按照之前约定的加密方式生成密钥,接下来的通信就可以通过该对称密钥来加密解密;

通过以上步骤可知,在 TLS 握手阶段,两端使用非对称加密的方式来通信,但是因为非对称加密损耗的性能比对称加密大,所以在正式传输数据时,两端使用对称加密的方式

# 发送请求&接收响应

HTTP 的默认端口是 80,HTTPS 的默认端口是 443

请求的基本组成是 请求行+请求头+请求体

POST /hello HTTP/1.1
User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
Host: www.example.com
Accept-Language: en, mi

name=niannian

响应的基本组成是 响应行+响应头+响应体

HTTP/1.1 200 OK
Content-Type:application/json
Server:apache

{password:'123'}

# 关闭TCP连接

等数据传输完毕,就要关闭 TCP 连接了。关闭连接的主动方可以是客户端,也可以是服务端,这里以客户端为例,整个过程有四次握手

  1. 客户端请求释放连接,仅表示客户端不再发送数据了;
  2. 服务端确认连接释放,但这时可能还有数据需要处理和发送;
  3. 服务端请求释放连接,服务端这时不再需要发送数据时;
  4. 客户端确认连接释放。

TCP 四次挥手

为什么要有四次挥手?

TCP 是可以双向传输数据的,每个方向都需要一个请求和一个确认。因为在第二次握手结束后,服务端还有数据传输,所以没有办法把第二次确认和第三次合并。

TCP 第四次挥手为什么要等待 2MSL?

MSL, The Maximum Segment Lifetime 最长报文段寿命

客户端在发送完第四次的确认报文段后会等待 2MSL 才正真关闭连接,MSL 是指数据包在网络中最大的生存时间。目的是确保服务端收到了这个确认报文段,

假设服务端没有收到第四次握手的报文,试想一下会发生什么?在客户端发送第四次握手的数据包后,服务端首先会等待,在 1 个 MSL 后,它发现超过了网络中数据包的最大生存时间,但是自己还没有收到数据包,于是服务端认为这个数据包已经丢失了,它决定把第三次握手的数据包重新给客户端发送一次,这个数据包最多花费一个 MSL 会到达客户端。

一来一去,一共是 2MSL,所以客户端在发送完第四次握手数据包后,等待 2MSL 是一种兜底机制,如果在 2MSL 内没有收到其他报文段,客户端则认为服务端已经成功接受到第四次挥手,连接正式关闭

# 浏览器渲染

上面讲完了网络请求部分,现在浏览器拿到了数据,剩下需要渲染进程工作了。浏览器渲染主要完成了一下几个工作:

  1. 构建 DOM 树;
  2. 样式计算;
  3. 布局定位;
  4. 图层分层;
  5. 图层绘制;
  6. 合成显示;

# 构建 DOM 树

HTML 文件的结构没法被浏览器理解,所以先要把 HTML 中的标签变成一个可以给 JS 使用的结构。

在控制台可以尝试打印 document,这就是解析出来的 DOM 树:

DOM 树

# 样式计算

CSS 文件一样没法被浏览器直接理解,所以首先把 CSS 解析成样式表。这三类样式都会被解析:

  • 通过 link 引用的外部 CSS 文件
  • <style> 标签内的样式
  • 元素的 style 属性内嵌的 CSS

在控制台打印 document.styleSheets,这就是解析出的样式表:

样式表

利用这份样式表,我们可以计算出 DOM 树中每个节点的样式。之所以叫计算,是因为每个元素要继承其父元素的属性。

<style>
    span {
        color: red
    }
    div {
        font-size: 30px
    }
</style>
<div>
    <span>年年</span>
</div>

比如上面的年年,不仅要接受 span 设定的样式,还要继承 div 设置的。

DOM 树中的节点有了样式,现在被叫做渲染树。

为什么要把 CSS 放在头部,js 放在 body 的尾部?

在解析 HTML 的过程中,遇到需要加载的资源特点如下:

  • CSS 资源异步下载,下载和解析都不会阻塞构建 DOM 树 <link href='./style.css' rel='stylesheet'/>
  • JS 资源同步下载,下载和执行都会阻塞构建 DOM 树 <script src='./index.js'/>

因为这样的特性,往往推荐将 CSS 样式表放在 head 头部,js 文件放在 body 尾部,使得渲染能尽早开始。

CSS 会阻塞 HTML 解析吗?

上文提到页面渲染是渲染进程的任务,这个渲染进程中又细分为 GUI 渲染线程JS 线程

解析 HTML 生成 DOM 树,解析 CSS 生成样式表以及后面去生成布局树、图层树都是由 GUI 渲染线程去完成的,这个线程可以一边解析 HTML,一边解析 CSS,这两个是不会冲突的,所以也提倡把 CSS 在头部引入。

但是在 JS 线程执行时,GUI 渲染线程没有办法去解析 HTML,这是因为 JS 可以操作 DOM,如果两者同时进行可能引起冲突。如果这时 JS 去修改了样式,那此时 CSS 的解析和 JS 的执行也没法同时进行了,会先等 CSS 解析完成,再去执行 JS,最后再去解析 HTML。

CSS 会阻塞 HTML 解析吗?

从这个角度来看,CSS 有可能阻塞 HTML 的解析。

预加载扫描器是什么?

上面提到的外链资源,不论是同步加载 JS 还是异步加载 CSS、图片等,都要到 HTML 解析到这个标签才能开始,这似乎不是一种很好的方式。实际上,从 2008 年开始,浏览器开始逐步实现了预加载扫描器:在拿到 HTML 文档的时候,先扫描整个文档,把 CSS、JS、图片和 web 字体等提前下载。

JS 脚本引入时 async 和 defer 有什么差别?

预加载扫描器解决了 JS 同步加载阻塞 HTML 解析的问题,但是我们还没有解决 JS 执行阻塞 HTML 解析的问题。所以有了 asyncdefer 属性。

  • 没有 deferasync,浏览器会立即加载并执行指定的脚本
  • async 属性表示异步执行引入的 JavaScript,经加载好,就会开始执行
  • defer 属性表示延迟到 DOM 解析完成,再执行引入的 JS

JS 脚本引入时 async 和 defer 有什么差别?

注意:在加载多个 JS 脚本的时候,async 是无顺序的执行,而 defer 是有顺序的执行

preload, prefetch 有什么区别?

之前提到过预加载扫描器,它能提前加载页面需要的资源,但这一功能只对特定写法的外链生效,并且我们没有办法按照自己的想法给重要的资源一个更高的优先级,所以有了 preloadprefetch

  • preload: 以高优先级为当前页面加载资源;
  • prefetch: 以低优先级为后面的页面加载未来需要的资源,只会在空闲时才去加载;

无论是 preload 还是 prefetch,都只会加载,不会执行,如果预加载的资源被服务器设置了可以缓存 cache-control 那么会进入磁盘,反之只会被保存在内存中。具体使用如下:

<head>
    <!-- 文件加载 -->
    <link rel="preload" href="main.js" as="script">
    <link rel="prefetch" href="news.js" as="script">
</head>

<body>
    <h1>hello world!</h1>
    <!-- 文件文件执行 -->
    <script src="main.js" defer></script>
</body>

为了保证资源正确被预加载,使用时需要注意:

  1. preload 的资源应该在当前页面立即使用,如果不加上 script 标签执行预加载的资源,控制台中会显示警告,提示预加载的资源在当前页面没有被引用;
  2. prefetch 的目的是取未来会使用的资源,所以当用户从 A 页面跳转到 B 页面时,进行中的 preload 的资源会被中断,而 prefetch 不会;
  3. 使用 preload 时,应配合 as 属性,表示该资源的优先级,使用 as="style" 属性将获得最高的优先级,as ="script" 将获得低优先级或中优先级,其他可以取的值有 font/image/audio/video
  4. preload 字体时要加上 crossorigin 属性,即使没有跨域,否则会重复加载:
    <link rel="preload href="font.woff" as="font" crossorigin>
    

此外,这两种预加载资源不仅可以通过 HTML 标签设置,还可以通过 js 设置:

var res = document.createElement("link");
res.rel = "preload";
res.as = "style";
res.href = "css/mystyles.css";
document.head.appendChild(res);

以及 HTTP 响应头:

Link: </uploads/images/pic.png>; rel=prefetch

# 布局定位

上面详细的讲述了 HTML 和 CSS 加载、解析过程,现在我们的渲染树中的节点有了样式,但是不知道要画在哪个位置。所以还需要另外一颗布局树确定元素的几何定位。

布局树只取渲染树中的可见元素,意味着 head 标签,display:none 的元素不会被添加。

# 图层分层

现在我们有了布局树,但依旧不能直接开始绘制,在此之前需要分层,生成一棵对应的图层树。浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,我们希望能更加方便地实现这些效果。

并不是布局树的每个节点都能生成一个图层,如果一个节点没有自己的层,那么这个节点就从属于父节点的图层。

通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

  1. 拥有层叠上下文属性的元素会被提升为单独的一层:明确定位属性 position 的元素、定义透明属性 opacity 的元素、使用 CSS 滤镜 filter 的元素等,都拥有层叠上下文属性。
  2. 需要剪裁(clip)的地方也会被创建为图层 overflow

在 Chrome 的开发者工具:更多选项-更多工具-Layers 可以看到图层的分层情况。

Chrome 开发者工具

# 图层绘制

在完成图层树的构建之后,接下来终于到对每个图层进行绘制。首先会把图层拆解成一个一个的绘制指令,排布成一个绘制列表,在上文提到的开发者工具的 Layers 面板中,点击 detail 中的 profiler 可以看到绘制列表。

至此,渲染进程中的主线程 —— GUI 渲染线程已经完成了它所有任务,接下来交给渲染进程中的合成线程。

合成线程接下来会把视口拆分成图块,把图块转换成位图。

至此,渲染进程的工作全部完成,接下来会把生成的位图还给浏览器进程,最后在页面上显示。

# 性能优化,还可以做些什么

# 预解析、预渲染

除了上文提到的使用 preload, prefetch 去提前加载,还可以使用 DNS Prefetch, Prerender, Preconnect

  1. DNS Prefetch: DNS 预解析;

    <link rel="dns-prefetch" href="//fonts.googleapis.com">
    
  2. preconnect: 在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析,TLS 协商,TCP 握手;

    <link href="https://cdn.domain.com" rel="preconnect" crossorigin>
    

    preconnect

  3. Prerender: 获取下个页面所有的资源,在空闲时渲染整个页面;

    <link rel="prerender" href="https://www.keycdn.com">
    

# 减少回流和重绘

回流是指浏览器需要重新计算样式、布局定位、分层和绘制,回流又被叫重排

触发回流的操作:

  • 添加或删除可见的 DOM 元素
  • 元素的位置发生变化
  • 元素的尺寸发生变化
  • 浏览器的窗口尺寸变化

重绘是只重新像素绘制,当元素样式的改变不影响布局时触发。

回流=计算样式+布局+分层+绘制重绘=绘制。故回流对性能的影响更大

所以应该尽量避免回流和重绘。比如利用 GPU 加速来实现样式修改,transform/opacity/filters 这些属性的修改都不是在主线程完成的,不会重绘,更不会回流。

# 参考资料

# Web Workers

Web Worker, Service Worker, Share Worker 都是将脚本运行在浏览器主线程之外单独的线程中,它们之间的区别是它们所应用的场景和他们的特性。

  • Web Worker: 通常目的是让我们减轻主线程中的密集处理工作的脚本
  • Service worker: 是浏览器和网络间的代理。通过拦截文档中发出的请求,Service Worker 可以将请求重定向为缓存中的数据,达到离线运行的目的
  • Share Worker: 共享 Worker 可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker

# Worker

一直以来,一个网页只会有两个线程:GUI 渲染线程JS 引擎线程。即便你的 JS 写得再天花乱坠,也只能在一个进程里面执行。然而,JS 引擎线程和 GUI 渲染线程是互斥的,因此在 JS 执行的时候,UI 页面会被阻塞住。为了在进行高耗时 JS 运算时,UI 页面仍可用,那么就得另外开辟一个独立的 JS 线程来运行这些高耗时的 JS 代码,这就是 Web Worker

Worker 有两个特点:

  1. 只能服务于新建它的页面,不同页面之间不能共享同一个 Worker。
  2. 当页面关闭时,该页面新建的 Worker 也会随之关闭,不会常驻在浏览器中。

基本用法:

// main.js
var worker = new Worker('work.js');
worker.postMessage('Hello World');
worker.postMessage({ method: 'echo', args: ['Work'] });

worker.onmessage = function (event) {
    console.log('Received message ' + event.data);
    doSomething();
}

function doSomething() {
    // 执行任务
    worker.postMessage('Work done!');
}
// worker.js
self.addEventListener('message', function (e) {
    var data = e.data;
    switch (data.cmd) {
        case 'start':
            self.postMessage('WORKER STARTED: ' + data.msg);
            break;
        case 'stop':
            self.postMessage('WORKER STOPPED: ' + data.msg);
            self.close(); // Terminates the worker.
            break;
        default:
            self.postMessage('Unknown command: ' + data.msg);
    };
}, false);

Worker 有以下几个使用注意点:

  1. 同源限制:分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源
  2. DOM 限制:Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 document, window, parent 这些对象。但是,Worker 线程可以使用 navigator 对象和 location 对象
  3. 通信联系:Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成
  4. 脚本限制:Worker 线程不能执行 alert() 方法和 confirm() 方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求
  5. 文件限制:Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络

# 参考资料

# Service Worker

Service Worker 与 Worker 相比,相同点是:它们都是在常规的 JS 引擎线程以外开辟了新的 JS 线程。不同点主要包括以下几点:

  1. Service Worker 不是服务于某个特定页面的,而是服务于多个页面的(按照同源策略)
  2. Service Worker 会常驻在浏览器中,即便注册它的页面已经关闭,Service Worker 也不会停止,本质上它是一个后台线程,只有你主动终结,或者浏览器回收,这个线程才会结束
  3. 生命周期、可调用的 API 等等也有很大的不同

# 注册

Service Worker 脚本通过 navigator.serviceWorker.register 方法注册到页面, 之后可以脱离页面在浏览器后台运行:

if (navigator.serviceWorker) {
    navigator.serviceWorker
        .register('/sw.js', { scope: '/' })
        .then(reg => {
            reg.onupdatefound = () => {
                const installingWorker = reg.installing;
                installingWorker.onstatechange = () => {
                    switch (installingWorker.state) {
                        case 'installed':
                            if (navigator.serviceWorker.controller) {
                                const event = document.createEvent('Event');
                                event.initEvent('sw.update', true, true);
                                window.dispatchEvent(event);
                            }
                            break;
                        default:
                            break;
                    }
                };
            };
        })
        .catch(e => {
            console.error('Error during service worker registration:', e);
        });
}

出于安全原因,Service Worker 脚本的作用范围不能超出脚本文件所在的路径。比如地址是 /sw-test/sw.js 的脚本只能控制 /sw-test/ 下的页面。

# 生命周期

Service Worker 从注册开始需要先 install,如果 install 成功,接下来需要 activate,然后才能接管页面。但是如果页面被先前的 Service Worker 控制着,那么它会停留在 installed(waiting) 这个阶段等到页面重新打开才会接管页面,或者可以通过调用 self.skipWaiting() 方法跳过等待。所以一个 Service Worker 脚本的生命周期有这样一些阶段(从左往右):

Service Worker 生命周期

  • Parsed: 注册完成,脚本解析成功,尚未安装
  • Installing: 对应 Service Worker 脚本 install 事件执行,如果事件里有 event.waitUntil() 则会等待传入的 Promise 完成才会成功
  • Inatalled(Waiting): 页面被旧的 Service Worker 脚本控制,所以当前的脚本尚未激活。可以通过 self.skipWaiting() 激活新的 Service Worker
  • Activating: 对应 Service Worker 脚本 activate 事件执行,如果事件里有 event.waitUntil() 则会等待这个 Promise 完成才会成功。这时可以调用 self.clients.claim() 接管页面
  • Activated: 激活成功,可以处理 fetchmessage 等事件
  • Redundant: 安装失败,或者激活失败,或者被新的 Service Worker 替代掉

Service Worker 脚本最常用的功能是截获请求和缓存资源文件,这些行为可以绑定在下面这些事件上:

  • install 事件中,抓取资源进行缓存
  • activete 事件中,遍历缓存,清除过期的资源
  • fetch 事件中,拦截请求,查询缓存或者网络,返回请求的资源

# 缓存策略

处在 activated 状态的 Service Worker 可以拦截作用范围下的页面的网络请求,由 Service Worker 监听 fetch 事件来决定请求如何响应。Service Worker 可以访问 Cache API, Fetch API 等接口,获取数据和完成响应。

常见的缓存策略包括:

  • 网络优先:从网络获取, 失败或者超时再尝试从缓存读取
  • 缓存优先:从缓存获取, 缓存插叙不到再尝试从网络抓取
  • 最快:同时查询缓存和网络, 返回最先拿到的
  • 仅限网络:仅从网络获取
  • 仅限缓存:仅从缓存获取

下面针对其中几类策略进一步做示例说明:

const search = self.location.search || ';
const version = search.match(/\?v=(\d+)/)[1] || 'vpark';
const cacheKeyPrefix = `vpark:${version}`;
const debugMode = !!search.match(/\?debug=(\d+)/);
const cacheKey = (...args) => [cacheKeyPrefix, ...args].join(':');
const cacheFirstHost = [
    /https?:\/\/cdn.zqianduan.com\//,
    new RegExp(`${self.location.origin}/static/`)
];
const networkFirstHost = [
    /\/(zqd-api|api)\//
];
const offlineResources = [
    // 管理后台离线没有意义,因为几乎所有页面及接口都要校验权限
    // 'index.html'
];

self.addEventListener('install', (event) => {
    /**
     * 1. `skipWaiting()` 在等待期间调用,还是在之前调用并没有什么不同
     * 2. `skipWaiting()` 意味着新 SW 控制了之前用旧 SW 获取的页面,也就是说页面有一部分资源是通过旧 SW 获取,剩下一部分是通过新 SW 获取的,这样做可能会给客户端带来一些不可控的影响
     * 栗子:VPark 管理后台,资源做了分包处理,localhost 环境下,各静态资源命名
     * 未增加 hash 冗余,因此在开发调试期间可能出现当前页面受新旧两个 SW 控制的场
     * 景,需特别注意;其余环境因对资源命名增加了 hash 冗余,所以,若资源有更新,
     * 客户端尚未缓存最新资源,所以静态资源全部来源于网络请求,除了受旧 SW 控制的资源
     * 需再次从网络请求并在新 SW 中重新缓存外,不会造成其它不可控的影响
     */
    self.skipWaiting();
    // event.waitUntil(
    //     caches.open(cacheKey('offline'))
    //         .then(cache => cache.addAll(offlineResources))
    // );
});

self.addEventListener('activate', (event) => {
    const cacheDeletePromises = () => {
        return caches.keys()
            .then(keys => Promise.all(
                keys
                    // 过滤不需要删除的资源
                    .filter(key => !key.startsWith(cacheKeyPrefix))
                    // 删除旧版本资源,返回为 Promise 对象
                    .map(key => caches.delete(key))
            ));
    };

    event.waitUntil(
        Promise.all(
            [cacheDeletePromises()]
        ).then(() => {
            return self.clients.claim();
        })
    );
});

self.addEventListener('fetch', (event) => {
    const request = event.request;

    // 策略1:非 GET 请求永远从网络获取资源
    if (request.method !== 'GET' || debugMode) return;

    // 策略2:静态资源,缓存优先,读取失败则从网络请求并缓存
    if (cacheFirstHost.some(regex => request.url.match(regex))) {
        event.respondWith(
            caches.match(request)
                .then((response) => {
                    return response
                        || fetch(request).then((resp) => {
                            const copy = resp.clone();

                            caches.open(cacheKey('static'))
                                .then(cache => {
                                    cache.put(request, copy);
                                });
                            return resp;
                        });
                })
        );

        return;
    }

    // 策略3:GET 接口请求,网络优先,如果请求失败,则尝试从缓存读取
    if (networkFirstHost.some(regex => request.url.match(regex))) {
        event.respondWith(
            fetch(request).then((response) => {
                const copy = response.clone();

                caches.open(cacheKey('resources'))
                    .then(cache => {
                        cache.put(request, copy);
                    });

                return response;
            })
                .catch(() => {
                    return caches.match(request).then((response) => response);
                })
        );
    }
});

# 注意事项

特性

  1. 基于 Web Worker(JavaScript 主线程的独立线程,如果执行消耗大量资源的操作也不会堵塞主线程)
  2. 在 Web Worker 的基础上增加了离线缓存的能力
  3. 本质上充当 Web 应用程序(服务器)与浏览器之间的代理服务器
  4. 创建有效的离线体验(将一些不常更新的内容缓存在浏览器,提高访问体验)
  5. 由事件驱动的,具有生命周期
  6. 可以访问 cache 和 indexDB
  7. 支持消息推送
  8. 并且可以让开发者自己控制管理缓存的内容以及版本
  9. 可以通过 postMessage 接口把数据传递给其他 JS 文件
  10. 更多无限可能

注意

  • Service Worker 运行在 Worker 上下文,因此它不能访问 DOM
  • 它设计为完全异步,同步 API(如 XHR 和 localStorage)不能在 Service Worker 中使用
  • 出于安全考量,Service Workers 只能由 HTTPS 承载
  • 在 Firefox 浏览器的用户隐私模式,Service Worker 不可用
  • 其生命周期与页面无关(关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动)

# 参考资料

# Share Worker

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,

注意:如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)。

构造了 SharedWorker 实例对象后,我们需要通过其 port 属性进行通信,主要的 API 如下:

const sw = new SharedWorker('/public/shared.js');

// 发送数据
sw.port.postMessage('...');

// 监听数据
sw.port.onmessage = function (event) {
    // ...
}

由于构造的多个 SharedWorker 实例形成了一个共享的连接,因此在连接成功时,我们给每个实例分配一个唯一 ID:

// main.js
new Vue({
    data() {
        return {
            workerID: 0,
            sw: {},
        }
    },
    mounted() {
        this.sw = new SharedWorker('/public/shared.js');
        this.sw.port.addEventListener('message', (evt) => {
            let {
                type,
                data
            } = evt.data
            // 初始化连接时返回一个 id
            if (type == 'id') {
                this.workerID = data
            }
        });
        this.sw.port.start();
    },
    methods: {
        clickMusic(item, index) {
            // 省略部分代码
            // 每次通信时将 id 带上
            this.sw.port.postMessage({
                type: 'set',
                id: this.workerID,
                data: item
            })
        }
    }
});

ShareWorker 内部监听 connect 事件,并且处理内部的 port 事件:

// shared.js
let id = 1;
const connectedClients = new Set();

// 给其他连接端发送消息
function sendMessageToClients(payload, currentClientId = null) {
    connectedClients.forEach(({ id, client }) => {
        if (currentClientId && currentClientId == id) return;
        client.postMessage(payload);
    });
}

// 当前连接绑定消息监听
function setupClient(clientPort) {
    clientPort.onmessage = (event) => {
        const { type, data, id } = event.data;
        if(type == 'set') {
            sendMessageToClients({
                type: 'get',
                data: data,
            }, id)
        }
    };
}

self.addEventListener('connect', (event) => {
    const newClient = event.source;
    // 每次连接后给 client 唯一 id 标识
    // 将每次连接存在数组中
    connectedClients.add({
        client: newClient,
        id: id,
    });
    setupClient(newClient);
    newClient.postMessage({
        type: 'id',
        data: id
    });
    id++;
});

# 参考资料

# Event Loop

Event Loop 即事件循环,是指浏览器Node 的一种解决 JavaScript 单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。

JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为宏任务(macrotask)微任务(microtask)。在 ES6 规范中,macrotask 称为 taskmicrotask 称为 jobs

宏任务 MacroTask 包括:

  • script (整体代码)
  • setTimeout
  • setInterval
  • setImmediate (浏览器暂时不支持,只有IE10支持,具体可见 MDN (opens new window))
  • I/O
  • UI render

微任务 MicroTask 包括:

  • process.nextTick (Node 独有)
  • Promise then
  • async/await (实际就是 promise)
  • MutationObserver (html5 新特性)

# 浏览器

Javascript 有一个 main thread 主线程call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。

JS 调用栈

JS 调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。

同步任务和异步任务

Javascript 单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

在浏览器中,一次 Event Loop 顺序是这样的:

  1. 执行同步代码,这属于宏任务
  2. 执行栈为空,查询是否有微任务需要执行
  3. 执行所有微任务
  4. 必要的话渲染 UI
  5. 然后开始下一轮 Event Loop,执行宏任务中的异步代码

其中,需要注意 async/await 的理解。async/await 在底层转换成了 promisethen 回调函数。每次我们使用 await,解释器都创建一个 Promise 对象,然后把剩下的 async 函数中的操作放到 then 回调函数中。

举个例子:

console.log('script start');

async function async1() {
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2 end');
}

async1();

setTimeout(function() {
    console.log('setTimeout');
}, 0);

new Promise(resolve => {
    console.log('Promise');
    resolve()
}).then(function() {
    console.log('promise1');
})
.then(function() {
    console.log('promise2');
});

console.log('script end');
查看答案

输出script start, async2 end, Promise, script end, async1 end, promise1, promise2, setTimeout

解析

  1. 首先,执行 script,这属于宏任务,执行所有同步代码,将宏任务微任务划分到各自队列中
    MacroTasks: run script, setTimeout callback
    Microtasks: async2, async1.then, promise1(then), promise2(then)
    
    JS stack: script
    Log: script start-> async2 end -> Promise -> script end。
    

    注意:new Promise(fn1).then(fn2) 其中 fn1 是同步任务。

  2. 同步代码执行完成,此时执行栈已为空,查询是否有微任务需要执行
    MacroTasks: setTimeout callback
    Microtasks: async1.then, promise1(then), promise2(then)
    
    JS stack: microtasks
    Log: async1 end -> promise1 -> promise2
    
  3. 开始下一轮的 Event Loop,执行宏任务中的异步代码
    MacroTasks: setTimeout callback
    Microtasks:
    
    JS stack: setTimeout
    Log: setTimeout
    

加深下对 async/awaitPromise 的理解:

async function f() {
    await new Promise(resolve => {
        setTimeout(() => {
            console.log('1');
        }, 2000);
    });
    await new Promise(resolve => {
        setTimeout(() => {
            console.log('2');
        }, 3000);
    });

    console.log('3');
}

f();
查看答案

输出:2 秒后输出 1

解析:注意 await 之后返回的 promise 状态没有发生变化,一直是 pending,在第一个 promise resolve 之后才会继续往下执行。

async function f2() {
    let promiseA = new Promise(resolve => {
        setTimeout(() => {
            console.log('1');
        }, 2000);
    });

    let promiseB = new Promise(resolve => {
        setTimeout(() => {
            console.log('2');
        }, 3000);
    });

    await promiseA;
    await promiseB;
    console.log('3');
}
f2();
查看答案

输出:2 秒后打印 1,再过 1 秒打印 2

解析:与上一道题不同的是,此处的两个 Promise 先进行了实例化,会立即执行。又因为 await 返回的 promise 状态没有变化,一直处于 pending,所以不会继续往下执行。

# Node

Node 中的 Event Loop 和浏览器中的不相同。Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行,下面是一个 libuv 引擎中的事件循环的模型:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────│  connections  │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

这些阶段大致的功能如下:

  1. timers: 执行定时器队列中的回调如 setTimeout()setInterval()
  2. I/O callbacks: 执行几乎所有的回调,但是不包括 close 事件定时器setImmediate() 的回调
  3. idle, prepare: 仅在内部使用,可以不必理会
  4. poll: 等待新的 I/O 事件,Node 在一些特殊情况下会阻塞在这里
  5. check: setImmediate() 的回调会在这个阶段执行
  6. close callbacks: 执行 close 事件的 callback,例如 socket.on('close'[,fn]) 或者 http.server.on('close, fn)

下面我们来按照代码第一次进入 libuv 引擎后的顺序来详细解说这些阶段:

# poll 阶段

当 v8 引擎将 js 代码解析后传入 libuv 引擎后,循环首先进入 poll 阶段。poll 阶段的执行逻辑如下:先查看 poll queue 中是否有事件,有任务就按先进先出的顺序依次执行回调。 当 queue 为空时,会检查是否有 setImmediate() 的 callback,如果有就进入 check 阶段执行这些 callback。但同时也会检查是否有到期的 timer,如果有,就把这些到期的 timer 的 callback 按照调用顺序放到 timer queue 中,之后循环会进入 timer 阶段执行 queue 中的 callback。这两者的顺序是不固定的,受到代码运行的环境的影响。如果两者的 queue 都是空的,那么 loop 会在 poll 阶段停留,直到有一个 i/o 事件返回,循环会进入 i/o callback 阶段并立即执行这个事件的 callback。

值得注意的是,poll 阶段在执行 poll queue 中的回调时实际上不会无限的执行下去。有两种情况 poll 阶段会终止执行 poll queue 中的下一个回调:

  1. 所有回调执行完毕
  2. 执行数超过了 node 的限制

# check 阶段

check 阶段专门用来执行 setImmediate() 方法的回调,当 poll 阶段进入空闲状态,并且 setImmediate queue 中有 callback 时,事件循环进入这个阶段。

# close阶段

当一个 socket 连接或者一个 handle 被突然关闭时(例如调用了 socket.destroy() 方法),close 事件会被发送到这个阶段执行回调。否则事件会用 process.nextTick() 方法发送出去。

# timer 阶段

这个阶段以先进先出的方式执行所有到期的加入 timer 队列里的 callback,一个 timer callback 指的是一个通过 setTimeout 或者 setInterval 函数设置的回调函数。

# I/O callback 阶段

如上文所言,这个阶段主要执行大部分 I/O 事件的回调,包括一些为操作系统执行的回调。例如一个 TCP 连接生错误时,系统需要执行回调来获得这个错误的报告。

# process.nextTick, setTimeout 与 setImmediate 的区别与使用场景

在 Node 中有三个常用的用来推迟任务执行的方法:process.nextTick, setTimeoutsetInterval 与之相同)与 setImmediate

这三者间存在着一些非常不同的区别:

process.nextTick():

尽管没有提及,但是实际上 Node 中存在着一个特殊的队列,即 nextTick queue。这个队列中的回调执行虽然没有被表示为一个阶段,但是这些事件却会在每一个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段之前,会先检查 nextTick queue 中是否有任务,如果有,那么会先清空这个队列。与执行 poll queue 中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用 process.nextTick() 方法会导致 Node 进入一个死循环,直到内存泄漏。

那么何时使用这个方法比较合适呢?下面有一个例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

这个例子中当,当 listen 方法被调用时,除非端口被占用,否则会立刻绑定在对应的端口上。这意味着此时这个端口可以立刻触发 listening 事件并执行其回调。然而,这时候 on('listening) 还没有将 callback 设置好,自然没有 callback 可以执行。为了避免出现这种情况,Node 会在 listen 事件中使用 process.nextTick() 方法,确保事件在回调函数绑定后被触发。

setTimeout()setImmediate()

在三个方法中,这两个方法最容易被弄混。实际上,某些情况下这两个方法的表现也非常相似。然而实际上,这两个方法的意义却大为不同。

setTimeout() 方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。注意这个「第一时间执行」,这意味着,受到操作系统和当前执行任务的诸多影响,该回调并不会在我们预期的时间间隔后精准的执行。执行的时间存在一定的延迟和误差,这是不可避免的。Node 会在可以执行 timer 回调的第一时间去执行你所设定的任务。

setImmediate() 方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即 poll 阶段之后。有趣的是,这个名字的意义和之前提到过的 process.nextTick() 方法才是最匹配的。Node 的开发者们也清楚这两个方法的命名上存在一定的混淆,他们表示不会把这两个方法的名字调换过来 —— 因为有大量的 Node 程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。

setTimeout() 和不设置时间间隔的 setImmediate() 表现上及其相似。猜猜下面这段代码的结果是什么?

setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});

实际上,答案是不一定。没错,就连 Node 的开发者都无法准确的判断这两者的顺序谁前谁后。这取决于这段代码的运行环境。运行环境中的各种复杂的情况会导致在同步队列里两个方法的顺序随机决定。但是,在一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个 I/O 事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});

答案永远是:

immediate
timeout

因为在 I/O 事件的回调中,setImmediate 方法的回调永远在 timer 的回调前执行。

Node 与浏览器的 Event Loop 差异

  • 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
  • Node 端,v10 及以前版本,microtask 在事件循环的各个阶段之间执行,v10 版本之后和浏览器行为统一

# 示例及解析

# 示例一
console.log('start');

setTimeout(() => {
    console.log('timer1');
    Promise.resolve().then(function () {
        console.log('promise1');
    });
}, 0);

setTimeout(() => {
    console.log('timer2');
    Promise.resolve().then(function () {
        console.log('promise2');
    });
}, 0);

Promise.resolve().then(function () {
    console.log('promise3');
});

console.log('end');
查看答案

输出start, end, promise3, timer1, promise1, timer2, promise2

解析

  1. 首先,执行主线程任务,打印 start, end
  2. 然后,执行微任务 Promise,这一步和浏览器一致,打印 promise3
  3. 进入 timers 阶段,这里会执行定时器到期的回调,打印 timer1, promise1, timer2, promise2
# 示例二
setTimeout(() => {
    console.log('timeout1');
}, 0);

setImmediate(() => {
    console.log('immediate1');
});

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
    setImmediate(() => {
        console.log('immediate2');
    });
});
查看答案

输出timeout1, immediate1, immediate2, timeout2

解析:有 IO 操作时,immediate 回调在 poll 完成之后立即执行

# 示例三
let bar;
console.log('start');
const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});

setTimeout(() => {
    console.log('timer1');
    Promise.resolve().then(function () {
        console.log('promise1');
    });
}, 0);
setTimeout(() => {
    console.log('timer2');
    Promise.resolve().then(function () {
        console.log('promise2');
    });
}, 0);

new Promise(function (resolve) {
    console.log('promise3');
    resolve();
}).then(function () {
    console.log('promise4');
});
console.log('end');

function someAsyncApiCall(callback) {
    process.nextTick(callback);
}

someAsyncApiCall(() => {
    console.log('bar', bar);
});

bar = 1;
查看答案

输出start, promise3, end, bar 1, promise4, timer1, promise1, timer2, promise2, immediate, timeout

解析

  1. 首先,执行主线程的执行栈任务,打印 start, promise3, end
  2. process.nextTick 的机制是发生在执行栈尾部,优先于微任务,所以打印 bar 1
  3. 执行任务队列的微任务队列,打印 promise4
  4. 进入 timers 阶段,打印 timer1, promise1, timer2, promise2
  5. 然后,到达 poll 阶段,由于有文件 IO 操作,此时的 seImmediate 也就是 check 阶段调用的函数会优先于定时器先执行,打印 immediate, timeout

# 参考资料

上次更新: 2024/4/15 02:28:03