원문: https://www.voorhoede.nl/en/blog/building-design-system-react-web-components/
리액트로 범용 디자인 시스템을 구축하여 모든 웹 애플리케이션이나 프레임워크에서 사용할 수 있다면 어떨까요? 우리는 리액트를 웹 컴포넌트로 컴파일하여 이를 달성했습니다. 저희의 방법을 함께 살펴보시죠.
고객에게 디자인 시스템을 제공하기 위해 컴포넌트를 한 번 작성하고 다른 웹 프레임워크로 구축된 여러 웹 애플리케이션에서 사용할 수 있는 범용 솔루션이 필요했습니다. 하나의 프레임워크(리액트)에서 컴포넌트를 작성하고 래퍼, 폴리필, 툴링을 조합하여 모든 컨텍스트에서 작동하도록 하는 간단한 아이디어였습니다. 예상보다 구현이 더 어려웠습니다. 하지만 결과에 만족하고 그 과정에서 배운 것을 공유하게 되어 기쁩니다.
저희 블로그 포스트인 디자인 시스템 컴포넌트를 위한 프레임워크 선택 방법에서 범용 컴포넌트를 만들기 위한 다양한 개념을 설명했는데, 그중 하나가 리액트를 사용하는 것입니다. 간단히 말해, 리액트(.tsx 파일)로 컴포넌트를 작성하고 프리액트 커스텀 엘리먼트와 결합한 래퍼(.wc.ts 파일)를 사용하여 웹 컴포넌트로 컴파일합니다. 이렇게 하면 디자인 시스템을 리액트 애플리케이션의 경우 리액트 컴포넌트로, 다른 모든 애플리케이션의 경우 웹 컴포넌트로 사용할 수 있습니다.
리액트를 사용하여 컴포넌트를 개발하고 프리액트 래퍼를 사용하여 웹 컴포넌트로 컴파일할 수도 있습니다.
이 글에서는 디자인 시스템을 보편적으로 사용할 수 있게 해주는 웹 컴포넌트에 중점을 두겠습니다. 이 개념이 어떻게 작동하는지 좀 더 자세히 살펴봅시다. 우선, 프리액트는 리액트를 대체할 수 있으며 두 가지 주목할 만한 이점이 있습니다. "프리액트의 작은 크기와 표준 우선 접근법은 웹 컴포넌트를 구축하는 데 훌륭한 선택입니다." 디자인 시스템에는 일반적으로 리액트가 제공하는 매력적인 부가 기능이 필요하지 않기 때문에 프리액트는 우리에게 훌륭한 옵션입니다.
Vite, Rollup 또는 package.json과 같은 툴링 구성을 사용하여 리액트를 프리액트로 대체할 수 있습니다.
{
...
"alias": {
"react": "preact/compat",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime"
},
...
}
프리액트를 설정을 구성했으니 이제 리액트 컴포넌트를 웹 컴포넌트로 전환할 수 있습니다. 모든 관련 파일을 디렉터리에서 번들로 묶어 줍니다. 사용자에게 경고 메시지를 표시하는 컴포넌트를 예로 들어보겠습니다.
components/alert/
alert.tsx ← 리액트 컴포넌트
alert.css ← 컴포넌트 스타일
alert.wc.ts ← 웹 컴포넌트 래퍼
참고: 디자인 시스템 패키지를 사용하는 개발자에게 최상의 경험을 제공하기 위해 항상 타입스크립트를 사용하여 디자인 시스템을 작성합니다. 하지만 예제를 간단하고 간결하게 유지하기 위해 타이핑을 생략했습니다. 또한 예제 디자인 시스템을 ACME라고 부르며 모든 곳에서 접두사로 'acme'를 사용합니다.
alert.tsx - 리액트를 사용하는 컴포넌트를 작성합니다.
// alert.tsx
import './alert.css'
const Alert = ({ children, type = 'info' }) => (
<div className={`
alert
alert--type-${ type }
`}>
{ children }
</div>
)
alert.wc.ts - preact-custom-element를 사용하여 리액트 컴포넌트를 웹 컴포넌트로 등록합니다.
// alert.wc.ts:
import register from 'preact-custom-element'
import Alert from ‘./Alert.tsx’
register(Alert, 'acme-alert', ['type'], { shadow: true })
HTML 페이지 또는 기타 프레임워크에서 웹 컴포넌트로 사용합니다.
<!-- 모든 HTML 페이지에서 사용합니다. -->
<acme-alert type=”warning”>My message.</acme-alert>
컴포넌트 빌드를 시작했을 때, 프리액트 커스텀 엘리먼트는 우리의 요구사항에 맞지 않는다는 결론에 빠르게 도달했습니다. 독립형(웹) 컴포넌트를 구축하도록 설계된 것 같지만, 우리는 스타일을 포함하고 상호 작용할 수 있는 컴포넌트 시스템을 구축하고자 했습니다. 이를 위해 프리액트 커스텀 엘리먼트를 커스텀 해 이벤트 처리를 지원하고 스타일링을 포함하는 기능을 추가했습니다.
컴포넌트는 빌딩 블록으로 기능하기 때문에 서로 통신할 수 있어야 합니다. 어트리뷰트는 이미 프리액트 커스텀 엘리먼트에 의해 프로퍼티로 변환되었지만, 그 반대 방향으로도 통신할 수 있어야 합니다.
예를 들어 알림 컴포넌트에 해제 버튼을 추가해 보겠습니다.
// alert.tsx
import './alert.css'
const Alert = ({ children, type = ‘info’, onDismiss }) => (
<div className={`
alert
alert--type-${type}
`}>
{ children }
<button type=”button” onClick={onDismiss}>
Dismiss
</button>
</div>
)
이를 위해 eventNames라는 옵션을 추가했습니다.
// alert.wc.ts:
import register from '@acme/register'
import Alert from ‘./Alert.tsx’
register({
component: Alert,
tagName: 'acme-alert',
propNames: [‘type'],
eventNames: ['onDismiss'],
shadow: true,
});
작동 방식은 이러한 콜백에 대한 프록시 함수를 생성하고, 이 함수가 호출될 때 사용자 정의 이벤트를 발생시키는 것입니다. 이 사용자 정의 이벤트의 이름은 eventNames로 전달된 값을 통해 생성됩니다. 이 경우 'onDismiss'는 'acme-dismiss'가 됩니다. 이는 웹 컴포넌트에서 발생하는 이벤트와 다른 버블링 이벤트와의 충돌을 방지하기 위한 사용자 정의 이벤트 이름입니다. 이 변경으로 이제 이벤트 핸들링을 지원하며 다음과 같이 사용할 수 있습니다.
// 어디서나 사용할 수 있습니다.
<acme-alert type=”warning”>My message.</acme-alert>
<script>
document.querySelector(‘acme-alert’)
.addEventListener(‘acme-dismiss’, (event) => ...)
</script>
디자인 시스템 컴포넌트를 어디서나 사용할 수 있도록 하려면 우리가 빌드하는 웹 컴포넌트가 페이지의 기존 스타일에 의해 오염되지 않아야 합니다. 프리액트 커스텀 엘리먼트는 Shadow DOM이 원치 않는 스타일을 차단할 수 있도록 지원하지만, 원하는 스타일을 포함할 방법은 제공하지 않습니다. 그래서 프리액트 커스텀 엘리먼트 헬퍼를 추가로 커스터마이징 했습니다.
인라인 스타일 목록을 포함한 새 웹 컴포넌트를 등록할 수 있습니다.
// alert.wc.ts:
import register from '@acme/register'
import Alert from ‘./Alert.tsx’
import alertStyles from './alert.css?inline'
register({
component: Alert,
tagName: 'acme-alert',
propNames: [‘type'],
eventNames: ['onDismiss'],
styles: [alertStyles],
shadow: true,
});
예제 컴포넌트에는 하나의 스타일시트만 있지만, 다른 컴포넌트들은 입력과 (공유된) 레이블 스타일이 필요한 입력 컴포넌트 같이 여러 스타일시트에 의존할 수 있습니다. 디자인 시스템의 모든 컴포넌트에 필요한 스타일시트(예: 리셋 스타일)는 등록 헬퍼 자체에 포함되어 있으므로 모든 개별 컴포넌트에 등록할 필요가 없습니다. 등록 헬퍼 내에서 모든 스타일은 구성 가능한 스타일시트(Constructible StyleSheet)로 결합 됩니다. 작성된 코드는 다음과 같습니다.
// @acme/register 내부에서 스타일 제어
const sheets = [
resetStyles,
otherHelperStyles,
...this.styles, // register를 통해 전달
].map((styles) => {
const sheet = new CSSStyleSheet();
sheet.replaceSync(styles);
return sheet;
});
this.root.adoptedStyleSheets = sheets;
Safari에서 구성 가능한 스타일시트 지원은 현재 실험 중이므로 현재로서는 폴리필이 필요합니다.
import 'construct-style-sheets-polyfill'
이 시점에서 리액트 컴포넌트가 웹 컴포넌트로 성공적으로 컴파일되었으므로 여기서 마무리할 수 있습니다. 하지만 저희는 고성능 프로젝트를 제공하기 위해 항상 노력하기 때문에 아직 해야 할 일이 더 남아 있습니다.
우리의 작업 설정에 따라 각 컴포넌트는 자체 프리액트 런타임을 번들링 합니다. 압축 및 압축 해제된 4kb의 프리액트는 리액트의 45kb(코어 + DOM)에 비하면 작지만, 컴포넌트가 수십 개에 달할 때는 여전히 그 규모가 커집니다.
모든 컴포넌트에 프리액트를 번들링하면 모든 컴포넌트가 독립적으로 작동할 수 있다는 장점이 있습니다. 또한 한 팀이 10개의 컴포넌트를 구현할 경우 번들에 40KB(gzip으로 압축됨)가 추가된다는 의미이기도 합니다. 이 추가 번들 크기는 동일한 프리액트 인스턴스의 10배로 구성됩니다. 이제 최적화할 시간입니다!
이 문제를 해결하기 위해 프리액트를 한 번 임포트하여 모든 하위 컴포넌트에서 사용할 수 있도록 하는 'app-provider'를 만들었습니다.
다음과 같이 사용할 수 있습니다.
// 프로바이더가 프리액트를 로드하고 노출합니다
<acme-app-provider>
<acme-component-a>...</acme-component-a>
<acme-component-b>...</acme-component-b>
<acme-component-c>...</acme-component-c>
</acme-app-provider>
'app-provider'는 (위에서 설명한 것처럼 프리액트의 별칭인) React와 ReactDOM을 가져와서 window 객체에 첨부합니다. 기존 프로퍼티와 충돌할 가능성을 배제하기 위해 라이브러리 이름 앞에 접두사를 붙입니다.
import React from 'react'
import ReactDOM from 'react-dom'
import { register } from '@acme/register'
// 다음과 같은 다른 공용 종속성도 포함합니다.
import 'construct-style-sheets-polyfill'
window.__ACME__React = React
window.__ACME__ReactDOM = ReactDOM
const AppProvider = ({ children }) => {
return <>{children}</>
}
register({
component: AppProvider,
tagName: 'acme-app-provider',
options: { shadow: true },
})
리액트에 대한 별칭을 프리액트로 구성했기 때문에 앱 프로바이더는 실제로 프리액트를 번들로 제공합니다. 마지막으로 다른 모든 컴포넌트에 대한 (Rollup) 빌드는 전역에 노출된 리액트(DOM)를 사용하도록 구성됩니다.
return {
output: {
format: 'iife',
// 리액트를 소비하는 대신 제공하도록
// app-provider에 대한 예외를 추가합니다.
...(!isAppProvider && {
globals: {
'react': '__ACME__React',
'react-dom': '__ACME__ReactDOM',
},
}),
// ...
}
이제 앱 프로바이더 내에 중첩된 모든 컴포넌트는 각 JS 종속성의 단일 버전을 공유합니다.
Shadow DOM은 JS 종속성에서와 같은 방식으로 컴포넌트 간에 공통 CSS를 공유할 수 없게 합니다. 그러나 사용자 정의 CSS 프로퍼티로 정의된 모든 디자인 시스템 토큰은 Shadow DOM을 통과할 수 있습니다. 따라서 모든 컴포넌트에서 이러한 사용자 정의 CSS 속성을 추출하여 단일 공용 테마 프로바이더를 통해 사용할 수 있도록 합니다.
다음과 같이 사용할 수 있습니다.
// 프로바이더는 CSS 변수를 포함하고 노출합니다.
<acme-theme-provider>
<acme-component-a>...</acme-component-a>
<acme-component-b>...</acme-component-b>
<acme-component-c>...</acme-component-c>
</acme-theme-provider>
이 테마 프로바이더의 또 다른 장점은 향후 밝은/어두운 테마 스위치를 더 쉽게 추가할 수 있다는 것입니다.
마지막으로 최적화하고 싶은 것이 하나 더 있는데, 바로 CSS 클래스 이름입니다. 특히 디자인 시스템에서는 컴포넌트와 그 모든 부분의 명확한 이름을 중요하게 생각합니다. 이는 개발과 문서화에서도 중요하지만, 프로덕션에서는 최종 사용자 경험을 우선시합니다. 프로덕션 환경에서 클래스 이름은 범위가 정해져 있고 충돌이 발생하지 않아야 하므로 무엇이든 사용할 수 있습니다. 긴 클래스 이름은 HTML과 스타일시트를 부풀리기 때문에 프로덕션에서는 클래스 이름을 최소화하여 디자인 시스템 전체 번들 크기의 약 5%를 더 절약하기로 했습니다.
개발 환경: 의미를 전달하는 긴 클래스 이름
프로덕션 환경: 효율적인 클래스 이름 지정으로 번들 크기 5% 절약
이 모든 것을 종합하면 이제 어디에서나 사용할 수 있는 컴포넌트가 포함된 디자인 시스템을 갖추게 되었습니다. 범위 지정 스타일링과 이벤트 처리를 지원하는 커스텀 컴파일러를 통해 리액트 컴포넌트를 웹 컴포넌트로 전환할 수 있습니다. 또한 디자인 시스템의 번들 크기를 최적화하기 위해 공유 앱 및 테마 제공업체로 설정을 확장했습니다.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!