はじめに
Vue 3のプロジェクトで実装やコードレビューをしていると、「動作はしているけれど少し分かりづらい」「後から修正しづらそう」と感じることがあります。
そこで今回は、自分がVue 3のコードを見るときに意識しているポイントを、備忘録も兼ねて15個書き出してみました。
「こう書くべき」というルールではなく、プロジェクトの設計方針やチームのルールを前提に、レビュー時に確認するためのチェックポイントとしてまとめています。
1. watchを必要以上に使っていないか
watchを見つけたときは、これはwatchである必要があるのかを考えるようにしています。
特に、別の値から計算できる値を同期するためだけにwatchを使っている場合は、computedで表現できないか確認しています。
気になる例
import { watch, ref, Ref } from "vue"
let price: Ref<number> = ref(100)
let quantity: Ref<number> = ref(2)
let total: Ref<number> = ref(200)
watch([price, quantity], (): void => {
total.value = price.value * quantity.value
})
自分ならこう書く
import { computed, ref, Ref } from "vue"
let price: Ref<number> = ref(100)
let quantity: Ref<number> = ref(2)
const total = computed((): number => {
return price.value * quantity.value
})
watch自体を避けているわけではありません。
単純な値の算出であればcomputedの方が読みやすくならないかを見るようにしています。
2. 同じ意味の状態を複数持っていないか
状態管理の変数が増えているコードを見るときは、その中に、別の状態から計算できる値がないか確認しています。
気になる例
import { watch, ref, Ref } from "vue"
let users: Ref<User[]> = ref<User[]>([])
let userCount: Ref<number> = ref(0)
watch(users, (): void => {
userCount.value = users.value.length
})
自分ならこう書く
import { computed, ref, Ref } from "vue"
let users: Ref<User[]> = ref<User[]>([])
const userCount = computed((): number => {
return users.value.length
})
1番目の観点とよく似ていますが、導出できる値ならなるべくcomputedで計算するようにしています。
3. コンポーネント内でしか使わない状態をStoreに置いていないか
Piniaなどのストアは、複数のコンポーネントで状態を共有するときに便利です。
一方で、「状態ならとりあえずストアに置く」という使い方になっていないかは確認するようにしています。
例えば、あるコンポーネント内にあるアコーディオンの開閉状態の場合です。
気になる例
import { defineStore } from "pinia"
export const useUiStore = defineStore("ui", {
state: () => ({
isDetailOpen: false
})
})
<template>
<button @click="uiStore.isDetailOpen = !uiStore.isDetailOpen">
詳細を表示
</button>
<div v-if="uiStore.isDetailOpen">
商品の詳細情報
</div>
</template>
<script setup lang="ts">
import { useUiStore } from "../stores/uiStore"
const uiStore = useUiStore()
</script>
このisDetailOpenをProductDetail.vueの中でしか使わないのであれば、ストアで共有する必要はありません。
自分ならこう書く
<template>
<button @click="isDetailOpen = !isDetailOpen">
詳細を表示
</button>
<div v-if="isDetailOpen">
商品の詳細情報
</div>
</template>
<script setup lang="ts">
import { ref, Ref } from "vue"
let isDetailOpen: Ref<boolean> = ref(false)
</script>
コンポーネント内で完結する状態をローカルに持っておけば、その状態をどこで使っていて、どこから変更されるのかを追いやすくなります。
ストアを使うこと自体が問題というわけではなく、自分の場合は、この状態は本当に複数のコンポーネントから参照・変更する必要があるのか? を確認するようにしています。
特定のコンポーネント内だけで使う値であれば、ストアを「何でも置けるグローバル変数の置き場所」のように使わず、まずはコンポーネント内で管理できないか考えるようにしています。
4. Templateに複雑なロジックを書いていないか
Templateを見るときは、条件式を追うだけで時間がかかる状態になっていないか気にしています。
気になる例
<template>
<button
v-if="
user.isActive &&
user.role === 'admin' &&
isMaintenanceMode
"
>
編集
</button>
</template>
自分ならこう書く
<template>
<button v-if="canEdit">
編集
</button>
</template>
<script setup lang="ts">
import { computed } from "vue"
const canEdit = computed((): boolean => {
return (
user.value.isActive &&
user.value.role === "admin" &&
isMaintenanceMode.value
)
})
</script>
canEditのように条件へ名前を付けると、Templateから意図を読み取りやすくなります。
5. コンポーネントに処理を詰め込みすぎていないか
vueファイルが大きくなっている場合は、どのような処理が含まれているか確認します。
API通信、入力チェック、データ変換、状態管理などがすべて1か所に集まると、コンポーネントが肥大化していきます。
気になる例
const saveUser = async (user: UserForm): Promise<void> => {
// 入力チェック
// データ変換
// API通信
// エラー処理
// Store更新
}
コンポーネントが肥大化すると、コードを読むときに一度に把握しなければならない情報が増えたり、再利用しづらくなったりすると感じています。
例えば、データ変換やAPI通信を別のモジュールへ分けられるのであれば、
const saveUser = async (user: UserForm): Promise<void> => {
const request: UserRequest = createUserRequest(user)
await userApi.update(request)
}
のように、コンポーネント側には画面に近い処理を残せます。
6. ライフサイクルに対応した後始末があるか
イベントリスナーやTimerを登録している処理を見つけたときは、解除処理も確認しています。
気になる例
import { onMounted } from "vue"
onMounted((): void => {
window.addEventListener("scroll", handleScroll)
})
自分ならこう書く
import { onMounted, onUnmounted } from "vue"
onMounted((): void => {
window.addEventListener("scroll", handleScroll)
})
onUnmounted((): void => {
window.removeEventListener("scroll", handleScroll)
})
Timerの場合も同じように確認します。
import { onUnmounted } from "vue"
const timerId: number = window.setInterval(loadData, 5000)
onUnmounted((): void => {
window.clearInterval(timerId)
})
自分の場合は、登録する処理があるなら、解除する必要もあるかをセットで見るようにしています。
7. 例外を握りつぶしていないか
try/catchがあるだけで安心せず、catchしたあとに何が起こるのかを見るようにしています。
気になる例
const submit: () => Promise<void> = async (): Promise<void> => {
try {
await saveUser()
} catch (error: unknown) {
console.error(error)
}
}
この場合、利用者から見ると「何も起こらなかった」ように見えるかもしれません。
例えばこんな形
const submit: () => Promise<void> = async (): Promise<void> => {
try {
await saveUser()
} catch (error: unknown) {
console.error(error)
errorMessage.value = "保存に失敗しました"
}
}
設計によっては、上位へ例外を投げ直す場合もあります。
const executeRequest: () => Promise<void> = async (): Promise<void> => {
try {
await request()
} catch (error: unknown) {
logger.error(error)
throw error
}
}
「catchしているか」ではなく「catchしたあと、システムや画面がどうなるか」 を見るようにしています。
8. anyを使っていないか
TypeScriptを使う場合は、基本的にanyを使わないようにしています。
anyを使うと、その値に対する型チェックがほとんど効かなくなり、TypeScriptを使っているメリットが小さくなってしまいます。
気になる例
import { ref, Ref } from "vue"
let user: Ref<any> = ref<any>(null)
const showUserName: (user: any) => void = (user: any): void => {
console.log(user.name)
}
自分ならこう書く
import { ref, Ref } from "vue"
type User = {
id: number
name: string
}
let user: Ref<User | null> = ref<User | null>(null)
const showUserName: (user: User) => void = (user: User): void => {
console.log(user.name)
}
9. 関数名・変数名と実際の責務が一致しているか
関数名を読んで想像した処理と、実際の中身が大きく違っていないかを見るようにしています。
少し気になる例
const getUser: () => Promise<void> = async (): Promise<void> => {
const user: User = await userApi.get()
userStore.setUser(user)
router.push("/home")
}
getUserという名前だけを見ると、ユーザー情報を取得する関数に見えますが、実際にはStore更新や画面遷移まで行っています。
例えばこんな形
const fetchUser: () => Promise<User> = async (): Promise<User> => {
return await userApi.get()
}
const completeLogin: () => Promise<void> = async (): Promise<void> => {
const user: User = await fetchUser()
userStore.setUser(user)
router.push("/home")
}
関数名だけを見ても、ある程度中身を予測できるかを意識しています。
10. コメントがコードの説明だけになっていないか
コメントについては、数が多いか少ないかより、何を説明しているかを見るようにしています。
少し気になるコメント
// 有効なユーザーだけを抽出する
const activeUsers: User[] = users.filter((user: User): boolean => user.isActive)
filterの条件を見れば、「有効なユーザーだけを抽出している」ことはコードから読み取れます。
自分ならこういう情報を残す
// API側では退会済みユーザーも返却されるため、画面表示前に除外する
const activeUsers: User[] = users.filter((user: User): boolean => user.isActive)
処理そのものは同じですが、こちらはコードだけでは分からない背景を残しています。
「何をしているか」より「なぜこの処理が必要なのか」をコメントで補えているかを見るようにしています。
11. 正常系だけでなく境界値を考慮しているか
サンプルデータで問題なく動いているコードでも、データが0件になったときなどに問題が起きることがあります。
気になる例
import { computed } from "vue"
const firstUserName = computed((): string => {
return users.value[0].name
})
usersが空の場合はエラーになります。
例えばこんな形
import { computed } from "vue"
const firstUserName = computed((): string => {
return users.value[0]?.name ?? ""
})
コードを見るときは、0件、1件、最大件数、null、undefined、空文字、非常に長い文字列などの場合も少し考えるようにしています。
12. v-htmlを安易に使っていないか
v-htmlを見つけた場合は、まず表示しているHTMLがどこから来ているのか確認するようにしています。
気になる例
<template>
<div v-html="userInput"></div>
</template>
userInputがユーザー入力や外部サービスから取得したデータであれば、安全に扱えるものなのか確認が必要です。
単純に文字列を表示したいだけであれば、
<template>
<div>{{ userInput }}</div>
</template>
のような実装で十分な場合があります。
「v-htmlを使っている」ことより、その値の入力元は何かを見るようにしています。
AIが生成したコードで特に意識していること
実務では、生成AIを使ったコーディングはまだ経験していません。
一方、個人開発では生成AIを使って実装することがあるため、その際は通常のレビュー観点に加えて、既存コードとの重複や設計との整合性、変更範囲を特に確認するようにしています。
13. 既存の共通処理を重複実装していないか
生成AIへ一部分だけ実装を依頼すると、プロジェクト全体の既存コードを十分に把握できず、すでに存在する共通処理と似た実装を新しく作ることがあります。
そのため、生成されたコードを見るときは、共通関数、共通コンポーネント、Store、API通信処理などに、すでに同じ役割のものが存在しないかを確認するようにしています。
同じような処理が複数箇所に増えると、仕様変更時にそれぞれを修正する必要が出たり、処理ごとに細かな違いが生まれたりすることがあります。
AIが新しい処理を追加していたら、「本当に新しく作る必要があるのか」 を確認するようにしています。
14. 既存プロジェクトの設計と整合しているか
生成されたコード単体では問題なく動いていても、既存プロジェクトの設計方針から外れていることがあります。
例えば、プロジェクト内ではAPI通信を専用のモジュールへまとめているのに、新しく生成されたコードだけが直接APIへアクセスしていたり、既存のStoreや共通処理を使わず独自の実装を追加していたりするケースです。
API通信に限らず、
- ファイルの配置場所
- Storeの使い方
- 共通関数の置き場所
- コンポーネントの分割方針
- 命名ルール
なども確認しています。
「このコード単体で正しいか」だけでなく「このプロジェクトの中で自然なコードか」 という見方をするようにしています。
15. 変更範囲が要求以上に広がっていないか
AIに小さな修正を依頼したつもりでも、周辺コードのリファクタリングなど、本来の依頼とは直接関係のない変更まで含まれることがあります。
必要な変更であれば問題ありませんが、変更範囲が広くなるほど、
- レビューする差分が増える
- デグレードの可能性が増える
- 本来何を変更したかったのか分かりづらくなる
といったデメリットがあります。
そのため、「依頼した内容に対して、この差分は妥当な大きさか」 を確認するようにしています。
まとめ
今回は、自分がVue3のコードを見るときに意識しているポイントを、備忘録としてまとめてみました。
どれも必ず適用するルールではなく、気になるコードを見つけたときに「状態の持ち方は適切か」「エラー時はどうなるか」「既存の設計と合っているか」と考えるためのきっかけとして使っています。
今後また開発で気づいた観点があれば、随時追加していこうと思います。