October 03, 2023
원문 🔗:
kyleshevlin
[번역] UI Composition(합성)
AKA 한 걸음 뒤로 물러서서, 한 걸음 앞으로 나아가기
컴포지션으로 쉽게 해결할 수 있는 문제를 해결하기 위해, 지독한 조건부나 기괴한 CSS 또는 두 가지를 모두 사용한 컴포넌트를 자주 접하게 됩니다. 컴포넌트의 특정 부분이 어떤 책임을 가져야 하는지, 그리고 그 책임을 적절히 분리하고 적용하는 방법을 인식하는 데는 약간의 연습이 필요합니다. 예를 들어 보겠습니다.
Card 컴포넌트가 있다고 가정해 보겠습니다. Card에는 테두리가 있어야 하며, 내부 콘텐츠는 이 테두리로 채워져야 합니다. 재미를 위해 box-shadow를 추가해 보겠습니다. 다음과 같은 모양이 될 것입니다:
This is a Card
This is the Card's content. Notice it is padded from the border.
멋지네요! 하지만 곧 디자인 팀에서 Card에 기본적으로 hr 태그로 구분된 "섹션"을 만들 수 있기를 원한다는 사실을 알게 됩니다. 아무런 변경 없이 그렇게 하면 어떻게 될까요?
This is Section 1
This is the content of Section 1. Isn't it great?!
This is Section 2
This is the content of Section 2. Isn't it also great?!
우리가 원하는 것은 그런 것이 아닐 것입니다. 우리는 divider가 카드 전체 너비에 채워질 수 있기를 원하며, 각 섹션이 동일하게 패딩되기를 원합니다. 약간 hacky한 CSS를 사용해 볼 수도 있습니다. 만약 제가 이 컴포넌트가 있는 코드베이스에서 작업했다면, 다음과 같은 것을 보고도 놀라지 않았을 것입니다:
.card hr {
/* This matches the vertical padding of the wrap */
margin-top: 1rem;
margin-bottom: 1rem;
/* negative margin moves the `hr` to the left side of the box */
margin-left: -1rem;
/* 2rem accounts for left and right padding */
width: calc(100% + 2rem);
}이 방법은 동작은 하겠지만, 가장 강력한 솔루션은 아닙니다. hacky CSS를 작성하기보다는, 한발 물러서서 다시 전진해야 합니다. 컴포지션은 우리가 그렇게 하는 데 도움이 될 것입니다.
분명하지 않을 수도 있지만 Card에는 너무 많은 책임이 있습니다. 클래스, 객체 및 함수가 너무 많은 일을 할 수 있는 것처럼 요소의 스타일도 마찬가지입니다.
자세히 살펴보면 Card가 두 가지 작업을 별도로 수행하고 있음을 알 수 있습니다. 첫째, 모든 자식을 border와 box shadow로 감싸고 있습니다.
둘째, 이 래핑 border와 자식들 사이에 패딩을 적용하고 있습니다. 문제를 해결하려면 이러한 책임을 여러 컴포넌트로 분리해야 합니다.
Card를 Wrap, Section 및 Divider 컴포넌트가 있는 compound(복합 또는 결합) 컴포넌트로 분할해 보겠습니다.
Card.Wrap = function Wrap({ children }) {
return (
<div className="rounded border-2 border-gray-300 shadow-[8px_8px] shadow-accent">
{children}
</div>
)
}
Card.Section = function Section({ children }) {
return <div className="p-4">{children}</div>
}
Card.Divider = function Divider() {
return <hr className="border-t-2 border-t-gray-300" />
}이제 우리는 조각들을 올바르게 조합할 수 있습니다.
<Card.Wrap>
<Card.Section>
<Stack gap={1}>
<h3>This is Section 1</h3>
<p>This is the content for Section 1</p>
</Stack>
</Card.Section>
<Card.Divider />
<Card.Section>
<p>This is an entirely different section. Notice the padding works correctly!</p>
</Card.Section>
</Card.Wrap>This is Section 1
This is the content for Section 1
This is an entirely different section. Notice the padding and divider work correctly!
바로 여기서 Card에 과부하가 걸린 것을 인식하고, 이를 분리하는 작업이 바로 컴포넌트 기반 디자인 시스템을 구축하는 작업입니다. 이것이 바로 이러한 컴포넌트들을 견고하고 유연하게 만드는 방법입니다.
이를 좀 더 개선하기 위해 두 가지 단계를 수행할 수 있습니다. 원래의 Card 컴포넌트를 복합 컴포넌트의 정상적인 디폴트 구성으로 만들어 봅시다:
function Card({ children }) {
return (
<Card.Wrap>
<Card.Section>{children}</Card.Section>
</Card.Wrap>
)
}이렇게 하면 가장 간단한 Card가 필요할 때 바로 사용할 수 있습니다.
이를 개선할 수 있는 두 번째 방법은 컨텍스트를 사용하여 Card.Wrap 외부에서 Card.Section 또는 Card.Divider를 사용하는 것을 오류로 만드는 것입니다.
// Setting the default to false will let us trigger our Error if necessary
const CardContext = React.useContext(false)
const useCardContext = () => {
const context = React.useContext(CardContext)
// We'll set context to true in our Provider, which will avoid this condition
// We don't even need to return anything from this hook because we're not
// using the context value anyways.
if (!context) {
throw new Error(
'You may only use Card compound components inside of a `Card.Wrap` component',
)
}
}
Card.Wrap = function Wrap({ children }) {
return (
// By setting the value to true, we enable our
// compound components to work safely
<CardContext.Provider value={true}>
<div
style={{
border: '2px solid var(--colors-offsetMore)',
borderRadius: 4,
boxShadow: '8px 8px var(--colors-accent)',
}}
>
{children}
</div>
</CardContext.Provider>
)
}
Card.Section = function Section({ children }) {
useCardContext()
return <div style={{ padding: '1rem' }}>{children}</div>
}
Card.Divider = function Divider() {
useCardContext()
return <hr />
}여기서 한 단계 더 나아가 Section에 context provider의 또 다른 레이어를 추가하면, Section이 중첩되거나 Divider가 Section에 전달되는 것을 방지할 수 있습니다.
Card.Section = function Section({ children }) {
useCardContext()
return (
<CardContext.Provider value={false}>
<div style={{ padding: '1rem' }}>{children}</div>
</CardContext.Provider>
)
}여러분의 상상력이 이 문제가 단순한 Card 컴포넌트보다, 더 많은 영향을 미친다는 것을 이해하는 데 도움이 되기를 바랍니다. 너무 많은 스타일링 책임을 맡는 데에는 다른 위법 사항(offenses)이 있다는 것입니다. 가장 흔한 것 중 하나는 exporting margins이지만 이에 대한 글은 다음 기회로 미루겠습니다.
일부 UI가 원하는 방식으로 작동하도록 하는 데 어려움을 겪고 있다면, 한 발 물러서서 한 걸음 더 나아가십시오. 컴포넌트의 일부에 과부하가 걸리는지, 해당 책임을 분리해도 문제가 해결되지 않는지 살펴보세요.