【Vue.js】データの同期が取れない場合に確認すべき場所

リアルタイムにデータを制御する際に便利なJSフレームワーク、Vue.jsですが、オブジェクトの同期のとり方には独特の癖があります。よく引っかかりがちなので、Vueの初心者用TIPSにも注記があるのですが、後学のため具体的にどういうものかを説明してみようと思います。

なお、最初はVue2のoptionsAPIで記述しているため、Vue3のcompositionAPIでの検証結果は次の章となります。

なお、趨勢としてVueもTypeScriptでの記述が主流となってきていますが、本記事ではそこには触れていません。

## サンプル
問題が起きたサンプルデータが以下のような万年カレンダーです。初期動作では現在の年月に合わされ、年、月を変更するとそれに連動して日付が入れ替わります。

```vue:Calender.vue
<template>
	<content>
	<table class="tbl" border="1">
		<caption>
			<select v-model="selYear" @change="changeYear()">
				<option v-for="y in years" >{{y}}</option>
			</select>年
			<select v-model="selMonth" @change="changeMonth()">
				<option v-for="m in months">{{m}}</option>
			</select>月
		</caption>
		<thead>
			<tr>
				<th>Sun</th>
				<th>Mon</th>
				<th>Tue</th>
				<th>Wed</th>
				<th>Thi</th>
				<th>Fri</th>
				<th>Sat</th>
			</tr>
		</thead>
		<tbody>
			<CalTr v-for="(week,widx) in calboxes"
				:week = "week"
			></CalTr>
		</tbody>
	</table>
	</content>
</template>
<script>
import CalTr from './CalTr.vue'
export default{
	name: 'Calender',
	components:{
		CalTr,
	},
	data(){
		return{
			months:[1,2,3,4,5,6,7,8,9,10,11,12],
			years: [],
			year: '',
			month: '',
			selYear: '',
			selMonth: '',
			days:{
				date: '',
			},
			calboxes: [], //カレンダーボックスの格納
			word: [], //スケジュールの入力
		}
	},
	mounted(){
		let d = new Date();
		let year = d.getFullYear()
		let month = d.getMonth() + 1
		this.makeCalBox(year,month) //カレンダーボックスの作成
		this.makeYearPulldown(year) //年プルダウンの生成
		this.selMonth = month
		this.selYear = year
	},
	methods:{
		//年の構築
		makeYearPulldown(year){
			let years = this.years
			for(let y = 1900;y <= year;y++){
				years.push(y)
			}
			this.years = years
		},
		//年の入れ替え
		changeYear(){
			let year = this.selYear
			let month = this.selMonth
			this.makeCalBox(year,month) //カレンダーボックスの作成
			return year
		},
		//月の入れ替え
		changeMonth(){
			let year = this.selYear
			let month = this.selMonth
			this.makeCalBox(year,month) //カレンダーボックスの作成
			return month
		},
		//カレンダーボックスの構築
		makeCalBox(year,month){
			let d = new Date(year,month-1,1)
			let lastday = this.getLastDay(year,month)
			let buf_blank = d.getDay()
			let cnt_calbox = buf_blank + lastday + 1
			let calboxes = this.calboxes
			calboxes = []
			let weeks_tmp = []
			let days = this.days
			let day
			for(let i = 1; i < cnt_calbox;i++){
				day = i - buf_blank
				if(day < 1){
					day = null
				}else{
					days = {"date":day}
				}
				if(i % 7 === 0 ){
					calboxes.push(weeks_tmp)
					weeks_tmp.push(days)
					weeks_tmp = []
				}else{
					weeks_tmp.push(days)
				}
			}
			calboxes.push(weeks_tmp)
			this.calboxes = calboxes
		},
		//最終日を取得
		getLastDay(year,month){
			let lastday = 0
			if(month == 2){
				if((year % 4 === 0 && year % 100 !== 0 )|| year % 400 == 0){
					lastday = 29
				}else{
					lastday = 28
				}
			}else if(month < 8){
				if(month % 2 === 1){
					lastday = 31
				}else{
					lastday = 30
				}
			}else if(month >= 8){
				if(month % 2 === 0){
					lastday = 31
				}else{
					lastday = 30
				}
			}
			return lastday
		}
	},
}
</script>
```

```vue:CalTr.vue
<template>
	<tr>
		<CalTd v-for="(day,didx) in week"
			:day="day"
			:didx="didx"
			:week="week"
		></CalTd>
	</tr>
</template>
<script>
import CalTd from './CalTd.vue' 
export default{
	name: 'CalTr',
	components:{
		CalTd,
	},
	props:[
		"week",
	],
}
</script>
```

```vue:CalTd.vue
<template>
	<td class="td" width="180px"">
		<dl>
			<dt>
			<p v-if="day.date != ''" p class="daystamp">
				{{ day.date }}
			</p>
			</dt>
			<dd></dd>
		</dl>
	</td>
</template>
<script>
export default{
	name: 'CalTd',
	props:[
		"day",
		"didx",
		"week",
	],
}
</script>
```

このように挙動します。

![cal1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/6b43f06d-07e5-7810-1192-cf8d5fd792be.jpeg)
![cal2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/308883c7-6dca-f0e3-f5bd-688c36ab9b4e.jpeg)


では、このカレンダーに対して、スケジュールを入力できるようにしてみます。

## スケジュール入力機能を実装

```vue:Calender.vue
<template>
	<content>
	<table class="tbl" border="1">
		<caption>
			<select v-model="selYear" @change="changeYear()">
				<option v-for="y in years" >{{y}}</option>
			</select>年
			<select v-model="selMonth" @change="changeMonth()">
				<option v-for="m in months">{{m}}</option>
			</select>月
		</caption>
		<thead>
			<tr>
				<th>Sun</th>
				<th>Mon</th>
				<th>Tue</th>
				<th>Wed</th>
				<th>Thi</th>
				<th>Fri</th>
				<th>Sat</th>
			</tr>
		</thead>
		<tbody>
			<CalTr v-for="(week,widx) in calboxes"
				:word = "word"
				:week = "week"
			></CalTr>
		</tbody>
	</table>
	<label>予定を記述した後、日付をクリック！<input type="text" v-model="word" ></label>
	</content>
</template>
<script>
import CalTr from './CalTr.vue'
export default{
	name: 'Calender',
	components:{
		CalTr,
	},
	data(){
		return{
			months:[1,2,3,4,5,6,7,8,9,10,11,12],
			years: [],
			year: '',
			month: '',
			selYear: '',
			selMonth: '',
			days:{
				date: '',
				schedule: '',
			},
			calboxes: [], //カレンダーボックスの格納
			word: [], //スケジュールの入力
		}
	},
	mounted(){
		let d = new Date();
		let year = d.getFullYear()
		let month = d.getMonth() + 1
		this.makeCalBox(year,month) //カレンダーボックスの作成
		this.makeYearPulldown(year) //年プルダウンの生成
		this.selMonth = month
		this.selYear = year
	},
	watch:{
		calboxes:function(){
			//this.word = '' //一旦はコメントアウト
		}
	},
	methods:{
		//年の構築
		makeYearPulldown(year){
			let years = this.years
			for(let y = 1900;y <= year;y++){
				years.push(y)
			}
			this.years = years
		},
		//年の入れ替え
		changeYear(){
			let year = this.selYear
			let month = this.selMonth
			this.makeCalBox(year,month) //カレンダーボックスの作成
			return year
		},
		//月の入れ替え
		changeMonth(){
			let year = this.selYear
			let month = this.selMonth
			this.makeCalBox(year,month) //カレンダーボックスの作成
			return month
		},
		//カレンダーボックスの構築
		makeCalBox(year,month){
			let d = new Date(year,month-1,1)
			let lastday = this.getLastDay(year,month)
			let buf_blank = d.getDay()
			let cnt_calbox = buf_blank + lastday + 1
			let calboxes = this.calboxes
			calboxes = []
			let weeks_tmp = []
			let days = this.days
			let day
			for(let i = 1; i < cnt_calbox;i++){
				day = i - buf_blank
				if(day < 1){
					day = null
				}else{
					days = {"date":day,"schedule":''}
				}
				if(i % 7 === 0 ){
					calboxes.push(weeks_tmp)
					weeks_tmp.push(days)
					weeks_tmp = []
				}else{
					weeks_tmp.push(days)
				}
			}
			calboxes.push(weeks_tmp)
			this.calboxes = calboxes
		},
		//最終日を取得
		getLastDay(year,month){
			let lastday = 0
			if(month == 2){
				if((year % 4 === 0 && year % 100 !== 0 )|| year % 400 == 0){
					lastday = 29
				}else{
					lastday = 28
				}
			}else if(month < 8){
				if(month % 2 === 1){
					lastday = 31
				}else{
					lastday = 30
				}
			}else if(month >= 8){
				if(month % 2 === 0){
					lastday = 31
				}else{
					lastday = 30
				}
			}
			return lastday
		}
	},
}
</script>
```

```vue:CalTr.vue
<template>
	<tr>
		<CalTd v-for="(day,didx) in week"
			:day="day"
			:didx="didx"
			:word="word"
			:week="week"
		></CalTd>
	</tr>
</template>
<script>
import CalTd from './CalTd.vue' 
export default{
	name: 'CalTr',
	components:{
		CalTd,
	},
	props:[
		"week",
		"word",
	],
}
</script>
```

```vue:CalTd.vue
<template>
	<td class="td" width="180px" @click="setSchedule(day,didx)">
		<dl>
			<dt>{{ day.date }}</dt>
			<dd>{{day.schedule}}</dd>
		</dl>
	</td>
</template>
<script>
export default{
	name: 'CalTd',
	props:[
		"day",
		"didx",
		"word",
		"week",
	],
	methods:{
		setSchedule(day,didx){
			if(day.date > 0){
				let week = this.week
				let word = this.word
				day.schedule = word
				console.log(day) //ここでデバッグ
				this.$set(week,didx,day)
			}
		}
	}
}
</script>
```

仕様としては、入力欄にスケジュールやメモを記述した後、対象の日付のボックスをクリックすると、その記述された文字が反映されるというものですが、このままでは**うまく動作しません**。

![cal3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/900e4bbf-8b14-54a7-cb7a-02cb13055c46.jpeg)

ですが、孫コンポーネントCalTd.vue内のメソッドでデバッグするとlog内にはきちんと値は投入されているようです。

**デバッグ1**
![cal4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/a8af9ef1-61e4-a741-7343-19906839b27d.jpeg)

## 原因は何なのか？
その答えは公式サイトにあります。

[DOM が更新されない。どうして？](https://jp.vuejs.org/2016/02/06/common-gotchas/)

>data 監視時に、存在しない新たなプロパティを追加した場合 view は更新されません。ES5 の制限とブラウザ間で一貫性のある動作を確保するために、Vue.js はプロパティの追加/削除を検出することはできません。ベストプラクティスは前もってリアクティブにする必要があるプロパティを常に宣言します。あなたが実行時にプロパティを追加または削除する必要がある場合は、グローバルな Vue.set または Vue.delete メソッドを使用します。

つまり、このプログラムの場合は初期設定でこそ

```
calbox:{date:'',schedule:''}
```

このようにdateとscheduleというプロパティを設定しているのに、カレンダーボックスを作成する際に、dateプロパティしか投入していないために、中の値がそのプロパティ(date)だけ残された状態で更新されているということです。

具体的にはこの部分で、dateプロパティだけを更新しているので、初期に設定したはずのscheduleプロパティは無視されています。

```vue:Calender.vue
			for(let i = 1; i < cnt_calbox;i++){
				day = i - buf_blank
				if(day < 1){
					day = null
				}else{
					days = {"date":day} //ここが問題！
				}
				if(i % 7 === 0 ){
					calboxes.push(weeks_tmp)
					weeks_tmp.push(days)
					weeks_tmp = []
				}else{
					weeks_tmp.push(days)
				}
			}
			calboxes.push(weeks_tmp)
			this.calboxes = calboxes
```

この状態でデバッグしてみると一目瞭然で、初期に設定していたはずのscheduleプロパティはないものとして上書きされています。

**デバッグ2**
![cal5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/1b73efae-c9f8-4ab2-6175-239b785b0bc1.jpeg)

### 修正方法
では、このプログラムをどうやって修正するかですが、初期設定はdateプロパティとscheduleプロパティが存在しているので、それに合わせるように**初期値をプログラム上で設定**していかないといけません。

```vue:Calender.vue
				}else{
					days = {"date":day,"schedule":''} //このようにプロパティを合わせる
				}
```

このようにすれば、コンソール画面でこのようになります。

**デバッグ3**
![cal6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/e62c99ac-ec03-894c-7c12-2b6be099d1d3.jpeg)

先ほどのデバッグ1と見比べてみると、プロパティは2つ存在しているのは共通していますが、Observerの部分が異なっており、デバッグ1ではscheduleプロパティがObserverによって同期が取られていません。**Vueで同期をとるためには、Observerで監視されている必要がある**のです。

要は**dataはただの変数置き場なので、そこに設定するだけではプロパティの同期まで取れない**ということです。

逆にこの**Observerを確認すれば、どの値が同期が取れていて、逆にどの値が同期取れていないのかを、瞬時に見分けることができる**わけです。

![cal4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/a82537e4-52da-466e-0b9d-7e3d5f7c0f90.jpeg)

# Vue3の場合
では、Vue3でcompositionAPIを用いた場合はどうなるかを検証してみました。結論からいえば、Vue2までのoptionsAPIより厳格な書き方が必要になることがわかり、かなりデバッグに苦慮しました。なぜなら、propsの解釈がこれまでと大きく異なるためです。

```vue:Calender.vue
<template>
	<div id="con">
	<table class="tbl" border="1">
		<caption class="changer">
			<select v-model="state.selYear" @change="changeYear()">
				<option v-for="(y,yidx) in state.years" :key="yidx">{{y}}</option>
			</select>年
			<select v-model="state.selMonth" @change="changeMonth()">
				<option v-for="(m,midx) in state.months" :key="midx">{{m}}</option>
			</select>月
		</caption>
		<thead>
			<tr>
				<th>Sun</th>
				<th>Mon</th>
				<th>Tue</th>
				<th>Wed</th>
				<th>Thi</th>
				<th>Fri</th>
				<th>Sat</th>
			</tr>
		</thead>
		<tbody>
			<CalTr v-for="(week,widx) in state.calboxes"
				:key="widx"
				:week="week"
				:word="state.word" //検索文字はstateオブジェクトから展開すること
			></CalTr>
		</tbody>
	</table>
	<label>予定を記述した後、日付をクリック！<input type="text" v-model="state.word" ></label>
	</div>
</template>
<script>
import  { reactive,onMounted,watch } from 'vue'
import CalTr from './CalTr.vue'
export default{
	components:{
		CalTr,
	},
	setup(){
		let state = reactive({
			months:[1,2,3,4,5,6,7,8,9,10,11,12],
			years: [],
			year: '',
			month: '',
			selYear: '',
			selMonth: '',
			days:{
				date: '',
				schedule: '',
			},
			calboxes: [], //カレンダーボックスの格納
			word: [], //スケジュールの入力
		})
		onMounted(()=>{
			let d = new Date();
			let year = d.getFullYear()
			let month = d.getMonth() + 1
			makeCalBox(year,month) //カレンダーボックスの作成
			makeYearPulldown(year) //年プルダウンの生成
			state.selMonth = month
			state.selYear = year
		})
		watch(state.calboxes,()=>{
			state.word = ''
		})
		//年の構築
		const makeYearPulldown = (year)=>{
			let years = state.years
			for(let y = 1900;y <= year;y++){
				years.push(y)
			}
			return years
		}
		//年の入れ替え
		const changeYear = ()=>{
			let year = state.selYear
			let month = state.selMonth
			makeCalBox(year,month) //カレンダーボックスの作成
			return year
		}
		//月の入れ替え
		const changeMonth = ()=>{
			let year = state.selYear
			let month = state.selMonth
			makeCalBox(year,month) //カレンダーボックスの作成
			return month
		}
		//カレンダーボックスの構築
		const makeCalBox = (year,month)=>{
			let d = new Date(year,month-1,1)
			let lastday = getLastDay(year,month)
			let buf_blank = d.getDay()
			let cnt_calbox = buf_blank + lastday + 1
			let calboxes = state.calboxes
			calboxes = []
			let weeks_tmp = []
			let days = days
			let day
			for(let i = 1; i < cnt_calbox;i++){
				day = i - buf_blank
				if(day <= 0){
					day = ''
				}
				days = {"date":day,"schedule":''}
				if(i % 7 === 0 ){
					calboxes.push(weeks_tmp)
					weeks_tmp.push(days)
					weeks_tmp = []
				}else{
					weeks_tmp.push(days)
				}
			}
			calboxes.push(weeks_tmp)
			state.calboxes = calboxes
		}
		//最終日を取得
		const getLastDay = (year,month)=>{
			let lastday = 0
			if(month == 2){
				if((year % 4 === 0 && year % 100 !== 0 )|| year % 400 == 0){
					lastday = 29
				}else{
					lastday = 28
				}
			}else if(month < 8){
				if(month % 2 === 1){
					lastday = 31
				}else{
					lastday = 30
				}
			}else if(month >= 8){
				if(month % 2 === 0){
					lastday = 31
				}else{
					lastday = 30
				}
			}
			return lastday
		}
		return{
			state,makeYearPulldown,changeYear,changeMonth,makeCalBox,getLastDay
		}
	}
}
</script>
```

ここで大きく引っかかったのが、以下の位置です。

```vue:vue
	if(day <= 0){
		day = ''
	}
	days = {"date":day,"schedule":''}
```

従来のoptionsAPIの場合、日付とならない空白セルの部分は**nullを代入しても子孫コンポーネントの値を継承してくれたのに対し、compositionAPIの場合は、nullの場合だとメンバが未定義としてエラーを返してしまう**ので、子孫コンポーネントに対し、一旦は値を全部代入しておく必要があるわけです。そしてここでは、dayは日付を代入するため0以前は不要なので、空文字を代入しています。

## 子孫コンポーネントについて
そこを修正した上で、子孫コンポーネントを見ていきますが、ここにも大きな落とし穴がありました。day.dateはシステム呼出時に制御されるので普通にpropsで値を渡すことができますが、**reactiveを用いて渡したpropsは分割代入できない**（つまりは、データ同期のための更新処理ができない）というcompositionAPI上の重要な仕様があるために、day.scheduleの代入ができません（エラーとなる）。

したがって、ここでは**toRef**メソッドを用いて、weekの値を参照渡しする必要があります。ただし、wordは逐一、値が入れ替わるものなので、こっちは逆にtoRefで同期をとると、今まで代入した値と連動してしまうので、propsから値渡しする必要があります。

また、compositionAPIではVue2のoptionsAPIで用いたVue.setメソッドも使えません。なので、ここは普通に代入処理を用いる必要があります。

### 子孫コンポーネント
子コンポーネントはそこまで変わりません。

```vue:calTr.vue
<template>
	<tr>
		<CalTd v-for="(day,didx) in week"
			:key="didx"
			:day="day"
			:didx="didx"
			:word="word"
			:week="week"
		></CalTd>
	</tr>
</template>
<script>
import CalTd from './CalTd.vue' 
export default{
	components:{
		CalTd,
	},
	props:[
		"week",
		"word",
	],
}
</script>
```

この孫コンポーネントが今回の主題となる場所です。

```vue:calTd.vue
<template>
	<td class="td" width="180px" @click="setSchedule(day,didx)">
		<dl>
			<dt>
			<p class="daystamp">{{ day.date }}</p>
			</dt>
			<dd>{{ day.schedule }}</dd>
		</dl>
	</td>
</template>
<script>
import { toRef } from 'vue'
export default{
	props:[
		"day",
		"didx",
		"week",
		"word",
	],
	setup(props){
		const  week = toRef(props.week)　//weekは同期をとる必要があるので、toRefを通して渡す
		const setSchedule = (day,didx)=>{
			day.schedule = props.word //wordは同期をとってはいけないので、propsから渡す
			week[didx] = day.schedule //Vue2までのVue.setに代わる代入手段
		}
		return{
			setSchedule
		}
	}
}
</script>
```

つまり、**compositionAPIの場合、同期がうまく取れない場合は値の渡し方に誤りがないかも確認する必要がある**わけです。