15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

MVVMパターンをVue.jsのコードから理解してみる。

Posted at

はじめに

アプリ開発をしていると、MVVMパターンで作ってみました的な記事をよく見る。
そこでMVVMパターンを理解しようと調べてみると、MVCパターンとの比較で語られる記事が多く、イメージがしにくい。
なぜなら、各パターンの代表として語られるフレームワークのカバーする範囲が、まるで異なるからだ。

  • Ruby on Rails(MVC) -> FE/BEを含むフレームワーク
  • Vue.js(MVVM) -> FEフレームワーク

これらを比較すると、MVCのVの部分を細分化したものがMVVMなのか?と誤解してしまう。

本記事の目的 
Vue.jsで作成したサンプルアプリのコードに対して何がMVでVでMなのかを対応付け行い、MVVMに関する理解を深める。

Vueのサンプルアプリのコード

Vueのサンプルアプリのurl

MVCとMVVMの違い(ちょーざっくり)

MVC, MVVMなどを含むアーキテクチャパターンの違いに関して、巷には様々な記事が溢れている。

MVCのCとMVVMのVMがほぼ同じようなものっぽい印象がある。
ただMVCのVとCの関係と異なり、MVVMのVとVMの関係においては、"データバインディング"がなされている。
データのやりとりを行うのではなく、データを共有している、というイメージだ。

MVCパターン

MVCパターンでは、ViewとControllerはデータのやりとりを行なっている。
userがwebブラウザに表示されたViewを経由してControllerに対して操作を行い、Controllerで処理されたデータを用いてViewをwebブラウザに返却する、という流れだ。

prog_mvc_rails.png

モジュール 説明
Model DB へのアクセス
View html ページと html ページの作成
Controller 入力の受け取り。他のモジュールの操作

(出典) 雑把の UI アーキテクチャー史(MVCからMVVMへ)

MVVMパターン

MVVMパターンでは、ViewModelとViewはデータのやりとりを行なっているのではなく、データバインディングが行われている。
そのため、ViewModelで値が変更されると、自動的にViewでの表示も変わる。

(出典) Vue.jsで実現するMVVMパターン Fluxアーキテクチャとの距離

さて、抽象概念の次は具体的なコードを考えてみよう。

vue.jsのコードとMVVMの対応

サンプルアプリの解説

The Movie Database(TMDB)apiにアクセスし、タイトルから映画を検索し、気に入った映画をお気に入り登録することができるSPA。

画面収録-2022-01-21-2.57.14.gif

フォルダ階層

ちなみに、実際にはviewが複数存在しているため、各ViewModelから別のViewModelに紐づくModelへアクセスしたりするVMとMの配線問題が発生してしまう。 そのため、全てのModelをまとめて、一つの巨大なStoreとして管理する手法が取られる。(Vuexの利用) (出典) [Vue.jsで実現するMVVMパターン Fluxアーキテクチャとの距離](https://speakerdeck.com/shinpeim/vue-dot-jsdeshi-xian-surumvvmpatan-fluxakitekutiyatofalseju-li)

MVVMとコードの対応

  • Model(Store) -> storeフォルダ内のindex.tsファイル
    • index.tsは、src/store/module内のファイルを読み込んでいる。
  • View -> src/viewフォルダ内の.vueファイルのtemplate部分
  • ViewModel -> sr/viewフォルダ内の.vueファイルのscript部分
  • event -> View内の、@clickメソッド
  • databind -> View内で、ViewModel内の変数を用いている部分
  • dispatch -> storeに対して、commit/dispatch操作をしている部分
  • Observe -> store内の変数にアクセスして取得している部分
Home.vue
<template> <!-- View -->
  <div class="home">
    <div class="home__switchButton">
         <!-- event↓ -->     <!-- databind↓ -->
      <button @click="switchList">{{ currentTitle }}</button>
    </div>
    <div v-if="notSearched" class="home__notSearched">
      上の検索欄から検索してください!
    </div>
    <div v-else-if="isEmpty" class="home__isEmpty">
      検索結果がありません
    </div>
    <div v-else class="home__container">
      <div v-if="!isFavoirteList" class="home__pager">
        <button v-if="isPrevShown" @click="movePrev">Prev</button>
        <span class="home__pageCount">{{ currentPage }}/{{ totalPages}}</span>
        <button v-if="isNextShown" @click="moveNext">Next</button>
      </div>

      <div class="home__movieCards">
        <div
          v-for="movie in displayMovies"
          :key="movie.id"
          class="home__movieCard"
          @click="selectMovie(movie)"
        >
          <div class="home__image">
            <img v-if="hasBackdrop(movie)" width=210 :src="movie.backdropUrl()">
            <img v-else width=210 src="../assets/noimage.png">
          </div>

          <div class="home__description">
            <div class="home__movieTitle">{{ movie.original_title }}</div>
            <div class="home__movieText">
              <div>
                {{ voteAverageText(movie) }} ({{ voteCountText(movie) }}件)
              </div>
              <div>{{ releaseDateText(movie) }}</div>
              <div @click="clickFavorite($event, movie)">
                <fa class="home__icon" v-if="isFavorited(movie)" icon="heart" />
                <fa class="home__icon" v-else :icon="['far', 'heart']" />
              </div>
            </div>
          </div>
        </div>
      </div>

      <div v-if="!isFavoirteList" class="home__pager">
        <button v-if="isPrevShown" @click="movePrev">Prev</button>
        <span class="home__pageCount">{{ currentPage }}/{{ totalPages}}</span>
        <button v-if="isNextShown" @click="moveNext">Next</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts"> // ViewModel
import { Options, Vue } from 'vue-class-component';
import { useStore } from "vuex"
import { Movie } from "../models/Movie";

@Options({
})
export default class Home extends Vue {
  store = useStore()
  isFavoirteList: boolean = false;

  
  switchList() {
    this.isFavoirteList = !this.isFavoirteList;
  }

  isFavorited(movie: Movie): boolean {
    return this.favoriteMovies.filter(favo => favo.id == movie.id ).length != 0;
  }

  voteAverageText(movie: Movie): string {
    return movie.vote_average == 0 ? "-" : String(movie.vote_average); 
  }

  isVoteAverageLow(movie: Movie): boolean {
    return movie.vote_average <= 3;
  }

  isVoteAverageHigh(movie: Movie): boolean {
    return movie.vote_average > 8;
  }

  voteCountText(movie: Movie): string {
    return movie.vote_count == 0 ? "-" : String(movie.vote_count); 
  }

  releaseDateText(movie: Movie): string {
    return movie.release_date == "" ? "-" : String(movie.release_date); 
  }

  hasBackdrop(movie: Movie) {
    return movie.backdrop_path !== "" && movie.backdrop_path !== null;
  }

  get displayMovies(): Movie[] {
    if (this.isFavoirteList) {
      return this.favoriteMovies;
    } else {
      return this.movies;
    }
  }

  get currentTitle(): string {
    if (this.isFavoirteList) {
      return "See Results";
    } else {
      return "See Favorites";
    }
  }

  get notSearched(): boolean {
    return this.currentPage == null && !this.isFavoirteList;
  }

  get isEmpty(): boolean {
    return this.totalPages == 0 && this.currentPage == 1 && !this.isFavoirteList;
  }

  get isPrevShown(): boolean {
    return this.currentPage != 1;
  }

  get isNextShown(): boolean {
    return this.currentPage != this.totalPages;
  }

  //////////////////////////////////////////////////////
  // ここから下が、Store(Model)の値を監視している部分(Observe)
  //////////////////////////////////////////////////////

  get movies(): Movie[] {
    return this.store.state.movie.movies;
  }

  get favoriteMovies(): Movie[] {
    return this.store.state.favorite.favoriteMovies;
  }

  get currentPage(): number | null {
    return this.store.state.movie.currentPage;
  }

  get totalPages(): number | null {
    return this.store.state.movie.totalPages;
  }

  //////////////////////////////////////////////////////
  // ここから上が、Store(Model)の値を監視している部分(Observe)
  //////////////////////////////////////////////////////


  //////////////////////////////////////////////////////
  // ここから下が、Store(Model)に対する操作(Dispatch)
  //////////////////////////////////////////////////////

  clickFavorite(event: any, movie: Movie) {
    if (this.isFavorited(movie)) {
      this.store.commit("favorite/removeFavorite", movie);
    } else {
      this.store.commit("favorite/addFavorite", movie);
    }
    event.stopPropagation();
  }

  selectMovie(movie: Movie) {
    this.store.dispatch("movie/getMovie", movie.id);
  }

  movePrev() {
    this.store.dispatch("movie/searchMovies", { 
      query: this.store.state.movie.query, 
      page: this.store.state.movie.currentPage - 1
    });
  }

  moveNext() {
    this.store.dispatch("movie/searchMovies", { 
      query: this.store.state.movie.query, 
      page: this.store.state.movie.currentPage + 1
    });
  }

  //////////////////////////////////////////////////////
  // ここから上が、Store(Model)に対する操作(Dispatch)
  //////////////////////////////////////////////////////
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.home__switchButton {
  margin-bottom: 20px;
}

.home__movieCards {
  margin: 0 auto;
  width: 928px;
  display: flex;
  flex-wrap : wrap;
}

.home__movieCard {
  margin: 10px;
  width: 210px;
  height: 200px;
  border: 1px solid rgba(150, 200, 50, 1);
  border-radius: 20px;
  overflow: hidden;
  cursor: pointer;
}

.home__description {
  padding: 0 10px 10px 10px;
}

.home__movieTitle {
  /** 2行以上で省略 */
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.home__movieText {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.home__icon {
  color: pink;
}

.home__pager {
  margin-top:10px;
  margin-bottom:10px;
}

.home__pageCount {
  margin-left: 10px;
  margin-right: 10px;
}
</style>

終わりに

Webアプリケーションの文脈で考えると...

  • Ruby on Rails(MVC) -> フロントエンド・バックエンドまでを含むフレームワーク
  • Vue.js(MVVM) -> フロントエンドフレームワーク

So

  • MVCとの比較によるMVVMのイメージが難しい。
  • MVCのVを細分化したものがMVVM?みたいに思ってしまっていた。

But

アーキテクチャパターンは、より一般的な概念であるはず。

In the End

実際にコードのどの部分が何にあたるかを考えると、MVVMの意味が理解できた気がする。
通信を伴わないデスクトップアプリやスマホアプリでの挙動で比較すると、より理解が深まる気がする。

15
10
0

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
15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?