Vue2+Vuetify2 から Vue3+Vuetify3 への移行（プログラム書き換え）

# はじめに
エネルギー診断サービス「エネがえる」を開発している @ysakurada です。

エネがえるはこれまで Vue2+Vuetify2で稼働していたのですが、Vue2 EOL対応で Vue3+Vuetify3への移行を実施しましたので、その辺りの経験を記事として残しておこうと思います。

使用しているのは次のバージョンです。
- vue 3.3.4
- vuetify 3.4.0

# 目次
- Vue3対応で大きく変わったところ
	- OptionsAPI → CompositionAPI
	- validationの方法
	- Vuetify component の細かい property や class表記 の 大幅な変更
	- なくなった/変わった Vuetify component
- その他の細かいTips
    - カスタムコンポーネントに v-modelを設定する
    - v-treeviewの代替

# Vue3対応で大きく変わったところ
Vue3対応で大きく変わった/変えたところは

- Options API → Composition API
- Vuex → Pinia
- Webpack → VITE
- validationの方法
- Vuetify component の細かい property や class表記 の 大幅な変更
- なくなった/変更された Vuetify component（treeview, date-picker etc.）

などがあります。

## OptionsAPI → CompositionAPI
プログラムを書くにあたって、こちらのブログがとても参考になりました。
[【Vue 3】Composition API の基本](https://b1san-blog.com/post/vue/vue-3-composition-api/)
 
プログラミング中に、特に引っかかった部分だけ書きます。

### ref, reactive

\<template>で使用している変数の中で、コンポーネントの稼働中に値が変わる可能性のあるものは ref() または reactive() （または computed）で定義する必要があります。
（Options APIでは data() 内に定義して、this.xx でアクセスすればよかったのが、this が使えなくなり、ref/reactive の違いを意識する必要があります。）

参考URLにあるように、「基本的に、オブジェクトは reactive、それ以外は ref を使用するのが良い」と私も思います（familyinfo.value.region_cdとか書きたくないｗ）が、オブジェクトそのものを変更してそれを watchしたい場合には ref を使用します。

逆に、稼働中に値が変わらない変数、\<template> 内で使用していない変数 は ref/reactive でなくてもOKです。

### computed

リアクティブに計算した結果にアクセスしたい場合に用います。なお、Piniaに保存したデータは \<template>内で、baseStore.xxx で直接アクセスできます（Vuexの時は computedで定義していました）。

``` vue
<script setup>
import {useBaseStore} from '@/stores/base.js';
const baseStore = useBaseStore();
//（省略）
</script>
<template>
  <!--（省略）-->
  <v-btn @click="xxx"
    :disabled="baseStore.isLoading"
    text="レポートをダウンロード"
  ></v-btn>
</template>
```

### props

propsで下位のコンポーネントに渡したデータのプロパティは下位コンポーネントで変更できます（データそのものは変更できません）。

``` js
//（省略）
const props = defineProps({
  editSim: Object,
});
//（省略）
const xxx = () => {
  props.editSim.sim_date = moment().format('YYYY/MM/DD');
}
```

### watch

オブジェクトの中のプロパティを watchしたいときは、次のように書きます。

``` js
const familyinfo = reactive({});
watch(() => familyinfo.region_cd, (newval, oldval) => {
  //（省略）
});
```

watchはしたいけど、初期処理の時だけ無視したい場合は次のように書いています。

``` js
const initState = ref(true);
//（省略）
watch(() => familyinfo.region_cd, (newval, oldval) => {
  if (initState.value) return;
  //（省略）
});

const init = () => {
  //（省略）
  nextTick(() => {
    initState.value = false;
  });
}

init();
```

また、配列の個々の値を watchしたいときは次のように書きます。

``` js
// localItem.hourlyRatios = (new Array(24)).fill(0);
watch(() => localItem.hourlyRatios, (newval, oldval) => {
  //（省略）
}, {deep: true});
```

## validationの方法

Vue2版では VeeValidateを使用していましたが、Vue3版では rulesプロパティを使用した validationに変更しています（Vuetify2の途中から実装されたようです）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908638/1db08402-7333-2451-ee0e-f2419ddee111.png)

実際のコードは次のような形になります。
``` vue: （主要部分のみ抽出）
<script setup>
import * as Rules from '@/utils/rules.js';

const formRef = ref();
const formValid = ref();

watch(() => localItem.password, (newval, oldval) => {
  formRef.value.validate();  // Ａ
});
const ruleSamePw = () => {  // Ｂ
  if (localItem.password == localItem.password2) return true;
  else return '一致しません';
};
</script>

<template>
  <v-form ref="formRef" v-model="formValid" @submit.prevent="submit">
    <v-text-field
      label="ユーザー名"
	  v-model="localItem.username"
	  :rules="[Rules.required, Rules.isHalfWidth]" <!--Ｃ-->
	/>
	<v-text-field
	  label="新規パスワード"
	  v-model="localItem.password"
	  :rules="[Rules.required, Rules.isHalfWidth]"
	/>
	<v-text-field
	  label="新規パスワード(確認用)"
	  v-model="localItem.password2"
	  :rules="[Rules.required, ruleSamePw]" <!--Ｄ-->
	/>
	<v-btn type="submit"
	  :disabled="!formValid" <!--Ｅ-->
	>ログイン</v-btn>
  </v-form>
</template>
```
静的な validation ruleは /utils/rules.js にまとめてあり、入力域のある formの中に上の（Ｃ）のような形で validationを実装できます。

動的に変化する他の変数と比較（一致、最大値、最小値 etc.）するような validation の場合は、そのコンポーネント内に ruleの関数を実装しています（Ｂ，Ｄ） 。これは rulesプロパティに値を渡す方法が見つけられなかったための苦肉の策ですｗ。

また、rulesプロパティによる validationはその項目が focusされていないと実行されません。初期表示時や他の項目の変更時に validationを実行したい場合は formに対して validate()を実行します（Ａ）。（なお、戻り値は Promiseですので、結果を受け取りたい場合は awaitします。）

validationの結果は v-form の v-modelにセットされるので、これを用いて v-btnの disabledを制御しています（Ｅ）。

## Vuetify component の細かい property や class表記 の 大幅な変更
（この記事を参照する方は最初に確認されているとは思いますが）[Upgrade Guide - Vuetify](https://vuetifyjs.com/en/getting-started/upgrade-guide/) は必ず読んで、どういったところに変更が入っているか確認しておいてください。

## なくなった/変わった Vuetify component
エネがえるで使用していた Vuetifyコンポーネントの変更で特に痛かったのは以下です。

- v-treeview が未実装
- v-date-picker の rangeプロパティがなくなった（別のコンポーネントとして置き換え予定らしい）
- v-stepper の構成が大きく変わった

### v-treeview が未実装

以下の２通りの回避策を実装しました。

- checkbox付きの v-autocomplete で multiple選択
- vue3treeviewを MyTreeview.vue でラップして使用

ひとつ目はキーワード入力によるフィルタリングが可能ですが、階層的な表示ができません。ふたつ目は、逆に階層的な表示が可能ですが、キーワード入力によるフィルタリングができません。いずれも不可能ということではなく、作りこめば可能と思います。

### v-date-picker の rangeプロパティがなくなった

カスタムコンポーネントでラップして、開始日・終了日をそれぞれ選択する形にしました。

### v-stepper の構成が大きく変わった

v-stepperは 垂直方向へのステップがなくなった（みたいな）ので、横方向のタブみたいな形になって、見た目が大きく変わっています。

# その他の細かいTips

## カスタムコンポーネントに v-modelを設定する
v-modelで双方向バインディングができるのでコードがすっきりします。

``` vue: MyDatePicker.vue (主要部分のみ抽出)
<script setup>
const emit = defineEmits(['update:modelValue']);
const props = defineProps({
  modelValue: String,  // Ａ
});

const date = ref();

watch(() => props.modelValue, (newval, oldval) => {
  date.value = new Date(newval);
});

watch(date, (newval, oldval) => {
  const val = moment(newval).format('YYYY-MM-DD');
  emit('update:modelValue', val);  // Ｂ
});

const init = () => {
  date.value = new Date(props.modelValue);
}

init();
</script>
```

``` vue: MyDateRangePicker.vue (主要部分のみ抽出)
<script setup>
const date0 = ref();
date0.value = moment().format('YYYY-MM-DD');
</script>

<template>
  <MyDatePicker
    v-model="date0"
  ></MyDatePicker>
</template>
```
上の例は、文字列の年月日（date0: YYYY-MM-DD形式）を介して、下位コンポーネント内では Date型で処理しています。どちらのコンポーネントで変更しても双方向バインディングされています。

肝は、Ａと Ｂです。

上位からは propsの modelValue という名前で v-modelのデータを渡し、下位での変更は update:modelValue というイベントで変更を通知することで v-modelによる双方向バインディングが実現できます（簡単！）。

## v-treeviewの代替
こんな感じのツリービューを使用したいわけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/908638/d1650cae-2057-533b-ff01-782ae2964232.png)

Vuetify2でのコードはこちら
``` vue:（主要部分のみ抽出）
<script>
const choices = ref([]); // 選択されたノードの idからなる１次配列
// items の形式はこんな感じ
const items = [
  id: 'all',
  name: '全プラン',
  locked: true,
  children: [
    {
      id: '4',  // epcorp_cd
      name: '東京電力エナジーパートナー',
      children: [
        { id: '4:5', name: '従量電灯B (40A)'}, // idを 親id + ':' + epplan_cd でセット
        { id: '4:45', name: 'スタンダードS (40A)' },
        // （省略）
      ]
    }
  ]
]
</script>

<template>
  <v-treeview 
    v-model="choices"
    :items="items" 
    selectable 
    :search="search"
    item-disabled="locked"
  ></v-treeview>
</template>
```
Vuetify3で Treeviewが未実装だったので、こちらのブログを参考に 代わりにvue3-treeviewを使って MyTreeview.vueでラップしました。
[【Vue】Vue-3-Treeviewでツリービューを表示する](https://wingdoor.co.jp/blog/%E3%80%90vue%E3%80%91vue-3-treeview%E3%81%A7%E3%83%84%E3%83%AA%E3%83%BC%E3%83%93%E3%83%A5%E3%83%BC%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B/)

参考URLを見るとわかりますが nodeの定義方法が違っているので、変換してやる必要があります。

```vue: MyTreeview.vue（主要部分のみ抽出）
<script setup>
import 'vue3-treeview/dist/style.css';
import Tree from 'vue3-treeview';

const props = defineProps({
  modelValue: Array,
  items: Array,
  disabled: Boolean,
});

// vue3-treeview の config
const config = ref({
  roots: ["all"],
  checkboxes: true,
  checkMode: 0,
  padding: 22,
});
const nodes = reactive({});

// props.disabled が変わった時に実行
const changeDisabled = (disabled) => {
  for (const key in nodes) {
    nodes[key].state.disabled = disabled;
  }
}

// items(node)のフォーマット変換
const addnode = (item, root) => {
  const key = String(item.id);
  root[key] = {
    text: item.name,
    children: [],
    state: {
      checked: false,
      disabled: props.disabled,
    }
  }
  if (item.children) {
    item.children.forEach(child => {
      root[key].children.push(String(child.id));
      addnode(child, root);
    });
  }
}

// v-modelの更新
const getChecked = (node) => {
  for (const key in node.children) {
    const id = node.children[key];
    nodes[id].state.checked = node.state.checked;
  }

  const choices = [];
  for (const key in nodes) {
    if (nodes[key].children.length === 0) {
      if (nodes[key].state.checked) {
        choices.push(key);
      }
    }
  }

  emit('update:modelValue', choices);
}

// 初期表示時に子ノードの状態を親ノードの statusに反映（初期表示時は これをやらないと反映されない）
const checknode = (node, root) => {
  if (node.children.length == 0) return;
  node.children.forEach(key => {
    if (root[key].children.length > 0) {
      checknode(root[key], root);
    }
  });

  const childStates = node.children.map(key => {
    return root[key].state;
  });
  const state = {
    checked: false,
    disabled: props.disabled,
  }
  if (childStates.find(item => !!(item.indeterminate))) {
    state.checked = false;
    state.indeterminate = true;
  } else {
    if (childStates.find(item => !!(item.checked))) {
      if (childStates.find(item => !(item.checked))) {
        state.checked = false;
        state.indeterminate = true;
      } else {
        state.checked = true;
      }
    }
  }
  node.state = state;
}

const init = () => {
  const root = {};
  addnode(props.items[0], root);
  props.modelValue.forEach(key => {
    root[key].state.checked = true;
  });
  checknode(root.all, root);

  Object.assign(nodes, root);
}

init();
</script>

<template>
  <Tree
    :config="config"
    :nodes="nodes"
    @nodeChecked="getChecked"
    @nodeUnchecked="getChecked"
  ></Tree>
</template>
```
# おわりに
ボク自身の経験をいくつか書いてきました。
調査・知識不足で、間違っている部分等あるかもしれませんが、ちょっとした参考にでもなれば幸いです（間違っている部分等はご指摘いただければ幸いです）。