LoginSignup
16
12

More than 1 year has passed since last update.

vue-realworld-example-appを読んでみた【ベストプラクティス】

Last updated at Posted at 2021-06-13

コードリーディングを行ったリポジトリはこちらです。

gothinkster/vue-realworld-example-app
codesandbox

更新履歴

2021/6/15 誤字脱字の修正、わかりにくい表現の見直し。全体的な内容の追加。
2021/6/13 初版

📌はじめに

📖 記事の背景

vue-realworld-example-app は、実際のアプリで使用する機能(CRUD、認証、ルーティングなど)を盛り込んだVue.jsプロジェクトである。Webアプリケーションの基本的な機能が一通りまとまっている。

本記事は、一般的なVue.jsプロジェクトはどのような構成になっているか、どのような技術が使われているかを知るために、vue-realworld-example-app をコードリーディングし、まとめたものである。

📖 ( 参考 前提知識 )

コードリーディングを行うにあたる前提知識として、以下が必要と感じた。

  • Vue.js v2 (Router、Vuex)の基礎知識
  • Vue CLI の基礎知識

また、知っているとよいと感じた知識は以下。(調べながらで読めると思います)

  • REST API の基礎知識
  • JWTの基礎知識
  • ローカルストレージの基礎知識

📌全体構成の確認

📖フォルダ構成

/src構成と役割は以下の通り。

  • App.vuemain.js アプリのエントリーポイント。アプリ全体の設定などはここで行う。
  • /views 表示する画面のコンポーネントを管理する。ルーティングと対応している。
  • /router Vue Routerによるルーティングの設定を行っている。
  • /components アプリ内で使用するコンポーネントを管理する。
  • /store 状態管理を行うVuexのフォルダ。/views, /components内のから呼ばれる。
  • /common API、JWT、フィルタ、設定などのアプリ全体で使用する共通機能。

📖データの流れ(参考)

データの流れを整理した。わかりにくいかもしれないので参考までに。

1.PNG

📖コンポーネント構成

コンポーネント構成は下記の通り。「緑=/views、黄色=/components」である。/ruterでルーティングされているコンポーネントは/viewsに、それ以外は/componentsで管理している。

2.PNG

📖 /store, /commonの構成

/store, /commonの関係性は下記のとおり。

/storeは、4つのモジュールをexport している。それぞれのモジュールは、state, getters, actions, mutationsを持ち、状態管理を行う。状態管理を行う中で、インターフェースのような役割を果たすtypeから関数名を参照したり、共通処理を/commonを参照し、実行している。

/commonは、/storeで使用する共通処理ファイル(.service.js)とその設定ファイル(config.js)、アプリ全体で使用するフィルターファイル(.filter.js)を持つ。

3.PNG

📌 ソースコード詳細

各ソースコードの気になったところを抜粋して解説する。
https://github.com/gothinkster/vue-realworld-example-app

📖 main.js、App.vue

🔖 main.js

フィルターの定義

import DateFilter from "./common/date.filter";
import ErrorFilter from "./common/error.filter";

Vue.filter("date", DateFilter);
Vue.filter("error", ErrorFilter);

アプリ全体で使用する日付変換(DateFilter)や エラー変換(ErrorFilter)のフィルターはここで設定する。フィルターそのものの定義は/commonでしている。

API処理の初期化

import ApiService from "./common/api.service";

ApiService.init();

サーバとのHTTP通信のための初期化を行う。
ApiService.init()では、vue-axiosプラグインを適用し、デフォルトURLの設定をしている。

ページロードごとの認証処理

import { CHECK_AUTH } from "./store/actions.type";

router.beforeEach((to, from, next) =>
  Promise.all([store.dispatch(CHECK_AUTH)]).then(next)
);

router.beforeEach()はページロードごとに呼ばれる関数。Promise.all()は、配列を引数にとり、すべてのPromiseを実行する。
ページロードごとにトークンの認証処理をしている。

参考 Vue公式 ナビゲーションガード

参考 MDN Promise.all()

🔖 App.vue

ヘッダ、フッタコンポーネントの使用

<template>
  <div id="app">
    <RwvHeader />
    <router-view />
    <RwvFooter />
  </div>
</template>

ヘッダ、フッタはアプリ全体で表示するため、ここで使用する。

📖 / router

🔖 index.js

childrenによるネストルーティング

routes: [
    {
        path: "/",
        component: () => import("@/views/Home"),
        children: [
            {
                path: "",
                name: "home",
                component: () => import("@/views/HomeGlobal")
            },
            ...
        ],
        ...
    },
    ...
]

childrenを使用することで、ネストされたルートとなる。
またnameをつけることで、<router-link :to={ name: 'home' }>と指定できる。

📖 /views

🔖 / views / Home.vue 周辺

  • Home.vue

ルーティング概要

<router-link>HomeGlobalHomeMyFeedHomeTag に飛ばすリンクを設定し、
<router-view>で表示する仕組み。

データ(tag)の流れ

<script>
import { mapGetters } from "vuex";
import { FETCH_TAGS } from "@/store/actions.type";

export default {
  // ...
  mounted() {
    this.$store.dispatch(FETCH_TAGS);
  },
  computed: {
    ...mapGetters(["isAuthenticated", "tags"]),
    tag() {
      return this.$route.params.tag;
    }
  }
};
</script>

mounted()はVuexのactionを使用してFETCH_TAGSを実行し、サーバからtagsを取得する。取得したtagsは、Vuexのstateで管理されるので、mapGetter()で取得し、コンポーネント内で使用している。

mapGetters()は引数で指定したstateの値を取得する関数。import {mapGetters} from "vue"を使用してインポートし、computedで使用する。

  • HomeGlobal.vue, HomeMyFeed.vue, HomeTag.vue

template構成

<template>
  <div class="home-global"><RwvArticleList type="all" /></div>
  <!--
    <div class="home-my-feed"><RwvArticleList type="feed" /></div>
    <div class="home-tag"><RwvArticleList :tag="tag"></RwvArticleList></div>
  -->
</template>

上記3つの<template>はどれも似た構成。@/components/ArticleList.vueを属性 ( type )を変えて使用している。

🔖/ views / Login.vue , Register.vue, Settings.vue

  • Login.vue

フォームとsubmit ボタン

<form @submit.prevent="onSubmit(email, password)">
...
</form>

v-modelでバインディングして、ボタン押下すると値が送信されるごくごく一般的なフォームの実装となっている。@submit.preventevent.preventDefault()を呼び出す処理。これにより、フォーム送信後もページのリロードは行われない。.preventイベント修飾子と呼ばれる。

ログイン成功時、ホーム画面へ

import { mapState } from "vuex";
import { LOGIN } from "@/store/actions.type";

export default {
  ...
  data() {
    return {
      email: null,
      password: null
    };
  },
  methods: {
    onSubmit(email, password) {
      this.$store
        .dispatch(LOGIN, { email, password })
        .then(() => this.$router.push({ name: "home" }));
    }
  },
  ...
}; 

ログインに成功したら、.then(() => { this.$router.push({ name: "home" })});を行い、ホームに飛ばす処理となっている。

state の加工

import { mapState } from "vuex";

export default {
  ...
  computed: {
    // auth.errors を errors に代入する処理。
    // ↓ 加工後の名称: state => 加工処理(state.加工対象のステート名)
    ...mapState({
      errors: state => state.auth.errors
    })
  }
};

上記はauth.errorserrors に代入する処理。

  • Register.vue

Login.vueとほぼ同じ構成。違いは、フォームにusernameの欄ができて、Vuexのactionが、LOGINからREGISTERになったくらい。

  • Setting.vue

Login.vueRegister.vueとほぼ同じ構成。

🔖/ views / Profile.vue 周辺

  • Profile.vue

ルーティング概要

<router-link>ProfileArticlesProfileFavorited に飛ばすリンクを設定し、<router-view>で表示する仕組み。

認証状態に依存する画面表示

<div v-if="isCurrentUser()">
  <router-link
    class="btn btn-sm btn-outline-secondary action-btn"
    :to="{ name: 'settings' }"
  >
    ...
  </router-link>
</div>
<div v-else>
  <button
    class="btn btn-sm btn-secondary action-btn"
    v-if="profile.following"
    @click.prevent="unfollow()"
  >
    ...
  </button>
  <button
    class="btn btn-sm btn-outline-secondary action-btn"
    v-if="!profile.following"
    @click.prevent="follow()"
  >
    ...
  </button>
</div>

<div v-if="isCurrentUser()">を使用することで、ログイン中のユーザかどうかで表示画面が変わる実装となっている。v-if="profile.following"部分も同様で、フォローしているかどうかで表示画面が変わる。

mounted()を使用した初期化処理

import {
  FETCH_PROFILE,
  FETCH_PROFILE_FOLLOW,
  FETCH_PROFILE_UNFOLLOW
} from "@/store/actions.type";

export default {
  ...
  mounted() {
    this.$store.dispatch(FETCH_PROFILE, this.$route.params);
  },
  ...
  watch: {
    $route(to) {
      this.$store.dispatch(FETCH_PROFILE, to.params);
    }
  }
};      

初期化時にmounted()で、ページのユーザ名をパラメータから取得している。

Vue.jsではパラメータが #/@hoge から #/@huga へ遷移するときに同じコンポーネントインスタンスが再利用されるのでmounted()が実行されない。そこで、watch: $route(to){...}を使用して、パラメータの検知をおこなっている。参考

  • ProfileArticles.vueProfileFavorited.vue
<template>
  <div class="profile-page">
    <RwvArticleList :author="author" :items-per-page="5"></RwvArticleList>
    <!--
    <RwvArticleList :favorited="favorited" :items-per-page="5">
    </RwvArticleList>
    -->
  </div>
</template>

templateのはどれも似た構成。@/components/ArticleListを呼び、属性 ( author, favorited )を変えているだけ。

🔖/ views / Article.vue

ナビゲーションガードを用いたデータ取得

import store from "@/store";
import { FETCH_ARTICLE, FETCH_COMMENTS } from "@/store/actions.type";

export default {
  //...
  beforeRouteEnter(to, from, next) {
    Promise.all([
      store.dispatch(FETCH_ARTICLE, to.params.slug),
      store.dispatch(FETCH_COMMENTS, to.params.slug)
    ]).then(() => {
      next();
    });
  }
  //...
}

beforeRouteEnter()を用いて、FETCH_ARTICLEFETCH_COMMENTSを呼ぶ。articlecommentの最新を取得しstateを更新する。

v-htmlを用いた画面表示

<div v-html="parseMarkdown(article.body)"></div>
import marked from "marked";
// ...

export default {
  // ...
  methods: {
    parseMarkdown(content) {
      return marked(content);
    }
  }
}

markedはマークダウン解析ツール、htmlを返すため、v-htmlディレクティブを使用している。

🔖/ views / ArticleEdit.vue

ナビゲーションガードを用いたデータ取得

// [/editor/:slug] => [/editor] の場合、エディタを空にして表示する。
// beforeRouteUpdateは、パラメータが変わったタイミングでも実行される。
async beforeRouteUpdate(to, from, next) {
  await store.dispatch(ARTICLE_RESET_STATE);
  return next();
},
// [/editor] の場合、下記のifがfalseになり、実行されない。
// [/editor/:slug] の場合、下記のifがtrueになり、実行される。 
async beforeRouteEnter(to, from, next) {
  await store.dispatch(ARTICLE_RESET_STATE);
  if (to.params.slug !== undefined) {
    await store.dispatch(
      FETCH_ARTICLE,
      to.params.slug,
      to.params.previousArticle
    );
  }
  return next();
},
// [/editor/:slug] から去るときエディタを空にする。
async beforeRouteLeave(to, from, next) {
  await store.dispatch(ARTICLE_RESET_STATE);
  next();
},

ルートが変更したら実行されるナビゲーションガードで制御する。ARTICLE_RESET_STATEで記事エディタを空に更新するが、/editor/slugのような場合は、元記事が記載されたまま表示する。

※ ちなみに上記のナビゲーションガードは、パラメータが変わったタイミングでは実行されないので、 /editor から/editor/:slugに遷移した場合、元記事が取得できない。

参考:https://tsudoi.org/weblog/5738/

参考:https://router.vuejs.org/ja/guide/advanced/navigation-guards.html

タグ登録処理

<template>
  ... 
  <input
     type="text"
     class="form-control"
     placeholder="Enter tags"
     v-model="tagInput"
     @keypress.enter.prevent="addTag(tagInput)"
  />
  <div class="tag-list">
    <span
      class="tag-default tag-pill"
      v-for="(tag, index) of article.tagList"
      :key="tag + index"
    >
      <i class="ion-close-round" @click="removeTag(tag)"> </i>
      {{ tag }}
    </span>
  </div>
  ...
</template>

@keypress.enter.prevent="addTag(tagInput)"のように、Enter押下時にタグ登録処理を行う実装となっている。

📖 /components

🔖/ components / TheHeader.vue

ヘッダを表示するコンポーネント。

認証状態に応じた画面表示

<ul v-if="!isAuthenticated" class="nav navbar-nav pull-xs-right">
  ....    
</ul>
<ul v-else class="nav navbar-nav pull-xs-right">
  ....
</ul>

認証状態によって表示する画面をv-ifで切り替えている。

router-linkのパラメータ指定

<router-link
  class="nav-link"
  active-class="active"
  exact
  :to="{
    name: 'profile',
    params: { username: currentUser.username }
   }"
>
  {{ currentUser.username }}
</router-link>

<router-link>では:to="{params:{...}}でパラメータを指定することができる。

🔖 / components / VTag.vue

タグを表示するコンポーネント。

v-text を用いた画面出力

<router-link :to="homeRoute" :class="className" v-text="name"></router-link>

v-text="name"{{ name }}と同じ

propsの型指定

export default { 
  props: {
    name: {
      type: String,
      required: true
    },
    className: {
      type: String,
      default: "tag-pill tag-default"
    }
  }
  // ...
}

propsでは、型の指定(type)、必須項目指定(required)、デフォルト値(default)などプロパティの型を指定できる。以降で説明するコンポーネントのpropsにおいても、型の指定を必ず行っていた。

🔖 / components / ArticleMeta.vue 周辺

  • ArticleMeta.vue

記事作成者の情報などで構成されるコンポーネント。

フィルタの使用

<span class="date">{{ article.createdAt | date }}</span>

main.jsで定義したフィルターが使用されている。

子コンポーネントでの制御

<rwv-article-actions
  v-if="actions"
  :article="article"
  :canModify="isCurrentUser()"
></rwv-article-actions>

フォロー、いいねボタンはArticleActionsコンポーネントで制御している。

  • ArtcleActions.vue

フォローやいいねボタンを構成するコンポーネント

ユーザ状態に応じた画面表示

<span v-if="canModify">
    <!-- Edit Article, DeleteArticleボタンが表示 -->
</span>
<span v-else>
    <!-- Follow, Favoriteボタンが表示 -->
</span>

ユーザが記事の作成者か否かで表示画面を変えている。

また、classを変えるなどの見た目の変更はcomputed、 ボタン押下時の処理はmethodsで行う。

🔖 / components / CommentEditor.vue 周辺

  • CommentEditor.vue

記事に対するコメントフォームを構成するコンポーネント。

フォームの実装

<RwvListErrors :errors="errors" />
<form class="card comment-form" @submit.prevent="onSubmit(slug, comment)">
methods: {
  onSubmit(slug, comment) {
    this.$store
      .dispatch(COMMENT_CREATE, { slug, comment })
      .then(() => {
         this.comment = null;
         this.errors = {};
      })
      .catch(({ response }) => {
        this.errors = response.data.errors;
      });
  }
}

フォームのPostボタンを押下すると、COMMENT_CREATEが実行される。この処理が失敗すると、RwvListErrors部分でエラーメッセージが表示される。

  • ListError.vue

errorsオブジェクトを受け取った時に、エラーメッセージを画面に表示するコンポーネント。

🔖 / components / Comment.vue

コメント表示のコンポーネント。

削除イベント

<span v-if="isCurrentUser" class="mod-options">
  <i class="ion-trash-a" @click="destroy(slug, comment.id)"></i>
</span>
computed: {
  isCurrentUser() {
    if (this.currentUser.username && this.comment.author.username) {
      return this.comment.author.username === this.currentUser.username;
    }
    return false;
  },
  // ...
}

@click="destroy(slug, comment.id)"はクリックイベントで、コメントを削除する処理。isCurretntUserは現在のユーザと著者が同じかどうかのフラグで、同じ場合はこの削除ボタンを表示する。

🔖 / components / ListErrors.vue

errorsオブジェクトを受け取った時に、エラーメッセージを画面に表示するコンポーネント。CommentEditor.vueでも使用している。

🔖 / components / ArticleList.vue

記事プレビュー一覧を表示するコンポーネント。

大まかな処理の流れは、listConfigに記事情報を持たせて、currentpagetypeauthortagfavoritedwatchで監視し、変更があったらfetchArticle()で新しい記事の取得。である。

🔖 / components / VArticlePreview.vue 周辺

  • VArticlePreview.vue

記事プレビューを表示するコンポーネント。

ArticleMeta.vueTagList.vueコンポーネントを使用している。

  • TagList.vue

タグ一覧を表示するコンポーネント。

🔖 / components / VPagination.vue

ページ割りの表示をするコンポーネント。

クラスのバインディング

<li
  v-for="page in pages"
  :data-test="`page-link-${page}`"
  :key="page"
  :class="paginationClass(page)"
  @click.prevent="changePage(page)"
>
  <a class="page-link" href v-text="page" />
</li>

:class="paginationClass(page)"で現在のページと一致していたらactive-classをつけ、表示デザインを変える。

$emitを使用した親コンポーネントへのイベント通知

ページ割りのボタンが押下されたら、@click.prevent="changePage(page)"を実行する。

changePage(goToPage) {
  if (goToPage === this.currentPage) return;
  this.$emit("update:currentPage", goToPage);
},

親コンポーネント(ArticleList.vue)では、以下のようにコンポーネントを使用している。

 <VPagination :pages="pages" :currentPage.sync="currentPage" />

.sync修飾子をつけることで、子コンポーネントからthis.$emit('update:currentPage', goToPage) により親に通知することができる。(親は update:prop名のイベントを監視)
参照1【Vue】知っておきたい .sync修飾子のすゝめ参考2_公式

📖 /store

🔖 / store / index.js

import Vue from "vue";
import Vuex from "vuex";

import home from "./home.module";
import auth from "./auth.module";
import article from "./article.module";
import profile from "./profile.module";

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    home,
    auth,
    article,
    profile
  }
});

storeの中が膨大になるのを防ぐため、複数のファイルで管理する。分割のためにmodulesを使用する。

🔖 / store / .modules.js

全体構成

.modules.jsは共通して以下の4つをexportしている。参考:Vuex公式

export default {
  state,       // 状態管理利を行う対象
  actions,      // 非同期処理を行う。mutationsを呼ぶ。
  mutations,    // stateの状態を変更する
  getters       // stateを取得する
};
  • auth.modules.js

ファイルの分割

import ApiService from "@/common/api.service";
import JwtService from "@/common/jwt.service";
import {
  LOGIN,
  LOGOUT,
  REGISTER,
  CHECK_AUTH,
  UPDATE_USER
} from "./actions.type";
import { SET_AUTH, PURGE_AUTH, SET_ERROR } from "./mutations.type";

APIおよびJWTの処理は共通処理として切り出している。また、actionmutationで使用する関数は、.typeとして別ファイルで定義している。

Boolean型へのキャスト

const state = {
  errors: null,
  user: {},
  isAuthenticated: !!JwtService.getToken()
};

isAuthenticated: !!JwtService.getToken()!!は二重否定を行い、Boolean型にキャストする処理。

getters

const getters = {
  currentUser(state) {
    return state.user;
  },
  isAuthenticated(state) {
    return state.isAuthenticated;
  }
};

ユーザ状態と認証状態を返す。

actions, mutations

const actions = {
  [LOGIN](context, credentials) {
    return new Promise(resolve => {
      ApiService.post("users/login", { user: credentials })
        .then(({ data }) => {
          context.commit(SET_AUTH, data.user);
          resolve(data);
        })
        .catch(({ response }) => {
          context.commit(SET_ERROR, response.data.errors);
        });
    });
  },
  [LOGOUT](context) {
    context.commit(PURGE_AUTH);
  },
  // ...
}

const mutations = {
  [SET_ERROR](state, error) {
    state.errors = error;
  },
  [SET_AUTH](state, user) {
    state.isAuthenticated = true;
    state.user = user;
    state.errors = {};
    JwtService.saveToken(state.user.token);
  },
  [PURGE_AUTH](state) {
    state.isAuthenticated = false;
    state.user = {};
    state.errors = {};
    JwtService.destroyToken();
  }
};

APIを呼び、成功したらSET_AUTH、失敗したらSET_ERROR、ログアウト時はPURGE_AUTHと、mutationにコミットする。

📖 /common

🔖 / common / .service.js

  • api.service.js

axios, vue-axiosの使用

import Vue from "vue";
import axios from "axios";
import VueAxios from "vue-axios";
import JwtService from "@/common/jwt.service";
import { API_URL } from "@/common/config";

axiosvue-axiosを使用している。参考 vue-axios

トークンの処理、ベースURLの管理は別ファイルに分離している。

処理の分割

const ApiService = {
  init() {/**/},
  setHeader() {/**/},  

  // http method
  query(resource, params) {/**/},
  get(resource, slug = "") {/**/},
  post(resource, params) {/**/},
  update(resource, slug, params) {/**/},
  put(resource, params) {/**/},
  delete(resource) {/**/}
};

export default ApiService;

export const TagsService = {
  get() {
    return ApiService.get("tags");
  }
};

export const ArticlesService = {
  // ...
};

export const CommentsService = {
  // ...
};

export const FavoriteService = {
  // ...
};

ファイルの中でも処理の分割を行っている。ApiServiceにて「初期化、ヘッダの設定、HTTP通信の基本処理」がまとまっている。それらを用いてTagServiceArticlesServiceなど、具体的な処理を実装していることがわかる。

  • jwt.service.js

ローカルストレージを用いたトークン管理

const ID_TOKEN_KEY = "id_token";

export const getToken = () => {
  return window.localStorage.getItem(ID_TOKEN_KEY);
};

export const saveToken = token => {
  window.localStorage.setItem(ID_TOKEN_KEY, token);
};

export const destroyToken = () => {
  window.localStorage.removeItem(ID_TOKEN_KEY);
};

export default { getToken, saveToken, destroyToken };

ローカルストレージでトークンを管理している。

※ ローカルストレージでトークンを管理するのはセキュリティ上よくないと聞いたことがあるが、どうなのだろうか?要調査

参考 MDN localStrage

🔖 / common / .filter.js

main.jsにてVue.filter(xxx)で使用している。

関数で定義され、フィルターをかますと、戻り値の値に変換される。

参考 : https://jp.vuejs.org/v2/guide/filters.html

所感

Vue.jsに触れてこのリポジトリを見つけたときからいつかは理解したいと思っていたので、一通り理解できた???のでよかったです。

記事を見直すと、トップダウンで書いてきたので、後から出てくる機能が前で使用されていてわかりにくいと感じました。記載の工夫が必要と感じました。ここまで書いたので、これからも定期的に読み直して、不足分は追記していこうと思います。

コードリーディングした感想は、

  • プログラミングと同じくらい、構成の設計は大切
    • 特に、処理は小さく分割する、機能をまとめることが重要
  • リーディングを行う際、全体のファイルの関係性を整理すると、頭に入りやすい
  • リポジトリを読むときは、Issuesを見るとヒントが隠れている

実はこれはVue2のプロジェクトで、数年間更新がされていないリポジトリです。今後は、学んだことを自分のアプリに適用すること。それと並行して、Vue3のReal World example appのリーディングに取り組んでいきたいです。

16
12
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
16
12