はじめに
最近、フロントエンドのスキルアップのためにVue.jsの学習を始めました。
これまではHTMLやJavaScriptを少し触ったことがある程度で、モダンなJSフレームワークはほぼ初めての経験です。
今回は「AIに課題を出してもらい、それを解きながらレビューをもらう」というスタイルで学習を進めてみました。
最初はそもそもVueの知識がまったくない状態だったので、「課題を出されても、何を調べれば良いのか分からない」という感じで、書き始める際のハードルが高かったです。しかし、AIに質問しながらヒントをもらうことで「なるほど、こういうことか」と少しずつ理解していくことができました。
まだ不慣れで、たびたび見直さないと使えない状態なので、Lv.1〜Lv.5の課題を通して躓いたポイントと学んだことを、自分用のための備忘録として残しておきます。
課題Lv.1:カウンターの作成
最初の課題は、基本的なカウンターの作成です。
【要件】
- 画面に「現在の数字は:〇〇です」と表示させる(初期値は0)
- 「+1」ボタンと、「-1」ボタンを設置し、クリックすると数字が増減するようにする。
- もし数字が「10」以上になったら、「上限に達しました!」という警告メッセージを赤色で表示する。
- 数字が「0」未満にならないようにする。
躓きポイント:変数を書き換えても画面が変わらない
上記の要件を満たそうとした際、最初は通常のJavaScriptの感覚で変数(let count = 0;)を用意していましたが、ボタンを押しても画面上の数字が変わりませんでした。
【解決法: ref を使う】
Vueで値の変更を画面に反映させるには、ref を使う必要があると教わりました。
import { ref } from 'vue';
const count = ref(0); // refで初期化する
// JavaScript側で値を操作するときは countに .value を付ける
const clickHandler_add = () => count.value++;
【補足:イベントの省略記法について】
Vueではクリック時のイベントを v-on:click と書きますが、実務ではこれを @click と省略して書くのが一般的だそうです。そのため、本備忘録でも以降のコードはすべて省略記法(@)に統一しています。
【参考】この時のソースコード
<script setup>
import { ref } from "vue";
const count = ref(0);
const maxCount = 10;
// Lv.1の基本に忠実に、JavaScript側で関数として処理を定義する
const clickHandler_add = () => count.value++;
const clickHandler_del = () => count.value > 0 ? count.value-- : null;
</script>
<template>
<!-- {{}}で指定することで変数を利用できる -->
<p>現在の数字は{{ count }}です</p>
<!-- スタイルも指定できる -->
<p v-if="count >= maxCount" class="error-message">上限に達しました!</p>
<!-- クリック時の関数していなどはこのような形 -->
<button @click="clickHandler_add">+1</button>
<button @click="clickHandler_del">-1</button>
</template>
<style scoped>
.error-message { color: red; font-weight: bold; }
</style>
課題Lv.2:簡単なTodoリスト
テキスト入力とリスト表示を組み合わせたTodoリストの実装です。
【要件】
- テキスト入力欄(
<input>)と、「追加」ボタンを設置する。 - 入力欄に文字を入れてボタンを押すと、その文字が箇条書きのリストとして下に追加されていく。
- リストに追加されたら、入力欄は空に戻るようにする。
- 各リストの横に「削除」ボタンをつけ、押すとその項目が消えるようにする。
躓きポイント: <input> から値を取り出す方法
入力された文字を取得してリスト追加(要件2)しようとしましたが、プレーンなJavaScriptの document.getElementById().value に代わる書き方が分かりませんでした。
【解決法: v-model】
v-model を使うと、入力欄と変数を直接連携させることができました。
<input type="text" v-model="inputTask">
リストの要素を並べるのには v-for を使い、個別の項目を削除(要件4)する処理は v-for="(item, index) of list" のようにしてインデックスを取得し、それを削除ボタンに渡すようにしました。
【補足: v-for の :key について】
今回リストを並べる際、とりあえず v-for="(item, index) of list" :key="index" のようにインデックスをキーとして使っています。
学習段階ではこれで動きますが、実は「要素削除」を行う場合、配列のインデックスを流用すると「中身は消えたのに、表示がずれる・再レンダリングの効率が悪化する」といった不整合を招くリスクがあると教わりました。実務ではデータベース等で発行される一意なID(item.id など)を持たせて :key="item.id" とするのがベストプラクティスとなるそうです。
【参考】この時のソースコード
<script setup>
import { ref } from "vue";
const list = ref([]);
const inputTask = ref("");
// Lv.2の学習(関数への引数渡し等)を考慮し、関数として定義しつつスリム化
const add = () => {
list.value.push({ task: inputTask.value });
inputTask.value = "";
};
const del = (index) => list.value.splice(index, 1);
</script>
<template>
<div>
<ul>
<li v-for="(item, index) of list" :key="index">
{{ item.task }}
<button @click="del(index)">削除</button>
</li>
</ul>
<!-- v-modelで指定することで連携できる -->
<input type="text" v-model="inputTask" placeholder="タスクを入力">
<button v-if="inputTask.length > 0" @click="add">追加</button>
</div>
</template>
課題Lv.3:リアルタイム検索フィルター
配列データを検索ボックスの入力文字でリアルタイムに絞り込む機能です。
【要件】
- 事前に配列データとしていくつかの果物の名前を用意し、初期状態として画面にリスト表示しておく。
- 画面の上にテキスト入力欄(検索ボックス)を配置する。
- ユーザーが入力欄に文字を打ち込むと、文字を打つたびにリアルタイムでリストが絞り込まれて表示されるようにする。
躓きポイント: 画面が真っ白になる
要件3を満たすために、computed を使ってフィルタリングの実装をした直後、エラーにより画面に何も表示されなくなりました。
【解決法: インポート忘れ】
ref と同様に、computed も使う前にインポートする必要がありました。
import {ref, computed} from "vue";
【気付いた点】
JavaScriptの filter() と includes() を組み合わせて computed でリストを算出しておきました。すると、あとから要素を追加・削除した際も、表示用のリストが自動的に更新されました。イベントごとに更新の処理を自分で書かなくて済む点は便利だと感じました。
【参考】この時のソースコード
<script setup>
import { ref, computed } from "vue";
const list = ref([{name:"りんご"}, {name:"みかん"}, {name:"ぶどう"}, {name:"メロン"}, {name:"いちご"}]);
const inputName = ref("");
const inputStr = ref("");
// Lv.3の学習:computedを使ったリストの動的フィルタリング
const searchResult = computed(() => list.value.filter(item => item.name.includes(inputStr.value)));
const add = () => {
list.value.push({ name: inputName.value });
inputName.value = "";
};
</script>
<template>
<div>
<input v-model="inputStr" placeholder="検索したい文字列">
<ul>
<li v-for="(item, index) of searchResult" :key="index">
{{ item.name }}
</li>
</ul>
<input type="text" v-model="inputName" placeholder="くだものを入力">
<button v-if="inputName.length > 0" @click="add">追加</button>
</div>
</template>
課題Lv.4:Todoリストのアップグレード(動的クラス)
Lv.2のタスクをベースにした拡張機能の実装です。
【要件】
- データを追加するとき、
{ task: "文字列", isDone: false }のように最初から「完了フラグ」を持たせて追加する。 - リストの各項目の横に「完了」ボタンを設置する。
- 「完了」ボタンを押すと、その項目の
isDoneがtrue(または反転)に変わるようにする。 -
isDoneがtrueになっている項目は、文字に「打ち消し線」が引かれるようにデザインを変更する。
躓きポイント: 条件分岐での表示切り替え
要件4の「完了状態ならデザインを変える」を実装する際、最初は <p v-if="isDone" class="finished"> と <p v-else> のように、タグ自体を2つ書いて出し分けようと考えていました。
【解決法: 動的クラスバインディング :class 】
Vueでは、クラス自体の付け外しを条件で制御できる機能がありました。
<li :class="item.isDone ? 'finished-message' : ''">
また、ボタンの機能(要件3)を切り替える際も、イベントごとに別々の関数を作るのではなく、単一のボタン内で完結させる書き方も試しました。
<button @click="item.isDone = !item.isDone">
{{ item.isDone ? 'リセット' : '完了' }}
</button>
ある程度短い処理であればHTML(<template>)側にインラインで直接書けるとのことです。ちなみに、タグの中に書く時は .value は不要という細かいルールがありました。
【参考】この時のソースコード
<script setup>
import { ref } from "vue";
const list = ref([]);
const inputTask = ref("");
const add = () => {
list.value.push({ task: inputTask.value, isDone: false });
inputTask.value = "";
};
</script>
<template>
<div>
<ul>
<li v-for="(item, index) of list" :key="index" :class="item.isDone ? 'finished-message' : ''">
{{ item.task }}
<!-- 1行で書くとすっきりする -->
<button @click="item.isDone = !item.isDone">{{ item.isDone ? 'リセット' : '完了' }}</button>
<!-- ボタンの役割が異なる場合は、別々にしたほうが対応しやすい
<button v-if="!item.isDone" @click="item.isDone = true">完了</button>
<button v-if="item.isDone" @click="item.isDone = false">リセット</button>
-->
<button @click="list.splice(index, 1)">削除</button>
</li>
</ul>
<input type="text" v-model="inputTask" placeholder="タスクを入力">
<button v-if="inputTask.length > 0" @click="add">追加</button>
</div>
</template>
<style scoped>
.finished-message { text-decoration: line-through; }
</style>
課題Lv.5:親子コンポーネント通信(PropsとEmit)
コンポーネント間のデータのやりとりの実装です。
【要件】
- 新しく親コンポーネントと、子コンポーネントの2つのファイルを作成する。
- 親コンポーネントには、「全体の『いいね!』の合計数」を表示する。
- 親から子コンポーネントを3つ呼び出して画面に並べ、子コンポーネントにはそれぞれ自身の名前と「いいねボタン」を持たせる。
- 子コンポーネントの「いいねボタン」が押されたら、親コンポーネントの「合計数」が+1されるようにする。
躓きポイント: 単方向データフロー
要件4を満たすため、最初は子コンポーネントから直接カウント変数を増やそうとしましたが、正常に動きませんでした。Vueでは「子は親からもらったデータ(props)を勝手に直接書き換えてはいけない」という仕組みがあるそうです。
【解決法: 親で処理し、子からはイベントを送るだけにする】
子は $emit という機能を使って「押された」という事実だけを親へ伝え、実際のデータの加算処理は親側に用意した関数(incrementLike)に任せるという形に変更しました。これもロジックとテンプレートを切り離すための重要な設計思想だと学びました。
<!-- 親側:配列のインデックスを把握して待ち受ける -->
<Task05child01
v-for="(animal, index) of list"
:child="animal"
@like-clicked="incrementLike(index)"
/>
<!-- 子側:自分が何番目かなどは意識せず、親に報告するだけ -->
<button @click="$emit('like-clicked')">いいね</button>
合計値の算出
全体合計数(要件2)については、最初は totalCount.value++ のように別途用意した変数を手動で加算していましたが、これも computed を使って計算させるように修正しました。
const totalCount = computed(() => {
let sum = 0;
for (const animal of list.value){
sum += animal.count;
}
return sum;
});
こうすることで、いいねを押したときはもちろん、動物自体を削除した際などにも合計数が合わなくなるバグを防ぎやすくなるようです。
なお、同じ処理を以下のようにも記載できるそうです。(第一引数のsum(累積値:accumulatorと呼ぶそうです)にanimal.countが加算されていく)
const totalCount = computed(() => list.value.reduce((sum, animal) => sum + animal.count, 0));
【参考】この時のソースコード
<script setup>
import { ref, computed } from "vue";
import Task05child01 from './Task05child01.vue';
const list = ref([{name:"犬", count: 0}, {name:"猫", count: 0}, {name:"鳥", count: 0}]);
const inputAnimal = ref("");
// reduceメソッドを使うとfor文を書かずに1行で合計値を算出できます
const totalCount = computed(() => list.value.reduce((sum, animal) => sum + animal.count, 0));
const add = () => {
list.value.push({ name: inputAnimal.value, count: 0 });
inputAnimal.value = "";
};
// 親要素としてのロジックを関数で管理(テンプレートとロジックの分離)
const incrementLike = (index) => {
list.value[index].count++;
};
</script>
<template>
<input type="text" v-model="inputAnimal" placeholder="動物を入力">
<button v-if="inputAnimal.length > 0" @click="add">追加</button>
<p>合計: {{ totalCount }}</p>
<ul>
<Task05child01
v-for="(animal, index) of list" :key="index" :child="animal"
@like-clicked="incrementLike(index)"
@delete-clicked="list.splice(index, 1)"
/>
</ul>
</template>
<script setup>
const props = defineProps(["child"]);
const emit = defineEmits(["like-clicked", "delete-clicked"]);
</script>
<template>
<li>
{{ child.name }} : {{child.count}}
<button @click="$emit('like-clicked')">いいね</button>
<button @click="$emit('delete-clicked')">削除</button>
</li>
</template>
全体のまとめと今後の課題
今回の学習を通して、Vueの特徴的な機能(ref, v-model, v-for, :class, computed, Props, $emit)を一通り触れてみました。DOM操作をちまちまと書かなくて良いのは、確かに便利だと感じました。
ただ、まだVueの世界の入り口に立ったばかりであり、現状ではドキュメントなどを見直さないと自力で使いこなせる状態にはなっていません。備忘録として残した今回のコードを参考にしつつ、今後も色々な書き方を試していく必要がありそうです。
また、今回はVueの基本機能だけを使いましたが、VuetifyなどといったUIライブラリなどの存在もよく聞くので、ゆくゆくはそれらについても知らないといけないのかなと思っています。今後はそういった関連技術も含めて、少しずつ知識を広げていきたいです。