网页开发基本功-性能优化

性能优化作为前端领域一个老生常谈的话题,经常会在不同的开发阶段遇到这个问题,很多用户会抱怨道:"怎么网页加载这么慢啊!!",这个时候可能就需要对网页进行加载优化了,当然性能优化是一个比较笼统的概念,包含了首屏加载优化以及交互优化等等,本文我们主要针对首屏加载优化谈谈如何进行性能优化。

网页加载过程

经典面试题:"从输入一个URL到网页加载完成的过程发生了什么?",理解这个流程可以帮助我们更好的理解性能优化的基本逻辑。以单页面应用加载流程为例:

单页面应用由于HTML文件中只有根节点供后续挂载,所以首次渲染(FCP)也是空白界面,主要加载时长为静态资源的加载解析执行。所以主要解决方案就是围绕这块加载时长来进行优化。

优化方案

网络相关

CDN

减少静态资源加载时长首选方案就是CDN(内容分发网络),由于CDN的分发特性,利用与用户最近服务器建立连接,减少了通信传输的时间。

参考文档:web.dev/articles/co...

接入CDN需要在构建的CI流程增加上传CDN的流程,另外需要修改项目的构建配置,以 vue-cli 举例:

js 复制代码
const IS_PROD = process.env.NODE_ENV === 'production'
​
module.exports = {
  publicPath: IS_PROD
    ? `https://example.alicdn.cn/`
    : '/',
}

DNS解析

主域名以及链接的各种静态资源域名都需要进行域名解析,可以使用 dns-prefetch 的方式提前解析,减少请求时长,这个方式只适合对 HTML 文件中的静态资源链接起作用,因为主域名在加载之前解析已处理。

html 复制代码
<link rel="dns-prefetch" href="https://www.a.com" />

在目前的一些主流浏览器中均已支持 pre-connect 属性,这个属性相比于 dns-prefetch 不仅可以提前解析DNS,同时还会与对应域名提前建立HTTP链接。

html 复制代码
<link rel="pre-connect" href="https://www.a.com" />

这里需要注意pre-connect 属性在页面具有较多的第三方域名时,仅对主要域名设置即可,比如CDN的域名,建立链接的域名过多,可能会造成服务占用导致加载速度变慢。

参考文档:developer.mozilla.org/en-US/docs/...

HTTP2.0

HTTP的通信协议建议使用HTTP2.0,对比HTTP1.1协议有两个优点:

  1. 通过二进制的分帧层作为通信层,服务器使用信息流将对应的数据帧发送到客户端,数据帧在另一端重新组装这些帧,所以可以保证同一个连接多个请求使用,请求之间互不干扰,也就是多路复用。
  2. 请求头压缩,由于HTTP1.1协议传输都会携带一组请求头,以纯文本的形式发送,当请求头中包含cookie等长文本时会导致体积较大,2.0使用HPACK压缩请求头,减少了请求体积,提升了传输性能。

参考文档:web.dev/articles/pe...

域名发散

如果某些场景下请求的协议为HTTP1.1,可以使用域名发散的方式提高传输的效率,本质上是因为浏览器为了避免DDOS攻击,对同一个域名下的请求并发数做了限制,比如Chorme的最大并发数为6个。

所有如果有大量的并发请求,可以将请求分散到不同的域名下,提升并发的数量从而加快传输速度,由于域名存在DNS解析的过程,域名过多会导致性能下降,所以根据实际的使用情况添加对应的域名数量就可以。

HTTP压缩

开启HTTP压缩可以减少传输的体积,提升传输速度。目前主流的浏览器在请求资源请求头都会带上 Accpect-Encoding 字段:

makefile 复制代码
Accept-Encoding: gzip, compress, br

资源服务器也需要在返回头中增加资源的压缩方式:

css 复制代码
Content-Encoding: br

大部分的CDN托管平台默认值支持HTTP压缩,如果适配的浏览器支持 Brotil ,优先使用这种压缩算法,相对比之下br的压缩效率是最高的。目前浏览器对于压缩方式兼容性如下:

zstd压缩率以及解压缩的速度都略高于br,不过目前还在浏览器的实验性阶段,暂时无法使用

同时项目资源在打包构建时也要对资源进行压缩然后上传CDN,考虑到浏览器的兼容性,一般我们压缩两种格式 brgz ,安装 compression-webpack-plugin 插件处理即可:

js 复制代码
const CompressionPlugin = require('compression-webpack-plugin')
​
module.exports = {
    plugins: [
      new CompressionPlugin({
        filename: '[path][base].gz',
        algorithm: 'gzip',
        test: /.js$|.css$|.html$/,
        threshold: 10240,
        minRatio: 0.8,
      }),
      new CompressionPlugin({
        filename: '[path][base].br',
        algorithm: 'brotliCompress',
        test: /.(js|css|html|svg)$/,
        compressionOptions: {
          params: {
            [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
          },
        },
        threshold: 10240,
        minRatio: 0.8,
        deleteOriginalAssets: false,
      }),
    ]
}

HTTP缓存

增加HTTP资源可以减少同一份资源在二次加载所需时长,将静态服务器的请求头设置 Cache-Control 字段:

arduino 复制代码
Cache-Control:public, max-age=31536000

max-age 字段用于指定浏览器可以缓存资源的时长,单位为秒。比如示例将时长设置为 31536000,相当于 1 年:60 秒 × 60 分钟 × 24 小时 × 365 天 = 31536000 秒。

关于缓存的策略有多种,可参考 MDN文档

使用HTTP缓存需要注意资源的缓存是否更新,如在webpack构建的文件一般都会文件名称中增加文件 Hash,避免文件内容未更新,另外一般入口 HTML 文件一般不设置缓存,避免发布热修无法及时更新,不进行缓存可以做设置如下:

ini 复制代码
Cache-Control: no-store, max-age=0

Service Worker

Service Worker是一种渐进式增强的功能,可以用于HTTP请求拦截并将请求结果存储在本地,将首屏加载的资源请求以及重要的接口请求缓存在Cache Stroage中,在网页离线或者网络状态较差的情况下使用缓存内容,优化首屏加载体验。

接入Service Worker可以使用workbox,同时这个库也提供了webpack的插件workbox-webpack-plugin,如在 vue-cli 中使用:

js 复制代码
const { GenerateSW } = require('workbox-webpack-plugin')
​
export.default = {
    plugins: [
        new GenerateSW({
             exclude: [/.../, '...'],
              maximumFileSizeToCacheInBytes: ...,
              navigateFallback: '...',
              runtimeCaching: [{
                // Routing via a matchCallback function:
                urlPattern: ({request, url}) => ...,
                handler: '...',
                options: {
                  cacheName: '...',
                  expiration: {
                    maxEntries: ...,
                  },
                },
              },
            }]
        })
    ]
}

资源的缓存有多种策略可以选择,根据业务场景使用合适的缓存方式,参考workbox文档

请求优先级

浏览器加载CSS、JS以及XHR请求的优先级是按照默认顺序规则进行加载的,在之前要修改加载资源的优先级只能通过一些HACK的方式,如预加载。

在现代浏览器中可以通过 Fetch Priority API 和 fetchpriority HTML 属性修改请求的优先级,可以针对用户信息请求、页面背景图片等对首屏展示影响较大的请求提高其优先级,优化关键的性能指标。

fetchpriority 的可选值为 high (提高资源请求优先级)、low(降低资源请求优先级)、auto (使用浏览器默认优先级)。

html 复制代码
<img src="image.jpg" fetchpriority="high">
<img src="image1.jpg" fetchpriority="low">
js 复制代码
let userInfo = await fetch('/user', {priority: 'high'}))

构建产物相关

构建产物的体积对资源的加载速度至关重要,在webpack构建的项目中可以使用webpack-bundle-analyzer 插件进行体积分析:

资源体积压缩

JS资源

JS资源压缩目前比较常用的有UglifyJSterser等,这些库通过移除代码中的空行、注释、对变量名称进行缩短重写以及压缩算法等减少文件的体积。

如果项目是基于webpack进行打包编译,默认在 production 模式下会使用 terser-webpack-plugin 插件对打包资源进行混淆压缩。如果需要对插件的配置型自定义,需要安装插件并修改:

js 复制代码
const TerserPlugin = require("terser-webpack-plugin");
​
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
        parallel: 4,
    })],
  },
};

由于terser使用JS进行压缩,所以压缩速度上面比较慢,也可以使用 SWC 或者 ESbuild 提供的压缩机制:

js 复制代码
const SwcMinifyPlugin = require('swc-minify-webpack-plugin');
​
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new SwcMinifyPlugin({
        // 可选的压缩选项
        compress: {
          // 压缩选项
        },
        mangle: true, // 是否混淆变量名
      }),
    ],
  },
};
js 复制代码
const { ESBuildMinifyPlugin } = require('esbuild-loader')
​
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new SwcMinifyPlugin({
      }),
    ],
  },
};

CSS资源

CSS资源的压缩在webpack中一般使用postcss的 cssnano 插件:

js 复制代码
module.exports = {  
  module: {  
    rules: [  
      {  
        test: /.css$/,  
        use: [  
          'style-loader',  
          'css-loader',  
          {  
            loader: 'postcss-loader',  
            options: {  
              plugins: [
                require('cssnano')()  
              ]  
            }  
          }  
        ]  
      }  
    ]  
  }, 
}  

CSS的压缩机制除了删除空白行、注释之外,也会对颜色值进行格式转换压缩,但是无法通过修改类名的方式压缩体积,所以开发过程中类名的名称长度和语义化之间做衡量。

CSS的样式除了一些容器独有的高宽颜色之外,大部分的样式是重复的,我们可以通过复用样式类的方式减少体积,这也是目前原子化 CSS 比较流行的原因之一,如 tailwindcss

顺带一提,目前大部分的构建机制都会使用 mini-css-extract-plugin 抽离CSS资源,原因在于webpack的默认构建机制会将所有的资源(如图片、CSS等)当做 JavaScript 模块,便于建立模块关系依赖,这种处理方式在项目规模较小的情况下可以减少HTTP请求的次数,但是随着项目的外部依赖及规模变大,会导致JS资源的体积较大,将CSS抽离出来一方面可以减少JS体积,另一方面在项目的改动更集中的在逻辑层面改动的情况下CSS不发生变动,可以更好的利用浏览器的缓存机制。

另外上述 ESBuildMinifyPlugin 也可以用于CSS的压缩:

js 复制代码
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new SwcMinifyPlugin({
          css: true
      }),
    ],
  },
};

图片资源

静态资源除了JS、CSS资源体积较大之外就是图片资源,减少图片资源体积最好的方式就是不用图片(没开玩笑,狗头),部分场景下的实现可以使用一些复杂的CSS样式实现(如下图),另外可以使用字体图标,如 iconfont,不仅可以减少体积,也可以增加图标的灵活性。

另外一些场景下使用图标是不可避免的,此时选择一个合适的图标格式可以极大的减少图标的体积,常见的图片格式有以下这些:

  • svg:一种可缩放矢量格式的图片格式,基于XML格式。可以适配不同分辨率下显示不会失真,适用于一些图标的显示。对于图标中包含大量路径和颜色信息的图标,在一些性能较差或受限制的设备上,由于解析渲染耗费时间以及浏览器单线程的渲染机制,会导致页面渲染阻塞。

  • png: 支持无损压缩,可以保持图片的细节,压缩后不会失真,不支持缩放,所以一般在适配多分辨率情况下需要UI提供2x图以适配不同设备,但是支持透明底色,所以可以作为logo或者活动图等。

  • jpeg: 和png格式图片特性上基本类似,但是不支持透明底色,优点在于支持的色域较广,对于色彩追求较高的场景可以使用,但是需要注意压缩为有损压缩,会存在一定的失真。

  • webp: 由 google 在2010年提出的现代图片格式,具备 pngjpeg 的同时,相比之下可以减少25%的图片体积,如果不需要兼容IE的情况下推荐优先使用这种格式图片,可以通过一些工具将其他格式图片转为 webp

    javascript 复制代码
    const webp = require('webp-converter');
    // 将PNG图片转换为WebP格式
    webp.cwebp('input.png', 'output.webp', '-q 80');

一般我们从UI获取的图片都是经过压缩的,那么二般的情况下话我们可以通过tinypng 压缩图片体积,也有一些其他的工具如专门针对svg 压缩的 svgo等,可以使用 imagemin 集成工具进行压缩,也可以使用对应的webpack插件image-minimizer-webpack-plugin

需注意由于imagemin中依赖一些C代码包部署在外网,可能存在下载失败的情况,需自备外网访问环境。

js 复制代码
const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin");
​
module.exports = {
  module: {
    rules: [
      {
        test: /.(jpe?g|png)$/i,
        type: "asset",
      },
    ],
  },
  optimization: {
    minimizer: [
      new ImageMinimizerPlugin({
        minimizer: {
          implementation: ImageMinimizerPlugin.squooshMinify,
          options: {
            encodeOptions: {
              mozjpeg: {
                quality: 100,
              },
              webp: {
                lossless: 1,
              }
            },
          },
        },
      }),
    ],
  },
};

工程配置

工程配置主要是利用构建工具提供的一些特性,从而提高页面的性能,由于目前比较流程的打包工具还是webpack,所以我们就从webpack中一些特性进行讲解,当然目前主流的打包工具如 rollup 也提供了类似的一些功能。

Split Chunk

这个功能是由 SplitChunksPlugin 插件实现的,目的在于解决避免模块之前的重复依赖,用于去重和分离chunk文件。这种做法有以下优点:

  • 避免重复打包构建相同模块,减少构建包体积
  • 利用浏览器缓存,将通用的模块抽出避免整个文件进行更新
  • 增加模块文件数量,避免单个文件体积过大,利用并行下载提高下载速度

webpack在默认情况下开启了这个功能,默认的配置在官方的中文文档中是这么描述的:

开箱即用的 SplitChunksPlugin 对于大部分用户来说非常友好。

默认情况下,它只会影响到按需加载的 chunks,因为修改 initial chunks 会影响到项目的 HTML 文件中的脚本标签。

webpack 将根据以下条件自动拆分 chunks:

  • 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
  • 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
  • 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
  • 当加载初始化页面时,并发请求的最大数量小于或等于 30

当尝试满足最后两个条件时,最好使用较大的 chunks。

默认的配置如下:

js 复制代码
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },

最大请求并发数设置为30是因为目前大多数的CDN服务器都支持了HTTP2.0,可以多路复用无最大请求并发的限制,如果为HTTP1.1,一般设置最大请求并发数为6。

chunks 的可选值有 asyncinitialall。async标识在异步chunk之前共享,initial为非异步之间共享,all为两者都可以共享。

异步模块指的是通过 import() 方式导入的模块,如在 vue-cli 项目中通过路由维度进行模块拆分:

js 复制代码
// router.js
const user = () => import(/* webpackChunkName: "user" */'./components/user.vue');
const order = () => import(/* webpackChunkName: "order" */'./components/order.vue');
​
export default {
    routes: [
        {
          path: '/user',
          component: user,
        },
        {
          path: '/order',
          component: order,
        },
    ]
}

按照默认的配置上面的两个模块都会被拆分为单独的chunk文件,并可以通过 webpackChunkName 设置对应的chunk名称。

node_modules 中的包默认构建到 chunk-vendor文件中,但是在业务复杂的场景下一方面这个文件体积增大,另一方面可能会引入一些业务的组件库,需要经常更新版本,这个时候可以利用代码分割机制对加载进行优化:

js 复制代码
// vue.config.js
export.default = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        cacheGroups: {
          'chunk-vue': {
            test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
            priority: 10,
            chunks: 'initial'
          },
          'chunk-element-ui': {
            test: /[\\/]node_modules[\\/](element-ui)[\\/]/,
            priority: 10,
            chunks: 'initial'
          },
        },
      },
    }
  }
}

上述代码针对代码中不经常发生变动的模块(如vue等框架层面的库)进行了抽离,文件不发生变化便于浏览器缓存并减少chunk-vendor文件体积。另外针对易变动的模块,以 element-ui 类比一些业务模块,将这些模块切割为单独模块,避免单个变动影响整个chunk-vendor文件缓存。

Tree Shaking

Tree Shaking是基于ESM模块的静态加载机制,通过在编译时进行加载依赖分析和优化,从而移除代码中一些未用到的无效代码(副作用)减少代码体积。

相比之下,CommonJS的模块加载是动态的,这和模块的机制设计之初所支持的环境是有关系的,CommonJS是为Node环境中设计,一般为服务器无需编译所以在运行时加载模块,ESM的设计是服务于Web应用,所以设计了一些适合浏览器环境的加载特性,如异步加载。

所以在日常业务引入包时需要注意包提供的模块形式以及引入方式,避免CommonJS模块影响代码构建体积,如将 lodash 替换为 lodash-es

在webpack中设置打包模式为生产模式默认会开启Tree Shaking。但是在编写一些库(如组件库)提供给外部使用的时候需要注意通过package.json文件中的sideEffects字段或者内联注释/@PURE/用于提示构建工具是否支持Tree Shaking从而移除未使用的代码。

json 复制代码
{
    "sideEffects": false
}

浏览器兼容性

说起浏览器兼容性就不得不提Babel,由于用户侧的使用的浏览器不同,导致对一些新的技术特性并不支持,为了保证可以正常运行,就需要对代码的新特性进行兼容,这就是polyfill,也是Babel的重要功能之一。

但是polyfill中兼容的内容一般很多,导致构建代码体积较大,可以利用 browserslist 明确需要兼容的浏览器范围从而减少polyfill内容。创建 .browserslistrc 文件指定浏览器范围,具体配置信息可参考文档

text 复制代码
last 2 versions
> 1%
js 复制代码
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { 
                useBuiltIns: 'usage'
              }]
            ]
          }
        }
      }
    ]
  }
};

browserslist 虽然可以根据目标浏览器减少代码的体积,但是在比较新的浏览器中这些polyfill也是比较多余的,此时我们可以使用动态polyfill的方式,动态polyfill为在线服务,根据请求头中的 User-Agent 返回对应浏览器的对应的polyfill。如阿里提供动态polyfill的服务:

text 复制代码
https://polyfill.alicdn.com/polyfill.min.js?features=Promise,Reflect

Babel配置仅针对项目中的代码,一般是忽略 node_modules 模块的,因为大部分第三方库是经过编译处理的,如果某个库没有处理需要在配置项中指定进行Babel处理。

资源加载相关

按需加载

首屏指的是在首次加载展示在屏幕可视区域的内容,所以我们需要保证在视图区域的内容优先加载,其他的区域延迟加载或异步加载。

图片懒加载

图片懒加载是业务中比较常见的一种优化形式,即在图片在视图区域或滚动到一定阈值再触发图片加载,比如轮播图。之前的实现方法为监听图片的位置与视图窗口作对比,改变img标签的src属性动态加载图片,不过在现代浏览器中更推荐使用 loading 属性设置懒加载:

html 复制代码
<img src="a.png" loading="lazy" />

这个属性目前主流浏览器均已支持,如果需要兼容版本的浏览器可以添加对应的polyfill

js 复制代码
// <img class="loading-lazy" />
if(!('loading' in document.createElement('img'))) {
    import('loading-attribute-polyfill/dist/loading-attribute-polyfill.module.js')
}

JS懒加载

JS资源懒加载可以通过动态 import() 加载模块,可以根据不同的维度对模块进行划分,另外可以对模块标记 prefetch 预加载对应的模块,加速动态加载模块的加载速度:

js 复制代码
import(/* webpackPrefetch: true */ 'a.js');

CSS懒加载

默认情况下,CSS资源的加载解析会阻塞渲染,在 CSSDOM 构建完成之前,浏览器不会处理渲染内容,所以CSS保留首屏需要的样式即可,一些其余的样式可以通过延迟解析的形式加载,避免阻塞首屏的渲染:

html 复制代码
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

通过 preload 声明资源提前获取加载资源,但是不会触发解析,通过 onload 事件将 rel属性的值从preload更改为stylesheet,这样CSS资源就是在load事件之后再触发解析渲染。

总结

以上主要通过了不同的几个方面介绍了一些SPA(单页面应用)性能优化的方法,性能优化的重点是要get到浏览器整个加载过程的细节,通过优化每一个细节流程从而提升整体的性能,文章中介绍为我在业务用到的一些方法,也欢迎各位补充一些方法。是经过编译处理的,如果某个库没有处理需要在配置项中指定进行Babel处理。

相关推荐
别拿曾经看以后~1 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
川石课堂软件测试1 小时前
性能测试|docker容器下搭建JMeter+Grafana+Influxdb监控可视化平台
运维·javascript·深度学习·jmeter·docker·容器·grafana
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
problc2 小时前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter
Gavin_9152 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼3 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
待磨的钝刨4 小时前
【格式化查看JSON文件】coco的json文件内容都在一行如何按照json格式查看
开发语言·javascript·json
前端青山9 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
从兄10 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
清灵xmf11 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询