마이그레이션 가이드
이 페이지는 두 가지를 다룹니다: Vue(lh-ui-plus / lh-ui-vue2) → React의 일반적인 개념 매핑과, 이 라이브러리 구현에서 Vue 버전과 의도적으로 다른 점.
개념 매핑 총정리
| Vue (lh-ui-plus) | React (@lh-ui/react) |
|---|---|
defineProps<T>() | interface LhXxxProps + 구조 분해 기본 매개변수 |
withDefaults(...) | 구조 분해 기본값 (defaultProps 사용 안 함) |
emits + emit('change', [val, e]) | onChange?: (value, event) => void |
v-model="x" | value={x} onChange={...} |
v-model:visible="x" | visible={x} onVisibleChange={...} |
defineExpose({ ... }) | forwardRef + useImperativeHandle |
<slot> | children |
<slot name="footer"> | footer?: ReactNode 프로퍼티 |
명명된 슬롯 #header | header?: ReactNode 프로퍼티 |
provide(...) / inject(...) | Context.Provider / useContext |
provide는 반드시 computed | Context value는 반드시 useMemo |
computed(() => ...) | useMemo(() => ..., [deps]) |
watch(src, cb) | useEffect(() => cb(), [src]) |
watch(src, cb, { immediate: true }) | useEffect (effect는 본래 즉시 한 번 실행됨) |
onMounted / onUnmounted | useEffect의 setup / cleanup |
<Transition name="lh-x-fade"> | useTransition(visible, duration)가 동일 클래스명을 구동 |
<Teleport to="body"> | createPortal(..., document.body) |
ref="<name>" | useRef<HTMLDivElement>(null) |
nextTick() | flushSync() 또는 effect (보통은 필요 없음) |
app.use(LhUI) | 없음 — 명명된 내보내기를 주문형으로 도입 |
<LhXxx v-bind="obj"> | <LhXxx {...obj}> |
제어와 비제어
Vue의 modelValue / update:modelValue는 React에서 두 가지 props로 나뉩니다:
<!-- Vue -->
<LhInput v-model="text" />
// React: 제어형
<LhInput value={text} onChange={(val) => setText(val)} />
// React: 비제어형
<LhInput defaultValue="초기값" />
혼용(동시에 value와 defaultValue를 전달)하면 개발 모드에서 경고가 출력되며, React 네이티브 form 의미론과 일치합니다.
이벤트 콜백
모든 이벤트 props는 onXxx 명명으로 통일되고, 콜백 시그니처는 2-매개변수 (value, event)로 통일됩니다:
<!-- Vue -->
<LhSelect @change="onChange" />
// React
<LhSelect onChange={(value, option) => console.log(value, option)} />
전환 애니메이션
Vue의 <Transition>은 선언형이고, React 버전은 자체 구현한 useTransition hook으로 동일한 클래스명 세트를 구동합니다:
import { useTransition } from '@lh-ui/react'
const { mounted, phase } = useTransition(visible, 300)
// 클래스명: `lh-dialog-fade-${phase}` — phase ∈ enter-from / enter-active / enter-to
클래스명이 share SCSS와 완전히 일치해, 시각적 표현이 Vue 버전과 프레임 단위로 같습니다.
오버레이와 텔레포트
Vue의 <Teleport to="body">는 React의 createPortal에 대응하며, Dialog / Select 드롭다운 / Tooltip은 모두 document.body에 마운트됩니다. 위치 지오메트리는 useLhPopper가 계산합니다 (Vue 버전과 @lh-ui/share/core/shared/popper를 공유).
명령형 ref
Vue의 defineExpose는 forwardRef + useImperativeHandle에 대응합니다:
<!-- Vue -->
<LhForm ref="formRef" />
// React
const formRef = useRef<LhFormInstance>(null)
<LhForm ref={formRef} />
<formRef.current?.validate() />
전역 설정
<!-- Vue -->
<LhConfigProvider :size="size">
<App />
</LhConfigProvider>
// React
<LhConfigProvider size={size}>
<App />
</LhConfigProvider>
이 라이브러리와 Vue 버전의 구현 차이
이하 차이점은 React 형태에서의 의도된 조정이며, 마이그레이션 시 주의가 필요합니다. 각 항목은 저장소 내에 등록되어 있습니다.
1. 스타일 주문형 주입
lh-ui-plus(전체 주입)와 달리, 이 라이브러리는 lh-ui-vue2에 맞춥니다: 패키지 진입점에서 스타일을 주입하지 않고, 사용자가 theme.css + style/<name>을 명시적으로 가져옵니다.
2. 빌드 체인은 vue2와 동일
vite lib mode + preserveModules + tsc가 d.ts를 그대로 생성합니다 (plus는 rollup 다중 진입점 스캔 사용). preserveModules는 주문형 도입을 자연스럽게 지원하고, tsx에는 SFC 가상 모듈이 없으며, tsc의 d.ts 직출이 가장 간단합니다.
3. ConfigProvider 소비 측 폴백
Vue 버전은 inject(..., null)로 각 컴포넌트가 null 체크를 합니다. React 버전의 소비 hook은 모든 필드가 undefined인 폴백 객체를 반환해, 컴포넌트 내부에서 null 체크가 필요 없습니다 — useContext는 호출 지점에서 기본값을 줄 수 없기 때문에, 이는 React Context의 일반적인 관행입니다.
4. notification / loading의 마운트 차이
셋 모두 명령형 서비스지만 마운트 방식이 다릅니다: message / notification은 싱글톤 컨테이너를 공유하고 (지연 createRoot + action 채널), loading은 아무 목표 요소(전체 화면 또는 일부)에 마운트되어야 하며, per-instance createRoot를 사용해 각 인스턴스가 자신의 root와 overflow / inert 복원 핸들을 갖습니다.
notification의 API는 객체 형태입니다 (notification.success({ title, content }), plus의 Notification(options)에 맞춤). 문자열 줄임 표기도 받습니다 (제목 줄만 렌더링). FLIP 이동 애니메이션은 미구현: Vue의 transition-group이 제공하는 -move 클래스와 동등한 원시 요소가 React 쪽에 없어, 큐의 추가 / 삭제 때 형제 카드가 순간적으로 위치를 바꿉니다.
5. Input의 onChange 2-매개변수
Vue 버전은 @input / @change가 분리되어 있고, React 버전은 onChange(value, event)로 통일됩니다 (추가로 onInput이 네이티브 입력 이벤트를 전달).
6. Input autosize를 컴포넌트가 DOM 조작
Vue 버전은 data-autosize 속성으로 디렉티브가 DOM 레벨에서 높이를 계산합니다. React 버전은 useEffect에서 scrollHeight를 읽고 textarea.style.height에 직접 기록합니다 (minRows / maxRows 제약). 추가적인 디렉티브 메커니즘 도입을 피하기 위해서입니다.
7. Select 위치 계산을 useLhPopper로 변경
일부 Vue 버전의 Select는 직접 fixed 위치를 쓰고 뒤집지 않습니다. React 버전은 useLhPopper로 통일합니다 (Dialog / Tooltip과 동일한 지오메트리, 동일한 넘침 뒤집기와 스크롤 시 닫기).
8. Dialog 순수 제어
plus의 Dialog는 내부에 isVisible 중간 상태를 갖고 watch로 modelValue와 양방향 동기화합니다. React 버전은 중간 상태를 제거했습니다: open을 외부가 소유하고, 모든 닫기 경로는 onClose만 호출합니다. 제어 의미가 더 순수하고 props 와 내부 상태의 경쟁을 없앱니다.
9. Dialog 생명주기를 두 그룹의 effect로 분할
Vue 버전은 watch(visible, cb, { immediate: true }) 콜백 하나로 열고 닫기를 처리합니다. React 버전은 "open effect"(오버레이 스택 등록, 스크롤 잠금, onOpen 트리거)와 "mounted effect"(focus trap, onOpened / onClosed 트리거)로 나눕니다. 전환 애니메이션의 마운트 시점과 open 시점이 React에서는 서로 다른 두 개의 독립적인 생명주기이기 때문입니다.
10. Form의 비동기 검증 수정
plus의 validateField는 Promise.resolve(!message)를 사용해, 비동기 validator가 반환하는 Promise를 기다리지 않습니다 — 비동기 검증이 항상 true를 반환합니다. React 버전은 isThenable 판정을 추가해, 비동기 validator가 완료된 뒤에야 판정합니다:
if (isThenable(message)) return message.then((error) => !error)
이 결함은 패밀리 버그픽스 대장에 등록되었으며, Vue 버전으로의 수정은 아직 이식되지 않았습니다.
11. Tooltip 제어 단순화
plus의 Tooltip은 manual 스위치로 visible 제어에 맞춥니다. React 버전은 visible 프로퍼티를 직접 봅니다: visible을 전달하면 제어형이고, onVisibleChange 콜백이 외부에 알리며, manual 중간 스위치는 더 이상 유지하지 않습니다.
12. React 측 개선 (plus에는 아직 없음)
이하 차이점은 React 버전의 개선이며, 마이그레이션 시 새 기능으로 사용하면 됩니다:
- Tag 닫기 즉시 언마운트: 닫기 버튼을 클릭하면 Tag가 즉시 DOM에서 언마운트된 뒤
onClose가 발생합니다 (plus는 이벤트만 던지고 외부v-if가 노드를 제거). - Input 지우기 되돌아쓰기:
clearable의 지우기 버튼이onChange('')도 함께 던져, 제어 상태가 자동으로 동기화됩니다 (plus의@clear는 이벤트만 던짐). - Form change 검증:
rule.trigger === 'change'인 규칙이 콘텐츠 영역의 change 버블에 따라 발생합니다 (plus는 reactive watch로 구동). - Form 재설정 시 에러 초기화:
resetFields()가 내부 버전 번호를 증가시키고, 각 FormItem이 감시해 에러 상태를 비웁니다 (plus는 reactive 체인으로 자동 비움). - Input 비밀번호 표시 / 숨김:
showPassword프로퍼티는type="password"일 때 눈 버튼을 렌더링해 평문 전환. plus / vue2에는 이 프로퍼티가 없습니다.
13. Wave C 컴포넌트의 React 특화
- Drawer 마스크는 기본적으로 클릭 관통 안 됨:
maskClosable기본값false(core의canDrawerClose는 기본 거부). plus의closeOnClickModal기본값은true이므로 마이그레이션 시 명시적으로 선언해야 합니다. 포커스 트랩은lockScroll이 켜져 있을 때만 활성화되며 (shouldDrawerTrapFocus), 스크롤을 잠그지 않을 때는 배경 콘텐츠의 Tab 순서를 유지합니다. - Dropdown 슬롯을 프로퍼티로 변경: plus의
#dropdown슬롯은 React 쪽에서 동명의dropdown프로퍼티가 됩니다. 메뉴 항목은 Context로 DOM을 등록하고, roving tabindex는activeEl앵커 상태로 구동됩니다 (Vue 버전은getCurrentInstance로 자식 컴포넌트 인스턴스를 수집). - Popover / Popconfirm 제어화: Tooltip과 동일하게
visible을 전달하면 제어형,onVisibleChange가 외부로 나가며, plus의manual스위치는 없습니다.
타입 마이그레이션 치트시트
// Vue: props는 withDefaults로 추론
const props = withDefaults(defineProps<LhButtonProps>(), { type: 'text' })
// React: 인터페이스 + 구조 분해 기본값
function LhButton(props: LhButtonProps) {
const { type = 'text' } = props
}
// Vue: emit 선언
const emit = defineEmits<{ change: [val: string, e: Event] }>()
// React: props의 콜백
interface LhXxxProps {
onChange?: (value: string, event: React.ChangeEvent<HTMLInputElement>) => void
}