今天收到用户反馈:你们系统反应太慢了,看看别人的系统页面一下就加载出来了!
顺带着发过来一个视频,我看完视频愣了一下;
我说:这不慢呀,点击任何操作页面能立即做出响应,页面切换渲染也没有明显的卡顿,这哪里慢了?
用户说:你看你这个图,要转半天才能显示,别人系统一点过来就显示了,你这个太慢了!
面对这个问题我开始陷入沉思!!!
问题原因
在使用fabric
的时候,由于保存的图形对象资源太大,导致fabric
在加载的时候会有长时间的白屏,这个体验并不好,所以我就在加载的时候加了一个loading
;
但是数据量大的时候,loading
的时间也会很长,就长时间loading
,用户并不愿意等待;
我们先来看一下简单的小测试案例:
可以看到1mb
的数据量,加载时间大概在500ms - 1000ms
之间,这还抛去接口请求的时间,反馈到用户眼里就可能是2s - 3s
的加载时间;
测试代码如下:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style>
html, body {
margin: 0;
padding: 0;
background: #ddd;
}
.loading {
position: absolute;
left: 50%;
top: 50%;
translate: -50% -50%;
width: 50px;
padding: 8px;
aspect-ratio: 1;
border-radius: 50%;
background: #25b09b;
--_m:
conic-gradient(#0000 10%,#000),
linear-gradient(#000 0 0) content-box;
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: source-out;
mask-composite: subtract;
animation: l3 1s infinite linear;
}
@keyframes l3 {
to {
transform: rotate(1turn)
}
}
</style>
<body>
<canvas id="canvas"></canvas>
</body>
<script src="./fabric.min.js"></script>
<script>
const generateRandomColor = () => {
const color = Math.floor(Math.random() * 16777216).toString(16);
return '#' + ('000000' + color).slice(-6);
};
const width = window.innerWidth;
const height = window.innerHeight;
// 创建 1w 个矩形 和 1w 个线条,随机摆放
const canvas = new fabric.Canvas('canvas', {
width,
height,
backgroundColor: '#fff',
});
for (let i = 0; i < 2000; i++) {
const line = new fabric.Line([Math.random() * width, Math.random() * height, Math.random() * width, Math.random() * height], {
stroke: generateRandomColor(),
strokeWidth: 1,
selectable: false,
hoverCursor: 'default',
});
canvas.add(line);
}
const jsonData = canvas.toJSON();
// 计算数据大小 MB
const size = JSON.stringify(jsonData).length / 1024 / 1024;
console.log(size.toFixed(2) + 'mb');
const createLoading = () => {
const loading = document.createElement('div');
loading.className = 'loading'
document.body.appendChild(loading);
return () => {
document.body.removeChild(loading);
}
}
canvas.clear();
canvas.renderAll();
const cancel = createLoading();
setTimeout(() => {
console.time('time')
canvas.loadFromJSON(jsonData, () => {
console.timeEnd('time')
cancel();
});
}, 100)
</script>
</html>
解决方案
我们并不能控制fabric
加载方式,简单来说常规的优化方案在这里并不适用;
而且由于这个图也是通过用户扫描SVG
加载出来的,所以优化原始资源在这里也不能使用,那么只能另辟奇径了;
我的方案是在用户保存的时候,将画布生成一张图片上传到后端,在加载数据的时候,我们将图片直接先用img
标签加载出来,然后等整个画布渲染完成之后,将img
移除即可;
正好fabric
提供了toDataUrl
的方法,文档戳这里toDataURL
解决代码
数据保存方面
js
// 转成图片
const png = canvas.toDataURL({
format: 'png',
quality: 1,
});
console.log(png)
// 将Data URI转换为Blob对象的函数
function dataURItoBlob(dataURI) {
const byteString = atob(dataURI.split(',')[1]);
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeString });
}
// 创建Blob对象
const blob = dataURItoBlob(png);
const pngFile = new File([blob], 'xxx.png', { type: 'image/png' });
DataURL
应该都知道,就是一个base64
编码的字符串,如果需要上传到后端,然后再到前端处理起来肯定是比较麻烦的;
所以这里先将DataURL
转换成一个Blob
,最后再将这个Blob
变成一个File
对象,File
类型已经确定了,直接写死image/png
;
这样我们就可以将这个DataURL
当成一个普通的图片文件传到后端了;
前端展示方面
html
<img src="xxx.png" id="placeholderImg">
<script>
canvas.on('after:render', () => {
const img = document.getElementById('placeholderImg');
document.body.removeChild(img)
canvas.off('after:render');
})
</script>
前端怎么展示都无所谓,因为前端框架太多,每个框架的处理方式都不一样,所以直接原生JS
操作都行;
fabric
的hooks
中有一个after:render
,代表渲染完成之后触发,我们可以在这个事件回调中移除这个img
;
如果太早或者太晚都不行,太早会有闪现的现象,太晚会有两个图形重叠产生的叠加效果,然后闪现恢复正常;
这里由于fabric
并没有提供once
方法,所以只能使用on + off
来模拟once
方法;
效果展示
可以看鼠标指针,在default
状态下使用的就是图片,在move
状态下就是加载完成;
也可以看控制台输出,在开始加载文字输出的时候,图片页面是正常展示的,在时耗输出完成就是已经加载完成;
最开始的白屏是因为创建数据导致的,如果直接从服务器拿数据就没有这个过程,只有图片加载会有一定的耗时,但是图片资源会被浏览器缓存,第二次加载读缓存用户感知几乎为0。
完整代码如下:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style>
html, body {
margin: 0;
padding: 0;
background: #ddd;
}
.loading {
position: absolute;
left: 50%;
top: 50%;
translate: -50% -50%;
width: 50px;
padding: 8px;
aspect-ratio: 1;
border-radius: 50%;
background: #25b09b;
--_m: conic-gradient(#0000 10%, #000),
linear-gradient(#000 0 0) content-box;
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: source-out;
mask-composite: subtract;
animation: l3 1s infinite linear;
}
@keyframes l3 {
to {
transform: rotate(1turn)
}
}
.placeholder-img {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
</style>
<body>
<canvas id="canvas"></canvas>
</body>
<script src="./fabric.min.js"></script>
<script>
const generateRandomColor = () => {
const color = Math.floor(Math.random() * 16777216).toString(16);
return '#' + ('000000' + color).slice(-6);
};
// 将Data URI转换为Blob对象的函数
function dataURItoBlob(dataURI) {
const byteString = atob(dataURI.split(',')[1]);
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeString });
}
// 创建 loading
const createLoading = () => {
const loading = document.createElement('div');
loading.className = 'loading'
document.body.appendChild(loading);
return () => {
document.body.removeChild(loading);
}
}
// 创建 img
const createPlaceholderImg = (src) => {
const img = document.createElement('img');
img.src = src;
img.className = 'placeholder-img'
document.body.appendChild(img);
return () => {
document.body.removeChild(img);
}
}
const width = window.innerWidth;
const height = window.innerHeight;
// 创建 1w 个矩形 和 1w 个线条,随机摆放
const canvas = new fabric.Canvas('canvas', {
width,
height,
backgroundColor: '#fff',
});
for (let i = 0; i < 2000; i++) {
const line = new fabric.Line([Math.random() * width, Math.random() * height, Math.random() * width, Math.random() * height], {
stroke: generateRandomColor(),
strokeWidth: 1,
selectable: false,
hoverCursor: 'default',
});
canvas.add(line);
}
canvas.renderAll();
// 转成图片
const png = canvas.toDataURL({
format: 'png',
quality: 1,
});
// 创建Blob对象
const blob = dataURItoBlob(png);
const pngFile = new File([blob], 'xxx.png', { type: 'image/png' });
const jsonData = canvas.toJSON();
// 计算数据大小 MB
const size = JSON.stringify(jsonData).length / 1024 / 1024;
console.log(size.toFixed(2) + 'mb');
canvas.clear();
canvas.renderAll();
console.log("开始加载");
const url = URL.createObjectURL(pngFile);
const cancel = createPlaceholderImg(url);
setTimeout(() => {
canvas.on('after:render', () => {
canvas.off('after:render');
cancel();
});
console.time('time')
canvas.loadFromJSON(jsonData, () => {
console.timeEnd('time')
});
}, 100)
</script>
</html>
总结
在前端大多数性能优化优化的可能并不是性能,而是用户体验,为了更好的用户体验,大多数优化都是使用的障眼法;
例如长列表的虚拟滚动、大数据分片渲染,再加上我上面一个非常简单的优化方案,了解原理之后也就不过如此的感觉;
本质上该渲染的数据都需要渲染,该耗时的操作耗时依旧是固定的,只是我们应该如何让用户尽早的看到他们想看到信息,如何让操作变得流畅;
而把这些耗时放到用户感知不到的地方进行,从而达到优化的目的。