개발새발 로그

[2024-02-28] 팀프로젝트 개인회고 - Carousel의 내부 요소가 Link면 드래그할 때 Link 작동 막기, set함수를 호출하게 두면 안돼! 본문

TIL

[2024-02-28] 팀프로젝트 개인회고 - Carousel의 내부 요소가 Link면 드래그할 때 Link 작동 막기, set함수를 호출하게 두면 안돼!

이즈흐 2024. 2. 28. 21:36

1.  이전에 만든 Carousel의 내부에 만약 Link 태그와 같이 이벤트를 갖는 요소가 들어가면 드래그할 때 그 이벤트가 실행되는 문제가 발생

말 그대로 큰 문제가 생겼다.

Carousel 내부에 이벤트를 가진 요소가 들어간다는 생각을 하지 못했다.

그래서 드래그를 했을 때 내부의 이벤트를 막아주는 로직이 필요했다.

드래그를 하자마자 클릭이 대서 Link가 작동하는 모습

 

Link태그를 비활성화 하는 방법?

일단 드래그의 상태는 배제하고, Link 기능을 비활성화 할 수 있는 방법이 있는지 찾아봐야했다.

 

공식문서를 찾아보니 아래와 같이 나와있었다

그래서 아래와 같이 만들어 주었다.

 <Link
      href={`auction/${id}`}
      className={cn("flex gap-6", className)}
      passHref
      legacyBehavior>
      <a>
      ...
      </a>

그럼 이제 a태그에는 onClick이나 onMouseDown을 적용할 수 있으니까

Link태그를 비활성화 할 수 있는 방법을 찾은 것이다!

 

 

onMouseDown, OnMouseMove, onMouseUp, onClick으로 드래그 상태를 확인하기

일단 마우스 이벤트 순서를 간단히 말하면

onMouseDown -> OnMouseMove -> onMouseUp -> onClick
순이다.
그래서 등록한 함수가 위 순서대로 호출된다.

 

그래서 검색을 해보면 아래와 같이 드래그 방지 코드를 볼 수 있었다.

const div = document.querySelector('div');
 
let isDrag;
 
div.addEventListener('mousedown', () => {
  isDrag = false;
});
 
div.addEventListener('mousemove', () => {
  isDrag = true;
});
 
div.addEventListener('mouseup', () => {
  if (!isDrag) {
    // YOUR_CLICK_EVENT
    alert('hi');  
  }
});

// -> 리액트로 변환하면
const [isDrag, setIsDrag] = useState(false);

const handleMouseDown = (e) => {
  setIsDrag(true);
};

const handleMouseMove = (e) => {
  if (!isDrag) return;
};

const handleMouseUp = (e) => {
  setIsDrag(false);
};

const handleLinkClick = (e) => {
  if (isDrag) {
    e.preventDefault();
  }
};

기본적인 흐름은 원하는 것이 맞았지만 원하는 기능을 할 수 없었다.

저 기능을 하게되면 여전히 드래그했을 때 Link 태그도 기능을 하게된다.

 

왜냐하면 isDrag라는 상태가 원래는 true를 갖고 있다가

handleMouseUp에서 false가 되고, 리 렌더링이 된다.

그러면 isDrag는 false를 갖게 되고, 

handleLinkClick에서 isDrag는 false이므로 Link 기능을 막는 e.preventDefault()가 작동하지 않는다.

preventDefault는 브라우저가 적용하는 기본 동작을 방지하는 역할을 한다.

 

드래그를 click한 상태와 drag한 상태로 나눠보자!

그래서 위 코드를 기준으로 바꿔보았다.

  const [isClick, setIsClick] = useState(false);
  const [isDrag, setIsDrag] = useState(false);
  const handleMouseDown = (e) => {
    setIsDrag(false);
    setIsClick(true);
  };

  const handleMouseMove = (e) => {
    if (isClick) setIsDrag(true);
  };

  const handleMouseUp = (e) => {
    setIsClick(false);
  };

  const handleLinkClick = (e) => {
    if (isDrag) {
      e.preventDefault();
    }
  };


...

<Link
      href={`auction/${id}`}
      className={cn("flex gap-6", className)}
      passHref
      legacyBehavior>
      <a
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onClick={handleLinkClick}>
        
        ...

mouseDown을 하면

 - isClick은 true가 된다. -> 클릭됨을 뜻함

 - isDrag는 false가 된다. -> isDrag를 초기화 해줌

mouseMove를 하면

 - isClick이 true면 setIsDrag(true)를 해준다. -> 클릭된 이후 move가 된거니까 드래그 중임을 뜻하므로 isDrag는 true

mouseUp을 하면 

 - isClick은 fasle가 된다. -> 클릭이 끝남을 뜻함

마지막으로 mouseClick에서는

 - 이전 행동에서 드래그한 행동인지를 isDrag로 구분한다.

 - 드래그한 이후의 클릭이었다면 Link 태그의 href기능을 막는다.

 

이를 통해서 드래그했을 때 Link 태그의 기능을 막을 수 있게 되었다.

 

 

 

MouseMove가 계속 일어나는데 불필요하게 실행되는 setIsDrag()

위 코드의 주석에서 봤듯이 handleMouseMove 부분에서 계속해서 setIsDrag가 발생한다.

 

사실 setIsDrag는 항상 같은 값으로 변경되기때문에 리-렌더링은 일어나지않는다.

이유는 리액트 자체에서 setIsDrag를 호출하더라도 새로운 값이 현재 상태와 동일하다면, 

컴포넌트를 리렌더링하지 않는다.

즉, 상태 값이 변경되지 않았다면 컴포넌트의 리렌더링은 발생하지 않는다.

 

하지만 실제로 변경되지 않았음에도 불구하고 setState를 호출하는 것은 불필요한 작업을 수행하는 것이다.

이 불필요한 작업은 뭘까?

리액트의 리 -렌더링을 간단하게 살펴보자.

 

1. 일단 setIsDrag 함수가 호출되면, 리액트는 해당 컴포넌트의 상태를 업데이트하고, 그 결과 컴포넌트의 리렌더링이 필요하다는 것을 인식한다.

2. 상태 업데이트가 발생하면, 리액트는 '조정' 단계( Reconciliation)를 시작한다. 이 단계에서 리액트는 가상 DOM 트리를 새로 생성하고, 이를 이전 가상 DOM 트리와 비교한다. 이 비교 과정을 통해 리액트는 어떤 부분이 업데이트되어야 하는지 결정하는 것이다.

3. 리액트는 조정 단계에서 계산된 변경사항을 실제 DOM에 반영한다. '커밋' 단계 (Commit Phase) 에서 실제 DOM이 업데이트되며, 이로 인해 사용자는 변경된 내용을 화면에서 볼 수 있는 것이다.

 

근데 setIsDrag가 변경되지않는데 호출한다면 위 과정은 모두 불필요한 작업이다.

상태 변경이 없음에도 상태 변경을 감지하려고 시도하고, 필요한 경우 리렌더링을 수행해야 하는지 확인하는 작업을 수행한다.

이는 리액트의 작업을 불필요하게 만들어서 애플리케이션 전반적인 성능에 영향을 미칠 수 있다.

 

따라서 상태변경이 필요하지 않은 경우에는 setState를 호출하지 않는 것이 좋다.

 

 

그래서 아래와 같이 코드를 바꿔주었다.

  const [isClick, setIsClick] = useState(false);
  const [isDrag, setIsDrag] = useState(false);
  const handleMouseDown = (e: MouseEvent) => {
    setIsDrag(false);
    setIsClick(true);
  };

  const handleMouseMove = (e: MouseEvent) => {
    if (!isClick || isDrag) return;
   setIsDrag(true);
  };

  const handleMouseUp = (e: MouseEvent) => {
    setIsClick(false);
  };

  const handleLinkClick = (e: MouseEvent) => {
    if (isDrag) {
      e.preventDefault();
    }
  };

 

이를 통해서 눈에 보이지는 않지만 성능향상이 되었다..!ㅋㅋㅋ

 

 

 

+ 추가 챌린지

지금까지 위 내용은 Carousel의 자식요소인 children에 있는 컴포넌트(Link태그가 있는 컴포넌트)에 위 코드를 넣었을 때 잘 작동하던 것이었다.

그럼 Carousel을 쓸 때마다 저 이벤트를 넣어줘야한다?? 너무 복잡했다.

그래서 Carousel 내에서 이 기능을 알아서 하도록 구현해야한다.

 

그러나 Carousel 내에서 하기에는 상당히 복잡해졌다.

 

어찌됐든 Link 태그의 기능이나 a 태그의 기능이나

e.preventDefaults()로 기능을 막아야하는데

이를 위해서는 isDragging이라는 상태가  필요했다.

그러면 이 상태를 위해서 Carousel의 {childern}에 있는 자식요소에게 isDragging을 전달해줘야 하는데 쉽지 않았다.

 

1. useCarousel 처럼 만드는 방법을 시도해봤다.

"use client";

import {
  Children,
  cloneElement,
  isValidElement,
  ReactNode,
  useRef
} from "react";

import { cn } from "@/utils/cn";

import Icon from "@/app/_component/common/Icon";
import useDragScroll from "./useDragScroll";

interface UseCarouselParams {
  itemsToShow?: number;
  childSize?: number;
  useButton?: boolean;
  groupGap?: number;
  useNav?: boolean;
  height?: number;
  className?: string;
}

export interface UseCarouselResult {
  Carousel: ({ children }: { children: ReactNode }) => JSX.Element;
  isDragging: boolean;
}

const useCarousel = ({
  itemsToShow = 4,
  childSize = 100,
  height,
  useButton = false,
  useNav = false,
  groupGap = 5,
  className,
  ...props
}: UseCarouselParams): UseCarouselResult => {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const itemToShowWidth = itemsToShow * (childSize + groupGap) + childSize / 2;

  const {
    isLeftButtonActive,
    isRightButtonActive,
    buttonScrollLeft,
    buttonScrollRight,
    handleMouseDown,
    handleMouseMove,
    handleMouseUp,
    handleTouchDown,
    handleClickIndicator,
    currentElement,
    isDragging
  } = useDragScroll({
    containerRef,
    childSize: childSize + groupGap
  });

  const Carousel = ({ children }: { children: ReactNode }) => {
    const totalSlideCarousels = Children.count(children);

    const child = Children.toArray(children).map((child: ReactNode) => {
      if (isValidElement(child)) {
        return cloneElement(child as JSX.Element, {
          style: {
            width: `${childSize}px`,
            height: `${height ? `${height}px` : "auto"}`
          },
          className: child.props.className
        });
      }
    });
    return (
      <>
        ...
      </>
    );
  };

  return { Carousel, isDragging };
};

export default useCarousel;

하지만 위 방법은 useDragScroll이 밖에 선언되어있어서 

드래그를하면 set함수 호출로 리-렌더링이 되고, 

mouseUp을 했을 때 다시 초기 상태로 돌아가서 드래그가 정상적으로 안되는 문제가 있었다.

 

2. Context API를 사용하는 방법

시도하지는 않았지만 해결 방법으로는 적합했다.

하지만 최대한 상태관리를 사용하지 않고 하고 싶어서 일단 배제했다.

 

 

 

마지막 방법으로 point-events를 이용한 방법을 택했다. 

아래 자세히 설명해보겠다.

 

2. 끝나지 않은 Carousel 

기존의 Carousel에서 기능하던 useDragScroll 커스텀 훅을 변경할 것이다.

위 코드를 바탕으로 아래와 같이 작성했다.

const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
    if (!containerRef.current) return;
    e.preventDefault();
    e.stopPropagation();

    const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
    const { scrollLeft } = containerRef.current;

    setIsClick(true); // 클릭했음
    setStartX(clientX);
    setCurrentScrollLeft(scrollLeft || 0);
  };


  const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
    if (!containerRef.current) return;
    if (!isClick) return;
    setIsDragging(true); // 드래그 중
    
    const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;

    const currentX = clientX; 
    const walk = currentX - startX;
    containerRef.current.scrollLeft = currentScrollLeft - walk;
  };


  const handleMouseUp = () => {
    if (!isDragging || !containerRef.current) return; //드래그 안하고있으면 안됨

    const { scrollLeft } = containerRef.current;
    const nearestElementIndex = Math.round(scrollLeft / childSize);
    handleClickIndicator(nearestElementIndex);
    setIsDragging(false); // 드래그 끝
    setIsClick(false); // 클릭 끝
  };
  
  //Carousel 내부를 자동으로 정렬하는 함수
  const handleClickIndicator = (index: number) => {
    if (!containerRef.current) return;
    setCurrentElement(index);
    containerRef.current.scrollBy({
      left: index * childSize - containerRef.current.scrollLeft,
      behavior: "smooth"
    });
  };

일단 아까 했던 코드와 다른점은 handleLinkClick과 같은 함수가 없다는 것이다.

그래서 setIsDragging을 mouseUp할 때 변경해주었다.

 

이제 여기서 설정된 isDragging을 반환해줘서 Carousel 내부에 Drag상태에 따라 Link기능을 비활성화 하려고한다.

const {
    isLeftButtonActive,
    isRightButtonActive,
    buttonScrollLeft,
    buttonScrollRight,
    handleMouseDown,
    handleMouseMove,
    handleMouseUp,
    handleTouchDown,
    isDragging,
    handleClickIndicator,
    currentElement
  } = useDragScroll({
    containerRef,
    childSize: childSize + groupGap
  });

  return (
    <>
      <div
        className="flex items-center"
        {...props}>
        <div
          ref={containerRef}
          className={cn("order-2 flex overflow-hidden", className)}
          style={{
            width: `${itemToShowWidth}px`
          }}
          role="slider"
          aria-valuenow={itemsToShow}
          aria-valuemin={1}
          aria-valuemax={totalSlideCarousels}
          tabIndex={0}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          onTouchStart={handleTouchDown}
          onTouchMove={handleMouseMove}
          onTouchEnd={handleMouseUp}
          onMouseLeave={handleMouseUp}>
          <div
            style={{
              gap: `${groupGap}px`,
              pointerEvents: isDragging ? "none" : "auto" // 이벤트 비활성화
            }}
            className="flex items-center justify-center flex-nowrap">
            {child}
          </div>
          <div style={{ marginRight: `${childSize / 2}px` }}></div>
        </div>

...

주석 부분과 같이 point-events를 이용해서 드래그시 내부의 클릭 이벤트를 제어했다.

point-events는 IE 10버전 이하에서는 동작을 안한다고 하니 주의해야한다.

 

위와같이 적용하면 손쉽게 드래그가 가능했다.

하지만 드래그시 마우스 포인터가 포인터 형식에서 기본 커서로 변경되는 점이 조금 아쉬웠다.

 

그래서 아래와 같이 바꿔주었다.

<div
        className="flex items-center"
        {...props}>
        <div
          ref={containerRef}
          className={cn("order-2 flex overflow-hidden", className)}
          style={{
            width: `${itemToShowWidth}px`
          }}
          role="slider"
          aria-valuenow={itemsToShow}
          aria-valuemin={1}
          aria-valuemax={totalSlideCarousels}
          tabIndex={0}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          onTouchStart={handleTouchDown}
          onTouchMove={handleMouseMove}
          onTouchEnd={handleMouseUp}
          onMouseLeave={handleMouseUp}>
          <div
            style={{cursor: isDragging ? "grab" : "auto"}}> // grap이 뜨도록!
            <div
              style={{
                gap: `${groupGap}px`,
                pointerEvents: isDragging ? "none" : "auto"
              }}
              className="flex items-center justify-center flex-nowrap">
              {child}
            </div>
          </div>
          <div style={{ marginRight: `${childSize / 2}px` }}></div>
        </div>

위 처럼 div를 하나 더 감싸서 해결했다.

그러면 아래 gif처럼 진행된다.

 

 

한 가지  더 걸리는 점...

클릭을 한 후에 마우스를 움직였을 때 
Link의 이동이 늦어지는 틈을 타서 드래깅이 되는 현상이 있다.

 

마우스 클릭 후 Link 이동 버퍼링을 틈타서 드래깅이 되는 것이다.

 

어떤 이동이 일어나면 isDragging을 false로 만들어야하는데

ContextAPI와 같은걸 이용해야할 것 같았다.

 

728x90
반응형
LIST