はじめに
この記事では、
良いコード = リアクティブなコード
と定義しています。
主にVue 3を前提としたコード提示をするので、コード部分についてはVueの基本文法を知っている人がメインターゲットです。
ただし、似たような話は他のSPAフレームワーク(筆者の感覚ではSvelteも結構近いはず)にも言えるので、モダンフレームワークを触る人に通じる話かと思います。
もしより良い書き方や間違いの指摘があればどしどしコメントいただけますと幸いです。
リアクティブでないコードとは
筆者が入っているプロジェクト(Vue 3)において、下記の要件を満たすべき画面がありました(この記事のためにかなり簡単にしています)。
- 複数のチェックボックスが並んでいる
- すべてのチェックボックスを選択するとボタンが押せる
みなさんならどう書くでしょうか?
このコードのレビューをお願いしたいと依頼されて筆者が出会ったコードは下記のようなものでした(大体の内容はそこから引用していますが、コード自体は生成AIをベースに作りました)。
<script setup lang="ts">
import { ref } from 'vue';
type CheckboxItem = {
id: number;
label: string;
checked: boolean;
};
// チェックボックスの項目データ
const items = ref<CheckboxItem[]>([
{ id: 1, label: '選択肢1', checked: false },
{ id: 2, label: '選択肢2', checked: false },
{ id: 3, label: '選択肢3', checked: false },
{ id: 4, label: '選択肢4', checked: false },
{ id: 5, label: '選択肢5', checked: false },
]);
const areAllChecked = ref(false);
// チェック状態が変更された時の処理
const updateCheckedStatus = (): void => {
areAllChecked.value = items.value.every((item) => item.checked);
};
</script>
<template>
<div class="checkbox-container">
<h2>チェックボックス一覧</h2>
<div v-for="(item, index) in items" :key="index" class="checkbox-item">
<input
type="checkbox"
:id="'checkbox-' + index"
v-model="items[index].checked"
@change="updateCheckedStatus"
/>
<label :for="'checkbox-' + index">{{ item.label }}</label>
</div>
<button type="button" :disabled="!areAllChecked">Button</button>
</div>
</template>
- 選択肢を配列として、
v-for
でループで表示する - ボタンの押下可否は
:disabled
で変数を渡す
このあたりはよさそうです。シンプルかつVueの機能を活用できていると言えるでしょう。ではこのコードは問題ないと言えるでしょうか?
残念ながらこのコードはリアクティブではなく、Vueを活かしきれていません。何がいけないのでしょうか?
そもそもリアクティブって?
そもそも「リアクティブ」とはどういう意味でしょうか?辞書を引くと、
- よく反応する
- 反応の早い
- 反応性の高い
- 敏感な
という言葉が見られます(参考:英辞郎)
VueなどのSPAフレームワークにおけるリアクティブとは、「ある値(状態)が変化したときに、その変化に反応する仕組み」と言えます。例えばref
による変数の宣言がそれにあたります。Vueでは、単に変数を宣言するときと「リアクティブな変数」を宣言するときとで記法が異なります。
const foo = 0; // 普通の変数
const bar = ref(0); // リアクティブな変数
bar
はリアクティブな変数ですが、具体的にどんなことが実現できるのでしょうか?
最も単純な例は、「Ref
変数が変化するとそれに応じて見た目も変わる」です。
例えば、以下のような<template>
があったとします。
<template>
<p>{{ bar }}</p>
</template>
単にbar
の値を出力するだけの要素ですが、何らかの処理でbar
に変更が加わると、<p>
タグの中身はそれに応じて勝手に更新されます!
<script setup lang="ts">
const bar = ref(0); // リアクティブな変数
const increment = () => {
bar.value += 1;
}
</script>
<template>
<button type="button" @click="increment">Inc</button>
<!-- ↑のbuttonを押すたびに↓の値が増える -->
<p>{{ bar }}</p>
</template>
この「勝手に更新」される仕組みがリアクティブです。Vueでは、<template>
から<script>
内のリアクティブな値を参照していた場合、そのリアクティブな値に変化があるたびに影響を受ける部分が自動的に更新されます。
筆者はこの関係をよく「依存関係」と表現します。ある値A
が他の値B
に依存しているとき、B
の変化に合わせて良しなにA
も勝手に変化してくれることが望ましいです。逆にいえば、B
に変化を加えるたびに、自分でわざわざA
への操作も併せて行うやり方は、変更漏れの危険性がありますし、何よりA
がどのようなロジックで決定されるものなのかがはっきりしません。すなわち、依存関係が可視化できず、かつ、保証しにくいのです。
リアクティブだと何がうれしいのか?
前項の最後に書いた、「依存関係の可視化と保証」がリアクティビティがもたらす最強の恩恵と筆者は考えています。変数や要素がどのように依存しあっているのかを「宣言的」に記述することで、コードの可読性を高め、かつ変更漏れを防ぐことができるのです。
変数によって要素を反応させる方法はVueの記法としていくつか存在します。先ほどの{{ }}
を使う方法もありますし、v-if
で表示/非表示を切り替える、v-for
でイテレーティブに表示する、などもそれにあたります。
もう少し高度なことはできないでしょうか?例えば、変数→要素だけでなく、変数→変数、すなわち他の変数に応じて値が決まる変数、はどのように定義するのでしょうか?
Vueにはそれに応えるcomputed
というものがあります。例えば、
- 税抜き価格(
exTax
) - 税率(
ratio
)
の二つの可変な値から、自動的に税込み価格(inTax
)を算出したいとします。
この場合、inTax
はexTax
とratio
に依存しています。こういうときにcomputed
が活躍します。
結論として、以下のように定義できます。computed
の中身は関数を書く決まりになっています。
const exTax = ref(1000);
const ratio = ref(0.1);
const inTax = computed(() => exTax.value * ratio.value);
inTax
が何を表現しているのか一目瞭然です。明らかに、exTax
とratio
を乗じたものと分かります。これが「可視化」です。inTax
の宣言箇所を見れば、「inTax
がどのように決まるのか」がロジックとして書かれています。
computed
のすごいところは、その値が変化すれば、上記の例でいえば「exTax
かratio
に変更が加われば」、自動的にinTax
が再計算されるところです。これが「保証」です。依存先が変われば依存元も追従する仕組みをVueが提供してくれるので、開発者はexTax
やratio
に変更を加えるロジックのみ書けばよいのです。
もしVueが提供するリアクティビティを活用しなければどうなってしまうのでしょうか?
同じような内容をあえてcomputed
を使わないで書くと、次のようになります(<template>
がリアクティブに変更されることは利用するのでref
自体は残します)。
const exTax = ref(1000);
const ratio = ref(0.1);
// 初期値の定義はされているが、この値そのものが何を表現するのか不明
const inTax = ref(exTax.value * ratio.value);
// exTax更新用関数
const updateExTax = (value: number) => {
exTax.value = value;
inTax.value = exTax.value * ratio.value; // 忘れるな!
}
// ratio更新用関数
const updateRatio = (value: number) => {
ratio.value = value;
inTax.value = exTax.value * ratio.value; // 忘れるな!
}
どうでしょうか?記述量が増えた割にメリットは感じられるでしょうか?
筆者はこの書き方を「setter
的な書き方」を表現することがあります。能動的にinTax
への代入(set
)をしなければinTax
を正しく扱えない方式を指し、基本的にVueにおけるアンチパターンと考えています。
1つ目の武器: computed
では答え合わせといきましょう。先ほどのコードのどこがダメだったのでしょうか?
勘のいいひとはお分かりだと思いますが、以下の部分です。
const areAllChecked = ref(false);
// チェック状態が変更された時の処理
const updateCheckedStatus = (): void => {
areAllChecked.value = items.value.every((item) => item.checked);
};
コードを読む限り、areAllChecked
は「items
のすべての要素のchecked
がtrue
である」かどうかを表す値です。このような意図があるにも関わらず、areAllChecked
は宣言時は単にref(false)
となっている点で、このロジックが可視化できていません。また、items
が更新されたときに、依存関係にあるareAllChecked
を自力で更新しようとしなければならず、手間が増えています。
改善後は以下の通りです。
const areAllChecked = computed(() => items.value.every((item) => item.checked));
areAllChecked
の宣言時にcomputed
を使って宣言することで、簡潔にリアクティビティを持たせています。
こういうケースはどうする?
別の例を見てみましょう。
<script setup lang="ts">
import { ref } from 'vue'
interface UserProfile {
name: string
theme: 'light' | 'dark'
}
const userProfile = ref<UserProfile>({
name: 'John',
theme: 'light'
})
const api = {
async updateUser(profile: UserProfile): Promise<void> {
// ユーザ情報を更新するAPIを呼び出す
}
}
// プロフィール名更新
const updateName = async () => {
userProfile.value.name = 'Jane'
await api.updateUser(userProfile.value)
}
// テーマ切り替え
const toggleTheme = async () => {
userProfile.value.theme = userProfile.value.theme === 'light' ? 'dark' : 'light'
await api.updateUser(userProfile.value)
}
</script>
<template>
<button @click="updateName">Update Name</button>
<button @click="toggleTheme">Toggle Theme</button>
<pre>{{ userProfile }}</pre>
</template>
※本質に注目するため、できる限りコード量はそぎ落としています。
userProfile
というオブジェクトがあり、このオブジェクトに対する変更が加わる度にそれを永続化する仕組みを実装したいです(具体的にはAPIを呼び出す)。変更はupdateName
やtoggleTheme
で行われるので、それぞれの関数の中に更新処理を埋め込んでいます。こうすれば、変更に応じて特定の関数を実行できます。目的は達成していますね。
しかし、このコードもイマイチです。もう一つ上のステージを目指してみましょう。何が足りないのでしょうか?
「因果関係」を正しく表現したい
今回は最初のケースとは異なります。なぜなら登場する変数がuserProfile
の一つだけで、いわゆる「依存関係」に該当する変数間の関係性がありません。このようなケースでもリアクティブは関係あるのでしょうか?
このコンポーネントで実現したいことは、「userProfile
が変更されるたびに更新関数を実行する」のはずです。ところが、このコードでは「userProfile
を変更する関数の中で更新関数を実行する」を実現しています。
これらは微妙に違います。前者はuserProfile
の変化に反応することを明記しています。一方、後者は変化を引き起こす根源の事象に着目しています。すなわち、「userProfile
に変更を与えうる処理をすべて把握し、それぞれの箇所に更新関数を埋め込もう」という発想です。
こう書き表すと、前章の「setter的な書き方」に近いアンチパターンと分かりやすくなります。更新関数はuserProfile
の変化に反応して実行されてほしいのに、その「因果関係」が表現できていません。このようなケースではどうしたらよいでしょうか?
2つ目の武器: watch
ここで登場するのがwatch
になります。watch
の第一引数にリアクティブな変数を渡し、第二引数に関数を渡すと、変数の変化に応じてリアクティブに関数を実行してくれます。
<script setup lang="ts">
import { ref, watch } from 'vue';
// リアクティブな変数
const counter = ref(0);
// 変数の変更を監視して処理を実行
watch(counter, (newValue, oldValue) => {
console.log(`Counter changed from ${oldValue} to ${newValue}`);
});
</script>
<template>
<button @click="counter++">Increment Counter</button>
<p>Counter: {{ counter }}</p>
</template>
これは、変数の変化→処理の実行という因果関係を表現するのに最適です。因果関係を記述できるので、コードの可読性も上がり、何より実装漏れの心配がありません。
先の例をwatch
を使って実装するとこのようになります。
<script setup lang="ts">
import { ref, watch } from 'vue';
interface UserProfile {
name: string;
theme: 'light' | 'dark';
}
const userProfile = ref<UserProfile>({
name: 'John',
theme: 'light',
});
const api = {
async updateUser(profile: UserProfile): Promise<void> {
// ユーザ情報を更新するAPIを呼び出す
},
};
// プロフィール名更新
const updateName = async () => {
userProfile.value.name = 'Jane'
}
// テーマ切り替え
const toggleTheme = async () => {
userProfile.value.theme = userProfile.value.theme === 'light' ? 'dark' : 'light'
}
// watchを使って変更を検知し、APIを呼び出す
watch(userProfile, async (newProfile) => {
await api.updateUser(newProfile);
});
</script>
<template>
<button @click="updateName">Update Name</button>
<button @click="toggleTheme">Toggle Theme</button>
<pre>{{ userProfile }}</pre>
</template>
watch
を使うことで、userProfile
の変更に起因して何が起きるのかを記述することができました。繰り返しになりますが、因果関係をはっきりとさせることで、どの変数の変更がどのような副作用を及ぼすのかを追跡しやすくなります。違う言い方をすれば、変数の副作用の抜け漏れを防ぐことができます。
リアクティブを使いこなしてこそVueを使う意味がある
いかがでしょうか。computed
とwatch
というシンプルかつたった2つのVueの武器を紹介しましたが、その強力さが少しでも伝われば嬉しいです。
最後に、computed
とwatch
の主な使い分けをまとめます。
computed
を使うといいケース
- ある変数(複数可)に依存して決まる変数を定義したい場合
- 例:配列から条件に合う要素だけ抜き出す
- 例:変数同士で計算を行う
watch
を使うといいケース
- ある変数の変化をきっかけに何かをトリガーしたい場合
- 例:値が変わるたびにAPIを呼び出す
- 例:値が変わるたびにDOM要素を変更したい
筆者は、Vueを使う最大のメリットは「シンプルにリアクティビティを実現できること」だと考えています。ぜひこのメリットを活かしたSPA開発を進めていきましょう!