Edited at

Vue.js+Vuexでasync/awaitを使ったPresentational and Container Component Pattern(サンプルつき)


はじめに

フロントエンド設計において、コンポーネントをどのように分割するかは、様々な方法があります。

Presentational and Container Component Patternは、そのなかでも紹介されることが多いパターンですが、実際の案件を想定したサンプルがなかなか見つからなかったため、今回作成しました。

実例として、

会員登録を例に


  • 入力画面でフォームの値を取得する

  • 確認画面で、入力した内容を表示する

  • 確認画面でボタンを押すと、


    • APIへリクエストするためのトークンを取得する

    • 取得したトークンと入力内容をマージして、会員登録APIへリクエストする



  • 処理が完了したら、完了画面を表示する

という状況を想定しています。

非同期処理が合わさった際に、どのようなコードになるかイメージしていただけるよう、json-serverを使用して、ダミーAPIにリクエストを送っています。


サンプルについて

記事では多くのコードを割愛しているため、コード全体はこちらからご覧ください。

https://github.com/shibe23/sandbox-vue-pccp

ローカルで確認をする場合は、下記コマンドを実行してください。

npm install

npm run demo


動作確認環境

vue-cli:3.5.2

node.js : 8.11.4

npm: 5.6.0


Presentational and Container Component Patternって?

フロントエンドのデザインパターンの1つで、

コンポーネントを


  • データの整形と受け渡しだけを行うコンポーネント(Container Component)

  • データを受け取って表示するだけのコンポーネント(Presentational Component)

に分けることで、コンポーネント間のデータ関連の処理と、表示処理を疎結合に保とうというものです

参考:

https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0


Container Component


  • Presentational Componentにデータを渡す役割を持つ

  • Presentational Componentからイベントの通知を受け取った場合、対応するメソッドが呼び出される


Presentational Component


  • 状態を持たず、HTMLやCSSのような見た目を担当するコンポーネント

  • ボタンクリックなどのイベントが発生した場合は、イベントがあったことを、Container Componentに通知する


実装例


サンプル用API


db.json

{

"token": {
"RequestToken": "123456ABCDEFG"
},
"user": {
"Success": true
}
.....
]
}
}

ダミーAPIのため、すべてGETで処理をしていますが、


  1. token APIへリクエスト

  2. レスポンスからトークンを取得して、入力フォームの値と合わせたパラメータを作成

  3. 2をuser APIへPOSTして会員登録

という流れを想定して作成しました。


Container Component


Home.vue

<template>

<div class="section">
<div class="columns is-centered">
<div class="column is-6">
<user1 v-if="isInput" @update="updateUserInfo" />
<user2
v-if="isConfirm"
:request="request"
@register="registerUserInfo"
/>
<user3 v-if="isResister" />
</div>
</div>
</div>
</template>

<script>
import user1 from "@/components/user1.vue";
import user2 from "@/components/user2.vue";
import user3 from "@/components/user3.vue";

export default {
name: "home",
components: {
user1,
user2,
user3
},
data: function() {
return {
request: {},
status: "input"
};
},
computed: {
isInput() {
return this.status === "input";
},
isConfirm() {
return this.status === "confirm";
},
isResister() {
return this.status === "register";
}
},
methods: {
updateUserInfo(data) {
this.request = data;
this.status = "confirm";
},
async registerUserInfo() {
await this.$store.dispatch("user/POST_USER", this.request);
this.status = "register";
}
}
};
</script>

<style scoped>
.inner {
margin: 2em 0;
}
</style>


Container Componentには、Presentational Componentにpropsで受け渡すためのデータと、emitを受け取ったときに実行するメソッドが書かれています。

各コンポーネントの表示は、入力 → 確認 → 完了をthis.statusを変更することで切り替えています。

入力画面で入力されたデータは、確認画面で使用しますが、Storeに格納して参照させるのではなく、dataに格納し、propsで渡しています。

こうすることで、リロードやほかのページ遷移によって、保持している入力データが破棄されるため、氏名のような個人情報を入力するページでは、データをどのタイミングで破棄するか意識する必要がなくなります。


Presentational Component


user1.vue

<template>

<div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">E-mail</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
class="input"
type="email"
name="email"
maxlength="20"
v-model="input.email"
/>
</p>
</div>
</div>
</div>

<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Name</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
class="input"
type="text"
name="name"
maxlength="20"
v-model="input.name"
/>
</p>
</div>
</div>
</div>

<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Password</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
class="input"
type="password"
name="password"
maxlength="20"
v-model="input.password"
/>
</p>
</div>
</div>
</div>

<a class="button is-primary is-outlined" @click="update">Confirm</a>
</div>
</template>

<script>
export default {
data: function() {
return {
input: {
email: "",
name: "",
password: ""
}
};
},

methods: {
update() {
this.$emit("update", this.input);
}
}
};
</script>


Presentational Componentでは、HTMLと、ボタンなどによって実行されるemitの処理が書かれています。

入力データはv-modelで一時的にdata内に格納し、update()メソッドでContainer Componentに渡しています。

このように、Presentational Componentの中でデータ処理は、


  • $emitで親コンポーネントにupdateイベントを伝達する

  • 入力データを親コンポーネントに渡す

のみを行います。


メリット・デメリット

メリットとしては、


  • 入力 → 確認へ遷移するとき、Container側でv-modelのデータを管理することで、画面遷移のためにStoreにデータを格納する必要がない

  • 完了画面に遷移したときに、入力したデータを破棄したい場合も、容易にコントロールできる

各画面を別のURLとして扱った場合は、CSRF対策や、完了画面でリロードをした場合に2重に登録処理が走らないよう、確認画面で登録用のAPIにアクセスするためのトークンを発行し、完了画面では、そのトークンを使って登録処理をする必要があります。

通常、Storeはサイト全体で値を共有するため、localStorageなどにデータを保存している場合が多いと思います。

Presentational and Container Component Patternの場合、別の画面に値を引き渡すためだけに、Storeにデータを格納する必要が無いため、入力データや破棄タイミング等を意識せずに済みます。

デメリットとしては、


  • URLが切り替わらないため、すべて同じページとして扱われる

  • 従来のwebアプリとは異なる挙動になるため、計測タグなどの外部ツールの導入がしづらい可能性がある

そのほか、Containerをまたいで、Storeの情報を保持する必要がある場合や、Containerの状態が、ほかのContainerで使用されているmodulesのデータに依存する場合は、データの依存関係が複雑になりやすいため、注意が必要です。


まとめ

Presentational and Container Component Patternを使うことで、見た目と処理が分離され、見通しがよくなりました。

使いどころは検討する必要がありますが、見通しがよくなる以外にもメリットがあるため、導入する価値はあると思います。