@ilokesto/utilinent 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -1474
- package/dist/components/For.d.ts +8 -1
- package/dist/components/For.js +11 -4
- package/dist/components/Observer.js +2 -2
- package/dist/components/Repeat.d.ts +9 -2
- package/dist/components/Repeat.js +13 -4
- package/dist/components/Show.d.ts +13 -3
- package/dist/components/Show.js +12 -3
- package/dist/constants/htmlTags.d.ts +1 -0
- package/dist/constants/htmlTags.js +3 -0
- package/dist/experimental/Slot.d.ts +4 -0
- package/dist/experimental/Slot.js +69 -0
- package/dist/experimental.d.ts +5 -0
- package/dist/experimental.js +5 -0
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -4
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -1,1474 +1,283 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
```tsx
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
```tsx
|
|
285
|
-
type NotificationState =
|
|
286
|
-
| { status: "pending", message: string }
|
|
287
|
-
| { status: "sent", message: string }
|
|
288
|
-
| { status: "failed", message: string };
|
|
289
|
-
|
|
290
|
-
function NotificationStatus({ notification }: { notification: NotificationState }) {
|
|
291
|
-
const { Switch, Match } = createSwitcher(notification); // K는 자동으로 "status"로 추론
|
|
292
|
-
|
|
293
|
-
return (
|
|
294
|
-
<Switch when="status">
|
|
295
|
-
<Match case="pending">
|
|
296
|
-
{(props) => (
|
|
297
|
-
<div className="notification-pending">
|
|
298
|
-
<ClockIcon />
|
|
299
|
-
<span>{props.message}</span>
|
|
300
|
-
</div>
|
|
301
|
-
)}
|
|
302
|
-
</Match>
|
|
303
|
-
|
|
304
|
-
<Match case="sent">
|
|
305
|
-
{(props) => (
|
|
306
|
-
<div className="notification-success">
|
|
307
|
-
<CheckIcon />
|
|
308
|
-
<span>{props.message}</span>
|
|
309
|
-
</div>
|
|
310
|
-
)}
|
|
311
|
-
</Match>
|
|
312
|
-
|
|
313
|
-
<Match case="failed">
|
|
314
|
-
{(props) => (
|
|
315
|
-
<div className="notification-error">
|
|
316
|
-
<XIcon />
|
|
317
|
-
<span>{props.message}</span>
|
|
318
|
-
</div>
|
|
319
|
-
)}
|
|
320
|
-
</Match>
|
|
321
|
-
</Switch>
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
```
|
|
325
|
-
|
|
326
|
-
### 케이스 3: 여러 리터럴 필드가 있는 경우 (명시적 타입 지정 필요)
|
|
327
|
-
|
|
328
|
-
```tsx
|
|
329
|
-
type ComplexState =
|
|
330
|
-
| { status: "loading", priority: "high" }
|
|
331
|
-
| { status: "success", priority: "low" }
|
|
332
|
-
| { status: "error", priority: "medium" };
|
|
333
|
-
|
|
334
|
-
function ComplexStatus({ state }: { state: ComplexState }) {
|
|
335
|
-
// status와 priority 모두 리터럴 타입이므로 명시적으로 지정
|
|
336
|
-
const { Switch, Match } = createSwitcher<ComplexState, "status">(state);
|
|
337
|
-
|
|
338
|
-
return (
|
|
339
|
-
<Switch when="status" fallback={<div>알 수 없는 상태</div>}>
|
|
340
|
-
<Match case="loading">
|
|
341
|
-
{(props) => (
|
|
342
|
-
<div className={`loading priority-${props.priority}`}>
|
|
343
|
-
<Spinner />
|
|
344
|
-
<span>로딩 중... (우선순위: {props.priority})</span>
|
|
345
|
-
</div>
|
|
346
|
-
)}
|
|
347
|
-
</Match>
|
|
348
|
-
|
|
349
|
-
<Match case="success">
|
|
350
|
-
{(props) => (
|
|
351
|
-
<div className={`success priority-${props.priority}`}>
|
|
352
|
-
<CheckIcon />
|
|
353
|
-
<span>완료! (우선순위: {props.priority})</span>
|
|
354
|
-
</div>
|
|
355
|
-
)}
|
|
356
|
-
</Match>
|
|
357
|
-
|
|
358
|
-
<Match case="error">
|
|
359
|
-
{(props) => (
|
|
360
|
-
<div className={`error priority-${props.priority}`}>
|
|
361
|
-
<ErrorIcon />
|
|
362
|
-
<span>오류 발생 (우선순위: {props.priority})</span>
|
|
363
|
-
</div>
|
|
364
|
-
)}
|
|
365
|
-
</Match>
|
|
366
|
-
</Switch>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
---
|
|
372
|
-
|
|
373
|
-
# OptionalWrapper - 조건부 래퍼
|
|
374
|
-
|
|
375
|
-
**기존 방식의 문제점**
|
|
376
|
-
특정 조건에 따라 요소를 다른 컴포넌트로 감싸야 할 때, 기존 방식은 중복 코드를 유발하거나 가독성을 해칩니다.
|
|
377
|
-
|
|
378
|
-
```tsx
|
|
379
|
-
// ❌ 중복 코드가 발생하는 기존 방식
|
|
380
|
-
{isClickable ? (
|
|
381
|
-
<button onClick={handleClick}>
|
|
382
|
-
<img src={image} alt="thumbnail" />
|
|
383
|
-
</button>
|
|
384
|
-
) : (
|
|
385
|
-
<img src={image} alt="thumbnail" /> // 중복!
|
|
386
|
-
)}
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
**OptionalWrapper의 해결책**
|
|
390
|
-
`OptionalWrapper`는 조건에 따라 래퍼를 적용하거나 생략하는 패턴을 간단하고 재사용 가능하게 만듭니다.
|
|
391
|
-
|
|
392
|
-
```tsx
|
|
393
|
-
interface OptionalWrapperProps {
|
|
394
|
-
when: boolean; // 래퍼를 적용할 조건
|
|
395
|
-
children: React.ReactNode; // 감싸질 내용
|
|
396
|
-
wrapper: (children: React.ReactNode) => React.ReactNode; // 조건이 true일 때 적용할 래퍼 함수
|
|
397
|
-
}
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
**✅ OptionalWrapper를 사용한 개선된 방식**
|
|
401
|
-
```tsx
|
|
402
|
-
// 조건부 링크 래핑
|
|
403
|
-
<OptionalWrapper
|
|
404
|
-
when={hasUrl}
|
|
405
|
-
wrapper={(children) => <a href={url} target="_blank">{children}</a>}
|
|
406
|
-
>
|
|
407
|
-
<img src={image} alt="thumbnail" />
|
|
408
|
-
</OptionalWrapper>
|
|
409
|
-
|
|
410
|
-
// 조건부 버튼 래핑
|
|
411
|
-
<OptionalWrapper
|
|
412
|
-
when={isClickable}
|
|
413
|
-
wrapper={(children) => (
|
|
414
|
-
<button onClick={handleClick} className="clickable-wrapper">
|
|
415
|
-
{children}
|
|
416
|
-
</button>
|
|
417
|
-
)}
|
|
418
|
-
>
|
|
419
|
-
<ProductCard product={product} />
|
|
420
|
-
</OptionalWrapper>
|
|
421
|
-
|
|
422
|
-
// 조건부 스타일 컨테이너
|
|
423
|
-
<OptionalWrapper
|
|
424
|
-
when={isHighlighted}
|
|
425
|
-
wrapper={(children) => (
|
|
426
|
-
<div className="highlight-border p-4 bg-yellow-100">
|
|
427
|
-
{children}
|
|
428
|
-
</div>
|
|
429
|
-
)}
|
|
430
|
-
>
|
|
431
|
-
<ContentBlock content={content} />
|
|
432
|
-
</OptionalWrapper>
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
**🎯 실제 사용 사례**
|
|
436
|
-
```tsx
|
|
437
|
-
function MediaCard({ media, isInteractive }: { media: Media, isInteractive: boolean }) {
|
|
438
|
-
return (
|
|
439
|
-
<OptionalWrapper
|
|
440
|
-
when={isInteractive}
|
|
441
|
-
wrapper={(children) => (
|
|
442
|
-
<button
|
|
443
|
-
className="media-button"
|
|
444
|
-
onClick={() => openModal(media)}
|
|
445
|
-
aria-label={`${media.title} 상세보기`}
|
|
446
|
-
>
|
|
447
|
-
{children}
|
|
448
|
-
</button>
|
|
449
|
-
)}
|
|
450
|
-
>
|
|
451
|
-
<div className="media-content">
|
|
452
|
-
<img src={media.thumbnail} alt={media.title} />
|
|
453
|
-
<h3>{media.title}</h3>
|
|
454
|
-
<p>{media.description}</p>
|
|
455
|
-
</div>
|
|
456
|
-
</OptionalWrapper>
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
---
|
|
462
|
-
|
|
463
|
-
# Mount - 클라이언트 사이드 렌더링
|
|
464
|
-
|
|
465
|
-
**기존 방식의 문제점**
|
|
466
|
-
Next.js나 SSR 환경에서 DOM이 마운트된 후에만 실행되어야 하는 코드를 처리할 때, `useEffect`와 `useState`를 반복적으로 사용하게 되어 보일러플레이트 코드가 많아집니다.
|
|
467
|
-
|
|
468
|
-
```tsx
|
|
469
|
-
// ❌ 반복되는 보일러플레이트 코드
|
|
470
|
-
function ClientOnlyComponent() {
|
|
471
|
-
const [isMounted, setIsMounted] = useState(false);
|
|
472
|
-
const [content, setContent] = useState<ReactNode>("Loading...");
|
|
473
|
-
|
|
474
|
-
useEffect(() => {
|
|
475
|
-
setIsMounted(true);
|
|
476
|
-
const loadContent = async () => {
|
|
477
|
-
await someAsyncOperation();
|
|
478
|
-
setContent(<ActualContent />);
|
|
479
|
-
};
|
|
480
|
-
loadContent();
|
|
481
|
-
}, []);
|
|
482
|
-
|
|
483
|
-
if (!isMounted) {
|
|
484
|
-
return <div>Loading...</div>;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
return <>{content}</>;
|
|
488
|
-
}
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
**Mount 컴포넌트의 해결책**
|
|
492
|
-
`Mount` 컴포넌트는 클라이언트 사이드 렌더링과 비동기 작업을 간단하고 선언적으로 처리할 수 있게 해줍니다.
|
|
493
|
-
|
|
494
|
-
```tsx
|
|
495
|
-
interface MountProps {
|
|
496
|
-
fallback?: React.ReactNode; // 마운트 전 또는 로딩 중 표시할 내용
|
|
497
|
-
children: React.ReactNode | (() => React.ReactNode | Promise<ReactNode>); // 마운트 후 렌더링할 내용
|
|
498
|
-
}
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
**✅ Mount를 사용한 개선된 방식**
|
|
502
|
-
|
|
503
|
-
**기본 클라이언트 사이드 렌더링**
|
|
504
|
-
```tsx
|
|
505
|
-
// 간단한 클라이언트 전용 컴포넌트
|
|
506
|
-
<Mount fallback={<div>Loading...</div>}>
|
|
507
|
-
<ClientOnlyWidget />
|
|
508
|
-
</Mount>
|
|
509
|
-
|
|
510
|
-
// 브라우저 전용 API 사용
|
|
511
|
-
<Mount fallback={<div>Initializing...</div>}>
|
|
512
|
-
<GeolocationComponent />
|
|
513
|
-
</Mount>
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
**비동기 작업과 함께**
|
|
517
|
-
```tsx
|
|
518
|
-
// 기존 방식 (복잡함)
|
|
519
|
-
function OldWay() {
|
|
520
|
-
const [content, setContent] = useState("Loading...");
|
|
521
|
-
|
|
522
|
-
useEffect(() => {
|
|
523
|
-
const loadContent = async () => {
|
|
524
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
525
|
-
setContent("Loaded!");
|
|
526
|
-
};
|
|
527
|
-
loadContent();
|
|
528
|
-
}, []);
|
|
529
|
-
|
|
530
|
-
return <div>{content}</div>;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// ✅ Mount 사용 (간단함)
|
|
534
|
-
function NewWay() {
|
|
535
|
-
return (
|
|
536
|
-
<Mount fallback={<div>Loading...</div>}>
|
|
537
|
-
{async () => {
|
|
538
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
539
|
-
return <div>Loaded!</div>;
|
|
540
|
-
}}
|
|
541
|
-
</Mount>
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
```
|
|
545
|
-
|
|
546
|
-
**🎯 실제 사용 사례**
|
|
547
|
-
```tsx
|
|
548
|
-
// 차트 라이브러리 (클라이언트 전용)
|
|
549
|
-
<Mount fallback={<ChartSkeleton />}>
|
|
550
|
-
{async () => {
|
|
551
|
-
const chartData = await fetchChartData();
|
|
552
|
-
return <Chart data={chartData} />;
|
|
553
|
-
}}
|
|
554
|
-
</Mount>
|
|
555
|
-
|
|
556
|
-
// 지도 컴포넌트
|
|
557
|
-
<Mount fallback={<MapPlaceholder />}>
|
|
558
|
-
<MapComponent coordinates={coordinates} />
|
|
559
|
-
</Mount>
|
|
560
|
-
|
|
561
|
-
// 테마 의존적 컴포넌트
|
|
562
|
-
<Mount fallback={<div>테마 로딩 중...</div>}>
|
|
563
|
-
{() => {
|
|
564
|
-
const theme = getClientTheme();
|
|
565
|
-
return <ThemedComponent theme={theme} />;
|
|
566
|
-
}}
|
|
567
|
-
</Mount>
|
|
568
|
-
```
|
|
569
|
-
|
|
570
|
-
---
|
|
571
|
-
|
|
572
|
-
# Repeat - 횟수 기반 반복 렌더링
|
|
573
|
-
|
|
574
|
-
**기존 방식의 문제점**
|
|
575
|
-
|
|
576
|
-
특정 횟수만큼 컴포넌트를 반복 렌더링할 때, 기존 방식은 불필요한 배열을 생성하거나 복잡한 로직을 작성해야 합니다.
|
|
577
|
-
|
|
578
|
-
```tsx
|
|
579
|
-
// ❌ 불필요한 배열 생성
|
|
580
|
-
{Array(5).fill(null).map((_, index) => (
|
|
581
|
-
<SkeletonCard key={index} />
|
|
582
|
-
))}
|
|
583
|
-
|
|
584
|
-
// ❌ 복잡한 반복 로직
|
|
585
|
-
{(() => {
|
|
586
|
-
const items = [];
|
|
587
|
-
for (let i = 0; i < starCount; i++) {
|
|
588
|
-
items.push(<Star key={i} filled={i < rating} />);
|
|
589
|
-
}
|
|
590
|
-
return items;
|
|
591
|
-
})()}
|
|
592
|
-
```
|
|
593
|
-
|
|
594
|
-
**Repeat 컴포넌트의 해결책**
|
|
595
|
-
|
|
596
|
-
`Repeat` 컴포넌트는 횟수 기반 반복 렌더링을 간단하고 직관적으로 처리할 수 있게 해줍니다.
|
|
597
|
-
|
|
598
|
-
```tsx
|
|
599
|
-
interface RepeatProps {
|
|
600
|
-
times: number; // 반복 횟수
|
|
601
|
-
fallback?: React.ReactNode; // times가 0 이하일 때의 대체 내용
|
|
602
|
-
children: (index: number) => React.ReactNode; // 각 반복에서 렌더링할 함수
|
|
603
|
-
}
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
**✅ Repeat을 사용한 개선된 방식**
|
|
607
|
-
|
|
608
|
-
**기본 반복 렌더링**
|
|
609
|
-
```tsx
|
|
610
|
-
// 스켈레톤 UI 생성
|
|
611
|
-
<Repeat times={5} fallback={<div>로딩할 항목이 없습니다</div>}>
|
|
612
|
-
{(index) => <SkeletonCard key={index} delay={index * 200} />}
|
|
613
|
-
</Repeat>
|
|
614
|
-
|
|
615
|
-
// 평점 시스템
|
|
616
|
-
<Repeat times={5}>
|
|
617
|
-
{(index) => (
|
|
618
|
-
<Star
|
|
619
|
-
key={index}
|
|
620
|
-
filled={index < rating}
|
|
621
|
-
onClick={() => setRating(index + 1)}
|
|
622
|
-
/>
|
|
623
|
-
)}
|
|
624
|
-
</Repeat>
|
|
625
|
-
|
|
626
|
-
// 페이지네이션 번호
|
|
627
|
-
<Repeat times={totalPages}>
|
|
628
|
-
{(index) => {
|
|
629
|
-
const pageNumber = index + 1;
|
|
630
|
-
return (
|
|
631
|
-
<PageButton
|
|
632
|
-
key={pageNumber}
|
|
633
|
-
page={pageNumber}
|
|
634
|
-
active={currentPage === pageNumber}
|
|
635
|
-
onClick={() => setCurrentPage(pageNumber)}
|
|
636
|
-
/>
|
|
637
|
-
);
|
|
638
|
-
}}
|
|
639
|
-
</Repeat>
|
|
640
|
-
```
|
|
641
|
-
|
|
642
|
-
**🎯 실제 사용 사례**
|
|
643
|
-
|
|
644
|
-
```tsx
|
|
645
|
-
// 로딩 스켈레톤
|
|
646
|
-
function ProductListSkeleton() {
|
|
647
|
-
return (
|
|
648
|
-
<div className="grid grid-cols-3 gap-4">
|
|
649
|
-
<Repeat times={9}>
|
|
650
|
-
{(index) => (
|
|
651
|
-
<div key={index} className="animate-pulse">
|
|
652
|
-
<div className="bg-gray-300 h-48 rounded-lg mb-4"></div>
|
|
653
|
-
<div className="bg-gray-300 h-4 rounded mb-2"></div>
|
|
654
|
-
<div className="bg-gray-300 h-4 rounded w-2/3"></div>
|
|
655
|
-
</div>
|
|
656
|
-
)}
|
|
657
|
-
</Repeat>
|
|
658
|
-
</div>
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// 진행률 표시기
|
|
663
|
-
function ProgressDots({ total, current }: { total: number, current: number }) {
|
|
664
|
-
return (
|
|
665
|
-
<div className="flex space-x-2">
|
|
666
|
-
<Repeat times={total}>
|
|
667
|
-
{(index) => (
|
|
668
|
-
<div
|
|
669
|
-
key={index}
|
|
670
|
-
className={`w-3 h-3 rounded-full ${
|
|
671
|
-
index < current ? 'bg-blue-500' : 'bg-gray-300'
|
|
672
|
-
}`}
|
|
673
|
-
/>
|
|
674
|
-
)}
|
|
675
|
-
</Repeat>
|
|
676
|
-
</div>
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// 메뉴 아이템 생성
|
|
681
|
-
function NavigationMenu({ menuCount }: { menuCount: number }) {
|
|
682
|
-
return (
|
|
683
|
-
<nav className="flex space-x-4">
|
|
684
|
-
<Repeat times={menuCount} fallback={<div>메뉴가 없습니다</div>}>
|
|
685
|
-
{(index) => {
|
|
686
|
-
const menuItem = menuItems[index];
|
|
687
|
-
return (
|
|
688
|
-
<a
|
|
689
|
-
key={index}
|
|
690
|
-
href={menuItem?.href}
|
|
691
|
-
className="px-4 py-2 text-gray-700 hover:text-blue-600"
|
|
692
|
-
>
|
|
693
|
-
{menuItem?.label || `메뉴 ${index + 1}`}
|
|
694
|
-
</a>
|
|
695
|
-
);
|
|
696
|
-
}}
|
|
697
|
-
</Repeat>
|
|
698
|
-
</nav>
|
|
699
|
-
);
|
|
700
|
-
}
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
**🔧 유용한 패턴들**
|
|
704
|
-
|
|
705
|
-
```tsx
|
|
706
|
-
// 조건부 반복 (0일 때 fallback 표시)
|
|
707
|
-
<Repeat times={itemCount} fallback={<EmptyState />}>
|
|
708
|
-
{(index) => <Item key={index} data={items[index]} />}
|
|
709
|
-
</Repeat>
|
|
710
|
-
|
|
711
|
-
// 지연 애니메이션
|
|
712
|
-
<Repeat times={6}>
|
|
713
|
-
{(index) => (
|
|
714
|
-
<div
|
|
715
|
-
key={index}
|
|
716
|
-
className="fade-in"
|
|
717
|
-
style={{ animationDelay: `${index * 100}ms` }}
|
|
718
|
-
>
|
|
719
|
-
<Card />
|
|
720
|
-
</div>
|
|
721
|
-
)}
|
|
722
|
-
</Repeat>
|
|
723
|
-
|
|
724
|
-
// 인덱스 기반 스타일링
|
|
725
|
-
<Repeat times={4}>
|
|
726
|
-
{(index) => (
|
|
727
|
-
<div
|
|
728
|
-
key={index}
|
|
729
|
-
className={`col-span-${index % 2 === 0 ? '2' : '1'}`}
|
|
730
|
-
>
|
|
731
|
-
<GridItem />
|
|
732
|
-
</div>
|
|
733
|
-
)}
|
|
734
|
-
</Repeat>
|
|
735
|
-
```
|
|
736
|
-
---
|
|
737
|
-
|
|
738
|
-
# Observer - 뷰포트 감지
|
|
739
|
-
|
|
740
|
-
**기존 방식의 문제점**
|
|
741
|
-
뷰포트에 요소가 들어오거나 나가는 것을 감지하기 위해 직접 `IntersectionObserver` API를 사용하면 보일러플레이트 코드가 많아지고, cleanup 처리를 놓치기 쉽습니다.
|
|
742
|
-
|
|
743
|
-
```tsx
|
|
744
|
-
// ❌ 복잡한 기존 방식
|
|
745
|
-
function LazyImage({ src, alt }: { src: string, alt: string }) {
|
|
746
|
-
const [isVisible, setIsVisible] = useState(false);
|
|
747
|
-
const [hasLoaded, setHasLoaded] = useState(false);
|
|
748
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
749
|
-
|
|
750
|
-
useEffect(() => {
|
|
751
|
-
const element = ref.current;
|
|
752
|
-
if (!element) return;
|
|
753
|
-
|
|
754
|
-
const observer = new IntersectionObserver(
|
|
755
|
-
([entry]) => {
|
|
756
|
-
if (entry.isIntersecting && !hasLoaded) {
|
|
757
|
-
setIsVisible(true);
|
|
758
|
-
setHasLoaded(true);
|
|
759
|
-
observer.unobserve(element);
|
|
760
|
-
}
|
|
761
|
-
},
|
|
762
|
-
{ threshold: 0.1 }
|
|
763
|
-
);
|
|
764
|
-
|
|
765
|
-
observer.observe(element);
|
|
766
|
-
return () => observer.unobserve(element);
|
|
767
|
-
}, [hasLoaded]);
|
|
768
|
-
|
|
769
|
-
return (
|
|
770
|
-
<div ref={ref}>
|
|
771
|
-
{isVisible ? (
|
|
772
|
-
<img src={src} alt={alt} />
|
|
773
|
-
) : (
|
|
774
|
-
<div className="w-full h-64 bg-gray-200" />
|
|
775
|
-
)}
|
|
776
|
-
</div>
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
```
|
|
780
|
-
|
|
781
|
-
**Observer 컴포넌트의 해결책**
|
|
782
|
-
|
|
783
|
-
`Observer` 컴포넌트는 뷰포트 감지 로직을 간단하고 재사용 가능하게 만들어 다양한 최적화 패턴을 쉽게 구현할 수 있게 해줍니다.
|
|
784
|
-
|
|
785
|
-
```tsx
|
|
786
|
-
interface ObserverProps {
|
|
787
|
-
children: React.ReactNode | ((isIntersecting: boolean) => React.ReactNode);
|
|
788
|
-
fallback?: React.ReactNode; // 뷰포트에 보이지 않을 때 표시할 내용
|
|
789
|
-
threshold?: number | number[]; // 교차 임계값 (0.0 ~ 1.0)
|
|
790
|
-
rootMargin?: string; // 루트 마진
|
|
791
|
-
triggerOnce?: boolean; // 한 번만 트리거할지 여부
|
|
792
|
-
onIntersect?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; // 교차 이벤트 콜백
|
|
793
|
-
}
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
**✅ Observer를 사용한 개선된 방식**
|
|
797
|
-
|
|
798
|
-
**지연 로딩 (Lazy Loading)**
|
|
799
|
-
|
|
800
|
-
```tsx
|
|
801
|
-
// fallback을 활용한 깔끔한 지연 로딩
|
|
802
|
-
<Observer
|
|
803
|
-
threshold={0.1}
|
|
804
|
-
triggerOnce={true}
|
|
805
|
-
fallback={<div className="w-full h-64 bg-gray-200 animate-pulse" />}
|
|
806
|
-
>
|
|
807
|
-
<img src={imageUrl} alt="지연 로딩 이미지" loading="lazy" />
|
|
808
|
-
</Observer>
|
|
809
|
-
|
|
810
|
-
// 함수형 children으로 더 세밀한 제어
|
|
811
|
-
<Observer
|
|
812
|
-
threshold={0.1}
|
|
813
|
-
triggerOnce={true}
|
|
814
|
-
fallback={<ImageSkeleton />}
|
|
815
|
-
>
|
|
816
|
-
{(isIntersecting) =>
|
|
817
|
-
isIntersecting ? (
|
|
818
|
-
<img src={imageUrl} alt="지연 로딩 이미지" loading="lazy" />
|
|
819
|
-
) : null
|
|
820
|
-
}
|
|
821
|
-
</Observer>
|
|
822
|
-
|
|
823
|
-
// Show 컴포넌트와 함께 사용하여 조건부 활성화
|
|
824
|
-
<Show when={shouldLoad}>
|
|
825
|
-
<Observer
|
|
826
|
-
threshold={0.2}
|
|
827
|
-
triggerOnce={true}
|
|
828
|
-
fallback={<ComponentSkeleton />}
|
|
829
|
-
>
|
|
830
|
-
<HeavyComponent data={data} />
|
|
831
|
-
</Observer>
|
|
832
|
-
</Show>
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
**무한 스크롤**
|
|
836
|
-
```tsx
|
|
837
|
-
// 무한 스크롤 트리거
|
|
838
|
-
<Observer
|
|
839
|
-
threshold={1.0}
|
|
840
|
-
rootMargin="0px 0px 200px 0px" // 하단 200px 전에 트리거
|
|
841
|
-
onIntersect={(isIntersecting) => {
|
|
842
|
-
if (isIntersecting && hasNextPage && !isLoading) {
|
|
843
|
-
loadMoreItems();
|
|
844
|
-
}
|
|
845
|
-
}}
|
|
846
|
-
>
|
|
847
|
-
<div className="h-20 flex items-center justify-center">
|
|
848
|
-
{isLoading ? <Spinner /> : "더 보기"}
|
|
849
|
-
</div>
|
|
850
|
-
</Observer>
|
|
851
|
-
|
|
852
|
-
// 페이지네이션과 함께
|
|
853
|
-
<For each={items}>
|
|
854
|
-
{(item) => <ItemCard key={item.id} item={item} />}
|
|
855
|
-
</For>
|
|
856
|
-
|
|
857
|
-
<Show when={hasNextPage}>
|
|
858
|
-
<Observer
|
|
859
|
-
threshold={0.5}
|
|
860
|
-
onIntersect={(isIntersecting) => {
|
|
861
|
-
if (isIntersecting) loadNextPage();
|
|
862
|
-
}}
|
|
863
|
-
>
|
|
864
|
-
<LoadMoreButton />
|
|
865
|
-
</Observer>
|
|
866
|
-
</Show>
|
|
867
|
-
```
|
|
868
|
-
|
|
869
|
-
**애니메이션 트리거**
|
|
870
|
-
```tsx
|
|
871
|
-
// 뷰포트 진입 시 애니메이션
|
|
872
|
-
<Observer
|
|
873
|
-
threshold={0.3}
|
|
874
|
-
triggerOnce={true}
|
|
875
|
-
fallback={
|
|
876
|
-
<div className="opacity-0 translate-y-10 transition-all duration-1000">
|
|
877
|
-
<FeatureCard />
|
|
878
|
-
</div>
|
|
879
|
-
}
|
|
880
|
-
>
|
|
881
|
-
<div className="opacity-100 translate-y-0 transition-all duration-1000">
|
|
882
|
-
<FeatureCard />
|
|
883
|
-
</div>
|
|
884
|
-
</Observer>
|
|
885
|
-
|
|
886
|
-
// 함수형 children으로 더 세밀한 애니메이션 제어
|
|
887
|
-
<Observer threshold={0.3} triggerOnce={true}>
|
|
888
|
-
{(isIntersecting) => (
|
|
889
|
-
<div className={`transition-all duration-1000 ${
|
|
890
|
-
isIntersecting
|
|
891
|
-
? 'opacity-100 translate-y-0'
|
|
892
|
-
: 'opacity-0 translate-y-10'
|
|
893
|
-
}`}>
|
|
894
|
-
<FeatureCard />
|
|
895
|
-
</div>
|
|
896
|
-
)}
|
|
897
|
-
</Observer>
|
|
898
|
-
|
|
899
|
-
// 순차적 애니메이션
|
|
900
|
-
<Repeat times={features.length}>
|
|
901
|
-
{(index) => (
|
|
902
|
-
<Observer
|
|
903
|
-
key={index}
|
|
904
|
-
threshold={0.5}
|
|
905
|
-
triggerOnce={true}
|
|
906
|
-
fallback={
|
|
907
|
-
<div className="opacity-0 scale-95 transition-all duration-700">
|
|
908
|
-
<FeatureItem feature={features[index]} />
|
|
909
|
-
</div>
|
|
910
|
-
}
|
|
911
|
-
>
|
|
912
|
-
<div
|
|
913
|
-
className="opacity-100 scale-100 transition-all duration-700"
|
|
914
|
-
style={{ transitionDelay: `${index * 100}ms` }}
|
|
915
|
-
>
|
|
916
|
-
<FeatureItem feature={features[index]} />
|
|
917
|
-
</div>
|
|
918
|
-
</Observer>
|
|
919
|
-
)}
|
|
920
|
-
</Repeat>
|
|
921
|
-
```
|
|
922
|
-
|
|
923
|
-
**🎯 실제 사용 사례**
|
|
924
|
-
|
|
925
|
-
```tsx
|
|
926
|
-
// 갤러리 이미지 지연 로딩
|
|
927
|
-
function ImageGallery({ images }: { images: ImageData[] }) {
|
|
928
|
-
return (
|
|
929
|
-
<div className="grid grid-cols-3 gap-4">
|
|
930
|
-
<For each={images}>
|
|
931
|
-
{(image) => (
|
|
932
|
-
<Observer
|
|
933
|
-
key={image.id}
|
|
934
|
-
threshold={0.1}
|
|
935
|
-
triggerOnce={true}
|
|
936
|
-
fallback={
|
|
937
|
-
<div className="aspect-square overflow-hidden rounded-lg">
|
|
938
|
-
<div className="w-full h-full bg-gray-300 animate-pulse" />
|
|
939
|
-
</div>
|
|
940
|
-
}
|
|
941
|
-
>
|
|
942
|
-
<div className="aspect-square overflow-hidden rounded-lg">
|
|
943
|
-
<img
|
|
944
|
-
src={image.url}
|
|
945
|
-
alt={image.alt}
|
|
946
|
-
className="w-full h-full object-cover"
|
|
947
|
-
loading="lazy"
|
|
948
|
-
/>
|
|
949
|
-
</div>
|
|
950
|
-
</Observer>
|
|
951
|
-
)}
|
|
952
|
-
</For>
|
|
953
|
-
</div>
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// 뷰포트 진입 분석
|
|
958
|
-
function AnalyticsSection({ sectionId, children }: {
|
|
959
|
-
sectionId: string,
|
|
960
|
-
children: React.ReactNode
|
|
961
|
-
}) {
|
|
962
|
-
return (
|
|
963
|
-
<Observer
|
|
964
|
-
threshold={0.5}
|
|
965
|
-
triggerOnce={true}
|
|
966
|
-
onIntersect={(isIntersecting, entry) => {
|
|
967
|
-
if (isIntersecting) {
|
|
968
|
-
analytics.track('section_viewed', {
|
|
969
|
-
sectionId,
|
|
970
|
-
visibilityRatio: entry?.intersectionRatio,
|
|
971
|
-
viewportHeight: window.innerHeight
|
|
972
|
-
});
|
|
973
|
-
}
|
|
974
|
-
}}
|
|
975
|
-
>
|
|
976
|
-
{children}
|
|
977
|
-
</Observer>
|
|
978
|
-
);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// 진행률 표시기 (entry가 필요한 경우는 onIntersect에서 처리)
|
|
982
|
-
function ScrollProgressIndicator() {
|
|
983
|
-
const [progress, setProgress] = useState(0);
|
|
984
|
-
|
|
985
|
-
return (
|
|
986
|
-
<Observer
|
|
987
|
-
threshold={Array.from({length: 101}, (_, i) => i / 100)} // 0.00 ~ 1.00
|
|
988
|
-
rootMargin="-50% 0px -50% 0px"
|
|
989
|
-
onIntersect={(isIntersecting, entry) => {
|
|
990
|
-
setProgress(entry.intersectionRatio * 100);
|
|
991
|
-
}}
|
|
992
|
-
>
|
|
993
|
-
<div className="fixed top-0 left-0 w-full h-2 bg-gray-200 z-50">
|
|
994
|
-
<div
|
|
995
|
-
className="h-full bg-blue-500 transition-all duration-300"
|
|
996
|
-
style={{ width: `${progress}%` }}
|
|
997
|
-
/>
|
|
998
|
-
</div>
|
|
999
|
-
</Observer>
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// 조건부 로딩 - Show 컴포넌트와 함께 사용
|
|
1004
|
-
function ConditionalContent({ shouldLoad, children }: {
|
|
1005
|
-
shouldLoad: boolean,
|
|
1006
|
-
children: React.ReactNode
|
|
1007
|
-
}) {
|
|
1008
|
-
return (
|
|
1009
|
-
<Show when={shouldLoad} fallback={<div>로딩이 비활성화되었습니다</div>}>
|
|
1010
|
-
<Observer
|
|
1011
|
-
threshold={0.1}
|
|
1012
|
-
fallback={<ContentPlaceholder />}
|
|
1013
|
-
>
|
|
1014
|
-
{children}
|
|
1015
|
-
</Observer>
|
|
1016
|
-
</Show>
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
```
|
|
1020
|
-
|
|
1021
|
-
**🔧 고급 패턴들**
|
|
1022
|
-
|
|
1023
|
-
```tsx
|
|
1024
|
-
// 다중 임계값으로 점진적 페이드 효과
|
|
1025
|
-
<Observer
|
|
1026
|
-
threshold={[0, 0.25, 0.5, 0.75, 1.0]}
|
|
1027
|
-
fallback={<div className="opacity-0"><GradualContent /></div>}
|
|
1028
|
-
onIntersect={(isIntersecting, entry) => {
|
|
1029
|
-
// entry가 필요한 세밀한 제어는 콜백에서
|
|
1030
|
-
console.log('Intersection ratio:', entry.intersectionRatio);
|
|
1031
|
-
}}
|
|
1032
|
-
>
|
|
1033
|
-
<div className="opacity-100 transition-opacity duration-300">
|
|
1034
|
-
<GradualContent />
|
|
1035
|
-
</div>
|
|
1036
|
-
</Observer>
|
|
1037
|
-
|
|
1038
|
-
// 루트 마진을 활용한 프리로딩
|
|
1039
|
-
<Observer
|
|
1040
|
-
threshold={0}
|
|
1041
|
-
rootMargin="0px 0px 500px 0px" // 500px 전에 미리 로딩
|
|
1042
|
-
triggerOnce={true}
|
|
1043
|
-
onIntersect={(isIntersecting) => {
|
|
1044
|
-
if (isIntersecting) {
|
|
1045
|
-
preloadNextPageData();
|
|
1046
|
-
}
|
|
1047
|
-
}}
|
|
1048
|
-
fallback={<div>프리로드 트리거 대기 중...</div>}
|
|
1049
|
-
>
|
|
1050
|
-
<div>다음 페이지 프리로드 트리거</div>
|
|
1051
|
-
</Observer>
|
|
1052
|
-
|
|
1053
|
-
// 뷰포트 벗어남 감지 (entry 없이도 가능)
|
|
1054
|
-
<Observer
|
|
1055
|
-
threshold={0}
|
|
1056
|
-
onIntersect={(isIntersecting) => {
|
|
1057
|
-
if (!isIntersecting) {
|
|
1058
|
-
pauseVideo();
|
|
1059
|
-
} else {
|
|
1060
|
-
playVideo();
|
|
1061
|
-
}
|
|
1062
|
-
}}
|
|
1063
|
-
>
|
|
1064
|
-
<VideoPlayer src={videoUrl} />
|
|
1065
|
-
</Observer>
|
|
1066
|
-
```
|
|
1067
|
-
|
|
1068
|
-
> **⚠️ 브라우저 호환성**: `Observer`는 내부적으로 `IntersectionObserver` API를 사용하므로 현대 브라우저에서 잘 지원되지만, 구형 브라우저에서는 폴리필이 필요할 수 있습니다. 컴포넌트는 API가 지원되지 않는 환경에서 graceful fallback을 제공합니다.
|
|
1069
|
-
|
|
1070
|
-
```tsx
|
|
1071
|
-
// Observer 사용 - 더 간결함
|
|
1072
|
-
<Observer threshold={0.1} fallback={<Skeleton />}>
|
|
1073
|
-
<HeavyComponent />
|
|
1074
|
-
</Observer>
|
|
1075
|
-
|
|
1076
|
-
// 기존 IntersectionObserver와 동일한 기능
|
|
1077
|
-
<IntersectionObserver threshold={0.1} fallback={<Skeleton />}>
|
|
1078
|
-
<HeavyComponent />
|
|
1079
|
-
</IntersectionObserver>
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
**🎯 빠른 사용 예제**
|
|
1083
|
-
|
|
1084
|
-
```tsx
|
|
1085
|
-
// 지연 로딩
|
|
1086
|
-
<Observer threshold={0.1} triggerOnce={true} fallback={<ImageSkeleton />}>
|
|
1087
|
-
<img src={imageUrl} alt="지연 로딩 이미지" />
|
|
1088
|
-
</Observer>
|
|
1089
|
-
|
|
1090
|
-
// 애니메이션 트리거
|
|
1091
|
-
<Observer threshold={0.3} triggerOnce={true}>
|
|
1092
|
-
{(isIntersecting) => (
|
|
1093
|
-
<div className={`transition-all duration-1000 ${
|
|
1094
|
-
isIntersecting ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
|
|
1095
|
-
}`}>
|
|
1096
|
-
<FeatureCard />
|
|
1097
|
-
</div>
|
|
1098
|
-
)}
|
|
1099
|
-
</Observer>
|
|
1100
|
-
|
|
1101
|
-
// 무한 스크롤
|
|
1102
|
-
<Observer
|
|
1103
|
-
threshold={1.0}
|
|
1104
|
-
rootMargin="0px 0px 200px 0px"
|
|
1105
|
-
onIntersect={(isIntersecting) => {
|
|
1106
|
-
if (isIntersecting && hasNextPage) {
|
|
1107
|
-
loadMoreItems();
|
|
1108
|
-
}
|
|
1109
|
-
}}
|
|
1110
|
-
>
|
|
1111
|
-
<LoadMoreButton />
|
|
1112
|
-
</Observer>
|
|
1113
|
-
```
|
|
1114
|
-
|
|
1115
|
-
# Slacker - 스마트 지연 로딩
|
|
1116
|
-
|
|
1117
|
-
복잡한 컴포넌트나 데이터를 미리 로드하면 초기 페이지 로딩이 느려지고, 사용자가 실제로 보지 않는 콘텐츠까지 로드하게 됩니다.
|
|
1118
|
-
|
|
1119
|
-
**❌ 일반적인 방식의 문제점**
|
|
1120
|
-
|
|
1121
|
-
```tsx
|
|
1122
|
-
// 모든 차트가 한 번에 로드 (페이지 로딩 느림)
|
|
1123
|
-
function Dashboard() {
|
|
1124
|
-
return (
|
|
1125
|
-
<div>
|
|
1126
|
-
<HeavyChart1 data={data1} />
|
|
1127
|
-
<HeavyChart2 data={data2} />
|
|
1128
|
-
<HeavyChart3 data={data3} />
|
|
1129
|
-
</div>
|
|
1130
|
-
);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// 수동 lazy loading (복잡한 상태 관리)
|
|
1134
|
-
function LazyChart() {
|
|
1135
|
-
const [inView, setInView] = useState(false);
|
|
1136
|
-
const [Component, setComponent] = useState(null);
|
|
1137
|
-
const [data, setData] = useState(null);
|
|
1138
|
-
const [loading, setLoading] = useState(false);
|
|
1139
|
-
|
|
1140
|
-
useEffect(() => {
|
|
1141
|
-
if (inView && !Component) {
|
|
1142
|
-
setLoading(true);
|
|
1143
|
-
Promise.all([
|
|
1144
|
-
import('./HeavyChart'),
|
|
1145
|
-
fetch('/api/data').then(r => r.json())
|
|
1146
|
-
]).then(([module, fetchedData]) => {
|
|
1147
|
-
setComponent(module.default);
|
|
1148
|
-
setData(fetchedData);
|
|
1149
|
-
setLoading(false);
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
}, [inView]);
|
|
1153
|
-
|
|
1154
|
-
return (
|
|
1155
|
-
<div ref={observerRef}>
|
|
1156
|
-
{loading && <ChartSkeleton />}
|
|
1157
|
-
{Component && data && <Component data={data} />}
|
|
1158
|
-
</div>
|
|
1159
|
-
);
|
|
1160
|
-
}
|
|
1161
|
-
```
|
|
1162
|
-
|
|
1163
|
-
**Slacker 컴포넌트의 해결책**
|
|
1164
|
-
|
|
1165
|
-
`Slacker` 컴포넌트는 뷰포트에 진입할 때까지 로딩을 지연시키고, loader에서 반환된 데이터를 children 함수에 전달하여 렌더링하는 스마트한 지연 로딩 솔루션입니다.
|
|
1166
|
-
|
|
1167
|
-
```tsx
|
|
1168
|
-
interface SlackerProps {
|
|
1169
|
-
children: (loaded: any) => React.ReactNode; // loader의 결과를 받는 함수
|
|
1170
|
-
fallback?: React.ReactNode; // 로딩 중 표시할 내용
|
|
1171
|
-
threshold?: number | number[]; // 교차 임계값 (기본: 0.1)
|
|
1172
|
-
rootMargin?: string; // 루트 마진 (기본: "50px")
|
|
1173
|
-
loader: () => Promise<any> | any; // 동적 로딩 함수 (필수)
|
|
1174
|
-
}
|
|
1175
|
-
```
|
|
1176
|
-
|
|
1177
|
-
**✅ Slacker를 사용한 개선된 방식**
|
|
1178
|
-
|
|
1179
|
-
```tsx
|
|
1180
|
-
// 컴포넌트 lazy loading
|
|
1181
|
-
<Slacker
|
|
1182
|
-
fallback={<ChartSkeleton />}
|
|
1183
|
-
loader={async () => {
|
|
1184
|
-
const { HeavyChart } = await import('./HeavyChart');
|
|
1185
|
-
return HeavyChart;
|
|
1186
|
-
}}
|
|
1187
|
-
>
|
|
1188
|
-
{(Component) => <Component data={data} />}
|
|
1189
|
-
</Slacker>
|
|
1190
|
-
|
|
1191
|
-
// 데이터 lazy loading
|
|
1192
|
-
<Slacker
|
|
1193
|
-
fallback={<div>Loading data...</div>}
|
|
1194
|
-
loader={async () => {
|
|
1195
|
-
const response = await fetch('/api/data');
|
|
1196
|
-
return response.json();
|
|
1197
|
-
}}
|
|
1198
|
-
>
|
|
1199
|
-
{(data) => (
|
|
1200
|
-
<div>
|
|
1201
|
-
<h2>{data.title}</h2>
|
|
1202
|
-
<p>{data.description}</p>
|
|
1203
|
-
</div>
|
|
1204
|
-
)}
|
|
1205
|
-
</Slacker>
|
|
1206
|
-
|
|
1207
|
-
// 컴포넌트와 데이터 함께 로딩
|
|
1208
|
-
<Slacker
|
|
1209
|
-
fallback={<ChartSkeleton />}
|
|
1210
|
-
loader={async () => {
|
|
1211
|
-
const [{ Chart }, chartData] = await Promise.all([
|
|
1212
|
-
import('chart.js'),
|
|
1213
|
-
fetch('/api/chart-data').then(r => r.json())
|
|
1214
|
-
]);
|
|
1215
|
-
return { Chart, data: chartData };
|
|
1216
|
-
}}
|
|
1217
|
-
>
|
|
1218
|
-
{({ Chart, data }) => <Chart data={data} />}
|
|
1219
|
-
</Slacker>
|
|
1220
|
-
```
|
|
1221
|
-
|
|
1222
|
-
**🎯 실제 사용 사례**
|
|
1223
|
-
|
|
1224
|
-
```tsx
|
|
1225
|
-
// 대시보드의 차트들
|
|
1226
|
-
function Dashboard() {
|
|
1227
|
-
return (
|
|
1228
|
-
<div className="grid grid-cols-2 gap-6">
|
|
1229
|
-
<Slacker
|
|
1230
|
-
fallback={<ChartSkeleton />}
|
|
1231
|
-
loader={async () => {
|
|
1232
|
-
const [{ PieChart }, salesData] = await Promise.all([
|
|
1233
|
-
import('./charts/PieChart'),
|
|
1234
|
-
fetch('/api/sales').then(r => r.json())
|
|
1235
|
-
]);
|
|
1236
|
-
return { Component: PieChart, data: salesData };
|
|
1237
|
-
}}
|
|
1238
|
-
>
|
|
1239
|
-
{({ Component, data }) => <Component data={data} />}
|
|
1240
|
-
</Slacker>
|
|
1241
|
-
|
|
1242
|
-
<Slacker
|
|
1243
|
-
fallback={<ChartSkeleton />}
|
|
1244
|
-
loader={async () => {
|
|
1245
|
-
const [{ LineChart }, trafficData] = await Promise.all([
|
|
1246
|
-
import('./charts/LineChart'),
|
|
1247
|
-
fetch('/api/traffic').then(r => r.json())
|
|
1248
|
-
]);
|
|
1249
|
-
return { Component: LineChart, data: trafficData };
|
|
1250
|
-
}}
|
|
1251
|
-
>
|
|
1252
|
-
{({ Component, data }) => <Component data={data} />}
|
|
1253
|
-
</Slacker>
|
|
1254
|
-
</div>
|
|
1255
|
-
);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// 이미지 갤러리의 고해상도 이미지
|
|
1259
|
-
function PhotoGallery({ photos }: { photos: Photo[] }) {
|
|
1260
|
-
return (
|
|
1261
|
-
<div className="grid grid-cols-3 gap-4">
|
|
1262
|
-
<For each={photos}>
|
|
1263
|
-
{(photo) => (
|
|
1264
|
-
<Slacker
|
|
1265
|
-
key={photo.id}
|
|
1266
|
-
fallback={
|
|
1267
|
-
<div className="aspect-square bg-gray-200 animate-pulse rounded-lg" />
|
|
1268
|
-
}
|
|
1269
|
-
loader={async () => {
|
|
1270
|
-
const [imageUrl, metadata] = await Promise.all([
|
|
1271
|
-
loadHighResImage(photo.id),
|
|
1272
|
-
fetch(`/api/photos/${photo.id}/metadata`).then(r => r.json())
|
|
1273
|
-
]);
|
|
1274
|
-
return { imageUrl, metadata };
|
|
1275
|
-
}}
|
|
1276
|
-
>
|
|
1277
|
-
{({ imageUrl, metadata }) => (
|
|
1278
|
-
<div className="aspect-square rounded-lg overflow-hidden">
|
|
1279
|
-
<img
|
|
1280
|
-
src={imageUrl}
|
|
1281
|
-
alt={metadata.title}
|
|
1282
|
-
className="w-full h-full object-cover"
|
|
1283
|
-
/>
|
|
1284
|
-
<div className="p-2">
|
|
1285
|
-
<p className="text-sm text-gray-600">{metadata.description}</p>
|
|
1286
|
-
</div>
|
|
1287
|
-
</div>
|
|
1288
|
-
)}
|
|
1289
|
-
</Slacker>
|
|
1290
|
-
)}
|
|
1291
|
-
</For>
|
|
1292
|
-
</div>
|
|
1293
|
-
);
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// 복잡한 에디터 컴포넌트
|
|
1297
|
-
<Slacker
|
|
1298
|
-
fallback={
|
|
1299
|
-
<div className="h-96 bg-gray-100 rounded border-2 border-dashed flex items-center justify-center">
|
|
1300
|
-
<div className="text-center">
|
|
1301
|
-
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
|
1302
|
-
<p>에디터 로딩 중...</p>
|
|
1303
|
-
</div>
|
|
1304
|
-
</div>
|
|
1305
|
-
}
|
|
1306
|
-
loader={async () => {
|
|
1307
|
-
const [
|
|
1308
|
-
{ default: CodeMirror },
|
|
1309
|
-
{ default: prettier },
|
|
1310
|
-
extensions
|
|
1311
|
-
] = await Promise.all([
|
|
1312
|
-
import('@uiw/react-codemirror'),
|
|
1313
|
-
import('prettier/standalone'),
|
|
1314
|
-
import('./editor-extensions')
|
|
1315
|
-
]);
|
|
1316
|
-
return { CodeMirror, prettier, extensions };
|
|
1317
|
-
}}
|
|
1318
|
-
>
|
|
1319
|
-
{({ CodeMirror, prettier, extensions }) => (
|
|
1320
|
-
<CodeMirror
|
|
1321
|
-
value={code}
|
|
1322
|
-
height="400px"
|
|
1323
|
-
extensions={extensions}
|
|
1324
|
-
onChange={(val) => setCode(prettier.format(val))}
|
|
1325
|
-
/>
|
|
1326
|
-
)}
|
|
1327
|
-
</Slacker>
|
|
1328
|
-
|
|
1329
|
-
// 지도 컴포넌트
|
|
1330
|
-
<Slacker
|
|
1331
|
-
fallback={<MapSkeleton />}
|
|
1332
|
-
threshold={0.3}
|
|
1333
|
-
rootMargin="100px" // 더 일찍 로딩 시작
|
|
1334
|
-
loader={async () => {
|
|
1335
|
-
const [{ Map }, { Marker }, locationData] = await Promise.all([
|
|
1336
|
-
import('react-leaflet'),
|
|
1337
|
-
import('react-leaflet'),
|
|
1338
|
-
fetch('/api/locations').then(r => r.json())
|
|
1339
|
-
]);
|
|
1340
|
-
|
|
1341
|
-
// 분석 트래킹은 loader 내부에서 처리
|
|
1342
|
-
analytics.track('map_loaded');
|
|
1343
|
-
|
|
1344
|
-
return { Map, Marker, locations: locationData };
|
|
1345
|
-
}}
|
|
1346
|
-
>
|
|
1347
|
-
{({ Map, Marker, locations }) => (
|
|
1348
|
-
<Map center={[51.505, -0.09]} zoom={13} style={{ height: '400px' }}>
|
|
1349
|
-
<For each={locations}>
|
|
1350
|
-
{(location) => (
|
|
1351
|
-
<Marker key={location.id} position={[location.lat, location.lng]} />
|
|
1352
|
-
)}
|
|
1353
|
-
</For>
|
|
1354
|
-
</Map>
|
|
1355
|
-
)}
|
|
1356
|
-
</Slacker>
|
|
1357
|
-
```
|
|
1358
|
-
|
|
1359
|
-
**🔧 고급 패턴들**
|
|
1360
|
-
|
|
1361
|
-
```tsx
|
|
1362
|
-
// 조건부 lazy loading - Show와 함께 사용
|
|
1363
|
-
<Show when={userCanSeeAdvancedFeatures} fallback={<BasicView />}>
|
|
1364
|
-
<Slacker
|
|
1365
|
-
fallback={<AdvancedFeatureSkeleton />}
|
|
1366
|
-
loader={async () => {
|
|
1367
|
-
const { AdvancedDashboard } = await import('./AdvancedDashboard');
|
|
1368
|
-
return AdvancedDashboard;
|
|
1369
|
-
}}
|
|
1370
|
-
>
|
|
1371
|
-
{(Component) => <Component user={user} />}
|
|
1372
|
-
</Slacker>
|
|
1373
|
-
</Show>
|
|
1374
|
-
|
|
1375
|
-
// 에러 핸들링과 재시도
|
|
1376
|
-
function SafeSlacker({ children, ...props }) {
|
|
1377
|
-
const [error, setError] = useState(null);
|
|
1378
|
-
const [retryCount, setRetryCount] = useState(0);
|
|
1379
|
-
|
|
1380
|
-
const wrappedLoader = async () => {
|
|
1381
|
-
try {
|
|
1382
|
-
setError(null);
|
|
1383
|
-
return await props.loader();
|
|
1384
|
-
} catch (err) {
|
|
1385
|
-
setError(err);
|
|
1386
|
-
throw err;
|
|
1387
|
-
}
|
|
1388
|
-
};
|
|
1389
|
-
|
|
1390
|
-
if (error && retryCount < 3) {
|
|
1391
|
-
return (
|
|
1392
|
-
<div className="text-center p-4">
|
|
1393
|
-
<p>로딩 실패: {error.message}</p>
|
|
1394
|
-
<button
|
|
1395
|
-
onClick={() => setRetryCount(c => c + 1)}
|
|
1396
|
-
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
|
|
1397
|
-
>
|
|
1398
|
-
재시도 ({retryCount}/3)
|
|
1399
|
-
</button>
|
|
1400
|
-
</div>
|
|
1401
|
-
);
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
return (
|
|
1405
|
-
<Slacker {...props} loader={wrappedLoader}>
|
|
1406
|
-
{children}
|
|
1407
|
-
</Slacker>
|
|
1408
|
-
);
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// 프리로딩 전략
|
|
1412
|
-
<Slacker
|
|
1413
|
-
rootMargin="200px 0px" // 뷰포트 200px 전에 로딩 시작
|
|
1414
|
-
threshold={0}
|
|
1415
|
-
fallback={<ContentSkeleton />}
|
|
1416
|
-
loader={async () => {
|
|
1417
|
-
// 중요한 리소스는 우선 로딩
|
|
1418
|
-
const mainContent = await import('./MainContent');
|
|
1419
|
-
|
|
1420
|
-
// 덜 중요한 리소스는 백그라운드에서 로딩
|
|
1421
|
-
setTimeout(async () => {
|
|
1422
|
-
await import('./SecondaryFeatures');
|
|
1423
|
-
}, 100);
|
|
1424
|
-
|
|
1425
|
-
return mainContent.default;
|
|
1426
|
-
}}
|
|
1427
|
-
>
|
|
1428
|
-
{(Component) => <Component />}
|
|
1429
|
-
</Slacker>
|
|
1430
|
-
|
|
1431
|
-
// 점진적 로딩
|
|
1432
|
-
<Slacker
|
|
1433
|
-
fallback={<BasicChart />} // 먼저 기본 차트 표시
|
|
1434
|
-
loader={async () => {
|
|
1435
|
-
const { EnhancedChart } = await import('./EnhancedChart');
|
|
1436
|
-
return EnhancedChart;
|
|
1437
|
-
}}
|
|
1438
|
-
>
|
|
1439
|
-
{(EnhancedChart) => <EnhancedChart data={data} />}
|
|
1440
|
-
</Slacker>
|
|
1441
|
-
|
|
1442
|
-
// 뷰포트 진입 분석이 필요한 경우 - Observer와 조합
|
|
1443
|
-
<Observer
|
|
1444
|
-
onIntersect={(isIntersecting) => {
|
|
1445
|
-
if (isIntersecting) {
|
|
1446
|
-
analytics.track('heavy_component_section_viewed');
|
|
1447
|
-
}
|
|
1448
|
-
}}
|
|
1449
|
-
>
|
|
1450
|
-
<Slacker
|
|
1451
|
-
fallback={<HeavyComponentSkeleton />}
|
|
1452
|
-
loader={async () => {
|
|
1453
|
-
const { HeavyComponent } = await import('./HeavyComponent');
|
|
1454
|
-
analytics.track('heavy_component_loaded');
|
|
1455
|
-
return HeavyComponent;
|
|
1456
|
-
}}
|
|
1457
|
-
>
|
|
1458
|
-
{(Component) => <Component />}
|
|
1459
|
-
</Slacker>
|
|
1460
|
-
</Observer>
|
|
1461
|
-
```
|
|
1462
|
-
|
|
1463
|
-
> **💡 성능 팁**: `Slacker`는 triggerOnce가 기본적으로 활성화되어 있어 한 번 로드된 후에는 다시 언로드되지 않습니다. 메모리 사용량을 고려하여 큰 컴포넌트는 필요에 따라 언마운트하는 로직을 별도로 구현하세요.
|
|
1464
|
-
|
|
1465
|
-
> **🔍 분석/트래킹**: 로딩 이벤트 추적은 loader 함수 내부에서 처리하고, 뷰포트 진입 자체를 감지해야 한다면 `Observer` 컴포넌트와 조합하여 사용하세요.
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
# 🤝 기여하기
|
|
1469
|
-
|
|
1470
|
-
Utilinent는 오픈소스 프로젝트입니다. 버그 리포트, 기능 제안, 풀 리퀘스트를 환영합니다!
|
|
1471
|
-
|
|
1472
|
-
## 🔗 관련 링크
|
|
1473
|
-
- [GitHub Repository](https://github.com/ilokesto/utilinent)
|
|
1474
|
-
- [NPM Package](https://www.npmjs.com/ayden94/utilinent)
|
|
1
|
+
# @ilokesto/utilinent
|
|
2
|
+
|
|
3
|
+
React를 위한 유용하고 재사용 가능한 컴포넌트 및 훅 모음입니다.
|
|
4
|
+
|
|
5
|
+
## 소개
|
|
6
|
+
|
|
7
|
+
`@ilokesto/utilinent`는 React 애플리케이션 개발 시 반복적으로 필요한 로직과 패턴을 선언적이고 읽기 쉬운 컴포넌트로 제공합니다. 조건부 렌더링, 리스트 렌더링, 지연 로딩 등의 작업을 간편하게 처리할 수 있도록 돕습니다.
|
|
8
|
+
|
|
9
|
+
## 설치
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @ilokesto/utilinent
|
|
13
|
+
# 또는
|
|
14
|
+
yarn add @ilokesto/utilinent
|
|
15
|
+
# 또는
|
|
16
|
+
pnpm add @ilokesto/utilinent
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 주요 기능
|
|
20
|
+
|
|
21
|
+
### 기본 컴포넌트
|
|
22
|
+
|
|
23
|
+
기본적으로 다음 컴포넌트들을 가져와 사용할 수 있습니다.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Show, For, Repeat, Observer, OptionalWrapper } from '@ilokesto/utilinent';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
#### `<Show>`
|
|
30
|
+
|
|
31
|
+
조건부 렌더링을 위한 컴포넌트입니다. `when` prop이 `true`일 때만 `children`을 렌더링합니다.
|
|
32
|
+
|
|
33
|
+
또한, `Show.div`, `Show.span`과 같이 HTML 태그를 직접 사용하여 래퍼(wrapper) 컴포넌트를 지정할 수 있습니다. 이 경우, `when` prop이 `true`일 때 해당 태그로 감싸진 `children`이 렌더링됩니다.
|
|
34
|
+
|
|
35
|
+
**사용 예시:**
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { Show } from '@ilokesto/utilinent';
|
|
39
|
+
|
|
40
|
+
function UserProfile({ user }) {
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<Show when={user.isLoggedIn} fallback={<div>로그인이 필요합니다.</div>}>
|
|
44
|
+
<h1>{user.name}님, 환영합니다!</h1>
|
|
45
|
+
</Show>
|
|
46
|
+
|
|
47
|
+
{/* HTML 태그와 함께 사용 */}
|
|
48
|
+
<Show.div when={user.isAdmin} className="admin-badge">
|
|
49
|
+
관리자
|
|
50
|
+
</Show.div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### `<For>`
|
|
57
|
+
|
|
58
|
+
배열을 순회하며 각 항목을 렌더링합니다. `Array.prototype.map`과 유사하지만, 배열이 비어있을 경우를 위한 `fallback`을 지원합니다.
|
|
59
|
+
|
|
60
|
+
`<Show>`와 유사하게, `For.ul`, `For.div`와 같이 HTML 태그를 사용하여 렌더링되는 항목들을 감싸는 컨테이너 엘리먼트를 지정할 수 있습니다.
|
|
61
|
+
|
|
62
|
+
**사용 예시:**
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { For } from '@ilokesto/utilinent';
|
|
66
|
+
|
|
67
|
+
function TodoList({ todos }) {
|
|
68
|
+
return (
|
|
69
|
+
<For.ul each={todos} fallback={<li>할 일이 없습니다.</li>} className="todo-list">
|
|
70
|
+
{(todo, index) => <li key={index}>{todo.text}</li>}
|
|
71
|
+
</For.ul>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### `<Repeat>`
|
|
77
|
+
|
|
78
|
+
주어진 횟수(`times`)만큼 `children` 함수를 반복하여 렌더링합니다.
|
|
79
|
+
|
|
80
|
+
다른 컴포넌트들과 마찬가지로, `Repeat.div`와 같이 HTML 태그를 사용하여 반복되는 항목들을 감싸는 컨테이너를 지정할 수 있습니다.
|
|
81
|
+
|
|
82
|
+
**사용 예시:**
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import { Repeat } from '@ilokesto/utilinent';
|
|
86
|
+
|
|
87
|
+
function StarRating({ rating }) {
|
|
88
|
+
return (
|
|
89
|
+
<Repeat.div times={rating} className="star-container">
|
|
90
|
+
{(index) => <span key={index}>⭐️</span>}
|
|
91
|
+
</Repeat.div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### `<Observer>`
|
|
97
|
+
|
|
98
|
+
컴포넌트가 화면에 보일 때(intersect) `children`을 렌더링합니다. Intersection Observer API를 기반으로 하며, 지연 로딩(lazy loading) 구현에 유용합니다.
|
|
99
|
+
|
|
100
|
+
**사용 예시:**
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { Observer } from '@ilokesto/utilinent';
|
|
104
|
+
|
|
105
|
+
function LazyComponent() {
|
|
106
|
+
return (
|
|
107
|
+
<Observer fallback={<div>로딩 중...</div>}>
|
|
108
|
+
{/* 화면에 보일 때 렌더링될 무거운 컴포넌트 */}
|
|
109
|
+
<HeavyComponent />
|
|
110
|
+
</Observer>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### `<OptionalWrapper>`
|
|
116
|
+
|
|
117
|
+
`when` prop이 `true`일 때만 `children`을 `wrapper` 함수로 감싸줍니다.
|
|
118
|
+
|
|
119
|
+
**사용 예시:**
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { OptionalWrapper } from '@ilokesto/utilinent';
|
|
123
|
+
|
|
124
|
+
function Post({ post, withLink }) {
|
|
125
|
+
return (
|
|
126
|
+
<OptionalWrapper
|
|
127
|
+
when={withLink}
|
|
128
|
+
wrapper={(children) => <a href={`/posts/${post.id}`}>{children}</a>}
|
|
129
|
+
>
|
|
130
|
+
<h2>{post.title}</h2>
|
|
131
|
+
<p>{post.summary}</p>
|
|
132
|
+
</OptionalWrapper>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 실험적 기능
|
|
138
|
+
|
|
139
|
+
실험적인 컴포넌트 및 훅은 `experimental` 경로에서 가져올 수 있습니다. 이 기능들은 API가 변경될 수 있습니다.
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
import { Mount, Slacker, createSwitcher, useIntersectionObserver, Slot, Slottable } from '@ilokesto/utilinent/experimental';
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### `<Slot>` 및 `<Slottable>`
|
|
146
|
+
|
|
147
|
+
자식 컴포넌트에 props를 전달하고 병합하는 Radix UI의 `<Slot>`과 유사한 패턴을 구현합니다. `<Slot>`은 자신의 props를 자식 요소에 병합합니다. 여러 자식 중 특정 자식에게 props를 전달하고 싶을 때는 `<Slottable>`로 감싸주면 됩니다.
|
|
148
|
+
|
|
149
|
+
Props 병합 규칙은 다음과 같습니다:
|
|
150
|
+
* **`className`**: 부모와 자식의 `className`이 합쳐집니다.
|
|
151
|
+
* **`style`**: 부모와 자식의 `style` 객체가 병합됩니다 (부모 우선).
|
|
152
|
+
* **이벤트 핸들러**: 부모와 자식의 이벤트 핸들러가 모두 순차적으로 호출됩니다.
|
|
153
|
+
* **`ref`**: 부모와 자식의 `ref`가 모두 연결됩니다.
|
|
154
|
+
* **기타 props**: 부모의 props가 자식의 props를 덮어씁니다.
|
|
155
|
+
|
|
156
|
+
**사용 예시:**
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { Slot, Slottable } from '@ilokesto/utilinent/experimental';
|
|
160
|
+
|
|
161
|
+
const Button = ({ asChild = false, ...props }) => {
|
|
162
|
+
const Comp = asChild ? Slot : 'button';
|
|
163
|
+
return <Comp {...props} />;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// 기본 버튼으로 사용
|
|
167
|
+
<Button onClick={() => alert('Clicked!')}>Click Me</Button>
|
|
168
|
+
|
|
169
|
+
// 다른 컴포넌트(a 태그)를 렌더링하면서 props를 전달
|
|
170
|
+
<Button asChild>
|
|
171
|
+
<a href="/home">Go Home</a>
|
|
172
|
+
</Button>
|
|
173
|
+
|
|
174
|
+
// 여러 자식 중 특정 자식에게 props 전달
|
|
175
|
+
<Button asChild>
|
|
176
|
+
<div>
|
|
177
|
+
<span>Icon</span>
|
|
178
|
+
<Slottable>Text</Slottable>
|
|
179
|
+
</div>
|
|
180
|
+
</Button>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### `<Mount>`
|
|
184
|
+
|
|
185
|
+
컴포넌트가 마운트될 때 비동기적으로 `children`을 렌더링합니다. `children`으로 비동기 함수를 전달할 수 있습니다.
|
|
186
|
+
|
|
187
|
+
**사용 예시:**
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
import { Mount } from '@ilokesto/utilinent/experimental';
|
|
191
|
+
|
|
192
|
+
function AsyncComponent() {
|
|
193
|
+
return (
|
|
194
|
+
<Mount fallback={<div>로딩 중...</div>}>
|
|
195
|
+
{async () => {
|
|
196
|
+
const { HeavyComponent } = await import('./HeavyComponent');
|
|
197
|
+
return <HeavyComponent />;
|
|
198
|
+
}}
|
|
199
|
+
</Mount>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `<Slacker>`
|
|
205
|
+
|
|
206
|
+
컴포넌트나 데이터의 지연 로딩(lazy loading)을 위한 고급 컴포넌트입니다. 뷰포트에 들어왔을 때 `loader` 함수를 실행하여 비동기 작업을 처리하고, 로딩이 완료되면 결과를 `children`에게 전달하여 렌더링합니다.
|
|
207
|
+
|
|
208
|
+
**사용 예시:**
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
import { Slacker } from '@ilokesto/utilinent/experimental';
|
|
212
|
+
|
|
213
|
+
// 데이터 지연 로딩
|
|
214
|
+
function LazyUserData({ userId }) {
|
|
215
|
+
return (
|
|
216
|
+
<Slacker
|
|
217
|
+
fallback={<div>사용자 정보 로딩 중...</div>}
|
|
218
|
+
loader={async () => {
|
|
219
|
+
const response = await fetch(`/api/users/${userId}`);
|
|
220
|
+
return response.json();
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{(user) => (
|
|
224
|
+
<div>
|
|
225
|
+
<h1>{user.name}</h1>
|
|
226
|
+
<p>{user.email}</p>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</Slacker>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### `createSwitcher` / `<Switch>` / `<Match>`
|
|
235
|
+
|
|
236
|
+
주어진 데이터와 조건에 따라 여러 `<Match>` 컴포넌트 중 하나를 선택하여 렌더링합니다. `createSwitcher` 팩토리 함수를 통해 `<Switch>`와 `<Match>` 컴포넌트를 생성하여 사용합니다.
|
|
237
|
+
|
|
238
|
+
**사용 예시:**
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
import { createSwitcher } from '@ilokesto/utilinent/experimental';
|
|
242
|
+
|
|
243
|
+
const data = { type: 'image', src: 'image.jpg' };
|
|
244
|
+
const { Switch, Match } = createSwitcher(data);
|
|
245
|
+
|
|
246
|
+
function Media() {
|
|
247
|
+
return (
|
|
248
|
+
<Switch when="type" fallback={<div>지원하지 않는 형식입니다.</div>}>
|
|
249
|
+
<Match case="image">
|
|
250
|
+
{(data) => <img src={data.src} />}
|
|
251
|
+
</Match>
|
|
252
|
+
<Match case="video">
|
|
253
|
+
{(data) => <video src={data.src} controls />}
|
|
254
|
+
</Match>
|
|
255
|
+
</Switch>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### `useIntersectionObserver`
|
|
261
|
+
|
|
262
|
+
Intersection Observer API를 React 훅으로 감싼 것입니다. 컴포넌트의 뷰포트 내 가시성을 추적하는 데 사용됩니다.
|
|
263
|
+
|
|
264
|
+
**사용 예시:**
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { useIntersectionObserver } from '@ilokesto/utilinent/experimental';
|
|
268
|
+
import { useRef } from 'react';
|
|
269
|
+
|
|
270
|
+
function MyComponent() {
|
|
271
|
+
const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.5 });
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div ref={ref} style={{ transition: 'opacity 0.5s', opacity: isIntersecting ? 1 : 0.2 }}>
|
|
275
|
+
{isIntersecting ? '이제 화면에 보입니다!' : '화면 밖에 있습니다.'}
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## 라이선스
|
|
282
|
+
|
|
283
|
+
[MIT](./LICENSE)
|