记一次优化体验,loading并不是所有用户都能接受的方案

今天收到用户反馈:你们系统反应太慢了,看看别人的系统页面一下就加载出来了!

顺带着发过来一个视频,我看完视频愣了一下;

我说:这不慢呀,点击任何操作页面能立即做出响应,页面切换渲染也没有明显的卡顿,这哪里慢了?
用户说:你看你这个图,要转半天才能显示,别人系统一点过来就显示了,你这个太慢了!

面对这个问题我开始陷入沉思!!!

问题原因

在使用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操作都行;

fabrichooks中有一个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>

总结

在前端大多数性能优化优化的可能并不是性能,而是用户体验,为了更好的用户体验,大多数优化都是使用的障眼法;

例如长列表的虚拟滚动、大数据分片渲染,再加上我上面一个非常简单的优化方案,了解原理之后也就不过如此的感觉;

本质上该渲染的数据都需要渲染,该耗时的操作耗时依旧是固定的,只是我们应该如何让用户尽早的看到他们想看到信息,如何让操作变得流畅;

而把这些耗时放到用户感知不到的地方进行,从而达到优化的目的。

相关推荐
M_emory_10 分钟前
解决 git clone 出现:Failed to connect to 127.0.0.1 port 1080: Connection refused 错误
前端·vue.js·git
Ciito13 分钟前
vue项目使用eslint+prettier管理项目格式化
前端·javascript·vue.js
成都被卷死的程序员1 小时前
响应式网页设计--html
前端·html
fighting ~1 小时前
react17安装html-react-parser运行报错记录
javascript·react.js·html
老码沉思录1 小时前
React Native 全栈开发实战班 - 列表与滚动视图
javascript·react native·react.js
abments1 小时前
JavaScript逆向爬虫教程-------基础篇之常用的编码与加密介绍(python和js实现)
javascript·爬虫·python
mon_star°1 小时前
将答题成绩排行榜数据通过前端生成excel的方式实现导出下载功能
前端·excel
Zrf21913184551 小时前
前端笔试中oj算法题的解法模版
前端·readline·oj算法
老码沉思录1 小时前
React Native 全栈开发实战班 - 状态管理入门(Context API)
javascript·react native·react.js
明月与玄武2 小时前
关于性能测试:数据库的 SQL 性能优化实战
数据库·sql·性能优化