[Next.js] 포트폴리오 웹 제작기 - 번외. 컴포넌트 라이브러리 배포

olwooz·2023년 3월 12일
0
post-thumbnail

슬롯 머신 이펙트를 React 컴포넌트 라이브러리로 만들어보려고 한다.

코드 변경

1. 컴포넌트 분리

우선은 기존 코드에서 컴포넌트 부분만 똑 떼어서 별도의 프로젝트로 생성해줬다.
Vite로 React+TypeScript 프로젝트를 만들고 framer-motion만 설치했다.
storybook을 써 보고 싶었는데 현재 버그로 인해 Node.js 버전을 다운그레이드 해야 해서 다음에 써 보기로 했다.
스타일과 셔플 버튼 등 필요 없는 부분을 빼버리고 슬롯 머신 텍스트 div 하나만 남겼다.

// components/SlotMachine.tsx

import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Variants } from 'framer-motion';

interface Props {
  textData: string[];
}

interface VariantProps {
  scaleY: number;
  y: string | number;
  opacity: number;
  filter?: string;
}

const ARRAY_REPEAT = 5;

const SlotMachine = ({ textData }: Props) => {
  const [count, setCount] = useState(0);
  const [currentIndex, setCurrentIndex] = useState(0);
  const textArr = Array(ARRAY_REPEAT).fill(textData).flat();
  const lastIndex = textArr.length - 1 - count;

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prev) => {
        return prev < lastIndex ? prev + 1 : prev;
      });
    }, getDuration(10, currentIndex));

    return () => clearInterval(interval);
  }, [currentIndex, lastIndex, count]);

  const variants: Variants = {
    initial: { scaleY: 0.3, y: '-50%', opacity: 0 },
    animate: ({ isLast }) => {
      let props: VariantProps = { scaleY: 1, y: 0, opacity: 1 };
      if (!isLast) props['filter'] = 'blur(1.5px)';

      return props;
    },
    exit: { scaleY: 0.3, y: '50%', opacity: 0 },
  };

  function handleClick() {
    setCurrentIndex(0);
    setCount((prev) => {
      return prev < textData.length - 1 ? prev + 1 : 0;
    });
  }

  function getDuration(base: number, index: number) {
    return base * (index + 1) * 0.5;
  }

  return (
    <div>
      <AnimatePresence mode="popLayout">
        {textArr.map((text, i) => {
          const isLast = i === lastIndex;

          return (
            i === currentIndex && (
              <motion.p
                className="slotMachineText"
                key={text}
                custom={{ isLast }}
                variants={variants}
                initial="initial"
                animate="animate"
                exit="exit"
                transition={{ duration: getDuration(isLast ? 0.1 : 0.01, i), ease: isLast ? 'easeInOut' : 'linear' }}
                onClick={handleClick}
                whileHover={{ opacity: 0.5, transition: { duration: 0.2 } }}
                whileTap={{ scaleY: 0.7, y: '-30%', transition: { duration: 0.2 } }}
              >
                {t(`main.textData.${text}`)}
              </motion.p>
            )
          );
        })}
      </AnimatePresence>
    </div>
  );
};

export default SlotMachine;
// App.tsx

import './App.css'
import SlotMachine from './components/SlotMachine'

function App() {
  const textData = [...Array(15)].map(_ => String(Math.round(Math.random() * 30)));

  return (
    <div className="App">
      <SlotMachine textData={textData} />
    </div>
  )
}

export default App
/* App.css */

.slotMachineText {
  cursor: pointer;
  font-size: xx-large;
}

사실 이렇게 떼어냈을 때 혹시나 동작하지 않으면 어쩌나 걱정했는데 다행히 잘 돌아간다.

2. 로직 수정

기존에 만들었던 슬롯 머신 컴포넌트는 이펙트만 슬롯 머신처럼 보이는 것이고 실제로는 랜덤으로 텍스트를 선택하는 방식이 아니었다. 최소한의 클릭으로 존재하는 모든 데이터를 다 볼 수 있게 하려는 의도였다.

이번엔 좀 더 제네릭한 컴포넌트가 될 수 있게 랜덤 옵션을 선택할 수 있게 하려고 한다.

배열 섞기

random 옵션을 props로 넘겨주고, randomtrue일 때 데이터 배열을 섞어줬다.

가장 간단하게 배열을 섞을 수 있는 방법은 아래와 같다.

function shuffle(array) {
  array.sort(() => Math.random() - 0.5);
}

하지만 이 방식은 sort()로 인한 오버헤드가 발생하고, 무작위성으로 인해 JavaScript 엔진에 따라 결과가 편향되게 나올 수 있다. (참조)
따라서 Fisher-Yates shuffle 알고리즘으로 섞어주는 게 좋다.

if (random) textData = shuffle(textData);

function shuffle(array: string[]) {
  const shuffled = [...array];

  for (let i = shuffled.length - 1; i > 0; i--) {
    let j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }

  return shuffled;
}

요소 선택

기존에는 텍스트를 누르면 count를 증가시켜 데이터를 순서대로 보여줬다.
하지만 이제는 랜덤일 때와 아닐 때를 구분해서 다음 요소를 결정해야 한다.

이펙트의 생동감을 위해 배열의 요소 갯수도 어느 정도 유지하면서,
랜덤인 경우에는 랜덤으로 다음 요소를 결정하고, 랜덤이 아닌 경우에는 순서에 따라 다음 요소를 결정하도록 로직을 개선했다.
이 과정에서 state도 변경되었다.

if (random) textData = shuffle(textData);

const textArr = textData.length < MIN_ARR_LEN ? Array(Math.round(MIN_ARR_LEN / textData.length)).fill(textData).flat() : textData;

const [data, setData] = useState(textArr);
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);

function handleClick() {
  const prevIndex = currentIndex;
  const nextIndex = random ? data.length - prevIndex + Math.round(Math.random() * (textArr.length - 1)) : textArr.length - 1;
  setData([...data.slice(prevIndex), ...textArr]);
  setCurrentIndex(0);
  setSelectedIndex(nextIndex);
}

getDuration

가변 길이의 데이터에 부드러운 transition이 이뤄지도록 getDuration 함수를 수정했다.

function getDuration(base: number, index: number) {
  return base * (index + 1) * (5 / dataCount);
}

데이터가 5개일 때와 1000개일 때 애니메이션 길이 차이는 있지만 transition은 둘 다 자연스럽게 보인다.

3. 초기 텍스트

props로 초기 텍스트를 사용자가 지정할 수 있게 해줬다.

interface Props {
  initialText: String;
  textData: string[];
  random?: boolean;
}

/* ... */

const [data, setData] = useState([initialText, ...textArr]);

4. 예외 처리

데이터 갯수가 0일 때 예외 처리를 해줬다.

if (textData.length === 0) {
  return <div><p>Please enter at least one element in textData.</p></div>
}

라이브러리 배포

이 글을 참고했다. 해당 글에서는 패키지를 빌드하고 배포 전에 로컬에서 먼저 테스트해보는 과정을 알려주고, 배포 과정은 알려주지 않는다.

1. 셋업

먼저 Vite의 react-ts preset template을 사용해 프로젝트를 셋업한다.

yarn create vite my-lib --template react-ts

2. vite.config.js

// vite.config.js

import react from '@vitejs/plugin-react';
import path from 'node:path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
    plugins: [
        react(),
        dts({
            insertTypesEntry: true,
        }),
    ],
    build: {
        lib: {
            entry: path.resolve(__dirname, 'src/lib/index.ts'),
            name: 'SlotMachine',
            formats: ['es', 'umd'],
            fileName: (format) => `react-slot-machine.${format}.js`,
        },
        rollupOptions: {
            external: ['react', 'react-dom', 'framer-motion'],
            output: {
                globals: {
                    react: 'React',
                    'react-dom': 'ReactDOM',
                    'framer-motion': 'FramerMotion',
                },
            },
        },
    },
});

위 링크에서 파일 이름과 external dependency만 바꿔줬다.
node:path에서 오류가 발생한다면 @types/nodedevDependencies로 설치해주면 된다.

3. package.json

기존 package.jsondependenciespeerDependencies에 넣어준다.
이는 라이브러리의 사용자들에게 해당 라이브러리를 사용하기 위해서는 peerDependency에 명시된 모듈이 필요하다고 말해주는 것이다.

"peerDependencies": {
  "framer-motion": "^10.2.4",
  "react": "^16.8.0",
  "react-dom": "^16.8.0"
},

만약 dependencies에 있는 react가 라이브러리 유저 프로젝트의 react와 버전이 다르다면 불필요하게 또 설치하게 되는데, peerDependencies에 호환되는 버전 범위를 명시해주면 사용자가 다른 버전의 같은 모듈을 설치하는 것을 방지해준다.

하단에 배포 관련 정보 또한 추가해준다.

"files": [
  "dist"
],
"main": "./dist/react-slot-machine.umd.js",
"module": "./dist/react-slot-machine.es.js",
"types": "./dist/index.d.ts",
"exports": {
  ".": {
    "import": "./dist/react-slot-machine.es.js",
    "require": "./dist/react-slot-machine.umd.js"
  }
}

각각 요소는 다음과 같은 의미를 가진다.
"files" - 배포된 패키지에 포함되는 디렉토리/파일
"main" - CommonJS (e.g. Node.js) 환경에서의 패키지 진입점
umd - Universal Module Definition의 약자로, 모듈 코드를 모듈이 로딩되는 환경을 감지하고 그에 맞게 모듈 API를 노출하는 함수로 감싼 것. Node.js와 브라우저 환경 모두에서 사용되는 모듈에 특히 유용하다고 한다.
"module" - ES6 (e.g. 모던 브라우저) 환경에서의 패키지 진입점
"types" - 패키지의 TypeScript 선언 파일 경로
"exports" - CommonJS와 ES6 환경 모두 호환되는 패키지의 module exports

4. 컴포넌트 코드

src/lib 디렉토리에 컴포넌트를 붙여넣고 index.ts를 만들어준다.

// lib/index.ts

export { default as SlotMachine } from './SlotMachine'

5. 빌드

yarn build로 빌드한다.

6. 배포

npm 공식 문서를 참고했다.
프로젝트의 root에서 npm 계정으로 로그인하고 npm publish를 하면 된다.
중간에 403 에러가 발생했는데, 이미 존재하는 라이브러리와 이름이 중복돼서 발생한 문제였다.
원래는 이름을 react-slot-machine으로 하고 싶었는데,
아쉽지만 react-slot-machine-text로 바꿨다.

결과

새 프로젝트를 만들고 import 해 봤는데 잘 작동하는 모습이다.
많은 사람들이 사용하면 좋겠다는 마음으로 readme도 채워넣었다.
npm & GitHub

0개의 댓글