[效能優化] 為虛擬列表增加離屏渲染和快取

皮小蛋發表於2022-04-14

image.png

背景

在之前的一篇關於無限滾動優化的文章中, 我們使用了虛擬列表來改善使用者體驗,並取得了不錯的效果。

本篇是後續,在虛擬列表中的圖片縮圖增加離屏渲染和壓縮快取的能力, 作為功能增強。

主要的處理:

  1. 增加一個用離屏渲染壓縮圖片的 Avatar 元件, 並替換原有的 Avatar 組建
  2. 增加了 LRU Cache 來快取壓縮過後的圖片
  3. 實驗性的加入 Web worker 防止壓縮圖片時主執行緒卡頓

下文主要是分享具體方法的實現, 希望對大家有所幫助。

正文

我們知道, 在虛擬列表中, 視區之外的節點是不會被渲染的, 如圖所示:

image.png

只有進入到視區, 或者設定的某個閾值, 比如上下一屏之內, 才會掛載和渲染, 以此來保持總的結點數在一個合理的範圍內, 以此較少瀏覽器的負擔, 縮短螢幕響應時間。

本地優化是針對列表中的圖片, 做了一些處理, 來提高處理效率。

image.png

詳細實現

頁面接入

// 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 不熟的同學可以參考這篇文章

內容就這麼多, 希望對大家有所啟發。

才疏學淺,如果錯誤, 歡迎指正, 謝謝。

相關文章