【Vue.js / Nuxt.js】 .sync修飾子で状態の更新を簡単に行う

今更感が溢れる内容ではありますが、最近初めて .sync修飾子 を使ってみて、コンポーネント/page がすっきりしたので、まとめてみます。

検証環境
  • nuxt: 2.15.3
  • @nuxtjs/composition-api: 0.22.3
  • @nuxt/typescript-build: 2.1.0
  • element-ui: 2.15.1

.sync修飾子とは

jp.vuejs.org

親pageや親コンポーネントで用意した状態を、子からイベント経由で更新したい場合に簡単に実装ができるシンタックスシュガーです。

そもそも子から親の状態は直接更新できない

プロパティ — Vue.js

単方向のデータフロー 全てのプロパティは、子プロパティと親プロパティの間に 単方向のバインディング を形成します: 親のプロパティが更新されると子へと流れ落ちていきますが、逆向きにデータが流れることはありません。これによって、子コンポーネントが誤って親の状態を変更すること(アプリのデータフローを理解しづらくすることがあります)を防ぎます。

vueの基本ですが、単方向のデータフロー制約があるため、親から渡ってきた状態を子が直接更新することはできません。子側無理やり更新すると以下のコンソールエラーで怒られます。

[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: "formData"

found in...

じゃあ更新するためにどうするかと言うと、emitによるカスタムイベントを発火させて親に通知し、親で更新してもらう必要があるわけですね。

<template>
    <el-input
      :value="props.formData"
      @input="updateFormData"
    ></el-input>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    formData: {
      type: String,
      required: true,
      default: '',
    },
  },

  setup(props, { emit }) {
    const updateFormData = (input: string) => {
      emit('updateFormData', input) // 親に通知
    }

    return {
      props,
      updateFormData,
    }
  },
})
</script>

<style lang="scss" scoped></style>
<template>
    <FormPlaySync
      :form-data="state.formData"
      @updateFormData="updateFormData"
    />
</template>

<script lang="ts">
import { defineComponent, reactive } from '@nuxtjs/composition-api'
import FormPlaySync from '~/components/FormPlaySync.vue'

export default defineComponent({
  components: {
    FormPlaySync,
  },

  setup() {
    const state = reactive({
      formData: '',
    })

    const updateFormData = (input: string) => {
      state.formData = input
    }

    return {
      state,
      updateFormData,
    }
  },
})
</script>

<style lang="scss" scoped></style>

普段Vueで開発されている方からするとお馴染みかもしれません。

ですがこれだと、子から渡ってきた値をそのまま親の状態に反映させるだけのメソッドが親に生やされ、少し冗長にも思えます。親のコード量によっては見通しが悪くなってしまうかもしれません。

そこで使用したいのが .sync修飾子です。

.sync修飾子を使って書き換えてみる

親では子にバインドしたい状態に.syncを加え、子ではイベント名をupdate:props名に変更します。こうすることで、親側で代入処理を明記することなく自動的に状態を更新してくれます。

<template>
  <div class="FormPlaySync">
    <h2 class="title">FormPlaySync</h2>
    <el-input :value="props.formData" @input="updateFormData"></el-input>
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    formData: {
      type: String,
      required: true,
      default: '',
    },
  },

  setup(props, { emit }) {
    const updateFormData = (input: string) => {
      emit('update:formData', input) // イベント名を 'update:props名' に変更
    }

    return {
      props,
      updateFormData,
    }
  },
})
</script>

<style lang="scss" scoped></style>
<template>
  <div class="FormPlayGroundPage">
    <h1 class="title">FormPlayGroundPage</h1>
    <FormPlaySync :form-data.sync="state.formData" /> // .syncを追加、イベント監視を削除
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from '@nuxtjs/composition-api'
import FormPlaySync from '~/components/FormPlaySync.vue'

export default defineComponent({
  components: {
    FormPlaySync,
  },

  setup() {
    const state = reactive({
      formData: '',
    })
    // 状態を更新するメソッドを削除
    return {
      state,
    }
  },
})
</script>

<style lang="scss" scoped></style>

親側がスッキリしました!

propsがオブジェクトの場合は以下でできます。

<template>
  <div class="FormPlaySync">
    <h2 class="title">FormPlaySync</h2>
    <p>オブジェクト</p>
    <el-input
      :value="props.piyo"
      @input="updateFormDataObject('piyo', $event)"
    />
    <el-input
      :value="props.fuga"
      @input="updateFormDataObject('fuga', $event)"
    />
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    piyo: {
      type: String,
      required: true,
      default: '',
    },
    fuga: {
      type: String,
      required: true,
      default: '',
    },
  },

  setup(props, { emit }) {
    const updateFormDataObject = (key: string, value: string) => {
      emit(`update:${key}`, value)
    }

    return {
      props,
      updateFormDataObject,
      updateFormDataPrimitive,
    }
  },
})
</script>

<template>
  <div class="FormPlayGroundPage">
    <h1 class="title">FormPlayGroundPage</h1>
    <FormPlaySync v-bind.sync="state.formData" /> // v-bind.syncに変更
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from '@nuxtjs/composition-api'
import FormPlaySync from '~/components/FormPlaySync.vue'

export default defineComponent({
  components: {
    FormPlaySync,
  },

  setup() {
    const state = reactive({
      formData: {
        piyo: '',
        fuga: '',
      },
    })

    return {
      state,
    }
  },
})
</script>

子のpropsでオブジェクトのキーをそれぞれ定義し、inputイベントでkeyとvalueをパラメーターとして送ってあげて、emit(update:${key}, value)で更新ができます。

v-modelでもできる

ちなみに親側で、v-bindではなくv-modelを使用して、子のイベントを直接検知することも可能です。 しかし、Vue2だと子で受け取るprops名はvalueにしなければいけない、emit時のイベント名はinputにしなければいけない、などの制約があるようで、少し使いにくいです。

jp.vuejs.org

ただ、Vue3だと上記制約が解消され、.sync修飾子は削除、v-modelの使用に統一されているようです。

v3.ja.vuejs.org

Composition apiにおける.sync修飾子やv-model

個人的にですが、Composition Functionにモデルとそのロジックをまとめたい場合は .sync修飾子を使わず、子から親にイベント通知後、Composition Function内にあるメソッドを発火してモデルを更新するように実装しています。

Composition apiを使用したモデル層の切り出しにおいては、モデルに関するロジックは1箇所にまとめておくことで、よりComposition apiの良さを引き出せるのではないかと考えているためです。


終わりに

親子間の通信がある場合、かつComposition Functionを使用しない場面において、.sync修飾子はコンポーネントの見通しを良い状態に保ついい手段なのではないかと思いました。子側で算出プロパティのgetterとsetterを利用する方法もありますが、.sync修飾子の方が簡潔な気がします。

今後も機会があれば使っていこうかと思います。

参考