This article explains how to implement a functional component using hooks from requirements analysis to implementing an Image component as an example. Full-text code is written in TypeScript, so a little bit of knowledge is required.

1. The demand

Without regard to compatibility, the < IMG > tag is already rich in functionality, including lazy loading, image rollback in combination with the SRC and SRcset attributes, and even the use of progressively encoded images instead of image placeholders.

Now, for compatibility, we define the requirements for the Image component:

  • Support image placeholder;
  • Support image rollback;
  • Compatible with lazy loading of pictures.

The React Image component is bound to behave a little differently than the tag, but we should try to make them behave the same or similar as possible, so there are some hidden requirements:

  • No extra elements, ensuring that the component only returns<img>Elements;
  • Don’t add extra styles;
  • Pass through excess properties of the Image component.
  • Forward the ref to the Image componentrefApply directly to<img>Elements.

In addition, there is another requirement that is often overlooked:

  • Support for server-side rendering.

2. The analysis

As we know, the most advanced browsers support lazy loading of images natively. Just set the loading property to “lazy”. In incompatible browsers, IntersectionObserver can be used to observe whether an image enters the window and dynamically set the image source for lazy loading. In order to distinguish the original lazy loading, it can be called custom lazy loading. Lazy loading, unless specified, refers to custom lazy loading.

To implement placeholder, rollback, and lazy loading of an Image, you simply need to dynamically modify the Image source properties: SRC and srcSet, so these two properties need to be the state of the Image component. The following will examine how to modify these two states from the three execution phases of the component: first render, mount, and update.

Initialize the state of the Image component for the first rendering:

  • If it’s lazy loading,srcsrcSetIs the initial state ofundefined;
  • Otherwise, placeholder images are preferredsrc;
  • If there is no placeholder Image and the Image component doessrcsrcSetProperty (real image source), these two properties are directly used as states;
  • If none of the above conditions are met, a rollback image is usedsrc.

After the component is mounted:

  • If lazy loading is usedIntersectionObserverTo observe the<img>Whether the element appears in the window;
  • Otherwise, determine whether a placeholder image is currently used, if so, preload the real image immediately, and set the status after the preload is complete.

Component updates can occur in two ways:

  • Component properties update;
  • Lazily loaded images appear in the window.

Regardless of which update condition, the update logic is roughly the same:

  • If there is a placeholder image, use it as a placeholder imagesrc, and immediately preload real images;
  • If there is no placeholder image and there is a real image, the real image is directly used as the state;
  • If none of the above conditions are met, a rollback image is usedsrc.

3. Prepare

Before writing the component, we can do some preparatory work: browser environment judgment, native lazy loading function judgment, use and compatibility of IntersectionObserver interface, encapsulation of generic hooks.

3.1 Environment and function judgment

// Check whether it is a browser environment.
constinBrowser = !! (typeof window! = ="undefined" &&
  window.document &&
  window.document.createElement
);

// Check whether native lazy loading is supported.
const supportNativeLazyLoading = "loading" in HTMLImageElement.prototype;
Copy the code

3.2 IntersectionObserver

IntersectionObserver provides a method for asynchronously detecting the intersection changes between target elements and Windows. The usage method is as follows:

const observer = new IntersectionObserver((entries) = > {
  entries.forEach(entry= > {
    // Determine whether to intersect.
    if (entry.isIntersecting) {
      // Do something.}}); });// Listen on the target element.
observer.observe(target);
// Stop listening.
observer.disconnect();
Copy the code

The examples above show only the functions required in this article. See the usage documentation and interface documentation for details.

IntersectionObserver browsers have already received high approval, but we can use intersection-Observer as a polyfill for better compatibility. Import at the head of the file:

import "intersection-observer";
Copy the code

3.3 Hooks

The following encapsulates the generic hooks needed to implement the Image component.

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

// To support server-side rendering, the alias is assigned according to the execution environment.
// Use 'useLayoutEffect' in browser environment and 'useEffect' in server environment.
const useIsomorphicLayoutEffect = inBrowser ? useLayoutEffect : useEffect;

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

/ / unloading.
function useUnmount(effect: () => void) {
  const effectRef = useRef(effect);
  effectRef.current = effect;
  
  useEffect(() = > {
    return () = >{ effectRef.current(); }; } []); }// Persist the callback function. Return a callback wrapped with 'usePersist' whose address remains the same, but the function executed is the latest.
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;
}

// merge 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" inref) { ref.current = instance; }}); }; }Copy the code

4. To achieve

4.1 Component Properties

  • inheritance<img>Element, and reuse itloadingProperties;
  • Add placeholder imagesplaceholderProperties;
  • Add a rollback picturefallbackProperties;
  • Disallow passing child components to Image.
import { ImgHTMLAttributes } from "react";

export interface ImageProps
  extendsOmit<ImgHTMLAttributes<HTMLImageElement>, "children"> { fallback? :string; placeholder? :string;
}
Copy the code

4.2 Component Status

In addition to the image source state in requirements analysis, two additional states need to be added: Alt and Visibility.

  • altWith:<img>Properties.
  • visibility: Controls visibility without affecting the width, height and position of components in the document.

The purpose of these two states is to avoid image size collapse and border appearance during lazy loading. The reason is that, with Alt and no image source, appears as an inline element, and the width and height will be ignored, resulting in lazy loading errors. will have a border if there is no Alt and no image source.

interfaceImageState { alt? :string; src? :string; srcSet? :string; visibility? :"hidden";
}
Copy the code

4.3 Component Templates

Most functional components can use the following template:

  • Forwarding ref;
  • Pass-through redundant attributes;
  • Define the properties, state, and output of a component.
const Image = forwardRef<HTMLImageElement, ImageProps>((props, ref) = > {
  const {
    children,
    style,
    alt: altProp,
    src: srcProp,
    srcSet: srcSetProp,
    loading,
    
    // The following four attributes affect the loading and parsing of images, which are used when preloading images.sizes, decoding, crossOrigin, referrerPolicy, fallback, placeholder, onError, ... rest } = props;// Check whether lazy loading is used.
  const lazy = loading === "lazy";
  // Determine whether to use native lazy loading. When there is a placeholder image, the preload action needs to be triggered, so you cannot use native lazy loading.
  constuseNativeLazyLoading = lazy && supportNativeLazyLoading && ! placeholder;// Determine whether to use custom lazy loading.
  constuseCustomLazyLoading = lazy && inBrowser && ! useNativeLazyLoading;// Check whether there is an image source, i.e. whether there are 'SRC' and 'srcSet' attributes.
  consthasSource = !! srcProp || !! srcSetProp;const [state, setState] = useState<ImageState>(() = > {
    // TODO:The initial state
  });
  const { alt, src, srcSet, visibility } = state;

  // Listen for an error in the '' element, using the fallback image.
  function handleError(event: any) {
    if(fallback && src ! == fallback) { setState({alt: altProp, src: fallback });
    }
    if (typeof onError === "function") { onError(event); }}// the ref of the '' element, which is used as the target for intersection listening.
  const imageRef = useRef<HTMLImageElement>(null);
  // merge 'imageRef' and forward 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}
    />
  );
});
Copy the code

You may have noticed that the fallback attribute is used as the key of the element, to prevent fallbacks from not being updated. For example, if the image has been loaded incorrectly and the image has fallen back to fallback, updating the fallback attribute without the key will not trigger the onError event again and will not update the fallback.

4.4 Initial Status

const [state, setState] = useState<ImageState>(() = > {
  let alt: string | undefined;
  let src: string | undefined;
  let srcSet: string | undefined;
  let visibility: "hidden" | undefined;
  
  // Use custom lazy loading to hide the border of the image.
  if (useCustomLazyLoading) {
    visibility = "hidden";
  } else {
    alt = altProp;
    
    // Use placeholder images first.
    if (placeholder) {
      src = placeholder;
      
    // Use real images.
    } else if (hasSource) {
      src = srcProp;
      srcSet = srcSetProp;
      
    // Finally use the rollback image.
    } else if(fallback) { src = fallback; }}return { alt, src, srcSet, visibility };
});
Copy the code

4.5 Image preloading

Image preloading is done by instantiating a window.Image, setting properties, listening for load events, and updating the state of the component. Note that if the browser already caches this image, the component’s state can be updated immediately without listening for the LOAD event.

// Preload the image instance ref.
const preloadRef = useRef<HTMLImageElement>();

// Clear image preloading.
const clearPreload = usePersist(() = > {
  if (preloadRef.current) {
    // Set 'SRC' and 'srcset' to empty strings to tell the browser to stop loading images.
    preloadRef.current.src = "";
    preloadRef.current.srcset = "";
    // Prevent accidental updating of component state.
    preloadRef.current.onload = null;
    // Delete the instance.
    preloadRef.current = undefined; }});// Images are preloaded. The component state is updated immediately and returns true if the image is already cached, otherwise the listening 'load' event returns false.
// If this function returns true, there is no need to set a placeholder image.
const preloadSource = usePersist(() = > {
  // Clean up the last image preload.
  clearPreload();
  
  if (inBrowser && hasSource) {
    preloadRef.current = new window.Image();
    
    // The following four attributes affect image loading and parsing.
    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;
    }
    
    // Set the image source.
    if (srcProp) {
      preloadRef.current.src = srcProp;
    }
    if (srcSetProp) {
      preloadRef.current.srcset = srcSetProp;
    }
    
    // If the image is already cached, update the status directly.
    if (preloadRef.current.complete) {
      setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
      return true;
      
    // Otherwise listen for 'load' events.
    } else {
      preloadRef.current.onload = () = > {
        clearPreload();
        setState({ alt: altProp, src: srcProp, srcSet: srcSetProp }); }; }}else {
    setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
    return true;
  }
  return false;
});
Copy the code

4.6 Updating Logic

const updateSource = usePersist(() = > {
  // Clean up the previous image preloading.
  clearPreload();
  
  if (placeholder) {
    // If the image is not cached, set the placeholder image.
    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 }); }});Copy the code

4.7 Picture and window intersection monitoring

Intersection listening is enabled only for custom lazy loading. After the real picture has been set, you need to stop listening.

// cross listener ref.
const observerRef = useRef<IntersectionObserver>();

// Clean up the listener.
const clearObserver = usePersist(() = > {
  if (observerRef.current) {
    observerRef.current.disconnect();
    observerRef.current = undefined; }});// Listen for the callback.
const handleIntersect = usePersist((entries: IntersectionObserverEntry[]) = > {
  const entry = entries && entries[0];
  if (entry && entry.isIntersecting) {
    if (observerRef.current) {
      observerRef.current.disconnect(); // Stop listening after the intersection event is triggered} updateSource(); }});// Intersection listening is enabled only for custom lazy loading.
if(! observerRef.current && useCustomLazyLoading) { observerRef.current =new IntersectionObserver(handleIntersect);
}
Copy the code

4.8 mount

Using useLayoutEffect in a browser environment, the component immediately checks if the image is cached when it is mounted, and if it is cached, it has a chance to update the image before the interface renders, thus avoiding flickering.

useIsomorphicLayoutEffect(() = > {
  // If lazy loading is used, it listens for intersection events.
  if (useCustomLazyLoading && imageRef.current && observerRef.current) {
    observerRef.current.observe(imageRef.current);
    
  // If you are using placeholder images, perform image preloading immediately.
  } else if(src === placeholder && hasSource) { preloadSource(); }} []);Copy the code

The 4.9 update

// Re-instantiate the intersection listener after the custom lazy load flag changes.
useUpdate(() = > {
  clearObserver();
  if (useCustomLazyLoading) {
    observerRef.current = new IntersectionObserver(handleIntersect);
  }
}, [useCustomLazyLoading]);

// After the image resource is updated, according to the condition to determine whether to execute intersection listening, or directly execute the update logic.
useUpdate(() = > {
  if (useCustomLazyLoading && imageRef.current && observerRef.current) {
    observerRef.current.disconnect();
    observerRef.current.observe(imageRef.current);
  } else {
    updateSource();
  }
}, [srcProp, srcSetProp, fallback, placeholder, useCustomLazyLoading]);
Copy the code

4.10 uninstall

  • Clean up image preloading.
  • Clear intersection listeners.
useUnmount(() = > {
  clearPreload();
  clearObserver();
});
Copy the code

4.11 Merging code

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

// Check whether it is a browser environment.
constinBrowser = !! (typeof window! = ="undefined" &&
  window.document &&
  window.document.createElement
);

// Check whether native lazy loading is supported.
const supportNativeLazyLoading = "loading" in HTMLImageElement.prototype;

// To support server-side rendering, the alias is assigned according to the execution environment.
// Use 'useLayoutEffect' in browser environment and 'useEffect' in server environment.
const useIsomorphicLayoutEffect = inBrowser ? useLayoutEffect : useEffect;

/ / update.
function useUpdate(effect: EffectCallback, deps? : DependencyList) {
  const mountedRef = useRef(false);

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

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

  useEffect(() = > {
    return () = >{ effectRef.current(); }; } []); }// Persist the callback function. Return a callback wrapped with 'usePersist' whose address remains the same, but the function executed is the latest.
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;
}

// merge 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" inref) { ref.current = instance; }}); }; }// Component properties.
export interface ImageProps
  extendsOmit<ImgHTMLAttributes<HTMLImageElement>, "children"> { fallback? :string; placeholder? :string;
}

// Component status.
interfaceImageState { 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,

    // The following four attributes affect the loading and parsing of images, which are used when preloading images.sizes, decoding, crossOrigin, referrerPolicy, fallback, placeholder, onError, ... rest } = props;// Check whether lazy loading is used.
  const lazy = loading === "lazy";
  // Determine whether to use native lazy loading. When there is a placeholder image, the preload action needs to be triggered, so you cannot use native lazy loading.
  constuseNativeLazyLoading = lazy && supportNativeLazyLoading && ! placeholder;// Determine whether to use custom lazy loading.
  constuseCustomLazyLoading = lazy && inBrowser && ! useNativeLazyLoading;// Check whether there is an image source, i.e. whether there are 'SRC' and 'srcSet' attributes.
  consthasSource = !! srcProp || !! srcSetProp;const [state, setState] = useState<ImageState>(() = > {
    let alt: string | undefined;
    let src: string | undefined;
    let srcSet: string | undefined;
    let visibility: "hidden" | undefined;

    // Use custom lazy loading to hide the border of the image.
    if (useCustomLazyLoading) {
      visibility = "hidden";
    } else {
      alt = altProp;

      // Use placeholder images first.
      if (placeholder) {
        src = placeholder;

        // Use a real image source.
      } else if (hasSource) {
        src = srcProp;
        srcSet = srcSetProp;

        // Finally use the rollback image.
      } else if(fallback) { src = fallback; }}return { alt, src, srcSet, visibility };
  });
  const { alt, src, srcSet, visibility } = state;

  // Listen for an error in the '' element, using the fallback image.
  function handleError(event: any) {
    if(fallback && src ! == fallback) { setState({alt: altProp, src: fallback });
    }
    if (typeof onError === "function") { onError(event); }}// the ref of the '' element, which is used as the target for intersection listening.
  const imageRef = useRef<HTMLImageElement>(null);
  // merge 'imageRef' and forward ref.
  const mergedRef = useMergedRef(imageRef, ref);
  // Preload the image instance ref.
  const preloadRef = useRef<HTMLImageElement>();
  // cross listener ref.
  const observerRef = useRef<IntersectionObserver>();

  // Clear image preloading.
  const clearPreload = usePersist(() = > {
    if (preloadRef.current) {
      // Set 'SRC' and 'srcset' to empty strings to tell the browser to stop loading images.
      preloadRef.current.src = "";
      preloadRef.current.srcset = "";
      // Prevent accidental updating of component state.
      preloadRef.current.onload = null;
      // Delete the instance.
      preloadRef.current = undefined; }});// Clean up the listener.
  const clearObserver = usePersist(() = > {
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = undefined; }});// Images are preloaded. The component state is updated immediately and returns true if the image is already cached, otherwise the listening 'load' event returns false.
  // If this function returns true, there is no need to set a placeholder image.
  const preloadSource = usePersist(() = > {
    // Clean up the last image preload.
    clearPreload();

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

      // The following four attributes affect image loading and parsing.
      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;
      }

      // Set the image source.
      if (srcProp) {
        preloadRef.current.src = srcProp;
      }
      if (srcSetProp) {
        preloadRef.current.srcset = srcSetProp;
      }

      // If the image is already cached, update the status directly.
      if (preloadRef.current.complete) {
        setState({ alt: altProp, src: srcProp, srcSet: srcSetProp });
        return true;

        // Otherwise listen for 'load' events.
      } 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(() = > {
    // Clean up the previous image preloading.
    clearPreload();

    if (placeholder) {
      // If the image is not cached, set the placeholder image.
      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 }); }});// Listen for the callback.
  const handleIntersect = usePersist((entries: IntersectionObserverEntry[]) = > {
    const entry = entries && entries[0];
    if (entry && entry.isIntersecting) {
      if (observerRef.current) {
        observerRef.current.disconnect(); // Stop listening after the intersection event is triggered} updateSource(); }});// Intersection listening is enabled only for custom lazy loading.
  if(! observerRef.current && useCustomLazyLoading) { observerRef.current =new IntersectionObserver(handleIntersect);
  }

  / / a mount.
  useIsomorphicLayoutEffect(() = > {
    // If lazy loading is used, it listens for intersection events.
    if (useCustomLazyLoading && imageRef.current && observerRef.current) {
      observerRef.current.observe(imageRef.current);

      // If you are using placeholder images, perform image preloading immediately.
    } else if(src === placeholder && hasSource) { preloadSource(); }} []);// Re-instantiate the intersection listener after the custom lazy load flag changes.
  useUpdate(() = > {
    clearObserver();
    if (useCustomLazyLoading) {
      observerRef.current = new IntersectionObserver(handleIntersect);
    }
  }, [useCustomLazyLoading]);

  // After the image resource is updated, according to the condition to determine whether to execute intersection listening, or directly execute the update logic.
  useUpdate(() = > {
    if (useCustomLazyLoading && imageRef.current && observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current.observe(imageRef.current);
    } else {
      updateSource();
    }
  }, [srcProp, srcSetProp, fallback, placeholder, useCustomLazyLoading]);

  / / unloading.
  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;
Copy the code