Vueを学ぶ
Vueを書くことになりそうな気配がするので一旦Vueを学ぶ
APIスタイル
VueにはOptions APIとComposition APIの2つがある。
Options API
<script>
export default {
// data() で返すプロパティはリアクティブな状態になり、
// `this` 経由でアクセスすることができます。
data() {
return {
count: 0
}
},
// メソッドの中身は、状態を変化させ、更新をトリガーさせる関数です。
// 各メソッドは、テンプレート内のイベントハンドラーにバインドすることができます。
methods: {
increment() {
this.count++
}
},
// ライフサイクルフックは、コンポーネントのライフサイクルの
// 特定のステージで呼び出されます。
// 以下の関数は、コンポーネントが「マウント」されたときに呼び出されます。
mounted() {
console.log(`The initial count is ${this.count}.`)
}
}
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
Composition API
<script setup>
import { ref, onMounted } from 'vue'
// リアクティブな状態
const count = ref(0)
// 状態を変更し、更新をトリガーする関数。
function increment() {
count.value++
}
// ライフサイクルフック
onMounted(() => {
console.log(`The initial count is ${count.value}.`)
})
</script>
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
TODOアプリ
とりあえずTODOアプリ作りながら学習しよう。v0に作ってもらったReactのビューをvueに移植してみる。
pnpm create vue@latest
pnpm i
pnpm run dev
Biome
pnpm add --save-dev --save-exact @biomejs/biome
pnpm biome init
tailwind
とりあえず、Tailwindを入れる。
pnpm i -D tailwindcss@latest postcss@latest autoprefixer@latest
pnpm exec tailwind init -p
// tailwind.config.js
module.exports = {
- purge: [],
+ purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}
/* ./src/assets/main.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
shadcn/ui
// App.vue
<script setup lang="ts">
import { ref } from "vue";
import TaskForm from "./components/TaskForm.vue";
import TaskItems from "./components/TaskItems.vue";
const tasks = ref<string[]>([]);
const addTask = (task: string) => {
tasks.value.push(task);
};
</script>
<template>
<main>
<TaskForm @add-task="addTask" />
<TaskItems :tasks />
</main>
</template>
// TaskForm.vue
<script setup lang="ts">
import AddInput from "./AddInput.vue";
const emit = defineEmits(["add-task"]);
const inputName = "task-name";
const placeholder = "ex) Vueの勉強する";
const handleSubmit = (e: Event) => {
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const task = formData.get(inputName)?.toString() ?? "";
emit("add-task", task);
form.reset();
};
</script>
<template>
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="px-4 py-8 sm:px-0">
<form class="space-y-6" @submit.prevent="handleSubmit">
<AddInput :name="inputName" :placeholder/>
</form>
</div>
</div>
</template>
// TaskItems.vue
<script setup lang="ts">
defineProps<{
tasks: string[];
}>();
</script>
<template>
<ul class="mt-6 space-y-2">
<li v-for="task in tasks" :key="task">{{ task }}</li>
</ul>
</template>
propsの値を変更すべきではない
const tasks = ref<string[]>([]);
</script>
<template>
<main>
<TaskForm :tasks />
<ul class="mt-6 space-y-2">
<li v-for="task in tasks" :key="task">{{ task }}</li>
</ul>
</main>
</template>
このようなコードを書いて子コンポーネント内でのpropsの型がRef<string[], stirng[]>
ではなくstring[]
でないとコンパイルが通らなかった。
これがなぜかよくわからなくて調べるとVueはリアクティブな値からvalueを取り出してpropsとして渡すそうだ。
でもそれだと再レンダリングかからないのではないか??でも正常に動いてそう。
これは公式ドキュメントにもかいてあるようで配列やオブジェクトは参照渡しになるのでpropsの値を書き換えできてしまうそう。
この挙動を変えるコストが高いそうでこれをVueは禁止にしてはいないそうだが親コンポーネントから渡ってきたpropsは単一方向で読み取りのみであるべきでアンチパターンだそうでもしこれがやりたいならemitとかを使うのかな?(そもそもでpropsの値を変更するようなコードを書いてはいけないという話だった。)
emit使うように修正
const tasks = ref<string[]>([]);
const addTask = (task: string) => {
tasks.value.push(task);
};
</script>
<template>
<main>
<TaskForm @add-task="addTask" />
<ul class="mt-6 space-y-2">
<li v-for="task in tasks" :key="task">{{ task }}</li>
</ul>
</main>
</template>
// TaskForm.vue
<script setup lang="ts">
import AddInput from "./AddInput.vue";
const emit = defineEmits(["add-task"]);
const inputName = "task-name";
const placeholder = "ex) Vueの勉強する";
const handleSubmit = (e: Event) => {
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const task = formData.get(inputName)?.toString() ?? "";
emit("add-task", task);
form.reset();
};
</script>
<template>
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="px-4 py-8 sm:px-0">
<form class="space-y-6" @submit.prevent="handleSubmit">
<AddInput :name="inputName" :placeholder/>
</form>
</div>
</div>
</template>
メモ
- HTMLのイベントに型付けする場合、asを使って適切な要素の型に型アサーションする必要がある
- propsは単一方向で読み取り専用
- 配列やオブジェクトをpropsにすると、参照渡しなので書き換え可能になってしまうがアンチパターン
- その場合はemitを使う
Vueにおけるパフォーマンスの最適化
関数の再作成
ReactにおけるuseCallbackはVueではどうやるのか
Vue.jsでの関数の扱い
Vue 3のComposition APIでは、setup関数内で定義された関数は、一度だけ定義され、コンポーネントの再レンダリング時に再作成されません。つまり、Vueではコンポーネントの再レンダリング時に関数が再生成されないため、ReactのuseCallbackのような関数のメモ化が不要な場合が多いです。
関数が再生成されない理由:
setup関数はコンポーネントの初期化時に一度だけ実行され、その戻り値がコンポーネントのインスタンスにバインドされます。
テンプレート内で使用される関数は、この初期化時に定義された関数を参照します。
ChatGPTより。Vueにおいてはコンポーネントがマウントされたときにscriptタグ内の関数は1度だけ作成され、再レンダリング時に関数は再作成されないためVueではReactのuseCallbackはない。
計算結果のメモ化
ReactのuseMemo。これはVueではcomputedで用意されている。
const total = computed(() => price + 100)
上記の例ではpriceという値に変化があったときだけ再計算される。computedを使う場合、関数は副作用のない純粋関数である必要がある。
コンポーネントのメモ化
ReactにおけるReact.memoの話。以下ChatGPT。
Vue.jsとReactの再レンダリングの違い
React:
デフォルトでは、親コンポーネントが再レンダリングされると、その子コンポーネントも再レンダリングされます。
React.memoを使用して、propsが変更されない限り再レンダリングを防ぐことができます。
Vue.js:
リアクティブシステムにより、データの変更に応じて必要な部分だけを効率的に更新します。
Vue.jsの仮想DOMは、変更があった部分のみを再レンダリングします。
親コンポーネントが再レンダリングされても、子コンポーネントのpropsに変更がなければ、子コンポーネントの再レンダリングは最小限に抑えられます。
Vueにおいては変更があった箇所だけが再レンダリングされ、propsに変更がない子コンポーネントは再レンダリングされない。
v-once, v-memo
完全に静的な要素はv-onceを使うことで再レンダリングを防ぐことができる。v-onceを使えるところは全部使った方がいい気がしてしまうがReactのメモ化と同じで使いすぎは逆に可読性を悪くしたりするのと、望んでいたほどのパフォーマンス向上が見込めないなどのデメリットがある。
遅延読み込み
動的importやdefineAsyncComponentを使うことで実際に必要になったときに読み込みが発生する。
浅いref
refを使ってリアクティブなデータを作るとオブジェクトでネストした値を変更しても検知できる。これはshallowRefを使うことで浅いrefを作ることが可能で巨大なオブジェクトを扱う時なんかに監視のコストを下げパフォーマンス向上に貢献できる。
まとめ
Reactでもそうだったが最適化をやりすぎても得られるリターンはそこまで多くない。加えて、VueではReactで気にしなければならなかったuseCallback(関数の再作成)やReact.memo(子コンポーネントの再レンダリング)を気にしなくても良い感じになっているっぽい。これはVueのリアクティブシステムにおける仮想DOMの差分更新とReactの仕組みの違いなんだと思う。
そう考えるとVueは再レンダリングによるパファーマンスの最適化についてあまり考えなくて良いようになっているとも言える。
v-once, v-memo, 遅延読み込みあたりはほんとにパファーマンスを上げたい時にチューニングするくらいで普段はそこまで気にしなくて良さそう。
computedは重い計算処理に対しては有効なので使えるときは使った方がいいかもしれない。
ReactとVueの違い
この記事がわかりやすくまとめてくれていてだいぶ腹落ちした。
ReactとVueのレンダリングの仕組みについて深くは理解できていないがVueはJavaScriptのgetter, setter, proxyを用いて値をリアクティブに監視し、変更のあった値を使用しているコンポーネントのみを再レンダリングするリアクティブシステムを採用している。
そのため、ReactにあるReact.memoやuseCallbackの考慮がいらないというのは今からフロントを学び始めている自分にとってはありがたい。
useMemoに関してはcomputedという関数が用意されていてそれを使えばいい。
Reactのドキュメントに書いてあるが大抵のユースケースにおいてこういった再レンダリングの最適化は不要であることが多いとのことなのでそこまで気にしなくてもいいんだろうが、実際の現場ではこういったメモ化を使用した最適化を徹底している現場もたぶんあるだろうし、Reactを書くならば考えないわけにはいかないと思う。その点、Vueはcomputedしか考慮することがないのでありがたい。
refとreactive
最初refだとオブジェクトのネストした深い階層まではリアクティブにならないのでreactiveを使うものと思っていたがどうやらそんなことはないらしい。とりあえず迷うならrefを使っとけば良いらしい。
読み直したら、ドキュメントにもrefで深いところまでリアクティブに監視できると書いてあったしreactiveよりもrefを推奨すると書いてあった。
一応、reactiveはrefの内部でも呼ばれているらしい。
reactiveよりrefを推奨する理由としてはreactiveだとプリミティブな値を使えなかったり、分割代入などでリアクティブ性がなくなってしまうといった制限があるかららしい。
メモ
- HTML属性の中ではマスタッシュ構文(二重波括弧)が使えない
- 代わりに
v-bind
もしくは:
を使う - Vue3.4以上では
<div :id></div>
のようにオブジェクトのプロパティの省略記法のようにも宣言できる - JavaScriptの式が書けるのはマスタッシュ構文の中かv-から始まるディレクティブの中
- DOMイベントを購読する
v-on
というディレクティブがあるがこれは@
という省略記法がある - ディレクティブには
@submit.prevent="onSubmit"
のように修飾子をつけることが可能 - リアクティビティ
- ref()
- reactive()
- リアクティブな値の変更は更新サイクルのnext tickまで再レンダリングを待機する
- Reactでもstateの変更はキューのように管理され同期的にレンダリングが走らないようになっていたはず
-
nextTick()
を明示的に呼び出すことでDOMの更新を待つこともできる - computed()
- 制御構文
- v-if, v-else, v-else-if
- v-for
- Reactと同じようにkey属性を必ず指定するようにする
- 配列の変更検知
- pushやsortのような元の配列を変更するようなミューテーションメソッドの呼び出しは変更検知される
- filterやsliceのような新しい配列を返すような関数は以下のように配列自体を置き換える必要がある(配列自体を置き換えるが変更あった部分だけいい感じに再レンダリングされるので効率的)
items.value = items.value.filter((item) => item.message.match(/Foo/))
- イベントハンドラは自動的にDOMイベントオブジェクトを受け取る
const name = ref('Vue.js')
function greet(event) {
alert(`Hello ${name.value}!`)
// `event` はネイディブ DOM イベントです。
if (event) {
alert(event.target.tagName)
}
}
<!-- `greet` は上で定義したメソッド名です。 -->
<button @click="greet">Greet</button>
- DOMイベントオブジェクトの代わりに引数を指定することもできる。
<button @click="say('hello')">Say hello</button>
<button @click="say('bye')">Say bye</button>
- 特殊変数$eventを使うことでイベントハンドラーにDOMオブジェクトを渡すこともできる。またアロー関数も使える
<!-- インラインでアロー関数を使用する場合 -->
<button @click="(event) => warn('Form cannot be submitted yet.', event)">
Submit
</button>
- selectやinput, textareaのようなフォーム入力のときにはv-modelを使うと便利
- よく使うライフサイクルフックとして
onMounted
、onUpdated
、onUnmounted
がある - ウォッチャー
- watch
- watchEffect
- onWatcherCleanup
- テンプレート参照
- useTemplateRefとrefを使ってHTML要素やコンポーネントを参照することができる
- 参照は要素のあるマウント後である必要がある
- マウント前には参照はnullになるのでそれを考慮する必要がある
- ReactでいうuseRef
- すでに書いたが子コンポーネントから親コンポーネントの状態を変えたい場合、emitを使う
- リアクティブなpropsの分割代入
- reactiveで作った値はオブジェクト全体がリアクティブな値として扱われ、分割代入してしまうとオブジェクト内の値はコピーされて代入されるため、リアクティブ性が失われる
- refで作ったオブジェクトはオブジェクト内の値がそれぞれリアクティブな値として管理されるため分割代入してもリアクティブ性は失われない
- こういった理由からもreaciveよりrefが推奨される理由でもある
- コンポーネントにv-modelを使うことで親子間でリアクティブな値を双方向バインディングできる
- これは子でpropsとして渡ってきた値を変更して親の状態を変えてはいけないという制約に違反していそうに見えるが、実際はpropsとemitを使ったコードにコンパイルするため、emitを使った親コンポーネントの状態変更の処理のマクロともいえる
- だとするとかなり便利
- フォールスルー属性
- classとかstyleみたいなpropsとかemitみたいにコンポーネントで受け取ることを前提としていない属性のこと
- これらは自動的にコンポーネントのルートタグの属性に転送される
- slot
- フォールバックコンテンツ
- 名前付きスロット
- 条件付きスロット
- etc
- Provide / inject
- propsのバケツリレー
- ReactのuseContext
- 基本的にはprovideにkeyとvalueを渡して使用したいコンポーネントでingectにkeyを与えてvalueを取り出す
- valueの値をinject先で更新する必要がある場合は、更新関数も一緒にprovideで渡すのが良い
- もし、valueを読み取り専用にしたい場合はreadonly()でラップすることができる
- もし、大規模なアプリケーションでprovideとingjectを使う場合、keyにSymbolを使うことでkeyの衝突を避けることが可能
- 非同期コンポーネントと動的import
- コンポーザブル
- Reactでいうところのカスタムhooks
- これは後で別途まとめる
- カスタムディレクティブ
- setup関数内でvから始まるキャメルケースのオブジェクトを宣言することでカスタムディレクティブとして使うことができる
- これはDOM要素を直接操作するしかないときに使うべき
- プラグイン
- トランジション
- あまりアニメーションなどを実装することはなさそうなのでスキップ
- KeepAlive
- Teleport
- Suspense
- ルーター
- Vue Router
- これも後でやる
- 状態管理
- Pinia
- これもあとでやる
- テスト
- これもあとでやる
- SSR
- vue/server-rendererというパッケージが使えるよう
- ちゃんとやるならNuxt
- SSGならVitePress
Pinia
Vue Router
Vueのテスト
Vueというかフロントのテストにおいては以下のような感じ。
- 単体テスト
- コンポーザブル
- コンポーネントが必要なテスト
- そうでないもの
- 単純なユーティルやクラス
- コンポーザブル
- コンポーネントテスト
- ホワイトボックステスト
- ブラックボックステスト
- E2Eテスト
単体テストはサーバーと同じで振る舞いをテストする。コンポーザブルはコンポーネントからロジックを抽出したものなので当然、テストがしたくなるでしょう。コンポーザブル内ではonMountedのようなライフサイクルイベントを呼び出していたり、Provided / Injectを使う場合、テスト用にコンポーネントをマウントする必要があり、これはテスト用のヘルパー関数として用意すると良い。そうでなければ、コンポーザブルは副作用のない純粋関数になっていることがほとんどのため普通に単体テストを書く。ツールはvitest
コンポーネントのテストには対象のコンポーネントの親子関係が依存関係として出てくるためこれはインテグレーションテストに分類されるでしょう。コンポーネントに必要なものをモックすることなく振る舞いをテストするブラックボックステストとモックを使ったりなどしてコンポーネントに必要なものを手動で用意して提供するのがホワイトボックステスト。ツールにはcypressやvitestでも書ける。playwrightはcypressと同じブラウザテスト用のツールだけどコンポーネントのテストのサポートはまだ実験的。
E2Eテストはブラウザでどのように振る舞うかをテストする。実際はcypressやplaywrightでヘッドレスブラウザを使いテストする。
最低限実装者がテストするものとしてコンポーザブルや普通のtsファイルにある関数のテストはvitestを使ってシンプルに単体テストとして書くことができるだろう。コンポーネントテストは大抵API通信が発生したりするため、これはモックを使用してホワイトボックステストとして書くのが簡単でしょう。依存関係をすべてモック化できればコンポーネントテストも単体テストとみなせるでしょう。依存コンポーネントを実際に組み合わせてテストするのであればそれはインテグレーションなコンポーネントテストと言える。E2Eテストは画面実装者が作成するには重すぎるので一旦置いておこう
vitestはtestsや__test__ディレクトリを探索し、テストファイルは*.spec.tsもしくは*.test.tsのように書く。コンポーネントのそばにテストディレクトリを配置していいということだったのでルートディレクトリにテストディレクトリをおくよりコンポーネントのそばにテストディレクトリを配置したほうがわかりやすそう。
また、Storybookとvitestを統合してコンポーネントをテストすることもできる、らしい。Storybookを用いたテストはビジュアルリグレッションテストに分類されることが多く、UIが壊れていないことをテストできる。Stroybookは何となくデザイナーとフロントエンドエンジニア間で齟齬なくUIコンポーネントを実装するために使うモチベーションが高そう。
モックにはMSWが使われていそうか?
とりあえず、vitestの公式ドキュメントには目を通しておいた方がいいかもしれない
宿題
- 各種テスト書いてみる
- コンポーザブルのテスト
- ライフサイクルあり
- ライフサイクルなし
- コンポーネントのテスト
- コンポーザブルのテスト
- Tanstack Query
- GraphQL
- Connect
- サバイバルTS読む