【React】1044- 如何设计一个好用的 React Image 组件?

系统管理员 2022-08-29 00:07 246阅读 0赞

7b6e0813748af747c45e9bbfafb0d65b.png

前言

本文为笔者阅读 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组件。

f5f2fa3247add50be3dd8f622a51ed10.gif

preview.gif

useImage

首先分析可复用的逻辑,可以发现使用者需要关注三个状态:loadingerror以及src,毕竟加载图片也是异步请求嘛。

对 react-use[3] 熟悉的同学会很容易联想到useAsync

自定义一个 hooks,接收图片链接作为参数,返回调用方需要的三个状态。

基础实现

  1. import * as React from "react";
  2. // 将图片加载转为promise调用形式
  3. function imgPromise(src: string) {
  4. return new Promise((resolve, reject) => {
  5. const i = new Image();
  6. i.onload = () => resolve();
  7. i.onerror = reject;
  8. i.src = src;
  9. });
  10. }
  11. function useImage({ src }: { src: string }): {
  12. src: string | undefined,
  13. isLoading: boolean,
  14. error: any,
  15. } {
  16. const [loading, setLoading] = React.useState(true);
  17. const [error, setError] = React.useState(null);
  18. const [value, setValue] = (React.useState < string) | (undefined > undefined);
  19. React.useEffect(() => {
  20. imgPromise(src)
  21. .then(() => {
  22. // 加载成功
  23. setLoading(false);
  24. setValue(src);
  25. })
  26. .catch((error) => {
  27. // 加载失败
  28. setLoading(false);
  29. setError(error);
  30. });
  31. }, [src]);
  32. return { isLoading: loading, src: value, error: error };
  33. }

我们已经完成了最基础的实现,现在来慢慢优化。

性能优化

对于同一张图片来讲,在组件 A 加载过的图片,组件 B 不用再走一遍new Image()的流程,直接返回上一次结果即可。

  1. + const cache: {
  2. + [key: string]: Promise<void>;
  3. + } = {};
  4. function useImage({
  5. src,
  6. }: {
  7. src: string;
  8. }): { src: string | undefined; isLoading: boolean; error: any } {
  9. const [loading, setLoading] = React.useState(true);
  10. const [error, setError] = React.useState(null);
  11. const [value, setValue] = React.useState<string | undefined>(undefined);
  12. React.useEffect(() => {
  13. + if (!cache[src]) {
  14. + cache[src] = imgPromise(src);
  15. + }
  16. - imgPromise(src)
  17. + cache[src]
  18. .then(() => {
  19. setLoading(false);
  20. setValue(src);
  21. })
  22. .catch(error => {
  23. setLoading(false);
  24. setError(error);
  25. });
  26. }, [src]);
  27. return { isLoading: loading, src: value, error: error };
  28. }

优化了一丢丢性能。

支持 srcList

上文提到过一点:图片加载失败,加载备选图片或展示error占位符。

展示error占位符我们可以通过error状态去控制,但是加载备选图片的功能还没有完成。

主要思路如下:

  1. 将入参src改为srcList,值为图片url或图片(含备选图片)的url数组;
  2. 从第一张开始加载,若失败则加载第二张,直到某一张成功或全部失败,流程结束。类似于 tapable[4] 的AsyncSeriesBailHook

对入参进行处理:

  1. const removeBlankArrayElements = (a: string[]) => a.filter((x) => x);
  2. const stringToArray = (x: string | string[]) => (Array.isArray(x) ? x : [x]);
  3. function useImage({ srcList }: { srcList: string | string[] }): {
  4. src: string | undefined,
  5. loading: boolean,
  6. error: any,
  7. } {
  8. // 获取url数组
  9. const sourceList = removeBlankArrayElements(stringToArray(srcList));
  10. // 获取用于缓存的键名
  11. const sourceKey = sourceList.join("");
  12. }

接下来就是重要的加载流程啦,定义promiseFind方法,用于完成以上加载图片的逻辑。

  1. /**
  2. * 注意 此处将imgPromise作为参数传入,而没有直接使用imgPromise
  3. * 主要是为了扩展性
  4. * 后面会将imgPromise方法作为一个参数由使用者传入,使得使用者加载图片的操作空间更大
  5. * 当然若使用者不传该参数,就是用默认的imgPromise方法
  6. */
  7. function promiseFind(
  8. sourceList: string[],
  9. imgPromise: (src: string) => Promise<void>
  10. ): Promise<string> {
  11. let done = false;
  12. // 重新使用Promise包一层
  13. return new Promise((resolve, reject) => {
  14. const queueNext = (src: string) => {
  15. return imgPromise(src).then(() => {
  16. done = true;
  17. // 加载成功 resolve
  18. resolve(src);
  19. });
  20. };
  21. const firstPromise = queueNext(sourceList.shift() || "");
  22. // 生成一条promise链[队列],每一个promise都跟着catch方法处理当前promise的失败
  23. // 从而继续下一个promise的处理
  24. sourceList
  25. .reduce((p, src) => {
  26. // 如果加载失败 继续加载
  27. return p.catch(() => {
  28. if (!done) return queueNext(src);
  29. return;
  30. });
  31. }, firstPromise)
  32. // 全都挂了 reject
  33. .catch(reject);
  34. });
  35. }

再来改动useImage

  1. const cache: {
  2. - [key: string]: Promise<void>;
  3. + [key: string]: Promise<string>;
  4. } = {};
  5. function useImage({
  6. - src,
  7. + srcList,
  8. }: {
  9. - src: string;
  10. + srcList: string | string[];
  11. }): { src: string | undefined; loading: boolean; error: any } {
  12. const [loading, setLoading] = React.useState(true);
  13. const [error, setError] = React.useState(null);
  14. const [value, setValue] = React.useState<string | undefined>(undefined);
  15. // 图片链接数组
  16. + const sourceList = removeBlankArrayElements(stringToArray(srcList));
  17. // cache唯一键名
  18. + const sourceKey = sourceList.join('');
  19. React.useEffect(() => {
  20. - if (!cache[src]) {
  21. - cache[src] = imgPromise(src);
  22. - }
  23. + if (!cache[sourceKey]) {
  24. + cache[sourceKey] = promiseFind(sourceList, imgPromise);
  25. + }
  26. - cache[src]
  27. - .then(() => {
  28. + cache[sourceKey]
  29. + .then((src) => {
  30. setLoading(false);
  31. setValue(src);
  32. })
  33. .catch(error => {
  34. setLoading(false);
  35. setError(error);
  36. });
  37. }, [src]);
  38. return { isLoading: loading, src: value, error: error };
  39. }

需要注意的一点:现在传入的图片链接可能不是单个src,最终设置的valuepromiseFind找到的src,所以 cache 类型定义也有变化。

30021bbd39fd96882b16601a74411342.png

react-image-1

自定义 imgPromise

前面提到过,加载图片过程中,使用方可能会插入自己的逻辑,所以将 imgPromise 方法作为可选参数loadImg传入,若使用者想自定义加载方法,可传入该参数。

  1. function useImage({
  2. + loadImg = imgPromise,
  3. srcList,
  4. }: {
  5. + loadImg?: (src: string) => Promise<void>;
  6. srcList: string | string[];
  7. }): { src: string | undefined; loading: boolean; error: any } {
  8. const [loading, setLoading] = React.useState(true);
  9. const [error, setError] = React.useState(null);
  10. const [value, setValue] = React.useState<string | undefined>(undefined);
  11. const sourceList = removeBlankArrayElements(stringToArray(srcList));
  12. const sourceKey = sourceList.join('');
  13. React.useEffect(() => {
  14. if (!cache[sourceKey]) {
  15. - cache[sourceKey] = promiseFind(sourceList, imgPromise);
  16. + cache[sourceKey] = promiseFind(sourceList, loadImg);
  17. }
  18. cache[sourceKey]
  19. .then(src => {
  20. setLoading(false);
  21. setValue(src);
  22. })
  23. .catch(error => {
  24. setLoading(false);
  25. setError(error);
  26. });
  27. }, [sourceKey]);
  28. return { loading: loading, src: value, error: error };
  29. }

实现 Img 组件

完成useImage后,我们就可以基于其实现 Img 组件了。

预先定义好相关 API:

属性 说明 类型 默认值 src 图片链接 string / string[] - loader 可选,加载过程占位元素 ReactNode null unloader 可选,加载失败占位元素 ReactNode null loadImg 可选,图片加载方法,返回一个 Promise (src:string)=>Promise imgPromise 当然,除了以上 API,还有<img />标签原生属性。编写类型声明文件如下:

  1. export type ImgProps = Omit<
  2. React.DetailedHTMLProps<
  3. React.ImgHTMLAttributes<HTMLImageElement>,
  4. HTMLImageElement
  5. >,
  6. "src"
  7. > &
  8. Omit<useImageParams, "srcList"> & {
  9. src: useImageParams["srcList"];
  10. loader?: JSX.Element | null;
  11. unloader?: JSX.Element | null;
  12. };

实现如下:

  1. export default ({
  2. src: srcList,
  3. loadImg,
  4. loader = null,
  5. unloader = null,
  6. ...imgProps
  7. }: ImgProps) => {
  8. const { src, loading, error } = useImage({
  9. srcList,
  10. loadImg,
  11. });
  12. if (src) return <img src={src} {...imgProps} />;
  13. if (loading) return loader;
  14. if (error) return unloader;
  15. return null;
  16. };

测试效果如下:

edd01e97d7264688d2b491cf0a4423ac.gif

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

6c62e0fcde5d5c751e91905653e137a3.gif

1. JavaScript 重温系列(22篇全)

2. ECMAScript 重温系列(10篇全)

3. JavaScript设计模式 重温系列(9篇全)

  1. 正则 / 框架 / 算法等 重温系列(16篇全)

  2. Webpack4 入门(上)|| Webpack4 入门(下)

  3. MobX 入门(上) || MobX 入门(下)

  4. 120+篇原创系列汇总

863da02ed3d22cff65416b4173b40359.gif

回复“加群”与大佬们一起交流学习~

点击“阅读原文”查看 120+ 篇原创文章

发表评论

表情:
评论列表 (有 0 条评论,246人围观)

还没有评论,来说两句吧...

相关阅读