移行ガイド
本ページは 2 つのことを説明します: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 prop |
名前付きスロット #header | header?: ReactNode prop |
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 は元々直ちに 1 回実行される) |
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 では 2 組の props に分かれます:
<!-- Vue -->
<LhInput v-model="text" />
// React:制御
<LhInput value={text} onChange={(val) => setText(val)} />
// React:非制御
<LhInput defaultValue="初期値" />
混用(value と defaultValue を同時に渡す)は開発時に警告を 1 つ出します。React ネイティブフォームのセマンティクスと一致します。
イベントのコールバック
すべてのイベント 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 のマウントの差異
3 つとも命令的サービスですが、マウントの方式が異なります: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 のライフサイクルを 2 組の effect に分割
Vue 版は watch(visible, cb, { immediate: true }) の 1 つのコールバックで開閉を処理します。React 版は「open effect」(オーバーレイスタックへの登録、スクロールのロック、onOpen の発火)と「mounted effect」(focus trap、onOpened / onClosed の発火)に分割します。トランジションのマウントのタイミングと open のタイミングは React では 2 つの独立したライフサイクルだからです。
10. Form の 非同期バリデーションの修正
plus の validateField は Promise.resolve(!message) を使い、非同期 validator が返す Promise を待機しません —— 非同期バリデーションは常に true を返します。React 版は isThenable の判定を加え、非同期 validator の完成を明示的に待ってから判定します:
if (isThenable(message)) return message.then((error) => !error)
この欠陥はファミリーの bugfix 台帳に登録されており、Vue 版の修正は後でバックポートされる予定です。
11. Tooltip の制御の簡素化
plus の Tooltip は manual スイッチと visible による制御を組み合わせます。React 版は visible prop を直接に見ます: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 のパスワード表示切替:
showPasswordprop はtype="password"の時に目のボタンを描画して平文を切り替える。 plus / vue2 にこの prop はない。
13. Wave C コンポーネントの React 特化
- Drawer のマスクはデフォルトでクリック貫通しない:
maskClosableのデフォルトはfalse(core のcanDrawerCloseはデフォルトで拒否)。 plus のcloseOnClickModalはデフォルトtrueなので、移行時には明示的な宣言が必要。フォーカストラップはlockScrollが有効な時にのみ 有効になり (shouldDrawerTrapFocus)、スクロールをロックしない時は背後の内容の Tab 序列を維持する。 - Dropdown のスロットを prop に変更:plus の
#dropdownスロットは React 側で同名のdropdownprop に変更。 メニュー項目は 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
}