rewrite Image component

- remove react-image dependency
- use manual blob load
- abort load when exiting viewport
This commit is contained in:
jeffvli
2026-03-09 20:47:52 -07:00
parent 3644ea2969
commit 31a201ca32
10 changed files with 390 additions and 315 deletions
+51 -256
View File
@@ -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',
};
}
+9
View File
@@ -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>,