🐡

実務でVue.js3を使ってみての感想

2024/08/13に公開

はじめに

今回、Vue3から導入されたCompositionAPIを実務で使用した経験から、OptionsAPIとの違いや、CompotisonAPIを使ってみた感想を紹介したいと思います。

また、この記事を通してCompositionAPIとOptionsAPIのどちらを採用するか迷っている方にとって、今回の内容が検討材料になれば幸いです。

この記事のターゲット

  • CompotisonAPIの導入を考えている方。
  • OptionsAPIとCompotisonAPIどちらを採用するか検討している方。
  • CompotisonAPI初心者の方。

実務で対応した実装

今回のプロジェクトは、コミュニケーションツールの開発で、初期バージョンのVueで作られていた画面をVue3で一から作り直すという対応でした。
私は主に投稿された記事に対するコメントの新規投稿、編集、削除機能をメインに担当しました。
初めてVue3を触るということもあり、ほとんどのコンポーネントをCompositionAPIで記述してしまっていました。(この時は何も考えていなかった...。)

OptionsAPIとの比較

ここからは、投稿処理のコードをもとに、CompositionAPIとOptionsAPIでの実装の違いを比較してみたいと思います。
実際には複数の投稿機能がありましたが、今回は以下の3つに絞って紹介したいと思います。
・ コメントの入力
・ メンション機能
・ ファイルのアップロードと削除

CompositionAPIの場合
form.vue
<template>
  <div>
    <textarea rows="10" placeholder="コメント入力" v-model="text"></textarea>
    <!-- メンション機能 -->
    <multiselect
        v-model="mentionUsers"
        :options="allUser"
        :multiple="true"
        placeholder="ユーザーを選択"
        label="name"
        track-by="id"
    >
    </multiselect>

    <!-- 画像機能 -->
    <div v-for="(input, index) in fileList" :key="index">
      <input
        type="file"
        @change="(event) => handleFileUpload(event, index)"
        class="file-input"
      />
      <div @click="openFileDialog(index)">
        <div v-if="input.files && input.files.length">
          <span>{{ input.files[0].name }}</span>
          <span @click.stop="removeFile(index)">削除</span>
        </div>
        <span v-else>ファイルを選択</span>
      </div>
    </div>
    <button @click="submitData" :disabled="isDisabled">
      コメントする
    </button>
  </div>
</template>

<script setup>
import { ref, defineProps, defineEmits, watch } from "vue";
import Multiselect from "vue-multiselect";

const props = defineProps({
  allUser: {
    type: Object,
    required: true,
    default: () => [],
  },
});

//コメントの送信が成功したかどうか
const emit = defineEmits(["error-message", "success-message"]);
const errorMessage = (message) => {
  emit("error-message", message);
};
const successMessage = (message) => {
  emit("success-message", message);
};

const requestData = ref({
  text: "",
  mentionedUsers: [],
  movies: [],
});

// コメント入力
const text = ref("");
watch(text, (newText) => {
      requestData.value.text = newText;
        isDisabled.value = newText.length === 0;
  }
});

// メンションされたユーザー
const mentionUsers = ref([]);
watch(mentionUsers, (newUser) => {
  requestData.value.mentionedUsers = newUser.map((user) => user.userId);
});

//画像選択
const fileList = ref([{ files: [] }]);
const openFileDialog = (index) => {
  document.querySelectorAll(".file-input")[index].click();
};
const handleFileUpload = (event, index) => {
  const files = event.target.files;
  if (!fileList.value[index].files.length) {
    fileList.value[index].files = Array.from(files);
    fileList.value.push({ files: [] });
  } else {
    fileList.value[index].files = Array.from(files);
  }
};
const removeFile = (index) => {
  fileList.value.splice(index, 1);
};

//コメント送信
const submitData = async () => {
    try {
        const formData = new FormData();

    for (const key in requestData.value) {
      if (Array.isArray(requestData.value[key])) {
        requestData.value[key].forEach((item) => {
          formData.append(`${key}[]`, item);
        });
      } else {
        formData.append(key, requestData.value[key]);
      }
    }

    fileList.value.forEach((input) => {
      if (input.files) {
        input.files.forEach((file) => {
          formData.append("files[]", file);
        });
      }
    });

    const response = await fetch("http://localhost:8080/api/comment", {
      method: "POST",
      headers: {
        "X-CSRF-TOKEN": "csrfToken",
      },
      body: formData,
    });
    const resData = await response.json();

    if (resData.errors) {
      errorMessage('エラーが発生しました');
      return;
    } else {
      successMessage('コメントしました');
    }
    } catch (error) {
    console.error("エラーが発生しました", error);
  }
}
</script>

上記の処理の簡単に説明をすると、


1. コメント入力
refで初期値を空に設定し、watch関数を使ってtextの変更を監視し、値が変更されるたびにrequestData.value.textに新しい値が反映されます。
また、コメントが入力されていない場合はisDisabledtrueにし、送信ボタンを無効化しています。


2. メンション機能
refで初期値を空の配列に設定し、watch関数を使ってmentionUsersの変更を監視します。
そこから選択されたユーザーのuserIdを抽出して、requestData.value.mentionedUsersに保存します。


3. 画像アップロード
画像ファイルのアップロードに対応するために、fileListという配列を管理しています。
各配列要素は、アップロードされたファイルのリスト (files) を保持します。

  • handleFileUploadメソッドは、ユーザーがファイルを選択したときに呼び出され、選択されたファイルを fileListに保存します。
  • openFileDialogメソッドは、指定されたインデックスに対応するファイル選択ダイアログを開く処理を行います。
  • removeFileメソッドは、指定されたインデックスのファイルを削除し、配列から該当の要素を取り除きます。

4.コメント送信
submitDataメソッドでは、requestDatafileListに保存されたデータを FormDataオブジェクトに変換し、サーバーにPOSTリクエストを送信します。
また、emitを使用してレスポンスにエラーがあればerror-messageイベントを、成功した場合はsuccess-messageイベントをトリガーしてメッセージを表示します。


フロントエンジニアなら基本的に上から読んでいっても分かりやすいコードになってると思います。
個人的には関連する処理が一箇所にまとまっているのでコードが追いやすいです。
まぁこれがCompositionAPIのいいところなんですが。

これをOptionsAPIで書くとどうなるのか?

OptionsAPIの場合
form.vue
<template>
  <div>
    <textarea rows="10" placeholder="コメントをどうぞ" v-model="text"></textarea>
    <!-- メンション機能 -->
    <multiselect
        v-model="mentionUsers"
        :options="allUser"
        :multiple="true"
        placeholder="ユーザーを選択してください"
        label="name"
        track-by="id"
    >
    </multiselect>

    <!-- 画像機能 -->
    <div v-for="(input, index) in fileList" :key="index">
      <input
        type="file"
        @change="(event) => handleFileUpload(event, index)"
        class="file-input"
      />
      <div @click="openFileDialog(index)">
        <div v-if="input.files && input.files.length">
          <span>{{ input.files[0].name }}</span>
          <span @click.stop="removeFile(index)">削除</span>
        </div>
        <span v-else>ファイルを選択</span>
      </div>
    </div>
    <button @click="submitData" :disabled="isDisabled">
      コメントする
    </button>
  </div>
</template>

<script>
import Multiselect from "vue-multiselect";

export default {
  components: {
    Multiselect
  },
  props: {
    allUser: {
      type: Object,
      required: true,
      default: () => [],
    },
  },
  data() {
    return {
      text: "",
      mentionUsers: [],
      fileList: [{ files: [] }],
      requestData: {
        text: "",
        mentionedUsers: [],
        movies: [],
      },
      isDisabled: true,
    };
  },
  watch: {
    text(newText) {
      this.requestData.text = newText;
      this.isDisabled = newValue.length === 0;
    },
    mentionUsers(newUser) {
      this.requestData.mentionedUsers = newUser.map(user => user.userId);
    }
  },
  methods: {
    errorMessage(message) {
      this.$emit("error-message", message);
    },
    successMessage(message) {
      this.$emit("success-message", message);
    },
    openFileDialog(index) {
      this.$el.querySelectorAll(".file-input")[index].click();
    },
    handleFileUpload(event, index) {
      const files = event.target.files;
      if (!this.fileList[index].files.length) {
        this.$set(this.fileList[index], 'files', Array.from(files));
        this.fileList.push({ files: [] });
      } else {
        this.$set(this.fileList[index], 'files', Array.from(files));
      }
    },
    removeFile(index) {
      this.fileList.splice(index, 1);
    },
    async submitData() {
      try {
        const formData = new FormData();

        for (const key in this.requestData) {
          if (Array.isArray(this.requestData[key])) {
            this.requestData[key].forEach(item => {
              formData.append(`${key}[]`, item);
            });
          } else {
            formData.append(key, this.requestData[key]);
          }
        }

        this.fileList.forEach(input => {
          if (input.files) {
            input.files.forEach(file => {
              formData.append("files[]", file);
            });
          }
        });

        const response = await fetch("http://localhost:8080/api/comment", {
          method: "POST",
          headers: {
            "X-CSRF-TOKEN": "csrfToken",
          },
          body: formData,
        });
        const resData = await response.json();

        if (resData.errors) {
          this.errorMessage('エラーが発生しました');
          return;
        } else {
          this.successMessage('コメントしました');
        }
      } catch (error) {
        console.error("Error submitting data:", error);
      }
    },
  }
};
</script>

どうでしょうか?
各オプション(data、watch、methods)に処理が分かれてしまっているため見づらくないですか...?
CompositionAPIの場合は関連する処理が1つにまとまっているのでコメントも残しやすかったです。
あと、個人的にはdefineEmitsemitを定義することによって、どのメソッドが親から渡ってきたのかが把握しやすくなりました。

CompositionAPIを書いて思ったこと

CompositionAPIめっちゃいい!書きやすい!と思った私はどんどんコードを書き進めていきました。
しかし、コードを書き進んでいくうちに
「あれ?1つのコンポーネント内に大量のJavaScriptのコード書いてね?」
という事に気づきました....。

なぜ大量のコードを書いてしまったかというと、1つのコンポーネントに複数の役割(ロジック)を持たせてしまっていました。
コンポーネントは原則として単一の責任を持つことが推奨されています。
つまり、特定の機能や複数のロジックは各コンポーネントに分けて管理するべきなのです。

とは言いつつ、今回のプロジェクトでは実際にロジックを分けて別コンポーネントで管理するのが難しかったため、1つのコンポーネントに大量のJavaScriptコードを書くしかありませんでした。

もし細かくロジックを分けて、各コンポーネントで管理できるのであれば、OptionsAPIでも可読性は悪くならないので、OptionsAPIで書いても良いのかなと思います。

ドキュメントでも特にOptionsAPIは非推奨になったとは記載されていないので。

Options API は非推奨になったのですか?​
いいえ、私たちは特にそうする予定はありません。 Options API は Vue の不可欠な要素であり、多くの開発者が Vue を愛する理由にもなっています。 Composition API の利点の多くは大規模プロジェクトでこそ現れるものであり、多くの低~中程度の複雑性のシナリオにおいては Options API が堅実な選択肢であり続けることも理解しています。

結論

CompositionAPIとOptionsAPIは、コンポーネントの規模、開発するメンバーによって使い分けるべし!!

 
理由としては、vueの特性として初学者にも分かりやすいという点があります。
OptionsAPIは小規模のコンポーネント向きで、各オプション(data、methods、computedなど)が明確に分かれているので、初心者にとって読みやすい構造になっています。

一方で、Composition APIは大規模なコンポーネント向きで、関数ベースでロジックを整理できるため、可読性が向上します。
しかし、ある程度のJavaScriptの理解が必要となるため、初心者にとってはハードルが高いかもしれません..。

なので、開発効率を上げるためには、最初のコンポーネント設計や開発メンバーのスキルバランスを考慮した上で、どちらを採用するか検討することが重要になってくると感じました。

合同会社春秋テックブログ

Discussion