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

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

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

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

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


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

https://github.com/ninoko1995/vue-app

Vueのサンプルアプリのurl

https://angry-carson-84bdcd.netlify.app/

## MVCとMVVMの違い(ちょーざっくり)
MVC, MVVMなどを含むアーキテクチャパターンの違いに関して、巷には様々な記事が溢れている。

https://qiita.com/shinkuFencer/items/f2651073fb71416b6cd7

https://atmarkit.itmedia.co.jp/fdotnet/chushin/greatblogentry_10/greatblogentry_10_01.html

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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/c2734794-2387-23f4-dc5d-137dcc5526ac.png)

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

(出典) [雑把の UI アーキテクチャー史(MVCからMVVMへ)](http://yohshiy.blog.fc2.com/blog-entry-215.html)

### MVVMパターン
MVVMパターンでは、ViewModelとViewはデータのやりとりを行なっているのではなく、データバインディングが行われている。
そのため、ViewModelで値が変更されると、自動的にViewでの表示も変わる。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/933ec6cc-f2ca-763b-a898-93d44d09226f.png" width="400">

(出典) [Vue.jsで実現するMVVMパターン Fluxアーキテクチャとの距離](https://speakerdeck.com/shinpeim/vue-dot-jsdeshi-xian-surumvvmpatan-fluxakitekutiyatofalseju-li)

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

## vue.jsのコードとMVVMの対応
### サンプルアプリの解説
[The Movie Database(TMDB)](https://www.themoviedb.org/)の[api](https://www.themoviedb.org/documentation/api)にアクセスし、タイトルから映画を検索し、気に入った映画をお気に入り登録することができるSPA。

![画面収録-2022-01-21-2.57.14.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/e1acfdad-84ef-c23c-b09d-72a3eadd3a94.gif)

### フォルダ階層
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/15d6f6a9-b6ce-2950-1098-c48fe2f9d908.png" width=200>
ちなみに、実際にはviewが複数存在しているため、各ViewModelから別のViewModelに紐づくModelへアクセスしたりするVMとMの配線問題が発生してしまう。
そのため、全てのModelをまとめて、一つの巨大なStoreとして管理する手法が取られる。(Vuexの利用)
 <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/244a05dc-46b0-a61f-c18b-cb35963c5b62.png" width="350">→<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/9486fefb-32f9-68d1-875e-3053a25f9c6b.png" width="350">
(出典) [Vue.jsで実現するMVVMパターン Fluxアーキテクチャとの距離](https://speakerdeck.com/shinpeim/vue-dot-jsdeshi-xian-surumvvmpatan-fluxakitekutiyatofalseju-li)


### MVVMとコードの対応
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/933ec6cc-f2ca-763b-a898-93d44d09226f.png" width="400">

- 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内の変数にアクセスして取得している部分


```vue: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の意味が理解できた気がする。
通信を伴わないデスクトップアプリやスマホアプリでの挙動で比較すると、より理解が深まる気がする。