一、背景和目标
前端性能优化的话题大家一直都在讨论,无论是在工作还是面试都经常遇到
- 工作方面:
- toC 项目确实要着重考虑这个问题,强调性能和用户体验 ➡️「得用户者得天下」
- 面试方面:关于前端性能优化的面试题很多,比如:
- Web 性能优化有哪些常见的手段?
- 如何解决单页面应用首屏加载慢的问题?
- 为什么要把 <script> 放在 <body> 之后?
- <script> 的 async 和 defer 有什么区别?
- HTTP/1.1 和 HTTP/2.0 有什么区别?
其实我之前也有遇到这种「Web 性能优化有哪些常见的手段?」的面试题,当时也都是从网上看的各种总结和脑图,但是总觉得那样一条条的记不太方便,而且和面试官沟通的时候思维也会跳来跳去,面试官和自己体验都不好。为了解决上面的问题吧,我找到了一种新的梳理思路,分享到这里,希望能对大家也有一些帮助和启发吧 ~
二、前人的肩膀
其实如今前后端分离已经很久了,各项前端技术也越来越成熟,关于 Web 性能优化的话题各个大厂也有自己的技术总结,比较有代表性的:雅虎的军规 14/35 条军规。(具体多少条我也不太确定,感兴趣的同学可以自己去网上找一下)
网上还有一些他人总结的关于前端性能优化的文章,这里我找了两张图:
- 关于性能优化文章的观点和组织形式也是各种各样,但其实内容都差不多。
- 文章很多,各种脑图确实有一定的规律,但看完之后没多久就忘记了,记起来没有逻辑性。
三、前端页面展示过程
其实性能优化的方法就那么多,个人不太建议大家像上图中的那么记。可以结合一个页面从无到有的角度来分析每个步骤中可以涉及到的优化方法。以用户访问百度为例:
1. 域名系统(Domin Name System)
- 首先浏览器会先从缓存中查找有没有访问过百度,如果访问过就把上一次百度的 IP 拿来用。
- 如果之前没有访问过,浏览器就会去问操作系统该网址对应的 IP,操作系统会看自己的缓存 hosts,如果有缓存就直接用。
- 没有的话就操作系统就会去问网络运营商(ISP,互联网服务提供商),运营商会查询并告诉浏览器百度对应的具体 IP ,浏览器接下来可以和这个 IP 建立 TCP 链接。
2. 传输控制协议(Transmission Control Protocol)
- 三次握手:浏览器(Browse)向服务器(Server)发送 TCP 链接请求...
- 构建 HTTP 请求报文,接受服务器响应...
- 四次挥手:...
3. 浏览器解析并渲染界面
- 浏览器在显示页面之前是一个边解析边渲染的过程,首先浏览器解析 HTML 文档构成 DOM 树,然后解析 CSS 文件构建 CSSOM 树,然后将 DOM 树和 CSSOM 树合并成一棵渲染树。
- 注:在 Dom 树的构建过程中如果遇到 JS 脚本和外部 JS 连接,则会停止构建 DOM 树来执行和下载相应的代码,这会造成阻塞,这就是为什么推荐 JS 代码应该放在 Html 代码的后面。
- 然后浏览器根据渲染树开始进行页面渲染:浏览器渲染的步骤分为三个:布局(Layout)、绘制(Paint)、合成(Composition)。其中布局步骤是根据渲染树计算出 DOM 节点盒模型的位置和大小,然后绘制步骤是在布局的基础上,给边框、文字和阴影等绘制颜色,最后合成步骤是根据已经布局和绘制好的页面,依据层叠关系展示到前端页面上。
四、可优化的步骤:DNS 优化 - 预解析
<!--index.html-->
<script src="http://a.com/1.js"></script>
<script src="http://b.com/2.js"></script>
在没有缓存和预先的 hosts 配置的情况下,上述代码的 DNS 解析过程为:
查询 http://a.com 的 IP -> 1.js -> 查询 http://b.com 的 IP -> 2.js
- 优化方法 1
<!--1. 用meta信息来告知浏览器, 当前页面要做DNS预解析:-->
<meta http-equiv="x-dns-prefetch-control" content="on" />
<!--2. 在页面header中使用link标签来强制对DNS预解析: -->
<link rel="dns-prefetch" href="http://a.com" />
<link rel="dns-prefetch" href="http://b.com" />
- 优化方法 2
在 RFC5988 标准中定义了一个头部字段:Link
The Link entity-header field provides a means for serialising one or more links in HTTP headers. It is semantically equivalent to the <LINK> element in HTML, as well as the atom:link feed-level element in Atom [RFC4287].
在 index.html 的响应头里写:Link: <http://a.com/>; rel=dns-prefetch
注:dns-prefetch 需慎用,多页面重复 DNS 预解析会增加重复 DNS 查询次数。
五、可优化的步骤:TCP/HTTP 相关优化
1. TCP 连接复用
HTTP/1.0 每次请求都需要建立新的 TCP 连接,连接不能复用。HTTP/1.1 默认新的请求可以在上次建立的 TCP 连接之上发送,连接可以复用。
HTTP/1.0:
开 TCP -> request -> response -> 关 TCP -> 开 TCP-> request -> response -> 关 TCP -> ...
HTTP/1.1:
开 TCP -> request -> response -> request -> response -> request -> response -> 关 TCP
- 如何实现?通过 HTTP 头部字段保证 TCP 持续连接
在发送 HTTP 的请求头中设置:Connection: keep-alive。服务端如果愿意将这条连接保持在打开状态,就会在响应中包含同样的首部。如果响应中没有包含 Connection: keep-alive 首部,则客户端会认为服务端不支持 keep-alive。
- 超时关闭
在发送 HTTP 的请求头中设置:KeepAlive: timeout=5, max=100。服务端如果支持这个等待时间,就会在响应中包含同样的首部。服务端也可能响应更高的时间间隔,以服务端响应为准。
优点:减少重复进行 TCP 三次握手的开销,提高效率。
注意:在同一个 TCP 连接中,新的请求需要等上次请求收到响应后,才能发送。
2. TCP 并行连接
HTTP/1.0 -> 未优化
开 TCP -> request -> response -> 关 TCP -> 开 TCP -> request -> response -> 关 TCP -> ...
HTTP/1.1 -> 连接复用
开 TCP -> request -> response -> request -> response -> request -> response -> 关 TCP
HTTP/1.1 -> 并行连接
开 TCP -> request -> response -> request -> response -> request -> response -> 关 TCP
开 TCP -> request -> response ->
开 TCP -> request -> response ->
开 TCP -> request -> response ->
- 如何实现?多个 link、script 标签或 Promise.all API 等方法来实现。
注意:并行连接同域名有数量的限制,不同浏览器对并行连接对数量限制也不同,大概在 4-12 个之间,可以通过添加新域名的方式来突破限制。
3. HTTP/1.1 管道化
HTTP/1.0 -> 未优化
开 TCP -> request -> response -> 关 TCP -> 开 TCP -> request -> response -> 关 TCP -> ...
HTTP/1.1 -> 连接复用
开 TCP -> request -> response -> request -> response -> request -> response -> 关 TCP
HTTP/1.1 -> 并行连接
开 TCP -> request -> response -> request -> response -> request -> response -> 关 TCP
开 TCP -> request -> response ->
开 TCP -> request -> response ->
开 TCP -> request -> response ->
HTTP/1.1 -> 管道化
开 TCP -> request -> response ->
-> request1 -> response1 ->
-> request2 -> response2 ->
-> request3 -> response3 ->
注:必须按照与请求相同的顺序回送 HTTP 响应。HTTP 报文中没有序列号标签,因此如果收到的响应失序了,就没办法将其与请求匹配起来了。
4. HTTP/2.0 多路复用
- 「连接复用」是串行的,新的请求需要等上次请求收到响应后,才能发送
- 「并行连接」同域名有数量限制,无限制的增加域名也不切实际
- 「管道化」由于响应的实效性问题,可能造成顺序方面的 bug
HTTP/2.0 引入了帧(Frame)的概念,每一帧包含 Length+Type+Flags+StreamlD+Payload 五部分,前四个部分是固定的长度,为 9 字节,第五部分 Payload 的最大长度可以到 16Mb。
保留了请求和响应的概念,请求头和响应头会被发送方压缩后,分成几个连续的 Frame 传输,头字段会出现在这些 Frame 的 Payload 中;接收方拼合这些 Frame 后,解压缩即可得到真正的请求头或响应头。
引入了流(Stream)的概念,一个 Stream 由双向传输的连续且有序的 Frame 组成,一个 TCP 连接可以同时包含多个 Stream,一个Stream 只用于一次请求和一次响应。Stream之间不会互相影响。
5. HTTP/2.0 服务端推送
用户请求了 index.html,服务器直接主动推送 index.js 需要用到的相应的 css、js、image 资源。
- 配置方法
//nginx
location / {
root /user/share/nginx/html;
index index.html index.htm;
http2-push /styles.css;
http2-push /img.png;
}
//nginx
location = / {
...
http2_push_preload on;
}
// 然后服务端,在 index.html 的响应头中添加:
// Link: </style.css>; rel=preload; as=style
六、可优化的步骤:资源相关优化
1. 资源精简
- HTML:删空格,单标签
- CSS:
- 使用缩写
- #FFFFFF -> #FFF
- 0.1 -> .1
- 0px -> 0
- 使用缩写
- 删除无用的 CSS 代码
- CSS 很多属性是继承的,可以配合 Coverage 工具分析样式代码使用率,删除无用代码
- JS:treeshaking
- 图片:减少体积,图片压缩
2. 小体积资源内联
- 小图片:data URLs - MDN
Data URLs,即前缀为
data:
协议的URL,其允许内容创建者向文档中嵌入小文件。Data URLs 由四个部分组成:前缀(
data:
)、指示数据类型的MIME类型、如果非文本则为可选的base64
标记、数据本身data:[<mediatype>][;base64],<data>
// npm install url-loader --save-dev
// index.js
import img from './image.png';
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192, // 8kb
}
}
]
}
]
}
};
- 小 CSS / JS 文件:
html-webpack-inline-source-plugin -> 依赖于 html-webpack-plugin
// npm i -D html-webpack-inline-source-plugin html-webpack-plugin
const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
var HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
module.exports = {
entry: './main.js',
output: {
path: path.resolve('./dist'),
filename: 'bundle.js'
},
plugins: [
new htmlWebpackPlugin({
template: './index.html',
filename: 'index-[hash].html',
inject: 'head',
inlineSource: '.(js|css)'
}),
new HtmlWebpackInlineSourcePlugin()
]
}
注:1 个连接请求两个合并在一起的文件不如两个文件并行请求。小文件内联可以减少 HTTP 请求数。
3. 图片资源合并
- CSS 雪碧图:Out of date!
- IConFont:
<span class="myFont"></span>
- SVG Symbols:
<use xlink:href="#login">
icon font 采用的是字体渲染,icon font 在一倍屏幕下渲染效果并不好,在细节部分锯齿还是很明显的,而 SVG 是图形所以在浏览器中使用的是图形渲染,支持渐变,易编辑。
4. 资源压缩
服务端响应后,前端需要下载相应的资源:下载速度 = 下载量/下载时间。
- 减少下载量:后端开启压缩算法 - 响应体的 Content-Encodeing:gzip。相当于 server 给 browser 发了个压缩包,browser 自动解压缩,减少传输量 -> 进而减少响应时间。
- 加快下载速度:提升服务器的下载速度、增加用户的带宽
- 服务端资源压缩配置:
- Nginx
gzip on;
#开启gzip压缩功能
gzip_min_length 1k;
#设置允许压缩的页面最小字节数,页面字节数从header头的content-length中获取。默认值是0,不管页面多大都进行压缩。建议设置成大于1k。如果小于1k可能会越压越大。
gzip_buffers 4 16k;
#压缩缓冲区大小。表示申请4个单位为16k的内容作为压缩结果流缓存,默认值是申请与原始数据大小相同的内存空间来存储gzip压缩结果。
gzip_http_version 1.0;
#压缩版本(默认1.1,前端为squid2.5时使用1.0)用于设置识别http协议版本,默认是1.1,目前大部分浏览器已经支持gzip解压,使用默认即可。
gzip_comp_level 2;
#压缩比率。用来指定gzip压缩比,1压缩比量小,处理速度快;9压缩比量大,传输速度快,但处理最慢,也必将消耗cpu资源。
gzip_types text/plain application/x-javascript text/css application/xml;
#用来指定压缩的类型,“text/html”类型总是会被压缩。
gzip_vary on;
#vary header支持。该选项可以让前端的缓存服务器缓存经过gzip压缩的页面,例如用squid缓存经过nginx压缩的数据。
- Node.js 压缩 HTTP 的请求和响应
// Node.js -> Front-end staff is required to configure it
if (/\bdeflate\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'deflate' });
pipeline(raw, zlib.createDeflate(), response, onError);
} else if (/\bgzip\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'gzip' });
pipeline(raw, zlib.createGzip(), response, onError);
} else if (/\bbr\b/.test(acceptEncoding)) {
response.writeHead(200, { 'Content-Encoding': 'br' });
pipeline(raw, zlib.createBrotliCompress(), response, onError);
} else {
response.writeHead(200, {});
pipeline(raw, response, onError);
}
- 一般大于 1k 的纯文本文件 html, js, css, xml 是需要压缩的
- 图片,视频等不要压缩,因为不但不会减小,在压缩时消耗 cpu 和内存资源
5. 使用 CDN
内容分发网络(CDN, Content Delivery Network),其实就是由遍布在各个地方的服务器组成,用户访问时,可以访问到距离最近的一个节点,从而实现加速。「空间换时间的策略」
- 增加域名的好处:HTTP/1.1 —> 新增域名以提高并量,加快资源下载速、cookie free
七、可优化的步骤:缓存相关优化
1. HTTP 资源缓存
缓存分为:缓存(强缓存)、内容协商(弱缓存/协商缓存)。缓存只能加速第二次及之后的访问速度。
💻 ---------------------> 百度.com index.html
---------------------> CDN.com style-xxx.css
---------------------> CDN.com main-xxx.js
---------------------> CDN.com img-xxx.png
服务器会告知浏览器,上述资源需要缓存起来:
Cache-Control:public, max-age=3600, must-revalidate
ETag:xxx
// public:指定中间层代理是否能缓存
// max-age:缓存时间
// must-revalidate:必须重新校验
// ETag string = MD5(file)
- 由于 max-age 不能更换,资源变更后只能弃用,重新打包相关资源 -> 更换 xxx md5 版本号编码
缓存(响应) | 内容协商(请求) | |
HTTP/1.1 | Cache-Control:public, max-age... Etag: xyz12345 | IfNoneMatch:xyz12345 if match: 304+空内容;else:200+新内容 |
HTTP/1.0 | Expire:timestamp 1 Last-Modify:timestamp 2 | If-Modify-Since:timestamp 2 if no modify: 304+空内容;else:200+新内容 |
- HTTP/1.0 缺点:
- 客户端和服务端的时间可能不一致,导致资源 expire 时间判断出问题
- Last-Modify:最精确的单位是秒,如果一秒之内资源变更两次,则无法校验
- 禁用缓存:
- Cache-Control:max-age=0, must-revalidate <=> Cache-Control:no-cache
- Cache-Control - MDN
2. 资源拆分缓存
按照使用频率,我们可以做如下的资源拆分,不频繁变更的资源几乎可以做到长时间 304。
JS | CSS | ||
runtime-xxx.js | webpack 运行时函数... | reset/normalize.css | 基础样式 |
vendor-xxx.js | 第三方基础库:Vue... | vendor-xxx.css | Element ui/Ant design... |
common-xxx.js | utils、script... | common-xxx.css | 公共的样式 |
page-index-xxx.js | 单个页面的 js | page-index-xxx.css | 单个页面的 css |
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
priority: 10,
minSize: 0,
test: /[\\/]node_modules[\\/]/, // 匹配 /node_modules/ 或 \node_modules\
name: 'vendors',
chunks: 'all', // all 表示同步加载和异步加载
}, // async 表示异步加载,initial 表示同步加载
common: {
priority: 5,
minSize: 0,
minChunks: 2, // 至少被两个文件同时引用认为 common
chunks: 'all',
name: 'common'
}
}
}
}
八、可优化的步骤:页面渲染及展示相关优化
1. 资源下载及解析时机
- CSS 在上、JS在下
- 从用户习惯考虑,先渲染看的见的东西、然后再去加载鼠标动作及点击事件
- CSS 下载和解析与 HTML 的解析不存在阻塞关系、CSS 的下载和解析与 JS 的下载和执行相互阻塞、HTML 的解析与 JS 的下载和执行相互阻塞
- 不重要的外置引入的 JS 使用 defer、async 属性做异步加载(统计、PV、广告类的脚本)
- 加载 CSS 推荐用 <link> 少用 @import
- <link> 引用 CSS 时,在页面载入时同时加载
- @import 需要页面网页完全载入以后加载
2. 组件的动态导入
const router = new VueRouter({
routes: [
{ path:'/home', component:() => import('./Home.vue')},
{ path:'/about, component:() => ({
component: import('./About.vue'),
loading: LoadingComponent,
error: ErrorComponent
})}
]
})
2. React 动态引入组件
import { Suspense, lazy } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
const Home = lazy(() => import('./routes/Home'))
const About = lazy(() => import('./routes/About'))
const App = () => (
<Router>
<Suspense fallback={ Loadingcomponent }>
<Switch>
<Route exact path="/" component={ Home }/>
<Route path="/about" component={ About }/>
</Switch>
</Suspense>
</Router>
)
3. 图片的懒加载/预加载
- 懒加载
电商类网站商品图片很多,同时加载不仅导致服务器压力很大而且影响页面渲染速度。为了解决以上问题,提高用户体验,就出现了懒加载方式来减轻服务器的压力,优先加载可视区域的内容,其他部分等进入了可视区域再加载,从而提高首屏渲染性能。
<img src="image.png">
<img src="placeholder.png" data-src="image.png">
原理:一张图片就是一个 <img> 标签,浏览器是否发起请求图片是根据 <img> 的 src 属性,所以实现懒加载的关键就是,在图片没有进入可视区域时,先不给 <img> 的 src 赋值,这样浏览器就不会发送请求,等到图片进入可视区域再给 src 赋值。这样第一屏的渲染速度就会很快。
- 预加载
将所有所需的图片提前请求加载到本地,这样后面在需要用到时就直接从缓存取图片。
var imgUrls = ['./image/preload_01','./image/preload_02','./image/preload_03'];//图片真实路径
var imgs=[]; //存储图片
function preloadImg(datas=[]){ // 文档加载完毕再加载图片
for(let i=0; i<datas.length; i++){
imgs[i] = new Image();
imgs[i].src = datas[i];
}
}
preloadImg(imgUrls);
懒加载和预加载不仅可以用在图片资源方面,其他资源同样适用,但是要充分考虑应用场景。就比如电商类的网适合做图片的懒加载,在线电子书应用适合做资源的预加载...
4. 样式及动画相关
- 使用缩写
- #FFFFFF -> #FFF
- 0.1 -> .1
- 0px -> 0
- 删除无用的 CSS 代码
- CSS 很多属性是继承的,可以配合 Coverage 工具分析样式代码使用率,删除无用代码
- 减少回流和重绘
- 尽量用 transfrom 和 opacity ,少用 left、top...
- 开启 GPU 硬件加速
- transform:translate3d(0, 0, 0)
- 动画默认是 CPU,上述代码可以开启显卡 GPU 加速渲染
- v-show vs. v-if
5 涉及用户交互相关
- 防抖和节流
- 尽量不要向页面大量插入元素 -> 卡
- 必要的情况下将其变成异步的小任务:requestAnimationFrame
- requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次回流或重绘中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率(一般为每秒 60 帧)
- 在隐藏或不可见的元素中,requestAnimationFrame 将不会进行重绘或回流,这当然就意味着更少的 CPU、GPU 内存使用量。
- 尽量减少 DOM 操作次数
- 必要情况下使用文档碎片:document.createDocumentFragment()
九、最后
- 有一些优化官方已经帮我们做了,比如 HTTP/2.0 的多路复用、用户推送...
- 前端性能优化,有时候不仅仅需要前端开发人员做工作,后端配合:Link 预先解析、gzip 压缩...
- 性能优化有时要做权衡和对比,不能过分考虑性能优化:dns 预解析、懒加载/预加载...
- 分享一下你知道的其他的优化方法吧~