前言
接上回说到,前两天的项目中遇到了大屏的报错问题,顺手解决了两个控制台的警告,超时警告的最后是通过 Chrome DevTool 发现了页面渲染的性能问题,数据的响应式更新造成了组件的重复渲染。 那么今天就来谈一谈性能优化吧,看看应该从哪几个方面入手提升页面的性能,刚好也让我借着这次机会再来系统的总结回顾一下!
性能指标
如果想要知道一个页面的性能,那首先要知道衡量的指标,页面再渲染的过程中会有哪些指标呢?
性能优化主要分为
- 加载时优化:网站加载的速度如 白屏时间、首屏时间
- 运行时优化:运行时性能是指页面运行时的性能表现
加载时优化
- 白屏时间:指的是从输入网址, 到页面开始显示内容的时间。
- 首屏时间:指从输入网址, 到首屏页面内容渲染完毕的时间。
白屏时间计算
将代码脚本放在 前面就能获取白屏时间:
<script>
new Date().getTime() - performance.timing.navigationStart
</script>
首屏时间计算
在window.onload事件中执行以下代码,可以获取首屏时间:
new Date().getTime() - performance.timing.navigationStart
网址的输入到页面渲染的过程
这个问题我想不少人在刷面试题的时候都遇到过吧,说一说从输入URL到页面渲染的整个过程...
- 首先查找浏览器是否缓存
- DNS 域名解析找到对应的 IP 地址
- 三次握手与服务器建立 TCP 连接
- 浏览器发送 HTTP 请求
- 服务器接收请求
- 服务器处理请求并返回 HTTP 报文
- 浏览器接收并解析渲染页面
渲染时优化
在整个页面渲染期间可以做到的优化点:
1. DNS 解析优化
DNS全称为Domain Name System,即域名系统,是域名和IP地址相互映射的一个分布式数据库。
域名解析即通过主机名,最终得到该主机名对应的IP地址的过程。
通过 DNS预解析优化、DNS 负载均衡、减少 DNS 请求、配置CDN域名加速等方式缩短浏览器访问DNS的时间。
那么先来看一下 DNS的解析流程:
浏览器访问域名时 DNS 解析方案是 先查缓存,再递归解析:浏览器缓存-系统缓存-路由器缓存-ISP
- 搜索浏览器自带的DNS缓存
- 查询操作系统缓存和 /etc/hosts本地文件
- 查询路由器中的DNS缓存
- 向根服务器发送递归查找,从 根域名服务器 > 顶级域名服务器 > 权威域名(主域名)服务器 > 本地域名服务器器 依次递归查找
- 将解析得到的结果保存到缓存,以备下一次使用
DNS 预解析
在用户点击链接之前预先解析链接中包含的域名,对网页中的域名进行解析缓存,因此在单击当前网页中的连接时无需进行 DNS 的解析,从而加快页面的加载速度提高用户体验。
设置 DNS 预解析:
- 用meta信息来告知浏览器, 当前页面要做DNS预解析
<meta http-equiv="x-dns-prefetch-control" content="on" />
- 使用HTML的DNS预解析标签:在页面 header 中使用 link 标签来强制对 DNS 预解析
DNS Prefetch 应该尽量的放在网页的前面,推荐放在后面
<link rel="dns-prefetch" href="http://xxx.example.com" />
这样浏览器在加载页面时就会预先解析 xxx.example.com 的DNS信息,从而加快后续页面的加载速度。
注意:配置 dns-prefetch 需慎用,DNS预解析虽然可以加速网站的加载速度,但也会增加网络流量和服务器负担,多页面重复DNS预解析会增加重复DNS查询次数。
DNS 缓存优化
DNS 缓存是指前一次查询获取到的 DNS 记录会被保存在本地 DNS 缓存中,优化 DNS 缓存可以降低 DNS 解析的延迟,提高网站的访问速度,同时也可以减轻 DNS 服务器的负担。
可以通过增加本地 DNS 缓存的大小、合理配置 TTL 值等方式来优化 DNS 缓存。
TTL(Time-To-Live)是 DNS 解析结果的缓存时间,经常作为本地 DNS 缓存清理的优化策略
减少 DNS 解析时间的一个有效方式就是合理配置 TTL 值。当 DNS 记录的 TTL 值设置得过大时,DNS 解析的结果会在本地缓存较长时间,导致在 DNS 服务器上更新 DNS 记录信息时,客户端本地 DNS 缓存中的记录仍然存在,从而会延迟DNS解析时间。
因此,需要根据网站的特点设置合理的TTL值。
DNS 负载均衡
DNS负载均衡技术是指在多台服务器分发请求时,将同一个域名映射到多个IP地址,当应答DNS查询时, DNS服务器对每个查询将以DNS文件中主机记录的IP地址按顺序返回不同的解析结果,使得客户端访问不同的服务器,从而达到负载均衡的目的,减少服务器压力。
CDN域名加速
CDN(Content Delivery Network,内容分发网络)构建在现有互联网基础之上的一层智能虚拟网络,通过在网络各处部署节点服务器,实现将源站内容分发至所有CDN节点,使用户可以就近获得所需的内容。
CDN服务缩短了用户查看内容的访问延迟,将静态资源通过 CDN 进行加速,减少IP地址切换带来的影响,解决了网络带宽小、用户访问量大、网点分布不均等问题,提升访问速度。
2. 使用HTTP2
解析速度快 多路复用 首部压缩 服务器推送
3. 减少HTTP请求数量
HTTP的请求建立和释放需要时间。
HTTP请求从建立到关闭一共经过以下步骤:
- 客户端连接到Web服务器
- 发送HTTP请求
- 服务器接受请求并返回HTTP响应
- 释放连接TCP链接
这些步骤都是需要花费时间的,在网络情况差的情况下,花费的时间更长。
如果页面的资源非常碎片化,每个HTTP请求只带回来几K甚至不到1K的数据(比如各种小图标)那性能是非常浪费的。
4. 压缩、合并文件,减少请求大小
-
压缩文件 -> 减少HTTP请求大小,可以减少请求时间 对html、css、js以及图片资源进行压缩处理
-
文件合并 -> 减少HTTP请求数量。 提取公共代码
-
压缩资源
- js压缩:UglifyPlugin
- CSS压缩:MiniCssExtractPlugin
- HTML压缩:HtmlWebpackPlugin
- 图片压缩:image-webpack-loader
- 消除无用的 js 代码:tree-shaking (消除无用 JS 代码,减少代码体积,且只对 export 导出的变量生效)
5. 服务器端渲染 SSR
客户端渲染: 获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。
服务端渲染:服务端返回 HTML 文件,客户端只需解析 HTML。
优点:首屏渲染快,SEO 好。
缺点:配置麻烦,增加了服务器的计算压力。
6. 静态资源使用CDN
用户与服务器的物理距离对响应时间也有影响。把内容部署在多个地理位置分散的服务器上能让用户更快地载入页面, CDN就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。
7. 资源懒加载
不重复加载相同的资源,其中懒加载包括 SPA 应用中的路由懒加载、图片懒加载
路由懒加载
在 SPA 项目中,一个路由对应一个页面,如果不做处理,会把所有页面打包成一个文件,当用户打开首页时,会一次性加载所有的资源,造成首页加载很慢,降低用户体验。
而路由懒加载可以帮助我们更高效地加载和使用页面的代码。
路由懒加载:是一种优化网站或应用程序性能的技术。它是指延迟加载(或懒加载)网站或应用程序中的代码,直到它们首次被请求或需要时才加载它们。
路由懒加载的实现:
// 将
// import UserDetails from './views/UserDetails.vue'
// 替换成
const UserDetails = () => import('./views/UserDetails.vue')
const router = createRouter({
// ...
routes: [{ path: '/users/:id', component: UserDetails }],
})
其中懒加载实现的主要方式是 ES6 的动态地加载模块 -- import():
在 webpack 打包过程中 import() 会动态的加载模块,调用 import 的之处,被视为分割点,会将进行请求的或者懒加载的子模块分离出来,打包成一个单独的文件。
将不同路由对应的组件分割成不同的代码块,当路由被访问的时候才加载对应组件,这样就会更加高效。
图片懒加载
先不给图片设置路径,当图片出现在浏览器可视区域时才设置真正的图片路径。
实现上就是先将图片路径设置给original-src,当页面不可见时,图片不会加载:
<img original-src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9eb06680a16044feb794f40fc3b1ac3d~tplv-k3u1fbpfcp-watermark.image" />
通过监听页面滚动,等页面可见时设置图片src:
const img = document.querySelector('img')
img.src = img.getAttribute("original-src")
如果想使用懒加载,还可以借助一些已有的工具库,例如 aFarkas/lazysizes、verlok/lazyload、tuupola/lazyload 等。
css中图片懒加载
除了对于 元素的图片进行来加载,在 CSS 中使用的图片一样可以懒加载,最常见的场景就是 background-url。
.login {
background-url: url(/static/img/login.png);
}
对于上面这个样式规则,如果不应用到具体的元素,浏览器不会去下载该图片。
所以你可以通过切换 className 的方式,放心得进行 CSS 中图片的懒加载。
8. 使用 Defer 异步加载JS
尽量将 CSS 放在文件头部,JavaScript 文件放在底部, 所有放在 head 标签里的 CSS 和 JS 文件都会堵塞渲染。
如果这些 CSS 和 JS 需要加载和解析很久的话,那么页面就空白了。
所以 JS 文件要放在底部,等 HTML 解析完了再加载 JS 文件,只要给 script 标签加上 defer 属性就可以实现异步下载、延迟执行。
补充:js 加载的 6 种方式
- 正常模式 -- script
<script src="index.js"></script>
- 会阻塞 dom 渲染,浏览器必须等待 index.js 加载和执行完成后才能去做其它事情
- async 异步加载 -- script
<script async src="index.js"></script>
- 异步加载,不会阻塞 dom 的渲染
- 无序加载,加载结束后立即执行
使用场景:若该 JS 资源与 dom 元素没有依赖关系,也不会产生其他资源所需要的数据时,可以使用 async 模式,比如埋点
- defer 异步加载 -- script
<script defer src="index.js"></script>
- 异步 且 有序的加载,在 dom 解析完毕之后,但在 DOMContentLoaded 事件之前执行
- 使用场景:一般情况下都可以使用 defer,特别是需要控制资源加载顺序时
- module 模式 -- script
<script type="module">import { a } from './a.js'</script>
加上 type="module" 的模块导入,浏览器会对其内部的 import 引用发起 HTTP 请求,获取模块内容。
这时 script 的行为会像是 defer 一样,在后台下载,并且等待 DOM 解析。
Vite 就是利用浏览器支持原生的 es module 模块,开发时跳过打包的过程,提升编译效率。
- preload -- link
<link rel="preload" as="script" href="index.js">
- 提前加载页面中必定会用到的资源
- 在浏览器的渲染前对资源进行加载,且不会阻塞 onload 事件
- 资源加载和执行的过程是分离的,预先加载相应的脚本代码,待到需要时自行调用
- 跳转或者关闭页面后,请求中断
- prefetch -- link
<link rel="prefetch" as="script" href="index.js">
- 利用空闲时间加载可能需要用到的资源
- 当页面跳转或关闭页面后,未完成的 prefetch 仍会继续请求,不会被中断
用 “preload” 和 “prefetch” 情况下,如果资源不能被缓存,那么都有可能浪费一部分带宽,在移动端请慎用。
运行时优化
1. 减少重绘与重排
浏览器渲染流程
-
- 解析HTML生成DOM树
- 解析CSS生成CSSOM规则树
- 将DOM树与CSSOM规则树合并生成Render(渲染)树
- 遍历Render(渲染)树开始布局, 计算每一个节点的位置大小信息
- 将渲染树每个节点绘制到屏幕上
重绘
当重新生成渲染树后, 将要将渲染树每个节点绘制到屏幕, 这个过程叫重绘。
重排
当改变DOM元素位置或者大小时, 会导致浏览器重新生成Render树, 这个过程叫重排
重排触发时机
重排发生后的根本原理就是元素的几何属性发生改变, 所以从能够改变几何属性的角度入手:
- 添加 | 删除可见的DOM元素
- 元素位置发生改变
- 元素本身的尺寸发生改变
- 内容变化
- 页面渲染器初始化
- 浏览器窗口大小发生改变
二者关系:重排会导致重绘, 但是重绘不会导致重排
减少重绘重排 :
- 避免 table 布局: 会造成整个 table 重新布局
- DOM 的读写操作分离
- 集中改变样式
- position 为 absolute 或 fixed
2. 避免页面卡顿
浏览器渲染更新页面的标准帧率也为60次/s - -60FPS(frames/pre second) , 那么每一帧的预算时间约为16.7ms ≈ 1s/60,浏览器在这个时间内要完成所有的整理工作,如果无法符合此预算, 帧率将下降,内容会在屏幕抖动, 此现象通常称为卡顿。
浏览器在刷屏的过程中的工作流程:
- 用js做了些逻辑,触发了style的样式变化
- style把应用的样式规则计算好之后,把影响到的页面元素进行重新布局,叫做layout
- 再把它画到内存的一个画布里面,paint成了像素,
- 最后把这个画布刷到屏幕上去,叫做composite,形成一帧。
这几项的任何一项如果执行时间太长了,就会导致渲染这一帧的时间太长,平均帧率就会掉。
当然上面的过程并不一定每一步都会执行,例如:
- 你的js只是做一些运算,并没有增删DOM或改变CSS,那么后续几步就不会执行
- style只改了颜色等不需要重新layout的属性就不用执行layout这一步
- style改了transform属性,在blink和edge浏览器里面不需要layout和paint
3. 长列表优化
长列表优化是指对于包含大量数据、需要展示成列表形式的页面进行优化的一种技术。在这种情况下,直接将所有数据展示在页面上可能会存在性能问题,比如页面加载速度缓慢、卡顿、内存占用等。
对于大量数据渲染时,JS 运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。也就是说 JS 执行是很快的,页面卡顿是因为同时渲染大量 DOM 所引起的,可采用分批渲染的方式来解决。
常见的长列表优化方案有:懒加载(触底加载更多)、虚拟列表、时间分片
实现虚拟列表
虚拟列表是一种用来优化长列表的技术。将页面按照可视内容大小进行切片它可以保证在列表元素不断增加,或者列表元素很多的情况下,依然拥有很好的滚动、浏览性能。
列表虚拟滚动效果如下:
不论列表怎么滚动,我们改变的只是滚动条的高度和可视区的元素内容,并没有增加任何多余的元素
核心思想在于:将页面按照可视内容大小进行切片,只渲染可见区域的列表元素,非可见区域不渲染,在滚动时动态更新可视区域。 即 滚动加载效果
滚动列表的组件实现:
当列表滚动时,通过 scrollTop 获取滚动条移动的距离,计算可视区域出现的起始元素 startIndex,同时更新对应列表数据,通过 css 样式中的 transform: translateY() 属性实现列表及滚动条的移动。
<template>
<div class="list" @scroll="scrollHandle" ref="list">
<div class="item" v-for="(item,index) in renderList" :key="index" :style="`height:${itemHeight}px;line-height:${itemHeight}px;transform:translateY(${top}px)`">
{{item}}
</div>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
list:[],//完整列表
itemHeight:60,//每一项的高度
renderList:[],//需要渲染的列表
startIndex:0,//开始渲染的位置
volume:0,//页面的容积:能装下多少个节点
top:0,
scroll,//用于初始化节流函数
}
},
mounted() {
this.initList();
// 获取列表视口高度
const cHeight= this.$refs.list.offsetHeight
// Math.ceil 向上取整 计算视口容纳的下节点个数并且设置缓存节点
this.volume=Math.ceil(cHeight/this.itemHeight)+2;
console.log(document.documentElement.clientHeight, cHeight,this.volume)
//设置要渲染的列表 设置成能够容纳下的最大元素个数
this.renderList=this.list.slice(0,this.volume);
//初始化节流函数 最短50毫秒触发一次
this.scroll=this.throttle(this.onScroll,50);
},
methods: {
//初始化列表 ,循环渲染 500条
initList(){
for(let i=0;i<500;i++){
this.list.push(i);
}
},
scrollHandle(){
this.scroll();
},
onScroll(){
// scrollTop常量记录当前滚动的高度
const scrollTop= this.$refs.list.scrollTop;
console.log(this.$refs.list.scrollTop)
// 获取向上滚动的列表个数,计算开始渲染的节点
const start=this.getCurStart(scrollTop);
// 对比上一次的开始节点 比较是否发生变化,发生变化后便重新渲染列表
if(this.startIndex!=start){
// 计算列表向上移动的偏移量 被itemHeight整除的数来作为item的偏移量
const startOffset = scrollTop - (scrollTop % this.itemHeight);
// 使用slice拿到需要渲染的那一部分
this.renderList=this.list.slice(start,this.startIndex+this.volume);
// 利用css的translateY 实现列表的向上滚动及滚动条的变化
//这里的 top 设置 translateY transform:translateY(${top}px)
this.top = startOffset;
}
this.startIndex=start;
},
getCurStart(scrollTop){
// Math.floor 向下取整,获取滚动条向上滚动的列表个数
return Math.floor(scrollTop/(this.itemHeight));
},
// 定时器 + 时间戳 首尾都执行
throttle(fn, delay) {
let timer, context, args;
let lastTime = 0;
return function () {
context = this;
args = arguments;
let currentTime = new Date().getTime();
// 清空定时器
clearTimeout(timer);
// 时间差 大于 delay 时
if (currentTime - lastTime > delay) {
// 防止时间戳和定时器重复
// 清空定时器后直接 执行 fn
fn.apply(context, args);
lastTime = currentTime;
} else {
timer = setTimeout(() => {
// 设置定时器 更新执行时间, 防止重复执行
lastTime = new Date().getTime();
// 执行后 清空定时器
fn.apply(context, args);
}, delay);
}
};
}
},
}
</script>
<style>
*{
margin: 0;
padding: 0;
}
.list{
height: 100vh;
overflow: scroll;
}
.item{
text-align: center;
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid lightgray;
}
</style>
常用的框架也有不错的开源实现, 如:
- 基于React的
react-virtualized
- 基于Vue 的
vue-virtual-scroll-list
- 基于Angular的
ngx-virtual-scroller
懒加载
实现原理:不渲染所有数据,只展示视图上可见的数据,通过监听父级元素的 scroll 事件,当滚动到页面底部时,加载更多数据
缺点:
- scroll 事件会频繁触发,需要通过节流来优化
- 滚动元素内有大量 DOM ,容易造成卡顿
时间分片
递归的渲染 DOM,通过定时器分批渲染的方式
因为浏览器的渲染机制是“宏任务—微任务—GUI渲染—宏任务...”。先等页面渲染完,再执行下一轮的 setTimeout(宏任务)
而优化中最好的方案是用 IntersectionObserver(交叉观察器)异步的,不随着目标元素的滚动同步触发,性能消耗极低。 通过 IntersectionObserver Api 来实现,图片元素一可见就调用回调,在回调中判断元素是否可见
4. 滚动事件性能优化
由于滚动事件发生非常频繁,所以频繁地执行监听回调就容易造成JavaScript执行与页面渲染之间互相阻塞的情况。
对应滚动这个场景,可以采用 防抖 和 节流 来处理,可以参考这篇文章 来手写一次防抖节流吧!
防抖
在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时,连续触发一个函数,只执行最后一次。
当一个事件频繁触发,而我们希望在事件触发结束一段时间后(此段时间内不再有触发)才实际触发响应函数时会使用防抖(debounce)。例如用户一直点击按钮,但你不希望频繁发送请求,你就可以设置当点击后 200ms 内用户不再点击时才发送请求。
节流
当频繁的触发一个事件,每隔一段时间内, 只会执行一次事件。
当一个事件频繁触发,而我们希望间隔一定的时间再触发相应的函数时, 就可以使用节流(throttle)来处理。比如判断页面是否滚动到底部,然后展示相应的内容;就可以使用节流,在滚动时每300ms进行一次计算判断是否滚动到底部的逻辑,而不用无时无刻地计算。
5. 使用 Web Workers
通过计算切片,使用 setTimeout 拆分密集型任务,但是有些计算无法利用此方法拆解,同时还可能产生副作用,这个方法需要视具体场景而动.
利用Web Worker 进行多线程编程.
Web Worker 是一个独立的线程(独立的执行环境),这就意味着它可以完全和 UI 线程(主线程)并行的执行 js 代码,从而不会阻塞 UI,它和主线程是通过 onmessage 和 postMessage 接口进行通信的。
Web Worker 使得网页中进行多线程编程成为可能。当主线程在处理界面事件时,worker 可以在后台运行,帮你处理大量的数据计算,当计算完成,将计算结果返回给主线程,由主线程更新 DOM 元素。
6. 代码上的优化
- 使用事件委托
绑定的事件越多, 浏览器内存占有就越多,从而影响性能,利用事件代理的方式就可节省一些内存。 - if-else 对比 switch
当判定条件越来越多时, 越倾向于使用switch,而不是if-else,switch
只要进行一次判断即可 - 布局上使用 flexbox 提升性能