6

image.png

背景

当前,当用户选择 200+ 个 sku 并进行编辑的时候,页面存在性能问题,响应缓慢,用户无法正常操作。

目前我们都是统一使用的原图,原图可能有 1024 * 1024,但实际展示可能很小,这样不可避免造成了一些流量的浪费,这么大的流量,也导致我们的页面加载不够快,尤其一些长列表的时候,因为图片尺寸的压缩,让我们的页面有一些卡顿。

在之前的一篇关于无限滚动优化的文章中, 我们使用了虚拟列表来改善用户体验,并取得了不错的效果。

虚拟列表的大概原理是:只有进入到视区, 或者设定的某个阈值, 比如上下一屏之内, 才会挂载和渲染, 以此来保持总的结点数在一个合理的范围内, 以此较少浏览器的负担, 缩短屏幕响应时间。

本篇是后续,在虚拟列表中的图片缩略图增加离屏渲染压缩并缓存的能力, 作为功能增强。

优化的目的:

  • 支持 2000 sku+ 可以同时正常操作;
  • 进入页面加载时间在 2 - 3s 以内,滚动展示不卡顿,操作反馈正常;
  • 更快的页面加载速度;

主要的处理:

  1. 增加一个用离屏渲染压缩图片的 Avatar 组件, 并替换原有的 Avatar 组建
  2. 增加了 LRU Cache 来缓存压缩过后的图片
  3. 实验性的加入 Web worker 防止压缩图片时主线程卡顿
  4. 使用更强大的 react-virtualized 代替原本的 react-virtual-list

下文主要分享方案设计以及核心代码的实现, 希望对大家有所帮助。

正文

现状分析

目前我们都是统一使用的原图,原图可能大于 1024 * 1024,但实际展示可能很小,没有对图片进行相应的尺寸裁切。

浪费用户加载流量,同时在一些虚拟滚动中,涉及到 DOM 创建和删除时,交互响应有明显的卡顿

1.gif

现有架构

前端上传商品图片:

image.png

image.png

现有架构的不足

使用错误的图片尺寸,不可避免造成了一些流量的浪费,这么大的流量,也导致我们的页面加载不够快,尤其一些长列表的时候,因为图片尺寸的压缩,让我们的页面有一些卡顿。

卡顿和加载速度慢,会影响用户体验,降低产品的体验质量。

改进方案

上传和获取图片

image.png

image.png

前端获取图片以及回退逻辑

image.png

根据参数获取目标尺寸的图片, 节省流量。

前端图片压缩

流程图

image.png

优化前后效果对比

优化前

选择全部 SKU 后,页面在等待 10s 后,弹出崩溃提示。

1.gif

image.png

优化后

除第一次加载数据有一个加载等待外,后面的滚动和交互都没有任何的卡顿。

2.gif

image.png

数据对比

image.png

image.png

解决了核心的渲染卡顿问题, 功能从可用变成了不可用,用户体验有了极大的提升。

遗留问题与风险

留问题

  1. 只涉及到用户使用频繁的场景,没有覆盖全量的图片;
  2. 无法降低用户的流量;
  3. 少量增加客户端运行内存。

风险预估

  1. 低于阈值的图片不会被处理;
  2. 历史数据的更新可能存在遗漏;
  3. 对于部分低版本用户,可能不可用,回退到老逻辑。

核心代码的详细实现

页面接入

// 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 不熟的同学可以参考这篇文章

内容就这么多, 希望对大家有所启发。

才疏学浅,如果错误, 欢迎指正, 谢谢。


皮小蛋
8k 声望12.8k 粉丝

积跬步,至千里。