Web 性能优化
DNS
- DNS => Domain Name System
- 域名需要转化成 IP => 浏览器(缓存) -> 操作系统(hosts) -> 运营商
TCP 连接
- TCP => Transmission Control Protocol => 传输控制协议
- 三次握手 => 确保客户端和服务端都可以收发消息
HTTP 请求
- HTTP => Hypertext Transfer Protocol => 超文本传输协议
Request
动词 URL HTTP/1.1
Accept: text/html
Host: baidu.com
Connection: keep-alive
Content-Type: application/json
...
{"id": "1"}
Response
HTTP/1.1 200 OK
Content-Type: text/html
Set-Cookie: session_id=xxx; Cache-Control: max-age=3600
Connection: keep-alive
...
{"info", "this is response"}
HTML 解析过程
- 解析 HTML 会构建 DOM 树
- 解析 CSS 会构建 CSS 树
-
JS 的下载和执行会阻塞 HTML 的解析 => 下载和执行 JS 文件会修改 DOM 树
- 执行 JS 可以会修改 DOM 树
- 解析是一行一行的,只有解析到 script 行,才能去下载
- CSS 的下载和解析会阻塞 JS 的执行,JS 的执行需要等到 CSS 下载和解析结束 => JS 需要读取 CSS 结果
async/defer
- <script defer/async ...
- defer => 不会阻塞 HTML 的解析,保证 JS 的执行是在 HTML 解析之后,DOM ready 事件之前。执行完所有 JS 文件会触发 DOM ready 事件。多个 JS 文件下载之后按书写顺序执行
- async => 不会阻塞 HTML 的解析,DOM ready 事件是在 HTML 解析之后触发,不确定执行 JS 文件和 DOM ready 事件的先后关系,多个 JS 文件下载完之后立即执行
页面渲染
- DOM 树 + CSS 树 => 渲染树
- DOM 树 + CSS 树 -> 渲染树 -> Layout -> Paint -> Composite
- Layout => 布局 => 大小、尺寸 => reflow => 重排
- Paint => 绘制 => 颜色、阴影 => repaint => 重绘
- Composite => 合成 => 层次 => transform 只会触发 Composite
Web 性能优化
- 指标 => DOM ready 事件发生 => DOMContentLoaded
- Dom ready 事件之前阶段
- DNS 解析 => DNS 预解析
- TCP 连接 => 连接复用(HTTP/1.1 默认) + 并行连接(并行发送请求)
- HTTP/2 => 多路复用(HTTP/2 默认)
- HTTP/1.1 => 资源合并 + 内联 + 压缩 + 精简 + cookie-free + CDN + 缓存 + 内容协商
- CSS 优化 + JS 优化
- 代码优化 => 位置 + 拆分 + 动态导入 + 懒加载 + 预加载
DNS
- DNS 预解析
<script src="http://a.com/1.js"></script>
<script src="http://b.com/2.js"></script>
需要解析 a.com 和 b.com,并且 b.com 必须要等到 1.js 下载并执行完之后才能解析
// 在 index.html 中的 <head> 里面写
<link rel="dns-prefetch" href="https://a.com/"/>
<link rel="dns-prefetch" href="https://b.com/"/>
// 在 index.html 的响应头中写
Link: <https://a.com/>; rel=dns-prefetch
TCP 连接
连接复用
- Connection: keep-alive => HTTP 的 keep-alive 实现 TCP 连接的复用
- 请求头中添加 Connection: keep-alive,并且在响应头中也添加 Connection: keep-alive
- Keep-Alive: timeout = 5, max = 100 => 两次请求的时间间隔小于多少认为可以复用同一个 TCP 连接 => 请求头和响应头都可设置,最终决定于响应头
- HTTP/1.1 及以上 Connection: keep-alive 是默认添加的
- 串行的
并发连接
- 连接复用是串行的,并发连接是并行的
- 并行连接有最大数量 => 不同的浏览器上限不同 => 大概是 4 - 12 个,大部分是 6 个
- case: 将请求拆分 => case:不要一个 css,要多个 css,可以并行下载
- case: 发送多个 ajax => 并行发送
HTTP 管道化 HTTP pipelining
- HTTP/1.1 有 bug
HTTP/2
- HTTP/1.1 基于字符串 => HTTP/2 基于帧 Frame
- HTTP/2 每一帧的组成
- 9 byte => Length + Type + Flags + StreamId => 用于标记
- 最大 16 M => Payload => 数据
- 请求头和响应头会被发送方压缩后,分成几个连续的 Frame 传输,接收方拼合这些 Frame 后,解压缩即可得到真正地请求头和响应头
- 引入流 Stream的概念,一个 Stream 由双向传输的连续且有序的 Frame 组成,一个 TCP连接可以同时包含多个 Stream,一个 Stream 只用于一次请求和一次相应。Stream 之间不会相互影响
- 头部字段改为小写,不允许出现大写
- 引入了伪头部字段的概念,出现在头部字段前面,必须以冒号开头
- 服务端可以先发响应,客户端拿到响应结果后可以保存,之后就不需要在发对应的请求了
// get => : 伪头 => 表明 HTTP/1.1 的第一部分
// => header 都是小写
// 伪头 + header -> 可能是一个 Frame
:method: GET
:scheme: https
:path: /zh-CN/docs/Web/CSS/Cascade
accept: text/html
cookie: xxx
cache-control: no-cache
// POST 有多个 Frame
多路复用
- 在一个 TCP 连接中可以同时进行多个请求与响应,每一个请求+响应都在同一个 Stream 上,由于有标记的 9 个字节,所以响应能够和请求对应上
- 一个 Stream 只能对应一个请求 + 响应
- 有了多路复用就不需要并行连接了
资源合并
- Icon Font => 将图标变成字体文件
- SVG Symbols => 使用 SVG 文件代替图标,case:一个 svg 里面有很多图标,使用
<use xlink:href="#user">
- 支持渐变 => Icon Font 不支持渐变
- SVG 编辑较 Icon Font 简单
资源内联
- 资源内联可以减少一些 TCP 连接
- 但是如果文件过大,传输时间过长,不如并行
- 小图片 => data URL => date url 可以通过 webpack 的 url-loader 编译
- 小 CSS 文件 =>
<style>代码</style>
=> 通过 webpack 插件可以实现 - 小 JS 文件 =>
<script>代码</script>
=> 通过 webpack 插件可以实现
资源压缩
- 将响应压缩之后再传递给浏览器,浏览器先解压缩再使用 => gzip
- NGINX => https://docs.nginx.com/nginx/admin-guide/web-server/compression
代码精简
- HTML => 删空格、删闭合
- CSS => 删未用
- JS => 改名、tree shaking
- SVG => 删除无用标签、属性
- 图片 => 减少体积(有损/无损)
减少 Cookie 体积
- 最大 4kb
- 同一个域名每次请求都带着
- 启用新域名 => 新域名不会带着其他域名的 Cookie => 实现了连接并发和清 Cookie => cookie-free
CDN
- CDN => 内容分发网络 => 负载均衡
- 域名传输到 DNS,DNS 会给一个最近的 IP
- 优点
- cookie free
- 并行请求/多路复用
- 下载速度快
- 缺点
- 跨域 => 设置 CORS
- 可控性差 => CDN 如果挂了,依赖 CDN
- 部署相对复杂
缓存 & 内容协商
- 强缓存 & 弱缓存
- Cache-Control: max-age=3600; => 缓存1小时,如果再次请求,则不会发送请求,而是直接返回缓存内容 => memory cache | disk cache
- 服务器主动更新缓存 => 首页不会缓存,首页引入新的文件 => 生成新的 hash
- 内容协商 => 缓存过期后能重用吗?=> 缓存过期后再次请求就是协商请求(带上 ETag) => 服务端会对比 ETag => If-None-Match: <Etag Value>
- 304 + 空响应 => Not Modify => 使用之前的缓存
- 200 + 新文件 => 删除或覆盖之前的缓存
缓存 | 内容协商 | |
---|---|---|
HTTP/1.1 | Cache-Control: max-age=3600; ETag: XXX | 请求头 => If-None-Match: XXX 响应 => 304 + 空 | 200 + 新内容 |
HTTP/1.0 | Expires: 时间点A; Last-Modified: 时间点B | 请求头 => If-Modified-Since: 时间点B 响应 => 304 + 空 | 200 + 新内容 |
Cache-Control
- public/private => 中间设备是否可以缓存
- max-age => 缓存时间
- must-revalidate => 必须重新校验
禁用缓存
- 没有 Cache-Control 的情况下,浏览器也会缓存 => case: GET + (200 | 301)
- 服务端 => response header =>
- Cache-Control: max-age=0,must-revalidate === Cache-Control: no-cache => 不缓存,但是可以协商
- Cache-Control: no-store => 不缓存、不协商
- 浏览器端 =>
- url 添加随机数 => get /user?_=随机数
- request header => Cache-Control: no-cache, no-store, max-age=0
DNS 缓存
- 操作系统缓存 IP
- 浏览器缓存 IP
代码优化
代码位置
- css 文件放在前面 => 有利于用户看到样式,否则可能白屏或闪烁(firefox + css 在 body 内) => 下载过慢也可导致白屏或闪烁(firefox + css 在 body 内)
- 不阻塞 HTML 解析,尽早下载
- 防止被外部 JS 阻塞
- JS 文件放在后面
- 可直接访问 DOM,无需关注 DOM ready 事件
- 避免阻塞 HTML 解析
代码拆分
- 根据变动频率进行分层。case:webpack 最终打包成 main-xxx.js(1MB),之后更改了一个文案,重新打包 main-yyy.js(1MB) => 用户需要重新下载
- JS 分层
- runtime-xxx.js => webpack 升级用到的
- vendor-xxx.js => 第三方库 Vue | React
- common-xxx.js => 公司级别的基础库
- <page>-index-xxx.js => 每个页面 => 上述只会更改这一个文件
// webpack.config.js module.exports = { // <page>-index-xxx.js entry: { app: './src/page/app.js', main: './src/page/main.js', admin: './src/page/admin.js' }, plugins: [ new HtmlWebpackPlugin({ filename: 'app.html', chunks: ['app'] }), new HtmlWebpackPlugin({ filename: 'main.html', chunks: ['main'] }), new HtmlWebpackPlugin({ filename: 'admin.html', chunks: ['admin'] }) ], optimization: { runtimeChunk: 'single', // runtime-xxx.js splitChunks: { cacheGroups: { vendor: { // vendor-xxx.js priority: 10, minSize: 0, // 如果不写 0,由于 React 文件尺寸太小,会直接跳过 test: /[\\/]node_modules[\\/]/, // 为了匹配 /node_modules/ 或 \node_modules\ name: 'vendors', // 文件名 chunks: 'all' // all 表示同步加载和异步加载,async 表示异步加载,initial 表示同步加载 // 这三行的整体意思就是把两种加载方式的来自 node_modules 目录的文件打包为 vendors.xxx.js }, common: { // common-xxx.js priority: 5, minSize: 0, minChunks: 2, name: 'common', chunks: 'all' } } } } }
- CSS 分层
- reset/normalize-xxx.css => 基础
- vendor-xxx.css => 第三方库 antd
- common-xxx.css => 公司级别
- <page>-index-xxx.css => 每个页面
JS 动态导入
- 有些 JS 文件用到的时候在下载
const array = [1, 2, 3];
import("lodash").then(_ => {
const clone = _.cloneDeep(array);
});
import React, {Suspense, lazy} from 'react';
import {BrowserRouter as Router, Route, Switch} from 'react-route-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const App = () => {
<Router>
<Suspense fallback={LoadingComponent}>
<Switch>
<Route exact path="/" component={Home}/>
<Route page="/about" component={About}/>
</Switch>
</Suspense>
</Router>
}
图片懒加载(Lazy Loading) 和预加载
- 对于多屏图片,第一次请求只请求第一屏的图片,当滚动到第二屏的时候再请求第二屏的图片
- 懒加载太慢了,第一次请求第一屏和第二屏的图片,滚动到第二屏的时候,请求第三屏的图片
<img src='product.jpg'>
// 修改为 => placeholder.png 很小
<img src='placeholder.png' data-src='product.jpg'>
// 监听滚动事件 => 对于每一个下一屏的图片
new Image().src = img.dataset.src
// 监听 new Image 的 onload 事件,之后将 img 的 src 替换
img.src = img.dataset.src
CSS 优化
- 删除无用 css
- 使用更高效的选择器
- 减少重排 => reflow => 将 .left 动画更改为 transform 动画
- 不要使用 @import url.css => 不能并行
- 启用 GPU 硬件加速 => transform: translate3d(0, 0, 0)
- 使用缩写 =>
-
FFFFFF -> #FFF
- 0.1 => .1
- 0px => 0
-
JS 优化
- 尽量不用全局变量 => 全局变量过多会使变量查找变慢
- 尽量少操作 DOM => 可以使用 Fragment 一次性插入多个 DOM 节点
- 尽量少触发重排 => 可以使用节流和防抖来降低i重排频率
- 尽量少用闭包,避免内存泄漏 => IE 浏览器的 Bug
- 1W个 list 如何显示 => 虚拟滚动列表