気付かないうちに Vue.js でやりがちなアンチパターンを知っておこう


こんにちは、とまだです。

[Vue.js アドベントカレンダー](https://qiita.com/advent-calendar/2024/vue)、1 日目の記事をお届けします！

私は普段、仕事で Vue.js を使うことがあるのですが、使い始めたころは **「やってはいけないこと」f** が分からず、アンチパターンを犯してしまうことがありました。

Vue.js の場合、アンチパターンを犯しても「とりあえず動いた！」ということが多いため、気づかないうちにアンチパターンを続けてしまうことがあります。

:::note warn
動くコードが、必ずしも正しいコードとは限りません。
むしろ、一見動いているように見えて、実は問題を抱えているコードも多いのです。
:::

今回は、そんな過去の自分への戒めとして、Vue.js でよくあるアンチパターンを 5 つ紹介します。

(Vue.js と書きましたが、React など他のライブラリ・フレームワークでも同様のアンチパターンが存在しますので、参考にしていただければ幸いです。)

## アンチパターン 1： タイマーの後片付け忘れ

まず最初は、タイマー系の処理でよく見かけるアンチパターンです。

もはや定番とも言える「タイマーの後片付け忘れ」です。

### アンチパターンを見てみよう

```vue
<template>
  <div class="timer">
    <h2>経過時間: {{ elapsedSeconds }}秒</h2>
    <button @click="startTimer">開始</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      elapsedSeconds: 0,
      timer: null,
    };
  },
  methods: {
    startTimer() {
      // 既存のタイマーをクリアせずに新しいタイマーを設定
      this.timer = setInterval(() => {
        this.elapsedSeconds++;
      }, 1000);
    },
  },
  // beforeUnmount/beforeDestroyがない！
};
</script>
```

**何が問題なのか？**

このコードには、2 つの重大な問題があります。

1. タイマーの重複設定

   - `startTimer`を複数回呼び出すと、既存のタイマーがクリアされずに新しいタイマーが追加される
   - 結果として、複数のタイマーが同時に動作してしまう

2. コンポーネント破棄時のクリーンアップ忘れ
   - コンポーネントが破棄されてもタイマーが動き続ける
   - メモリリーク（メモリの無駄遣い）の原因になる

### 改善例

では、先ほどのコードを改善してみましょう。

ここでは、`beforeUnmount`（Vue 3 以降）を使ってコンポーネントが破棄される際にタイマーをクリアするようにします。
（Vue 2 の場合は `beforeDestroy` を使います）

```vue
<script>
export default {
  data() {
    return {
      elapsedSeconds: 0,
      timer: null,
    };
  },
  methods: {
    startTimer() {
      // 既存のタイマーがあればクリア
      if (this.timer) {
        clearInterval(this.timer);
      }
      // 新しいタイマーを設定
      this.timer = setInterval(() => {
        this.elapsedSeconds++;
      }, 1000);
    },
  },
  // コンポーネント破棄時のクリーンアップ
  beforeUnmount() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
};
</script>
```

これで、タイマーの重複設定やコンポーネント破棄時のクリーンアップを行うことができるようになりました。

タイマーに限らず、たとえばイベントリスナーの登録や非同期処理のキャンセルなど、コンポーネントのクリーンアップを忘れないようにしましょう。

## アンチパターン 2： 親コンポーネントの直接操作

次は、親コンポーネントを直接いじってしまうアンチパターンです。

### アンチパターンを見てみよう

```vue
<template>
  <div>
    <h2>現在の設定: {{ $parent.settings.theme }}</h2>
    <button @click="changeParentState">親の状態を直接変更</button>
  </div>
</template>
<script>
export default {
  methods: {
    changeParentState() {
      // 親コンポーネントの状態を直接変更（親テーマが保持している値を変更したり、親のメソッドを呼び出したり）
      this.$parent.settings.theme = 'dark';
      this.$parent.count++;
      this.$parent.updateLayout();
    },
  },
};
</script>
```

Vue.js では `$parent` を使って親コンポーネントにアクセスすることができますが、これを使って親コンポーネントの状態を直接変更できてしまいます。

子コンポーネントの操作により、画面全体の何かを手軽に変更しようとすると、うっかり親コンポーネントを直接いじるコードを書いてしまうかもしれませんね。

**何が問題なのか？**

1. 密結合（コンポーネント間の強い依存関係）

   - 親コンポーネントの実装に強く依存してしまう
   - コードの再利用性が下がる

2. デバッグの困難さ
   - 状態の変更がどこで起きているのか追跡しにくい
   - 予期せぬバグの原因になりやすい

これも「とりあえず動く」ものではあるかもしれませんが、コードの品質を高めるためには避けるべきアンチパターンです。

### 改善例

では、親コンポーネントの状態を変更する代わりに、イベントを発火して親コンポーネントに変更を伝えるようにしましょう。

ここでは `$emit` を使って、`update-theme` イベントを発火して親コンポーネントにテーマの変更を伝える例を示します。

```vue
<template>
  <div>
    <h2>現在の設定: {{ theme }}</h2>
    <button @click="$emit('update-theme', 'dark')">テーマを変更</button>
  </div>
</template>
<script>
export default {
  props: {
    theme: String,
  },
  emits: ['update-theme'],
};
</script>
```

これにより、子コンポーネントは親コンポーネントの状態を直接変更することなく、親コンポーネントに変更を伝えるだけに留めることができます。

言い換えると、子が親のメソッドの中身を知らなくても、親が子のメソッドを知らなくても、コンポーネント間の通信ができるようになりました。

## アンチパターン 3：DOM の直接操作

続いて、DOM を直接操作してしまうアンチパターンです。

これは、Vue.js の最も重要な機能の一つを台無しにしてしまう危険な例です。

気付かずにやりがちですので、注意していきたいポイントです。

### 頭のいい Vue.js くんがやっていること

例えば、メッセンジャーアプリを作っているとしましょう。

```vue
<template>
  <div class="chat">
    <div v-for="message in messages" :key="message.id">
      {{ message.text }}
    </div>
  </div>
</template>
```

このチャット画面で新しいメッセージが来たとき、Vue.js は以下のような仕組みで画面を更新します。

1. まず「設計図」（仮想 DOM）上で変更を確認
2. 実際の画面で変更が必要な部分だけを更新

これは、設計図を見ながら家の必要な場所だけをリフォームするようなものです。

こうすることで、無駄な画面更新を抑えることができ、パフォーマンスが向上します。

### アンチパターンを見てみよう

一方、試しに以下のように DOM を直接操作してみましょう。

```vue
<template>
  <div>
    <div ref="container" class="chart-container"></div>
    <button @click="updateChart">更新</button>
  </div>
</template>
<script>
export default {
  mounted() {
    this.setupChart();
  },
  methods: {
    setupChart() {
      // DOMを直接操作
      const container = this.$refs.container;
      container.style.width = '500px';
      container.style.height = '300px';
      container.innerHTML = '<canvas></canvas>';

      const canvas = container.querySelector('canvas');
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = 'red';
      ctx.fillRect(0, 0, 100, 100);
    },
  },
};
</script>
```

これは、設計図を無視して家を直接改修するようなものです。

結果として設計図が機能しなくなるため、実際の構造をゼロからチェックしつつリフォームする必要が生じます。
また、設計図通りに改修しようとして、家の構造を壊してしまうかもしれません。

これだと、家のリフォームが終わるまでに時間がかかり、コストもかかります。

**何が問題なのか？**

1. Vue.js が用意している更新の仕組みが使えない

   - Vue.js は変更箇所を追跡できなくなる
   - 結果として無駄な画面更新が発生する可能性がある
   - パフォーマンスが低下する

2. コンポーネントの再利用が難しくなる
   - HTML の構造を直接いじっているので、構造が変わると動かなくなる
   - テストも難しくなる（HTML の構造に依存しすぎているため）

### 改善例

では、Vue.js の機能を使って DOM を操作する方法を見てみましょう。

例えば、チャートのサイズを変更する処理を考えてみましょう。

```js
// 良くない例：DOMを直接操作
updateChart() {
  const canvas = this.$refs.container.querySelector('canvas')
  canvas.width = window.innerWidth  // 画面幅に合わせて更新
  canvas.style.border = '1px solid black'
}

// 良い例：Vueの機能を使う
data() {
  return {
    chartWidth: window.innerWidth,
    chartStyle: {
      border: '1px solid black'
    }
  }
},
template: `
  <canvas :width="chartWidth" :style="chartStyle"></canvas>
`
```

良い例では、Vue.js の機能を使って画面の変更を行っています。

一見、似たようなことをしているように見えますが、ここでは以下のような流れをたどっています。

1. `data` で変更する値を定義
2. `template` で定義した値を使って画面を描画

これにより、Vue.js が画面の変更を追跡しやすくなり、パフォーマンスが向上するのです。

:::note info
「ちょっとした変更だから...」と思って直接 DOM をいじると、じわじわと問題が大きくなっていくことがあります。
可能な限り Vue.js の機能を経由して画面の変更を行うようにしましょう。
:::

## アンチパターン 4：v-model の誤用

フォーム系のコンポーネントで初心者がよく踏む地雷として、v-model の使い方に関するアンチパターンを紹介します。

### データの流れを追いかけてみよう

まず、v-model がどのように動くのかを理解するために、例え話を使って説明してみましょう。

v-model は、親コンポーネントと子コンポーネントの間での「データのやり取り」を管理する機能です。

例えば、図書館で本を借りる流れを想像してみてください。

1. 図書館（親）から本（データ）を借りる
2. 本を読み終わったら、必ず図書館に返却する
3. 勝手に本の中身を書き換えてはいけない

Vue.js でも同じです。親からデータを受け取ったら、changes（変更）は必ず親に報告する必要があります。

### アンチパターンを見てみよう

以下は、よくある間違った使い方です。

```vue
<!-- VModelMisuseComponent.vue -->
<template>
  <div>
    <input type="text" :value="localValue" @input="handleInput" />
    <p>ローカル値: {{ localValue }}</p>
  </div>
</template>
<script>
export default {
  props: {
    modelValue: String,
  },
  data() {
    return {
      localValue: this.modelValue, // ①親から借りた本をコピー
    };
  },
  methods: {
    handleInput(event) {
      this.localValue = event.target.value; // ②コピーを書き換える
      // ③親への報告を忘れている！
      // this.$emit('update:modelValue', this.localValue)
    },
  },
};
</script>
```

**何が問題なのか？**

1. データの流れが一方通行になってしまっている

   - 親からデータを受け取るだけ
   - 変更を親に報告していない（図書館に本を返していない！）

2. 親子間でデータの不整合が発生する
   - 親：元の値のまま
   - 子：変更された値
   - ⇒ どちらが正しい値なのか分からなくなる

### 改善例：正しい本の貸し借り

では、正しい実装を見てみましょう。

```vue
<template>
  <div>
    <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
    <p>現在の値: {{ modelValue }}</p>
  </div>
</template>
<script>
export default {
  props: {
    modelValue: String,
  },
  emits: ['update:modelValue'],
};
</script>
```

このコードでは、以下のようなデータの流れが守られています。

1. 親からデータを受け取る（`:value="modelValue"`）
2. 変更があったら即座に親に報告（`$emit('update:modelValue', ...)`）
3. ローカルでコピーを持たない（余計な state を持たない）

図書館の例で言えば、本を借りたら必ず返却する、というルールが守られていると言えます。

- 本を借りる（props 経由でデータを受け取る）
- 読書メモを図書館に提出する（emit で変更を報告）
- 勝手にコピーを作らない（localValue を持たない）

:::note info
v-model は便利な機能ですが、使い方を間違えると「データの行方不明事件」が発生します。
親子間のデータの受け渡しは、必ず「借りる → 変更を報告する」というサイクルを守りましょう！
:::

## アンチパターン 5：コンポーネント間の直接参照

最後は、コンポーネント間の直接参照に関するアンチパターンです。

### アンチパターンを見てみよう

以下は、コンポーネント間で直接やり取りしてしまう、よくある間違った例です。

```vue
<template>
  <div>
    <button @click="resetSibling">他のコンポーネントをリセット</button>
  </div>
</template>
<script>
export default {
  mounted() {
    // ①他のコンポーネントを直接参照
    this.siblingComponent = this.$parent.$refs.sibling;
  },
  methods: {
    resetSibling() {
      // ②直接メソッドを呼び出し
      this.siblingComponent.reset();
      this.siblingComponent.reload();
      // ③直接データを変更
      this.siblingComponent.data.count = 0;
    },
  },
};
</script>
```

**何が問題なのか？**

1. コンポーネントの独立性が失われる

   - 他のコンポーネントの内部構造に依存
   - 「このコンポーネントは必ずこういう構造だろう」という前提が生まれる

2. メンテナンスが困難になる
   - コンポーネントの修正が他のコンポーネントに影響する
   - バグが発生したときの原因特定が難しい

たとえば、アンチパターンのように、あるコンポーネントが他のコンポーネントのメソッドを呼び出しているとします。

もし他のコンポーネントがリファクタリングされたり、メソッド名が変更されたりした場合、呼び出し元のコンポーネントも修正が必要になります。

これだとコードの変更が増え、メンテナンスが困難になってしまいますよね。

### 改善例：正しい情報の伝え方

ではどうすれば良いのでしょうか？

正しい実装を見てみましょう。

```vue
<template>
  <div>
    <button @click="$emit('request-reset')">リセットをリクエスト</button>
  </div>
</template>
<script>
export default {
  emits: ['request-reset'],
};
</script>

<!-- 親コンポーネント -->
<template>
  <div>
    <ResetButton @request-reset="handleReset" />
    <SiblingComponent ref="sibling" />
  </div>
</template>
<script>
export default {
  methods: {
    handleReset() {
      this.$refs.sibling.reset();
    },
  },
};
</script>
```

このコードでは、以下のような流れが守られています。

1. 子コンポーネントは親にイベントを通知する（`$emit`）
2. 親コンポーネントがイベントを受け取って必要な処理を行う
3. 必要に応じて親から他のコンポーネントに指示を出す

このように、コンポーネント間の通信は、親を介して行うようにすることで、コンポーネントの独立性を保ちつつ、情報の伝達を行うことができます。

## まとめ

いかがでしたか？

Vue.js には、一見動いているように見えて実は問題を抱えているコードが多くあります。

今回紹介した 5 つのアンチパターンを覚えておくと、より良い Vue アプリケーションが書けるようになるはずです。

1. タイマーは必ずクリーンアップする
2. 親コンポーネントは直接触らない
3. DOM は直接操作しない
4. v-model は正しく使う
5. コンポーネント間の直接参照は避ける

これらの問題点を意識しながら、より良い Vue アプリケーションを作っていきましょう！

## 他にもアドベントカレンダー記事を書いています！

他にも、2024 年のアドベントカレンダーに参加しています。

以下の記事でまとめているので、よければ他の記事も読んでいただけると嬉しいです！

https://qiita.com/tomada/items/025b3745df8c484f5856