2018.05.03

Vue.jsコンポーネント入門 (5) コンポーネント間のコミュニケーション


本記事は Vue.js コンポーネント入門の第5回です。

今回は新しい機能の紹介はありません。props$emit を紹介しましたので、そのふたつの概念を整理し、実際にコンポーネントを作成して、組み合わせてアプリケーションを構築するための手がかりとなる考え方について説明します。

コンポーネントツリー

まずはコンポーネントツリーという概念を紹介します。

例えば以下のような構成のアプリケーションがあるとします。

<div id="app">
  <h1>Welcome to Vue Components!</h1>
  <component-a>
    <component-b></component-b>
    <component-c></component-c>
  </component-a>
  <component-d></component-d>
</div>

このとき、以下のコンポーネントツリーが生成されると考えられます。

root
├─ h1
├─ component-a
│  ├─ component-b
│  └─ component-c
└─ component-d

DOM ツリーと同様の考え方ですね。

何が言いたいかというと、

『コンポーネント間にも親子関係がある』

ということです。

上記の例でいうと、component-a は component-b および c の親コンポーネントです。

component-a と d は親子関係にありませんね。並列なので兄弟関係と言えるでしょう。

コンポーネント間のコミュニケーション

疎結合なコンポーネントが望ましい

まず前提として、コンポーネントはなるべく親または子のコンポーネントと疎結合な設計であることが望ましいとされます。

その理由の一つとして、再利用のしやすさがコンポーネントに対する判断基準となる点を挙げられます。基本的に特定のマークアップおよび機能(場合によってはスタイルも)を「使い回す」ためにコンポーネントとして切り出すので、言い換えると使い回すことがコンポーネントの存在意義であるわけです。既存の HTML 要素を考えてみてください。<a><img> など再利用可能な部品を組み合わせて美しい Web ページを構築することができているでしょう。

その他の理由として挙げられるのは、保守性です。上の例でいうと、component-b に変更を加えようとすると component-a を変更しなければならず、component-a を修正すると連鎖的に component-c まで修正しなければならなくなる…なんてことは避けたいですよね。そのためにはなるべく疎結合(他のコンポーネントとの関連性を制限する)に設計する必要があります。

オブジェクト指向プログラミングでも再利用性と保守性は重要なポイントとして挙げられます。オブジェクト指向もコンポーネント指向もプログラムの「部品化」を目指す点では共通しています。良い部品は他の部品となるべく疎結合であり、再利用性と保守性に優れた部品であるという考え方です。

Vue コンポーネントにおける疎結合性

Vue では、コンポーネント間でのコミュニケーションを制限することで疎結合が実現されます。

コミュニケーションと表現したのは、

  • 他のコンポーネントが持つデータの参照
  • 他のコンポーネントへのデータの受け渡し

のことです。

まず、他のコンポーネントが持つデータの参照は基本的にできません

オブジェクト指向に慣れている方には、他のクラスのprivateプロパティを参照できないのと似ている、と言うと理解しやすいでしょうか。

また、他コンポーネントへのデータの受け渡し方法は基本的に下記2パターンに限られます

  • 親から子へのデータの受け渡し → props
  • 子から親へのイベントの通知 → $emit

これ以外のデータのやり取りはないという理解で当面は問題ありません。

(「基本的に」「当面は」という表現を使っているのは、例外的あるいは発展的な方法があるからです。しかし今回の一連の記事は入門編ですからあえて紹介しませんし、本当に当面は基本ルールだけに則って開発をして問題も不足もありません。)

親子関係でのコミュニケーションを図で表現すると以下のようになるでしょう。

コンポーネントの親子関係

親から子にデータを渡したい場合は props を使用します。

逆に子から親にデータを渡したい場合は $emit によるイベント通知を用います。$emit の第2引数以降は親のメソッドであるイベントハンドラーへの引数なのでしたね。

兄弟関係にあるコンポーネント間(上記の例では component-a と d もしくは component-b と c)には直接コミュニケーションする手段はありません。

どうするかというと、共通の親コンポーネントを中継します。つまり、まず子から親にイベントを通知し、親から別の子に props を使ってデータを渡します。

コンポーネントの親子関係_2

ユースケースとしては、

画面を構成する親コンポーネント ├ 子コンポーネント:マークダウンの入力欄 └ 子コンポーネント:プレビュー画面

画面を構成する親コンポーネント ├ 子コンポーネント:検索文字列の入力欄 └ 子コンポーネント:検索結果のリスト表示

などが考えられるでしょう。

サンプルアプリケーション

コンポーネント間のコミュニケーションについての説明は以上です。ほぼ文字だけで説明してきましたので、理解の補強としてサンプルアプリを作ります。

第1回記事の通り環境構築を進めてください。

# プロジェクトディレクトリで以下のコマンドを実行してください。
$ git clone git@github.com:MasahiroHarada/vue-components-starter-kit.git chapter-5
$ cd chapter-5
$ npm install

作るもの

マークダウン形式で入力できるコメントパネルを作ってみましょう。

こんなやつです。

Github やその他のアプリケーションでもたまに見かけますよね?

「Write」タブがアクティブなときに入力欄が表示され、「Preview」タブがアクティブなときは入力欄に入力されたマークダウン文字列を HTML に変換した結果が表示される仕様です。

コンポーネントの設計

どのようなコンポーネントを作るか考えます。

まずは上掲の図のパネル全体を一つのコンポーネントとして切り出します。

次にタブで切り替わる入力欄とプレビューをそれぞれコンポーネントにします。

コンポーネントツリーは以下のようになるでしょう。

root
└─ パネル
   ├─ 入力欄
   └─ プレビュー

Getting started

見た目を整えるためにCSSフレームワーク Spectre.css を利用します。さらに少しカスタマイズを加えたいので SCSS ファイルを CSS に変換するための設定を追加したブランチを使用します。

# プロジェクトディレクトリで以下のコマンドを実行してください。
$ git clone git@github.com:MasahiroHarada/vue-components-starter-kit.git chapter-5
$ cd chapter-5
$ git checkout scss
$ npm install

次に、今回のサンプルで使用したいパッケージをインストールします。

$ npm install --save-dev marked spectre.css

スタイルシート(SCSS)

src ディレクトリの下の app.scss というファイルに以下の内容を記述してください。Spectre.css のスタイルを一部カスタマイズするための SCSS ファイルです。バンドルされた結果のスタイルは app.css というファイルとして public ディレクトリに出力されます。

src/app.scss
$primary-color: #0052cc;

@import '../node_modules/spectre.css/src/spectre.scss';

#app {
  padding: 1rem;
}

pre code {
  background: #f8f9fa;
  color: inherit;
  display: block;
  line-height: 1.5;
  overflow-x: auto;
  padding: 1rem;
  width: 100%;
}

.panel-header {
  align-items: center;
  display: flex;

  .avatar {
    margin-right: .8rem;
  }

  .panel-title {
    font-size: .825rem;
    font-weight: bold;
  }
}

.panel-body {
  margin-top: .8rem;
}

.panel-nav {
  .tab {
    padding: 0 .8rem;
  }
}

コンポーネント

さてここからが本番のコンポーネント作成です。

パネルコンポーネント

まずは全体のパネルコンポーネントを作成します。

このコンポーネントの機能的な役割は主にタブの切り替えです。activeTab というデータを使って切り替えを行います。activeTab1 なら入力欄、2 ならプレビューを表示する仕組みです。

また、content がマークダウン文字列のデータです。入力欄やプレビューの子コンポーネントではなく、パネル全体を統括するこのコンポーネントにマークダウン文字列のデータを持たせます。

src/components/MarkdownPanel
<template>
  <div class="panel">
    <div class="panel-header">
      <figure class="avatar avatar-lg">
        <img src="http://i.pravatar.cc/320" alt="">
      </figure>
      <div class="panel-title">User Name</div>
    </div>
    <div class="panel-nav">
      <ul class="tab">
        <li class="tab-item" :class="{ active: activeTab === 1 }">
          <a href="#" @click.prevent="activeTab = 1">Write</a>
        </li>
        <li class="tab-item" :class="{ active: activeTab === 2 }">
          <a href="#" @click.prevent="activeTab = 2">Preview</a>
        </li>
      </ul>
    </div>
    <div class="panel-body" v-show="activeTab === 1">
      <MarkdownEditor v-model="content" />
    </div>
    <div class="panel-body" v-show="activeTab === 2">
      <MarkdownPreview :text="content" />
    </div>
    <div class="panel-footer">
      <div class="text-right">
        <button class="btn btn-primary">Submit</button>
      </div>
    </div>
  </div>
</template>

<script>
import MarkdownEditor from './MarkdownEditor.vue'
import MarkdownPreview from './MarkdownPreview.vue'

export default {
  components: {
    MarkdownEditor,
    MarkdownPreview
  },
  data () {
    return {
      activeTab: 1,
      content: ''
    }
  }
}
</script>

サンプルなので Submit ボタンは何もしませんが、実際のユースケースを考えると以下のように親コンポーネントに content とともに送信イベントを通知するのではないかと思います。

<button class="btn btn-primary" @click="$emit('submit-markdown', content)">Submit</button>

このコンポーネント自身が content をサーバーサイドに送信することも考えられますが、それはちょっとやりすぎかなという感じですね。疎結合な部品を作るためにはコンポーネントの役割を制限する必要があるからです。サーバーサイドに送信するためには送信先の URL(バックエンド API のエンドポイントなど)を知っている必要がありますが、それだと別の文脈(見た目や機能は同じだが送信先は違う)にこのコンポーネントを配置できなくなってしまいます。

入力欄コンポーネント

次に入力欄のコンポーネントを作成します。

このコンポーネントはテキストエリアのラッパーです。

テキストエリアの入力データを親に $emit する部分が入門レベルとしては少しトリッキーでしょうか。

src/components/MarkdownEditor
<template>
  <div class="form-group">
    <textarea
      class="form-input"
      :value="text"
      @input="onInput"
      rows="10"
    ></textarea>
  </div>
</template>

<script>
export default {
  props: {
    value: { type: String, required: true }
  },
  data () {
    return { text: this.value }
  },
  methods: {
    onInput ($event) {
      this.text = $event.target.value
      this.$emit('input', this.text)
    }
  }
}
</script>

パネルコンポーネントでは、

<MarkdownEditor v-model="content" />

このように v-model の記述法を利用して入力欄コンポーネントにデータを渡し、かつ通知とともに入力データを受け取っています。

なぜなら、v-model は以下の記述をまとめたものだからです(マニュアル)。

<MarkdownEditor
  :value="content"
  @input="content = $event.target.value"
/>

それで入力欄コンポーネントでは value を props として受け取っているわけですが、この value をそのままテキストエリアの v-model にすると…

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"

props の値を直接変更しないでください!と怒られてしまいます。

こちらの説明を思い出してください。

他コンポーネントへのデータの受け渡し方法は基本的に下記2パターンに限られます

  • 親から子へのデータの受け渡し → props
  • 子から親へのイベントの通知 → $emit

もし親コンポーネントから受け取った props の値を子供が直接書き換えられてしまうと、疎結合なコンポーネント設計のための上記のルールが破られてしまいます。

そのため入力欄コンポーネントでは、textarea の v-model:value@input にバラすとともに text というデータを持たせることで、以下のようなデータの流れにしています。

MarkdownPanel
↓ content   ↑ text
↓ (:value)  ↑ (@input)
MarkdownEditor
↓ text      ↑ $event.target.value
↓ (:value)  ↑ (@input)
textarea

text というデータを中継点にしているのです。

<textarea
  class="form-input"
  :value="text"
  @input="onInput"
  rows="10"
></textarea>
data () {
  return { text: this.value }
},
methods: {
  onInput (e) {
    this.text = e.target.value
    this.$emit('input', this.text)
  }
}

少しややこしいですが、伝わりましたでしょうか。

プレビューコンポーネント

プレビューコンポーネントはシンプルですね。

marked.js というライブラリを使用してマークダウン文字列を HTML に変換します。

props である text の変更にリアルタイムで反応するために算出プロパティを用いています。

src/components/MarkdownPreview
<template>
  <div>
    <div class="empty" v-if="text === ''">
      No content yet.
    </div>
    <div v-else v-html="html"></div>
  </div>
</template>

<script>
import marked from 'marked'

export default {
  props: {
    text: { type: String, required: true }
  },
  computed: {
    html () {
      return marked(this.text)
    }
  }
}
</script>

コンポーネントまとめ

作成した3点のコンポーネントのコンポーネントツリーは以下の通りです。

root
└─ MarkdownPanel
   ├─ MarkdownEditor
   └─ MarkdownPreview

データの流れをもう一度見直してみてください。

  1. まず MarkdownPanel がマークダウン文字列のデータ(content)を管理しています。
  2. v-modelMarkdownEditorcontent を渡します。
  3. ユーザーの入力があると MarkdownEditor から MarkdownPanel にイベントとともに入力値が通知され、MarkdownPanel が管理する content が変更されます。
  4. MarkdownPanel から MarkdownPreview に props で content を渡しているため、3. でデータが変更されるとそれが MarkdownPreview にも伝わります。
  5. MarkdownPreview では算出プロパティ html でマークダウン文字列から HTML への変換を行なっているため、4. の変更が伝わるとすぐに算出プロパティが再計算され、HTML への変換が実行されます。

すべてのコンポーネントのデータが props と $emit を出入り口として繋がっている様子を把握できたでしょうか。

ちょうどこの絵の通りです。

コンポーネントの親子関係_2

慣れるまでは複雑に感じられるかもしれませんが、コンポーネント間のデータの流れは基本パターンとして props と $emit しかないので、一度理解できると自分でもコンポーネントを切り出したり組み合わせたりすることができるようになるはずです。そうなると楽しいですよ。

エントリポイント

index.js は特に難しいところはありませんね。

app.scss のインポートも記述しましょう。

src/index.js
import Vue from 'vue'

// Style
import './app.scss'

// Components
import MarkdownPanel from './components/MarkdownPanel.vue'

const app = new Vue({
  el: '#app',
  components: {
    MarkdownPanel
  }
})

HTML

最後に index.html に作成したパネルコンポーネントを配置します。

app.css の読み込みも追記しましょう。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Vue Component Tutorial</title>
  <link rel="stylesheet" href="./dist/app.css">
</head>
<body>
  <div id="app">
    <div class="container">
      <div class="columns">
        <div class="column col-6 col-md-12 col-mx-auto">
          <markdown-panel></markdown-panel>
        </div>
      </div>
    </div>
  </div>
  <script src="./dist/main.js"></script>
</body>
</html>

ここまでで完成です 🎉

もう一度完成したコードの動作例を貼っておきます。


以上、本記事では Vue.js コンポーネント入門の第5回として、コンポーネント間のコミュニケーションについて説明しました。

関連記事

Vue.js コンポーネント入門(全7回)

Vue.js 入門(全7回)