[디자인 시스템] 개별 컴포넌트 라이브러리 실습

한낱·2024년 5월 19일
0

디자인 시스템

목록 보기
5/9
post-thumbnail

서론

시간이 좀 지나긴 했는데 지난 포스팅에서 개별 컴포넌트를 다운받을 수 있는 라이브러리들의 코드를 살펴보고 어떻게 구현했을까를 고민해보았다.
이번 포스팅에서는 컴포넌트 마다 npm에 배포하는 방법을 통해 구현되었던 NextUI나 RadixUI와 같은 라이브러리를 만드는 실습을 진행해보고자 한다.

개별 컴포넌트 라이브러리 실습

실습을 진행하기에 앞서 NextUI와 RadixUI 폴더 구조를 비교해보았을 때,

NEXT
packages
	|-components
		|-개별 컴포넌트
        	|-테스트 폴더
            |-src 폴더
            |-스토리 폴더
            마크다운 파일, package.json, 번들러 파일, tsconfig 파일
	|- core
    |- hooks
    |- storybook
    |- utilities
    
Radix
packages
	|- core
    	|- number
        |- primitive
        |- rect
    |- react
    	|- 컴포넌트
        	|- src 폴더
            마크다운 파일, package.json

으로 구성되어 있었다.

패키지 추가

터보레포를 사용해서 패키지를 추가하는 방법은 2가지가 존재한다.
1. 직접 원하는 패키지 폴더를 생성하고 해당 위치에서 init을 하거나 package.json을 직접 추가할 수 있다.
2. code generator를 사용할 수 있다.

처음 패키지를 추가해볼 때에는 1이 적합하지만, NextUI나 RadixUI처럼 컴포넌트 하나가 추가될 때 마다 여러 개의 파일이 추가되어야 할 때에는 code gen을 사용하여 자동화해놓는 것이 편하다.

직접 패키지 추가

1. 컴포넌트 생성 및 barrel 파일 생성

packages 내부에 원하는 폴더를 생성하고, 컴포넌트 파일과 barrel 파일을 생성한다.

배럴 파일이란?
import 구문이 보다 간결해질 수 있도록 모듈을 모아서 export하는 파일이다. 개발자 입장에서는 보다 간결한 코드를 작성할 수 있어서 좋지만, 성능상의 이슈를 초래할 수 있고, 대다수의 번들러에서 트리쉐이킹이 되지 않는 문제가 있어 신중하게 사용해야 한다.

2. 스토리 파일 생성

각 컴포넌트마다 하나의 패키지로 배포하게 되면 해당 컴포넌트가 정상적으로 동작하는지 확인할 수가 없다. 따라서, 스토리 파일을 생성하여 정상적으로 동작하는지 확인한다.

import React from "react";
import { Button } from "../src";

export default {
	title: "Components/Button",
	component: Button,
};

export const Default = () => <Button />;

3. rollup 설정 파일

라이브러리 배포를 위해서는 번들러가 필수적이다. 번들러 자체가 minify, treeshaking 등을 통해 용량을 줄여줄 수 있다는 장점도 있고, 유저들이 다양한 JS 환경에서 개발하기 때문에 이를 지원해주는 라이브러리가 될 수 있어 번들러를 설정한다.
이번 실습에서는 rollup으로 번들러를 사용해보았다.

import pkg from "./package.json";
import typescript from "rollup-plugin-typescript2";
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import postcss from "rollup-plugin-postcss";
import postcssPrefixer from "postcss-prefixer";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";

const extensions = [".js", ".jsx", ".ts", ".tsx", ".scss"];

process.env.BABEL_ENV = "production";

function setUpRollup({ input, output }) {
	return {
		input,
		exports: "named",
		output,
		watch: {
			include: "*",
			exclude: "node_modules/**",
		},
		plugins: [
			peerDepsExternal(),
			json(),
			resolve({ extensions }),
			commonjs({
				include: /node_modules/,
			}),
			typescript({ useTsconfigDeclarationDir: true }),
			postcss({
				extract: true,
				modules: true,
				sourceMap: true,
				use: ["sass"],
				plugins: [
					postcssPrefixer({
						prefix: `${pkg.name}__`,
					}),
				],
			}),
		],
		external: ["react", "react-dom"],
	};
}

export default [
	setUpRollup({
		input: "index.ts",
		output: {
			file: "dist/cjs.js",
			sourcemap: true,
			format: "cjs",
		},
	}),
	setUpRollup({
		input: "index.ts",
		output: {
			file: "dist/esm.js",
			sourcemap: true,
			format: "esm",
		},
	}),
];

4. package.json

package.json을 통해 패키지 정보와 의존성 정보 등을 표시해준다.

{
  "name": "@repo/button",
  "version": "0.0.0",
  "description": "",
  "keywords": [
    "button"
  ],
  "author": "ShinYoung-Kim <lukey0515@naver.com>",
  "license": "MIT",
  "main": "src/index.ts",
  "sideEffects": false,
  "files": [
    "dist"
  ],
  "publishConfig": {
    "access": "public"
  },
  "scripts": {
    "build": "rollup -c",
		"watch": "rollup -cw",
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
  },
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  },
  "devDependencies": {
    "@babel/core": "^7.24.5",
		"@rollup/plugin-commonjs": "^25.0.7",
		"@rollup/plugin-json": "^6.1.0",
		"@rollup/plugin-node-resolve": "^15.2.3",
		"@types/react": "^18.2.61",
		"babel-preset-react-app": "^10.0.1",
		"node-sass": "^9.0.0",
		"postcss": "^8.4.38",
		"postcss-loader": "^8.1.1",
		"postcss-prefixer": "^3.0.0",
		"rollup": "^4.17.2",
		"rollup-plugin-peer-deps-external": "^2.2.4",
		"rollup-plugin-postcss": "^4.0.2",
		"rollup-plugin-typescript2": "^0.36.0",
		"typescript": "^5.3.3",
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

5. tsconfig.json

필수적인 파일은 아니나, 타입스크립트를 지원하고자 한다면 tsconfig.json 파일을 작성해야 한다.

code gen 활용해보기

터보레포 공식 홈페이지를 확인해보면 기본적으로 제공되는 code generator가 있다.
이는 workspace를 생성해주는 code generator로 실행하면 package.json 파일과 readme 파일이 생성된다.
사용자는 이 기본 code gen을 사용하는 대신 원하는 기능을 넣어 custom generator를 만들어도 된다.
이를 사용하여 원하는 컴포넌트 이름을 입력하면 자동으로 컴포넌트 패키지를 구성하는 데에 필요한 모든 파일이 생기는 code generator를 만들어보겠다.

1. config 파일 생성

turbo/generators 위치에 config 파일을 생성해주었다.

import { PlopTypes } from "@turbo/gen";

export default function generator(plop: PlopTypes.NodePlopAPI): void {
	plop.setGenerator("example", {
		description: "An example Turborepo generator - creates a new file at the root of the project",
		prompts: [
			{
				type: "input",
				name: "file",
				message: "What is the name of the new file to create?",
				validate: (input: string) => {
					if (input.includes(".")) {
						return "file name cannot include an extension";
					}
					if (input.includes(" ")) {
						return "file name cannot include spaces";
					}
					if (!input) {
						return "file name is required";
					}
					return true;
				},
			},
			{
				type: "input",
				name: "componentName",
				message: "Enter component name:",
			},
		],
	});
}

예전에 plop.js라는 유사한 라이브러리를 본 적이 있었는데, 해당 라이브러리를 기반으로 turborepo에서 generator를 만든 것 같다.

2. 템플릿 생성

원하는 파일명에 hbs를 붙여 기본적인 템플릿을 생성한다.
템플릿을 작성하다보면 컴포넌트 이름이 필요해질 때가 있는데, 이는 방금 작성한 config 파일에서 지정한 이름으로 가져올 수 있다.

const {{componentName}} = () => {
	return <div>{{componentName}}</div>;
};

export default {{componentName}};

3. util 추가

위 hbs에서 아쉬운 점은 어떤 상황에서는 대문자로 시작된 컴포넌트 이름이 필요하고 어떤 상황에서는 소문자로 시작하는 컴포넌트 이름이 필요한데 이를 반영할 수 없다는 것이다.
NEXT UI의 코드를 참고하여 이를 가능하게 하는 코드를 적용해보았다.

// config.ts
import { PlopTypes } from "@turbo/gen";

const capitalize = (str: string) => {
	return str.charAt(0).toUpperCase() + str.slice(1);
};

const camelCase = (str: string) => {
	return str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
};

export default function generator(plop: PlopTypes.NodePlopAPI): void {
	plop.setHelper("capitalize", (text) => {
		return capitalize(camelCase(text));
	});

	plop.setHelper("camelCase", (text) => {
		return camelCase(text);
	});

	plop.setGenerator("example", {
		description: "An example Turborepo generator - creates a new file at the root of the project",
		prompts: [
			{
				type: "input",
				name: "file",
				message: "What is the name of the new file to create?",
				validate: (input: string) => {
					if (input.includes(".")) {
						return "file name cannot include an extension";
					}
					if (input.includes(" ")) {
						return "file name cannot include spaces";
					}
					if (!input) {
						return "file name is required";
					}
					return true;
				},
			},
			{
				type: "input",
				name: "componentName",
				message: "Enter component name:",
			},
		],
	});
}

설정을 위와 같이 변경하면, 템플릿에서 아래처럼 접근이 가능해진다.

const {{capitalize componentName}} = () => {
	return <div>{{componentName}}</div>;
};

export default {{capitalize componentName}};

4. 템플릿와 설정 파일 action 연결

이 상태에서는 code generator가 템플릿 파일의 존재를 모르기 때문에 action이라는 속성을 통해 둘을 연결해주어야 한다.

import { PlopTypes } from "@turbo/gen";

const capitalize = (str: string) => {
	return str.charAt(0).toUpperCase() + str.slice(1);
};

const camelCase = (str: string) => {
	return str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
};

export default function generator(plop: PlopTypes.NodePlopAPI): void {
	plop.setHelper("capitalize", (text) => {
		return capitalize(camelCase(text));
	});

	plop.setHelper("camelCase", (text) => {
		return camelCase(text);
	});

	plop.setGenerator("example", {
		description: "An example Turborepo generator - creates a new file at the root of the project",
		prompts: [
			{
				type: "input",
				name: "file",
				message: "What is the name of the new file to create?",
				validate: (input: string) => {
					if (input.includes(".")) {
						return "file name cannot include an extension";
					}
					if (input.includes(" ")) {
						return "file name cannot include spaces";
					}
					if (!input) {
						return "file name is required";
					}
					return true;
				},
			},
			{
				type: "input",
				name: "componentName",
				message: "Enter component name:",
			},
		],
		actions: [
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/src/index.ts",
				templateFile: "templates/component/src/index.ts.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/src/{{ capitalize componentName }}.tsx",
				templateFile: "templates/component/src/component.tsx.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/stories/{{ componentName }}.stories.tsx",
				templateFile: "templates/component/stories/component.stories.tsx.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/package.json",
				templateFile: "templates/component/package.json.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/README.md",
				templateFile: "templates/component/README.md.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/tsconfig.json",
				templateFile: "templates/component/tsconfig.json.hbs",
			},
			{
				type: "add",
				path: "{{ turbo.paths.root }}/packages/components/{{ dashCase file }}/rollup.config.ts",
				templateFile: "templates/component/rollup.config.ts.hbs",
			},
		],
	});
}

속성을 통해 템플릿 파일의 위치와 원하는 생길 파일의 위치를 지정해줄 수 있다.

5. 확인해보기

pnpm turbo gen이라는 명령어를 사용하면 위에서 생성한 커스텀 code gen을 사용할 수 있고, 그 결과로 다음과 같이 파일들이 자동으로 생성된다.

이 코드가 실제로 잘 동작하는지 확인하기 위해 스토리북으로 확인해보면,

다음과 같이 오류가 없는 것을 확인할 수 있다.

profile
제일 재밌는 개발 블로그(희망 사항)

0개의 댓글