从龟速 11s 到闪电 1s,详解前端性能优化之首屏加载

390次阅读  |  发布于2年以前

背景

前段时间公司服务器网络波动,网站访问变慢,一些性能问题也随之暴露了出来。纷纷反馈在这样的弱网条件下,访问新项目时,加载了近1分钟都没加载出来,而访问其他页面顶多也就30-40s。

在网络恢复后,尝试访问了下页面,无缓存首次打开需要等待近11s的时间,最大的资源达到了3.7M...

在对项目做了一些优化处理后,再次无缓存打开可以发现网页几乎是秒开,平均耗时在1s以内

在这里总结记录一下,基本上都是一些常规可复制的优化手段,希望能为同样想优化网页性能的你提供思路~

优化效果

Network

Slow3G条件下22-25s加载完成

lighthouse

hiper

关于性能优化

在开始之前,我们需要明白一个原则:性能优化的最终目的是提升用户体验。 简而言之就是让用户感觉这个网站很「快」(至少不慢hh),这里的「快」有两种,一种是「真的快」一种是「觉得快」

做好这两方面都能提升用户对网站的性能评价。

权衡取舍

另外就是软件工程没有银弹,一种优化方案可能适用于大多数项目,但是某些特殊情况下很可能会起反效果。

举个,由于浏览器有单域名下并发请求限制,通常我们会将依赖统一打成一个vendor包(vue-cli默认策略),减少首屏请求数,且依赖不变动的情况下文件指纹不变,可以有效利用304缓存。在依赖不多的情况这么处理确实有助于提升加载速度,但一旦依赖多起来,vendor就会特别的大,在弱网条件下,会严重拖慢页面显示。这显然不是我们想要的,所以我们根据情况会对vendor进行拆分,比如拆分到CDN,或者直接拆分到页面中

因此,我们在做性能优化过程中,必须根据最终能给用户体验带来的提升权衡后做出适合当前项目的选择

指标和目标

目标会影响我们在过程中的决策 指标则用来度量我们的目标

目标

首先我们需要确定目标,根据场景和项目复杂度不同,制定的目标也不同,比如希望比竞品快20%,或者符合标准的"2-5-10"原则等等

这里我定下的目标是

指标

关于指标这块,简单介绍下常见指标

调试工具

通过性能调试工具可以直观便捷地获取这些指标,比如Newwork、k6、hiper、Lighthouse...。具体可以看我关于性能调试工具的另一篇文章

瓶颈分析

Network分析

优化前Network

从Network上我们发现主要问题在3.2M的chunk-vendor.js上

Lighthouse分析

优化前Lighthouse

Performance分析

由于本次不涉及到应用内场景性能优化,Performance分析跳过...

dist目录分析

Webpack Bundle分析

优化前Bundle

从webpack bundle可以看出,问题着实不少

开始优化

体积优化

⚡排查并移除冗余依赖、静态资源

内容(点击展开/收起)

before:4.96M after:4.12M

⚡构建时压缩图片

内容(点击展开/收起)

每次使用在线服务手动压缩较为麻烦,可以直接在构建流程中加入压缩图片

使用image-webpack-loader

// install
npm i image-webpack-loader -D
// vue.config.js
chainWebpack: (config) => {
    if (isProd) {
        // 图片压缩处理
        const imgRule = config.module.rule('images')
        imgRule
            .test(/\.(png|jpe?g|gif|webp)(\?.*)?$/)
            .use('image-webpack-loader')
            .loader('image-webpack-loader')
            .options({ bypassOnDebug: true })
            .end()
    }
}
复制代码
  1. install或build时如果出现imagemin库下载失败,可以尝试 换源、配置github hosts、install时添加--user=root解决
  2. 由于在图片下载后已经手动用在线工具压缩过,这部分提升不大

before:4.12M after:4.00M

⚡使用webP图片

内容(点击展开/收起)

webP是谷歌推出的新图片格式(2010),同等质量下体积拳打png脚踢jpg,目前兼容性[1]还算可以,就苹果家的表现不太理想

转换为webP图片

可以手动,也可以加入构建自动化生成。

./cwebp -q 75 login_plane_2.png -o login_plane_2.webp
复制代码

页面中使用(兼容低版本)

<picture>
    <source srcset="hehe.webp" type="image/webp">
    <img src="hehe.png" alt="hehe>
</picture>
复制代码
// main.js
window.addEventListener('DOMContentLoaded', () => {
    const isSupportWebP = document.createElement('canvas')
    .toDataURL('image/webp')
    .indexOf('data:image/webp') === 0
    document.documentElement.classList.add(isSupportWebP ? '' : '.no-support-webp');
})
// css
.support-webp .bg{
    background-image: url("hehe.webp");
}

.no-support-webp .bg {
    background-image: url("hehe.png");
}
复制代码
  1. 请务必使用原图进行webp转换,否则会影响体积
  2. 项目大图不多,最大400KB的图片转换后只有48.9KB

⚡优化SVG图标

内容(点击展开/收起)

这一步我们来优化部分冗余的旧SVG图标被打包进去的情况,一般项目中SVG使用方式都是在iconfont生成JS然后引入。这种做法

更优的SVG玩法

要实现上述效果,只需要

// install
npm i svg-sprite-loader -D
// vue.config.js
chainWebpack: (config) => {
    // SVG处理
    config.module
      .rule('svg')
      .exclude.add(resolve('src/icons/svg'))
      .end()
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/icons/svg'))
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
      .end()

}
复制代码
// src/icons/index.js
const req = require.context('./svg', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

// main.js
import '@/icons'
复制代码
// src/icons/index.js
import Vue from 'vue'
import CaSVG from '@/components/ca-svg'
Vue.component('ca-svg', CaSVG)

// src/components/ca-svg.vue
<template>
  <svg :class="svgClass" aria-hidden="true" v-on="$listeners" :style="svgStyle">
    <use :xlink:href="iconName" />
  </svg>
</template>
...
// name属性为必须字段,其他size或color可以自由定制
复制代码

压缩优化

SVG通常会有一些冗余信息导致影响体积,这里我们可以使用svgo-loader来进一步压缩

// install
npm i svgo-loader -D

// vue.config.js
// 接上面svg的配置
...
.end()
.use('svgo-loader')
.loader('svgo-loader')
.end()
复制代码

⚡优化Ant-design-vue体积

内容(点击展开/收起)

可以看到这部分在chunk-vendor中占的比例不小

按需引入

这部分实际上已经是做了处理的了,具体操作参考ant-design-vue文档,按说明做没啥大坑,效果也符合预期。

快速上手 \- Ant Design Vue[2]

删除冗余组件

部分组件是不经常用的,但却使用了Vue.use()全局引入了。这里去掉不常用和没用到的全局引入,改为页面内import()引入

⚡优化Ant-design-icon体积

内容(点击展开/收起)

这一部分,由于我们在项目中只使用了几个Ant内置图标,不可能有530+KB。根据Ant文档的描述是由于其将ICON全量引入的关系导致的,说法是当前用法如果按需加载的话无法确定使用者会不会在运行时改变icon,比如配置的ICON。

重定向到本地来控制

这个问题,在React版的Ant-Design是已经是做了处理的了(写法上有所调整),但在Ant-Design-Vue-1.x中仍然没有官方解决方案。目前了解到的有两种方案

// alias配置
resolve: {
  alias: {
    '@ant-design/icons/lib/dist$': path.resolve(__dirname, './src/icons/antd-icon.js')
  }
}
// src/icons/antd-icon.js
export { default as LoadingOutline } from '@ant-design/icons/lib/outline/LoadingOutline'
复制代码
  1. 注意项目中和Ant组件中使用的ICON都要导出
  2. 优化后从530K+到30K+

⚡优化moment、moment-timezone体积

内容(点击展开/收起)

这两个包300K + 160K,加起来有460K,也是占的比较多的一项

不打包moment时区文件

这里使用内置的IgnorePlugin即可做到

// webpack plugins
plugins: [
  // Ignore all locale files of moment.js
  new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
复制代码

移除moment-timezone

moment-timezone包含了moment,项目中只有一处地方使用到,用来获取东八区的当前时间,moment是可以做到的,不需要额外引入moment-timezone

// old
moment.tz('Asia/Shanghai').format('YYYYMMDDHHmmss')
// new
moment.utcOffset(480).format('YYYYMMDDHHmmss')
复制代码

使用dayjs替代moment

dayjs实现了moment的大多数功能,Api基本一致,压缩后体积却不到2k,是优秀的替代方案,且多数情况下,dayjs可以完美的替代moment(按需引入插件)

但是这里遇到了使用Ant-Design的第二个坑,Ant-Design实现Date-Picker时使用了moment,所以在项目中不使用moment也不管用,一样会打包进来....

这个问题,在React版的Ant-Design又已经是做了处理的了,允许用户选择dayjs或moment。同样的,Ant-Design-Vue仍没有跟进...

所幸,dayjs作者提供了一个插件,可以将Ant-Design-Vue的moment替换成dayjs 虽然文档中只说Ant-Design-React可以用,但实际上在issue可以看到它也适用于antdV,不过需要调整一些配置

使用antd-dayjs-webpack-plugin[4]

// vue.config.js
const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin')
configureWebpack: {
    ...
    plugins: [
      new AntdDayjsWebpackPlugin({ preset: 'antdv3', replaceMoment: true })
    ],
}
复制代码

replaceMoment参数是用webpack alias来重定向moment到dayjs,由于项目中多处使用moment,这样做可以继续使用moment实际上调用的dayjs,实现无感知替换

  1. 替换成dayjs后,一些功能需要通过引入插件来保持一致,比如utc、updateLocale
  2. 替换成dayjs后,一些针对moment的优化需要改成dayjs或去掉

⚡优化core-js体积

内容(点击展开/收起)

core-js实际上就是浏览器新API的polyfill,项目是PC端,所以主要是为了兼容IE...

调整.browserslistrc

调整useBuiltIns

项目中默认是useBuiltIns: 'entry'将所有polyfill都引入了,导致包比较大。我们可以使用useBuiltIns: 'entry'调整下策略,按需引入,项目中没使用到的API就不做polyfill处理了

// babel.config.js
module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset',
    [
      '@babel/preset-env',
      {
        'useBuiltIns': 'usage', // entry,usage
        'corejs': 3
      }
    ]
  ],
  plugins
}
复制代码

btw,这里更优的做法应该是使用动态polyfill,让服务器根据UA判断是否要返回polyfill

before:4.96M after:2.96M


传输优化

⚡优化分包策略

内容(点击展开/收起)

vue-cli3的默认优化是将所有npm依赖都打进chunk-vendor,但这种做法在依赖多的情况下导致chunk-vendor过大

optimization: isProd ? {
  splitChunks: {
    chunks: 'all',
    maxInitialRequests: Infinity, // 默认为3,调整为允许无限入口资源
    minSize: 20000, // 20K以下的依赖不做拆分
    cacheGroups: {
      vendors: {
        // 拆分依赖,避免单文件过大拖慢页面展示
        // 得益于HTTP2多路复用,不用太担心资源请求太多的问题
        name(module) {
          // 拆包
          const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
          // 进一步将Ant组件拆分出来,请根据情况来
          // const packageName = module.context.match(/[\\/]node_modules[\\/](?:ant-design-vue[\\/]es[\\/])?(.*?)([\\/]|$)/)[1]
          return `npm.${packageName.replace('@', '')}` // 部分服务器不允许URL带@
        },
        test: /[\\/]node_modules[\\/]/,
        priority: -10,
        chunks: 'initial'
      }
    }
  },
  runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}` }
} : {}
复制代码
  1. Tips: vue inspect > output.js --mode production 可以查看最终配置
  2. 分包这块需要根据实际情况做对应处理,才能取得比较好的效果,总之多看文档多试就对了

⚡优化路由懒加载

内容(点击展开/收起)

SPA中一个很重要的提速手段就是路由懒加载,当打开页面时才去加载对应文件,我们利用Vue的异步组件和webpack的代码分割(import())就可以轻松实现懒加载了。

但当路由过多时,请合理地用webpack的魔法注释对路由进行分组,太多的chunk会影响构建时的速度

{
    path: 'register',
    name: 'register',
    component: () => import(/* webpackChunkName: "user" */ '@/views/user/register'),
}
复制代码

请只在生产时懒加载,否则路由多起来后,开发时的构建速度感人

⚡开启HTTP2

内容(点击展开/收起)

HTTP2是HTTP协议的第二个版本,相较于HTTP1 速度更快、延迟更低,功能更多。目前来看兼容性[5]方面也算过得去,在国内有超过50%的覆盖率。

通常浏览器在传输时并发请求数是有限制的,超过限制的请求需要排队,以往我们通过域名分片、资源合并来避开这一限制,而使用HTTP2协议后,其可以在一个TCP连接分帧处理多个请求(多路复用),不受此限制。(其余的头部压缩等等也带来了一定性能提升)

如果网站支持HTTPS,请一并开启HTTP2,成本低收益高,对于请求多的页面提升很大,尤其是在网速不佳时

Nginx开启HTTP2(>V1.95)

// nginx.conf
listen 443 http2;
复制代码
nginx -s stop && nginx
复制代码

HTTP2开启后

多路复用避开了资源并发限制,但资源太多的情况,也会造成浏览器性能损失(Chrome进程间通信与资源数量相关)

⚡Gzip压缩传输

内容(点击展开/收起)

Gzip压缩是一种强力压缩手段,针对文本文件时通常能减少2/3的体积。

HTTP协议中用头部字段Accept-EncodingContent-Encoding对「采用何种编码格式传输正文」进行了协定,请求头的Accept-Encoding会列出客户端支持的编码格式。当响应头的 Content-Encoding指定了gzip时,浏览器则会进行对应解压

一般浏览器都支持gzip,所以Accept-Encoding也会自动带上gzip,所以我们需要让资源服务器在Content-Encoding指定gzip,并返回gzip文件

Nginx配置Gzip

#开启和关闭gzip模式
gzip on;
#gizp压缩起点,文件大于1k才进行压缩
gzip_min_length 1k;
# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 6;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript ;
# nginx对于静态文件的处理模块,开启后会寻找以.gz结尾的文件,直接返回,不会占用cpu进行压缩,如果找不到则不进行压缩
gzip_static on
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 设置gzip压缩针对的HTTP协议版本
gzip_http_version 1.1;
复制代码

构建时生成gzip文件

虽然上面配置后Nginx已经会在响应请求时进行压缩并返回Gzip了,但是压缩操作本身是会占用服务器的CPU和时间的,压缩等级越高开销越大,所以我们通常会一并上传gzip文件,让服务器直接返回压缩后文件

// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin')
// gzip压缩处理
chainWebpack: (config) => {
    if(isProd) {
        config.plugin('compression-webpack-plugin')
            .use(new CompressionPlugin({
                test: /\.js$|\.html$|\.css$/, // 匹配文件名
                threshold: 10240, // 对超过10k的数据压缩
                deleteOriginalAssets: false // 不删除源文件
            }))
    }
}
复制代码
  1. 插件的默认压缩等级是9,最高级的压缩
  2. 图片文件不建议使用gzip压缩,效果较差

⚡Prefetch、Preload

内容(点击展开/收起)

标签的rel属性的两个可选值。 **Prefetch**,预请求,是为了提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器有可能通过事先获取和缓存对应资源,优化用户体验。 **Preload**,预加载,表示用户十分有可能需要在当前浏览中加载目标资源,所以浏览器必须预先获取和缓存对应资源。

Prefetch、Preload可以在某些场景下可以有效优化用户体验。举些场景

Vue-Cli3默认会使用preload-webpack-plugin对chunk资源做preload、prefetch处理,入口文件preload,路由chunk则是prefetch。

一般来说不需要做特别处理,如果判断不需要或者需要调整在vue.config.js中配置即可

  1. 理论上prefetch不会影响加载速度,但实际测试中,是有轻微影响的,不过这个见仁见智,我认为总体体验上还是有所提升的,
  2. 类似字体文件这种隐藏在脚本、样式中的首屏关键资源,建议使用preload
  3. 移动端流量访问时慎用

⚡托管至OSS + CDN加速

内容(点击展开/收起)

当应对一些弱网地区时,OSS + CDN无疑是很强力的提速手段。

OSS,对象存储

海量,安全,低成本,高可靠的云存储服务。可以通过简单的REST接口,在任何时间、任何地点上传和下载数据,也可以使用WEB页面对数据进行管理。

OSS的特点:

另外,OSS还提供一些方便的服务

这里推荐直接购买阿里家的OSS,OSS虽然也有传输加速服务,但对于静态热点文件的下载加速场景还是需要CDN加速

CDN,内容分发网络

CDN加速原理是把提供的域名作为源站,将源内容缓存到边缘节点。当客户读取数据时,会从最适合的节点(一般来说就近获取)获取缓存文件,以提升下载速度。

由于没申请到资源,项目并没有上OSS+CDN。但如果有条件还是建议上,提升很大


感知性能优化

白屏时的loading动画

内容(点击展开/收起)

首屏优化,在JS没解析执行前,让用户能看到Loading动画,减轻等待焦虑。通常会在index.html上写简单的CSS动画,直到Vue挂载后替换挂载节点的内容,但这种做法实测也会出现短暂的白屏,建议手动控制CSS动画关闭

首屏骨架加载

内容(点击展开/收起)

首屏优化,APP内常见的加载时各部分灰色色块。针对骨架屏页的自动化生成,业界已有不少解决方案。


感知优化的一些补充

首屏以外的一些场景优化,更多相关内容比如图片懒加载、组件懒加载等 后续文章会做介绍

渐进加载图片

内容(点击展开/收起)

一般来说,图片加载有两种方式,一种是自上而下扫描,一种则是原图的模糊展示,然后逐渐/加载完清晰。前者在网速差的时候用户体验较差,后者的渐进/交错式加载则能减轻用户的等待焦虑,带来更好的体验

渐进/交错格式图片

浏览器本身支持这种图片的模糊到清晰的扫描加载方式,只需要将处理好资源即可

渐进加载图片

先加载小图,模糊化渲染,图片加载完成后替换为原图,最典型的例子就是Medium,模糊化可以用filter或者canvas处理

加载占位图

先加载全局通用loading图或者用CSS填充色块,图片加载完成后替换为原图。简单粗暴,在弱网条件下很有用

  1. 几种方式可以同时搭配使用
  2. 渐进/交错格式图片会占用一定CPU和内存,酌情使用

路由跳转Loading动画

内容(点击展开/收起)

弱网优化手段,用了懒加载后用户如果在弱网条件下点击下一个页面在下个页面加载完成前页面内容不可用,用户会理解为卡顿。在VueRouter的路由守卫中处理即可

结尾

本文只介绍了首屏加载场景下的性能优化,实际上性能优化远不止这些内容,SPA的加载性能指标采集光靠Lighthouse、slow 3G模拟真的可信吗?除了加载场景外的其他方面呢?构建速度、操作流畅性...

业务

性能优化影响的,不仅是用户体验,还影响了转化率、搜索引擎排名,这些因素都会对最终的流量、销量等收入造成影响

来自Google的数据表明,一个有10条数据0.4秒能加载完的页面,变成30条数据0.9秒加载完之后,流量和广告收入下降90%。

Google Map 首页文件大小从100KB减小到70-80KB后,流量在第一周涨了10%,接下来的三周涨了25%。

亚马逊的数据表明:加载时间增加100毫秒,销量就下降1%。

立场不同,看问题角度也会变化,比如对老板来说最终目的其实是搞钱hh,用户体验这些花里胡哨的,别人不一定懂。

用户体验 × money √

所以如果必要,请在过程前后做好性能收益的数据监控和分析,在性能优化和产品指标之间建立正向联系,方便自上而下的推动技术方案的执行。这才是你说服上司或领导投入成本到性能优化上的重要依据

个人提升

性能优化算是老生常谈的话题了,但部分人在面对怎么做性能优化的问题时,仅仅只是罗列出各种常见优化手段,更有深度的答案应该是遇到什么性能问题,针对这个问题围绕某些性能指标采取了什么手段,手段是否带来了其他问题,怎么权衡,最终达到了什么样的效果。

参考

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8