背景
当前,当用户选择 200+ 个 sku 并进行编辑的时候,页面存在性能问题,响应缓慢,用户无法正常操作。
目前我们都是统一使用的原图,原图可能有 1024 * 1024,但实际展示可能很小,这样不可避免造成了一些流量的浪费,这么大的流量,也导致我们的页面加载不够快,尤其一些长列表的时候,因为图片尺寸的压缩,让我们的页面有一些卡顿。
在之前的一篇关于无限滚动优化的文章中, 我们使用了虚拟列表来改善用户体验,并取得了不错的效果。
虚拟列表的大概原理是:只有进入到视区, 或者设定的某个阈值, 比如上下一屏之内, 才会挂载和渲染, 以此来保持总的结点数在一个合理的范围内, 以此较少浏览器的负担, 缩短屏幕响应时间。
本篇是后续,在虚拟列表中的图片缩略图增加离屏渲染
和压缩并缓存
的能力, 作为功能增强。
优化的目的:
- 支持 2000 sku+ 可以同时正常操作;
- 进入页面加载时间在 2 - 3s 以内,滚动展示不卡顿,操作反馈正常;
- 更快的页面加载速度;
主要的处理:
增加一个用离屏渲染压缩图片的 Avatar 组件, 并替换原有的 Avatar 组建
;增加了 LRU Cache 来缓存压缩过后的图片
;实验性的加入 Web worker 防止压缩图片时主线程卡顿
;- 使用更强大的
react-virtualized
代替原本的react-virtual-list
下文主要分享方案设计
以及核心代码
的实现, 希望对大家有所帮助。
正文
现状分析
目前我们都是统一使用的原图,原图可能大于 1024 * 1024
,但实际展示可能很小,没有对图片进行相应的尺寸裁切。
浪费用户加载流量,同时在一些虚拟滚动中,涉及到 DOM 创建和删除时,交互响应有明显的卡顿
。
现有架构
前端上传商品图片:
现有架构的不足
使用错误的图片尺寸,不可避免造成了一些流量的浪费,这么大的流量,也导致我们的页面加载不够快,尤其一些长列表的时候,因为图片尺寸的压缩,让我们的页面有一些卡顿。
卡顿和加载速度慢,会影响用户体验,降低产品的体验质量。
改进方案
上传和获取图片
前端获取图片以及回退逻辑
根据参数获取目标尺寸的图片, 节省流量。
前端图片压缩
流程图
优化前后效果对比
优化前
选择全部 SKU 后,页面在等待 10s 后,弹出崩溃提示。
优化后
除第一次加载数据有一个加载等待外,后面的滚动和交互都没有任何的卡顿。
数据对比
解决了核心的渲染卡顿问题, 功能从可用变成了不可用,用户体验有了极大的提升。
遗留问题与风险
留问题
- 只涉及到用户使用频繁的场景,没有覆盖全量的图片;
- 无法降低用户的流量;
- 少量增加客户端运行内存。
风险预估
- 低于阈值的图片不会被处理;
- 历史数据的更新可能存在遗漏;
- 对于部分低版本用户,可能不可用,回退到老逻辑。
核心代码的详细实现
页面接入
// view
import AvatarWithZip from '../molecules/AvatarWithZip';
- <Avatar className="product-image" src={record.image} size={32} />
+ <AvatarWithZip className="product-image" src={record.image} size={32} openZip />
关键组件的详细实现
组件实现
// AvatarWithZip
import React, { useState } from 'react';
import Avatar, { AvatarProps } from 'antd/lib/avatar';
import useAvatarZipper from '@/hooks/use-avatar-zipper';
interface PropTypes extends AvatarProps{
openZip: boolean;
}
const AvatarWithZip: React.FC<PropTypes> = (props) => {
const { openZip, src, size, ...otherProps } = props;
const [localSrc, setLocalSrc] = useState(openZip ? '' : src);
const resize = useAvatarZipper(size as number);
React.useEffect(() => {
if (!openZip) {
return;
}
if (typeof src === 'string') {
resize(src).then(url => {
setLocalSrc(url);
});
} else if (typeof src === 'object') {
setLocalSrc(src);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, openZip]);
return (
<Avatar src={localSrc} size={size} {...otherProps} />
);
};
export default React.memo(AvatarWithZip);
对应封装的 hooks
import React from 'react';
import LRU from 'lru-cache';
import AvatarZipWorker from '@/workers/avatar-zip.worker.ts';
type UseAvatarZipper = (src: string) => Promise<string>;
const MAX_IAMGE_SIZE = 1024;
const zippedCache = new LRU<string, string>({
max: 500,
});
const worker = new AvatarZipWorker();
const useAvatarZipper: (size?: number) => UseAvatarZipper = (size = 32) => {
const offScreen = React.useRef<OffscreenCanvas>(new OffscreenCanvas(size, size));
const offScreenCtx = React.useRef(offScreen.current.getContext('2d'));
React.useEffect(() => {
offScreen.current = new OffscreenCanvas(size, size);
offScreenCtx.current = offScreen.current.getContext('2d');
worker.postMessage({ type: 'init', payload: size });
}, [size]);
const resize: (src: string, max?: number) => Promise<string> = React.useCallback((src, max = MAX_IAMGE_SIZE) =>
new Promise<string>((resolve, reject) => {
const messageHandler = (event: MessageEvent) => {
const { type, payload } = event.data;
if (type === 'error') {
reject(payload);
}
const { origin, dist } = payload;
if (origin === src) {
zippedCache.set(src, dist);
resolve(dist);
}
};
if (zippedCache.has(src)) {
resolve(zippedCache.get(src) as string);
return () => {};
}
worker.postMessage({ type: 'zip', payload: { src, max } });
worker.addEventListener('message', messageHandler);
return () => {
worker.removeEventListener('message', messageHandler);
};
}),
[]);
return resize;
};
export default useAvatarZipper;
worker 实现
const ctx: Worker = self as any;
interface MessageData {
type: string;
payload: any;
}
export interface MessageReturnData {
type: string;
payload: any;
}
let offScreen: OffscreenCanvas;
let offScreenCtx: OffscreenCanvasRenderingContext2D | null;
// Respond to message from parent thread
ctx.addEventListener('message', async (event: MessageEvent<MessageData>) => {
const { data } = event;
const { type, payload } = data;
if (type === 'init') {
offScreen = new OffscreenCanvas(payload, payload);
offScreenCtx = offScreen.getContext('2d');
}
if (type === 'zip') {
const { src, max } = payload;
try {
if (!offScreenCtx) {
throw Error();
}
const res = await fetch(src);
const srcBlob = await res.blob();
const imageBitmap = await createImageBitmap(srcBlob);
if (Math.max(imageBitmap.width, imageBitmap.height) <= max) {
ctx.postMessage({
origin: src,
dist: src,
});
}
const size = offScreen.width;
offScreenCtx.clearRect(0, 0, size, size);
offScreenCtx.drawImage(imageBitmap, 0, 0, size, size);
const blobUrl = await offScreen.convertToBlob().then(blob => URL.createObjectURL(blob));
ctx.postMessage({
type: 'success',
payload: {
origin: src,
dist: blobUrl,
}
});
} catch (err) {
ctx.postMessage({
type: 'error',
payload: err,
});
}
}
});
相关配置修改
// webpack 增加配置:
{
test: /\.worker\.ts$/,
loader: 'worker-loader',
options: {
chunkFilename: '[id].[contenthash].worker.js',
},
}
// types
declare module '*.worker.ts' {
class WebpackWorker extends Worker {
constructor();
}
export default WebpackWorker;
}
总结
总的来说, 实现思路并不复杂, 主要是 worker 的实现,以及边界问题的处理, 比如降级处理等。
对 worker 不熟的同学可以参考这篇文章。
内容就这么多, 希望对大家有所启发。
才疏学浅,如果错误, 欢迎指正, 谢谢。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。