在写这篇前端散记之前有写过另外一篇散记,可点击 前端散记 访问。所谓散记,东西都比较零散,更谈不上什么深入,但是至少可以让读者知道一些概念理论,如果深入可以自行去查询相关知识。

keep-alive vs http2

有很多文章都说 http2 相比 http1.1 增加了连接复用。这句话其实是不准确的。

在 HTTP 1.1 中 所有的连接默认都是持续连接,除非特殊声明不支持。 而在 http1.0 中,官方没有支持 keep-alive, 通常会手动在请求头中添加 Connection: Keep-Alive。

keep-alive 就是 TCP 连接复用的开端。改善的效果就是不再重新建立TCP连接,省去 三次握手 的时间。如下图:

keep-alive

优势有:

  • 较少的CPU和内存的使用(由于同时打开的连接的减少了);
  • 允许请求和应答的HTTP管线化;
  • 降低拥塞控制 (TCP连接减少了);
  • 减少了后续请求的延迟(无需再进行握手);
  • 报告错误无需关闭TCP连接。

http pipelining

有些文章中会有一个误区,就是TCP连接必须等一个请求响应完成后,才能复用。这是不对的,但其实可以注意上面优势里提到到 http pipelining,如下图:

HTTP1.1 中,一个TCP连接里是可以同时发送(实际有先后,但可以在响应前)多个请求的。但它是有序的,遵循先进先出,服务端只能按顺序响应请求(如果前面的请求没有响应完成或需要很长时间,后面的请求就会被阻塞),所以可能发生 队头阻塞(HOL blocking),造成延迟。

连续的 GET 和 HEAD 请求总可以管线化的。一个连续的幂等请求,如 GET,HEAD,PUT,DELETE,是否可以被管线化取决于一连串请求是否依赖于其他的。

所以keep-alive 的劣势也很明显:

  • Keep-Alive可能会极大的影响服务器性能,因为它在文件被请求之后还保持了不必要的连接很长时间;
  • 可能发生队头阻塞(HOL blocking),造成延迟。

HTTP2

HTTP2 主要解决的问题也是 TCP连接复用。但它比 keep-alive 更彻底,类似于通信工程里的时分复用,多个请求可以同时发送(不分先后),同时响应,解决了 队头阻塞(HOL blocking)的问题,极大提高效率。

keep-alive 的 HTTP pipelining 相当于单线程的,而 HTTP2 相当于并发。

HTTP2 的优点:

  • 对HTTP头字段进行数据压缩(即HPACK算法);
  • HTTP/2 服务端推送(Server Push);
  • 请求管线化;
  • 修复HTTP/1.0版本以来未修复的队头阻塞问题;
  • 对数据传输采用多路复用,让多个请求合并在同一 TCP连接内。

后三个优点其实都是多路复用带来的优点。

HTTP3相关

HTTP/3 新特性

1. HTTP/3简介

虽然 HTTP/2 解决了很多之前旧版本的问题,但是它还是存在一个巨大的问题,主要是底层支撑的 TCP 协议造成的。

上文提到 HTTP/2 使用了多路复用,一般来说同一域名下只需要使用一个 TCP 连接。但当这个连接中出现了丢包的情况,那就会导致 HTTP/2 的表现情况反倒不如 HTTP/1 了。

因为在出现丢包的情况下,整个 TCP 都要开始等待重传,也就导致了后面的所有数据都被阻塞了。但是对于 HTTP/1.1 来说,可以开启多个 TCP 连接,出现这种情况反到只会影响其中一个连接,剩余的 TCP 连接还可以正常传输数据。

那么可能就会有人考虑到去修改 TCP 协议,其实这已经是一件不可能完成的任务了。因为 TCP 存在的时间实在太长,已经充斥在各种设备中,并且这个协议是由操作系统实现的,更新起来不大现实。

基于这个原因,Google 就更起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上,HTTP/3 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。

QUIC 虽然基于 UDP,但是在原本的基础上新增了很多功能,接下来我们重点介绍几个QUIC新功能。

2. QUIC新功能

  • 0-RTT

通过使用类似 TCP 快速打开的技术,缓存当前会话的上下文,在下次恢复会话的时候,只需要将之前的缓存传递给服务端验证通过就可以进行传输了。0RTT 建连可以说是 QUIC 相比 HTTP2 最大的性能优势。那什么是 0RTT 建连呢?

这里面有两层含义:

  1. 传输层 0RTT 就能建立连接。
  2. 加密层 0RTT 就能建立加密连接。

上图左边是 HTTPS 的一次完全握手的建连过程,需要 3 个 RTT。就算是会话复用也需要至少 2 个 RTT。

而 QUIC 呢?由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT 就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比 TLS 的会话记录单要高很多。

  • 多路复用

虽然 HTTP/2 支持了多路复用,但是 TCP 协议终究是没有这个功能的。QUIC 原生就实现了这个功能,并且传输的单个数据流可以保证有序交付且不会影响其他的数据流,这样的技术就解决了之前 TCP 存在的问题。

同HTTP2.0一样,同一条 QUIC连接上可以创建多个stream,来发送多个HTTP请求,但是,QUIC是基于UDP的,一个连接上的多个stream之间没有依赖。比如下图中stream2丢了一个UDP包,不会影响后面跟着 Stream3 和 Stream4,不存在 TCP 队头阻塞。虽然stream2的那个包需要重新传,但是stream3、stream4的包无需等待,就可以发给用户。

另外QUIC 在移动端的表现也会比 TCP 好。因为 TCP 是基于 IP 和端口去识别连接的,这种方式在多变的移动端网络环境下是很脆弱的。但是 QUIC 是通过 ID 的方式去识别一个连接,不管你网络环境如何变化,只要 ID 不变,就能迅速重连上。

  • 加密认证的报文

TCP 协议头部没有经过任何加密和认证,所以在传输过程中很容易被中间网络设备篡改,注入和窃听。比如修改序列号、滑动窗口。这些行为有可能是出于性能优化,也有可能是主动攻击。

但是 QUIC 的 packet 可以说是武装到了牙齿。除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部都是经过认证的,报文 Body 都是经过加密的。

这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。

如上图所示,红色部分是 Stream Frame 的报文头部,有认证。绿色部分是报文内容,全部经过加密。

  • 向前纠错机制

QUIC协议有一个非常独特的特性,称为向前纠错 (Forward Error Correction,FEC),每个数据包除了它本身的内容之外,还包括了部分其他数据包的数据,因此少量的丢包可以通过其他包的冗余数据直接组装而无需重传。向前纠错牺牲了每个数据包可以发送数据的上限,但是减少了因为丢包导致的数据重传,因为数据重传将会消耗更多的时间(包括确认数据包丢失、请求重传、等待新数据包等步骤的时间消耗)。

假如说这次我要发送三个包,那么协议会算出这三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。当出现其中的非校验包丢包的情况时,可以通过另外三个包计算出丢失的数据包的内容。当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包就不能使用纠错机制了,只能使用重传的方式了。

总结

HTTP/1.x 有连接无法复用、队头阻塞、协议开销大和安全因素等多个缺陷;
HTTP/2 通过多路复用、二进制流、Header 压缩等等技术,极大地提高了性能,但是还是存在着问题的;
QUIC 基于 UDP 实现,是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议。

小程序优化

以本人所项接触的项目(IM相关)实战为例

  1. 去除后端返回来的冗余字段;
  2. 状态管理(以为项目使用的是 mpVue 小程序开发框架) ;
  3. 图片资源优化(采用 CDN);
  4. 图片使用 CDN 之后发现网络请求多了还是给人感觉体验不好,所以又采取了部分较小的图片直接打包进了项目 JS,由于担心小程序项目大小过大,所以才去分包;
  5. 群组人员 ID 缓存,并通过 ID 数组是否变化来加载人员信息。

Webpack 优化

  1. externals + cdn;
  2. cache-loader;
  3. happy-pack、thread-loader;
  4. webpack-bundle-analyzer
  5. Tree Shaking
  6. CommonChunkPlugin (Webpack 4.0移除,使用 SplitChunkPlugin 替代);
  7. DllPlugin 和 DllReferencePlugin:DLLPlugin 就是将包含大量复用模块且不会频繁更新的库进行编译,只需要编译一次,编译完成后存在指定的文件(这里可以称为动态链接库)中。在之后的构建过程中不会再对这些模块进行编译,而是直接使用 DllReferencePlugin 来引用动态链接库的代码。

浏览器渲染页面的过程

从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:

  1. DNS 查询
  2. TCP 连接
  3. HTTP 请求即响应
  4. 服务器响应
  5. 客户端渲染

浏览器对内容的渲染,这一部分(渲染树构建、布局及绘制),又可以分为下面五个步骤:

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

async/await 与 Generator 区别

ES7 提出的async 函数,终于让 JavaScript 对于异步操作有了终极解决方案。No more callback hell。

async 函数是 Generator 函数的语法糖。使用 关键字 async 来表示,在函数内部使用 await 来表示异步。

想较于 Generator,Async 函数的改进在于下面四点:

  • 内置执行器。Generator 函数的执行必须依靠执行器,而 Aysnc 函数自带执行器,调用方式跟普通函数的调用一样;
  • 更好的语义。async 和 await 相较于 * 和 yield 更加语义化;
  • 更广的适用性。co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise对象。而 async 函数的 await 命令后面则可以是 Promise 或者 原始类型的值(Number,string,boolean,但这时等同于同步操作);
  • 返回值是 Promise。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,可以直接使用 then() 方法进行调用。

JSONP 封装

以下是之前在做跨域项目所封装的 jsonp 库

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
29
30
31
32
33
34
35
36
37
38
39
function jsonp(options) {
options = options || {}
if (!options.url) {
throw new Error('参数不合法')
}

// 创建 script 标签并加入到页面中, 如果没传callback默认生成一个
let callbackName = options.callback || options.data['callback'] || ('jsonp_' + Math.random()).replace('.', '')

let oHead = document.getElementsByTagName('head')[0]
options.data['callback'] = callbackName
let params = formatParams(options.data)

let oScript = document.createElement('script')
oHead.appendChild(oScript);
// 创建jsonp回调函数
window[callbackName] = function (json) {
oHead.removeChild(oScript)
clearTimeout(oScript.timer)
window[callbackName] = null
if (json) {
options.success && options.success(json)
} else {
options.fail && options.fail({ code: 'FS_UNKNOW', summary: '请求错误' })
}
}

// 发送请求
oScript.src = options.url + (options.url.indexOf('?') > -1 ? '&' : '?') + params

// 超时处理
if (options.time) {
oScript.timer = setTimeout(function () {
window[callbackName] = null
oHead.removeChild(oScript)
options.fail && options.fail({ code: 'S_FAIL', summary: '请求超时' })
}, options.time)
}
}

平民版深度拷贝

比乞丐版 JSON.parse(JSON.stringify(obj)) 好点的深度拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 深拷贝
* @param {*} target
*/
export const deepClone = target => {
if (typeof target === 'object') {
let newTarget = Array.isArray(target) ? [] : {}
for (const i in target) {
if (typeof target[i] === 'object') {
newTarget[i] = deepClone(target[i])
} else {
newTarget[i] = target[i]
}
}
return newTarget
} else {
return target
}
}

instanceof 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
function new_instance_of(leftVaule, rightVaule) { 
let rightProto = rightVaule.prototype; // 取右表达式的 prototype 值
leftVaule = leftVaule.__proto__; // 取左表达式的__proto__值
while (true) {
if (leftVaule === null) {
return false;
}
if (leftVaule === rightProto) {
return true;
}
leftVaule = leftVaule.__proto__
}
}

new 封装

1
2
3
4
5
function myNew(Con, ...args) {
let obj = Object.create(Con.prototype)
let result = Con.apply(obj, args)
return typeof obj === 'object' ? result : obj
}

手写 Promise (乞丐版)

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function MyPromise (executor) {
this.value = undefined
this.reason = undefined
this.status = 'pending' // 状态 pending、resolved、rejected,默认为 pending
this.resolvedCallbacks = []
this.rejectedCallbacks = []

function resolve (value) {
if (this.pending === 'pending') {
this.value = value
this.status = 'resolved'
this.resolvedCallbacks.forEach(fn => fn())
}
}
function reject (reason) {
if (this.pending === 'pending') {
this.reason = reason
this.status = 'rejected'
this.rejectedCallbacks.forEach(fn => fn())
}
}

executor(resolve, reject)
}
MyPromise.prototype.then = function (fn) {
if (this.status === 'resolved') {
fn(this.value)
}
if (this.status === 'pending') {
this.resolvedCallbacks.push(function () {
fn(this.value)
})
}
}
MyPromise.prototype.catch = function (fn) {
if (this.status === 'rejected') {
fn(this.reason)
}
if (this.status === 'pending') {
this.rejectedCallbacks.push(function () {
fn(this.reason)
})
}
}