【Vue + Onsen UI】コンポーネントでラジオボタンを使う時は再利用に気を付けないと危ないかもしれない

Onsen UI の PageStack を利用したページ遷移での話だけど、他の実装方法でも同じ状況がある気がするからまとめておく。
# 具体例
構成はこんな感じ。

index.html
　└ main.js
　　　└ App.vue
　　　　　├ Page1.vue
　　　　　│　└ CustomToolbar.vue
　　　　　└ Page2.vue
　　　　　　　└ CustomToolbar.vue

Page1.vue と Page2.vue の両方で同じツールバー CustomToolbar.vue を使っている。

このツールバーの中にラジオボタンを入れると、v-model でバインドされてないような状態になってた。
以下はその原因になってる該当箇所。

```vue:App.vue
<template>
  <v-ons-navigator :page-stack="pageStack">
    <component :is="page" v-for="page in pageStack" :key="page.key" :page-stack="pageStack">
    </component>
  </v-ons-navigator>
</template>

<script>
//・・・省略
```

```vue:customToolbar.vue
<template>
  <v-ons-toolbar>
    <v-ons-toolbar-button @click="sizeDialogVisible = true">
      <i class="zmdi zmdi-format-size"></i>
    </v-ons-toolbar-button>
    <v-ons-alert-dialog :visible.sync="sizeDialogVisible" cancelable>
      <span slot="title">フォントサイズ変更</span>
      <v-ons-list>
        <v-ons-list-item v-for="(item, $index) in sizes" :key="item.size" tappable>
          <label class="left">
            <v-ons-radio :input-id="'radio-' + $index" :value="item.size" v-model="selectedSize"></v-ons-radio>
          </label>
          <label :for="'radio-' + $index" class="center">{{ item.name }}</label>
        </v-ons-list-item>
      </v-ons-list>
    </v-ons-alert-dialog>
  </v-ons-toolbar>
  <!-- ・・・省略 -->
</template>

<script>
export default {
  data() {
    return {
      //・・・省略
    };
  },
  //・・・省略
};
</script>
```

原因の箇所以外はかなり省略していてわかりづらいから一応解説。

* ツールバーからアイコンをタップしてフォントサイズの変更用のモーダルを表示する
* モーダルに表示されたフォントサイズのリストからラジオボタンで選択してフォントサイズを変更する

Page1.vue が表示されているときは問題なく動作していて、Page2.vue が表示されているときは反応しなくなった。

# 原因
数時間あれこれ試して悩んだ挙句、初歩的な原因にようやく気付いた。
**v-for で生成しているラジオボタンの input-id が重複していた。**

pageStack でのページ遷移は、Page1 と Page2 の表示非表示を display:none で切り替えている。
DOM には Page1 と Page2 が両方とも残っているため、**customToolbar が DOM に2つ存在していた**。
その結果、Page1 では一応動作していたものの、Page2 では反応しなくなっていた。

# 解決策
id が被らなければいいわけで、解決策はいくらでもありそうだけど一応2パターン掲載する。

## 解決策1. 別のコンポーネントに切り分ける
上記の例ではラジオボタンを使っている部分がモーダルになっている。
モーダルを別のコンポーネントにして App.vue から読み込んで、各コンポーネントで使うように構成を変えてしまえば、DOM要素に1つだけしか描画されなくなる。
理屈的にはこの方法がベターかもしれないけど、設計的に変更できない場合の方が多い気がする。

## 解決策2. ページごとに固有のIDをつける
App.vue の **:is="Page"** を使う。
App.vue から Page1.vue と Page2.vue に値を渡して、上記の $index の後に追加すればそれぞれ別の id になる。
私はこの方法で対応したから一応修正したコードも。

```vue:App.vue
<template>
  <v-ons-navigator :page-stack="pageStack">
    <!-- デフォルトの属性に「:page="page"」を追加してる -->
    <component :is="page" v-for="page in pageStack" :key="page.key" :page-stack="pageStack" :page="page">
    </component>
  </v-ons-navigator>
</template>

<script>
//・・・省略
```

```vue:customToolbar.vue
<template>
  <v-ons-toolbar>
    <v-ons-toolbar-button @click="sizeDialogVisible = true">
      <i class="zmdi zmdi-format-size"></i>
    </v-ons-toolbar-button>
    <v-ons-alert-dialog :visible.sync="sizeDialogVisible" cancelable>
      <span slot="title">フォントサイズ変更</span>
      <v-ons-list>
        <v-ons-list-item v-for="(item, $index) in sizes" :key="item.size" tappable>
          <label class="left">
            <!-- $index の後に pageNum を追加してる -->
            <v-ons-radio :input-id="'radio-' + $index + pageNum" :value="item.size" v-model="selectedSize"></v-ons-radio>
          </label>
          <label :for="'radio-' + $index + pageNum" class="center">{{ item.name }}</label>
        </v-ons-list-item>
      </v-ons-list>
    </v-ons-alert-dialog>
  </v-ons-toolbar>
  <!-- ・・・省略 -->
</template>

<script>
export default {
  data() {
    return {
      //props の page からキーを取得
      pageNum: this.page.key,
      //・・・省略
    };
  },
  //・・・省略
  props: ["page"]
};
</script>
```

# 余談
初歩的すぎるせいなのか、コンポーネントでラジオボタンを使う時は再利用に気を付けよう、的な情報が見当たらなくて無駄に時間を費やした。
そんなわけで、もしかしたら同じようなところでつまづく人もいるかもしれないと思って記事にしてみた。

とはいえ、もっとスマートなやり方がありそうな気がしてならない。
v-if とか使ってDOM要素から消したりとか、他にも方法ありそうだけど実装方法がいまいちわからない。

id の重複が原因になる不具合を令和になってから体験するとは思いもしなかった。