Start
通常一个页面可以分为三个阶段,加载阶段、交互阶段、关闭阶段,影响前端性能的主要就是前面两个阶段,那本文主要会从这两个阶段入手讲解,如果能搭配浏览器DevTools那就更棒了。
加载阶段
指从发出请求到渲染出完整页面的过程
1.请求优化
减少HTTP请求
HTTP请求大致包括DNS查找,TCP三次握手,发送HTTP请求,处理请求返回数据,浏览器接收等过程。以下几点
- DNS查找为递归查找,会有一定的时间开销。
- 请求头会带上一些额外的信息,当请求资源很小可能请求头带的数据比传输数据量还大,那传输速度就慢了。
- 每个请求对服务器也会造成一定的开销。
如何减少:
- 减少外部脚本数
- 合并
CSS、JS - 图片使用
Base64 - 图片地图/CSS精灵
CDN- 将多个请求合并为一个进行请求
一个HTTP请求在无缓存情况下会占去40%-60%的响应时间,最大化减少请求数和缓存对网站有重要意义。
避免重定向
301永久重定向和302临时重定向都会把用户指向到指定的URL,在最好的情况下,每个重定向都会添加一次往返(HTTP 请求-响应);而在最坏的情况下,除了额外的HTTP请求-响应周期外,它还可能会让更多次的往返执行DNS查找、TCP握手。因此,应尽可能减少对重定向的使用以提升网站性能。
如果一定要使用重定向的话,如http重定向到https,要使用301,而不是302,因为如果使用302则每一次访问http都会重定向到https页面,而永久重定向在第一次从http重定向到https之后,每次访问http,会直接返回https的页面
GET请求
浏览器的POST方法是一个“两步走”的过程:先发送文件头然后才发送数据。GET方法只发送一个TCP包。根据HTTP规范,当仅仅获取数据时使用GET更加有意义,发送数据使用POST。
采用域名分片或升级到HTTP2
浏览器为每个域名最多提供6个TCP连接,那么你可以让1个站点下面的资源放在多个域名下面,比如2个你就能同时支持12个TCP连接,这被称为域名分片技术。你也可以升级到HTTP2,因为没有域名连接限制,同时还有多路复用,头部信息压缩等优点。
预加载,能加快100-500ms
- preconnect(预连接) : 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析,TLS 协商,TCP 握手,这消除了往返延迟并为用户节省了时间。
- preload:高优先级加载资源。
- dns-prefetch DNS请求在带宽方面流量非常小,可是延迟会很高,尤其是在移动设备上。
dns-prefetch允许浏览器在用户浏览页面时在后台运行 DNS 的解析。如此一来,DNS 的解析在用户点击一个链接时已经完成,所以可以减少延迟。
2. 关键资源
能阻塞网页首次渲染的资源称为关键资源,HTML、JavaScript、CSS文件
可以从以下方面优化:
关键文件加载顺序
我们都知道,CSS放上面,JS放下面,因为JS会阻塞HTML的解析和加载,如果CSS放下面,那可能会触发多次重绘。
减少关键资源的个数
- 通过浏览器
DevTools的Coverage查看资源利用情况合理删除 - 合理使用
JavaScript和CSS文件内联 - 非关键
JavaScript文件使用async或者defer异步加载
减少关键资源的大小
- 压缩资源
- UI库的按需引入,下面以antdv为例:
moment.js优化
// vue.config.js
configWebpack:{
new webpack.IgnorePlugin(/^./locale$/, /moment$/),
}
// main.js
import 'moment/locale/zh-cn'
对比图:
icon优化
可以在utils/assets下新建icons.js,用来定义需要的图标
// icons.js
export { default as DownOutline } from '@ant-design/icons/lib/outline/DownOutline'
...
// vue.config.js
chainWebpack: config => {
config.resolve.alias
.set('@ant-design/icons/lib/dist$', resolve('./src/utils/icons.js'))
}
对比图:
- 组件异步加载
- 分包,通过
splitChunks把第三方库和一些模块下代码进行分包, 官方文档
// vue.config.js
configureWebpack: {
optimization: {
splitChunks: {
chunks:'all',
minSize:30000,// 30kb
maxSize:300000,
cacheGroups: {
vue: {
name: 'chunk-vue',
chunks: 'initial',
priority: 20,
test: /[\/]node_modules[\/]vue|vue-router|vuex[\/]/
},
vendors: {
name: 'chunk-vendors',
test: /[\/]node_modules[\/]/,
chunks: 'initial',
priority: 20,
reuseExistingChunk: true,
enforce: true
},
views: {
name: 'chunk-views',
test: /[\/]src[\/]views[\/]/,
priority: 10
}
}
}
}
}
- 开启
GZIP,利用重复出现的字符串临时替换从而实现压缩 purgecss-webpack-plugin优化CSS,类似Tree-Shaking
请求关键资源需要的RTT
RTT(Round Trip Time):使用TCP协议传输一个文件时,这个数据不是一次传输过去的,需要拆分成一个个数据包来回多次进行传输的,1个数据包在14KB左右。比如文件大小140KB,就需要拆分成10个包传输,就需要10个RTT。RTT表示的是从发送端发送数据开始到收到接收端的确认,总共经历的时延。
- 使用CDN
- 减少关键资源的个数和大小
3.缓存方案
HTP缓存
- 强缓存
强制缓存就是向浏览器缓存查找请求结果,并根据请求结果的缓存规则来决定是否使用该缓存结果的过程。
HTTP/1.0中服务器通过Expires字段设置,返回的是缓存过期时间点,那浏览器本地时间和这个时间对比肯定是有误差的。
HTTP1.1中,可以通过Cache-Control来设置,max-age设置缓存内容多少秒失效,no-cache表示走协商缓存,no-store表示不走任何缓存,每次获取服务器资源。强缓存生效,状态码返回200:from disk cache硬盘缓存,from memory cache内存缓存。 - 协商缓存
协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程。
HTTP/1.1可以通过设置Last-Modified和Etag。
Last-Modified代表资源在服务器最后被修改的时间,可能这次修改了但是没有改变文件内容,计算不准确。服务端会在响应头设置laset-midified字段,客户端再次发起该请求时,会在请求头设置If-Modified-Since字段并携带上次请求返回的Last-Modified值。
Etag是服务器以文件内容生成一个唯一字符串标识,计算更准确但同时也会消耗服务器性能,优先级高于前者。客户端再发请求会在请求头设置If-None-Match并带上上次Etag的值,服务器对比。协商缓存生效,状态码返回304。如果不生效,重新返回资源,状态码返回200。
浏览器本地缓存
可以看到,淘宝在本地缓存了JS和API接口数据,那么下次就可以直接从本地取了。那问题是缓存如何更新呢?比如以文件内容做md5值,每次请求带给服务器,由服务器判断是否做更新,有新数据那我们就更新本地数据和md5值。
腾讯基于localStorage的缓存控制最为细致,可以做到字符级别的资源增量更新,修改多少代码就只下载修改的代码,最大限度减少了更新内容。
Cookie
Cookie是紧跟域名的,同一域名下的所有请求,都会携带Cookie。而静态资源往往不需要Cookie,那么把静态资源和主页面至于不同的域名下,就可以避开不必要的Cookie出现。当然,有时候服务端需要在请求头的一个字段中携带Token,我们就可以存在Local Storage中,而不用Cookie,如果你用Cookie的话,你会发现所有的静态资源请求都会携带上Cookie,这也被叫做Cookie污染,不但会影响请求响应速度还会造成带宽浪费。
交互阶段
页面加载完成到用户交互的整合过程
1.减少JavaScript脚本运行时间
时间切片
<div id="app"></div>
const list = document.querySelector('#app')
const total = 30000
for (let i = 0; i < total; ++i) {
let item = document.createElement('div')
item.innerText = `第${i}个`
list.appendChild(item)
console.log(i)
}
可以看到两个长任务,非常耗时,页面加载十分慢,操作也十分卡顿,那接下来我们要时间切片的方式改造一下:
const list = document.querySelector('#app')
const total = 30000
const size = 40
const render = (total) => {
if (total <= 0) {
return
}
let curPage = Math.min(total, size)
requestAnimationFrame(() => {
let fragment = document.createDocumentFragment()
for (let i = 0; i < curPage; i++) {
let item = document.createElement('div')
item.innerText = `第${i}个`
fragment.appendChild(item)
console.log(i)
}
list.appendChild(fragment)
render(total - curPage, curPage)
})
}
render(total)
可以看到,长任务被分解成一个个短任务,我们可以调整
size的大小,让Task能在一帧中完成。那我们依靠的就是requestAnimationFrame(请求动画帧)这个api,是指在下一次重绘之前调用回调函数,但是有一点需要注意的是,不能保证满帧运行,除非JavaScript代码执行时间不超过16.7ms,这里不考虑定时器触发JavaScript。那顺便介绍下requestIdleCallback就是会在浏览器空闲的时刻执行JS代码,会在一帧中剩余时间中调用,但是兼容性不好。
一般来说只有在频繁进行超过100ms的纯CPU任务更新时,时间切片才实际有用。HCI的研究表明,除非它在进行动画,否则对于正常的用户交互,大多数人对于100毫秒内的更新是感觉不到有什么不同的。
Web Workers
可以当作是主线程之外的一个线程,可以去执行一些复杂JavaScript脚本,在Web Workers中无法通过JavaScript访问DOM,所以我们把一些耗时并且不操作DOM的任务放到Web Workers中执行。
2.像素管道-关键渲染路径
JS修改一些样式,随后浏览器会进行样式计算,然后进行布局,绘制,最后将各个图层合并在一起完成整个渲染的流程
通过看一段代码来了解关键渲染路径
const app = document.querySelector('#app')
let p = document.createElement('p')
p.innerHTML = 'p'
app.appendChild(p)
重排重绘
此路径是关键渲染路径,比如修改
DOM宽高,移动DOM位置等,会重新走此路径,也是我们所说的重排(回流)Reflow。
比如修改背景图片、文字颜色或阴影等,会走上面流程,这既是我们常说的重绘
Repaint,+即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。
此过程不会重排重绘,仅仅是进行合成,也就是修改
transform 和 opacity 属性更改来实现动画,性能得到较大提升,比如动画或滚动。
FSL和LT
FSL(Forced Synchronous Layouts) 称为强制同步布局:JavaScript强制将计算样式和布局操作提前到当前的任务中。LT(Layout Thrashing)称为布局抖动,多次执行FSL
我们接着上面的代码改造一下:
const app = document.querySelector('#app')
let p = document.createElement('p')
p.innerHTML = 'p'
app.appendChild(p)
console.log(app.offsetWidth) // 添加一行代码
单个FSL对性能的影响确实不大,但多次的FSL就会触发布局抖动LT(Layout Thrashing),影响会变得非常大。
我们接着上面代码继续改造下
function fn() {
const app = document.querySelector('#app')
let p = document.createElement('p')
p.innerHTML = 'p'
app.appendChild(p)
console.log(app.offsetWidth)
}
for(let i=0;i<1000;i++){
fn()
}
Forced reflow is a likely performance bottleneck.翻译:强制回流很可能成为性能瓶颈。
可以看到大量操作DOM的代价十分的大,你还敢瞎搞嘛。
重排重绘优化方案:
- DOM属性(宽高值)做缓存,避免频繁获取
- DOM离线,先设置
dislay:none,然后再去操作DOM - 给DOM设置
absolute,脱离文档流 - 图片设置宽高,防止LS(Layout Shift)
- 使用
requestAnimationFrame,不使用定时器 - 使用
documentFragment - 防抖节流
- 避免使用table、float布局
- 避免逐条修改样式,使用类名合并样式
- 使用
transform、opacity,不使用left,CSS合成动画不影响主线程,可以利用硬件加速 will-chang,为元素单独生成一个图层,一般设置的是非常消耗性能的元素。但是不可作用于全局,内存顶不住- 浏览器的Flush队列:浏览器会把多次DOM操作放到一个队列中,会在下次重绘前清空队列。假如获取DOM的offsetTop,会强制把队列清空,这也导致了
FSL。因为浏览器要保证用户拿到的数据是最新,所以我们可以减少属性的实时获取。
3.避免频繁的垃圾回收
如果一些函数中频繁创建临时对象,那么垃圾回收器会频繁的去执行垃圾回收策略。当回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会产生掉帧和卡顿。
我们一个尽量优化存储结构,避免小颗粒对象产生。
4.图片
- 图片懒加载:原理就是监听图片是否在出现在可视区域,如果出现,则将事先放置在
data-src属性上的值赋值到src属性上。实现的话有两种方案:
第一种就是获取当前可视区域的高度innerHeight、元素距离可视区域顶部的高度offsetTop,监听滚动事件然后获取滚动高度scrollTop,判断innerHeight + scrollTop > offsetTop则图片显示。
还有一种就是使用Intersection Observer,判断图片是否出现在视口。但是有兼容性问题。 - 图片压缩
- 图片放cdn
- 合适的图片类型:使用webp格式,具有更优的图像数据压缩算法
- 大文件图片通过渐进式jpeg图片(Progressive JPEG),在浏览器渲染先模糊后清晰
- 某些场景下将图片转base64或者用图标代替(减少网络请求)
5.减少DOM数量
可以通过
DevTools查看DOM数量,上篇文章有讲到过
- DOM层级嵌套不宜过深,特别是在封装公用组件的时候
- 当服务端同时提供N条数据时,可以使用虚拟列表渲染参考
6.Vue
1.函数式组件
函数式组件是无状态的 (没有响应式数据),也没有实例 (没有 this 上下文),因为函数式组件只是函数,所以渲染开销也低很多。基本是DOM层的复用。
2.禁止响应式
当数据的广度或者深度很大的并且页面不需要做响应式的时候,可以通过Object.freeze(**)给数据设置禁止做响应式,那Vue内部就不会走defineReactive,能减少很大方面的性能消耗。
3.组件延时渲染
比如一个页面包括几个都非常大的子组件页面,那同时渲染这几个子组件肯定非常耗时,白屏时间过长,那我们可以一个个渲染,这样就能把一个长任务拆分成多个短任务。
拆分后的任务还是有点长,我们可以对子组件继续做拆分。
<template>
<div>
<h1>延时渲染</h1>
<Child1 v-if="delay(2)"/>
<Child2/>
<Child3 v-if="delay(4)"/>
</div>
</template>
<script>
import delay from '@mixins/delay'
export default {
mixins: [delay(4)]
}
</script>
看看这个delay
export default function (max = 10) {
return {
data() {
return {
current: 0
}
},
created() {
this.run()
},
methods: {
run() {
const step = () => {
window.requestAnimationFrame(() => {
if (this.current++ < max) {
step()
}
})
}
step()
},
delay(sort) {
return this.current >= sort
}
}
}
}
未完待续
End
好了,先分享这么多,如果喜欢,可以点个赞,你的点赞将是我最大的动力,谢谢!