本文以实现一个 Image 组件为例,详细讲解如何从需求分析到利用 hooks 具体实现一个函数式组件。全文代码使用 TypeScript 编写,所以需要你了解一点相关知识。
1. 需求
在不考虑兼容的情况下,<img>
标签的功能已经非常丰富,不仅支持懒加载,还可以结合 src
和 srcset
属性支持图片回退,甚至可以采用渐进式编码的图片代替图片占位。
现在,为了兼容性,我们定义 Image 组件的需求:
- 支持图片占位;
- 支持图片回退;
- 兼容图片懒加载。
通过 React 实现的 Image 组件,势必与 <img>
标签的行为有些不同,但我们应该尽可能使它们的行为相同或类似,所以又有一些隐藏的需求:
- 无多余的元素,保证组件只返回
<img>
元素; - 不添加多余的样式;
- 透传 Image 组件多余的属性。
- 转发 ref,将 Image 组件的
ref
直接应用于<img>
元素。
此外,还有一条常常被忽略的需求:
- 支持服务端渲染。
2. 分析
我们知道,比较先进的浏览器是原生支持图片懒加载的,只需设置 loading
属性的值为 "lazy"
即可。在不兼容的浏览器中,我们可以使用 IntersectionObserver 观察图片是否进入视窗来动态设置图片源以实现懒加载,为了区分原生懒加载,可称之为自定义懒加载。后文中出现的懒加载,若非特殊声明,皆指代自定义懒加载。
实现图片的占位、回退和懒加载,无非就是动态修改图片源属性:src
和 srcSet
,所以需要将这两个属性作为 Image 组件的状态。下面将从组件的三个执行阶段,首次渲染、挂载和更新,来分析如何修改这两个状态。
首次渲染,初始化 Image 组件的状态:
- 如果是懒加载,
src
和srcSet
的初始状态都应为undefined
; - 否则,优先使用占位图片作为
src
; - 如果没有占位图片,且 Image 组件有
src
和srcSet
属性(真实的图片源),则直接将这两个属性作为状态; - 如果以上条件都不符合,则使用回退图片作为
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 组件状态
除了需求分析中的图片源状态,还需要增加两个状态:alt
和 visibility
。
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;
复制代码