前端性能优化

2,344 阅读7分钟

Start

通常一个页面可以分为三个阶段,加载阶段、交互阶段、关闭阶段,影响前端性能的主要就是前面两个阶段,那本文主要会从这两个阶段入手讲解,如果能搭配浏览器DevTools那就更棒了。


加载阶段

指从发出请求到渲染出完整页面的过程

1.请求优化

减少HTTP请求

HTTP请求大致包括DNS查找,TCP三次握手,发送HTTP请求,处理请求返回数据,浏览器接收等过程。以下几点

  1. DNS查找为递归查找,会有一定的时间开销。
  2. 请求头会带上一些额外的信息,当请求资源很小可能请求头带的数据比传输数据量还大,那传输速度就慢了。
  3. 每个请求对服务器也会造成一定的开销。

如何减少:

  1. 减少外部脚本数
  2. 合并CSSJS
  3. 图片使用Base64
  4. 图片地图/CSS精灵
  5. CDN
  6. 将多个请求合并为一个进行请求

一个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

image.png

  1. preconnect(预连接) : 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括 DNS 解析,TLS 协商,TCP 握手,这消除了往返延迟并为用户节省了时间。
  2. preload:高优先级加载资源。
  3. dns-prefetch DNS请求在带宽方面流量非常小,可是延迟会很高,尤其是在移动设备上。dns-prefetch允许浏览器在用户浏览页面时在后台运行 DNS 的解析。如此一来,DNS 的解析在用户点击一个链接时已经完成,所以可以减少延迟。

2. 关键资源

能阻塞网页首次渲染的资源称为关键资源,HTML、JavaScript、CSS文件

可以从以下方面优化:

关键文件加载顺序

我们都知道,CSS放上面,JS放下面,因为JS会阻塞HTML的解析和加载,如果CSS放下面,那可能会触发多次重绘。

减少关键资源的个数
  1. 通过浏览器DevToolsCoverage查看资源利用情况合理删除
  2. 合理使用JavaScriptCSS文件内联
  3. 非关键JavaScript文件使用async或者defer异步加载
减少关键资源的大小
  1. 压缩资源
  2. UI库的按需引入,下面以antdv为例:
  • moment.js优化
// vue.config.js
configWebpack:{
    new webpack.IgnorePlugin(/^./locale$/, /moment$/),
}

// main.js
import 'moment/locale/zh-cn'

对比图:
image.png

  • 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'))
}

对比图:
image.png

  1. 组件异步加载
  2. 分包,通过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
                }
            }
        }
   }
}
  1. 开启GZIP,利用重复出现的字符串临时替换从而实现压缩
  2. purgecss-webpack-plugin 优化CSS,类似Tree-Shaking
请求关键资源需要的RTT

RTT(Round Trip Time):使用TCP协议传输一个文件时,这个数据不是一次传输过去的,需要拆分成一个个数据包来回多次进行传输的,1个数据包在14KB左右。比如文件大小140KB,就需要拆分成10个包传输,就需要10个RTT。RTT表示的是从发送端发送数据开始到收到接收端的确认,总共经历的时延。

  1. 使用CDN
  2. 减少关键资源的个数和大小

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-ModifiedEtag
    Last-Modified代表资源在服务器最后被修改的时间,可能这次修改了但是没有改变文件内容,计算不准确。服务端会在响应头设置laset-midified字段,客户端再次发起该请求时,会在请求头设置If-Modified-Since字段并携带上次请求返回的Last-Modified值。
    Etag是服务器以文件内容生成一个唯一字符串标识,计算更准确但同时也会消耗服务器性能,优先级高于前者。客户端再发请求会在请求头设置If-None-Match并带上上次Etag的值,服务器对比。协商缓存生效,状态码返回304。如果不生效,重新返回资源,状态码返回200。
浏览器本地缓存

image.png
可以看到,淘宝在本地缓存了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)
}

image.png 可以看到两个长任务,非常耗时,页面加载十分慢,操作也十分卡顿,那接下来我们要时间切片的方式改造一下:

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)

image.png 可以看到,长任务被分解成一个个短任务,我们可以调整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修改一些样式,随后浏览器会进行样式计算,然后进行布局,绘制,最后将各个图层合并在一起完成整个渲染的流程 image.png

通过看一段代码来了解关键渲染路径

const app = document.querySelector('#app')
let p = document.createElement('p')
p.innerHTML = 'p'
app.appendChild(p)

image.png

重排重绘

image.png 此路径是关键渲染路径,比如修改DOM宽高,移动DOM位置等,会重新走此路径,也是我们所说的重排(回流)Reflow

image.png 比如修改背景图片、文字颜色或阴影等,会走上面流程,这既是我们常说的重绘Repaint,+即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。

image.png 此过程不会重排重绘,仅仅是进行合成,也就是修改 transform 和 opacity 属性更改来实现动画,性能得到较大提升,比如动画或滚动。

FSL和LT

FSL(Forced Synchronous Layouts) 称为强制同步布局:JavaScript强制将计算样式和布局操作提前到当前的任务中。LT(Layout Thrashing)称为布局抖动,多次执行FSL

image.png

我们接着上面的代码改造一下:

const app = document.querySelector('#app')
let p = document.createElement('p')
p.innerHTML = 'p'
app.appendChild(p)
console.log(app.offsetWidth) // 添加一行代码

image.png

单个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()
}

image.png Forced reflow is a likely performance bottleneck.翻译:强制回流很可能成为性能瓶颈。 可以看到大量操作DOM的代价十分的大,你还敢瞎搞嘛。

重排重绘优化方案:
  1. DOM属性(宽高值)做缓存,避免频繁获取
  2. DOM离线,先设置dislay:none,然后再去操作DOM
  3. 给DOM设置absolute,脱离文档流
  4. 图片设置宽高,防止LS(Layout Shift)
  5. 使用requestAnimationFrame,不使用定时器
  6. 使用documentFragment
  7. 防抖节流
  8. 避免使用table、float布局
  9. 避免逐条修改样式,使用类名合并样式
  10. 使用transformopacity,不使用left,CSS合成动画不影响主线程,可以利用硬件加速
  11. will-chang,为元素单独生成一个图层,一般设置的是非常消耗性能的元素。但是不可作用于全局,内存顶不住
  12. 浏览器的Flush队列:浏览器会把多次DOM操作放到一个队列中,会在下次重绘前清空队列。假如获取DOM的offsetTop,会强制把队列清空,这也导致了FSL。因为浏览器要保证用户拿到的数据是最新,所以我们可以减少属性的实时获取。

3.避免频繁的垃圾回收

如果一些函数中频繁创建临时对象,那么垃圾回收器会频繁的去执行垃圾回收策略。当回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会产生掉帧和卡顿。
我们一个尽量优化存储结构,避免小颗粒对象产生。

image.png

4.图片

  1. 图片懒加载:原理就是监听图片是否在出现在可视区域,如果出现,则将事先放置在data-src属性上的值赋值到src属性上。实现的话有两种方案:
    第一种就是获取当前可视区域的高度innerHeight、元素距离可视区域顶部的高度offsetTop,监听滚动事件然后获取滚动高度scrollTop,判断innerHeight + scrollTop > offsetTop则图片显示。
    还有一种就是使用Intersection Observer,判断图片是否出现在视口。但是有兼容性问题。
  2. 图片压缩
  3. 图片放cdn
  4. 合适的图片类型:使用webp格式,具有更优的图像数据压缩算法
  5. 大文件图片通过渐进式jpeg图片(Progressive JPEG),在浏览器渲染先模糊后清晰
  6. 某些场景下将图片转base64或者用图标代替(减少网络请求)

5.减少DOM数量

可以通过DevTools查看DOM数量,上篇文章有讲到过

  1. DOM层级嵌套不宜过深,特别是在封装公用组件的时候
  2. 当服务端同时提供N条数据时,可以使用虚拟列表渲染参考

6.Vue

1.函数式组件

函数式组件是无状态的 (没有响应式数据),也没有实例 (没有 this 上下文),因为函数式组件只是函数,所以渲染开销也低很多。基本是DOM层的复用。

2.禁止响应式

当数据的广度或者深度很大的并且页面不需要做响应式的时候,可以通过Object.freeze(**)给数据设置禁止做响应式,那Vue内部就不会走defineReactive,能减少很大方面的性能消耗。

3.组件延时渲染

比如一个页面包括几个都非常大的子组件页面,那同时渲染这几个子组件肯定非常耗时,白屏时间过长,那我们可以一个个渲染,这样就能把一个长任务拆分成多个短任务。

image.png

image.png 拆分后的任务还是有点长,我们可以对子组件继续做拆分。

<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

好了,先分享这么多,如果喜欢,可以点个赞,你的点赞将是我最大的动力,谢谢!