【React】1044- 如何設計一個好用的 React Image 組件?



前言

本文爲筆者閱讀 react-image[1] 源碼過程中的總結,若有所錯漏煩請指出。✨ 倉庫傳送門[2]

作者:海秋

https://github.com/worldzhao/blog/issues/1

<img />可以說是開發過程中極其常用的標籤了。但是很多同學都是<img src="xxx.png" />一把梭,直到 UI 小姐姐來找你談談人生理想:

  1. 圖片加載太慢,需要展示 loading佔位符;
  2. 圖片加載失敗,加載備選圖片或展示 error佔位符。

作爲開發者的我們,可能會經歷以下幾個階段:

  • 第一階段: img標籤上使用 onLoad以及 onError進行處理;
  • 第二階段:寫一個較爲通用的組件;
  • 第三階段:抽離 hooks,使用方自定義視圖組件(當然也要提供基本組件);

現在讓我們直接從第三階段開始,看看如何使用少量代碼打造一個易用性、封裝性以及擴展性俱佳的image組件。

preview.gif

useImage

首先分析可複用的邏輯,可以發現使用者需要關注三個狀態:loadingerror以及src,畢竟加載圖片也是異步請求嘛。

react-use[3] 熟悉的同學會很容易聯想到useAsync

自定義一個 hooks,接收圖片鏈接作爲參數,返回調用方需要的三個狀態。

基礎實現

import * as React from "react";

// 將圖片加載轉爲promise調用形式
function imgPromise(src: string{
  return new Promise((resolve, reject) => {
    const i = new Image();
    i.onload = () => resolve();
    i.onerror = reject;
    i.src = src;
  });
}

function useImage({ src }: { src: string }): {
  src: string | undefined,
  isLoading: boolean,
  error: any,
} {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [value, setValue] = (React.useState < string) | (undefined > undefined);

  React.useEffect(() => {
    imgPromise(src)
      .then(() => {
        // 加載成功
        setLoading(false);
        setValue(src);
      })
      .catch((error) => {
        // 加載失敗
        setLoading(false);
        setError(error);
      });
  }, [src]);

  return { isLoading: loading, src: value, error: error };
}

我們已經完成了最基礎的實現,現在來慢慢優化。

性能優化

對於同一張圖片來講,在組件 A 加載過的圖片,組件 B 不用再走一遍new Image()的流程,直接返回上一次結果即可。

+ const cache: {
+  [key: string]: Promise<void>;
+ } = {};

function useImage({
  src,
}: {
  src: string;
}): { src: string | undefined; isLoading: boolean; error: any } {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [value, setValue] = React.useState<string | undefined>(undefined);

  React.useEffect(() => {
+   if (!cache[src]) {
+     cache[src] = imgPromise(src);
+   }

-   imgPromise(src)
+   cache[src]
      .then(() => {
        setLoading(false);
        setValue(src);
      })
      .catch(error => {
        setLoading(false);
        setError(error);
      });
  }, [src]);

  return { isLoading: loading, src: value, error: error };
}

優化了一丟丟性能。

支持 srcList

上文提到過一點:圖片加載失敗,加載備選圖片或展示error佔位符。

展示error佔位符我們可以通過error狀態去控制,但是加載備選圖片的功能還沒有完成。

主要思路如下:

  1. 將入參 src改爲 srcList,值爲圖片 url或圖片(含備選圖片)的 url數組;
  2. 從第一張開始加載,若失敗則加載第二張,直到某一張成功或全部失敗,流程結束。類似於 tapable [4]AsyncSeriesBailHook

對入參進行處理:

const removeBlankArrayElements = (a: string[]) => a.filter((x) => x);

const stringToArray = (x: string | string[]) => (Array.isArray(x) ? x : [x]);

function useImage({ srcList }: { srcList: string | string[] }): {
  src: string | undefined,
  loading: boolean,
  error: any,
} {
  // 獲取url數組
  const sourceList = removeBlankArrayElements(stringToArray(srcList));
  // 獲取用於緩存的鍵名
  const sourceKey = sourceList.join("");
}

接下來就是重要的加載流程啦,定義promiseFind方法,用於完成以上加載圖片的邏輯。

/**
 * 注意 此處將imgPromise作爲參數傳入,而沒有直接使用imgPromise
 * 主要是爲了擴展性
 * 後面會將imgPromise方法作爲一個參數由使用者傳入,使得使用者加載圖片的操作空間更大
 * 當然若使用者不傳該參數,就是用默認的imgPromise方法
 */

function promiseFind(
  sourceList: string[],
  imgPromise: (src: string
) => Promise<void>
): Promise<string
{
  let done = false;
  // 重新使用Promise包一層
  return new Promise((resolve, reject) => {
    const queueNext = (src: string) => {
      return imgPromise(src).then(() => {
        done = true;
        // 加載成功 resolve
        resolve(src);
      });
    };

    const firstPromise = queueNext(sourceList.shift() || "");

    // 生成一條promise鏈[隊列],每一個promise都跟着catch方法處理當前promise的失敗
    // 從而繼續下一個promise的處理
    sourceList
      .reduce((p, src) => {
        // 如果加載失敗 繼續加載
        return p.catch(() => {
          if (!done) return queueNext(src);
          return;
        });
      }, firstPromise)
      // 全都掛了 reject
      .catch(reject);
  });
}

再來改動useImage

const cache: {
-  [key: string]: Promise<void>;
+  [key: string]: Promise<string>;
} = {};

function useImage({
-  src,
+  srcList,
}: {
- src: string;
+ srcList: string | string[];
}): { src: string | undefined; loading: boolean; error: any } {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [value, setValue] = React.useState<string | undefined>(undefined);

// 圖片鏈接數組
+ const sourceList = removeBlankArrayElements(stringToArray(srcList));
// cache唯一鍵名
+ const sourceKey = sourceList.join('');

  React.useEffect(() => {
-   if (!cache[src]) {
-     cache[src] = imgPromise(src);
-   }

+   if (!cache[sourceKey]) {
+     cache[sourceKey] = promiseFind(sourceList, imgPromise);
+   }

-    cache[src]
-    .then(() => {
+    cache[sourceKey]
+     .then((src) => {
        setLoading(false);
        setValue(src);
      })
      .catch(error => {
        setLoading(false);
        setError(error);
      });
  }, [src]);

  return { isLoading: loading, src: value, error: error };
}

需要注意的一點:現在傳入的圖片鏈接可能不是單個src,最終設置的valuepromiseFind找到的src,所以 cache 類型定義也有變化。

react-image-1

自定義 imgPromise

前面提到過,加載圖片過程中,使用方可能會插入自己的邏輯,所以將 imgPromise 方法作爲可選參數loadImg傳入,若使用者想自定義加載方法,可傳入該參數。

function useImage({
+ loadImg = imgPromise,
  srcList,
}: {
+ loadImg?: (src: string) => Promise<void>;
  srcList: string | string[];
}): { src: string | undefined; loading: boolean; error: any } {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [value, setValue] = React.useState<string | undefined>(undefined);

  const sourceList = removeBlankArrayElements(stringToArray(srcList));
  const sourceKey = sourceList.join('');

  React.useEffect(() => {
    if (!cache[sourceKey]) {
-     cache[sourceKey] = promiseFind(sourceList, imgPromise);
+     cache[sourceKey] = promiseFind(sourceList, loadImg);
    }

    cache[sourceKey]
      .then(src => {
        setLoading(false);
        setValue(src);
      })
      .catch(error => {
        setLoading(false);
        setError(error);
      });
  }, [sourceKey]);

  return { loading: loading, src: value, error: error };
}

實現 Img 組件

完成useImage後,我們就可以基於其實現 Img 組件了。

預先定義好相關 API:

屬性 說明 類型 默認值 src 圖片鏈接 string / string[] - loader 可選,加載過程佔位元素 ReactNode null unloader 可選,加載失敗佔位元素 ReactNode null loadImg 可選,圖片加載方法,返回一個 Promise (src:string)=>Promise imgPromise 當然,除了以上 API,還有<img />標籤原生屬性。編寫類型聲明文件如下:

export type ImgProps = Omit<
  React.DetailedHTMLProps<
    React.ImgHTMLAttributes<HTMLImageElement>,
    HTMLImageElement
  >,
  "src"
> &
  Omit<useImageParams, "srcList"> & {
    src: useImageParams["srcList"];
    loader?: JSX.Element | null;
    unloader?: JSX.Element | null;
  };

實現如下:

export default ({
  src: srcList,
  loadImg,
  loader = null,
  unloader = null,
  ...imgProps
}: ImgProps) => {
  const { src, loading, error } = useImage({
    srcList,
    loadImg,
  });

  if (src) return <img src={src} {...imgProps} />;
  if (loading) return loader;
  if (error) return unloader;

  return null;
};

測試效果如下:

react-image-2

結語

值得注意的是,本文遵循 react-image 大體思路,但部分內容暫未實現(所以代碼可讀性要好一點)。其它特性,如:

  1. 支持 Suspense 形式調用;
  2. 默認在渲染圖片前會進行 decode,避免頁面卡頓或者閃爍。

有興趣的同學可以看看下面這些文章:

  • 用於數據獲取的 Suspense(試驗階段) [5]
  • 錯誤邊界(Error Boundaries) [6]
  • React:Suspense 的實現與探討 [7]
  • HTMLImageElement.decode() [8]
  • Chrome 圖片解碼與 Image.decode API [9]

參考資料

[1]

react-image: https://github.com/mbrevda/react-image

[2]

✨ 倉庫傳送門: https://github.com/worldzhao/build-your-own-react-image

[3]

react-use: https://github.com/streamich/react-use

[4]

tapable: https://github.com/webpack/tapable

[5]

用於數據獲取的 Suspense(試驗階段): https://zh-hans.reactjs.org/docs/concurrent-mode-suspense.html

[6]

錯誤邊界(Error Boundaries): https://zh-hans.reactjs.org/docs/error-boundaries.html#introducing-error-boundaries

[7]

React:Suspense 的實現與探討: https://zhuanlan.zhihu.com/p/34210780

[8]

HTMLImageElement.decode(): https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLImageElement/decode

[9]

Chrome 圖片解碼與 Image.decode API: https://zhuanlan.zhihu.com/p/43991630



1. JavaScript 重溫系列(22篇全)
2. ECMAScript 重溫系列(10篇全)
3. JavaScript設計模式 重溫系列(9篇全)
4.  正則 / 框架 / 算法等 重溫系列(16篇全)
5.  Webpack4 入門(上) ||  Webpack4 入門(下)
6.  MobX 入門(上)  ||   MobX 入門(下)
7. 120 +篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看 120+ 篇原創文章

本文分享自微信公衆號 - 前端自習課(FE-study)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章