Y.Hoshiをフォローする

【Vue3】Vue.jsのデータフローおさらい!

フロントエンド

最近社内ではフロントエンドではVue.jsでの開発がデフォルトになってきています。
Vue.jsではコンポーネント間のデータ受け渡しで「props / emit」を使用していますが、慣れないうちはいまいちデータフローがつかめず苦戦するんですよね(※今もですが。)

また、Vue3では「provide / inject」という新たな選択肢も登場し、ますます混乱を極めている方もいるかもしれません。

今回は、Vue.jsでのデータフローに焦点を絞って記事を書いていきます。
ひとつひとつの機能を把握するのはたいへんなので、まずはざっくりとした流れから見ていきましょう。

Vue.jsのデータフロー3選

Vue.jsでのデータ管理は、基本的に以下の3つがベースとなります。

  • props / emit 
  • vuex 
  • provide / inject (vue3での新機能)

どれを使っても大体同じ結果は得られると思うのですが、データへのアプローチの仕方が変わってきます。
それぞれ長所短所があるので、ひとつずつ見ていきましょう。

props / emit

Vue.jsにおける古き良きデータ管理方法が、props / emitによるデータの受け渡しです。

この方法では、「親 → 子」へのデータ渡し(props)と、「子→親」へのデータ更新要請(emit)という処理を各コンポーネントごとにおこなって、データの変更を連鎖的に伝播させていきます。俗にいう「バケツリレー」というやつですね。

props

ue2以前は、データの受け渡しは親子関係にあるコンポーネント同士でしかできず、かつ親から子へデータを渡すという一方通行的なアプローチしかありませんでした。
その際に利用するのが「props」というプロパティです。

親コンポーネントから子コンポーネントへデータを渡す際には、データを受け取る側でpropsプロパティに受け取りたいデータを指定することで、上位コンポーネントからのデータを受け取ることができます。

また、propsには、受け取る際の「データ型・初期値・バリデーション」などを登録できます。

...
props: {
  data: {
    type: String,  // データの型指定
    default: () => {
      return 'hoge' // データのデフォルト値
    },
    validator: val => {
      return ['hoge', 'fuga'].indexOf(val) !== -1 // データのバリデーション
    }
}
...

OptionAPIでの記法では「data」プロパティにコンポーネントで管理する変数などを格納していますが、データを受け取ったコンポーネントではpropsの変数にもdataと同様にアクセスすることができます。

ただし、Vue.jsでは下位のコンポーネントから上位のコンポーネントを直接変更することを推奨していません。更新しようとするとこのような警告が出るはずです。

error Unexpected mutation of “data” prop

emit

では、子から親のデータを変更したい場合はどうするのかというと、子から親に「データ変更してください!」とお願いをする形をとります。

具体的には、「下位コンポーネントでカスタムイベントを発火させることでデータの変更を上位コンポーネントに通知し、その際に変更したいデータも一緒に渡して上位コンポーネント側でデータを変更してもらう」という流れとなります。

その際に使用するのが「emit」です。
emitはvue2とvue3で微妙に書き方が変わっているのですが、やりたいことは同じです。

emitの第一引数がカスタムイベントの名前、第二引数(省略可)が渡したいデータ本体となります。

// vue2 
this.$emit('custom-event', data) 

// vue3 
setup(_, context) {
  context.emit('custom-event', data)
}

子コンポーネントでemitされたカスタムイベントは、親コンポーネント側でキャッチすることができます。

...
<!-- 子コンポーネントのカスタムイベントと対応する関数を登録する -->
<ChildComponent @custom-event="updateEvent" />
...

<script>
...
updateEvent(data) {
  this.data = data
}
...
</script>

props / emitの問題点

props / emitによるデータ受け渡しとイベントの伝播というのはVue.jsにおけるもっとも基本的なアプローチですが、ひとつ大きな問題があります。

それは、「コンポーネントの親子関係が深くなると冗長なコードが増える」ということです。端的に言うと、めんどくさいということです。

例えば、親コンポーネントがあり、そこから子コンポーネント・孫コンポーネント・ひ孫コンポーネントまでデータを受け渡したい、という場合を想定しましょう。

この場合、最上位コンポーネントから最下位コンポーネントまで3度のpropsによるデータ渡しと、最下位コンポーネントから最上位コンポーネントまで3度のカスタムイベント発火が必要となります。

小規模なアプリケーションであればこれでもよいのですが、規模が大きくなり、コンポーネントの親子関係が深くなる場合には、イベントを受けてデータを渡すだけの冗長なコードが量産されることになってしまいます。

provide / inject

provide / injectはvue3から追加されたプロパティ。
これまでのprops / emitによるデータ受け渡しによる冗長性を取り除くことが期待できます。

provide / injectを使ったデータ受け渡しでは、コンポーネントの親子関係をスキップしてデータの受け渡しができます。
その結果、直接「親コンポーネント => 孫コンポーネント」のようなデータの受け渡しが可能となり、props / emitによるバケツリレーが必要なくなります。

provide

上位コンポーネントから下位コンポーネントへと共有したいデータは関数は「provide」プロパティに登録することにより、各下位コンポーネントからアクセスすることが可能に。

上位コンポーネントのdataプロパティの値をprovideで指定し、下位コンポーネントで参照することもできますし、上位コンポーネントでprovideに指定したmethodsを、下位コンポーネントから実行することも可能です。

// components/Parent.vue
...
data() {
  return {
    provideData: null
  }
},
provide() {
  return {
    providedData: this.provideData,
    providedMethod: this.method
  }
},
methods: {
  method() {
    console.log('this is a provided method')
  }
}

inject

上位コンポーネントで提供されるprovideの値は、下位コンポーネントで「inject」することにより参照と実行が可能です。

// components/Child.vue
<template>
  <input @input="providedMethod" />
</template>
...
inject: ['providedData', 'providedMethod']
...

provide / injectの問題点

一見すると、props / emitによるバケツリレーからvuejsエンジニアを開放してくれる救世主のようにも見えるprovide / injectですが、必ずしもいいことばかりではありません。

コンポーネントの親子関係をスキップしてデータの受け渡し・関数の実行ができるということで、処理の流れが追いにくくなるというのが問題点のひとつです。
つまり、上位コンポーネントでprovideしているデータは、下位コンポーネントのいずれからでも参照できるため、バケツリレーほど処理の流れが明確ではありません。

そのため、provide / injectによるデータ受け渡しを乱用すると、ソースの可読性が低くなる可能性があります。
使いどころは慎重に選ぶ必要がありそうです。

vuex

「Vuex」は、Vue.jsにおけるprops / emitによるデータ管理のわずらわしさを解消してくれるライブラリです。

コンポーネントの親子関係を無視して、「共通のデータ管理領域(ストア)」でデータの更新や受け渡しを完結させることができるため、アプリケーションのどこからでもデータにアクセスできることが大きなメリットです。

また、Vuexを使用すると、コンポーネントをまたいでデータの更新・参照ができるのもポイント。
props / emit、provide / injectはいずれも親子関係にあるコンポーネントでしかデータの受け渡しができませんでしたが、vuexを使えば、異なるvueインスタンス同士でもコミュニケーションをとることができます。

上図はvuexによるステート管理を示したものです。props / emitでのデータ管理とは異なり、「ストア」というグローバルにアクセスできる管理領域を設けることで、データ更新処理の呼び出し・データの更新・更新したデータの取得というロジックを一カ所にまとめることができます。また、vuexを使わない場合、各コンポーネントに同じようなデータ更新処理が分散してしまうことがあるかもしれません。その点では、データ管理を一元化することによって重複コードを減らすことができるのも、vuexの利点ということができるでしょう。以下は、vuexのストアで管理する各構成要素です。

state

ストアで管理するデータ本体。

mutations内の処理で更新し、gettersをとおして各コンポーネントから参照することができます。

// store/index.js
state() {
  return {
    user: {
      name: 'vuex-tarou',
      age: 20,
      job: 'vuex'
    }
  }
}

getters

stateで管理されているデータを参照するための算出プロパティを記述する場所です。

各コンポーネントからstateの値を参照する場合は、このgettersから値を参照します。

// store/index.js
...
getters: {
 getUserName: (state) => {
    return state.user.name
 } 
}

******************************************

// components/User.vue
// namespaceを使用しない書き方
this.$store.getters.getUserName
// namespaceを利用した書き方
this.$store.getters.['{namespace}/getUserName']

gettersは最大4つまで引数をとることができ、各引数は下記に対応しています。
第一引数 (呼び出したモジュールの)state
第二引数 (呼び出したモジュールの)getters
第三引数 (vuex全体の)state
第四引数 (vuex全体の)getters
開発規模が大きくなり、ほかのモジュールのstateやgettersを参照する機会もあるかもしれませんが、そういう時に役立つかもしれません。

...
getters: {
  getGlobalData: (state, getters, rootState, rootGetters) {
    console.log(state)
    console.log(getters)
    console.log(rootState)
    console.log(rootGetters)
    return state.user.name
  }
}

actions & mutations

各コンポーネントからvuexにアクセスする際の窓口となるのが「actions」です。

actionsは任意の非同期処理を取り扱う領域。mutationsやgettersでは非同期処理を扱うことはありません。
また、actionsではstateの状態を変更することはなく「ミューテーションをコミットする」という役割が与えられています。

「mutations」では、actionsからcommitされてきたデータを使い、「stateを変更(mutate)する」という役割があります。

※なお、actionsからmutationsへcommitする際に使用する「context」はvuexのインスタンスの各種プロパティを保持しています。
そのため、下記のようなコードでactionsからstateを直接変更することも可能ですが、actionsの役割を逸脱する行為なのでやめましょう。

actions: {
  illigalMutation(context) {
    context.state.user.age = 100
  }
}

stateの更新処理は、必ず以下の流れで行います。
「➀componentからのdispatch => ➁actionsからのcommit => ➂mutationsでのstateの更新」

// usersリソースの値を更新するapiを送信し、そのレスポンスでstateを更新する処理
// components/User.vue
...
methods: {
  updateUserData(data) {
    this.$store.dispatch('updateUserData', data) // ➀actionsへのdispatch
  }
}
...

******************************************

// store/index.js
...
actions: {
  async updateUserData(context, payload) {
    await userUpdateApi(payload) // 非同期処理の実行
      .then(res => {
        context.commit('updateUserData', res.data) // ➁非同期処理のレスポンスをコミット
      })
      .catch(err => {
        errHandlerFunc(err)
      })
  }
}
...
mutations: {
  updateUserData(state, payload) {
    state.user.name = payload.name // ➂stateの更新
  }
}

おまけ: イベントバスによるデータフロー

イベントバスとは

システム・プログラミングの分野などでは、データの伝達構造を「バス」と呼ぶことがあります。(らしい)

「イベントバス」は、データの受け渡しをするバスを用意しておき、カスタムイベントを発火させることで、イベントの通知とデータの伝播を行うというものです。

イベントバスを使えば、vuexと同様に親子関係を無視したデータアクセスができ、異なるvueインスタンス間でのコミュニケーションも可能となります。

// app.js
// イベントバス用インスタンスのエクスポート
export const eventbus = new Vue()

******************************************

// componentA.vue
// カスタムイベントを実行する側(カスタムイベントの発火)
import { eventbus } from './../app.js'
...
methods: {
  update(data) {
    eventbus.$emit('custom-event', data)
  }
},
...

******************************************

// componentB.vue
// カスタムイベントを受け取る側(カスタムイベントの登録)
import { eventbus } from './../app.js'
...
mounted() {
  const self = this
  eventbus.$on('custom-event', function(data) {
    self.updateFunction(data)
  })
},
...

なお、イベントバス自体はvue.js特有の機能というわけではないので、自分で下記のようなクラスを実装するだけでも十分かもしれません。
 使い方は上の例とほぼ同じです。

class EventEmitter {
  constructor() {
    this.events = {}
  }

  addEvent(eventName, cb) {
    if (typeof this.events[eventName] === 'undefined') {
      this.events[eventName] = cb
    }
  }

  emitEvent(eventName, payload) {
    if (typeof this.events[eventName] !== 'undefined') {
      this.events[eventName](payload)
    }
  }
}