• #Vue3
  • #リアクティビティ
  • #props
  • #v-model

Vue 3のリアクティビティとpropsバインディングの仕組み

財務チャートのアニメーション実装で使用している、Vueのリアクティビティシステムによるデータ連携の仕組みを解説します。

全体の構成

┌─────────────────────────────────────────────────────────┐
│  親コンポーネント (proportional-animation.vue)            │
│                                                         │
│  const microsoftPeriodIndex = ref(0)  ← リアクティブな状態 │
│                                                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │ ProportionalFinancialStatementsAnimated         │   │
│  │   v-model="microsoftPeriodIndex"                │   │
│  │   (双方向バインディング - 値を読み書き)          │   │
│  └─────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │ AnimationControlPanel                           │   │
│  │   v-model="microsoftPeriodIndex"                │   │
│  │   (双方向バインディング - 値を読み書き)          │   │
│  └─────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │ EPSChart / FCFChart / ProfitMarginChart など     │   │
│  │   :current-period-index="microsoftPeriodIndex"  │   │
│  │   (読み取り専用 - 値を受け取るだけ)             │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Step 1: リアクティブな状態の定義

// 親コンポーネントで ref を使って状態を定義
const microsoftPeriodIndex = ref(0)

ref(0) は初期値 0 を持つリアクティブな参照を作成します。この値が変更されると、それを参照しているすべての場所が自動的に再レンダリングされます。

Step 2: v-model による双方向バインディング

<AnimationControlPanel
  v-model="microsoftPeriodIndex"
  :labels="microsoftPeriodLabels"
/>

v-model は以下のショートハンドです:

<AnimationControlPanel
  :modelValue="microsoftPeriodIndex"
  @update:modelValue="microsoftPeriodIndex = $event"
/>

子コンポーネント側での実装:

// AnimationControlPanel.vue
const props = defineProps<{
  modelValue: number
  labels: string[]
}>()

const emit = defineEmits<{
  'update:modelValue': [value: number]
}>()

// 値を変更する時
const goToNext = () => {
  const nextIndex = (props.modelValue + 1) % props.labels.length
  emit('update:modelValue', nextIndex)  // 親に通知
}

Step 3: props による読み取り専用バインディング

<EPSChart
  :eps-data="microsoftEpsData"
  :current-period-index="microsoftPeriodIndex"
/>

:current-period-index は単方向バインディング(親→子)です。子コンポーネントは値を受け取るだけで、変更はできません。

子コンポーネント側:

// EPSChart.vue
interface Props {
  epsData: EpsDataItem[]
  currentPeriodIndex: number
}

const props = defineProps<Props>()

// props.currentPeriodIndex を使って現在の期間をハイライト

データの流れ

  1. 初期状態: microsoftPeriodIndex = 0
  2. ユーザー操作: AnimationControlPanelの「次へ」ボタンをクリック
  3. emit発火: emit('update:modelValue', 1)
  4. 親の状態更新: microsoftPeriodIndex1 に変更
  5. 自動再レンダリング: microsoftPeriodIndex を参照しているすべてのコンポーネントが更新される
    • EPSChart の現在期間のハイライトが変わる
    • FCFChart の現在期間のハイライトが変わる
    • ProfitMarginChart の現在期間のハイライトが変わる
    • 財務諸表の表示期間も変わる

なぜこの設計が良いのか

単一の信頼できる情報源(Single Source of Truth)

// 状態は親コンポーネントで1箇所だけ管理
const microsoftPeriodIndex = ref(0)

複数のコンポーネントが同じ状態を参照するので、常に同期が保たれます。

明確なデータフロー

  • v-model: 状態を変更できるコンポーネント(コントロールパネル)
  • : 状態を読み取るだけのコンポーネント(チャート)

誰が値を変更できるかが明確になります。

実際のコード例

親コンポーネント

<template>
  <div class="two-column-layout">
    <!-- 左側: 財務諸表 + コントロール -->
    <div class="left-column">
      <ProportionalFinancialStatementsAnimated
        v-model="microsoftPeriodIndex"
        :companies="microsoftData"
      />
      <AnimationControlPanel
        v-model="microsoftPeriodIndex"
        :labels="microsoftPeriodLabels"
      />
    </div>

    <!-- 右側: チャート -->
    <div class="right-column">
      <EPSChart
        :eps-data="microsoftEpsData"
        :current-period-index="microsoftPeriodIndex"
      />
      <FCFChart
        :cash-flow-data="microsoftCashFlowData"
        :current-period-index="microsoftPeriodIndex"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

// 唯一の状態管理
const microsoftPeriodIndex = ref(0)

// チャート用のデータ(computed で期間ラベルからデータを生成)
const microsoftEpsData = computed(() => {
  return company.periods.map(period => ({
    label: period.label,
    eps: epsDataMap[period.label] || 0
  }))
})
</script>

子コンポーネント(チャート)

<template>
  <svg>
    <circle
      v-for="(point, index) in dataPoints"
      :key="index"
      :cx="point.x"
      :cy="point.y"
      :r="index === currentPeriodIndex ? 7 : 4"
      :fill="index === currentPeriodIndex ? '#ff6b6b' : '#4caf50'"
    />
  </svg>
</template>

<script setup lang="ts">
interface Props {
  epsData: { label: string; eps: number }[]
  currentPeriodIndex: number  // 親から渡される現在の期間
}

const props = defineProps<Props>()

// currentPeriodIndex を使って現在選択中の期間を大きく・赤く表示
</script>

まとめ

用途記法データの流れ
状態の定義ref(初期値)-
双方向バインディングv-model親 ⇄ 子
読み取り専用バインディング:props親 → 子

Vueのリアクティビティシステムにより、ref で定義した値が変更されると、その値を参照しているすべてのコンポーネントが自動的に更新されます。これが「宣言的UI」の核心部分です。