はじめに
アプリ開発をしていると、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ブラウザに返却する、という流れだ。
モジュール | 説明 |
---|---|
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。
フォルダ階層
ちなみに、実際には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内の変数にアクセスして取得している部分
<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の意味が理解できた気がする。
通信を伴わないデスクトップアプリやスマホアプリでの挙動で比較すると、より理解が深まる気がする。