跳到主要内容

SPA首屏性能优化

项目环境

  • 前端:vite 开发和打包
  • Node:express 通过tsc转译
  • 请求方式:请求到node端口,通过express返回静态文件

问题简介

在SPA页面中,通过打包工具打包后,会打包成一个大体积的index.js,在首次加载的时候,由于要加载这个大体积index.js,会出现长时间的白屏现象,所以需要考虑如何加速加载,减少白屏时间。

解决方案

提升请求速度

在不改变原有代码的情况下,尽可能减少文件加载所消耗的时间,从而提升性能。

图片转webp格式

页面中一般都会引入一些图片,如果图片过大就会导致一些没有必要的加载时间,完全可以考虑换一种格式的图片,在尽可能保持图片质量的前提下,减少图片的大小,就比如使用webp的图片。 webp的图片非常适合用于浏览器的图片展示,在图片质量差不多的情况下,极端情况下可能能减少97%以上的体积,当然虽然也存在转换后体积增大,但是这属于少数。 在尝试了很多种转换方式后,我最后使用了Google的webp工具,下载下来后自带环境,直接调用bin中的命令行工具即可,也可以放进PATH进行全局调用。

提供一个批量替换文件的脚本:

#!/bin/bash

# 把文件转换成webp的命令
# cwebp文档及下载地址
# https://developers.google.cn/speed/webp/docs/using
# 链接到全局命令示例
# sudo ln -s /Users/bestlyg/Documents/libwebp-1.4.0-mac-arm64/bin/cwebp /usr/local/bin/cwebp

# 定义要转的文件夹
IMG_PATH=./public/img
# 定义要转的后缀
IMG_SUFFIX=("jpg" "png" "jpeg")
# 定义质量
QUALITY=80
# 定义命令行路径,如果不是全局的,需要指定路径
CWEBP_COMMAND=cwebp
# 转换后是否删除源文件,注释后关闭
NEED_DELETE_OLD_FILE=1

for suffix in "${IMG_SUFFIX[@]}"; do
file_list=`find $IMG_PATH -type f -name "*.$suffix"`
for old_file_path in $file_list; do
if [ -f $old_file_path ]; then
old_file_path=$(realpath $old_file_path)
new_file_path="$(echo $old_file_path | cut -f1 -d ".").webp"
echo "======================================================"
echo "Transform : $suffix to webp"
echo "From : $old_file_path"
echo "To : $new_file_path"
# 调用工具转译
command="$CWEBP_COMMAND -q $QUALITY $old_file_path -o $new_file_path"
echo "Run : $command"
echo "==========>"
eval $command
if [ -n "$NEED_DELETE_OLD_FILE" ]; then
rm $old_file_path
echo "Deleted $old_file_path"
fi
fi
done
done

压缩文件

现阶段的浏览器都支持多种压缩算法,如最常见的gzip等,通过开启压缩算法能大幅度减少因为体积导致的网络传输上的时间。 本次项目中前端使用了vite,我选择使用了vite-plugin-compression在文件打包完后对静态文件进行本地压缩,后端使用了express作为后端的静态文件返回,可以考虑用compression开启动态压缩的方式,在返回静态文件的时候他会动态的把文件压缩成gzip并返回给浏览器。 考虑到本项目在打包完后并不会出现文件的动态变化,我使用了静态压缩的方式,即在前端打包的时候就把压缩文件生成好,在后端需要返回静态文件的时候直接返回即可。

vite中的配置
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
plugins: [
// br压缩算法,比gzip优秀
viteCompression({ algorithm: 'brotliCompress' }),
// gzip压缩算法,作为br不支持时的兜底
viteCompression({}),
],
});
node端的中间件
/**
* 通用路径处理函数,从项目根路径开始引入
* @param p 路径集合
*/
export function resolve(...p: string[]) {
return path.resolve(__dirname, '..', '..', ...p);
}

/**
* client打包产物路径,方便后续的路径拼接
*/
export const PATH_DIST_CLIENT = resolve('dist-client');

/**
* 静态压缩中间件
*/
export function createStaticCompression() {
const needStaticCompressionSet = new Set(['.js', '.css']);
// 只对js和css进行压缩文件返回
function needStaticCompression(filePath: string) {
return needStaticCompressionSet.has(path.extname(filePath));
}
// 通过后缀获得content-type
function getContentType(filePath: string) {
switch (path.extname(filePath)) {
case '.js':
return 'application/javascript';
case '.css':
return 'text/css';
default:
return null;
}
}
const requestHandler: RequestHandler = (req, res, next) => {
const filePath = resolve(PATH_DIST_CLIENT, '.' + req.url);
// 用来判断chrome是否支持某个压缩
const acceptEncoding = req.headers['accept-encoding'];
const contentType = getContentType(filePath);
// 提前校验基础信息是否符合
if (needStaticCompression(filePath) && contentType && fs.existsSync(filePath)) {
if (acceptEncoding?.includes('br') && fs.existsSync(filePath + '.br')) {
// 检测是否可以br压缩
res.setHeader('Content-Encoding', 'br');
res.setHeader('Content-Type', contentType);
fs.createReadStream(filePath + '.br').pipe(res);
} else if (acceptEncoding?.includes('gzip') && fs.existsSync(filePath + '.gz')) {
// 检测是否可以gzip压缩
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Content-Type', contentType);
fs.createReadStream(filePath + '.gz').pipe(res);
} else {
next();
}
} else {
next();
}
};
return requestHandler;
}

// with express app
app.use(createStaticCompression());

与压缩相关的http header

  • accept-encoding
    • 在浏览器请求后端时会自动的带上,表明当前的浏览器支持哪些压缩算法。
    • accept-encoding: gzip, deflate, br, zstd
  • content-encoding
    • 在后端返回压缩产物的时候需要用该字段告诉浏览器,目前返回的产物是通过哪一种压缩算法进行压缩的。
    • content-encoding: br

manualChunks对包合并

在vite中由于动态加载的原因,vite会把js文件拆的比较细,导致一个页面的请求可能存在几十个js文件的加载,由于考虑到在http1中,chrome对浏览器进行了限制,一个源只能存在最多6个并发请求,所文件非常多的时候会导致请求需要排队从而降低页面加载的速度,而且会存在一些js文件体积非常小,只有几K的大小,甚至请求的时间没有等待请求的时间长。 所以需要考虑对一些不常用的包进行整合成一个chunk,除了能减少散落的js文件,还有一个优点就是在于这些包一般都存在node_modules中,更新的频率相较于业务代码比较低,所以在两次打包后,只要保证这些依赖的版本号不变,那打出的chunk的哈希值也时一样的,此时客户在刷新请求更新后的页面时,由于这个包在上一个版本中已经被请求过缓存下来了,所以这次直接可以读取缓存,而不需要再次拉取文件。

export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
'node-modules-vendor': [
'stylis',
'dayjs',
'axios',
'ahooks',
'@ahooksjs/use-url-state',
'jotai',
'clsx',
'immer',
'@ant-design/icons',
'@heroicons/react',
],
'react-vendor': [
'react',
'react-dom',
'react-router-dom',
'remark-gfm',
'react-markdown',
],
},
},
},
});

多源请求

由于HTTP1在同源请求中最多存在6个并发,所以如果可以的话可以通过多个域名/端口等方式绕过这个限制,提升并发数,减少请求时间。

HTTP2

通过使用HTTP2,利用多路复用的优势,同样也能避免6个并发的问题,以及减少HTTP1所带来的大头问题。

CDN

CDN的全称是Content Delivery Network,即内容分发网络。其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输得更快、更稳定。通过在网络各处放置节点服务器所构成的在现有的互联网基础之上的一层智能虚拟网络,CDN系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。其目的是使用户可就近取得所需内容,解决 Internet网络拥挤的状况,提高用户访问网站的响应速度。

减少包体积

在首次渲染的时候,会加载基底index.js,通常用不到项目中的所有能力,但是因为一些引入问题,我们可能在打包的时候会把一些包都打进基底js中去,导致页面首次渲染的时候也需要加载这些包,但其实可能并不会被用到,尤其是有一些体积比较大的包,可能会导致文件体积的大幅度膨胀。

动态路由

把注册在路由中的每一个组件,都使用import函数拆分成动态import的方式,更好的支持按需加载,修改方式参考了原来路由中已经存在的一个方式进行修改。

常用的方案
  1. react-loadable
  2. loadable-components
  3. React.Suspense +React.lazy
解决方案

本次使用的是方案3,即原生React.Suspense配和React.lazy的方式并加以改造增加缓存功能,在lazy调用入参函数后,捕获到是一个Promise,此时会抛出实例,又上层catch,此时会找到最近的Suspese组件,对fallback进行渲染。

function Fallback() {
return <div className="flex items-center justify-center">Loading...</div>
}
const Component = lazy(()=>import("../temp.tsx"))
<Suspense fallback={<Fallback />}>
<Component {...props} />
</Suspense>

同时考虑到因为动态加载的都是静态文件,虽然再次请求时会因为缓存,不需要消耗请求时间,但是并不需要每次都动态加载,因为首次加载完后,完全可以缓存下来该组件,在下次直接使用,所以我封装了一个可以缓存的动态导入来代替原有的方案。

/**
* 缓存动态导入的组件
* 在首次加载完后,会把组件存在CachedComponent中,第二次不会再次调用import函数,直接发回CachedComponent
* 该函数会在引入在路由的时候直接被调用,每一个路由只会调用一次该函数
* @param load 动态加载组件的函数
*/
export function cachedDynamicImportComponent(
load: () => Promise<{ default: React.ComponentType<any> }>,
) {
// 如果不为null,表明请求过一次,已经缓存了
let CachedComponent: React.ComponentType | null = null;
// 封装一次load函数,获取到实际的组件用于缓存
function dynamicImport() {
return load().then(mod => {
CachedComponent = mod.default;
return mod;
});
}
// 仅在浏览器端执行,该函数会因为node端需要路由猜测,所以该文件在node端也会被执行
globalThis.window?.addEventListener('load', () => {
idleTaskQueue.addTaskAndRun(dynamicImport);
});
// 实际被渲染的组件
function DynamicImportComponent(props: any) {
if (CachedComponent) return <CachedComponent {...props} />;
const Component = lazy(dynamicImport);
return (
<Suspense fallback={<Fallback />}>
<Component {...props} />
</Suspense>
);
}
// 挂载一下本身的import函数方便后期处理import
DynamicImportComponent.load = load;
return DynamicImportComponent;
}

因为需要缓存这个组件,所以没发法直接使用html自带的preloadprefetch等功能,可以手动的模拟这些功能,考虑到在首次页面渲染完成之后,完全可以在后台空闲时间把其他组件都进行加载并缓存下来,当真实调用该组件的时候就不需要请求了。

type IdleTask = () => Promise<any>;
/**
* 空闲时运行任务的任务队列
*/
class IdleTaskQueue {
running = false;
queue: IdleTask[] = [];
addTask(task: IdleTask) {
this.queue.push(task);
}
addTaskAndRun(task: IdleTask) {
this.addTask(task);
this.run();
}
idleFunction: (fn: () => void) => void;
/**
* 获取idleFunction,防止环境中requestIdleCallback不存在
* 增加setTimeout兜底,setTimeout多次调用后会底层增加延时,不会阻塞
*/
getIdleFunction(): typeof this.idleFunction {
if (typeof requestIdleCallback !== 'undefined') {
return fn => {
requestIdleCallback(() => {
fn();
});
};
} else {
return fn => {
setTimeout(() => {
fn();
}, 0);
};
}
}
constructor() {
this.idleFunction = this.getIdleFunction();
}
async run(force = false) {
if (this.running && !force) return;
this.running = true;
const task = this.queue.shift();
if (!task) {
this.running = false;
} else {
this.idleFunction(() => {
// 一个个执行,每次执行完都重新判断空闲
task().finally(() => {
this.run(true);
});
});
}
}
}
export const idleTaskQueue = new IdleTaskQueue();

清理初始化需要的包

有些包因为需要初始化的关系,需要在首次渲染前就导入并进行处理,但是有些时候这些包并不需要这么早的导入进来。 比如项目中有对pdfjs的初始化,暂时当作它是必要的,一定要进行初始化这个包才可以工作,但是如果你首屏进入的是登录页,那其实根本用不到这个包,所以此时就可以稍微的延后他的初始化。

比如项目中存在pdfjs-dist的初始化:

import { version, GlobalWorkerOptions } from 'pdfjs-dist';
/** 设置 PDF.js 工作线程的源文件路径 */
GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.js`;

这会带来大约1M的基底js体积增加,然而如果用户只打开了登陆页面,其实根本用不到它,此时就可以考虑换一个位置初始化。

如果项目中使用了动态路由的方式,那么可以把这段代码放在某一个js文件的顶部,这样子只有在引入了这一个js的时候才会初始化。

如果项目中没有使用动态路由,那么可以考虑在空闲时间加载它:

// 不一定使用useEffect,只要保证里面的只执行一次就可以了
useEffect(() => {
// 确保空闲
requestIdleCallback(() => {
// 动态引入,防止进入时就加载
import('pdfjs-dist').then(mod => {
const { version, GlobalWorkerOptions } = mod;
GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.js`;
});
});
}, []);

原子化功能导出

如果一个tsx文件中,存在多个export,且在项目中都有被引用,那么它不会被树摇,所有的数据都需要加载到项目中,但是实际上可能用户登陆的时候并不会用到所有的导出,但是又会被打包成一个大的js文件,导致了多请求了一些数据。

比如说项目中有一个大的SVG导出文件:

export const SvgIcon = forwardRef(function SvgIconInner(
{ icon, className, ...restProps }: { icon: string; className: string },
ref: React.Ref<HTMLDivElement>,
) {
return (
<div
className={className}
{...restProps}
ref={ref}
dangerouslySetInnerHTML={{ __html: icon }}
/>
);
});

export const svg1 = `<svg/>`;
export const svg2 = `<svg/>`;

如果你项目中所有的svg导出都被使用了,那么这个大文件依旧存在且还是很大,此时可以考虑拆文件的形式,尽可能拆的够细:

export const SvgIcon = forwardRef(function SvgIconInner(
{ icon, className, ...restProps }: { icon: string; className: string },
ref: React.Ref<HTMLDivElement>,
) {
return (
<div
className={className}
{...restProps}
ref={ref}
dangerouslySetInnerHTML={{ __html: icon }}
/>
);
});

export * from './svg1';
export * from './svg2';

这样子虽然所有文件依旧被引入项目,但是对于基地的indexjs来说,就没有必要引用一个很大的tsx文件了,只需要链接到所需要的部分即可。

(终极)打扫屎山

随着业务的不断成长,随着开发人员的迭代,随着UI交互的更新,随着客户的奇思妙想,项目会随着时间变得越来越臃肿,越来越屎山。

无论如何做性能优化,可能改一改代码逻辑,减少一下代码的冗余,比增加一大堆配置更有效。

我认为一个项目的开发人员应该具有共同的一个项目开发方法,无论是驼峰使用、样式处理器的选择、生成组件的方式、组件的编写习惯、状态管理的选择等,还是基础组件库的选择,打包工具的使用,全局化的格式化等配置,大家都应该保持一致性。

否则以一个UI组件库为例,有人要用Antd,有人要用ArcoDesign,项目中依赖的包会越来越多越来越大,无论怎么优化,想通过预加载的方式加速,还是想通过压缩的方式减少包体积,都治标不治本,最好的办法就是大家统一基于一套标准去做扩展,哪怕这扩展有些开发人员可能觉得不符合他的习惯,他在开发这个项目的时候也应该要遵循。

所以性能优化的终极方案应该就是检查代码对代码进行优化,试想一下一个组件中因为有一个算法使用了O3的时间复杂度导致组件变的特别卡,无论用什么优化方式,它依然是O3,还是很慢,能优化的时间都是次要的,但是要是这个算法能被优化成O1,那性能立马有大幅度提升。

至于如何打扫屎山:

Code Review

在CodeReview的时候提交到主分支的代码如果能被多名其他开发成员一起检查过,更能容易发现这里出现的问题,以及是否存在不规范的点。

Lint工具

通过ESLint、StyleLint等工具,能有效的在开发的过程中就减少一些不规范的代码,以及在开发过程中团队能够组成一份符合自己团队风格的Lint规范。

规范提交

通过husky对于Commit增加hook,比如在commit前对变更的文件进行一次lint检测,不通过时不允许提交等。 通过commitlint工具对commit的信息规范化,尽可能详细的记录本次commit做了什么,好记性比如烂笔头,一定时间后除了commit,基本不会有人记得这一次改动是为啥,包括commit的提交者也有可能忘记。

算法优化

项目中难免需要创建一些函数,利用一个算法去处理一些对象或者通过一些参数获得一定的产出,如果这个算法还有时间/空间上的优化空间,那么可以考虑使用更优秀的算法去代替。

冗余代码的减少

如果项目中存在相同的一套逻辑,比如一个常量通过一个计算得到,那最好进行复用,把这部分放在一个能共同引用的地方,而不是写多次相同的逻辑。 如果项目中存在一些没有必要的代码增加,比如一个SVG字符串,因为内部存在一张base64的图片导致字符串特别长,甚至可能存在200K以上的体积增加,那就应该彻底清除这部分问题。

以上都是其中一部分通用性打扫屎山的方案,我觉得更多的还是需要一个项目共用一套解决方案,大家都能保持一致,这样才能尽可能减少屎山的增加。