4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VueAdvent Calendar 2024

Day 1

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

Last updated at Posted at 2024-11-04

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

Vue.js アドベントカレンダー、1 日目の記事をお届けします!

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

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

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

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

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

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

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

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

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

<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 を使います)

<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: 親コンポーネントの直接操作

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

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

<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 イベントを発火して親コンポーネントにテーマの変更を伝える例を示します。

<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 くんがやっていること

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

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

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

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

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

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

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

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

<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 を操作する方法を見てみましょう。

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

// 良くない例: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 が画面の変更を追跡しやすくなり、パフォーマンスが向上するのです。

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

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

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

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

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

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

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

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

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

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

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

<!-- 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. 親子間でデータの不整合が発生する

    • 親:元の値のまま
    • 子:変更された値
    • ⇒ どちらが正しい値なのか分からなくなる

改善例:正しい本の貸し借り

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

<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 を持たない)

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

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

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

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

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

<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. メンテナンスが困難になる

    • コンポーネントの修正が他のコンポーネントに影響する
    • バグが発生したときの原因特定が難しい

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

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

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

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

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

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

<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 年のアドベントカレンダーに参加しています。

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

4
1
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?