mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
rewrite Image component
- remove react-image dependency - use manual blob load - abort load when exiting viewport
This commit is contained in:
@@ -6,16 +6,19 @@ import {
|
||||
type ImgHTMLAttributes,
|
||||
memo,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Img } from 'react-image';
|
||||
|
||||
import styles from './image.module.css';
|
||||
import { useNativeImage } from './use-native-image';
|
||||
|
||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
|
||||
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
containerClassName?: string;
|
||||
@@ -24,11 +27,11 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 's
|
||||
enableViewport?: boolean;
|
||||
fetchPriority?: 'auto' | 'high' | 'low';
|
||||
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
|
||||
imageRequest?: ImageRequest;
|
||||
includeLoader?: boolean;
|
||||
includeUnloader?: boolean;
|
||||
isExplicit?: boolean;
|
||||
src: string | undefined;
|
||||
thumbHash?: string;
|
||||
unloaderIcon?: keyof typeof AppIcon;
|
||||
}
|
||||
|
||||
@@ -53,230 +56,62 @@ export function BaseImage({
|
||||
className,
|
||||
containerClassName,
|
||||
enableAnimation = false,
|
||||
enableDebounce = true,
|
||||
enableDebounce = false,
|
||||
enableViewport = true,
|
||||
fetchPriority,
|
||||
imageContainerProps,
|
||||
imageRequest,
|
||||
includeLoader = true,
|
||||
includeUnloader = true,
|
||||
isExplicit = false,
|
||||
onError,
|
||||
onLoad,
|
||||
src,
|
||||
unloaderIcon = 'emptyImage',
|
||||
...props
|
||||
}: ImageProps) {
|
||||
if (enableDebounce) {
|
||||
return (
|
||||
<ImageWithDebounce
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
enableAnimation={enableAnimation}
|
||||
enableViewport={enableViewport}
|
||||
imageContainerProps={imageContainerProps}
|
||||
includeLoader={includeLoader}
|
||||
includeUnloader={includeUnloader}
|
||||
isExplicit={isExplicit}
|
||||
src={src}
|
||||
unloaderIcon={unloaderIcon}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (enableViewport) {
|
||||
return (
|
||||
<ImageWithViewport
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
enableAnimation={enableAnimation}
|
||||
imageContainerProps={imageContainerProps}
|
||||
includeLoader={includeLoader}
|
||||
includeUnloader={includeUnloader}
|
||||
isExplicit={isExplicit}
|
||||
src={src}
|
||||
unloaderIcon={unloaderIcon}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
||||
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
isExplicit={isExplicit}
|
||||
{...restContainerProps}
|
||||
>
|
||||
{src ? (
|
||||
<Img
|
||||
className={clsx(styles.image, className, {
|
||||
[styles.animated]: enableAnimation,
|
||||
})}
|
||||
decoding="async"
|
||||
fetchPriority={fetchPriority}
|
||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
||||
src={src}
|
||||
unloader={
|
||||
includeUnloader ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : null
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
)}
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageWithDebounce({
|
||||
className,
|
||||
containerClassName,
|
||||
enableAnimation,
|
||||
enableViewport,
|
||||
fetchPriority,
|
||||
imageContainerProps,
|
||||
includeLoader,
|
||||
includeUnloader,
|
||||
isExplicit = false,
|
||||
src,
|
||||
unloaderIcon,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
const [debouncedSrc] = useDebouncedValue(src, 100, { waitForInitial: true });
|
||||
const viewport = useInViewport();
|
||||
const { inViewport, ref } = enableViewport ? viewport : { inViewport: true, ref: undefined };
|
||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
||||
|
||||
const hasBeenInViewportRef = useRef(false);
|
||||
const prevDebouncedSrcRef = useRef(debouncedSrc);
|
||||
const rawImageRequest = useMemo(
|
||||
() => imageRequest ?? (src ? { cacheKey: src, url: src } : undefined),
|
||||
[imageRequest, src],
|
||||
);
|
||||
const [debouncedImageRequest] = useDebouncedValue(rawImageRequest, 100, {
|
||||
waitForInitial: true,
|
||||
});
|
||||
const effectiveImageRequest = enableDebounce ? debouncedImageRequest : rawImageRequest;
|
||||
|
||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
||||
const [hasLoadedInInstance, setHasLoadedInInstance] = useState(false);
|
||||
|
||||
if (srcInDisplayedCache) {
|
||||
hasBeenInViewportRef.current = true;
|
||||
}
|
||||
useEffect(() => {
|
||||
setHasLoadedInInstance(false);
|
||||
}, [effectiveImageRequest?.cacheKey]);
|
||||
|
||||
if (prevDebouncedSrcRef.current !== debouncedSrc) {
|
||||
prevDebouncedSrcRef.current = debouncedSrc;
|
||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
||||
}
|
||||
const shouldLoadImage = Boolean(
|
||||
effectiveImageRequest && (!enableViewport || inViewport || hasLoadedInInstance),
|
||||
);
|
||||
|
||||
if (inViewport && debouncedSrc) {
|
||||
hasBeenInViewportRef.current = true;
|
||||
}
|
||||
const nativeImage = useNativeImage({
|
||||
enabled: shouldLoadImage,
|
||||
fetchPriority,
|
||||
onFetchError: src
|
||||
? () => {
|
||||
(onError as ((event: undefined) => void) | undefined)?.(undefined);
|
||||
}
|
||||
: undefined,
|
||||
request: effectiveImageRequest,
|
||||
});
|
||||
|
||||
const effectiveSrc = debouncedSrc ?? (srcInDisplayedCache ? src : undefined);
|
||||
const shouldShowImage = enableViewport
|
||||
? (inViewport || hasBeenInViewportRef.current) && effectiveSrc
|
||||
: effectiveSrc;
|
||||
|
||||
if (enableViewport) {
|
||||
if (shouldShowImage && effectiveSrc) {
|
||||
addToDisplayedCache(effectiveSrc);
|
||||
useEffect(() => {
|
||||
if (!nativeImage.isLoaded || !effectiveImageRequest?.cacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
isExplicit={isExplicit}
|
||||
ref={ref}
|
||||
{...restContainerProps}
|
||||
>
|
||||
{shouldShowImage && effectiveSrc ? (
|
||||
<Img
|
||||
className={clsx(styles.image, className, {
|
||||
[styles.animated]: enableAnimation,
|
||||
})}
|
||||
decoding="async"
|
||||
fetchPriority={fetchPriority}
|
||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
||||
src={effectiveSrc}
|
||||
unloader={
|
||||
includeUnloader ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : null
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
) : !src ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : (
|
||||
<ImageLoader className={className} />
|
||||
)}
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
setHasLoadedInInstance(true);
|
||||
}, [effectiveImageRequest?.cacheKey, nativeImage.isLoaded]);
|
||||
|
||||
if (effectiveSrc) addToDisplayedCache(effectiveSrc);
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
isExplicit={isExplicit}
|
||||
{...restContainerProps}
|
||||
>
|
||||
{effectiveSrc ? (
|
||||
<Img
|
||||
className={clsx(styles.image, className, {
|
||||
[styles.animated]: enableAnimation,
|
||||
})}
|
||||
decoding="async"
|
||||
fetchPriority={fetchPriority}
|
||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
||||
src={effectiveSrc}
|
||||
unloader={
|
||||
includeUnloader ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : null
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
) : !src ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : (
|
||||
<ImageLoader className={className} />
|
||||
)}
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageWithViewport({
|
||||
className,
|
||||
containerClassName,
|
||||
enableAnimation,
|
||||
fetchPriority,
|
||||
imageContainerProps,
|
||||
includeLoader,
|
||||
includeUnloader,
|
||||
isExplicit = false,
|
||||
src,
|
||||
unloaderIcon,
|
||||
...props
|
||||
}: ImageProps) {
|
||||
const { inViewport, ref } = useInViewport();
|
||||
const { className: containerPropsClassName, ...restContainerProps } = imageContainerProps || {};
|
||||
|
||||
const hasBeenInViewportRef = useRef(false);
|
||||
const prevSrcRef = useRef(src);
|
||||
|
||||
const srcInDisplayedCache = isInDisplayedCache(src);
|
||||
if (srcInDisplayedCache) {
|
||||
hasBeenInViewportRef.current = true;
|
||||
}
|
||||
|
||||
if (prevSrcRef.current !== src) {
|
||||
prevSrcRef.current = src;
|
||||
if (!srcInDisplayedCache) hasBeenInViewportRef.current = false;
|
||||
}
|
||||
|
||||
if (inViewport && src) {
|
||||
hasBeenInViewportRef.current = true;
|
||||
}
|
||||
|
||||
const shouldShowImage = (inViewport || hasBeenInViewportRef.current) && src;
|
||||
|
||||
if (shouldShowImage && src) addToDisplayedCache(src);
|
||||
return (
|
||||
<ImageContainer
|
||||
className={clsx(containerClassName, containerPropsClassName)}
|
||||
@@ -284,71 +119,31 @@ function ImageWithViewport({
|
||||
ref={ref}
|
||||
{...restContainerProps}
|
||||
>
|
||||
{shouldShowImage ? (
|
||||
<Img
|
||||
{nativeImage.displaySrc ? (
|
||||
<img
|
||||
className={clsx(styles.image, className, {
|
||||
[styles.animated]: enableAnimation,
|
||||
})}
|
||||
decoding="async"
|
||||
fetchPriority={fetchPriority}
|
||||
loader={includeLoader ? <ImageLoader className={className} /> : null}
|
||||
src={src}
|
||||
unloader={
|
||||
includeUnloader ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : null
|
||||
}
|
||||
onError={onError}
|
||||
onLoad={onLoad}
|
||||
src={nativeImage.displaySrc}
|
||||
{...props}
|
||||
/>
|
||||
) : !src ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : (
|
||||
) : nativeImage.isError ? (
|
||||
includeUnloader ? (
|
||||
<ImageUnloader className={className} icon={unloaderIcon} />
|
||||
) : null
|
||||
) : includeLoader ? (
|
||||
<ImageLoader className={className} />
|
||||
)}
|
||||
) : null}
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DISPLAYED_SRC_CACHE_KEY = 'feishin-displayed-src-cache';
|
||||
const MAX_DISPLAYED_SRC_CACHE = 500;
|
||||
|
||||
function addToDisplayedCache(src: string | undefined) {
|
||||
if (!src) return;
|
||||
try {
|
||||
const cache = getDisplayedSrcCache();
|
||||
if (cache.includes(src)) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (cache.length >= MAX_DISPLAYED_SRC_CACHE) {
|
||||
cache.shift();
|
||||
}
|
||||
|
||||
cache.push(src);
|
||||
sessionStorage.setItem(DISPLAYED_SRC_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
// ignore error if sessionStorage is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayedSrcCache(): string[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DISPLAYED_SRC_CACHE_KEY);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isInDisplayedCache(src: string | undefined): boolean {
|
||||
if (!src) return false;
|
||||
try {
|
||||
return getDisplayedSrcCache().includes(src);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const Image = memo(BaseImage);
|
||||
|
||||
const ImageContainer = forwardRef(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ImageRequest } from '/@/shared/types/domain-types';
|
||||
|
||||
type FetchPriority = 'auto' | 'high' | 'low';
|
||||
|
||||
interface NativeImageState {
|
||||
displaySrc?: string;
|
||||
status: 'error' | 'idle' | 'loaded' | 'loading';
|
||||
}
|
||||
|
||||
interface UseNativeImageArgs {
|
||||
enabled: boolean;
|
||||
fetchPriority?: FetchPriority;
|
||||
onFetchError?: () => void;
|
||||
request?: ImageRequest | null;
|
||||
}
|
||||
|
||||
export function useNativeImage({
|
||||
enabled,
|
||||
fetchPriority,
|
||||
onFetchError,
|
||||
request,
|
||||
}: UseNativeImageArgs) {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const loadedRequestSignatureRef = useRef<null | string>(null);
|
||||
const objectUrlRef = useRef<null | string>(null);
|
||||
const onFetchErrorRef = useRef(onFetchError);
|
||||
const [state, setState] = useState<NativeImageState>({ status: 'idle' });
|
||||
|
||||
const requestSignature = useMemo(() => {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
cacheKey: request.cacheKey,
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
});
|
||||
}, [request]);
|
||||
|
||||
onFetchErrorRef.current = onFetchError;
|
||||
|
||||
useEffect(() => {
|
||||
const abortCurrentRequest = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
};
|
||||
|
||||
const revokeObjectUrl = () => {
|
||||
if (!objectUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
loadedRequestSignatureRef.current = null;
|
||||
};
|
||||
|
||||
if (!request || !requestSignature) {
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
abortCurrentRequest();
|
||||
setState((currentState) =>
|
||||
currentState.displaySrc
|
||||
? { ...currentState, status: 'loaded' }
|
||||
: { status: 'idle' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedRequestSignatureRef.current === requestSignature && objectUrlRef.current) {
|
||||
setState({ displaySrc: objectUrlRef.current, status: 'loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
abortCurrentRequest();
|
||||
revokeObjectUrl();
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const init = {
|
||||
credentials: request.credentials,
|
||||
headers: request.headers,
|
||||
signal: abortController.signal,
|
||||
} as RequestInit & { priority?: FetchPriority };
|
||||
|
||||
if (fetchPriority) {
|
||||
init.priority = fetchPriority;
|
||||
}
|
||||
|
||||
const response = await fetch(request.url, init);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load image: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrlRef.current = objectUrl;
|
||||
loadedRequestSignatureRef.current = requestSignature;
|
||||
setState({ displaySrc: objectUrl, status: 'loaded' });
|
||||
} catch {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
revokeObjectUrl();
|
||||
setState({ status: 'error' });
|
||||
onFetchErrorRef.current?.();
|
||||
} finally {
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, fetchPriority, request, requestSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
displaySrc: state.displaySrc,
|
||||
isError: state.status === 'error',
|
||||
isLoaded: state.status === 'loaded',
|
||||
isLoading: state.status === 'loading',
|
||||
};
|
||||
}
|
||||
@@ -1397,6 +1397,7 @@ export type ControllerEndpoint = {
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getImageRequest: (args: ImageArgs) => ImageRequest | null;
|
||||
getImageUrl: (args: ImageArgs) => null | string;
|
||||
getInternetRadioStations: (
|
||||
args: GetInternetRadioStationsArgs,
|
||||
@@ -1471,6 +1472,13 @@ export type ImageArgs = BaseEndpointArgs & {
|
||||
query: ImageQuery;
|
||||
};
|
||||
|
||||
export type ImageRequest = {
|
||||
cacheKey: string;
|
||||
credentials?: RequestCredentials;
|
||||
headers?: Record<string, string>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ImageQuery = {
|
||||
id: string;
|
||||
itemType: LibraryItem;
|
||||
@@ -1523,6 +1531,7 @@ export type InternalControllerEndpoint = {
|
||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
|
||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||
getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;
|
||||
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
|
||||
getInternetRadioStations: (
|
||||
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
|
||||
|
||||
Reference in New Issue
Block a user