# 前言
前面篇章我们分析了 “从输入 URL 到页面加载完成” 的完整链路,主要经历 “网络请求、浏览器渲染” 两大过程,那么优化方案我们可以围绕这两方面展开探索,此篇我们先来看 “网络层面” 的优化方案。正文开始前,我们先思考如下问题:
- “网络层面” 可以从哪些方面着手做性能优化?
- 前端开发可以介入 “网络请求” 哪些环节的性能优化?
# 网络请求的关键环节
我们先来回顾下 “网络请求” 会经历 “重定向、查询缓存、DNS 解析、TCP 连接、HTTP 请求”5 个关键环节,具体如下图:
# 优化方案
回顾了网络请求的关键环节,我们对应便可以制定出 “使用缓存、DNS 优化、HTTP 优化、服务器端响应优化” 这 4 个优化方案。下面我们一一来看这几个方案如何落地:
# 1. 使用缓存
使用缓存可以减少网络 IO 消耗,大幅提高访问速度。但开发在 “网络层面:缓存命中” 可做的并不多,能介入优化的主要是 “浏览器缓存” 这一阶段,下面我们具体来看看优化方案有哪些?
# 浏览器缓存
大多时候,大家将 “浏览器缓存” 简单地理解成 “HTTP 缓存”,实际上它分为 4 个方面,按获取资源的请求优先级排列如下:
- Memory Cache(内存缓存)
- Service Worker Cache(离线缓存)
- HTTP Cache(HTTP 缓存)
- Push Cache(HTTP2 推送缓存)
Push Cache 是 HTTP2 的新特性,升级 HTTP2 版本即可,我们接下来更多还是从 “Memory Cache、离线缓存、HTTP 缓存”3 个方面来看如何使用浏览器缓存,进而提升页面性能。
# Memory Cache
Memory Cahche,是指存储在内存中的缓存,它是浏览器最先尝试去命中的一种缓存,也是响应速度最快的一种缓存。内存缓存是最快的,同时也是 “短命” 的,tab 标签关闭内存中的数据就不复存在。
对于有限的 Memory 内存,浏览器秉承的是 “节约原则”,小体积文件可以放入 Memory Cache,比如 Base64 的图片、小体积 JS、CSS 等。
# 离线缓存(PWA)
Service Worker
Service Worker
本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器,可以拦截资源请求根据自己的逻辑做处理,比如可以拦截网络请求、缓存资源、离线访问等。
- Service Worker 注册
if ('serviceWorker' in navigator) { | |
navigator.serviceWorker.register('/service-worker.js', { | |
scope: '/' | |
}).then(function (registration) { | |
// 注册成功 | |
console.log('ServiceWorker registration successful with scope: ', registration.scope); | |
}).catch(function (err) { | |
// 注册失败 :( | |
console.log('ServiceWorker registration failed: ', err); | |
}); | |
} |
Service Worker 生命周期
- 安装 install
self.addEventListener('install', function (event) {
event.waitUntil(
// 这里可以做一些缓存的操作
// 考虑到缓存也需要更新,open 内传入的参数为缓存的版本号
caches.open('test-v1').then((cache) => {
return cache.addAll([
// 此处传入指定的需缓存的文件名
'/test.html',
'/test.css',
'/test.js',
]);
})
);
});
- 激活 activate
self.addEventListener('activate', function (event) {
event.waitUntil(
// 这里可以做一些清理缓存的操作
);
});
- 运行
self.addEventListener('fetch', function (event) {
// 拦截请求
// 策略:先从缓存中获取,若缓存中没有发起请求从网络中获取
event.respondWith(
caches.match(event.request).then(function (response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
案例可参考:[ServiceWorker 让你的网页拥抱服务端的能力](http://www.lixianglong.cn/2023/04/22/application/fore-end/js/ServiceWorker 让你的网页拥抱服务端的能力)
PS:Service Worker
对协议是有要求的,必须在 https 协议下才能生效。
Manifest
manifest
通过 html 文件 head 中引用:
<link rel="manifest" href="myapp.manifest" /> |
myapp.manifest
配置文件示例:
CACHE MANIFEST | |
# 缓存文件版本号 | |
CACHE-VERSION: 1.0.0 | |
# 需要缓存的文件列表 | |
CACHE: | |
/index.html | |
/style.css | |
/script.js | |
/images/logo.png | |
# 在离线时需要使用的页面 | |
FALLBACK: | |
/ offline.html | |
# 有网络时,从服务器下载的资源 | |
NETWORK: | |
/api/latest-news.json |
# HTTP 缓存
- HTTP 强缓存
强缓存是设置 http header 头的 Cache-Control
(http1.1)、 Expires
(http1.0) 两个字段控制的。http 请求首先根据这个 2 个字段判断目标资源是否命中 “强缓存”,若命中则从缓存中获取资源,不会再向服务器发起请求。
命中强缓存,返回状态码为 200,具体请求如下图:
扩展:
Cache-Control 设置no-cache
和no-store
有什么区别?
no-cache
:每次请求都需要向源服务器询问缓存是否过期(新鲜校验)。no-store
:不进行任何缓存。Cache-Control 设置
private
和public
有什么区别?
private
:仅向客户端返回缓存。public
:向任何客户端和服务器端(包括 CDN、代理服务器)提供缓存。
- HTTP 协商缓存
协商缓存依赖于浏览器向服务器询问缓存信息,识别到 http header 头的 Etag 和 If-None—Match 或 Last-Modified 和 If-Modified-Since 匹配一样的话,则表示资源未被修改,返回 304 状态码。
# 本地存储
# Cookie
Cookie 是 HTTP header 请求头的一个字段,客户端可以用来 “记录登录的用户信息”(非敏感),从而判断用户是否登录状态。Cookie 的最大可存储 4KB 数据,当超过 4KB 时,它将面临被裁切的命运。
Cookie,以键值对的形式存储,JS 通过 document.cookie 来创建、读取及删除 Cookie,Chrome Application 面板可以查看到,具体如下图所示:
# Web Storage
为了弥补 Cookie 局限性,Web Storage 出现了,该技术解决了 “浏览器数据存储” 的问题,其存储容量扩大到 5-10M。Web Storage 分为 localStorage、sessionStorage 两种。
localStorage
localStorage 可以持久化本地存储数据,即使关闭页面数据永久有效。要使数据消失的办法是调用 localStorage.removeItem () 移除或手动删除。
localStorage 数据存储格式同 Cookie 一样,也是以键值对的形式存储,JS 通过 localStorage.setItem 保存数据,localStorage.getItem 获取数据。考虑 localStorage 持久化的特性,它适用于存储一些稳定的资源,比如图片内容丰富的网站会用它存储 Base64 格式的图片字符串:
sessionStorage
sessionStorage 则更适合存储同一会话的信息和页面生命周期的关键路径。当关闭页面时,会话结束后 sessionStorage 里面的数据就会被清除。
sessionStorage 仍以键值对的形式存储,存 / 取数据的 JS API 同 localStorage,Chrome Application 面板查看存储数据如下图:
# IndexedDB
当我们遇到大规模、结构复杂的数据需要存储时,Web Storage 也爱莫能助了,这时诞生了 IndexedDB(运行在客户端的非关系型数据库)。IndexedDB 最大存储空间是动态的 —— 取决于电脑硬盘大小(一般来说不会小于 250M),它能存储字符串,也能存储二进制数据。
IndexedDB 的基本使用如下:
// 后面的回调中,我们可以通过 event.target.result 拿到数据库实例 | |
let db | |
// 参数 1 位数据库名,参数 2 为版本号 | |
const request = window.indexedDB.open("xiaoceDB", 1) | |
// 使用 IndexedDB 失败时的监听函数 | |
request.onerror = function(event) { | |
console.log('无法使用IndexedDB') | |
} | |
// 成功 | |
request.onsuccess = function(event){ | |
// 此处就可以获取到 db 实例 | |
db = event.target.result | |
console.log("你打开了IndexedDB") | |
} | |
// 创建 object store(相当于数据库中的 “表” 单位) | |
request.onupgradeneeded = function(event){ | |
let objectStore | |
// 如果同名表未被创建过,则新建 test 表 | |
if (!db.objectStoreNames.contains('test')) { | |
objectStore = db.createObjectStore('test', { keyPath: 'id' }) | |
} | |
} | |
// 创建事务,指定表格名称和读写权限 | |
const transaction = db.transaction(["test"],"readwrite") | |
// 拿到 Object Store 对象 | |
const objectStore = transaction.objectStore("test") | |
// 向表格写入数据 | |
objectStore.add({id: 1, name: 'zhangsan'}) | |
// 操作成功时的监听函数 | |
transaction.oncomplete = function(event) { | |
console.log("操作成功") | |
} | |
// 操作失败时的监听函数 | |
transaction.onerror = function(event) { | |
console.log("这里有一个Error") | |
} |
IndexedDB API 使用规范,详见 W3C:w3c.github.io/IndexedDB/
Cookie | localStorage | sessionStorage | IndexDB | |
---|---|---|---|---|
作用 | 记录用户信息等(维护状态) | 持久化本地存储 | 同一会话数据共享存储(页面关闭,数据随之释放) | 存储大型结构复杂的数据 |
优势 | 方便维护状态 | 存储容量较大:5-10M | 同 localStorage | 存储容量基本无上限(依据硬盘大小) |
劣势 | 存储量小,上限 4KB | 存储不了大数据(超过 10M 的) | 同 localStorage | 使用 API 相对复杂 |
# 2. DNS 优化
# DNS 预解析(dns-prefetch)
link 标签的 rel 属性设置 dns-prefetch
,提前解析域名对应的 IP 地址。京东的首页采用了该优化手段来提升性能,审查源码如下:
# 合理设置域名个数
- webServer 主站域名(1 个)
主站域名,主要用来处理来自客户端发起的 HTTP 接口请求。提供接口服务业界较为成熟、高性能的是 Java、PHP 语言。
- 静态资源 CDN 域名(1-3 个)
我们可以将体积大、访问频率高,但更新不频繁的静态资源 JS、CSS、图片、音频、视频等提取出来放 CDN 服务器,有效地降低了网站的负载,提高网站的访问速度和用户体验。但注意总的域名保持在 2-4 个即可,以免太多造成 DNS 查询损耗。
CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。因此可以做到哪个服务器与用户距离近,优先来满足数据请求。
# 3. HTTP 的优化
# 减少 HTTP 请求数
- 合并请求
1)合并请求接口,减少请求数
- 根据业务与后端协商,尽可能合并请求接口,从而减少接口请求数。
- 减少图片网络请求:
CSS sprite
、web font
、小体积图片base64
替代
2)构建打包优化,减少文件数
webpack
尽可能减少 chunk 拆分数量:通过配置optimization.splitChunks
的 minSize(最小 chunk 体积)、maxInitialRequests(页面初始化最大并发请求数)、maxAsyncRequest(按需加载时最大并发请求数)等。webpack
使用CommonsChunkPlugin
提取公共代码,减少 chunk 数量- 启用
TreeShaking
,剔除无用代码,减少加载的文件资源。
更多的 webpack 优化方案可参考:《Webpack 实用的优化方案》
webapck5.x API 可查看官网:webpack.js.org/
# 减少 HTTP 单次请求时间
- 压缩请求体
# 请求 request header 设置 gzip | |
Accept-Encoding:gzip |
- 请求头尽可能不携带 cookie(减小请求头大小)
- 升级为 HTTP2.0/3.0,请求并发量增大,数据传输速度提升,进而减小 HTTP 请求时间
HTTP2.0
的新特性:
- 多路复用:解决 HTTP1.1 同源下并发量限制问题
- 首部压缩:使用 HPACK 算法(header 头用静态字典 / 动态字典维护,哈夫曼编码减小体积)压缩请求头体积,提升传输效率
- 二进制传输:传输数据采用二进制格式,而非 HTTP1.x 的文本格式,以提升传输效率
- 服务端推送 Server Push,也叫 Cache Push(假设客户端请求 html 资源,服务端不仅响应 html,还主动推送 JS 和 CSS 资源进入缓存以备将来之需)
HTTP3.0
的新特性:
- 更高效的多路复用:QUIC 协议的传输层使用 UDP,没有了 TCP 队头阻塞问题
- TLS 加密:提升传输安全可靠性
# 4. 服务器端响应优化
服务器端从架构层面来看,可以采取 “动静分离”(适用于静态页面较多项目,通过将静态资源请求部署 Nginx 和使用 CDN,减轻主站服务器的压力)、“负载均衡”(多台服务器同时支持请求)的手段来优化网络请求响应速度,从而提升页面用户体验。
# 动静分离
- 静态内容
基本不会变动的内容,也不会因为请求参数不同而变化,比如静态资源 html、js、css、图片等。
静态内容处理:
- html 静态页面可以部署到 Nginx(擅长处理静态文件)
- css、js、图片等基本不变的资源上传至 CDN 服务器
- 动态内容
内容会因请求参数不同而变动,且变化无规律几乎不可枚举。
动态接口,用大量的源站服务器来承载,可以结合反向代理、负载均衡来实现。
# 负载均衡
负载均衡(Load Balance)建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽,增加吞吐量,加强网络数据处理能力,提高网络的灵活性和可用性。
上述负载均衡定义有 2 个方面的含义:
- 首先,大量的并发访问或数据流量分担到多台节点设备上分别处理,减少用户等待响应的时间;
- 其次,单个重负载的运算分担到多台节点设备上做并行处理,每个节点设备处理结束后,将结果汇总返回给用户,系统处理能力得到大幅度提高。
# 负载均衡算法
1)轮询均衡 Round Robin
每次来自网络的请求轮流分配给内部中的服务器,从 1 至 N 然后重新开始。该种均衡算法适用于服务器组中的所有服务器都有相同的软硬件配置并且平均服务请求相对均衡的情况。
2)权重轮询均衡 Weighted Round Robin
根据服务器的不同处理能力,给每个服务器分配不同的权值,使其能够接受相应权值数的服务请求。例如:服务器 A 的权值被设计成 1,B 的权值是 3,C 的权值是 6,则服务器 A、B、C 将分别接受到 10%、30%、60%的服务请求。此种均衡算法能确保高性能的服务器得到更多的使用率,避免低性能的服务器负载过重。
3)随机均衡 Random
把来自网络的请求随机分配给内部中的多个服务器。
4)响应速度权衡 Response Time
负载均衡设备对内部各服务器发出一个探测请求(例如 Ping),然后根据内部中各服务器对探测请求的最快响应时间来决定哪一台服务器来响应客户端的服务请求。此种均衡算法能较好的反映服务器的当前运行状态,但这最快响应时间仅仅指的是负载均衡设备与服务器间的最快响应时间,而不是客户端与服务器间的最快响应时间。
5)最少连接数均衡 Least Connection
客户端的每一次请求服务在服务器停留的时间可能会有较大的差异,随着工作时间加长,如果采用简单的轮循或随机均衡算法,每一台服务器上的连接进程可能会产生极大的不同,并没有达到真正的负载均衡。最少连接数均衡算法对内部中需负载的每一台服务器都有一个数据记录,记录当前该服务器正在处理的连接数量,当有新的服务连接请求时,将把当前请求分配给连接数最少的服务器,使均衡更加符合实际情况,负载更加均衡。此种均衡算法适合长时处理的请求服务,如 FTP。
6)处理能力均衡
此种均衡算法将把服务请求分配给内部中处理负荷(根据服务器 CPU 型号、CPU 数量、内存大小及当前连接数等换算而成)最轻的服务器,由于考虑到了内部服务器的处理能力及当前网络运行状况,所以此种均衡算法相对来说更加精确,尤其适合运用到第七层(应用层)负载均衡的情况下。
# Nginx 实现负载均衡
基于对上述负载均衡算法的认知,我们来具体看看 Nginx 是如何实现负载均衡的?具体有哪些负载均衡策略?
Nginx 负载均衡可以通过配置 upstream 模块来实现,其中涉及了 “轮询均衡(默认)、权重均衡、ip_hash、fair/url_hash(第三方)”4 种负载均衡策略。通过编辑配置文件 /usr/local/etc/nginx/nginx.conf
,详细配置如下:
- 轮询(默认)
http { | |
upstream backend { | |
server 192.168.80.121:80; | |
server 192.168.80.122:80; | |
} | |
server { | |
listen 80; | |
server_name example.com; | |
location / { | |
proxy_pass http://backend; | |
} | |
} | |
} |
- 权重 weight(默认为 1):权重越大分配的请求越多
upstream backend { | |
server 192.168.80.121:80 weight=1; | |
server 192.168.80.122:80 weight=2; | |
} |
- ip_hash:每个请求访问 ip 的 hash 结果分配,这样每个访客固定访问一个后端服务器,可以解决 session 问题。
upstream backend { | |
ip_hash; | |
server 192.168.80.121:80; | |
server 192.168.80.122:80; | |
} |
- fair(第三方):按后端服务器的响应时间来分配请求,响应端时间短的优先分配。
upstream backend { | |
server server1; | |
server server2; | |
fair; | |
} |
- url_hash(第三方):按访问 url 的 hash 结果来分配请求,使每个 url 定向到同一个后端服务器,后端服务器为缓存时比较有效。
# 在 upstream 中加入 hash 语句,server 语句中不能写入 weight 等其他的参数,hash_method 是使用的 hash 算法 | |
upstream backend { | |
server squid1:3128; | |
server squid2:3128; | |
hash $request_uri; | |
hash_method crc32; | |
} |
# 总结
上述行文主要从网络层面来探索制定了 “使用缓存、DNS 优化、HTTP 优化、服务器端响应优化”4 个优化方案,渲染层面下一篇章继续探索~