从 0-1 优化首屏

37 阅读13分钟

项目背景

产品类型

移动端政务管家

技术架构

UI 框架:Vue3

路由管理:VueRouter4

状态管理:Pinia3

组件库:Vant4

构建工具:Vite6

CSS:Tailwindcss3

备注:项目采用 unplugin-auto-import/vite、 'unplugin-vue-components/vite'、VantResolver 完成组件与样式的自动引入,采用 vite-plugin-pages 自动引入路由

使用 Lighthouse 进行网站性能评分

网站的 FCP 与 LCP 十多秒,导致网站评分只有 55 分

查看诊断结果:

可以看到最影响评分的是阻塞渲染的资源,点击查看详情:

阻塞的资源是 css 文件(同步加载 CSS,为了解决无样式内容闪烁(FOUC)的问题),那如何才能使 CSS 阻塞的时间更少,减少网站的首屏渲染时间呢

  • 优化图片、字体资源和预加载:图片和字体资源的加载也会影响 CSS 的渲染。优化这些资源可以减少 CSS 文件的大小和加载时间,从而减少渲染阻塞;预加载(Preload)字体在移动端可以不阻塞 CSS 的渲染,预加载 LCP 区域的图片可以有效提升 LCP
  • ****按需加载、代码分割:只加载具体页面需要的资源,合理分割主包、减少主包体积,按路由进行分割页面,减少请求数量与请求时间, 有效提升网站的 FCP 与 LCP
  • 懒加载:只加载进入可视区域的资源,比如说图片,减少网络带宽
  • **压缩资源:**压缩资源可以减少文件大小,从而加快文件的下载速度
  • **PurgeCSS:**CSS Tree-Shaking 的方案,用于移除未使用的样式
  • 预渲染和 Critical CSS:在构建时生成静态 HTML 文件,将首屏渲染所需的关键 CSS 内联到 HTML 文件中,异步加载非关键 CSS

优化图片、字体资源和预加载

图片采用 Webp 格式

看到 Lighthouse 的诊断意见中也有一条,采用新一代格式提供图片

新一代格式的图片有哪些呢,Webp or AVIF,考虑到项目兼容现代浏览器,那就统一把首页用到的图片都转成 Webp 格式吧

可以看到网站的整体评分虽然没有变化,但是 LCP 下降了 5s

查看诊断结果可以看到提示使用新型图片的修改意见没有了,但提示阻塞渲染的资源可以节省的毫秒数没有明显减少

预加载最大图片

提示预加载 LCP 元素所有图片,那试试预加载最大元素的图片吧

通过查看诊断详情定位到具体元素找到 LCP 图片的名称

在打包后的 html 文件中先添加如下代码查看效果(重新预览)

<link rel="preload" as="image" href="/housekeeper-mobile/img/home/psychological.webp">

可以看到性能指标没有什么变化,不过诊断结果中确实没有预加载最大图片这一条了,既然没用就先不管吧

通过 Network 面板再次验证 psychological.webp 的启动器确实是 home 文档而不是 css 文件了

字体文件优化

在 Network 面板筛选请求的字体资源

果真有一个大文件的字体,先不使用字体文件试试

通过上面可以看出 FCP 与 LCP 都降低了 10s+,阻塞渲染的关键资源就是大的字体文件

直接不使用字体文件显然不科学,看看怎么优化使用字体的方案吧

字体文件按理来说影响的是 LCP,为什么会影响 FCP 呢

使用 font-display 属性

@font-face {  
  font-family: 'DingTalk JinBuTi';  
  src: url('../assets/font/DingTalk_JinBuTi_Regular.ttf') format('truetype');  
  display: swap;
}

通过 Lighthouse 的分析结果可以看到,FCP 并没有减少

预加载字体

先直接在打包后的 html 中添加如下代码

<link rel="preload" as="font" href="/housekeeper-mobile/assets/DingTalk_JinBuTi_Regular-6f6a1e15.ttf">

观察 Network 面板看预加载是否生效,不过发现字体文件加载了两遍,这样肯定试不出来结果

使用 unplugin-fonts 插件

Unfonts({  custom: {    display: 'swap',    preload: true,    families: [      {        name: 'DingTalk JinBuTi',        src: './src/assets/font/DingTalk_JinBuTi_Regular.ttf',      },    ],  },}),

字体确实被预加载了

哇塞,性能评分从 55 变成 71 了欸,FCP 下降了 10s,这说明预加载字体会让字体文件不阻塞关键路径的渲染,只是 LCP 还是没有变化

预加载 LCP 元素的图片

看到诊断结果有一条预加载 LCP 元素图片有望节省 2040毫秒

再试试预加载图片会不会有变化

可以看到 LCP 图片确实预加载了,但是诊断结果没什么区别,还是先不管预加载图片的事儿吧

使用 woff2 字体资源

可以看到字体文件的格式改为 woff2,字体文件的大小从 2.1M 变成了 1M

通过诊断结果可以看出虽然总体评分没有变化,但是 LCP 下降了 5s+

这个时候再试试预加载 LCP 的图片能否降低网站的 LCP 提升性能评分呢

LCP 有点影响,但不多

查看诊断结果可以看到最大内容渲染时间还是同样的模块,查看图片的大小也就 7KB,而字体的文件大小还是有 1M,看来还是得进一步优化字体

通过 Network WaterFall 面板也可以看出,最后加载完成得资源是字体文件

字体切割

有没有什么方案能对字体进行切割呢,改用 vite-plugin-font 切割字体的方案

// vite.config.ts
import Font from 'vite-plugin-font'
Font.vite({  
  scanFiles: {    // ?subsets will match default    
  default: ['src/**/*.{vue,ts}'],    
  home: ['src/views/index/home/*.{vue,ts}'], // 按页面分割  
  },
}),

// index/home.vue
import { css } from '@/assets/font/DingTalk_JinBuTi_Regular.ttf?subsets&key=home'

<template>
  <div :style="{fontFamily: css.family}">...</div></template>

可以看到,FCP 减少了 10s+,LCP 减少了 10s+,性能评分提升了 33 分;这已经与不使用字体使用评分时差不多了,FCP 比起预加载字体的方案多了 0.3s,经查验vite-plugin-font没有提供预加载的方案,不过整体比起来还是切割字体的方案更友好

预加载 LCP 元素所用图片

const preloadImage = (url: string) => {  const link = document.createElement('link')  link.rel = 'preload'  link.as = 'image'  link.href = url  document.head.appendChild(link)}router.beforeEach(to => {  if (to.name === 'index-home') {    const urls = [      `${import.meta.env.VITE_BASE_PATH}img/home/psychological.webp`,    ]    urls.forEach(url => {      preloadImage(url)    })  }})

可以看到 LCP 下降了 0.2s

按需加载与代码分割

包分析

查看主包包含的内容,使用 rollup-plugin-visualizer

// vite.plugin.ts
import { visualizer } from 'rollup-plugin-visualizer'

visualizer({  open: true,  gzipSize: true,  brotliSize: true,}),

build: {  
  rollupOptions: 
    {    
      output: 
        {     
           entryFileNames: '[name].[hash:8].main.js',    
        },  
      },
    },
}

根据 **/views/index/home/**src 看首页打包在了哪个页面,通过搜索发现在主包,主包同步打包没什么问题,不过主包中还包含了我的页面,还有大量没有使用的资源,没压缩前是 120k

为什么除了首页我的页面也被打包进主包了,其它页面没有呢,这个得看路由配置了

项目采用 vite-plugin-pages 自动生成路由,它会把 index 及 index 下的页面同步打包,这被认为是首页

只同步打包首页

AutoRoutesPlugin({  routeStyle: 'nuxt',  dirs: ['src/views'],  exclude: ['**/components', '**/*.ts'],  importMode: filepath => { // 对文件的打包模式进行控制    return filepath.includes('views/index.vue') ||      filepath.includes('views/index/home')      ? 'sync'      : 'async'  },}),

主包体积减少了 20kb, 不过还是包含 hooks、components 等没有使用的资源

不直接导出副作用模块&取消循环依赖

通过首页代码主路径分析,发现 views/index.vue 下存在以下引用:

import { type TabbarTabs } from '@/components'

通过分析:

  • components/index.ts 使用通配符导出,components/Field/index.ts 使用函数式组件,存在副作用代码

  • hooks/use-map.ts 文件存在副作用代码

    window._AMapSecurityConfig = { securityJsCode: '89ae8bccb2ac260eeb3af8273454317a',}

  • 循环依赖:store/** 与 api/activities 在使用 views/activities 的内容, 同时 views/activities 也在使用 store/** 与 api/activities 的内容

通过上面可以看到,解决模块副作用以及循环依赖的问题,主包体积减少了 70k,减少率达 58%

通过上面可以看到 FCP 减少了 1s,Lighthouse 评分增加了一分

代码分割

可以看到样式文件大主要是三方库 vant 的样式,先从不把 vant 的样式打包成全局的样式入手,修改 manualChunks 配置:

build: {  chunkSizeWarningLimit: 1024,  rollupOptions: {    output: {      entryFileNames: '[name].[hash:8].main.js',      manualChunks(id) {        if (/[\\/]node_modules[\\/]/.test(id)) {          if(id.includes('lodash-es')) return 'vendor-lodash-es'          if(id.includes('axios') || id.includes('qs')) return 'vendor-axios-qs'          if(id.includes('date-fns')) return 'vendor-date-fns'          if(id.includes('supabase')) return 'vendor-supabase'        }      },    },  },},

只把不是每个页面且体积比较大的依赖打包进单独的包,vue、vue-router、pinia 等几乎每个页面都需要用的包保留在主包中

可以看到,lighthouse 评分增加了 5 分,css 的总体积减少了 6k

通过 coverage 也可以看到,css 的使用率依旧不高

压缩 CSS 文件

经查验 css 文件已压缩

图片懒加载

自定义指令

<script lang="ts" setup>const vLazybg = {  mounted(    el: HTMLElement & { _observer: IntersectionObserver },    binding: Ref<string>,  ) {    const observer = new IntersectionObserver(      entries => {        entries.forEach(entry => {          if (entry.isIntersecting) {            // 添加背景图            el.classList.add(binding.value)            observer.unobserve(el) // 停止观察          }        })      },      {        rootMargin: '0px 100px 0px 0px', // 提前100px加载        threshold: 0.1,      },    )    observer.observe(el)    el._observer = observer  },  beforeUnmount(el: HTMLElement & { _observer: IntersectionObserver }) {    // 组件卸载时销毁观察者    if (el._observer) {      el._observer.disconnect()    }  },}
</script>

可以看到 LCP 降低了 0.1s,但不明显哈哈哈

PurgeCSS

经查验,TailwindCSS 已经配置启用 Purge

预渲染和 Critical CSS

预渲染能够提升项目的 FCP,但反而增加了 LCP,整体上并没有提升 Lighthouse 的性能评分;不过可以提升项目的 SEO 评分;预渲染与 Critical CSS 可看这篇文章:Critical CSS 在 CSR 项目中落地;采用预渲染会增大项目构建的复杂度,与收益不成正比,因此项目不打算采用这种方案

QA

Q:为什么字体预加载了还会再加载一遍

A:预加载的资源未正确设置 crossorigin,导致跨域时浏览器重新发起带凭证的请求,与预加载的匿名请求视为不同资源,可为跨域字体添加 crossorigin="anonymous"解决

Q:字体文件最大不能超过多大,静态资源最大不能超过多大

A:首屏字体建议 < 100KB,非首屏 < 500KB,通过格式转换、子集化和预加载优化;单个资源建议 < 500KB

Q:为什么设置字体文件 display: swap 并没有改变 LCP 呢

A: 移动端只要使用字体,且字体没有预加载就会阻塞 CSS 的渲染

Q: 什么样的网站适合使用预渲染

A:静态化程度比较高,对 SEO 有强要求的网站:博客、新闻网站、企业官网、产品介绍页等

关键总结

  • 在开发对首屏性能或者 SEO 有需求的项目是,应首先考虑选用 nuxt 或者 nextjs 的方案
  • 在开发对首屏性能有极致需求的项目时在首页尽量不要使用第三方组件,会带来额外的 CSS 样式
  • 对某一样式如果存在样式覆盖的情况,可考虑封装到自己单独的模块而不是全局
  • 在项目开发时,通用模块一定要确保不能存在副作用的代码,以及模块间的依赖关系应该清洗,不能存在循环引用的情况,防止不需要的代码被打包进主包
  • 影响性能的关键是是资源,从资源角度优化性能具有最高的性价比

TODO 项目

寻找 purge 第三方库样式的解决方案以及首页样式利用率的方案

图片转换自动化

寻找让 unplugin-fonts 只预加载指定得字体的方法

使用 web-font 的方式加载字体,不打包进文件

首页使用骨架屏