Alex

Alex 查看完整档案

西安编辑西安理工大学  |  电气工程及其自动化 编辑  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

Alex 收藏了文章 · 1月4日

一文串联 HTTP、TCP、IP、以太网

最近部门组织了一次前端性能优化交流会,大家从输入页面 URL 到最终页面展示内容这个过程提出了许多优化点。但同时发现很多同学对 HTTP 协议层的知识不能串联起来,于是整理了这篇文章,希望可以给大家带来一丝灵感。

当我们在页面上发起一个 AJAX 请求的时候,在网络协议层面都经历了哪些内容?

// 发起请求
fetch('https://baidu.com')
// 协议层1...
// 协议层2...
// 协议层3...
.then(res=>
  // 得到结果
  console.log(res)
})

如上述代码所示,我们对 baidu.com 发起了一个网络请求,最终在 then 方法中得到了具体的响应内容。

使用 Wireshark 抓包结果如下:

图中可以看到,请求 baidu.com 时,首先通过 TCP 3 次握手建立连接,然后通过 HTTP 传输内容,最后通过 TCP 4 次挥手断开连接。

真实的过程更加复杂,我们主要分析以下几点:

  • 建立连接阶段

    • DNS 域名解析(应用层)
    • 建立 TCP 连接(传输层)

      • 通过 IP 寻址找到目标服务器(网络层)
      • 通过 Mac 寻址找到服务器硬件接口(数据链路层)
      • 通过网线向服务器硬件接口传输比特信息(物理层)
  • 发送数据阶段

    • 建立 SSL 安全连接(传输层)
    • 发送 HTTP 请求(应用层)

建立连接阶段

要获取 baidu.com 的网页内容,就需要和 baidu 服务器建立连接,怎样建立这个连接呢?

  1. 通过 DNS 获取 baidu 的 IP 地址。
  2. 建立 TCP 连接。

DNS 域名解析

通过 DNS 解析,我们就能找到 baidu 服务器对应的 IP 地址。

如图:

经过 DNS 解析后,我们就能得到 baidu.com 的 IP 地址了:39.156.69.79 和 220.181.38.148,通常客户端会随机选中一个 IP 地址进行通信。

域名的解析步骤

其实 IP 不一定要通过 DNS 解析才能获取,它通常会被客户端缓存,只有在 DNS 缓存都没有命中的时候才会请求 DNS 服务器。

判断步骤如下:

  1. 判断浏览器是否有缓存 IP 地址。
  2. 判断本机是否有缓存该 IP 地址,如:检查 Host 文件。
  3. 判断本地域名解析服务器是否有缓存 IP 地址,如:电信,联通等运营商。
  4. 向 DNS 根域名解析服务器,解析域名 IP 地址。
  5. 向 DNS 二根域名解析服务器,解析域名 IP 地址。
  6. 以此类推,最终获得 IP 地址。

建立 TCP 连接

有了 IP 地址之后,客户端和服务器端就能建立连接了,首先是建立 TCP 连接。

TCP 是一种面向连接的、可靠的、基于字节流的传输层通信协议。

在这一层,我们传输的数据会按照一个个的字节装入报文中,当报文的长度达到最大分段(MSS)时,就会发送这个报文。如果传输的报文很长,可能会被拆分成多个 TCP 报文进行传输。

TCP 报文头如下:

我们主要看以下几点:

  • 源端口、目的端口。
  • 序列号:seq,报文的唯一标识。
  • 确认号:ack,报文的确认标识,便于确认 seq 是否已经收到。
  • TCP 标记:

    • SYN 为 1 表示这是连接请求或是连接接受请求。用于创建连接和同步序列号。
    • ACK 为 1 表示确认号字段有效。注意这里大写的 ACK 只是一个标记,和确认号 ack 并不相同。
    • FIN 为 1 表示要求释放连接。
  • 窗口:表示发送方可以接收的字节数,即接收窗口大小,用于流量控制。

接下来,我们看一下 TCP 是怎样建立连接的?

如图所示,建立 TCP 连接需要 3 个步骤,俗称三次握手。

  • 第一次握手:客户端向服务器端发送序列号 seq=x 的标识,表示开始建立连接。
  • 第二次握手:服务器端回发一个 ack=x+1 的标识,表示确认收到第一次握手,同时发送自己的标识 seq=y。

    • 客户端确认自己发出的数据能够被服务器端收到。
  • 第三次握手:客户端发送 ack=y+1 的标识,标识确认收到第二次握手。

    • 服务器端确认自己发出的数据能够被客户端收到。

经过了 3 次握手,即保证了客户端和服务器端都能正常发送和接收数据,TCP 连接也就建立成功了。

TCP 可靠传输原理

上文中说到,TCP 是可靠的传输,这是为什么呢?

这是因为 TCP 内部使用了 停止等待协议 ARQ ,它通过 确认重传 机制,实现了信息的可靠传输。

例如:

  • 客户端发送数据 M1
  • 服务器端确认数据 M1 收到
  • 客户端发送数据 M2
  • 服务器端确认数据 M2 收到
  • 依次类推 ...

在这期间,如果某一条数据很久都没有得到确认,客户端就会重传这条数据。这样一来,对于与每一次发送的数据,服务器端都得到了确认,即保证了数据的可靠性。

虽然 ARQ 可以满足数据可靠性,但每次只能发送和确认一个请求,效率太低了,于是就产生了连续 ARQ 协议。

连续 ARQ 协议 会连续发送一组数据,然后再批量等待这一组数据的确认信息,好比把单线程 ARQ 变成了多线程,大大提高了资源的利用效率。

如:

  • 客户端发送数据 M1、M2、M3、M4。
  • 服务器端确认数据 M4 收到,表示 M4 及之前的数据都收到了。
  • 客户端发送数据 M5、M6、M7、M8。
  • 服务器端确认数据 M8 收到,表示 M8 及之前的数据都收到了。

在这个流程中,服务器端不需要对每一个数据都返回确认信息,而是接收到多个数据时一并确认,这个方式叫做 累计确认


这里有个疑问,TCP 的每一次握手,是怎么找到目的服务器呢?

答:通过 IP 协议。

根据 IP 协议找到目标服务器

IP 协议的目的是实现网络层的数据转发,它通过路由器不断跳转,最终把数据成功送达目的地。

上文中的每一次 TCP 握手以及数据交互,都是通过 IP 协议去传输的。

IP 报文头如下:

我们关注以下两点就可以了:

  • 源 IP 地址
  • 目的 IP 地址

发起一个 IP 请求执行流程如下:

  1. 构建 IP 请求头(源 IP、目标 IP)。
  2. IP 协议通过算法,计算出一条通往服务器端的路径。
  3. 发送端查询路由表,找出下一跳的 IP 地址(通常是路由器),并发送数据。
  4. 路由器查询路由表,找出下一跳的 IP 地址,并发送数据。
  5. 不断重复步骤 4,直到找到目的局域网。
  6. 发送数据。
路由表存在于计算机或路由器中,由目的 IP 地址、子网掩码、下一跳地址、发送接口四部分组成。通过目的 IP 地址,即可找到下一跳的地址,进行转发。

例如:A 要向 G 发送 IP 数据。

具体流程如下:

  • A 生成 IP 头部(源 IP:A ,目的 IP:G)

    • A 查询路由表,发现下一跳为 B,于是把数据传给 B。
  • B 生成 IP 头部(源 IP:A ,目的 IP:G)

    • B 查询路由表,发现下一跳为 E,于是把数据传给 E。
  • E 生成 IP 头部(源 IP:A ,目的 IP:G)

    • E 查询路由表,发现下一跳为 G,于是把数据传给 G。
  • 到达目的地 G。

你是否有疑惑,为什么 IP 会按照这条路径向 G 传输数据呢?

其实,上图中的路径并非只有一条,我们通过 ABEG 到达了目的地 G,同样也可以通过 ABCFHG 到达 G,这两种路径都能完成任务,为什么 IP 不选择 ABCFHG 这条路径呢?

这就涉及到了 IP 寻址的算法。

IP 寻址算法

我们可以把网络中的所有计算机都看做是一个点,计算机之间的连接看做是一条线,这些点和线就组合成了一个图。

例如:

通过上图,我们就把复杂的网络转化成了数学问题。IP 寻址算法,其实就是图论中的最短路径的算法。

最短路径算法在 IP 协议中有 2 种实现:

  • RIP 协议

    • 使用距离矢量算法,确保 IP 路由跳转的次数最小
    • 原理

      • 每个节点中都保存有其他节点的位置信息(跳数和下一跳的 IP)。
      • 通过和邻居节点进行数据交换,更新自己到目的地的最短距离,不断重复,即可得到起点到终点的最短路径。
      • 实现简单,开销很小,适用于小型网络。
  • OSPF 协议

    • 使用迪杰斯特拉算法,确保 IP 路由跳转的速度最快
    • 原理

      • 从起始点开始,采用贪心算法的策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止。
      • 适用于大型网络。

通过以上两个协议,我们就能找到通往目的地的路径了。


这里抛出一个问题:IP 数据是怎样从一个路由器跳到另一个路由器呢?

答:通过以太网协议。

通过 Mac 寻址找到服务器硬件接口

IP 协议主要是用来寻找最优路径的,具体的传输是由以太网协议来做的。

以太网属于数据链路层,它主要负责相邻设备的通信。原理是通过查询交换机 Mac 表,找到通信双方的物理接口,进而开始通信。

以太网报文头如下:

我们只用关心以下 3 个点:

  • 源 Mac 地址
  • 目的 Mac 地址
  • 校验码 CRC:校验当前帧是否有效。

可以看到,以太网层都是通过 Mac 地址进行通信的,这里的 Mac 地址是哪里来的呢?

答:通过 ARP 协议。

ARP 协议 是一个通过解析 IP 地址来找寻 Mac 地址的协议。IP 地址转换成 Mac 地址后,就能进行以太网数据传输了。

例如:

当机器 A 向机器 C 发送数据时:

  • A 构建以太网报文(源地址:A,目的地址:C),并通过网卡发出数据帧。
  • 数据帧到达交换机 B,交换机取出目的地址 C 的 Mac 地址。
  • B 查询 Mac 表,根据目的地 Mac 地址,匹配 C 的硬件接口。

    • 如果找到 C 的硬件接口,发送数据。
    • 如果未找到 C 的硬件接口,向 B 直连的所有机器发送广播信息找 C,找到后会把 C 记录到 Mac 表中。

经过上述的流程,我们就找到了目的机器的硬件接口。


通过以太网协议,我们找到了目标机器的硬件接口,接下来要怎么发送信息呢?

答:通过物理层。

通过网线向服务器硬件接口传输比特信息

在没有 WiFi 的年代,我们只能通过插网线来进行上网,网线其实就是物理层的设备之一。

网线可以由多种材料组成,最常见的就是光纤和电缆。

光纤和电缆的传输原理类似,都是通过两个信号来模拟二进制数据的,一个信号即为一个比特。

  • 电缆中:高电位表示 1 ,低点位表示 0。
  • 光纤中:光亮表示 1,光熄灭表示 0。

如:在光纤中,我们通过观察光的闪动,即可得知传输的二进制数据。

有了这些物理设备,我们就能把复杂的数据转换成光信号或者电信号进行传输了。

发送数据阶段

发送数据可以分为两个步骤:

  • 建立安全层 SSL
  • 发送 HTTP 请求

建立安全层 SSL

本文的案例是发送一个 HTTPS 的请求,所以在发送数据之前,会创建一个 SSL 安全层,用于数据加密。

通常的加密方法有两种:

  • 非对称加密

    • A 有钥匙,B 没有钥匙,且他们都有一个公共的锁,B 给 A 发送数据时,都会先把数据锁起来再发送。
    • 接收数据时,A 用钥匙解开锁,即可得到数据。除 A 以外,其他人没有钥匙,也就获取不到数据。
    • 实现了单向通信加密。
  • 对称加密

    • A、B 双方都有一把相同的钥匙和一个公共的锁,每次发送数据时,都把数据放在锁里进行发送。
    • 接收数据时,A、B 双方就用各自的钥匙来解锁。其他人没有钥匙,也就获取不到数据。
    • 实现了双向通信加密。

互联网通信是双向的,所以我们需要使用对称加密,可是,怎样才能保证通信双方都有一把相同的钥匙呢?

目前的解决方案:

  • 先使用非对称加密,进行秘钥协商,让通信双方拿到相同的钥匙。
  • 然后使用对称加密,进行加密传输。

秘钥协商过程如图:

图中划重点:

  1. 客户端发送自身支持的加密算法。
  2. 服务器端选择一种加密算法,同时返回数字证书。
  3. 客户端确认证书有效。
  4. 客户端生成随机数,并使用证书中的服务器公钥加密,然后发送给服务器。
  5. 服务器端使用私钥解密,获得随机数。
  6. 双方使用第 2 步确定的加密算法,把随机数进行加密,即可获得相同的对称加密秘钥。

Ok,秘钥协商之后,我们的 SSL 安全层也就建好了。

秘钥协商时存在一个问题:

秘钥协商时,怎么保证是和真正的服务器在协商,而不是一个中间人呢?

答:数字证书

数字证书重点关注 2 个部分:

  • 服务器公钥
  • 数字签名

其中,数字签名又是由服务器公钥和证书私钥加密生成的,目的是为了防止服务器公钥被篡改。

有了数字证书,客户端就能通过验证证书,来判断服务器是否是真正的服务器了。

验证逻辑如下:

可以看到,数字证书通过同样的算法进行解密,如果得到相同的信息摘要,就能保证数据是有效的,如果不一致,则会验证失败,拒绝后续的请求。

到这里为止,所有的准备工作都就绪了,接下来才是发送 HTTP 请求。

发送 HTTP 请求

HTTP 协议其实就是制定了一个通信规则,规定了客户端和服务器之间的通信格式。

以请求 baidu 首页为例:

如上图所示,发起 HTTP 请求时,必须遵守以下规则:

  • 请求方法(必填) GET
  • 请求地址(必填) /
  • HTTP 协议版本(必填) 1.1
  • 其他 HTTP 头部字段(可选) HostUser-AgentAccept
  • 请求参数,放在空行后面(可选)

服务器响应请求时,同样遵守了 HTTP 响应规则:

  • HTTP 协议版本(必填) 1.1
  • 响应状态码(必填) 200
  • 状态码描述(必填) OK
  • 其他 HTTP 头部字段(可选) DateServerETagLast-Modified
  • 请求参数,放在空行后面(可选)

只要我们遵守这个规则,就能进行 HTTP 通信了。

到目前为止,我们已经分析完成了数据请求的所有过程,你是否都理解了呢?

思考与总结

本文通过一个网络请求,对整个 HTTP、TCP、IP、以太网等协议进行了流程化分析,最后再梳理一下:

  1. 请求 baidu.com。
  2. DNS 解析 baidu.com,得到 IP 地址。
  3. 建立 TCP 连接。
  4. IP 协议通过算法,计算出一条通往服务器最优路径。
  5. IP 沿着路径跳转时,会通过 ARP 协议把 IP 地址转换成 Mac 地址。
  6. 以太网通过 Mac 地址,找到通信双方的硬件接口。
  7. 物理层通过网线作为载体,在两个硬件接口之间传输比特信号。
  8. TCP 连接建立完毕。
  9. 建立 SSL 安全层。
  10. 发送 HTTP 请求。

最后,如果你对此有任何想法,欢迎留言评论!

查看原文

Alex 赞了文章 · 1月4日

一文串联 HTTP、TCP、IP、以太网

最近部门组织了一次前端性能优化交流会,大家从输入页面 URL 到最终页面展示内容这个过程提出了许多优化点。但同时发现很多同学对 HTTP 协议层的知识不能串联起来,于是整理了这篇文章,希望可以给大家带来一丝灵感。

当我们在页面上发起一个 AJAX 请求的时候,在网络协议层面都经历了哪些内容?

// 发起请求
fetch('https://baidu.com')
// 协议层1...
// 协议层2...
// 协议层3...
.then(res=>
  // 得到结果
  console.log(res)
})

如上述代码所示,我们对 baidu.com 发起了一个网络请求,最终在 then 方法中得到了具体的响应内容。

使用 Wireshark 抓包结果如下:

图中可以看到,请求 baidu.com 时,首先通过 TCP 3 次握手建立连接,然后通过 HTTP 传输内容,最后通过 TCP 4 次挥手断开连接。

真实的过程更加复杂,我们主要分析以下几点:

  • 建立连接阶段

    • DNS 域名解析(应用层)
    • 建立 TCP 连接(传输层)

      • 通过 IP 寻址找到目标服务器(网络层)
      • 通过 Mac 寻址找到服务器硬件接口(数据链路层)
      • 通过网线向服务器硬件接口传输比特信息(物理层)
  • 发送数据阶段

    • 建立 SSL 安全连接(传输层)
    • 发送 HTTP 请求(应用层)

建立连接阶段

要获取 baidu.com 的网页内容,就需要和 baidu 服务器建立连接,怎样建立这个连接呢?

  1. 通过 DNS 获取 baidu 的 IP 地址。
  2. 建立 TCP 连接。

DNS 域名解析

通过 DNS 解析,我们就能找到 baidu 服务器对应的 IP 地址。

如图:

经过 DNS 解析后,我们就能得到 baidu.com 的 IP 地址了:39.156.69.79 和 220.181.38.148,通常客户端会随机选中一个 IP 地址进行通信。

域名的解析步骤

其实 IP 不一定要通过 DNS 解析才能获取,它通常会被客户端缓存,只有在 DNS 缓存都没有命中的时候才会请求 DNS 服务器。

判断步骤如下:

  1. 判断浏览器是否有缓存 IP 地址。
  2. 判断本机是否有缓存该 IP 地址,如:检查 Host 文件。
  3. 判断本地域名解析服务器是否有缓存 IP 地址,如:电信,联通等运营商。
  4. 向 DNS 根域名解析服务器,解析域名 IP 地址。
  5. 向 DNS 二根域名解析服务器,解析域名 IP 地址。
  6. 以此类推,最终获得 IP 地址。

建立 TCP 连接

有了 IP 地址之后,客户端和服务器端就能建立连接了,首先是建立 TCP 连接。

TCP 是一种面向连接的、可靠的、基于字节流的传输层通信协议。

在这一层,我们传输的数据会按照一个个的字节装入报文中,当报文的长度达到最大分段(MSS)时,就会发送这个报文。如果传输的报文很长,可能会被拆分成多个 TCP 报文进行传输。

TCP 报文头如下:

我们主要看以下几点:

  • 源端口、目的端口。
  • 序列号:seq,报文的唯一标识。
  • 确认号:ack,报文的确认标识,便于确认 seq 是否已经收到。
  • TCP 标记:

    • SYN 为 1 表示这是连接请求或是连接接受请求。用于创建连接和同步序列号。
    • ACK 为 1 表示确认号字段有效。注意这里大写的 ACK 只是一个标记,和确认号 ack 并不相同。
    • FIN 为 1 表示要求释放连接。
  • 窗口:表示发送方可以接收的字节数,即接收窗口大小,用于流量控制。

接下来,我们看一下 TCP 是怎样建立连接的?

如图所示,建立 TCP 连接需要 3 个步骤,俗称三次握手。

  • 第一次握手:客户端向服务器端发送序列号 seq=x 的标识,表示开始建立连接。
  • 第二次握手:服务器端回发一个 ack=x+1 的标识,表示确认收到第一次握手,同时发送自己的标识 seq=y。

    • 客户端确认自己发出的数据能够被服务器端收到。
  • 第三次握手:客户端发送 ack=y+1 的标识,标识确认收到第二次握手。

    • 服务器端确认自己发出的数据能够被客户端收到。

经过了 3 次握手,即保证了客户端和服务器端都能正常发送和接收数据,TCP 连接也就建立成功了。

TCP 可靠传输原理

上文中说到,TCP 是可靠的传输,这是为什么呢?

这是因为 TCP 内部使用了 停止等待协议 ARQ ,它通过 确认重传 机制,实现了信息的可靠传输。

例如:

  • 客户端发送数据 M1
  • 服务器端确认数据 M1 收到
  • 客户端发送数据 M2
  • 服务器端确认数据 M2 收到
  • 依次类推 ...

在这期间,如果某一条数据很久都没有得到确认,客户端就会重传这条数据。这样一来,对于与每一次发送的数据,服务器端都得到了确认,即保证了数据的可靠性。

虽然 ARQ 可以满足数据可靠性,但每次只能发送和确认一个请求,效率太低了,于是就产生了连续 ARQ 协议。

连续 ARQ 协议 会连续发送一组数据,然后再批量等待这一组数据的确认信息,好比把单线程 ARQ 变成了多线程,大大提高了资源的利用效率。

如:

  • 客户端发送数据 M1、M2、M3、M4。
  • 服务器端确认数据 M4 收到,表示 M4 及之前的数据都收到了。
  • 客户端发送数据 M5、M6、M7、M8。
  • 服务器端确认数据 M8 收到,表示 M8 及之前的数据都收到了。

在这个流程中,服务器端不需要对每一个数据都返回确认信息,而是接收到多个数据时一并确认,这个方式叫做 累计确认


这里有个疑问,TCP 的每一次握手,是怎么找到目的服务器呢?

答:通过 IP 协议。

根据 IP 协议找到目标服务器

IP 协议的目的是实现网络层的数据转发,它通过路由器不断跳转,最终把数据成功送达目的地。

上文中的每一次 TCP 握手以及数据交互,都是通过 IP 协议去传输的。

IP 报文头如下:

我们关注以下两点就可以了:

  • 源 IP 地址
  • 目的 IP 地址

发起一个 IP 请求执行流程如下:

  1. 构建 IP 请求头(源 IP、目标 IP)。
  2. IP 协议通过算法,计算出一条通往服务器端的路径。
  3. 发送端查询路由表,找出下一跳的 IP 地址(通常是路由器),并发送数据。
  4. 路由器查询路由表,找出下一跳的 IP 地址,并发送数据。
  5. 不断重复步骤 4,直到找到目的局域网。
  6. 发送数据。
路由表存在于计算机或路由器中,由目的 IP 地址、子网掩码、下一跳地址、发送接口四部分组成。通过目的 IP 地址,即可找到下一跳的地址,进行转发。

例如:A 要向 G 发送 IP 数据。

具体流程如下:

  • A 生成 IP 头部(源 IP:A ,目的 IP:G)

    • A 查询路由表,发现下一跳为 B,于是把数据传给 B。
  • B 生成 IP 头部(源 IP:A ,目的 IP:G)

    • B 查询路由表,发现下一跳为 E,于是把数据传给 E。
  • E 生成 IP 头部(源 IP:A ,目的 IP:G)

    • E 查询路由表,发现下一跳为 G,于是把数据传给 G。
  • 到达目的地 G。

你是否有疑惑,为什么 IP 会按照这条路径向 G 传输数据呢?

其实,上图中的路径并非只有一条,我们通过 ABEG 到达了目的地 G,同样也可以通过 ABCFHG 到达 G,这两种路径都能完成任务,为什么 IP 不选择 ABCFHG 这条路径呢?

这就涉及到了 IP 寻址的算法。

IP 寻址算法

我们可以把网络中的所有计算机都看做是一个点,计算机之间的连接看做是一条线,这些点和线就组合成了一个图。

例如:

通过上图,我们就把复杂的网络转化成了数学问题。IP 寻址算法,其实就是图论中的最短路径的算法。

最短路径算法在 IP 协议中有 2 种实现:

  • RIP 协议

    • 使用距离矢量算法,确保 IP 路由跳转的次数最小
    • 原理

      • 每个节点中都保存有其他节点的位置信息(跳数和下一跳的 IP)。
      • 通过和邻居节点进行数据交换,更新自己到目的地的最短距离,不断重复,即可得到起点到终点的最短路径。
      • 实现简单,开销很小,适用于小型网络。
  • OSPF 协议

    • 使用迪杰斯特拉算法,确保 IP 路由跳转的速度最快
    • 原理

      • 从起始点开始,采用贪心算法的策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止。
      • 适用于大型网络。

通过以上两个协议,我们就能找到通往目的地的路径了。


这里抛出一个问题:IP 数据是怎样从一个路由器跳到另一个路由器呢?

答:通过以太网协议。

通过 Mac 寻址找到服务器硬件接口

IP 协议主要是用来寻找最优路径的,具体的传输是由以太网协议来做的。

以太网属于数据链路层,它主要负责相邻设备的通信。原理是通过查询交换机 Mac 表,找到通信双方的物理接口,进而开始通信。

以太网报文头如下:

我们只用关心以下 3 个点:

  • 源 Mac 地址
  • 目的 Mac 地址
  • 校验码 CRC:校验当前帧是否有效。

可以看到,以太网层都是通过 Mac 地址进行通信的,这里的 Mac 地址是哪里来的呢?

答:通过 ARP 协议。

ARP 协议 是一个通过解析 IP 地址来找寻 Mac 地址的协议。IP 地址转换成 Mac 地址后,就能进行以太网数据传输了。

例如:

当机器 A 向机器 C 发送数据时:

  • A 构建以太网报文(源地址:A,目的地址:C),并通过网卡发出数据帧。
  • 数据帧到达交换机 B,交换机取出目的地址 C 的 Mac 地址。
  • B 查询 Mac 表,根据目的地 Mac 地址,匹配 C 的硬件接口。

    • 如果找到 C 的硬件接口,发送数据。
    • 如果未找到 C 的硬件接口,向 B 直连的所有机器发送广播信息找 C,找到后会把 C 记录到 Mac 表中。

经过上述的流程,我们就找到了目的机器的硬件接口。


通过以太网协议,我们找到了目标机器的硬件接口,接下来要怎么发送信息呢?

答:通过物理层。

通过网线向服务器硬件接口传输比特信息

在没有 WiFi 的年代,我们只能通过插网线来进行上网,网线其实就是物理层的设备之一。

网线可以由多种材料组成,最常见的就是光纤和电缆。

光纤和电缆的传输原理类似,都是通过两个信号来模拟二进制数据的,一个信号即为一个比特。

  • 电缆中:高电位表示 1 ,低点位表示 0。
  • 光纤中:光亮表示 1,光熄灭表示 0。

如:在光纤中,我们通过观察光的闪动,即可得知传输的二进制数据。

有了这些物理设备,我们就能把复杂的数据转换成光信号或者电信号进行传输了。

发送数据阶段

发送数据可以分为两个步骤:

  • 建立安全层 SSL
  • 发送 HTTP 请求

建立安全层 SSL

本文的案例是发送一个 HTTPS 的请求,所以在发送数据之前,会创建一个 SSL 安全层,用于数据加密。

通常的加密方法有两种:

  • 非对称加密

    • A 有钥匙,B 没有钥匙,且他们都有一个公共的锁,B 给 A 发送数据时,都会先把数据锁起来再发送。
    • 接收数据时,A 用钥匙解开锁,即可得到数据。除 A 以外,其他人没有钥匙,也就获取不到数据。
    • 实现了单向通信加密。
  • 对称加密

    • A、B 双方都有一把相同的钥匙和一个公共的锁,每次发送数据时,都把数据放在锁里进行发送。
    • 接收数据时,A、B 双方就用各自的钥匙来解锁。其他人没有钥匙,也就获取不到数据。
    • 实现了双向通信加密。

互联网通信是双向的,所以我们需要使用对称加密,可是,怎样才能保证通信双方都有一把相同的钥匙呢?

目前的解决方案:

  • 先使用非对称加密,进行秘钥协商,让通信双方拿到相同的钥匙。
  • 然后使用对称加密,进行加密传输。

秘钥协商过程如图:

图中划重点:

  1. 客户端发送自身支持的加密算法。
  2. 服务器端选择一种加密算法,同时返回数字证书。
  3. 客户端确认证书有效。
  4. 客户端生成随机数,并使用证书中的服务器公钥加密,然后发送给服务器。
  5. 服务器端使用私钥解密,获得随机数。
  6. 双方使用第 2 步确定的加密算法,把随机数进行加密,即可获得相同的对称加密秘钥。

Ok,秘钥协商之后,我们的 SSL 安全层也就建好了。

秘钥协商时存在一个问题:

秘钥协商时,怎么保证是和真正的服务器在协商,而不是一个中间人呢?

答:数字证书

数字证书重点关注 2 个部分:

  • 服务器公钥
  • 数字签名

其中,数字签名又是由服务器公钥和证书私钥加密生成的,目的是为了防止服务器公钥被篡改。

有了数字证书,客户端就能通过验证证书,来判断服务器是否是真正的服务器了。

验证逻辑如下:

可以看到,数字证书通过同样的算法进行解密,如果得到相同的信息摘要,就能保证数据是有效的,如果不一致,则会验证失败,拒绝后续的请求。

到这里为止,所有的准备工作都就绪了,接下来才是发送 HTTP 请求。

发送 HTTP 请求

HTTP 协议其实就是制定了一个通信规则,规定了客户端和服务器之间的通信格式。

以请求 baidu 首页为例:

如上图所示,发起 HTTP 请求时,必须遵守以下规则:

  • 请求方法(必填) GET
  • 请求地址(必填) /
  • HTTP 协议版本(必填) 1.1
  • 其他 HTTP 头部字段(可选) HostUser-AgentAccept
  • 请求参数,放在空行后面(可选)

服务器响应请求时,同样遵守了 HTTP 响应规则:

  • HTTP 协议版本(必填) 1.1
  • 响应状态码(必填) 200
  • 状态码描述(必填) OK
  • 其他 HTTP 头部字段(可选) DateServerETagLast-Modified
  • 请求参数,放在空行后面(可选)

只要我们遵守这个规则,就能进行 HTTP 通信了。

到目前为止,我们已经分析完成了数据请求的所有过程,你是否都理解了呢?

思考与总结

本文通过一个网络请求,对整个 HTTP、TCP、IP、以太网等协议进行了流程化分析,最后再梳理一下:

  1. 请求 baidu.com。
  2. DNS 解析 baidu.com,得到 IP 地址。
  3. 建立 TCP 连接。
  4. IP 协议通过算法,计算出一条通往服务器最优路径。
  5. IP 沿着路径跳转时,会通过 ARP 协议把 IP 地址转换成 Mac 地址。
  6. 以太网通过 Mac 地址,找到通信双方的硬件接口。
  7. 物理层通过网线作为载体,在两个硬件接口之间传输比特信号。
  8. TCP 连接建立完毕。
  9. 建立 SSL 安全层。
  10. 发送 HTTP 请求。

最后,如果你对此有任何想法,欢迎留言评论!

查看原文

赞 83 收藏 52 评论 4

Alex 赞了文章 · 2020-11-24

Mac电脑下载YouTube视频的方法

Windows上面下载YouTube视频的软件有很多,今天我们来谈谈Mac电脑上面的软件。虽然Mac系统依然很小众,但是苹果也是全球第4大PC厂商,使用Mac电脑的人也不少了。下面我们一起来看一下吧!

1. Gihosoft TubeGet Mac版
一般来说,网上下载YouTube视频资源的方法主要分为两种,一种就是客户端软件。客户端软件的特点是功能相对比较强大,但是可能会占用比较大的系统内存。这里首先给大家介绍的是- Gihosoft TubeGet,一款操作简单、性能稳定的Mac版YouTube视频下载软件,当然它也有Windows版。大家可以自行百度搜索,很容易就能下载到,各大下载站点基本都有收录。

Gihosoft TubeGet比较好的一点就是它界面比较清爽,没有多余的设计,而且它也有中文版。安装和卸载也很方便,不会有什么捆绑软件和弹窗广告。只需轻轻点击鼠标几下,就可以将在线的YouTube视频保存到MacBook上面。另外它还支持直接从YouTube视频中获取MP3音乐,而不用下载整个视频内容。支持下载YouTube 4K(2160P)和8K(4320P)的超高清视频内容。同时还可以一次性将YouTube播放列表下载为视频或音频文件。不过目前该软件免费版功能有限制,一天只能下载5个视频,1个播放列表。

使用Gihosoft TubeGet在Mac上下载YouTube视频方法如下:

  1. 在你的MacBook电脑上安装并运行该视频下载软件;
  2. 在浏览器的URL栏里面复制想要下载的YouTube视频链接;
  3. 分析链接,选择下载分辨率,以及其他需要设置的地方;
  4. 单击下载按钮进行下载。

图片描述

Tips:当然MacBook上面也可以使用网站去下载YouTube上面的视频,参考文章:10 Best Online YouTube Downloader to Save HD Videos Free

2. Wondershare AllMyTube Mac版
Wondershare AllMyTube也是一款客户端YouTube视频下载软件,包含Windows和Mac版。AllMyTube支持从YouTube、Dailymotion、Break、Metacafe、BlipTV、Vimeo、AOL、Lynda、MegaVideo、Veoh、VideoBash、LiveLeak、MyVideo、FunnyOrDie、Nico Video、TV.com、Veevr、Adobe TV等多种国内外的下载视频网站下载视频。

AllMyTube的设计也很简单,分为正在下载、下载完成、转换和传输4大块。你可以将下载完成的YouTube视频转换成其他的格式,也可以将该视频传输到你的手机上面。不过想要使用传输功能,必须要在手机上面安装相应的软件,这点有点不好。另外,它也支持下载年龄限制的视频,以及获取视频文件上面的字幕等实用功能。AllMyTube免费版只能下载2次,使用方法也很简单,和上面的Gihosoft TubeGet如出一辙。

图片描述

3. Airy for Mac
Airy是一款在Mac上非常小巧的YouTube视频下载器。和其他软件相比,Airy只支持下载YouTube视频,不支持下载其他的网站。这个软件的设计完全是极简主义的代表,除了下载必须的按钮,没有其他任何多余的功能。Airy包含一般的YouTube视频下载软件所包含的功能,比如下载高清视频,支持下载1080P和4K极清视频。也支持将YouTube视频转换成MP3。

Airy支持同时批量下载多个视频,而且也有断点续传的功能。你可以在将下载的视频点击暂停,关闭软件,下次继续从断开的地方进行下载。当然,下载播放列表也是Airy的强项。Airy支持打包下载YouTube播放列表里面的全部视频,将其保存为视频或音频文件。注意播放列表和频道的下载和解析时间要比单个视频的时间要长一些,免费版只能下载2次。

使用Airy在Mac电脑上下载YouTube视频的步骤:

  1. 下载并安装Airy Mac版;
  2. 复制要下载的YouTube视频链接,并将其粘贴到Airy窗口;
  3. 选择想要下载的视频文件分辨率和保存位置。默认情况下,Airy 会将文件保存到 Downloads 文件夹中。
  4. 点击下载按钮开始下载。

图片描述

4. Downie
Downie是一款简单易用的YouTube视频下载软件,适合mac OS系统。支持YouTube、Vimeo、Dailyotion等视频网站的离线下载。Downie for Mac的界面很简洁,但是它的功能选项却很多,你都可以自己设置。比如你是否需要直接下载视频,是否需要自动选择最佳分辨率等等。

Downie的使用方法和一般的下载软件有点不同,采用的是拖放式,你需要将链接拖动到主界面,而不是像其他软件那样复制粘贴。如果要下载的视频有字幕,解析完成后会首先弹出字幕选项,让你选择需要下载的字幕语言;因为分辨率都是你自己先设置好的,如果没有字幕选项,则直接进入视频下载,不需要多余的设置,这点真的很人性化。当然如果你不太擅长设置一些参数,直接选择简单模式即可,Downie会默认配置最佳选项。Downie免费版可以试用15天,提供多种语言版本。

图片描述

5. MediaHuman
MediaHuman也是一款在Mac电脑上面运行的YouTube视频下载软件。MediaHuman支持几十种语言,但是没有看到简体中文。这个软件最高支持下载4K到8K的极清视频,同时也支持一次性下载播放列表和频道中的所有视频。而且也能直接获取视频里面的音频,你可以下载成原始的M4A格式,或者是转换成MP3,然后同步将其导入到iTunes。

在参数设置里面,你可以设置同时下载的个数,最高同时可以下载12个视频。如果你正在处理其他事情,也可以限制下载的速度。这个软件还有个好处是可以设置防休眠模式,这样你通宵下载视频的时候就不会断掉了。以上只是MediaHuman的一小部分功能,它也支持proxy的功能,支持事先设置将视频保存为某种特定的分辨率和格式,比如MP4,FLV或者是WebM格式。MediaHuman免费版最多只能下载20个视频,之后就要付费了。

图片描述

最后
其实,Mac电脑上面支持下载油管视频的软件虽然不多,但也不是寥寥无几,还是有其他类似的软件的。我从Google上面搜索了一下,还有其他的工具,参考文章:Top 12 YouTube Video Downloader for Mac that Work 2019

查看原文

赞 3 收藏 0 评论 8

Alex 赞了文章 · 2020-11-20

如何从YouTube下载视频和字幕并将视频和字幕合并在一起

YouTube里面有很多英文的视频资源,很多时候需要借助字幕才能弄懂。如果你想把一些YouTube视频下载下来,上传到其他视频网站上,会发现YouTube上的字幕和视频是独立的,没有合成到一起,所以,下载下来的视频是不含字幕的。如果你想把这些视频连带字幕文件一起下载下来,该怎么操作呢?这里使用的是Gihosoft TubeGet软件,下面一起来看下具体的操作方法。

一. 同时下载YouTube上的视频和字幕文件

因为在YouTube上面,视频和字幕文件是分开的。所以,我们需要先分开下载视频和字幕文件,这时用的是Gihosoft TubeGet软件,可以一起下载视频和字幕文件,一步到位。以下就是具体的下载方法:

  1. 首先就是进入到YouTube,然后在里面找到需要下载的视频,带“CC”表示有字幕,点击播放该视频。
  2. 在播放的时候,复制地址栏里出现的视频URL链接。
  3. 下载并安装Gihosoft TubeGet。安装好后,打开软件,点击“粘贴链接”按钮,刚刚复制的链接会被自动粘贴进去,并开始解析。
  4. 解析好后,弹出参数设置窗口,选择需要下载的视频质量。下面会有个“下载字幕”的选项,点击勾选,会显示该视频所包含的语言,选择需要下载的字幕语言即可。
  5. 最后点击“下载”按钮,视频开始下载,字幕也会跟着下载下来,下载的字幕文件为VTT和SRT格式。

image

二. 合并下载后的视频和字幕文件

视频和字幕文件下载完成后,是两个单独的文件。有些播放器不支持播放字幕的,因此有时候我们需要将它们合成为一个整体的带字幕视频。如何合成下载完成的YouTube视频和字幕文件呢?下面我们看看具体的操作方法:

  1. 打开Gihosoft TubeGet软件,点击进入“转换”板块。
  2. 然后,点击里面的“添加视频”按钮,导入需要转换的视频文件。
  3. 接着在右上角的“转换格式”那里选择需要转换的格式;然后点击“添加字幕文件”按钮,导入要转换的SRT字幕文件,然后选择以“硬字幕”或“软字幕”形式添加。
  4. 最后点击“转换”按钮,视频开始转换,字幕也会嵌入到视频中去。

image

以上就是下载YouTube视频和字幕,并合并在一起的方法。当然还有其他的方法,我以后也会写到。

查看原文

赞 1 收藏 0 评论 4

Alex 发布了文章 · 2020-11-13

批量下载YouTube视频的方法,支持播放列表和频道

在YouTube上面观看视频,如果你喜欢某一类视频,可以将它们整理放在一个播放列表里面,你也可以将这类列表设为私有或者是公开的形式。有时候你也需要将播放列表里的视频都下载到本地保存起来,或者是某个上传者的全部视频,也就是频道。那么,如何批量下载YouTube播放列表或频道里的视频呢?

这里使用的是Gihosoft TubeGet软件,可以一次性批量下载一个播放列表或者频道里的所有视频。只需执行几个步骤,以下就是操作的方法。

批量下载YouTube播放列表视频的步骤如下:
1. 下载Gihosoft TubeGet软件,Windows版和Mac版都有,根据你的电脑系统选择对应的版本。下载完成后,直接安装即可。

2. 接下来就要去获取播放列表的URL链接了。需要进入YouTube,打开要下载的播放列表或频道,复制地址栏里面的视频链接。通常你需要复制列表的链接,但是复制单个视频的链接也可以下载。不过解析的时候,会多一个选择步骤。

3. 打开Gihosoft TubeGet软件,点击“粘贴链接”按钮,软件开始去获取链接的必要的参数和信息。链接解析完成后,就会弹出分辨率选择窗口。注意:由于列表的个数比较多,所以解析时间会比解析单个视频稍长一点。

4. 如果你复制的是单个视频的链接,点击“粘贴链接”按钮后,软件会提示你是否要批量下载整个列表的视频,选择第二个选项即可。

5. 解析完成后,分辨率界面会跳出来,里面包含720P、1080P、2K和4K选项。如果你选择的是1080P,则大于该分辨率的视频会下载成1080P,小于1080P的会下载成该视频的最大分辨率。这个界面你也可以勾选指定的视频进行下载。

批量解析youtube播放列表视频

6. 点击下载按钮,播放列表里的开始按顺序进行下载,进度条开始走动,旁边也会显示下载百分比。列表同时下载5个视频,一个视频下载完成后,按顺序再去下载下一个视频。

image

7. 下载完成后,可以在对应的位置找到视频。该列表或频道里的视频是放在一个文件夹里面的,名字一致。

总结
以上就是使用Gihosoft TubeGet下载YouTube播放列表或者频道视频的方法。注意:最后下载的视频可能个数偏少,这是因为列表里面含有被删除或者是私人视频,解析的时候会自动跳过,所以最后的视频会偏少。

更多批量下载YouTube视频的方法,可以参考文章:批量下载YouTube视频/音频的方法

查看原文

赞 1 收藏 0 评论 0

Alex 赞了文章 · 2020-11-13

手把手带你入门前端工程化——超详细教程

本文将分成以下 7 个小节:

  1. 技术选型
  2. 统一规范
  3. 测试
  4. 部署
  5. 监控
  6. 性能优化
  7. 重构

部分小节提供了非常详细的实战教程,让大家动手实践。

另外我还写了一个前端工程化 demo 放在 github 上。这个 demo 包含了 js、css、git 验证,其中 js、css 验证需要安装 VSCode,具体教程在下文中会有提及。

技术选型

对于前端来说,技术选型挺简单的。就是做选择题,三大框架中选一个。个人认为可以依据以下两个特点来选:

  1. 选你或团队最熟的,保证在遇到棘手的问题时有人能填坑。
  2. 选市场占有率高的。换句话说,就是选好招人的。

第二点对于小公司来说,特别重要。本来小公司就不好招人,要是还选一个市场占有率不高的框架(例如 Angular),简历你都看不到几个...

UI 组件库更简单,github 上哪个 star 多就用哪个。star 多,说明用的人就多,很多坑别人都替你踩过了,省事。

统一规范

代码规范

先来看看统一代码规范的好处:

  • 规范的代码可以促进团队合作
  • 规范的代码可以降低维护成本
  • 规范的代码有助于 code review(代码审查)
  • 养成代码规范的习惯,有助于程序员自身的成长

当团队的成员都严格按照代码规范来写代码时,可以保证每个人的代码看起来都像是一个人写的,看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性,并坚持规范的开发习惯。

如何制订代码规范

建议找一份好的代码规范,在此基础上结合团队的需求作个性化修改。

下面列举一些 star 较多的 js 代码规范:

css 代码规范也有不少,例如:

如何检查代码规范

使用 eslint 可以检查代码符不符合团队制订的规范,下面来看一下如何配置 eslint 来检查代码。

  1. 下载依赖
// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. 配置 .eslintrc 文件
{
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "env": {
        "es6": true,
    },
    "parser": "babel-eslint",
    "extends": "airbnb-base",
}
  1. package.jsonscripts 加上这行代码 "lint": "eslint --ext .js test/ src/"。然后执行 npm run lint 即可开始验证代码。代码中的 test/ src/ 是指你要进行校验的代码目录,这里指明了要检查 testsrc 目录下的代码。

不过这样检查代码效率太低,每次都得手动检查。并且报错了还得手动修改代码。

为了改善以上缺点,我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候,自动验证代码并进行格式化,省去了动手的麻烦。

css 检查代码规范则使用 stylelint 插件。

由于篇幅有限,具体如何配置请看我的另一篇文章ESlint + stylelint + VSCode自动格式化代码(2020)

在这里插入图片描述

git 规范

git 规范包括两点:分支管理规范、git commit 规范。

分支管理规范

一般项目分主分支(master)和其他分支。

当有团队成员要开发新功能或改 BUG 时,就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染,就开一个分支叫 ssr,开发完了再合并回 master 分支。

如果改一个 BUG,也可以从 master 分支开一个新分支,并用 BUG 号命名(不过我们小团队嫌麻烦,没这样做,除非有特别大的 BUG)。

git commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分为三个部分(使用空行分割):

  1. 标题行: 必填, 描述主要修改类型和内容
  2. 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  3. 页脚注释: 可以写注释,BUG 号链接

type: commit 的类型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改
  1. scope: commit 影响的范围, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具体修改内容, 可以分为多行.
  4. footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

示例

fix(修复BUG)

如果修复的这个BUG只影响当前修改的文件,可不加范围。如果影响的范围比较大,要加上范围描述。

例如这次 BUG 修复影响到全局,可以加个 global。如果影响的是某个目录或某个功能,可以加上该目录的路径,或者对应的功能名称。

// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG,将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新页面)
feat: 添加网站主页静态页面

这是一个示例,假设对点检任务静态页面进行了一些描述。
 
这里是备注,可以是放BUG链接或者一些重要性的东西。
chore(其他修改)

chore 的中文翻译为日常事务、例行工作,顾名思义,即不在其他 commit 类型中的修改,都可以用 chore 表示。

chore: 将表格中的查看详情改为详情

其他类型的 commit 和上面三个示例差不多,就不说了。

验证 git commit 规范

验证 git commit 规范,主要通过 git 的 pre-commit 钩子函数来进行。当然,你还需要下载一个辅助工具来帮助你进行验证。

下载辅助工具

npm i -D husky

package.json 加上下面的代码

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 verify-commit.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

现在来解释下各个钩子的含义:

  1. "pre-commit": "npm run lint",在 git commit 前执行 npm run lint 检查代码格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 时执行脚本 verify-commit.js 验证 commit 消息。如果不符合脚本中定义的格式,将会报错。
  3. "pre-push": "npm test",在你执行 git push 将代码推送到远程仓库前,执行 npm test 进行测试。如果测试失败,将不会执行这次推送。

项目规范

主要是项目文件的组织方式和命名方式。

用我们的 Vue 项目举个例子。

├─public
├─src
├─test

一个项目包含 public(公共资源,不会被 webpack 处理)、src(源码)、test(测试代码),其中 src 目录,又可以细分。

├─api (接口)
├─assets (静态资源)
├─components (公共组件)
├─styles (公共样式)
├─router (路由)
├─store (vuex 全局数据)
├─utils (工具函数)
└─views (页面)

文件名称如果过长则用 - 隔开。

UI 规范

UI 规范需要前端、UI、产品沟通,互相商量,最后制定下来,建议使用统一的 UI 组件库。

制定 UI 规范的好处:

  • 统一页面 UI 标准,节省 UI 设计时间
  • 提高前端开发效率

测试

测试是前端工程化建设必不可少的一部分,它的作用就是找出 bug,越早发现 bug,所需要付出的成本就越低。并且,它更重要的作用是在将来,而不是当下。

设想一下半年后,你的项目要加一个新功能。在加完新功能后,你不确定有没有影响到原有的功能,需要测试一下。由于时间过去太久,你对项目的代码已经不了解了。在这种情况下,如果没有写测试,你就得手动一遍一遍的去试。而如果写了测试,你只需要跑一遍测试代码就 OK 了,省时省力。

写测试还可以让你修改代码时没有心理负担,不用一直想着改这里有没有问题?会不会引起 BUG?而写了测试就没有这种担心了。

在前端用得最多的就是单元测试(主要是端到端测试我用得很少,不熟),这里着重讲解一下。

单元测试

单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。

它应该怎么写呢?

  1. 根据正确性写测试,即正确的输入应该有正常的结果。
  2. 根据异常写测试,即错误的输入应该是错误的结果。

对一个函数做测试

例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。

对一个类做测试

假设有这样一个类:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

单元测试,必须把这个类的所有方法都测一遍。

对一个组件做测试

组件测试比较难,因为很多组件都涉及了 DOM 操作。

例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。

我们先来回顾一下上传图片的过程:

  1. 点击 <input type="file" />,选择图片上传。
  2. 触发 inputchange 事件,获取 file 对象。
  3. FileReader 将图片转换成 base64 码。

这个过程和下面的代码是一样的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}

上面的代码只是模拟,真实情况下应该是这样使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代码出现了 window 的事件对象 eventFileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:

// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后测试可以这样写:

// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()

通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。

TDD 测试驱动开发

TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。

TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。

虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。

测试框架推荐

我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。

部署

在没有学会自动部署前,我是这样部署项目的:

  1. 执行测试 npm run test
  2. 构建项目 npm run build
  3. 将打包好的文件放到静态服务器。

一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。

自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:

  1. 轮询。
  2. 监听 webhook 事件。

轮询

轮询,就是构建软件每隔一段时间自动执行打包、部署操作。

这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。

另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。

所以现在的构建软件基本采用监听 webhook 事件的方式来进行部署。

监听 webhook 事件

webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push 事件),当事件触发时,自动执行定义好的脚本。

例如 Github Actions,就有这个功能。

对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。

前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。

监控

监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。

性能监控

性能监控一般利用 window.performance 来进行数据采集。

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

这个 API 的属性 timing,包含了页面加载各个阶段的起始及结束时间。

在这里插入图片描述
在这里插入图片描述

为了方便大家理解 timing 各个属性的意义,我在知乎找到一位网友对于 timing 写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。

timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventEnd: 1543806782523,

    // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
    redirectStart: 0,

    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
    // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. 
    redirectEnd: 0,

    // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1543806782096,

    // DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查询完成的时间.
    //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1543806782227,

    // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 1543806782162,

    // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
    requestStart: 1543806782241,

    // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1543806782516,

    // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
        //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
    responseEnd: 1543806782537,

    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
    domLoading: 1543806782573,

    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
    domInteractive: 1543806783203,

    // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
    domContentLoadedEventStart: 1543806783203,

    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
    domContentLoadedEventEnd: 1543806783216,

    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
    domComplete: 1543806783796,

    // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1543806783796,

    // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1543806783802
}

通过以上数据,我们可以得到几个有用的时间

// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    whiteScreen = new Date() - performance.timing.navigationStart
</script>

通过这几个时间,就可以得知页面首屏加载性能如何了。

另外,通过 window.performance.getEntriesByType('resource') 这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

在这里插入图片描述

它一般包括以下几个类型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息

// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小结

通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:

  1. 资源过多
  2. 网速过慢
  3. DOM元素过多

除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。

错误监控

现在能捕捉的错误有三种。

  1. 资源加载错误,通过 addEventListener('error', callback, true) 在捕获阶段捕捉资源加载失败错误。
  2. js 执行错误,通过 window.onerror 捕捉 js 错误。
  3. promise 错误,通过 addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

我们可以建一个错误数组变量 errors 在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看代码

// 捕获资源加载失败错误 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})

小结

通过错误收集,可以了解到网站错误发生的类型及数量,从而可以做相应的调整,以减少错误发生。
完整代码和 DEMO 请看我另一篇文章前端性能和错误监控的末尾,大家可以复制代码(HTML文件)在本地测试一下。

数据上报

性能数据上报

性能数据可以在页面加载完之后上报,尽量不要对页面性能造成影响。

window.onload = () => {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

当然,你也可以设一个定时器,循环上报。不过每次上报最好做一下对比去重再上报,避免同样的数据重复上报。

错误数据上报

我在DEMO里提供的代码,是用一个 errors 数组收集所有的错误,再在某一阶段统一上报(延时上报)。
其实,也可以改成在错误发生时上报(即时上报)。这样可以避免在收集完错误延时上报还没触发,用户却已经关掉网页导致错误数据丢失的问题。

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing 的数据不会更新。
所以我们需要另想办法来统计切换路由到加载完成的时间。
拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫 beforeEach 里获取开始时间,在组件的 mounted 钩子里执行 vm.$nextTick 函数来获取组件的渲染完毕时间。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和错误监控,其实我们还可以做得更多。

用户信息收集

navigator

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。
在用户访问网站时,可以生成一个随机字符串+时间日期,保存在本地。在网页发生请求时(如果超过当天24小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

传统网站
用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。
SPA
可以利用 router 来获取用户停留时间,拿 Vue 举例,通过 router.beforeEachdestroyed 这两个钩子函数来获取用户停留该路由组件的时间。

浏览深度

通过 document.documentElement.scrollTop 属性以及屏幕高度,可以判断用户是否浏览完网站内容。

页面跳转来源

通过 document.referrer 属性,可以知道用户是从哪个网站跳转而来。

小结

通过分析用户数据,我们可以了解到用户的浏览习惯、爱好等等信息,想想真是恐怖,毫无隐私可言。

前端监控部署教程

前面说的都是监控原理,但要实现还是得自己动手写代码。为了避免麻烦,我们可以用现有的工具 sentry 去做这件事。

sentry 是一个用 python 写的性能和错误监控工具,你可以使用 sentry 提供的服务(免费功能少),也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。

注册账号

打开 https://sentry.io/signup/ 网站,进行注册。

选择项目,我选的 Vue。

安装 sentry 依赖

选完项目,下面会有具体的 sentry 依赖安装指南。

根据提示,在你的 Vue 项目执行这段代码 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安装 sentry 所需的依赖。

再将下面的代码拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址,注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后点击第一步中的 skip this onboarding,进入控制台页面。

如果忘了自己的 DSN,请点击左边的菜单栏选择 Settings -> Projects -> 点击自己的项目 -> Client Keys(DSN)

创建第一个错误

在你的 Vue 项目执行一个打印语句 console.log(b)

这时点开 sentry 主页的 issues 一项,可以发现有一个报错信息 b is not defined

这个报错信息包含了错误的具体信息,还有你的 IP、浏览器信息等等。

但奇怪的是,我们的浏览器控制台并没有输出报错信息。

这是因为被 sentry 屏蔽了,所以我们需要加上一个选项 logErrors: true

然后再查看页面,发现控制台也有报错信息了:

上传 sourcemap

一般打包后的代码都是经过压缩的,如果没有 sourcemap,即使有报错信息,你也很难根据提示找到对应的源码在哪。

下面来看一下如何上传 sourcemap。

首先创建 auth token。

这个生成的 token 一会要用到。

安装 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安装完上面两个插件后,在项目根目录创建一个 .sentryclirc 文件(不要忘了在 .gitignore 把这个文件添加上,以免暴露 token),内容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替换成刚才生成的 token。

org 是你的组织名称。

project 是你的项目名称,根据下面的提示可以找到。

在项目下新建 vue.config.js 文件,把下面的内容填进去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,执行 npm run build,就可以看到 sourcemap 的上传结果了。

我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。

未上传 sourcemap

已上传 sourcemap

可以看到,上传 sourcemap 后的报错信息更加准确。

切换中文环境和时区

选完刷新即可。

性能监控

打开 performance 选项,就能看到你每个项目的运行情况。具体的参数解释请看文档 Performance Monitoring

性能优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

手动检查

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

利用工具检查

监控工具

可以部署一个前端监控系统来监控网站性能,上一节中讲到的 sentry 就属于这一类。

chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用

如何做性能优化

网上关于性能优化的文章和书籍多不胜数,但有很多优化规则已经过时了。所以我写了一篇性能优化文章前端性能优化 24 条建议(2020),分析总结出了 24 条性能优化建议,强烈推荐。

重构

《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加易读、理解,性能优化则是为了让程序运行得更快。

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。

如果你专门拿一段时间来做重构,建议你在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。

重构手法

《重构2》这本书中,介绍了多达上百个重构手法。但我觉得有两个是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分太长或功能太多的函数

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太长或功能太多的函数

假设现在有一个注册功能,用伪代码表示:

function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}

这个函数包含了三个功能,验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的:

function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}

如果你对重构有兴趣,强烈推荐你阅读《重构2》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

赞 137 收藏 90 评论 23

Alex 收藏了文章 · 2020-11-13

手把手带你入门前端工程化——超详细教程

本文将分成以下 7 个小节:

  1. 技术选型
  2. 统一规范
  3. 测试
  4. 部署
  5. 监控
  6. 性能优化
  7. 重构

部分小节提供了非常详细的实战教程,让大家动手实践。

另外我还写了一个前端工程化 demo 放在 github 上。这个 demo 包含了 js、css、git 验证,其中 js、css 验证需要安装 VSCode,具体教程在下文中会有提及。

技术选型

对于前端来说,技术选型挺简单的。就是做选择题,三大框架中选一个。个人认为可以依据以下两个特点来选:

  1. 选你或团队最熟的,保证在遇到棘手的问题时有人能填坑。
  2. 选市场占有率高的。换句话说,就是选好招人的。

第二点对于小公司来说,特别重要。本来小公司就不好招人,要是还选一个市场占有率不高的框架(例如 Angular),简历你都看不到几个...

UI 组件库更简单,github 上哪个 star 多就用哪个。star 多,说明用的人就多,很多坑别人都替你踩过了,省事。

统一规范

代码规范

先来看看统一代码规范的好处:

  • 规范的代码可以促进团队合作
  • 规范的代码可以降低维护成本
  • 规范的代码有助于 code review(代码审查)
  • 养成代码规范的习惯,有助于程序员自身的成长

当团队的成员都严格按照代码规范来写代码时,可以保证每个人的代码看起来都像是一个人写的,看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性,并坚持规范的开发习惯。

如何制订代码规范

建议找一份好的代码规范,在此基础上结合团队的需求作个性化修改。

下面列举一些 star 较多的 js 代码规范:

css 代码规范也有不少,例如:

如何检查代码规范

使用 eslint 可以检查代码符不符合团队制订的规范,下面来看一下如何配置 eslint 来检查代码。

  1. 下载依赖
// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. 配置 .eslintrc 文件
{
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "env": {
        "es6": true,
    },
    "parser": "babel-eslint",
    "extends": "airbnb-base",
}
  1. package.jsonscripts 加上这行代码 "lint": "eslint --ext .js test/ src/"。然后执行 npm run lint 即可开始验证代码。代码中的 test/ src/ 是指你要进行校验的代码目录,这里指明了要检查 testsrc 目录下的代码。

不过这样检查代码效率太低,每次都得手动检查。并且报错了还得手动修改代码。

为了改善以上缺点,我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候,自动验证代码并进行格式化,省去了动手的麻烦。

css 检查代码规范则使用 stylelint 插件。

由于篇幅有限,具体如何配置请看我的另一篇文章ESlint + stylelint + VSCode自动格式化代码(2020)

在这里插入图片描述

git 规范

git 规范包括两点:分支管理规范、git commit 规范。

分支管理规范

一般项目分主分支(master)和其他分支。

当有团队成员要开发新功能或改 BUG 时,就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染,就开一个分支叫 ssr,开发完了再合并回 master 分支。

如果改一个 BUG,也可以从 master 分支开一个新分支,并用 BUG 号命名(不过我们小团队嫌麻烦,没这样做,除非有特别大的 BUG)。

git commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分为三个部分(使用空行分割):

  1. 标题行: 必填, 描述主要修改类型和内容
  2. 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  3. 页脚注释: 可以写注释,BUG 号链接

type: commit 的类型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改
  1. scope: commit 影响的范围, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具体修改内容, 可以分为多行.
  4. footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

示例

fix(修复BUG)

如果修复的这个BUG只影响当前修改的文件,可不加范围。如果影响的范围比较大,要加上范围描述。

例如这次 BUG 修复影响到全局,可以加个 global。如果影响的是某个目录或某个功能,可以加上该目录的路径,或者对应的功能名称。

// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG,将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新页面)
feat: 添加网站主页静态页面

这是一个示例,假设对点检任务静态页面进行了一些描述。
 
这里是备注,可以是放BUG链接或者一些重要性的东西。
chore(其他修改)

chore 的中文翻译为日常事务、例行工作,顾名思义,即不在其他 commit 类型中的修改,都可以用 chore 表示。

chore: 将表格中的查看详情改为详情

其他类型的 commit 和上面三个示例差不多,就不说了。

验证 git commit 规范

验证 git commit 规范,主要通过 git 的 pre-commit 钩子函数来进行。当然,你还需要下载一个辅助工具来帮助你进行验证。

下载辅助工具

npm i -D husky

package.json 加上下面的代码

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 verify-commit.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

现在来解释下各个钩子的含义:

  1. "pre-commit": "npm run lint",在 git commit 前执行 npm run lint 检查代码格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 时执行脚本 verify-commit.js 验证 commit 消息。如果不符合脚本中定义的格式,将会报错。
  3. "pre-push": "npm test",在你执行 git push 将代码推送到远程仓库前,执行 npm test 进行测试。如果测试失败,将不会执行这次推送。

项目规范

主要是项目文件的组织方式和命名方式。

用我们的 Vue 项目举个例子。

├─public
├─src
├─test

一个项目包含 public(公共资源,不会被 webpack 处理)、src(源码)、test(测试代码),其中 src 目录,又可以细分。

├─api (接口)
├─assets (静态资源)
├─components (公共组件)
├─styles (公共样式)
├─router (路由)
├─store (vuex 全局数据)
├─utils (工具函数)
└─views (页面)

文件名称如果过长则用 - 隔开。

UI 规范

UI 规范需要前端、UI、产品沟通,互相商量,最后制定下来,建议使用统一的 UI 组件库。

制定 UI 规范的好处:

  • 统一页面 UI 标准,节省 UI 设计时间
  • 提高前端开发效率

测试

测试是前端工程化建设必不可少的一部分,它的作用就是找出 bug,越早发现 bug,所需要付出的成本就越低。并且,它更重要的作用是在将来,而不是当下。

设想一下半年后,你的项目要加一个新功能。在加完新功能后,你不确定有没有影响到原有的功能,需要测试一下。由于时间过去太久,你对项目的代码已经不了解了。在这种情况下,如果没有写测试,你就得手动一遍一遍的去试。而如果写了测试,你只需要跑一遍测试代码就 OK 了,省时省力。

写测试还可以让你修改代码时没有心理负担,不用一直想着改这里有没有问题?会不会引起 BUG?而写了测试就没有这种担心了。

在前端用得最多的就是单元测试(主要是端到端测试我用得很少,不熟),这里着重讲解一下。

单元测试

单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。

它应该怎么写呢?

  1. 根据正确性写测试,即正确的输入应该有正常的结果。
  2. 根据异常写测试,即错误的输入应该是错误的结果。

对一个函数做测试

例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。

对一个类做测试

假设有这样一个类:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

单元测试,必须把这个类的所有方法都测一遍。

对一个组件做测试

组件测试比较难,因为很多组件都涉及了 DOM 操作。

例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。

我们先来回顾一下上传图片的过程:

  1. 点击 <input type="file" />,选择图片上传。
  2. 触发 inputchange 事件,获取 file 对象。
  3. FileReader 将图片转换成 base64 码。

这个过程和下面的代码是一样的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}

上面的代码只是模拟,真实情况下应该是这样使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代码出现了 window 的事件对象 eventFileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:

// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后测试可以这样写:

// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()

通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。

TDD 测试驱动开发

TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。

TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。

虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。

测试框架推荐

我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。

部署

在没有学会自动部署前,我是这样部署项目的:

  1. 执行测试 npm run test
  2. 构建项目 npm run build
  3. 将打包好的文件放到静态服务器。

一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。

自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:

  1. 轮询。
  2. 监听 webhook 事件。

轮询

轮询,就是构建软件每隔一段时间自动执行打包、部署操作。

这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。

另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。

所以现在的构建软件基本采用监听 webhook 事件的方式来进行部署。

监听 webhook 事件

webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push 事件),当事件触发时,自动执行定义好的脚本。

例如 Github Actions,就有这个功能。

对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。

前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。

监控

监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。

性能监控

性能监控一般利用 window.performance 来进行数据采集。

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

这个 API 的属性 timing,包含了页面加载各个阶段的起始及结束时间。

在这里插入图片描述
在这里插入图片描述

为了方便大家理解 timing 各个属性的意义,我在知乎找到一位网友对于 timing 写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。

timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventEnd: 1543806782523,

    // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
    redirectStart: 0,

    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
    // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. 
    redirectEnd: 0,

    // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1543806782096,

    // DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查询完成的时间.
    //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1543806782227,

    // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 1543806782162,

    // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
    requestStart: 1543806782241,

    // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1543806782516,

    // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
        //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
    responseEnd: 1543806782537,

    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
    domLoading: 1543806782573,

    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
    domInteractive: 1543806783203,

    // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
    domContentLoadedEventStart: 1543806783203,

    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
    domContentLoadedEventEnd: 1543806783216,

    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
    domComplete: 1543806783796,

    // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1543806783796,

    // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1543806783802
}

通过以上数据,我们可以得到几个有用的时间

// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    whiteScreen = new Date() - performance.timing.navigationStart
</script>

通过这几个时间,就可以得知页面首屏加载性能如何了。

另外,通过 window.performance.getEntriesByType('resource') 这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

在这里插入图片描述

它一般包括以下几个类型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息

// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小结

通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:

  1. 资源过多
  2. 网速过慢
  3. DOM元素过多

除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。

错误监控

现在能捕捉的错误有三种。

  1. 资源加载错误,通过 addEventListener('error', callback, true) 在捕获阶段捕捉资源加载失败错误。
  2. js 执行错误,通过 window.onerror 捕捉 js 错误。
  3. promise 错误,通过 addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

我们可以建一个错误数组变量 errors 在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看代码

// 捕获资源加载失败错误 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})

小结

通过错误收集,可以了解到网站错误发生的类型及数量,从而可以做相应的调整,以减少错误发生。
完整代码和 DEMO 请看我另一篇文章前端性能和错误监控的末尾,大家可以复制代码(HTML文件)在本地测试一下。

数据上报

性能数据上报

性能数据可以在页面加载完之后上报,尽量不要对页面性能造成影响。

window.onload = () => {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

当然,你也可以设一个定时器,循环上报。不过每次上报最好做一下对比去重再上报,避免同样的数据重复上报。

错误数据上报

我在DEMO里提供的代码,是用一个 errors 数组收集所有的错误,再在某一阶段统一上报(延时上报)。
其实,也可以改成在错误发生时上报(即时上报)。这样可以避免在收集完错误延时上报还没触发,用户却已经关掉网页导致错误数据丢失的问题。

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing 的数据不会更新。
所以我们需要另想办法来统计切换路由到加载完成的时间。
拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫 beforeEach 里获取开始时间,在组件的 mounted 钩子里执行 vm.$nextTick 函数来获取组件的渲染完毕时间。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和错误监控,其实我们还可以做得更多。

用户信息收集

navigator

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。
在用户访问网站时,可以生成一个随机字符串+时间日期,保存在本地。在网页发生请求时(如果超过当天24小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

传统网站
用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。
SPA
可以利用 router 来获取用户停留时间,拿 Vue 举例,通过 router.beforeEachdestroyed 这两个钩子函数来获取用户停留该路由组件的时间。

浏览深度

通过 document.documentElement.scrollTop 属性以及屏幕高度,可以判断用户是否浏览完网站内容。

页面跳转来源

通过 document.referrer 属性,可以知道用户是从哪个网站跳转而来。

小结

通过分析用户数据,我们可以了解到用户的浏览习惯、爱好等等信息,想想真是恐怖,毫无隐私可言。

前端监控部署教程

前面说的都是监控原理,但要实现还是得自己动手写代码。为了避免麻烦,我们可以用现有的工具 sentry 去做这件事。

sentry 是一个用 python 写的性能和错误监控工具,你可以使用 sentry 提供的服务(免费功能少),也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。

注册账号

打开 https://sentry.io/signup/ 网站,进行注册。

选择项目,我选的 Vue。

安装 sentry 依赖

选完项目,下面会有具体的 sentry 依赖安装指南。

根据提示,在你的 Vue 项目执行这段代码 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安装 sentry 所需的依赖。

再将下面的代码拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址,注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后点击第一步中的 skip this onboarding,进入控制台页面。

如果忘了自己的 DSN,请点击左边的菜单栏选择 Settings -> Projects -> 点击自己的项目 -> Client Keys(DSN)

创建第一个错误

在你的 Vue 项目执行一个打印语句 console.log(b)

这时点开 sentry 主页的 issues 一项,可以发现有一个报错信息 b is not defined

这个报错信息包含了错误的具体信息,还有你的 IP、浏览器信息等等。

但奇怪的是,我们的浏览器控制台并没有输出报错信息。

这是因为被 sentry 屏蔽了,所以我们需要加上一个选项 logErrors: true

然后再查看页面,发现控制台也有报错信息了:

上传 sourcemap

一般打包后的代码都是经过压缩的,如果没有 sourcemap,即使有报错信息,你也很难根据提示找到对应的源码在哪。

下面来看一下如何上传 sourcemap。

首先创建 auth token。

这个生成的 token 一会要用到。

安装 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安装完上面两个插件后,在项目根目录创建一个 .sentryclirc 文件(不要忘了在 .gitignore 把这个文件添加上,以免暴露 token),内容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替换成刚才生成的 token。

org 是你的组织名称。

project 是你的项目名称,根据下面的提示可以找到。

在项目下新建 vue.config.js 文件,把下面的内容填进去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,执行 npm run build,就可以看到 sourcemap 的上传结果了。

我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。

未上传 sourcemap

已上传 sourcemap

可以看到,上传 sourcemap 后的报错信息更加准确。

切换中文环境和时区

选完刷新即可。

性能监控

打开 performance 选项,就能看到你每个项目的运行情况。具体的参数解释请看文档 Performance Monitoring

性能优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

手动检查

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

利用工具检查

监控工具

可以部署一个前端监控系统来监控网站性能,上一节中讲到的 sentry 就属于这一类。

chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用

如何做性能优化

网上关于性能优化的文章和书籍多不胜数,但有很多优化规则已经过时了。所以我写了一篇性能优化文章前端性能优化 24 条建议(2020),分析总结出了 24 条性能优化建议,强烈推荐。

重构

《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加易读、理解,性能优化则是为了让程序运行得更快。

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。

如果你专门拿一段时间来做重构,建议你在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。

重构手法

《重构2》这本书中,介绍了多达上百个重构手法。但我觉得有两个是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分太长或功能太多的函数

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太长或功能太多的函数

假设现在有一个注册功能,用伪代码表示:

function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}

这个函数包含了三个功能,验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的:

function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}

如果你对重构有兴趣,强烈推荐你阅读《重构2》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

Alex 收藏了文章 · 2020-09-22

油管YouTube视频下载教程: 包含1080P&4K&8K视频的方法

当你需要一些视频素材的时候,可能会上YouTube去寻找,有时候也需要将他们下载下来。那么如何从YouTube上面下载视频呢?下面分享几种下载的方法,主要是1080P、2K、4K和8K等高清视频的下载,看着清晰。一起来看下吧!

1. 使用Gihosoft TubeGet桌面版软件进行下载(Window或Mac)

Gihosoft TubeGet,也就是TubeGet,是比较常用的一款下载YouTube视频的软件,界面比较干净,没什么乱七八糟的广告和弹窗,除了刚打开的时候会弹出升级提示,其它时候用着都很清爽,有Window和Mac 2个版本,没有手机版。

TubeGet使用简单,下载功能也比较全面,支持48fps和60fps视频的下载;也支持批量下载多个视频;字幕和封面也支持下载下来;1080P、2K、4K和8K等高清分辨率也支持获取。当然,不止YouTube,其他视频网站也支持,比如B站,Niconico,Twitter等。

使用Gihosoft TubeGet下载油管视频也很快捷,步骤如下:

1. 在电脑上下载该软件,然后安装,安装过程没有捆绑其他工具;

2. 访问YouTube,复制目标视频链接,手动将视频地址粘贴到软件里面;

3. 选择好分辨率和下载位置就可以开始下载了。

下载YouTube的1080P高清视频.png

另外,有的YouTube视频设置了地区限制,下载这类型的视频,需要先找一下地区,可以参考这篇豆瓣文章的PLUS:如何下载限制地区的YouTube视频

2.使用Y2mate网站下载

除了桌面版软件,还可以使用网站下载油管视频哦!网站的好处是使用简单,直接进入就可以使用了,Y2mate就是其中一个。使用Y2mate下载油管视频的方式也极其简单,直接手动输入YouTube视频的URL链接即可获得该视频所有的下载分辨率,然后点击“下载”按钮即可将视频保存到电脑、手机或平板上面。

Y2mate这种网站不好的地方是功能比较单一,只支持下载到1080P,以上的分辨率就下载不了了。而且,一次性只能下载一个,批量下载多个视频和列表等都不支持。不过,它是个网站,所以可以在手机上面使用,安卓和iPhone都可以。

Tips:由于服务器成本、版权问题等原因,可能网站会经常关闭。所以,能用的时候,要抓紧时间用!

油管视频下载方法.png

3.使用Clip Converter网站下载

Clip Converter和Y2mate一样,也是一个支持从YouTube等视频网站下载视频的网站。不过它比Y2mate功能要强大一点,支持2K、4K、8K视频的下载。使用也是一样比较方便,复制链接到网站,选择下载分辨率,就可以进行下载了。

Clip Converter因为也是一个网站,所以Y2mate的缺点都具备,比如只能下载单个视频等。而且,由于一些问题,Clip Converter现在不能下载带有音乐的视频了;对4k 60fps等高帧率的视频支持也不好,只能下载成30fps。

下载油管4k及8k超清视频.png

4. 使用4K Video Downloader下载视频

4K Video Downloader也是常用的一款油管视频下载工具,比较简洁。功能比较强大,下载高清视频,字幕,列表等都是支持的,一键配置功能,让下载更加智能化。而且,软件也是会定期更新的。

4K Video Downloader以前功能是比较完善的,但是随着YouTube变化得越来越快,有些功能已经有点跟不上了。比如,有些4K和8K视频只能获取到1080P的片源,而且有一些链接已经不支持下载了。不过大部分的下载功能还是具备的,作为一个常用的YouTube视频下载工具还是必备的。

YouTube高清视频下载教程.png

最后总结

以上就是常用的支持YouTube高清视频下载的工具。注意:YouTube是会经常调整功能的,所以网站和软件也会经常更新,所以,不要一直用同一个版本,要经常升级。

查看原文

Alex 赞了文章 · 2020-09-22

油管YouTube视频下载教程: 包含1080P&4K&8K视频的方法

当你需要一些视频素材的时候,可能会上YouTube去寻找,有时候也需要将他们下载下来。那么如何从YouTube上面下载视频呢?下面分享几种下载的方法,主要是1080P、2K、4K和8K等高清视频的下载,看着清晰。一起来看下吧!

1. 使用Gihosoft TubeGet桌面版软件进行下载(Window或Mac)

Gihosoft TubeGet,也就是TubeGet,是比较常用的一款下载YouTube视频的软件,界面比较干净,没什么乱七八糟的广告和弹窗,除了刚打开的时候会弹出升级提示,其它时候用着都很清爽,有Window和Mac 2个版本,没有手机版。

TubeGet使用简单,下载功能也比较全面,支持48fps和60fps视频的下载;也支持批量下载多个视频;字幕和封面也支持下载下来;1080P、2K、4K和8K等高清分辨率也支持获取。当然,不止YouTube,其他视频网站也支持,比如B站,Niconico,Twitter等。

使用Gihosoft TubeGet下载油管视频也很快捷,步骤如下:

1. 在电脑上下载该软件,然后安装,安装过程没有捆绑其他工具;

2. 访问YouTube,复制目标视频链接,手动将视频地址粘贴到软件里面;

3. 选择好分辨率和下载位置就可以开始下载了。

下载YouTube的1080P高清视频.png

另外,有的YouTube视频设置了地区限制,下载这类型的视频,需要先找一下地区,可以参考这篇豆瓣文章的PLUS:如何下载限制地区的YouTube视频

2.使用Y2mate网站下载

除了桌面版软件,还可以使用网站下载油管视频哦!网站的好处是使用简单,直接进入就可以使用了,Y2mate就是其中一个。使用Y2mate下载油管视频的方式也极其简单,直接手动输入YouTube视频的URL链接即可获得该视频所有的下载分辨率,然后点击“下载”按钮即可将视频保存到电脑、手机或平板上面。

Y2mate这种网站不好的地方是功能比较单一,只支持下载到1080P,以上的分辨率就下载不了了。而且,一次性只能下载一个,批量下载多个视频和列表等都不支持。不过,它是个网站,所以可以在手机上面使用,安卓和iPhone都可以。

Tips:由于服务器成本、版权问题等原因,可能网站会经常关闭。所以,能用的时候,要抓紧时间用!

油管视频下载方法.png

3.使用Clip Converter网站下载

Clip Converter和Y2mate一样,也是一个支持从YouTube等视频网站下载视频的网站。不过它比Y2mate功能要强大一点,支持2K、4K、8K视频的下载。使用也是一样比较方便,复制链接到网站,选择下载分辨率,就可以进行下载了。

Clip Converter因为也是一个网站,所以Y2mate的缺点都具备,比如只能下载单个视频等。而且,由于一些问题,Clip Converter现在不能下载带有音乐的视频了;对4k 60fps等高帧率的视频支持也不好,只能下载成30fps。

下载油管4k及8k超清视频.png

4. 使用4K Video Downloader下载视频

4K Video Downloader也是常用的一款油管视频下载工具,比较简洁。功能比较强大,下载高清视频,字幕,列表等都是支持的,一键配置功能,让下载更加智能化。而且,软件也是会定期更新的。

4K Video Downloader以前功能是比较完善的,但是随着YouTube变化得越来越快,有些功能已经有点跟不上了。比如,有些4K和8K视频只能获取到1080P的片源,而且有一些链接已经不支持下载了。不过大部分的下载功能还是具备的,作为一个常用的YouTube视频下载工具还是必备的。

YouTube高清视频下载教程.png

最后总结

以上就是常用的支持YouTube高清视频下载的工具。注意:YouTube是会经常调整功能的,所以网站和软件也会经常更新,所以,不要一直用同一个版本,要经常升级。

查看原文

赞 2 收藏 1 评论 0

Alex 关注了专栏 · 2020-09-18

疯狂的技术宅

本专栏文章首发于公众号:前端先锋 。

关注 27705

认证与成就

  • 获得 1 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-09-18
个人主页被 183 人浏览