使用 React Hooks 实现 Image 组件

本文以实现一个 Image 组件为例,详细讲解如何从需求分析到利用 hooks 具体实现一个函数式组件。全文代码使用 TypeScript 编写,所以需要你了解一点相关知识。

1. 需求

在不考虑兼容的情况下,<img> 标签的功能已经非常丰富,不仅支持懒加载,还可以结合 srcsrcset 属性支持图片回退,甚至可以采用渐进式编码的图片代替图片占位。

现在,为了兼容性,我们定义 Image 组件的需求:

  • 支持图片占位;
  • 支持图片回退;
  • 兼容图片懒加载。

通过 React 实现的 Image 组件,势必与 <img> 标签的行为有些不同,但我们应该尽可能使它们的行为相同或类似,所以又有一些隐藏的需求:

  • 无多余的元素,保证组件只返回 <img> 元素;
  • 不添加多余的样式;
  • 透传 Image 组件多余的属性。
  • 转发 ref,将 Image 组件的 ref 直接应用于 <img> 元素。

此外,还有一条常常被忽略的需求:

  • 支持服务端渲染。

2. 分析

我们知道,比较先进的浏览器是原生支持图片懒加载的,只需设置 loading 属性的值为 "lazy" 即可。在不兼容的浏览器中,我们可以使用 IntersectionObserver 观察图片是否进入视窗来动态设置图片源以实现懒加载,为了区分原生懒加载,可称之为自定义懒加载。后文中出现的懒加载,若非特殊声明,皆指代自定义懒加载。

实现图片的占位、回退和懒加载,无非就是动态修改图片源属性:srcsrcSet,所以需要将这两个属性作为 Image 组件的状态。下面将从组件的三个执行阶段,首次渲染、挂载和更新,来分析如何修改这两个状态。

首次渲染,初始化 Image 组件的状态:

  • 如果是懒加载,srcsrcSet 的初始状态都应为 undefined
  • 否则,优先使用占位图片作为 src
  • 如果没有占位图片,且 Image 组件有 srcsrcSet 属性(真实的图片源),则直接将这两个属性作为状态;
  • 如果以上条件都不符合,则使用回退图片作为 src

组件挂载后:

  • 如果是懒加载,则使用 IntersectionObserver 观察 <img> 元素是否出现在视窗内;
  • 否则,判断当前是否使用占位图片,如果是,则立即预加载真实图片,在预加载完成后设置状态。

组件的更新有两种情况:

  • 组件属性更新;
  • 懒加载的图片出现在视窗内。

无论哪种更新条件,它们的更新逻辑大致相同:

  • 如果有占位图片,则使用占位图片作为 src,并立即预加载真实图片;
  • 如果没有占位图片,而有真实图片,则直接将真实图片作为状态;
  • 如果以上条件都不符合,则使用回退图片作为 src

3. 准备

在编写组件之前,我们可以做一些准备工作:浏览器环境判断、原生懒加载功能判断、IntersectionObserver 接口的使用和兼容、封装通用 hooks。

3.1 环境和功能判断

// 判断是否为浏览器环境。
const inBrowser = !!(
  typeof window !== "undefined" &&
  window.document &&
  window.document.createElement
);

// 判断是否支持原生懒加载。
const supportNativeLazyLoading = "loading" in HTMLImageElement.prototype;
复制代码

3.2 IntersectionObserver

IntersectionObserver 提供了一种异步检测目标元素与视窗相交变化的方法。使用方法如下:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // 判断是否相交。
    if (entry.isIntersecting) {
      // Do something.
    }
  });
});
// 监听目标元素。
observer.observe(target);
// 停止监听。
observer.disconnect();
复制代码

上面的例子只展示了本文需要使用到的功能,详细功能请查看使用文档接口文档

虽然 IntersectionObserver 的浏览器支持率已经很高了,但为了更好的兼容性,我们可以使用 intersection-observer 作为 polyfill。在文件的首部导入即可:

import "intersection-observer";
复制代码

3.3 Hooks

下面封装了实现 Image 组件需要用到的通用 hooks。

import {
  Ref,
  useRef,
  useEffect,
  ForwardedRef,
  EffectCallback,
  DependencyList,
  useLayoutEffect,
} from "react";

// 为了支持服务端渲染,根据执行环境不同而赋值的别名。
// 浏览器环境中使用 `useLayoutEffect`,服务端环境中使用 `useEffect`。
const useIsomorphicLayoutEffect = inBrowser ? useLayoutEffect : useEffect;

// 更新。
function useUpdate(effect: EffectCallback, deps?: DependencyList) {
  const mountedRef = useRef(false);
  
  useEffect(() => {
    if (mountedRef.current) {
      return effect();
    } else {
      mountedRef.current = true;
    }
  }, deps);
}

// 卸载。
function useUnmount(effect: () => void) {
  const effectRef = useRef(effect);
  effectRef.current = effect;
  
  useEffect(() => {
    return () => {
      effectRef.current();
    };
  }, []);
}

// 持久化回调函数。通过 `usePersist` 包装后返回的回调函数,其地址不会变,但执行的函数还是最新的。
function usePersist<T extends (...args: any[]) => any>(callback: T): T {
  const persistRef = useRef<T>();
  const callbackRef = useRef(callback)
  callbackRef.current = callback

  if (persistRef.current === undefined) {
    persistRef.current = function (this: any, ...args) {
      return callbackRef.current.apply(this, args);
    } as T;
  }

  return persistRef.current;
}

// 合并 Ref。
function useMergedRef<T>(...refs: (ForwardedRef<T> | undefined)[]): Ref<T> {
  return (instance: T) => {
    refs.forEach((ref) => {
      if (typeof ref === "function") {
        ref(instance);
      } else if (ref && "current" in ref) {
        ref.current = instance;
      }
    });
  };
}
复制代码

4. 实现

4.1 组件属性

  • 继承 <img> 元素的属性,并复用 loading 属性;
  • 增加占位图片 placeholder 属性;
  • 增加回退图片 fallback 属性;
  • 禁止给 Image 传递子组件。
import { ImgHTMLAttributes } from "react";

export interface ImageProps
  extends Omit<ImgHTMLAttributes<HTMLImageElement>, "children"> {
  fallback?: string;
  placeholder?: string;
}
复制代码

4.2 组件状态

除了需求分析中的图片源状态,还需要增加两个状态:altvisibility

  • alt:同 <img> 的属性。
  • visibility:控制可见性,不影响组件在文档中的宽高和位置。

这两个状态的目的是为了在懒加载时,避免图片发生尺寸坍缩,避免图片出现边框。原因在于,有 alt 无图片源时,<img> 表现为行内元素,会忽略宽高,从而导致懒加载有误差;即无 alt 又无图片源时,<img> 会出现边框。

interface ImageState {
  alt?: string;
  src?: string;
  srcSet?: string;
  visibility?: "hidden";
}
复制代码

4.3 组件模板

大多数函数式组件都可以采用下面的模板:

  • 转发 ref;
  • 透传多余属性;
  • 界定组件的属性、状态和输出。
const Image = forwardRef<HTMLImageElement, ImageProps>((props, ref) => {
  const {
    children,
    style,
    alt: altProp,
    src: srcProp,
    srcSet: srcSetProp,
    loading,
    
    // 下面四个属性会影响图片的加载和解析,预加载图片时会用到。
    sizes,
    decoding,
    crossOrigin,
    referrerPolicy,
    
    fallback,
    placeholder,
    onError,
    ...rest
  } = props;

  // 判断是否使用懒加载。
  const lazy = loading === "lazy";
  // 判断是否使用原生懒加载。有占位图片时,需要触发预加载动作,所以不能使用原生懒加载。
  const useNativeLazyLoading = lazy && supportNativeLazyLoading && !placeholder;
  // 判断是否使用自定义懒加载。
  const useCustomLazyLoading = lazy && inBrowser && !useNativeLazyLoading;
  // 判断是否有图片源,即是否有 `src` 和 `srcSet` 属性。
  const hasSource = !!srcProp || !!srcSetProp;

  const [state, setState] = useState<ImageState>(() => {
    // TODO: 初始状态
  });
  const { alt, src, srcSet, visibility } = state;

  // 监听 `<img>` 元素的错误,使用回退图片。
  function handleError(event: any) {
    if (fallback && src !== fallback) {
      setState({ alt: altProp, src: fallback });
    }
    if (typeof onError === "function") {
      onError(event);
    }
  }
  
  // `<img>` 元素的 ref,在相交监听时作为 target。
  const imageRef = useRef<HTMLImageElement>(null);
  // 合并 `imageRef` 和转发的 ref。
  const mergedRef = useMergedRef(imageRef, ref);

  return (
    <img
      {...rest}
      key={fallback}
      ref={mergedRef}
      style={{ visibility, ...style }}
      alt={alt}
      src={src}
      srcSet={srcSet}
      sizes={sizes}
      decoding={decoding}
      crossOrigin={crossOrigin}
      referrerPolicy={referrerPolicy}
      loading={lazy ? (useNativeLazyLoading ? "lazy" : undefined) : loading}
      onError={handleError}
    />
  );
});
复制代码

你可能注意到,上面将 fallback 属性作为了 <img> 元素的 key,这是为了防止 fallback 不更新的问题。试想一下,如果图片已经加载错误,且回退到了 fallback,没有 key 的情况下,只更新 fallback 属性,是不会再次触发 onError 事件,也就无法更新 fallback

4.4 初始状态

const [state, setState] = useState<ImageState>(() => {
  let alt: string | undefined;
  let src: string | undefined;
  let srcSet: string | undefined;
  let visibility: "hidden" | undefined;
  
  // 使用自定义懒加载,隐藏图片的边框。
  if (useCustomLazyLoading) {
    visibility = "hidden";
  } else {
    alt = altProp;
    
    // 优先使用占位图片。
    if (placeholder) {
      src = placeholder;
      
    // 次而使用真实图片。
    } else if (hasSource) {
      src = srcProp;
      srcSet = srcSetProp;
      
    // 最后使用回退图片。
    } else if (fallback) {
      src = fallback;
    }
  }
  
  return { alt, src, srcSet, visibility };
});
复制代码

4.5 图片预加载

图片预加载通过实例化一个 window.Image,设置相关属性,监听 load 事件,并更新组件的状态。需要注意的是,如果浏览器已经缓存了此图片,则可以立即更新组件的状态,而不必监听 load 事件。

// 预加载图片实例的 ref。
const preloadRef = useRef<HTMLImageElement>();

// 清理图片预加载。
const clearPreload = usePersist(() => {
  if (preloadRef.current) {
    // 将 `src` 和 `srcset` 设置为空字符串,可以告知浏览器停止加载图片。
    preloadRef.current.src = "";
    preloadRef.current.srcset = "";
    // 防止意外更新组件的状态。
    preloadRef.current.onload = null;
    // 删除实例。
    preloadRef.current = undefined;
  }
});

// 图片预加载。如果图片已经缓存则立即更新组件状态并返回 true,否则监听 `load` 事件返回 false。
// 如果此函数返回了 true,就没有必要设置占位图片了。
const preloadSource = usePersist(() => {
  // 清理上一次图片预加载。
  clearPreload();
  
  if (inBrowser && hasSource) {
    preloadRef.current = new window.Image();
    
    // 下面四个属性会影响图片的加载和解析。
    if (sizes !== undefined) {
      preloadRef.current.sizes = sizes;
    }
    if (decoding !== undefined) {
      preloadRef.current.decoding = decoding;
    }
    if (crossOrigin !== undefined) {
      preloadRef.current.crossOrigin = crossOrigin;
    }
    if (referrerPolicy !== undefined) {
      preloadRef.current.referrerPolicy = referrerPolicy;
    }
    
    // 设置图片源。
    if (srcProp) {
      preloadRef.current.src = srcProp;
    }
    if (srcSetProp) {
      preloadRef.current.srcset = srcSetProp;
    }
    
    // 如果图片已经缓存,则直接更新状态。
    if (preloadRef.current.complete) {
      setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
      return true;
      
    // 否则监听 `load` 事件。
    } else {
      preloadRef.current.onload = () => {
        clearPreload();
        setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
      };
    }
  } else {
    setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
    return true;
  }
  return false;
});
复制代码

4.6 更新逻辑

const updateSource = usePersist(() => {
  // 清理之前的图片预加载。
  clearPreload();
  
  if (placeholder) {
    // 如果图片未缓存,才设置占位图片。
    if (!hasSource || !preloadSource()) {
      setState({ alt: altProp, src: placeholder });
    }
  } else if (hasSource) {
    setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
  } else if (fallback) {
    setState({ alt: altProp, src: fallback });
  }
});
复制代码

4.7 图片与视窗相交监听

只有自定义懒加载时才会开启相交监听。在已经设置了真实图片后,需要停止监听。

// 相交监听器 ref。
const observerRef = useRef<IntersectionObserver>();

// 清理监听器。
const clearObserver = usePersist(() => {
  if (observerRef.current) {
    observerRef.current.disconnect();
    observerRef.current = undefined;
  }
});

// 监听回调。
const handleIntersect = usePersist((entries: IntersectionObserverEntry[]) => {
  const entry = entries && entries[0];
  if (entry && entry.isIntersecting) {
    if (observerRef.current) {
      observerRef.current.disconnect(); // 相交事件触发后停止监听
    }
    updateSource();
  }
});

// 只有在自定义懒加载时才开启相交监听。
if (!observerRef.current && useCustomLazyLoading) {
  observerRef.current = new IntersectionObserver(handleIntersect);
}
复制代码

4.8 挂载

在浏览器环境中使用 useLayoutEffect,组件挂载后会立即检查图片是否缓存,如果图片已经缓存,则还有机会在界面渲染之前更新图片,这样就避免了闪动。

useIsomorphicLayoutEffect(() => {
  // 如果使用懒加载,则监听相交事件。
  if (useCustomLazyLoading && imageRef.current && observerRef.current) {
    observerRef.current.observe(imageRef.current);
    
  // 如果当前使用占位图片,立即执行图片预加载。
  } else if (src === placeholder && hasSource) {
    preloadSource();
  }
}, []);
复制代码

4.9 更新

// 自定义懒加载的标志变化后,重新实例化相交监听器。
useUpdate(() => {
  clearObserver();
  if (useCustomLazyLoading) {
    observerRef.current = new IntersectionObserver(handleIntersect);
  }
}, [useCustomLazyLoading]);

// 图片资源更新后,根据条件判断是执行相交监听,还是直接执行更新逻辑。
useUpdate(() => {
  if (useCustomLazyLoading && imageRef.current && observerRef.current) {
    observerRef.current.disconnect();
    observerRef.current.observe(imageRef.current);
  } else {
    updateSource();
  }
}, [srcProp, srcSetProp, fallback, placeholder, useCustomLazyLoading]);
复制代码

4.10 卸载

  • 清理图片预加载。
  • 清理相交监听器。
useUnmount(() => {
  clearPreload();
  clearObserver();
});
复制代码

4.11 合并代码

import "intersection-observer";
import React, {
  Ref,
  useRef,
  useState,
  useEffect,
  forwardRef,
  ForwardedRef,
  EffectCallback,
  DependencyList,
  useLayoutEffect,
  ImgHTMLAttributes,
} from "react";

// 判断是否为浏览器环境。
const inBrowser = !!(
  typeof window !== "undefined" &&
  window.document &&
  window.document.createElement
);

// 判断是否支持原生懒加载。
const supportNativeLazyLoading = "loading" in HTMLImageElement.prototype;

// 为了支持服务端渲染,根据执行环境不同而赋值的别名。
// 浏览器环境中使用 `useLayoutEffect`,服务端环境中使用 `useEffect`。
const useIsomorphicLayoutEffect = inBrowser ? useLayoutEffect : useEffect;

// 更新。
function useUpdate(effect: EffectCallback, deps?: DependencyList) {
  const mountedRef = useRef(false);

  useEffect(() => {
    if (mountedRef.current) {
      return effect();
    } else {
      mountedRef.current = true;
    }
  }, deps);
}

// 卸载。
function useUnmount(effect: () => void) {
  const effectRef = useRef(effect);
  effectRef.current = effect;

  useEffect(() => {
    return () => {
      effectRef.current();
    };
  }, []);
}

// 持久化回调函数。通过 `usePersist` 包装后返回的回调函数,其地址不会变,但执行的函数还是最新的。
function usePersist<T extends (...args: any[]) => any>(callback: T): T {
  const persistRef = useRef<T>();
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  if (persistRef.current === undefined) {
    persistRef.current = function (this: any, ...args) {
      return callbackRef.current.apply(this, args);
    } as T;
  }

  return persistRef.current;
}

// 合并 Ref。
function useMergedRef<T>(...refs: (ForwardedRef<T> | undefined)[]): Ref<T> {
  return (instance: T) => {
    refs.forEach((ref) => {
      if (typeof ref === "function") {
        ref(instance);
      } else if (ref && "current" in ref) {
        ref.current = instance;
      }
    });
  };
}

// 组件属性。
export interface ImageProps
  extends Omit<ImgHTMLAttributes<HTMLImageElement>, "children"> {
  fallback?: string;
  placeholder?: string;
}

// 组件状态。
interface ImageState {
  alt?: string;
  src?: string;
  srcSet?: string;
  visibility?: "hidden";
}

const Image = forwardRef<HTMLImageElement, ImageProps>((props, ref) => {
  const {
    children,
    style,
    alt: altProp,
    src: srcProp,
    srcSet: srcSetProp,
    loading,

    // 下面四个属性会影响图片的加载和解析,预加载图片时会用到。
    sizes,
    decoding,
    crossOrigin,
    referrerPolicy,

    fallback,
    placeholder,
    onError,
    ...rest
  } = props;

  // 判断是否使用懒加载。
  const lazy = loading === "lazy";
  // 判断是否使用原生懒加载。有占位图片时,需要触发预加载动作,所以不能使用原生懒加载。
  const useNativeLazyLoading = lazy && supportNativeLazyLoading && !placeholder;
  // 判断是否使用自定义懒加载。
  const useCustomLazyLoading = lazy && inBrowser && !useNativeLazyLoading;
  // 判断是否有图片源,即是否有 `src` 和 `srcSet` 属性。
  const hasSource = !!srcProp || !!srcSetProp;

  const [state, setState] = useState<ImageState>(() => {
    let alt: string | undefined;
    let src: string | undefined;
    let srcSet: string | undefined;
    let visibility: "hidden" | undefined;

    // 使用自定义懒加载,隐藏图片的边框。
    if (useCustomLazyLoading) {
      visibility = "hidden";
    } else {
      alt = altProp;

      // 优先使用占位图片。
      if (placeholder) {
        src = placeholder;

        // 次而使用真实图片源。
      } else if (hasSource) {
        src = srcProp;
        srcSet = srcSetProp;

        // 最后使用回退图片。
      } else if (fallback) {
        src = fallback;
      }
    }

    return { alt, src, srcSet, visibility };
  });
  const { alt, src, srcSet, visibility } = state;

  // 监听 `<img>` 元素的错误,使用回退图片。
  function handleError(event: any) {
    if (fallback && src !== fallback) {
      setState({ alt: altProp, src: fallback });
    }
    if (typeof onError === "function") {
      onError(event);
    }
  }

  // `<img>` 元素的 ref,在相交监听时作为 target。
  const imageRef = useRef<HTMLImageElement>(null);
  // 合并 `imageRef` 和转发的 ref。
  const mergedRef = useMergedRef(imageRef, ref);
  // 预加载图片实例的 ref。
  const preloadRef = useRef<HTMLImageElement>();
  // 相交监听器 ref。
  const observerRef = useRef<IntersectionObserver>();

  // 清理图片预加载。
  const clearPreload = usePersist(() => {
    if (preloadRef.current) {
      // 将 `src` 和 `srcset` 设置为空字符串,可以告知浏览器停止加载图片。
      preloadRef.current.src = "";
      preloadRef.current.srcset = "";
      // 防止意外更新组件的状态。
      preloadRef.current.onload = null;
      // 删除实例。
      preloadRef.current = undefined;
    }
  });

  // 清理监听器。
  const clearObserver = usePersist(() => {
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = undefined;
    }
  });

  // 图片预加载。如果图片已经缓存则立即更新组件状态并返回 true,否则监听 `load` 事件返回 false。
  // 如果此函数返回了 true,就没有必要设置占位图片了。
  const preloadSource = usePersist(() => {
    // 清理上一次图片预加载。
    clearPreload();

    if (inBrowser && hasSource) {
      preloadRef.current = new window.Image();

      // 下面四个属性会影响图片的加载和解析。
      if (sizes !== undefined) {
        preloadRef.current.sizes = sizes;
      }
      if (decoding !== undefined) {
        preloadRef.current.decoding = decoding;
      }
      if (crossOrigin !== undefined) {
        preloadRef.current.crossOrigin = crossOrigin;
      }
      if (referrerPolicy !== undefined) {
        preloadRef.current.referrerPolicy = referrerPolicy;
      }

      // 设置图片源。
      if (srcProp) {
        preloadRef.current.src = srcProp;
      }
      if (srcSetProp) {
        preloadRef.current.srcset = srcSetProp;
      }

      // 如果图片已经缓存,则直接更新状态。
      if (preloadRef.current.complete) {
        setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
        return true;

        // 否则监听 `load` 事件。
      } else {
        preloadRef.current.onload = () => {
          clearPreload();
          setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
        };
      }
    } else {
      setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
      return true;
    }
    return false;
  });

  const updateSource = usePersist(() => {
    // 清理之前的图片预加载。
    clearPreload();

    if (placeholder) {
      // 如果图片未缓存,才设置占位图片。
      if (!hasSource || !preloadSource()) {
        setState({ alt: altProp, src: placeholder });
      }
    } else if (hasSource) {
      setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
    } else if (fallback) {
      setState({ alt: altProp, src: fallback });
    }
  });

  // 监听回调。
  const handleIntersect = usePersist((entries: IntersectionObserverEntry[]) => {
    const entry = entries && entries[0];
    if (entry && entry.isIntersecting) {
      if (observerRef.current) {
        observerRef.current.disconnect(); // 相交事件触发后停止监听
      }
      updateSource();
    }
  });

  // 只有在自定义懒加载时才开启相交监听。
  if (!observerRef.current && useCustomLazyLoading) {
    observerRef.current = new IntersectionObserver(handleIntersect);
  }

  // 挂载。
  useIsomorphicLayoutEffect(() => {
    // 如果使用懒加载,则监听相交事件。
    if (useCustomLazyLoading && imageRef.current && observerRef.current) {
      observerRef.current.observe(imageRef.current);

      // 如果当前使用占位图片,立即执行图片预加载。
    } else if (src === placeholder && hasSource) {
      preloadSource();
    }
  }, []);

  // 自定义懒加载的标志变化后,重新实例化相交监听器。
  useUpdate(() => {
    clearObserver();
    if (useCustomLazyLoading) {
      observerRef.current = new IntersectionObserver(handleIntersect);
    }
  }, [useCustomLazyLoading]);

  // 图片资源更新后,根据条件判断是执行相交监听,还是直接执行更新逻辑。
  useUpdate(() => {
    if (useCustomLazyLoading && imageRef.current && observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current.observe(imageRef.current);
    } else {
      updateSource();
    }
  }, [srcProp, srcSetProp, fallback, placeholder, useCustomLazyLoading]);

  // 卸载。
  useUnmount(() => {
    clearPreload();
    clearObserver();
  });

  return (
    <img
      {...rest}
      key={fallback}
      ref={mergedRef}
      style={{ visibility, ...style }}
      alt={alt}
      src={src}
      srcSet={srcSet}
      sizes={sizes}
      decoding={decoding}
      crossOrigin={crossOrigin}
      referrerPolicy={referrerPolicy}
      loading={lazy ? (useNativeLazyLoading ? "lazy" : undefined) : loading}
      onError={handleError}
    />
  );
});

export default Image;
复制代码

猜你喜欢

转载自juejin.im/post/7041860384243843085