
前言
本文为笔者阅读 react-image[1] 源码过程中的总结,若有所错漏烦请指出。✨ 仓库传送门[2] 作者:海秋 https://github.com/worldzhao/blog/issues/1
<img />可以说是开发过程中极其常用的标签了。但是很多同学都是<img src="xxx.png" />一把梭,直到 UI 小姐姐来找你谈谈人生理想:
loading占位符;error占位符。作为开发者的我们,可能会经历以下几个阶段:
img标签上使用onLoad以及onError进行处理;hooks,使用方自定义视图组件(当然也要提供基本组件);现在让我们直接从第三阶段开始,看看如何使用少量代码打造一个易用性、封装性以及扩展性俱佳的image组件。

preview.gif
首先分析可复用的逻辑,可以发现使用者需要关注三个状态:loading、error以及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 };
}
优化了一丢丢性能。
上文提到过一点:图片加载失败,加载备选图片或展示error占位符。
展示error占位符我们可以通过error状态去控制,但是加载备选图片的功能还没有完成。
主要思路如下:
src改为srcList,值为图片url或图片(含备选图片)的url数组;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,最终设置的value为promiseFind找到的src,所以 cache 类型定义也有变化。

react-image-1
前面提到过,加载图片过程中,使用方可能会插入自己的逻辑,所以将 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 };
}
完成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]
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