From da4b8f72476f859c0501bca31534602e74683dc1 Mon Sep 17 00:00:00 2001 From: ssi02014 Date: Fri, 7 Jun 2024 17:26:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(react):=20useIntersectionObserver=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20InView,=20LazyImage=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cyan-weeks-approve.md | 5 + docs/docs/react/components/InView.mdx | 118 +++++++++++++----- docs/docs/react/components/LazyImage.mdx | 100 +++++++++------ .../react/hooks/useIntersectionObserver.mdx | 89 ++++++++++--- .../src/components/InView/InView.spec.tsx | 104 +++++++-------- .../react/src/components/InView/index.tsx | 23 ++-- .../react/src/components/LazyImage/index.tsx | 6 +- .../hooks/useIntersectionObserver/index.ts | 56 +++++---- .../useIntersectionObserver.spec.tsx | 112 +++++++---------- 9 files changed, 374 insertions(+), 239 deletions(-) create mode 100644 .changeset/cyan-weeks-approve.md diff --git a/.changeset/cyan-weeks-approve.md b/.changeset/cyan-weeks-approve.md new file mode 100644 index 00000000..9bdffd20 --- /dev/null +++ b/.changeset/cyan-weeks-approve.md @@ -0,0 +1,5 @@ +--- +'@modern-kit/react': patch +--- + +feat(react): useIntersectionObserver 개선 및 InView, LazyImage 수정 - @ssi02014 diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index 41f3d21f..c95b6bbc 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -2,7 +2,13 @@ import { InView } from '@modern-kit/react'; # InView -`Viewport`에 노출될 때 props로 넘겨주는 `action` 콜백 함수를 호출하는 컴포넌트입니다. +`InView`는 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)**를 선언적으로 활용 할 수 있는 컴포넌트입니다. + +`Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. + +`calledOnceVisible`을 활용해 컴포넌트가 `viewport에 노출 될 때 한번 onIntersectStart을 호출` 할 수 있습니다. + +Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고)
@@ -11,6 +17,18 @@ import { InView } from '@modern-kit/react'; ## Interface ```ts title="typescript" +interface IntersectionObserverInit { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; +} + +interface UseIntersectionObserverProps extends IntersectionObserverInit { + onIntersectStart?: (entry: IntersectionObserverEntry) => void; + onIntersectEnd?: (entry: IntersectionObserverEntry) => void; + calledOnceVisible?: boolean; +} + type InViewProps = React.ComponentProps<'div'> & UseIntersectionObserverProps; const InView: React.ForwardRefExoticComponent< @@ -24,46 +42,86 @@ const InView: React.ForwardRefExoticComponent< import { InView } from '@modern-kit/react'; const Example = () => { - const onAction = () => { + const handleIntersectStart = () => { + /* action */ + } + + const handleIntersectEnd = () => { /* action */ } return (
{/* ... */} - Box1 -
+ + Box1 + + ; ); }; ``` ## Example -
-
스크롤 해주세요.
- console.log("action callback(1)")} - calledOnce - > -
-

Box1

-

브라우저 개발자 도구의 콘솔을 확인해주세요.

-

action 콜백 함수가 최초 1회만 호출됩니다.

-

calledOnce: true

-
-
-
- console.log("action callback(2)")} - > +export const Example = () => { + const inViewStyle = { + width: '100%', + color: 'white', + textAlign: 'center', + fontSize: '21px', + padding: '0 20px', + } + return (
-

Box2

-

브라우저 개발자 도구의 콘솔을 확인해주세요.

-

action 콜백 함수가 여러 번 호출됩니다.

-

calledOnce: false

+
+ 스크롤 해주세요. +
+ console.log('action onIntersectStart(1)')} + onIntersectEnd={() => console.log('action onIntersectEnd(1)')} + calledOnceVisible + > +
+

Box1

+

브라우저 개발자 도구의 콘솔을 확인해주세요.

+

onIntersectStart가 최초 1회만 호출됩니다.

+

calledOnceVisible: true

+
+
+
+ console.log('action onIntersectStart(2)')} + onIntersectEnd={() => console.log('action onIntersectEnd(2)')} + > +
+

Box2

+

브라우저 개발자 도구의 콘솔을 확인해주세요.

+

onIntersectStart, onIntersectEnd 함수가 여러 번 호출됩니다.

+

calledOnceVisible: false

+
+
+
- -
-
\ No newline at end of file + ); +}; + + + +## Note +- [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver) \ No newline at end of file diff --git a/docs/docs/react/components/LazyImage.mdx b/docs/docs/react/components/LazyImage.mdx index b708c987..93899a8e 100644 --- a/docs/docs/react/components/LazyImage.mdx +++ b/docs/docs/react/components/LazyImage.mdx @@ -2,7 +2,7 @@ import { LazyImage } from '@modern-kit/react'; # LazyImage -`Viewport`에 노출될 때 할당된 이미지를 `Lazy Loading` 하는 이미지 컴포넌트입니다. +**[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)**를 활용해 `Viewport`에 노출될 때 할당된 이미지를 `Lazy Loading` 하는 이미지 컴포넌트입니다. `width`, `height` 값을 입력해 이미지의 크기를 조절 할 수 있으며, 동시에 `Layout Shift`를 개선할 수 있습니다. @@ -15,13 +15,21 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고 ## Interface ```ts title="typescript" +interface IntersectionObserverInit { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; +} + interface LazyImageProps extends React.ComponentProps<'img'>, IntersectionObserverInit { src: string; } -const LazyImage: React.ForwardRefExoticComponent & React.RefAttributes> +const LazyImage: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +>; ``` ## Usage @@ -92,44 +100,56 @@ const Example = () => { ## Example -
-
- 스크롤 해주세요. -
- console.log('img click1')} - /> - -
- - console.log('img click2')} - /> - -
- - console.log('img click3')} - /> -
+export const Example = () => { + return ( +
+
+ 스크롤 해주세요. +
+ console.log('img click1')} + /> + +
+ + console.log('img click2')} + /> + +
+ + console.log('img click3')} + /> +
+ ); +}; + + + ## Note - [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver) \ No newline at end of file diff --git a/docs/docs/react/hooks/useIntersectionObserver.mdx b/docs/docs/react/hooks/useIntersectionObserver.mdx index 09f5143c..7038c164 100644 --- a/docs/docs/react/hooks/useIntersectionObserver.mdx +++ b/docs/docs/react/hooks/useIntersectionObserver.mdx @@ -1,6 +1,10 @@ +import { useIntersectionObserver } from '@modern-kit/react'; + # useIntersectionObserver -`ref`를 할당한 타겟 엘리먼트가 `Viewport`에 노출되는 시점에 `action` 콜백 함수를 호출시키는 커스텀 훅입니다. +`ref`를 할당한 타겟 엘리먼트가 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. + +`calledOnceVisible`을 활용해 타겟 엘리먼트가 `viewport에 노출 될 때 한번 onIntersectStart을 호출` 할 수 있습니다. Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고) @@ -11,18 +15,28 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고 ## Interface ```ts title="typescript" +interface IntersectionObserverInit { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; +} + interface UseIntersectionObserverProps extends IntersectionObserverInit { - action: (entry: IntersectionObserverEntry) => void; - calledOnce?: boolean; + onIntersectStart?: (entry: IntersectionObserverEntry) => void; + onIntersectEnd?: (entry: IntersectionObserverEntry) => void; + calledOnceVisible?: boolean; } const useIntersectionObserver: ({ - action, - calledOnce, - root, - threshold, - rootMargin, -}: UseIntersectionObserverProps) => (node: T) => void; + onIntersectStart, + onIntersectEnd, + calledOnceVisible, // default: false + root, // default: null + threshold, // default: 0 + rootMargin, // default: '0px 0px 0px 0px' +}: UseIntersectionObserverProps) => { + ref: (node: T) => void; +}; ``` ## Usage @@ -30,22 +44,63 @@ const useIntersectionObserver: ({ import { useIntersectionObserver } from '@modern-kit/react'; const Example = () => { - const divRef = useIntersectionObserver({ - action: () => { /* action */}, - }); - const imgRef = useIntersectionObserver({ - action: (entry) => { /* 필요하다면 IntersectionObserverEntry 를 사용할 수 있습니다. */}, + const { ref: targetRef } = useIntersectionObserver({ + onIntersectStart: (entry) => { + console.log("onIntersectStart: ", entry); + }, + onIntersectEnd: (entry) => { + console.log("onIntersectEnd: ", entry); + }, + calledOnceVisible: false }); + + const boxStyle = { + height: "800px", + backgroundColor: "teal" + } return (
- {/* ... */} -
Box
- img +
+
+ 타겟 요소
+ 개발자 도구 콘솔을 확인해주세요. +
+
); }; ``` +## Exmaple + +export const Example = () => { + const { ref: targetRef } = useIntersectionObserver({ + onIntersectStart: (entry) => { + console.log("onIntersectStart: ", entry); + }, + onIntersectEnd: (entry) => { + console.log("onIntersectEnd: ", entry); + }, + calledOnceVisible: false + }); + const boxStyle = { + height: "800px", + backgroundColor: "teal" + } + return ( +
+
+
+ 타겟 요소
+ 개발자 도구 콘솔을 확인해주세요. +
+
+
+ ); +}; + + + ## Note - [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver) \ No newline at end of file diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx index d17f1b2a..57448b24 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.spec.tsx @@ -15,80 +15,72 @@ afterEach(() => { mockIntersectionObserverCleanup(); }); +interface TestComponentProps { + onIntersectStart: () => void; + onIntersectEnd: () => void; + calledOnceVisible?: boolean; +} + const TestComponent = ({ - action1, - action2, -}: { - action1: () => void; - action2: () => void; -}) => { + onIntersectStart, + onIntersectEnd, + calledOnceVisible, +}: TestComponentProps) => { return ( -
- - box1 - - box2 -
+ + box + ); }; describe('InView Component', () => { + const intersectStartMock = vi.fn(); + const intersectEndMock = vi.fn(); + it('should call the action function when the InView component is exposed to the viewport', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); + renderSetup( + + ); - const box1 = screen.getByText('box1'); - const box2 = screen.getByText('box2'); + const box = screen.getByText('box'); - expect(mockFn1).toBeCalledTimes(0); - expect(mockFn2).toBeCalledTimes(0); + expect(intersectStartMock).toBeCalledTimes(0); + expect(intersectEndMock).toBeCalledTimes(0); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - expect(mockFn1).toBeCalledTimes(1); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); + expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(1); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + expect(intersectEndMock).toBeCalledTimes(1); }); it('should call the action callback function once if the calledOnce prop is true', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); - - const box1 = screen.getByText('box1'); - - expect(mockFn2).toBeCalledTimes(0); - - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - expect(mockFn1).toBeCalledTimes(1); - - await waitFor(() => mockIntersecting({ type: 'hide', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'hide', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - - expect(mockFn1).toBeCalledTimes(1); - }); - - it('should call the action callback function every time it is exposed to the viewport if the calledOnce prop is false', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); + renderSetup( + + ); - const box2 = screen.getByText('box2'); + const box = screen.getByText('box'); - expect(mockFn2).toBeCalledTimes(0); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(1); + expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'hide', element: box2 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(2); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); - await waitFor(() => mockIntersecting({ type: 'hide', element: box2 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(3); + expect(intersectStartMock).toBeCalledTimes(1); + expect(intersectEndMock).toBeCalledTimes(0); }); }); diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx index 2105ce07..52c162d9 100644 --- a/packages/react/src/components/InView/index.tsx +++ b/packages/react/src/components/InView/index.tsx @@ -11,16 +11,25 @@ export const InView = forwardRef< HTMLDivElement, PropsWithChildren >((props, ref) => { - const { action, calledOnce, threshold, root, rootMargin, ...restProps } = - props; - - const intersectionObserverRef = useIntersectionObserver({ - action, - calledOnce, + const { + onIntersectStart, + onIntersectEnd, + calledOnceVisible, threshold, root, rootMargin, - }); + ...restProps + } = props; + + const { ref: intersectionObserverRef } = + useIntersectionObserver({ + onIntersectStart, + onIntersectEnd, + calledOnceVisible, + threshold, + root, + rootMargin, + }); return (
diff --git a/packages/react/src/components/LazyImage/index.tsx b/packages/react/src/components/LazyImage/index.tsx index d09f582d..c9b3cd37 100644 --- a/packages/react/src/components/LazyImage/index.tsx +++ b/packages/react/src/components/LazyImage/index.tsx @@ -13,12 +13,12 @@ export const LazyImage = forwardRef( { src, style, threshold, root, rootMargin, ...restProps }: LazyImageProps, ref ) => { - const imgRef = useIntersectionObserver({ - action: (entry) => { + const { ref: imgRef } = useIntersectionObserver({ + onIntersectStart: (entry) => { const targetImgElement = entry.target as HTMLImageElement; targetImgElement.src = src; }, - calledOnce: true, + calledOnceVisible: true, threshold, root, rootMargin, diff --git a/packages/react/src/hooks/useIntersectionObserver/index.ts b/packages/react/src/hooks/useIntersectionObserver/index.ts index 636c458e..7c8a5e7c 100644 --- a/packages/react/src/hooks/useIntersectionObserver/index.ts +++ b/packages/react/src/hooks/useIntersectionObserver/index.ts @@ -1,53 +1,65 @@ -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { usePreservedCallback } from '../usePreservedCallback'; -import { Nullable } from '@modern-kit/types'; import { noop } from '@modern-kit/utils'; +import { Nullable } from '@modern-kit/types'; export interface UseIntersectionObserverProps extends IntersectionObserverInit { - action: (entry: IntersectionObserverEntry) => void; - calledOnce?: boolean; + onIntersectStart?: (entry: IntersectionObserverEntry) => void; + onIntersectEnd?: (entry: IntersectionObserverEntry) => void; + calledOnceVisible?: boolean; } export const useIntersectionObserver = ({ - action, - calledOnce = false, + onIntersectStart, + onIntersectEnd, + calledOnceVisible = false, root = null, - threshold = [0], + threshold = 0, rootMargin = '0px 0px 0px 0px', }: UseIntersectionObserverProps) => { const intersectionObserverRef = useRef>(null); + const preservedIntersectStart = usePreservedCallback( + onIntersectStart ?? noop + ); + const preservedIntersectEnd = usePreservedCallback(onIntersectEnd ?? noop); - const callbackAction = usePreservedCallback(action ?? noop); - - const observerAction = usePreservedCallback( + const intersectionObserverCallback = useCallback( ([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => { - if (entry && entry.isIntersecting) { + if (!entry) return; + + if (entry.isIntersecting) { const targetElement = entry.target as T; - callbackAction(entry); + preservedIntersectStart(entry); - if (calledOnce) { + if (calledOnceVisible) { observer.unobserve(targetElement); } + } else { + preservedIntersectEnd(entry); } - } + }, + [calledOnceVisible, preservedIntersectStart, preservedIntersectEnd] ); - const targetRef = usePreservedCallback((node: T) => { + const targetRef = (node: T) => { if (intersectionObserverRef.current) { intersectionObserverRef.current.disconnect(); } - intersectionObserverRef.current = new IntersectionObserver(observerAction, { - root, - threshold, - rootMargin, - }); + intersectionObserverRef.current = new IntersectionObserver( + intersectionObserverCallback, + { + threshold, + root, + rootMargin, + } + ); if (node) { intersectionObserverRef.current.observe(node); } - }); + }; - return targetRef; + return { ref: targetRef }; }; diff --git a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx index 41820829..3a5d6edb 100644 --- a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx +++ b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx @@ -15,86 +15,70 @@ afterEach(() => { mockIntersectionObserverCleanup(); }); +interface TestComponentProps { + onIntersectStart: () => void; + onIntersectEnd: () => void; + calledOnceVisible?: boolean; +} + const TestComponent = ({ - action1, - action2, -}: { - action1: () => void; - action2: () => void; -}) => { - const boxRef1 = useIntersectionObserver({ - action: action1, - calledOnce: true, - }); - const boxRef2 = useIntersectionObserver({ - action: action2, + onIntersectStart, + onIntersectEnd, + calledOnceVisible, +}: TestComponentProps) => { + const { ref: boxRef } = useIntersectionObserver({ + onIntersectStart, + onIntersectEnd, + calledOnceVisible, }); - return ( -
-
box1
-
box2
-
- ); + return
box
; }; describe('useIntersectionObserver', () => { - it('should call the action callback function when the target element assigned to the returned "ref" is exposed to the Viewport', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); - - const box1 = screen.getByText('box1'); - const box2 = screen.getByText('box2'); - - expect(mockFn1).toBeCalledTimes(0); - expect(mockFn2).toBeCalledTimes(0); - - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - expect(mockFn1).toBeCalledTimes(1); + const intersectStartMock = vi.fn(); + const intersectEndMock = vi.fn(); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(1); - }); - - it('should call the action callback function only once when the calledOnce option is true', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); - - const box1 = screen.getByText('box1'); + it('should call the action callback function when the target element assigned to the returned "ref" is exposed to the Viewport', async () => { + renderSetup( + + ); - expect(mockFn2).toBeCalledTimes(0); + const box = screen.getByText('box'); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - expect(mockFn1).toBeCalledTimes(1); + expect(intersectStartMock).toBeCalledTimes(0); + expect(intersectEndMock).toBeCalledTimes(0); - await waitFor(() => mockIntersecting({ type: 'hide', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'hide', element: box1 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box1 })); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); + expect(intersectStartMock).toBeCalledTimes(1); - expect(mockFn1).toBeCalledTimes(1); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + expect(intersectEndMock).toBeCalledTimes(1); }); - it('should call the action callback function every time the target element is exposed to the Viewport when the calledOnce option is false', async () => { - const mockFn1 = vi.fn(); - const mockFn2 = vi.fn(); - renderSetup(); - - const box2 = screen.getByText('box2'); + it('should call the action callback function only once when the calledOnceVisible option is true', async () => { + renderSetup( + + ); - expect(mockFn2).toBeCalledTimes(0); + const box = screen.getByText('box'); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(1); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); + expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'hide', element: box2 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(2); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); - await waitFor(() => mockIntersecting({ type: 'hide', element: box2 })); - await waitFor(() => mockIntersecting({ type: 'view', element: box2 })); - expect(mockFn2).toBeCalledTimes(3); + expect(intersectStartMock).toBeCalledTimes(1); + expect(intersectEndMock).toBeCalledTimes(0); }); });