服务端渲染优化指南

性能审计方案

在讨论如何提升性能之前,我们首先要明确如何正确的统计性能指标,这样在后续的性能提升过程中才能有效的对比优化前后的效果。

浏览器的性能数据可以通过 Web Performance API 来获取,通过这些数据的各种推算,可以得出一些以用户为中心的性能指标,各种指标有很多的衡量方式,但是通常我们终点关注以下指标:

  • First Content Paint:首次内容绘制(FCP)测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,”内容”指的是文本、图像(包括背景图像)、<svg> 元素或非白色的 <canvas> 元素。
  • Largest Contentful Paint:最大内容绘制 (LCP) 指标会根据页面首次开始加载的时间点来报告可视区域内可见的最大图像或文本块完成渲染的相对时间。
  • Time to Interactive:可交互时长(TTI)测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。
  • Speed Index:速度指标(SI)是衡量页面加载期间内容的视觉显示速度,不同于 LCP,SI 会考虑到 Javascript 执行状态以及不可见内容的加载,是衡量网站最快可以让用户完整体验的指标。
  • Total Blocking Time:总阻塞时间(TBT)测量页面被阻止响应用户输入(例如鼠标点击、屏幕点击或按下键盘)的总时间。总和是首次内容绘制互动时间之间所有长时间任务的阻塞部分之和。任何执行时间超过 50 毫秒的任务都是长任务。50 毫秒后的时间量是阻塞部分。
  • Cumulative Layout Shift:累计布局便宜(CLS)是测量整个页面生命周期内发生的所有意外布局偏移中最大一连串的布局偏移分数。

上面的指标可以用 Google Lighthouse 工具进行测试,得出的性能总分是最直观可以衡量 Web 应用性能的指标。性能总分的计算规则为 TBT 占 30%、LCP 占 25%、CLS 占 15%,其余三项指标各占用 10%,这能很明显的体现出各项指标的重要性。

另外,关于 Google Lighthouse,其自身是集成与 Chrome dev tools 中的,同时可以作为 npm 包进行下载,使用其提供的 cli 或者作为 node module 进行引用,这就可以在服务器端对某个页面进行自动化的性能评估(但服务器端必须集成无头浏览器,更多信息参考 lighthouse - npm)。

静态资源优化

静态资源压缩是前端性能优化中最基础的提效方案,也是效果最为明显的。尽快的完成对静态资源的加载会极大的提升 FCP 以及 LCP 指标的分数。

图片压缩

对于图片压缩方案,如果想要压缩的精细,可以借助 Photoshop 等图像处理工具对图片进行手动压缩,如果采用手动压缩方案,可以参考以下处理:

  • 将图片按照渲染像素进行剪裁和压缩分辨率
  • 采用 JPEG 格式替代 PNG 格式的图片,以换取更高的压缩率
  • 对于单一色调的 PNG 图片,可以采用 PNG-8 仿色来对图片进行压缩

如果疲于对图片进行手动压缩,那么也可以直接使用构建工具对图片进行压缩,比如 webpack 的 image-webpack-loader,通过以下配置加入到图片 loder 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
test: /\.(png|jpe?g|gif|webp)(\?.*)?$/,
use: [
{
loader: 'url-loader',
options: {
limit: 3 * 1024,
name: `${STATIC_DIR_NAME}/image/[name]_[contenthash:8].[ext]`,
publicPath: `${getConfig().publicPath}`,
esModule: false,
},
},
{
loader: 'image-webpack-loader',
options: {
disable: process.env.NODE_ENV === 'development',
},
},
],
type: 'javascript/auto',
},

如果遇到环境问题,尝试更换构建环境系统或者降级到 image-webpack-loader@6 版本。

同时,对于较小的图片,可以使用 url-loader,并设置 limit 选项,小于指定尺寸的图片会被转为 base64 编码,这有利于加快页面的展示速度,尽快的加载用户所看到的图片,这个方案在 SSR 项目中使用时,会减少用户首屏渲染时等待图片的加载数,一定程度上也会提高 LCP 的渲染速度。

Gzip

使用 gzip 对前端的静态资源文件(主要是 js、css 文件)进行压缩后传输,会大大减小请求的大小,加快用户对服务的访问速度。客户端如果支持 gzip 的话(服务器透过请求头 accept-encoding 来判断),就可以使用 gzip 压缩过的代码,客户端浏览器获取到压缩过的代码后会在客户端进行解压缩然后再调用,这个过程虽然损失了性能,但速度上会比网络请求更快(原始文件越大,压缩带来的收益越高)。

accept-encoding 请求头的值代表当前浏览器所支持的压缩标准,现在主流的浏览器都支持 gzip,较新的浏览器会支持 br 这种效率更高的压缩方式(只有在 https 请求时,浏览器才会支持 br 的压缩)。如果服务端返回的是压缩过的资源,会使用 content-encoding 来告知浏览器当前资源采用了哪种压缩方式。

关于 gzip 的更多信息

服务器动态压缩

通常,主流的网页服务器(如Nginx、Caddy)都支持对静态文件进行 gzip。当浏览器发送请求到网页服务器后,它们在返回静态资源时,会对静态资源进行实时压缩后再进行传输。以 Nginx 为例,可以在 ngxin 配置文件中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 开启gzip
gzip on;

# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;

# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 5;

# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;

# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;

# 禁用IE 6 gzip
gzip_disable "MSIE [1-6]\.";

# 设置压缩所需要的缓冲区大小
gzip_buffers 32 4k;

配置可以写入到 http, server, location 任意片段中

这种压缩方式我们称之为动态压缩,其好处是可以通过配置将所有请求的静态资源都进行压缩,但缺点就是耗费服务器性能,因为每次请求都需要对原文件进行压缩后再发送,压缩过的文件是无法被重复利用的。

服务器静态压缩

那么与之对应的另外一种方式就是静态压缩,这种方法是通过使用 webpack、gulp 等前端构建工具,在编译完代码后,直接将生成 js、css 等静态文件进行压缩,并生成一个压缩后的副本,比如编译完成后生成 main.jsmain.js.gz 两个文件,后者为前者的压缩后文件。

将这些文件上传到服务器后,当网页服务器接收到静态资源的请求后,会主动查找服务器目录里有没有存放对应的压缩文件,如果有的话就直接将该压缩文件传递给客户端。

这里,在构建代码时以 webpack 为例,可以使用 compression-webpack-plugin 对代码进行压缩:

1
2
3
4
5
6
7
8
9
const config = {
// ... ...
plugins: [
// ... ...
new CompressionPlugin({
test: /\.(js|css)$/, // 只压缩 js 与 css 文件
}),
]
}

nginx 配置需要用到 ngx_http_gzip_static_module,新版的 ngixn 会自带该 module,只需要添加相关配置即可启用:

1
gzip_static on

配置可以写入到 http, server, location 任意片段中

优化样式加载

在 SSR 应用中,如果不对应用样式进行任何处理的话,从服务端生成 HTML 到完成客户端激活的这一过程中,HTML 样式是空白的,因为 SSR 升成 HTML 的过程中是无法生成样式的(很遗憾,vue-style-loader 在 Vue3 项目中无法在服务端渲染时生成生成当前页面的样式并注入到 HTML 中)。

为了避免样式闪烁问题,最粗暴的做法就是在服务端渲染的过程中把所有 Style 标签都插入到 HTML 中,但这样的话就会严重拖慢 FCP 导致性能评分降低,因为 CSS 加载会阻塞页面渲染。因此必须对每个页面进行按需加载页面样式。

一个比较讨巧的方案是使用 Webpack 的分包的逻辑,在使用了 vue-router 以及路由懒加载的情况,我们可以使用 webpackChunkName 的备注来对某个页面组件进行分包包名的指定,如:

1
const Home = () => import(/* webpackChunkName: "Home" */ '@/views/Home/index.vue');

这样,对于 Home 页面需要用到的 JS 会被打包为 Home.[hash].js,页面中用到的 CSS 就会被打包为 Home.[hash].css,这样我们在服务端渲染时,就可以通过判断路由名来获取当前用户访问的页面,再通过页面与 webpackChunkName 的对应关系,就可以获取到当前页面的 css 文件名,只需要将这个文件作为 style 标签注入到生成的 html 中即可。

总之,如果我们使用 WebPack 进行 vue3 项目的搭建,加载 css 的思路就是通过静态分析当前页面所用到的 chunk,然后再获取 chunk 对应到的 css 即可。同时要注意,对于全局样式,所用到的 css 也会被打包到主包的 chunk 中,需要正确的引用到。

代码分包

使用 Webpack 打包项目时,需要额外注意代码的分包情况。手动控制分包可以使用 Webpack splitchunks 配置项进行优化。

默认的优化规则如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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,
},
},
},
},
};

在默认的规则下,只有通过异步引入的包才会被单独拆分到一个文件中,比如 vue-router 的路由懒加载。我们可以将 splitChunks.chunks 改为 all,那么 webpack 就会将所有的包进行静态分析后进行拆分,举例来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: -10,
reuseExistingChunk: true,
minChunks: 3,
},
default: {
priority: -20,
reuseExistingChunk: true,
minChunks: 3,
},
},
},

按照默认的配置,打包完成后,我们的入口文件 mian.js 会是最大的,因为入口文件会引入很多第三方库以及 vue 框架的代码;但如果改为上面的配置,main.js 会小的很多,但是会生成一个很大的 vendor.js 文件,这个文件会将我们引用到的 node 模块都打包到 vendor.js 中,这样做的好处是可以极大化的减少其他文件的大小,避免重复的引用、重复的打包,但坏处就是会造成主包比较大,因此,我们还可以使用 minChunks 来规定只有引用过目标次数的包才会被打包到 vendor.js 下,这样就避免了无必要的提前加载。

另外一个使用场景是我们可以将某个 npm 包打包为单独的一个 js 文件,比如对于按需引用使用的 Element Plus,A 页面和 B 页面同时使用了某个组件,那么这个组件就会被打包到 A 页面和 B 页面,同时,我们也不想让其被打包到 vendor.js 中,那么我们就可以设置如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
splitChunks: {
chunks: 'all',
cacheGroups: {
elementPlus: {
test: /[\\/]node_modules[\\/]element-plus(.*)/,
name: 'element-plus',
priority: 20,
reuseExistingChunk: true,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: -10,
reuseExistingChunk: true,
minChunks: 3,
},
default: {
priority: -20,
reuseExistingChunk: true,
minChunks: 3,
},
},
},

Element Plus 会被单独打包为 element-plus.js 文件,同时,其样式文件也会被分析到一同打包到 element-plus.css 文件中,这样不仅有利于我们减少不必要的组件打包次数,同时还可以单独拆分出来 Element Plus 的样式,提供给 SSR 是进行加载。

总之,调整分包配置是迫不得已的行为,如果你不满足 webpack 的默认分包规则,想要尽可能的提升浏览器并发请求的能力,就可以针对主包进行更为细致的拆分。

正确的预载

浏览器的预加载对于提高页面的整体性能也非常有效,经过实践,可以带来大约 10 分的总分提升。但是如果使用了错误的预载方式,那么就会导致浏览器阻塞去加载更多无用的资源,导致性能大打折扣。

主流的浏览器预加载分为两中 prefetchpreload

  • prefetch 代表后续页面需要加载的资源
  • preload 代表当前页面需要加载的资源

prefetch 会在浏览器线程空闲的时候加载资源,尽可能的加快用户对后续资源的访问速度,是非常好用的一个手段(但是站在用户的角度,prefetch 会消耗用户额外的流量),由于其是在浏览器线程空闲时下载,因此不会占用应用加载的速度,对性能评分产生负面影响。

但是 preload 与 prefetch 不通的是,其加载的是浏览器当前页面需要的资源,会阻塞渲染过程,因此过多的 preload 会大幅降低浏览器的性能评分!且如果浏览器 preload 的资源在页面加载完成后的 3s 内没有被使用,浏览器控制台会弹出对应的警告,可以以此来判断是否使用了错误的 preload。

使用 @vue/preload-webpack-plugin 可以在编译好的 html 中自动插入所有的 js 与 css 作为 prefetch 资源(vue-cli 会默认添加该插件),按照如下配置即可:

1
2
3
{
plugins: [new PreloadWebpackPlugin({rel: 'prefetch'/** 注意改为 prefeth,默认为 preload */})];
}