Web极致性能优化指南(上)

1,579 阅读15分钟

1:写在前面

Web极致性能优化指南,由于涉及知识点较多,为了优化阅读体验遂将该系列文章将分为上下两篇,带你深入了解前端应用运行时的关键因素。并分享如何通过优化代码结构、提高运行效率以及优化资源加载等方案来实现这一目标。

无论你是初学者还是资深开发者,都将帮助你构建出色的Web应用。我将从多个维度分析运行时的优化策略,并通过专业知识向你展示如何将这些策略转化为实际操作而不是空话。强烈建议收藏!!!

温馨提示:码字不易,先赞后看,养成习惯!!!

2:懒加载

2.1:路由懒加载

在路由系统中使用异步语法,实现按需加载,只有使用到该路由模块才会进行渲染加载处理。例如,可以使用 import() 函数来动态导入:

export const basicRoutes = [
  {
    path: '/',
    redirect: '/menu/home'
  },
  {
    name: 'LoginTest',
    path: '/loginTest',
    component: () => import('@/views/login/indexTest.vue'),
    meta: {
      title: 'LoginTest'
    }
  }
]

2.2:异步组件加载

使用 Vue 提供的异步组件语法,内层实现基于promise。例如,可以使用 defineAsyncComponent() 函数来动态导入组件:

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */)
  })
})

// 你可以像使用其他一般组件一样使用 `AsyncComp`。
// 最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。
// 它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

甚至此时我们可以使用到一些高级配置项目去定制我们想要的组件加载效果

const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),
  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,
  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})
 
// 如果提供了一个加载组件,它将在内部组件加载时先行显示。
// 在加载组件显示之前有一个默认的 200ms 延迟——这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。
// 如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。
// 你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。

顺便看一下项目中该如何实践: image.png 其中leftListCommonDialog这两个组件你可以像正常组件一样的使用他们,并获取异步加载能力。

2.3:图片懒加载

1:getBoundingClientRect

说到图片懒加载我们只需要找到元素位置计算与窗口的距离进行判断,当元素出现在窗口上或者快要出现的时候执行相关操作并渲染。下面这个实例可以很直接的看到当向下滚动的时候会开始加载img,由于我们img文件不存在所以导致加载报错,但是这不影响我们实现这个功能,实际项目中实现基本是这个思路。

如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>图片懒加载示例</title>
    <style>
      /* 初始状态下图片高度为0,等待加载 */
      img {
        height: 0;
      }
    </style>
  </head>
  <body>
    <!-- 占位符 -->
    <div style="height: 1000px"></div>
    <!-- 需要懒加载的图片 -->
    <img data-src="placeholder.jpg" alt="Lazy-loaded Image" />
    <script>
      // 获取所有需要懒加载的图片
      let lazyImages = document.querySelectorAll('img[data-src]')
      function lazyLoad() {
        lazyImages.forEach(function (img) {
          // 获取图片的位置信息
          let rect = img.getBoundingClientRect()
          // 如果图片进入了视口范围内
          if (rect.top >= 0 && rect.top <= window.innerHeight) {
            // 加载图片
            img.setAttribute('src', img.getAttribute('data-src'))
            // 移除data-src属性,避免重复加载
            img.removeAttribute('data-src')
          }
        })
        // 移除已加载的图片,以减少下次检查的数量
        // lazyImages = document.querySelectorAll('img[data-src]');
      }
      // 页面加载时执行一次懒加载
      lazyLoad()
      // 滚动时触发懒加载
      window.addEventListener('scroll', lazyLoad)
      // 窗口大小改变时触发懒加载
      window.addEventListener('resize', lazyLoad)
    </script>
  </body>
</html>

// 在这个示例中,图片的实际地址通过data-src属性指定,而不是直接通过src属性。 
// 页面加载时,脚本会获取所有具有data-src属性的图片元素,然后通过getBoundingClientRect()方法检查它们是否在视口范围内。
// 如果在视口范围内,则将data-src属性的值赋给src属性,从而加载图片。当滚动或调整窗口大小时,懒加载函数将被触发,检查并加载可见区域内的图片。
// 最后在页面卸载的时候清一下缓存即可。

image.png

  • top:目标右上角距视窗上边沿距离
  • left:目标右上角距视窗左边沿距离
  • bottom:目标左下角角距视窗上边沿距离
  • right:目标左下角距视窗左边沿距离

(2)兼容性

image.png

(3)取巧方案:使用decoding="async"与loading="lazy"

如果你觉得上面的这些太麻烦,有没有简单的方式一样能实现呢?那么好消息是你可以直接用以上属性去直接代替麻烦的懒加载代码,非常方便简单且高效。

image.png 对兼容性要求不高的项目直接用这个就行(其实也不用谈兼容色变,兼容的目标是保证大多数人能得到更好的使用体验,既然有优秀的特性我们就应该追随并大胆的去使用它),这些已经在底层实现了懒加载的动作稳妥且安全。

点击查看:MDN对该属性的介绍

3:资源预加载

3.1:介绍

(1)prefetch

  • prefetch 是一种告诉浏览器在空闲时可以预加载资源的指令。它会在浏览器后台异步地下载指定的资源,并存储在浏览器缓存中,以备将来使用。
  • prefetch 适合用于加载用户即将访问的页面所需的资源,以加快后续页面的加载速度。例如,可以在当前页面上添加 prefetch 链接标签,以预加载下一个页面所需的 CSS 文件、JavaScript 文件或其他资源。

(2)preload

  • preload 是一种在当前页面加载时立即加载指定资源的指令。它会在浏览器优先级较高的下载队列中下载资源,并尽快应用到当前页面中。
  • preload 适合用于加载当前页面所需的重要资源,例如首屏所需的关键 CSS 文件、JavaScript 文件、字体文件等。通过在页面的头部添加 preload 标签,可以确保这些关键资源在页面渲染前已经被下载并准备就绪。

(3)preconnect:

  • preconnect 是一种优化网页性能的技术,它告诉浏览器在后续请求中预先建立到指定域名的连接。这样可以减少建立连接的时间,从而加速后续资源的加载。
  • preconnect 由于浏览器限制,不能持续存在,所以必要的资源才会做预连接设置。

3.2:使用

实际使用中只有开发者指定的某些资源才需要做这些指定加载方式,如果没有指定在 vite 中会默认给你加上:

image.png

当我们想对指定的资源进行预加载的时候可以通过<link>标签进行设置可以这样做。比如:

<link rel="preload" as="script" href="xxx.js" />
<link rel="prefetch" as="script" href="xxx.js" />
// 或者这样
<template>
  <router-link to="/about" prefetch>About</router-link>
  <router-link to="/contact" preload>Contact</router-link>
</template>

在实际开发中你可以指定的某些资源进行预操作,当打包编译的过程中打包器这些资源会进行特殊处理(注意在 webpackvite 中设置会有所区别),以保证用户设置的正确性。

4:脚本非阻塞异步加载

4.1:介绍

异步无阻塞加载 JavaScript 脚本是一种优化网页性能的技术,它可以在不阻塞页面渲染的情况下加载脚本文件,并在加载完成后立即执行。这种方式可以提高页面的加载速度和用户体验。

在 HTML 中,我们可以通过 <script> 标签的 async 和 defer 属性来实现异步加载脚本:

  1. async 属性
  • async 属性表示脚本的异步加载,它告诉浏览器立即开始下载脚本,但不会阻塞页面的解析和渲染。当脚本下载完成后,会立即执行,不管其他脚本是否已经下载完成。这意味着脚本的执行顺序不受控制,可能会与其在页面中的顺序不一致。
  • 使用 async 属性加载的脚本适用于独立、互相之间无依赖关系的脚本,例如用于分析、广告或跟踪的脚本。
  1. defer 属性
  • defer 属性表示脚本的延迟加载,它告诉浏览器立即开始下载脚本,但会延迟执行直到页面解析完成后、DOMContentLoaded 事件触发之前。多个 defer 脚本会按照它们在页面中出现的顺序依次执行,保证了执行顺序。
  • 使用 defer 属性加载的脚本适用于页面初始化时需要执行的脚本,例如用于初始化页面内容或绑定事件处理程序的脚本。

我们总结一下:

  • 使用 async 属性加载的脚本是异步的,可能会在页面解析过程中执行,不保证执行顺序(测试过多次其实也能按照顺序执行,可能在更复杂的环境下会出现)。
  • 使用 defer 属性加载的脚本是延迟加载的,会在页面解析完成后按照顺序执行。

4.2:使用

(1)异步加载第三方脚本: 如果你需要在页面中加载第三方脚本,并且这些脚本不依赖于页面的其他内容,你可以使用 async 属性来异步加载它们。

例如,在 Vue 组件中的 mounted 钩子函数中动态创建 <script> 标签并设置 async 属性来加载第三方脚本。如下:

export default {
  mounted() {
    const script = document.createElement('script');
    script.src = 'https://xxx.js';
    script.async = true;
    document.body.appendChild(script);
  }
}

(2)延迟加载初始化脚本: 如果你有一些初始化脚本需要在页面加载完成后执行,但又不想阻塞页面渲染,你可以使用 defer 属性来延迟加载这些脚本。如下:

import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
 
// 在根组件的 mounted 钩子函数中延迟加载初始化脚本
app.mount('#app');
 
const initScript = document.createElement('script');
initScript.src = 'xxx.js';
initScript.defer = true;
document.body.appendChild(initScript);

这样可以确保初始化脚本在页面加载完成后执行,而不会阻塞页面的渲染。在使用 async 和 defer 属性加载脚本时,需要注意脚本的加载和执行顺序以及对页面的影响。确保选择适当的加载方式来优化页面加载性能和用户体验。

5:压缩

5.1:压缩

对于 To C 网站大部分存在大量的图片,我粗略的统计了一下图片所耗费的流量已经超过了整个站点的 60% 以上的流量,所以对图片的优化将至关重要,同时也是个老生常谈的问题。

5.1.1:图片格式选择

推荐webp,是一种全新一代的图片格式。由谷歌2010年推出。在《web前端性能优化》这本书中也有提及。该格式图片拥有当前市面上绝大多数图片的优点集一身,实际使用下来在同等视觉体验下可以将图片所占的内存空间减小20%-50%,是一个非常优秀的图片格式,假设即使我们不对图片进行压缩也能在获得更小的图片输出,极大节约我们的带宽,提升加载速度。

caniuse webp兼容性非常好,基本没有后顾之忧了,请大家放心大胆的用(IE已逝!)。

企业微信截图_16818214812359.png

5.1.2:图片压缩

对于图片压缩市面上有很多打包器插件都可以做,具体配置也比较简单 rollup的配置插件:rollup-plugin-imageminwebpack可以选用这个插件image-webpack-loader

如果想单独压缩某一些大文件的推荐使用这个 在线图片压缩。个人觉得非常好用,压缩效果好(100%质量的情况下基本可以做到和原图无异,还能很大程度压缩大小),支持批量导入,批量下载,把需要压缩的图片批量导入选择压缩参数即可完成压缩。

5.1.3:svg压缩

一些使用比较简单的,重复使用的小图标可以用 svg 格式,svg 的好处不用我说了具体可以 看这一篇,有详细说明。但使用这个也是要注意的,就是对于复杂图标还是不建议使用 svg,相对而言其大小会变得非常巨大,得不偿失。还有一点就是别用多了,适量最好(个人经验是50个以内),多了会影响你的首页展示速度!具体可以观察一下这个文件的大小。没有优化之前这个文件将近 500k,现在只有大概 68k。首屏文件在网络上传输的时间大幅缩减。原来(300-500)ms --> 现在(50-150)ms 企业微信截图_16819718323940.png 如果有可能尽量控制在 14.4k 以内,能会进一步提升 FCP (first content painting) 对于 svg 压缩,推荐 svgo 用过都说好!

1:首先下载改安装pnpm -g install svgo

2:准备一个文件夹来承接压缩后的 svg 文件

企业微信截图_16818732368387.png

3:配置 svgo.config.js

module.exports = {
  plugins: [
    'removeDoctype',
    'removeXMLProcInst',
    'removeComments',
    'removeMetadata',
    'removeEditorsNSData',
    'cleanupAttrs',
    'inlineStyles',
    'minifyStyles',
    // 'cleanupIDs',
    'removeUselessDefs',
    'cleanupNumericValues',
    'convertColors',
    'removeUnknownsAndDefaults',
    'removeNonInheritableGroupAttrs',
    'removeUselessStrokeAndFill',
    // 'removeViewBox',
    'cleanupEnableBackground',
    'removeHiddenElems',
    'removeEmptyText',
    'convertShapeToPath',
    'convertEllipseToCircle',
    'moveElemsAttrsToGroup',
    'moveGroupAttrsToElems',
    'collapseGroups',
    'convertPathData',
    'convertTransform',
    'removeEmptyAttrs',
    'removeEmptyContainers',
    'mergePaths',
    'removeUnusedNS',
    'sortDefsChildren',
    'removeTitle',
    'removeDesc'
  ]
}

配置项可以参考一下,具体可以去 GitHub 看一下每一项含义再做定制化的压缩,这里不多介绍

4:在 package.json 文件里设置命令行做配置即可

"svgo": "svgo -f <你的源文件地址> -o <输出的压缩文件地址> --config svgo.config.js"

5:将 svgo 作为配置命令嵌入到你项目的整个构建流程中即可

"build": "pnpm svgo && vite build"

使用起来非常方便,不受架构限制,简单配置过就可以跑起来了

企业微信截图_16818737689049.png

5.1.4:小图片处理

由于站点中还有大量的 1-10kb小图片,但是不适用于 svg 那么我们可以通过配置来将其转成 base64url,图片被转换成类似于这样的一串字符串data:image/webp;base64,UklGRhALAABX...O/AA/fPxcP278tD9s/RA/Lv1pPoP9kAgA= 浏览器就不用再去下载,可以极大的减少 http 请求。但也有一些问题,转成 Base64 之后文件大约会增大 1/3,本质上网络还是要承担这一部分流量,具体是由于 Base64 要求把每三个8Bit的字节转换为四个 6Bit 的字节 (3*8 = 4*6 = 24),然后把 6Bit 再添两位高位0,组成四个 8Bit 的字节,也就是说,转换后的字符串理论上将要比原来的长1/3。 可以通过以下的配置将小图片转 base64企业微信截图_16818742694666.png 项目中通过配置,建议配置10k以下的值,不配置默认4kb 直达链接

1:转换规则

关于这个编码的规则:

①把3个字节变成4个字节

②每76个字符加一个换行符

③最后的结束符也要处理

RFC 4648 标准的 Base64 索引表 企业微信截图_16818759541593.png

2:例子

  • 首先,将二进制数据中每三组 8 个二进制位”重新分组为四组 6 个二进制位
  • 然后,每组的 6 个二进制位用一个十进制数来表示。6 个二进制位可表示的十进制数的范围是 0 - 63
  • 接下来,根据 Base64 索引表,将每组的十进制数转换成对应的字符,即每组可以用一个可打印字符来表示

ManBase64 编码结果为 TWFu,详细原理如下: 企业微信截图_16818762026838.png

  • 优点:节约 http 请求
  • 缺点:项目文件稍许变大

5.1.5:文件gzip

在实际项目中我们还可以额外对代码进行进一步压缩使用到的插件是:vite-plugin-compression 该插件利用了现代浏览器对 Gzip 和 Brotli 压缩算法的支持,可以同时生成经过 Gzip 和 Brotli 压缩的版本,以确保在不同浏览器环境下都能获得最佳的压缩效果。通过在 Vite 项目中配置 vite-plugin-compression 插件,可以轻松地为你的静态资源添加压缩版本,从而提高页面性能和用户体验。 以下是一个我们项目中的实际使用示例压缩的文件包括css、html、js、svg、json等关键文件。

示例如下:

1:安装:pnpm i -g vite-plugin-compression

2:引入:import viteCompression from 'vite-plugin-compression'

3:使用:

viteCompression({
  threshold: 1024,
  filter: /\.(css|html|js|svg|json)$/i,
  deleteOriginFile: false,
  algorithm: 'gzip'
})

配置好了后端支持一下就生效了,对比了一下整体 压缩了60% 左右,看一下效果

企业微信截图_16819748922647.png

5.1.6:其他

企业微信截图_16818766207809.png terser 具体根据自己需求来定,配置项请 移步这里

6:文件hash(缓存)

企业微信截图_16818849061119.png

企业微信截图_16818850378284.png

企业微信截图_16818850842704.png

企业微信截图_16818851253878.png

// vite.config.js
chunkFileNames: 'static/js/[name]-[hash].js', // 引入文件名的名称
entryFileNames: 'static/js/[name]-[hash].js', // 包的入口文件名称
assetFileNames: 'static/[ext]/[name]-[hash].[ext]', // 资源文件像 字体,图片等

看一下效果

企业微信截图_16818856579119.png

7:聚合碎片

7.1:介绍

聚合碎片的本意是要减少小文件的 http 请求,把这些小文件聚合到该页面需要请求的文件之中。想要的结果是一个http 请求可以涵盖几十个碎片请求(由于浏览器限制一个域下最多允许 6个tcp 同时存在),这样对于整个项目来说其实是非常有利的,把小文件直接并入大文件中,只需要拉取少数的几个文件就可以。减少了 tcp 连接次数,避免的大量的慢启动。同时也可以尽量的减少等待时间。所以综上站点策略将去聚合大量碎片,并尽量保持大文件个数控制在 6 个。

企业微信截图_16818864733322.png

7.2:使用

企业微信截图_1708676976955.png 通过 rollup 提供的 api,我们能抓住每一个碎片,将其按照每个页面进行高度的定制化。可以通过 id 这个参数进行正则匹配,进行分包或者聚合都行。思路和方式相似,按照自己的想法去写即可。

8:环境区分

环境区分这个思路比较纯粹,就是针对不同的环境做不同的配置策略。

比如:开发环境我希望能输出 log,那么就会在打包编译的时候去判断到底现在打包是哪一个环境,如果是开发,测试那么就会保留一些 log、debugger、err、sourcemap、comment 等,如果是生产环境就会屏蔽这些。

配置如下代码:

// vite.config.js
sourcemap: !isPro,
minify: 'terser',
terserOptions: {
    compress: {
      drop_console: isPro, // 删除console
      drop_debugger: isPro // 删除 debugger
    },
    format: {
      comments: false // 去掉注释内容
    }
}

配置过你会发现,每个环境的包大小差距很大,生产环境的包可能只有开发环境的一半大小。

9:虚拟列表

9.1:介绍

虚拟列表是一种用于优化大型列表或表格性能的技术,它只渲染可见区域的内容,而不是一次性渲染全部数据。 这种技术能够减少 DOM 元素的数量,提高页面加载速度和渲染性能。同时在高负载环境下相对的流畅度表现也非常好。

9.2:使用

以下是一个基础的代码实例,你也可以将这段代码放入你的项目中稍加改造就可以当做一个通用性虚拟列表组件。

如下:

<template>
  <!-- 虚拟列表容器 -->
  <div class="virtual-list" ref="listContainer" @scroll="handleScroll">
    <!-- 占位元素,用于撑开列表高度 -->
    <div class="list-placeholder" :style="{ height: totalHeight + 'px' }"></div>
    <!-- 可见列表项 -->
    <div
      class="list-item"
      v-for="(item, index) in visibleItems"
      :key="index"
      :style="{ height: itemHeight + 'px', transform: 'translateY(' + item.pos + 'px)' }"
    >
      {{ item.name }}
    </div>
  </div>
</template>
 
<script setup>
import { ref, onMounted } from 'vue'
 
// 数据
const listData = ref([]) // 列表数据
const visibleItems = ref([]) // 可见的列表项
const itemHeight = 50 // 列表项高度
let startIndex = 0 // 开始索引
let visibleItemCount = 0 // 可见列表项数量
let containerHeight = 0 // 容器高度
let totalHeight = 0 // 总高度
// Refs
const listContainer = ref(null) // 列表容器的引用
// 计算可见列表项
const calculateVisibleItems = () => {
  visibleItemCount = Math.ceil(containerHeight / itemHeight) // 计算可见列表项数量
  visibleItems.value = listData.value.slice(startIndex, startIndex + visibleItemCount) // 更新可见列表项
}
// 更新列表数据
const updateList = () => {
  totalHeight = listData.value.length * itemHeight // 计算总高度
}
// 滚动事件处理函数
const handleScroll = () => {
  const scrollTop = listContainer.value.scrollTop // 获取滚动条位置
  const maxScrollTop = totalHeight - containerHeight - 50 // 计算最大滚动高度(减去一个缓冲值)
  // 根据滚动位置确定开始索引
  if (scrollTop === 0) {
    startIndex = 0
  } else if (scrollTop >= maxScrollTop) {
    startIndex = Math.max(listData.value.length - visibleItemCount, 0) // 在底部时,调整开始索引确保最后一项可见
  } else {
    startIndex = Math.floor(scrollTop / itemHeight)
  }
  calculateVisibleItems() // 更新可见列表项
}
// 模拟获取数据
const fetchData = () => {
  for (let i = 0; i < 101; i++) {
    listData.value.push({ name: `Ak_${i}`, pos: i * itemHeight }) // 添加数据
  }
  updateList() // 更新列表
}
// 组件挂载后执行
onMounted(() => {
  setTimeout(() => {
    containerHeight = listContainer.value.clientHeight // 获取容器高度
    calculateVisibleItems() // 计算可见列表项
  }, 100) // 延迟执行,等待DOM渲染完成
})
fetchData() // 获取数据
</script>
 
<style>
.virtual-list {
  width: 100%;
  height: 100%;
  overflow-y: auto;
  position: relative;
}
.list-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.list-item {
  border-bottom: 1px solid #ccc;
  line-height: 50px;
  padding: 0 10px;
}
</style>

// 对于虚拟列表而言还有很多细节需要探讨,比如当数据位动态高度的时候应该如何处理。
// 所以要做好一个更加通用的方案需要更多的付出与思考,代码要写好不容易啊。

9.3:运行

1 (1).gif

10:总结

这是优化策略的上篇,在这一篇中我介绍了一些常用的优化方案,并给出可实现的具体案例供大家参考与引用,那么在Web极致性能优化指南(下)中我将继续带来更深层次的优化方案和策略实施,欢迎大家拍砖指导共同进步。