|
2 | 2 | title: React Transition Group 뜯어보기 |
3 | 3 | createdAt: 2024-10-15 |
4 | 4 | category: React |
5 | | -description: React Transition Group 은 어떻게 동작할까요? 내부 동작 원리와 사용법에 대해 알아봅니다 |
| 5 | +description: 디자인학과 졸업전시 사이트를 만들면서 React Transition Group 을 통해 DOM 개수를 줄여보면서 React Transition Group 은 어떻게 동작하는지 살펴보았습니다. |
6 | 6 | comment: true |
| 7 | +head: |
| 8 | + - - meta |
| 9 | + - name: keywords |
| 10 | + content: React, React Transition Group, TransitionGroup, CSSTransition, 애니메이션, React 애니메이션 |
7 | 11 | --- |
8 | 12 |
|
9 | | -:::warning |
10 | | -아직 작성중이거나 검토중인 글입니다. 내용이 부정확하거나 변경될 수 있습니다 |
| 13 | +디자인학과 졸업전시 웹사이트를 만들면서, 전체 페이지에 수평 아코디언 전환 애니메이션이 필요했습니다. |
| 14 | + |
| 15 | +<img src="./img/react-transition-group/design-exhibition-site.png" width="800px" alt="디자인학과 졸업전시 웹사이트"/> |
| 16 | + |
| 17 | +처음 접근은 단순히 다른 아코디언 애니메이션처럼 단순하게 접근했습니다. <br/> |
| 18 | +모든 페이지 컴포넌트를 한 번에 전부 마운트해두고, 각 섹션의 `max-width` 만 조절하면서 열리고 닫히는 애니메이션을 구현했습니다. |
| 19 | + |
| 20 | +## 🩸 사건 현장 |
| 21 | + |
| 22 | +그런데 웬걸.. Lighthouse에서 DOM 요소가 3,495개라는 경고가 뜨고, TBT(Total Blocking Time)도 높고 메모리 사용량도 계속 늘어나는 현상이 발생하는게 아니겠습니까? |
| 23 | + |
| 24 | +<div style="display: flex; width:100%; background:#151515;"> |
| 25 | + <img src="./img/react-transition-group/problem-1.png" style=" width:50%; object-fit:contain" alt="문제 상황 1"/> |
| 26 | + <img src="./img/react-transition-group/problem-2.png" style="width:50%; object-fit:contain" alt="문제 상황 2"/> |
| 27 | +</div> |
| 28 | + |
| 29 | +무엇보다 `안 보이는 페이지도 계속 DOM 요소로써 남아있다`는 점이 문제였습니다. <br/> |
| 30 | +애니메이션이 끝나도 컴포넌트는 언마운트 되지 않으니까, 브라우저 입장에서는 그냥 페이지 여러 개를 동시에 돌리고 있는 셈이었습니다. |
| 31 | + |
| 32 | +> 애니메이션은 자연스럽게 유지하되, 화면 바깥으로 나간 페이지는 DOM 에서 치워버릴 수 없을까? |
| 33 | +
|
| 34 | +`FramerMotion`, `React Spring` 등 여러 애니메이션 라이브러리를 살펴봤지만, 아코디언 애니메이션 하나를 위해서 번들 사이즈가 크기도 했고, 디자이너가 딱 원하는 애니메이션을 구현하기가 어려웠습니다. <br/> |
| 35 | + |
| 36 | +결국 `React Transition Group` 라이브러리를 사용해서 이 문제를 해결할 수 있었습니다. <br/> |
| 37 | +하지만, 공식문서가 ~~불친절~~ 해서 내부 동작 방식을 직접 살펴보고, 어떻게 애니메이션이 종료된 이후 컴포넌트가 언마운트 되는지 뒤적거려봤습니다. |
| 38 | + |
| 39 | +## 🕵️♀️ React Transition Group |
| 40 | + |
| 41 | +> React Transition Group은 컴포넌트가 들어오고 나갈 때(마운트/언마운트 시점)의 전환을 정의할 수 있도록 여러 간단한 컴포넌트를 제공합니다. <br/><br/> |
| 42 | +> React Transition Group은 React-Motion 같은 애니메이션 라이브러리가 아닙니다. 이 라이브러리 자체가 스타일을 직접 애니메이션시키지는 않습니다. <br/><br/> |
| 43 | +> 대신에, 이 라이브러리는 전환(transition)의 각 단계 정보를 노출하고, 클래스 이름을 붙이거나 떼고, 요소들을 그룹화하고, DOM을 적절히 조작해 줍니다. 덕분에 실제 시각적인 전환 애니메이션을 구현하는 작업이 훨씬 쉬워집니다. |
| 44 | +
|
| 45 | +[React Transition Group](https://reactcommunity.org/react-transition-group/) 공식 문서를 들어가보면 크게 `Transition`, `CSSTransition`, `SwitchTransition`, `TransitionGroup` 네 가지 컴포넌트를 제공한다고 나와있습니다. |
| 46 | + |
| 47 | +이제부터 이 네가지 컴포넌트에 대해 하나씩 살펴보겠습니다. |
| 48 | + |
| 49 | +## 1️⃣ `<Transition/>` : 이 요소를 지금 보여줄꺼야? 숨길꺼야? |
| 50 | + |
| 51 | +`<Transition/>` 컴포넌트는 React Transition Group 의 가장 기본이 되는 컴포넌트 입니다. <br/> |
| 52 | +이 컴포넌트는 자식 컴포넌트를 지금 보여줄꺼야? 숨길꺼야? 라는 상태를 `in` prop 으로 받아서, 컴포넌트의 라이프사이클을 상태머신으로 관리합니다. |
| 53 | + |
| 54 | +### ⚙️ `in: boolean` |
| 55 | + |
| 56 | +- `in` prop 은 boolean 타입으로, `true` 면 자식 컴포넌트를 보여주고, `false` 면 숨깁니다. |
| 57 | +- 이 값을 기반으로 `<Transition/>` 은 내부적으로 `entering`, `entered`, `exiting`, `exited` 네 가지 상태로 전환됩니다. |
| 58 | + |
| 59 | +### ⚙️ `mountOnEnter: boolean`, `unmountOnExit: boolean` |
| 60 | + |
| 61 | +- `mountOnEnter` : `in` 이 처음으로 `true` 가 되기전에 컴포넌트를 마운트하지 않습니다. |
| 62 | +- `unmountOnExit` : `in` 이 `false` 가 된 후에 컴포넌트를 언마운트합니다. |
| 63 | + |
| 64 | +> 이 두 옵션을 사용하면, 컴포넌트가 화면에 보일 때만 마운트되고, 화면에서 사라질 때 언마운트 되도록 할 수 있습니다. |
| 65 | +
|
| 66 | +### ⚙️ `appear: boolean` |
| 67 | + |
| 68 | +- 처음 마운트될 때도 등장 애니메이션을 적용할지 여부를 결정합니다. (기본값은 `false`) |
| 69 | + |
| 70 | +### ⚙️ `enter: boolean`, `exit: boolean` |
| 71 | + |
| 72 | +- 각 애니메이션을 아예 비활성화 할 수 있습니다 |
| 73 | +- `enter : false` 인 경우 들어올때 애니메이션 없이 바로 `entered` 상태로 전이됩니다. |
| 74 | +- `exit : false` 인 경우 나갈때 애니메이션 없이 바로 `exited` 상태로 전이됩니다. |
| 75 | + |
| 76 | +### ⚙️ `timeout: number` 와 `addEndListener` |
| 77 | + |
| 78 | +- React Transition Group 은 애니메이션이 언제 끝났는지 알아야 다음 상태로 전이 할 수 있습니다 |
| 79 | +- `timeout : number` : 지정된 ms 이후에 애니메이션이 끝났다고 간주합니다. |
| 80 | +- `addEndListener(node, done)` : DOM 노드의 실제 애니메이션 완료 이벤트를 잡아서 `done()` 을 호출해주는 방식이빈다. |
| 81 | + |
| 82 | +```tsx |
| 83 | +<Transition |
| 84 | + in={show} |
| 85 | + addEndListener={(node, done) => { |
| 86 | + node.addEventListener("transitioned", done); |
| 87 | + }} |
| 88 | +/> |
| 89 | +``` |
| 90 | + |
| 91 | +### 🔁 상태 전이 |
| 92 | + |
| 93 | +결국 `<Transition/>` 컴포넌트의 핵심은 각 props 에 따른 상태 전이입니다. <br/> |
| 94 | +텍스트로만 설명하면 잘 이해가 안되니... 상태 전이 다이어그램을 보면 이해가 쉽습니다 |
| 95 | + |
| 96 | +~~사실 프론트같은거 좋아하는 이유도 이렇게 시각적으로 뭔가 정리하는게 이해가 쉽고 재밌어서 그렇습니다 ㅎㅎ~~ |
| 97 | + |
| 98 | +<img src="./img/react-transition-group/transition.webp" width="600px" alt="Transition 상태 전이 다이어그램"/> |
| 99 | + |
| 100 | +:::details 🙋♂️ 상태 전이 다이어그램에서 `[]` 는 뭔가요? - Guard |
| 101 | +`[]` 표시는 가드(Guard) 조건을 의미합니다. <br/> |
| 102 | +가드 조건은 상태 전이가 발생하기 위한 추가적인 조건을 나타냅니다. <br/> |
| 103 | +예를 들어, 가장 위에 보이는 `[mountOnEnter === true]` 는 초기 상태에서 `mountOnEnter` props 가 `true` 일 때만 `unmounted` 상태로 전이된다는 의미입니다. <br/> |
11 | 104 | ::: |
12 | 105 |
|
13 | | -## 서론 |
| 106 | +## 2️⃣ `<CSSTransition/>` : CSS 클래스 이름 알아서 붙여줄게 ~ |
| 107 | + |
| 108 | +`<CSSTransition/>` 컴포넌트는 `<Transition/>` 컴포넌트를 확장한 컴포넌트로, CSS 클래스 이름을 사용하여 애니메이션을 제어할 수 있도록 도와줍니다. <br/> |
| 109 | +차이점은 하나입니다. `<Transition/>` 컴포넌트는 타이밍만 제어하고 스타일은 직접 `on*` 콜백 props 를 사용하여 직접 제어해야 하지만, `<CSSTransition/>` 컴포넌트는 각 상태에 맞는 CSS 클래스 이름을 자동으로 추가/제거 해준다는 점입니다. |
| 110 | + |
| 111 | +개발자는 그냥 `classNames` prefix 를 기준으로 CSS 만 정의해두면 됩니다. |
| 112 | + |
| 113 | +```tsx |
| 114 | +<CSSTransition in={show} timeout={300} classNames="prefix" mountOnEnter unmountOnExit> |
| 115 | + <Component /> |
| 116 | +</CSSTransition> |
| 117 | +``` |
| 118 | + |
| 119 | +이렇게만 써두면, 상태 전이에 따라 다음과 같은 클래스들이 자동으로 붙습니다 |
| 120 | + |
| 121 | +1. Enter 시 |
| 122 | + |
| 123 | +- `prefix-enter` |
| 124 | +- 이후 바로 `prefix-enter-active` |
| 125 | +- 완료 후 `prefix-enter-done` |
| 126 | + |
| 127 | +2. Exit 시 |
| 128 | + |
| 129 | +- `prefix-exit` |
| 130 | +- 이후 바로 `prefix-exit-active` |
| 131 | +- 완료 후 `prefix-exit-done` |
| 132 | + |
| 133 | +### ⚙️ `appear: boolean` 에 따른 classNames |
| 134 | + |
| 135 | +`appear:true` 를 설정한 경우, 마운트 초기에도 `prefix-appear`, `prefix-appear-active`, `prefix-appear-done` 클래스가 적용 됩니다. |
| 136 | + |
| 137 | +이를 통해 첫 등장 애니메이션은 쪼금 다르게 줄 수도 있습니다. |
| 138 | + |
| 139 | +### 🤨 왜 클래스가 두번에 나눠서 붙지 (reflow 트릭) |
| 140 | + |
| 141 | +애니메이션을 자연스럽게 시작하려면 `초기 상태 클래스` 와 `활성 상태 클래스` 를 서로 다른 프레임에 붙여야 합니다. |
| 142 | + |
| 143 | +`<CSSTransition/>` 컴포넌트는 대략 이런식으로 동작합니다 |
| 144 | + |
| 145 | +1. `onEnter` 시점에 `prefix-enter` 클래스를 붙여둔다 |
| 146 | +2. 다음 프레임에서 강제로 한번 reflow 를 발생시킨다 (`node.offset*` 같은 코드로 강제로 layout 을 읽어 브라우저가 현재 스타일을 계산하게 만듭니다) |
| 147 | +3. 그 다음에 `prefix-enter-active` 클래스를 붙인다 (최종 스타일) |
| 148 | + |
| 149 | +:::info |
| 150 | +두개의 클래스를 동시에 붙이면, 브라우저가 시작 상태를 못 잡고 그냥 최종 상태로 바로 넘어가 버리는 경우가 있어서 일부러 reflow 로 타이밍을 나눠줍니다! |
| 151 | +::: |
| 152 | + |
| 153 | +`exit` 도 동일한 방식으로 동작합니다 |
| 154 | + |
| 155 | +## 3️⃣ `<TransitionGroup/>` : 여러 컴포넌트의 입장과 퇴장을 관리해줭 |
| 156 | + |
| 157 | +`<Transition/>` 과 `<CSSTransition/>` 컴포넌트는 하나의 컴포넌트가 들어오고 나가는 순간을 관리합니다. |
| 158 | + |
| 159 | +근데 실제 애니메이션에서는 여러 컴포넌트가 동시에 들어오고 나가는 경우가 많습니다. <br/> |
| 160 | +여러 컴포넌트의 진입, 퇴장 타이밍을 관리해줘야 하는거죠 |
| 161 | + |
| 162 | +이걸 해결해주는 친구가 `<TransitionGroup/>` 컴포넌트입니다. |
| 163 | + |
| 164 | +```tsx |
| 165 | +<TransitionGroup component={null}> |
| 166 | + {items.map((item) => { |
| 167 | + return ( |
| 168 | + <CSSTransition |
| 169 | + key={item.id} |
| 170 | + timeout={300} |
| 171 | + classNames="prefix" |
| 172 | + mountOnEnter={true} |
| 173 | + unmountOnExit={true} |
| 174 | + > |
| 175 | + <ItemComponent item={item} /> |
| 176 | + </CSSTransition> |
| 177 | + ); |
| 178 | + })} |
| 179 | +</TransitionGroup> |
| 180 | +``` |
| 181 | + |
| 182 | +### ⚙️ `key` |
| 183 | + |
| 184 | +`<TransitionGroup/>` 컴포넌트는 key 를 기준으로 어떤 컴포넌트가 새로 들어왔는지, 어떤 컴포넌트가 나갔는지를 판단합니다. <br/> |
| 185 | + |
| 186 | +1. TransitionGroup 은 children 의 key 목록을 기억해 둡니다. |
| 187 | +2. 다음 렌더링시 key 의 목록이 바뀌었는지 비교합니다. |
| 188 | + - 새로 생긴 key : 새로운 children 컴포넌트가 들어온 것으로 판단하고 `in=true` 로 설정해서 enter 애니메이션을 시작합니다. |
| 189 | + - 없어진 key : 사라질 children 컴포넌트가 나간 것으로 판단하고 DOM 에서 바로 제거하지 않고, `in=false` 를 주면서 exit 애니메이션을 먼저 실행합니다. (사라지는 애를 바로 안지우고 퇴장 애니메이션을 실행할 시간을 줌) |
| 190 | +3. exit 애니메이션이 끝나는 시점에만 state 에서 제거해서 실제 DOM 에서도 제거합니다. |
| 191 | + |
| 192 | +### ⚙️ `component` |
| 193 | + |
| 194 | +기본적으로 `<TransitionGroup/>` 컴포넌트는 `<div>` 래퍼를 사용합니다. <br/> |
| 195 | +`null` 을 주면 자식만 렌더링할 수 있습니다. |
| 196 | + |
| 197 | +## 4️⃣ `<SwitchTransition/>` : 한 번에 하나의 컴포넌트만 보여줄꺼양 |
| 198 | + |
| 199 | +`<SwitchTransition/>` 컴포넌트는 항상 딱 하나의 자식 컴포넌트가 존재하고 그걸 바꿀 때 애니메이션 하고 싶다는 상황에 쓰이는 컴포넌트입니다. |
| 200 | + |
| 201 | +예를들어, 한장 사라지고 다른 한장이 나타나는 슬라이드쇼 같은 상황이 있겠네요 |
| 202 | + |
| 203 | +이 컴포넌트는 `mode` 라는 props 로 전환 순서를 결정합니다. |
| 204 | + |
| 205 | +### ⚙️ `mode : "out-in" | "in-out"` |
| 206 | + |
| 207 | +- `out-in` |
| 208 | + - 현재 컴포넌트의 exit 애니메이션을 완전히 끝내고 |
| 209 | + - 다 사라진 다음 새로운 자식이 enter 애니메이션으로 등장합니다. |
| 210 | +- `in-out` |
| 211 | + - 새로운 자식이 enter 애니메이션으로 먼저 들어오고 |
| 212 | + - 다 자리잡은 다음에 기존 자식에게 exit 애니메이션을 실행합니다. |
| 213 | + |
| 214 | +## 📲 (실험) 모바일 화면 전환 애니메이션을 만들어보자 |
| 215 | + |
| 216 | +이제까지 살펴본 내용을 바탕으로, 모바일 화면 전환 애니메이션을 구현해보겠습니다. <br/> |
| 217 | + |
| 218 | +:::info 요구사항 |
| 219 | + |
| 220 | +- 새로운 화면은 오른쪽에서 왼쪽으로 슬라이드 인 |
| 221 | +- 기존 화면은 왼쪽으로 슬라이드 아웃 |
| 222 | +- 두 화면이 동시에 전환됨 |
| 223 | + ::: |
| 224 | + |
| 225 | +여러 자식 컴포넌트가 동시에 들어오고 나가는 상황이므로 `<TransitionGroup/>` 컴포넌트를 사용하면 되겠네요 |
| 226 | + |
| 227 | +```tsx |
| 228 | +import { TransitionGroup, CSSTransition } from "react-transition-group"; |
| 229 | +import { Routes, Route, useLocation } from "react-router-dom"; |
| 230 | + |
| 231 | +function App() { |
| 232 | + const location = useLocation(); |
| 233 | + |
| 234 | + return ( |
| 235 | + <TransitionGroup className="page-wrapper"> |
| 236 | + <CSSTransition |
| 237 | + key={location.key} |
| 238 | + classNames="slide" |
| 239 | + timeout={300} |
| 240 | + mountOnEnter |
| 241 | + unmountOnExit |
| 242 | + > |
| 243 | + <Routes location={location}> |
| 244 | + <Route path="/" element={<HomePage />} /> |
| 245 | + <Route path="/about" element={<AboutPage />} /> |
| 246 | + {/* ... */} |
| 247 | + </Routes> |
| 248 | + </CSSTransition> |
| 249 | + </TransitionGroup> |
| 250 | + ); |
| 251 | +} |
| 252 | +``` |
| 253 | + |
| 254 | +### ⚠️ 여기서 중요한 포인트 |
| 255 | + |
| 256 | +- `key={location.key}` : 라우팅 할때 마다 key가 바뀌므로, `<TransitionGroup/>` 입장에서 "이전 화면 하나", "새 화면 하나" 라는 두 컴포넌트를 동시에 관리 할 수 있게 합니다 |
| 257 | +- `mountOnEnter`, `unmountOnExit` : 현재 보여줄 화면만 DOM 에 남기고, 이전 화면은 애니메이션이 끝나는 순간 언마운트 되도록 합니다 |
| 258 | + |
| 259 | +### 🎨 CSS 를 작성해보자 |
| 260 | + |
| 261 | +이제 prefix 에 따른 CSS 만 작성해주면 됩니다 |
| 262 | + |
| 263 | +```css |
| 264 | +/* 부모: 겹쳐서 보여줄 수 있도록 relative */ |
| 265 | +.page-wrapper { |
| 266 | + position: relative; |
| 267 | + overflow: hidden; |
| 268 | +} |
14 | 269 |
|
15 | | -디자인학과 졸업전시 웹사이트 프젝 진행 중 |
| 270 | +/* 각 페이지는 겹칠 수 있도록 absolute */ |
| 271 | +.page-wrapper > * { |
| 272 | + position: absolute; |
| 273 | + top: 0; |
| 274 | + left: 0; |
| 275 | + width: 100%; |
| 276 | + height: 100%; |
| 277 | +} |
16 | 278 |
|
17 | | -전체 페이지에 수평 아코디언 애니메이션을 넣으려고 |
18 | | -처음엔 모든 페이지를 한 번에 마운트해서 max-width로 접음 |
19 | | -근데 Lighthouse에서 DOM 요소 3,495개 경고 뜨고 |
20 | | -TBT 높고 메모리 사용량도 계속 늘어남 |
21 | | -언마운트 ? |
| 279 | +/* Enter (새 화면이 오른쪽에서 들어옴) */ |
| 280 | +.slide-enter { |
| 281 | + transform: translateX(100%); /* 시작: 오른쪽 바깥 */ |
| 282 | +} |
| 283 | +.slide-enter-active { |
| 284 | + transform: translateX(0%); |
| 285 | + transition: transform 300ms ease; |
| 286 | +} |
| 287 | +.slide-enter-done { |
| 288 | + transform: translateX(0%); |
| 289 | +} |
22 | 290 |
|
23 | | -## 문제 접근방법 |
| 291 | +/* Exit (이전 화면이 왼쪽으로 밀려나감) */ |
| 292 | +.slide-exit { |
| 293 | + transform: translateX(0%); /* 시작: 현재 위치 */ |
| 294 | +} |
| 295 | +.slide-exit-active { |
| 296 | + transform: translateX(-100%); /* 왼쪽 바깥으로 나감 */ |
| 297 | + transition: transform 300ms ease; |
| 298 | +} |
| 299 | +.slide-exit-done { |
| 300 | + transform: translateX(-100%); |
| 301 | +} |
| 302 | +``` |
24 | 303 |
|
25 | | -React Transition Group으로 상태 전이에 따라 in=true/false로 애니메이션 시도 |
26 | | -컴포넌트마다 enter/exit 애니메이션 끝나면 자동으로 언마운트 되도록 처리 |
27 | | -switch transition도 고려했지만, 여러 페이지를 동시에 다뤄야 해서 transitionGroup 선택 |
28 | | -CSS로 transform 기반 애니메이션 구현해서 reflow/repaint 최소화 |
| 304 | +### 🚀 어떻게 동작하나? |
29 | 305 |
|
30 | | -## 해결방법 |
| 306 | +1. 라우트 변경 => `<CSSTransition key={...}>` 가 새로운 화면을 하나 더 렌더 (TransitionGroup이 이전 화면은 in={false}, 새 화면은 in={true} 상태로 관리) |
| 307 | +2. 이전 화면에는 `.slide-exit` / 새 화면에는 `.slide-enter` 클래스가 각각 들어감 |
| 308 | +3. 다음 animation frame에서 `.slide-exit-active`, `.slide-enter-active` 클래스가 붙으면서 |
| 309 | + - 이전 화면: translateX(-100%)로 왼쪽으로 밀려나가고 |
| 310 | + - 새 화면: translateX(0)까지 오른쪽에서 슬라이드 인 |
| 311 | +4. 약 300ms 뒤 transition이 끝나면 |
| 312 | + - 이전 화면은 onExited => 언마운트 |
| 313 | + - 새 화면은 .slide-enter-done 상태로 정상적으로 남음 |
31 | 314 |
|
32 | | -`<TransitionGroup><CSSTransition>` 구조로 전환 흐름 제어 |
| 315 | +## 📖 참고 자료 |
33 | 316 |
|
34 | | -전환 시 DOM에 둘 다 남겨두고, 퇴장 페이지는 exit 애니메이션 후 DOM에서 제거 |
35 | | -새 페이지는 enter > entered 상태로 자연스럽게 등장 |
36 | | -css transform: translateX()로 GPU 가속 유도 |
37 | | -timeout 또는 transitionend 로 애니메이션 완료 감지 -> ,언마운트 |
| 317 | +- [React Transition Group 공식문서](https://reactcommunity.org/react-transition-group/) |
| 318 | +- [React Transition Group 소스코드](https://github.com/reactjs/react-transition-group) |
0 commit comments