记一次网页性能优化,首屏加载速度提升100%
背景
项目是一个基于umi3的单页项目(也可以类比基于Webpack的单页项目),最终打包成静态文件后使用Nginx部署。想要优化的原因是部署到服务器后网页访问速度慢,首页打开时能感受到明显的白屏与卡顿。(不说了🙊,说就是公司经费有限)。本文记录了每一步优化的思考与操作流程,最终效果是首屏性能与交互体验得到了显著提升。
优化过程中有用到下面三个分析工具:
- Lighthouse
- Chrome开发者工具的Network
- Webpack打包可视化工具
V1版本
先看看第一个版本的网页效果。v1使用的是umi默认的打包机制,打包后只生成一个js文件与一个css文件,在下面的网络图中可以看到。
图中标明了5个方框,可以看成是首页资源加载的5个阶段。分别是:
- index.html文件
- js文件与css文件
- 一个pages请求
- 一个blocks请求与一些图片资源
- 一些图片资源
不难看出来,这5个阶段是串行的,而这也是阻碍首屏性能的关键点。为什么会有这5个阶段呢?下面来一一解析。
第一步是html文件。一般单页项目的html就长下面这样,里面没什么内容,但会引入需要的css与js文件。
<!DOCTYPE html>
<html lang="en">
<head>
<!-- 一些其他信息 -->
<link rel="stylesheet" href="/umi.a42575e9.css" >
</head>
<body>
<div id="root"></div>
<script src="/umi.bc260ca8.js"></script>
</body>
</html>
第二步就是加载html里的css与js文件了,它们包含了这个单页项目的所有代码。
后面的步骤就是自己代码里写的东西了。代码中的逻辑是先发出一个pages请求,得到结果后,再发一个blocks请求。可以看下面的示意图。一个page可以看成是一个导航,每个page下面有多个block,每个block就是一个富文本。
第五步,在得到blocks的结果后,请求block里的一些图片资源。现在读者应该可以理解为什么这里会有5部分串行的请求了。
V1到V2的优化流程
优化前我们要弄清楚“主要矛盾”,这里的主要矛盾就是网络请求慢,从一个900多KB的JS文件要加载1.74s就可以看出来。所以我们的主要目标就是要减少网络请求时间。
提取关键JS/CSS
意思就是我们先只加载首页需要的JS,CSS,其他不相关的先放一边,后面需要的时候再加载。
这里我采取的一个主要方案是:页面拆分+路由懒加载。路由懒加载大家都比较熟悉,但这里的页面拆分指什么呢?
前面有讲到,每个页面都是一些block富文本块,这些富文本块内容是可以通过管理员账号编辑的。而之前富文本块的展示与编辑都是写在一个组件里,代码里也没有区分用户端与管理端。但用户其实只需要看到展示内容,所以我们需要把编辑富文本的逻辑都从首屏迁移出去。相比于对编辑富文本需要的组件一个个懒加载,更简便的方法就是把编辑逻辑整个抽离到另一个页面里去,即分为一个用户页页面与一个管理页面。
在umi中,通过添加dynamicImport配置就可以开启路由懒加载。这里的loading
是页面资源加载完成之前的展示内容。
dynamicImport: {
loading: '@/components/loading'
}
开启路由懒加载后的打包结果如下。vendors文件中是不同页面的公共组件。
这一步优化后,umi.js从941KB到了574KB,并且管理端页面与登录页面都被拆了出来。
合并请求
V1版本中,请求pages与请求blocks是两个串行的请求。优化后,我们将其合并为了一个,即一次返回pages与需要的blocks。这样就减少了一次请求时间。
开启gzip压缩
使用gzip压缩的目的是减小文件大小从而缩短请求时间。在Nginx中添加如下配置:
# gzip
#开启gzip模式
gzip on;
#gizp压缩起点,文件大于1k才进行压缩
gzip_min_length 1k;
# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 6;
# 进行压缩的文件类型。
gzip_types
application/javascript
application/x-javascript
application/xml
text/xml
text/plain
text/javascript
text/css;
# nginx对于静态文件的处理模块,开启后会寻找以.gz结尾的文件,直接返回,不会占用cpu进行压缩,如果找不到则不进行压缩
gzip_static on;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
同时,在umi中增加compression-webpack-plugin
,用于在打包时生成gzip文件。这样就不需要浪费服务器资源来压缩了。
chainWebpack(config, { env }) {
if (env === 'production') {
config.plugin('compression-webpack-plugin').use(
new CompressionPlugin({
test: /.js$|.html$|.css$/, // 压缩js,html,css文件
threshold: 10240, // 对超过10k的数据压缩
deleteOriginalAssets: false, // 不删除源文件
}),
);
}
}
压缩后,前面500多KB的JS文件就只有190KB了。
图片压缩&预加载
下面的展示图中有一张图片,也是这个网站的一个门面。由于这张图片占位比较大,V1版本中可以明显感觉到这张图片从无到有的加载过程。那有没有办法让这个过程变得无感呢?
这里的解决方法就是预加载。实现方式是在umi的html模版里添加一行:
<link rel="preload" href="/menmian.jpeg" as="image">
在使用图片前,我们可以先对它进行压缩。这里使用的是tinypng。
缓存接口数据
可能大家平常听的都是缓存JS,CSS,图片这些静态资源,为啥要缓存接口数据呢?其实这里是跟业务性质相关的,并不是所有场景都适用。这里每次切换page都会请求对应的blocks内容,但blocks的内容并不要求那么高的实时性。最主要还是请求一个blocks接口竟然要1s!(不说了,说就是经费不够)。
于是,这里对接口数据做了缓存,这样用户再切换到相同的page时就不用重新请求了。实现方法也很简单,如下:
// 缓存请求富文本块内容
const blockCache = new Map();
export const requestPageBlocksWithCache = async (
id,
) => {
const cache = blockCache.get(id);
if (cache) return Promise.resolve(cache);
const data = await request(`/pages/${id}/blocks`);
blockCache.set(id, data);
return data;
};
体验优化
然后还有一些体验优化,处理的都是一些慢网速下的问题。包括:
-
页面loading骨架屏
-
接口请求loading
-
修改后直接更新变量而不是重新请求整个列表
V2版本
到这,V2的优化也就差不多了。相信读者或多或少觉得应该还算做了点东西,效果应该会好点。然而。。。
每一步确实有每一步的效果,页面是懒加载了,图片是预加载了,接口请求是少了。可是咋还是这么多段请求呢!
原来是这样,先加载html,再加载umi,再加载layout,再加载userpage。这中间又阻塞了,猝不及防呀,这还比不上V1呢。但也不必着急,这不正是预加载可以做的嘛。layout,userpage这些资源都是我们需要的,所以可以一并和umi.js一起放在html里预加载,效果就和预加载图片一样。
然后,就到V3了。
V2到V3的优化流程
主要是解决V2中的资源阻塞问题。解决方案就是预加载。
这里通过一个umi插件来实现,主要思路是在构建完成后获取到所有构建文件名称,将需要preload的文件提取出来添加到html文件中(这里实现的比较简陋,没有处理一些可能的异常情况)。
import fs from 'fs';
import { parse } from 'node-html-parser';
import { join } from 'path';
import type { IApi } from 'umi';
const addPreloadLinksToHtml = (
filePath: string,
links: {
rel: string;
as: string;
href: string;
}[],
) => {
const html = fs.readFileSync(filePath).toString();
const root = parse(html);
const head = root.querySelector('head');
if (!head) return;
links.forEach((link) => {
head.innerHTML += `<link rel="${link.rel}" as="${link.as}" href="${link.href}">`;
});
fs.writeFileSync(filePath, root.innerHTML);
};
export default (api: IApi) => {
const { paths } = api;
api.describe({
key: 'preload',
config: {
schema(joi) {
return joi.array().items(joi.string());
},
},
enableBy: api.EnableBy.config,
});
api.onBuildComplete(({ err, stats }) => {
if (err) return;
const assets = Object.keys(stats?.stats[0].compilation.assets ?? {});
const preloadList = api.userConfig.preload as string[];
const preloadAssets = assets.filter(
(filename) =>
preloadList.some((name) => filename.includes(name)) &&
['js', 'css'].includes(filename.substring(filename.lastIndexOf('.') + 1)),
);
const umiJS = assets.find((name) => name.startsWith('umi') && name.endsWith('.js'));
if (umiJS) preloadAssets.unshift(umiJS); // 将 umijs 作为第一个预加载文件
api.logger.info('Preload assets: ', preloadAssets);
const preloadLinks = preloadAssets.map((filename) => {
const suffix = filename.substring(filename.lastIndexOf('.') + 1);
return {
rel: 'preload',
as: suffix === 'js' ? 'script' : 'style',
href: filename,
};
});
// add preload links to html
const htmlFilePath = join(paths.absOutputPath ?? '', 'index.html');
addPreloadLinksToHtml(htmlFilePath, preloadLinks);
});
};
这里除了把需要预加载的页面资源文件添加进来之外,还加上了umi.js作为第一个预加载资源。原因是在http1,http1.1中对同一个域名的TCP连接数有限,如下图里方框中有三个请求最开始是灰色,其实就是在等待连接。所以将umi.js先进行预加载,避免体积大的文件请求等待。
V3版本
最后看V3版本的效果,基本上是符合预期了。一共只有三段请求,第一次是html,第二次是许多并行的页面资源请求以及预加载的图片,第三段就是接口请求block了。首屏体验也好了许多。
V4
是不是到这里优化就结束了呢?远远没有。肉眼能观察到的优化之处就有下面一些。
umi.js体积优化
从打包来看,umi.js这个文件还是有挺多可以优化的。比如定制化的polyfill减小core-js的引入体积,自己实现引入的组件库的一些组件。
开启http2
解决前面提到了并发连接数问题。
CDN
既然服务器网络这么慢,为什么不把静态资源放到CDN上呢?(别问,问就是经费有限)
服务端渲染SSR
在服务端请求了block数据后一次性返回html内容,减少请求时间。后面补充~
参考
转载自:https://juejin.cn/post/7207743145998811173