[React] 서드파티 라이브러리 리액 컴포넌트로 만들기 (훅,Wrapping)

도도·2021년 9월 6일
1

기술Velog

목록 보기
25/28

리액트로 외부 라이브러리 감싸기

목적

  • 라이브러리를 사용하다보면 리액트컴포넌트로 제공해주지 않는 소스가 있다.
  • 예를들어, light-weight 차트 라이브러리는 직접 HTMLElemet를 참조해서 차트를 그려준다.
  • 이는 가상돔을 사용하는 리액트입장에서는 상당히 혼란스러울 수 있다.
  • 외부 라이브러리의 둠 조작을 허용하면서, 리액트의 상태를 바인딩 해보자.

전체 코드 + 주석

주요 컨셉

  • 컨테이너 HTMLElemnt 만들기
  • useRef의 사용
  • useLayoutEffect vs useEffect
  • Chart Data Binding
  • 추가 개선 사항
import React, { useLayoutEffect, useRef, useEffect, useCallback } from "react";
import { createChart, IChartApi, ISeriesApi } from "lightweight-charts";

// 차트에 들어가는 데이터 형태
export interface ILineData {
  time: string;
  value: number;
}
// 차트 hover 이벤트 발생, 콜백 인터페이스
export interface IonCrossHairChange {
  price: number;
}
/**
 * 라인 차트를 그립니다.
 * @param {ILineData} datas 차트 데이터 입력
 * @param {IonCrossHairChange} onCrossHairChange 차트 호버시 콜백함수
 * @returns {React.FC} 차트 컴포넌트
 */
interface ILineSeriesChart {
  datas?: ILineData[];
  onCrossHairChange?: (e: IonCrossHairChange) => void;
}

// 리팩토링 : (1) 생성과 소멸 useEffect로 잘 했는가
// 데이터 set : (2) useEffect로 데이터를 셋팅을 ?
// 데이터 onChange 함수 : (3) 적절한 설계인가?

const LineSeriesChart: React.FC<ILineSeriesChart> = ({
  datas,
  onCrossHairChange,
}) => {
  // 차트 컨테이너를 참조
  // useRef 타입 : refObject - JSX 컨테이너는 readonly ( usecallback에 의해 )
  const charContainer = useRef<HTMLDivElement>();
  // 차트 인터페이스 API 참조
  // useRef 타입 : Mutable Object - JSX 컨테이너 안의 chart 인스턴스를 리랜더링 가능
  const charApi = useRef<IChartApi>();
  // resize Observer
  // useRef을 사용하여 observer변수를 참조하자.
  // 차트 크기에 대한 반응형을 제공
  const resizeObserver = useRef(
    new ResizeObserver((entries, observer) => {
      // 관찰대상(chart container) 가 있다면
      if (entries && entries[0]) {
        const width = entries[0].contentRect.width;
        // 참조대상(차트 인스턴스) 가 있다면
        if (charApi.current) {
          // width를 동기화 하자.
          charApi.current.applyOptions({ width });
        }
      }
    })
  );

  // 그래프의 Area 시리즈 데이터를 참조
  // const lineSeries = useRef<ISeriesApi<"Line">>();
  const SeriesApiArea = useRef<ISeriesApi<"Area">>();

  // chart 컨테이너를 참조
  const handleContainerRef = useCallback((node) => {
    if (node) {
      // 컨테이터 요소 참조
      charContainer.current = node;
      // 리사이즈 구독
      resizeObserver.current.observe(node);
    }
  }, []);

  // 랜더링 전에, 차트에 대한 초기화를 진행한다.
  // HTML 컨테이너 요소가 변경되면 , 차트를 cleanup 시킨다.
  // TODO 리팩토링 : onCrossHairChange 콜백함수 역시
  // layout effect에 있을 필요가 없다.
  // 라인데이터 변경 effect처럼, 콜백함수가 변경되면 subscribeCrosshairMove 핸들러만 변경
  useLayoutEffect(() => {
    if (charContainer.current) {
      // 차트를 만들고
      const chart = createChart(charContainer.current, {
        height: 400,
        layout: {
          fontSize: 11,
        },
        rightPriceScale: {
          visible: false,
        },
        leftPriceScale: {
          visible: false,
        },
        grid: {
          horzLines: {
            color: "#ebebeb",
          },
          vertLines: {
            color: "#ebebeb",
          },
        },
      });
      // 라인 데이터 요소를 만들고
      charApi.current = chart;
      SeriesApiArea.current = chart.addAreaSeries({
        lineColor: "rgb(243, 188, 47)",
        topColor: "rgb(243, 188, 47,0.5)",
        bottomColor: "rgb(243, 188, 47,0)",
        lineWidth: 2,
      });
      // 구독 콜백 추가
      chart.subscribeCrosshairMove(function (param) {
        if (onCrossHairChange) {
          const price = param.seriesPrices.get(SeriesApiArea.current!);
          onCrossHairChange({ price: Number(price) });
        }
      });
    }

    return () => {
      // 컴포넌트가 unmount 되면 차트DOM 제거
      if (charContainer.current) {
        // console.log("will remove child!", charContainer.current.childNodes[0]);
        charContainer.current?.removeChild(charContainer.current.childNodes[0]);
      }
      // 컴포넌트가 unmount 되면 차트 Ref 제거
      charApi.current = undefined;
      // 컴포넌트가 unmount 되면 차트 DataSetter Ref 제거
      SeriesApiArea.current = undefined;
    };
  }, [charContainer, onCrossHairChange]);

  const setLineData = (data?: ILineData[]) => {
    if (SeriesApiArea.current && data) {
      SeriesApiArea.current?.setData(data);
    }
  };

  // 차트의 데이터만 바뀌는 경우
  // 차트를 다시 그릴 필요 없이 , 데이터만 교체
  useEffect(() => {
    setLineData(datas);
    return () => {};
  }, [datas]);

  return (
    <div
      className="chartContainer"
      ref={(node) => handleContainerRef(node)}
    ></div>
  );
};

export default LineSeriesChart;

주요 컨셉과 코드 구현

컨테이너 HTMLElemnt 만들기

  • 외부 차트 라이브러리를 사용하게 되면 DOM을 직접 조작하여 차트를 그린다.
  • 리액트의 가상돔과 혼란이 생길 수 있으므로, 리액트 가상돔에 빈 Div 태그를 반환하도록 하여
  • 리랜더링의 대상에서 제외시킨다.
  • 그래서 리랜더링의 관심에서 제외된 요소만을 차트 라이브러리가 root 태그로 사용하게끔 한다.
// 빈 컨테이너를 리턴
return (
  <div
    className="chartContainer"
    ref={(node) => handleContainerRef(node)}
  ></div>
);

useRef의 사용

  • 위에서 리랜더링의 대상에서 제외된 root 엘리먼트를 참조하기 위해 ref를 사용
  • 또한 차트라이브러리의 인터페이스도 ref로 참조 하자.
  // 차트 컨테이너를 참조
  // useRef 타입 : refObject - JSX 컨테이너는 readonly ( usecallback에 의해 )
  const charContainer = useRef<HTMLDivElement>();
  // 차트 인터페이스 API 참조
  // useRef 타입 : Mutable Object - JSX 컨테이너 안의 chart 인스턴스를 리랜더링 가능
  const charApi = useRef<IChartApi>();

useLayoutEffect vs useEffect

  • 처음에 차트를 그릴때는 useLayoutEffect를 사용했다.
  • 그 이후에 차트에 데이터 변경 등 상태변화에 대해서는 useEffect를 사용했다.
  • useLayoutEffect를 사용하게 되면, 랜더링 전에 Effect내부의 로직을 실행하므로써
  • 화면이 깜빡거리는 현상을 제거 할 수 있다.
  • 하지만, layoutEffect 내부의 함수 실행이 길다면, 화면이 그려지는데까지 오래 걸릴 수 있다.
useLayoutEffect(() => {
    if (charContainer.current) {
      // 차트를 만들고
      const chart = createChart(charContainer.current, {...});
      // 라인 데이터 요소를 만들고
      charApi.current = chart;
      SeriesApiArea.current = chart.addAreaSeries({...});
      // 구독 콜백 추가
      chart.subscribeCrosshairMove(function (param) {
        if (onCrossHairChange) {
          const price = param.seriesPrices.get(SeriesApiArea.current!);
          onCrossHairChange({ price: Number(price) });
        }
      });
    }

    return () => {
      // 컴포넌트가 unmount 되면 차트DOM 제거
      if (charContainer.current) {
        // console.log("will remove child!", charContainer.current.childNodes[0]);
        charContainer.current?.removeChild(charContainer.current.childNodes[0]);
      }
      // 컴포넌트가 unmount 되면 차트 Ref 제거
      charApi.current = undefined;
      // 컴포넌트가 unmount 되면 차트 DataSetter Ref 제거
      SeriesApiArea.current = undefined;
    };
  }, [charContainer, onCrossHairChange]);



  // 차트의 데이터만 바뀌는 경우
  // 차트를 다시 그릴 필요 없이 , 데이터만 교체
  useEffect(() => {
    setLineData(datas);
    return () => {};
  }, [datas]);

Chart Data Binding

  • 외부 차트 라이브러리의 상태와, 리액트의 useState 상태는 어떻게 동기화 시킬 것인가?
  • 다른 두 소스의 데이터를 동기화 시키는 것이 데이터 바인딩 이다.
  • useEffect을 이용해서 차트 랜더링 이후 발생하는 상태변화를 구독했다.
  • 특히 차트에 직접 전달되는 라인데이터는 변화가 있다면,
  • 차트 자체를 다시 그리는것이 아닌, 라인 부분만 그리도록 코드를 작성함
const setLineData = (data?: ILineData[]) => {
  if (SeriesApiArea.current && data) {
    SeriesApiArea.current?.setData(data);
  }
};

// 차트의 데이터만 바뀌는 경우
// 차트를 다시 그릴 필요 없이 , 데이터만 교체
useEffect(() => {
  setLineData(datas);
  return () => {};
}, [datas]);

추가 개선 사항

  • onCrossHairChange 콜백함수가 변경이 되면
  • 기존 차트를 버리고 다시 처음부터 그린다.
  • 물론 나는 이 컴포넌트에 콜백함수를 주입할때, useCallback으로 감싸서 넣기 때문에
  • 차트가 리랜더링 될 이유는 없다만 함수에 대한 메모지에이션 책임을 내부에서 지는것이 좋겠다고 생각함
profile
프론트 주력의 JS 개발자 입니다.! Node.js, React, NestJS 에 관심이 많습니다.

0개의 댓글