Vue3.0で導入予定のFunction API試し書きしてみた

こんにちは、橋本です。
相変わらず毎日Vueでアプリを作る日々を送っております。

さて、少し前に話題になった、Vue3.0で導入予定のFunction API をご存知でしょうか??
Vue2.0で採用されているオブジェクトベースのもの(以下、Standard API)とは異なり、setupというコンポーネントオプションを介して、コンポーネントの機能(data, computed, methods, lifecycle, ...)を実装していくという仕組み(以下、Function API)となります。

詳しくはこちらをご覧ください。
github.com

ちなみにですが、Vue2.0で採用されているStandard APIはVue3.0でも引き続き利用可能とのことです。
Standard APIとFunction APIのどちらでも好きな方を使って実装していくことになると思います。

また、Function APIについては、まだ仕様策定段階のため、この記事に書かれている情報はあくまで記事執筆時(2019/08/08)のものとなります。
Vue3.0リリース時には全く別のものになっている可能性も無きにしもあらずなので、その辺はご理解の程よろしくお願いします。

そもそもVue3.0がリリースされてないのに、試してみたもヘッタクレも無いだろうとお思いの方も多いかと思うのですが、以下のプラグインを組み込むことで、Vue2.0でもFunction APIの機能を一部使用することが可能となっています。

github.com

今回はこのプラグインを使って、試し書きしていきたいと思います。

今回作成していくサンプルについては、以下のURLから実際に動いているものを触ることができます。
挙動を確認したい場合は是非。

http://demo.asial.co.jp/~akifumi-h/vue-function-api-sample/

セットアップ

まずはvue-cliを使ってプロジェクトを作っていきます。

vue create vue-function-api-sample

次にvue-function-apiプラグインを追加します。

npm install vue-function-api --save

or

yarn add vue-function-api

最後にmain.jsにプラグインを使用するために以下のコードを追加します。

import Vue from 'vue'
import { plugin } from 'vue-function-api'
Vue.use(plugin)

これで準備は整いました。早速やっていきましょう。

基本的な使い方

Function APIはsetupというコンポーネントオプションの中で、用意されているいくつかの関数を使って、各機能のセットアップを行っていきます。

setupは第1引数にprops、第2引数にcontextを取る関数で、戻り値としてオブジェクトを返します。
戻り値のオブジェクトに含まれるプロパティ、メソッドがtemplateから参照可能となります。
templateで参照したい値が無い場合は、何も返さなくても大丈夫です。

ごちゃごちゃ説明するよりも実際のコードを見てもらったほうが手っ取り早いので、とりあえず簡単なカウンターのサンプルを作ってみました。

まずはVue2.0と同じ書き方である、Standard APIを使って書いたコードです。

<template>
  <div class="value-sample">
    <div class="count">現在のカウント: {{ count }}</div>
    <button @click="decrement">減らす</button>
    <button @click="increment">増やす</button>
  </div>
</template>
<script>
import Vue from 'vue'
export default {
  name: 'ValueSample',
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  }
}
</script>

これをFunction APIを使って書き直すとこのようになります。

<template>
  <div class="value-sample">
    <div class="count">現在のカウント: {{ count }}</div>
    <button @click="decrement">減らす</button>
    <button @click="increment">増やす</button>
  </div>
</template>
<script>
import Vue from 'vue'
import { value } from 'vue-function-api'
export default {
  name: 'ValueSample',
  setup() {
    const count = value(0)
    const decrement = () => count.value--
    const increment = () => count.value++
    return {
      count,
      decrement,
      increment
    }
  }
}
</script>

Standard APIでdataの中で定義していたリアクティブな変数は、valueという関数を使って定義しています。
Standard APIでmethodsの中で定義していたメソッドは、単純に関数を作るだけです。

それぞれtemplateの中で参照するために、setup関数の最後で、オブジェクトに含む形でリターンしています。

value関数を使うと、リアクティブにしたい変数のラッパーが返ってきます。
value関数に渡した値が変数の初期値となります。
decrement、incrementメソッドを見てもらったらわかるのですが、変数の値を変更する場合には、ラッパーのvalueプロパティを変更します。

ちなみに、templateでリアクティブな変数を使用する場合には、valueプロパティを指定する必要はありません。
自動的に値をアンラップしてくれるようです。便利ですね。

どうでしょうか?個人的にはFunction APIの方が好きなのですが、Standard APIの方が明示的にdataやmethodsを定義する分直感的で分かりやすいという人も多いと思います。

では、computedやwatch、mountedなどのlifecycleはどのように定義するのか、それぞれ簡単なサンプルと共に見ていきたいと思います。

computed

computedについては、computed関数を使って定義していきます。
まずは簡単なサンプルから。

<template>
  <div class="computed-sample">
    <label for="fullName">フルネーム:</label>
    <span>{{ fullName }}</span>
    <br />
    <label for="lastName">名字:</label>
    <input type="text" id="lastName" v-model="lastName" />
    <br />
    <label for="firstName">名前:</label>
    <input type="text" id="firstName" v-model="firstName" />
  </div>
</template>
<script>
import Vue from 'vue'
import { computed, value } from 'vue-function-api'
export default {
  name: 'ComputedSample',
  setup() {
    const firstName = value('')
    const lastName = value('')
    const fullName = computed(() => `${lastName.value} ${firstName.value}`.trim())
    return {
      firstName,
      fullName,
      lastName
    }
  }
}
</script>

computed関数の第1引数に渡した関数がcomputedな値を返す関数となります。
firstNameやlastNameの値が変更された場合、fullNameも変更されます。
これはstandard APIでcomputedを設定する場合と同じですね。

computedにセッターを指定する場合には、computed関数の第2引数にセッター関数を指定します。
こんな感じです。

<template>
  <div class="computed-sample">
    <label for="fullName">フルネーム:</label>
    <input type="text" id="fullName" v-model="fullName" />
    <br />
    <label for="lastName">名字:</label>
    <input type="text" id="lastName" v-model="lastName" />
    <br />
    <label for="firstName">名前:</label>
    <input type="text" id="firstName" v-model="firstName" />
  </div>
</template>
<script>
import Vue from 'vue'
import { computed, value } from 'vue-function-api'
export default {
  name: 'ComputedSample',
  setup() {
    const firstName = value('')
    const lastName = value('')
    const fullName = computed(
      () => `${lastName.value} ${firstName.value}`.trim(),
      newValue => {
        const [newLastName, newFirstName] = newValue.split(' ')
        firstName.value = newFirstName || ''
        lastName.value = newLastName || ''
      }
    )
    return {
      firstName,
      fullName,
      lastName
    }
  }
}
</script>

Standard APIではgetメソッドとsetメソッドを内包するオブジェクトをcomputedに設定していましたが、Fuction APIでは第2引数を指定するだけなので、シンプルで良いですね。
次はwatchを見ていきたいと思います。

watch

watchについてはwatch関数を使って定義していきます。

<template>
  <div class="watch-sample">
    <input type="text" id="firstValue" v-model="firstValue" />
  </div>
</template>
<script>
import Vue from 'vue'
import { value, watch } from 'vue-function-api'
export default {
  name: 'WatchSample',
  setup() {
    const firstValue = value('a')
    watch(firstValue, (newValue, oldValue) => {
      console.log(`firstValue: ${oldValue} => ${newValue}`)
    })
    return {
      firstValue
    }
  }
}

watch関数は第1引数に監視したい値、第2引数に値が変化した際のハンドラ、第3引数にオプションを指定します。
第1引数は、value, computed関数等で作成したリアクティブな値のラッパーや、setup関数の第1引数で得られるprops等を直接指定する他、監視したい値を返す関数、また複数の値を監視するために配列を指定したりすることも出来ます。
第2引数はStandardAPIのwatchのハンドラと同じですね。変更後の値と変更前の値が引数として渡されます。
第3引数のオプションは、Vue2.0のwatchのオプションとは少し異なっています。

Vue2.0のオプションは以下の2つです。
* deep
* immediate

Function APIのオプションは以下の5つです。
* lazy
* deep
* flush
* onTrack
* onTrigger

onTrackとonTriggerはvue-function-apiプラグインで使用出来ないため今回は飛ばします。

deepについては、Vue2.0と同じです。
lazyはimmediateの逆となっています。これは、Function APIのwatchがVue2.0とは異なり、デフォルトの状態では最初に一回実行されるためです。
lazyオプションを指定することで、最初の実行を止めることができます。Vue2.0ではデフォルトでは初回実行は発生せず、immediateを指定した場合のみ最初に実行されていたので、Function APIでは逆になったということです。
最後にflushですが、このオプションを指定することで、watchのハンドラの実行タイミングを変更することができます。
指定することができる値は、'pre','post','sync'となっており、デフォルト値はpostとなっています。
syncを指定した場合は監視対象の変更と同時に実行されます。preを指定した場合にはtemplateの更新前、postを指定した場合にはtemplateの更新後となります。

これらのオプションを指定したサンプルがこちら。
実際に実行して、どのような順番でconsole.logが実行されるか確認してみてください。

<template>
  <div class="watch-sample">
    <h3>Watch Sample</h3>
    <input type="text" id="firstValue" v-model="firstValue" />
    <input type="text" id="secondValue" v-model="secondValue" />
  </div>
</template>
<script>
import Vue from 'vue'
import {
  computed,
  onBeforeMount,
  onBeforeUpdate,
  onCreated,
  onMounted,
  onUpdated,
  value,
  watch
} from 'vue-function-api'
export default {
  name: 'WatchSample',
  setup() {
    console.log('setup')
    const firstValue = value('a')
    const secondValue = value('b')
    const computedValue = computed(() => firstValue.value + secondValue.value)
    watch(firstValue, (newValue, oldValue) => {
      console.log(`firstValue: ${oldValue} => ${newValue}`)
    })
    console.log('create watch firstValue')
    watch(
      firstValue,
      (newValue, oldValue) => {
        console.log(`firstValue | lazy: ${oldValue} => ${newValue}`)
      },
      { lazy: true }
    )
    console.log('create watch firstValue | lazy')
    watch(
      firstValue,
      (newValue, oldValue) => {
        console.log(`firstValue | pre: ${oldValue} => ${newValue}`)
      },
      { flush: 'pre' }
    )
    console.log('create watch firstValue | pre')
    watch(
      firstValue,
      (newValue, oldValue) => {
        console.log(`firstValue | post: ${oldValue} => ${newValue}`)
      },
      { flush: 'post' }
    )
    console.log('create watch firstValue | post')
    watch(
      firstValue,
      (newValue, oldValue) => {
        console.log(`firstValue | sync: ${oldValue} => ${newValue}`)
      },
      { flush: 'sync' }
    )
    console.log('create watch firstValue | sync')
    watch([firstValue, secondValue], (newValues, oldValues) => {
      console.log(`[firstValue, secondValue]: ${oldValues} => ${newValues}`)
    })
    onBeforeMount(() => console.log('before mount'))
    onBeforeUpdate(() => console.log('before update'))
    onCreated(() => console.log('created'))
    onMounted(() => console.log('mounted'))
    onUpdated(() => console.log('updated'))
    return {
      firstValue,
      secondValue,
      computedValue
    }
  }
}
</script>

lifecycle

次にライフライクルイベントのハンドラについてです。
Standard APIでは、created,beforeMount,mounted,などのメソッドを作成していました。
Function APIでは、各ライフライフサイクルに対応した関数が用意されています。

関数名は'onXXXXX'という軽視となっており、createdであれば'onCreated'、beforeMountであれば'onBeforeMount'、mountedであれば'onMounted'関数を利用していきます。
それぞれの関数は引数に関数をとり、それがライフサイクルイベントのハンドラとなります。

<template>
  <div class="lifecycle-sample">
    <FireErrorComponent />
  </div>
</template>
<script>
import Vue, { Component } from 'vue'
import {
  onActivated,
  onBeforeDestroy,
  onBeforeMount,
  onBeforeUpdate,
  onCreated,
  onDeactivated,
  onDestroyed,
  onErrorCaptured,
  onMounted,
  onUnmounted,
  onUpdated
} from 'vue-function-api'
import FireErrorComponent from '@/components/FireErrorComponent.vue'
export default {
  components: {
    FireErrorComponent
  },
  setup() {
    onActivated(() => console.log('activated'))
    onBeforeDestroy(() => console.log('before destroy'))
    onBeforeMount(() => console.log('before mount'))
    onBeforeUpdate(() => console.log('before update'))
    onCreated(() => console.log('created'))
    onDeactivated(() => console.log('deactivated'))
    onDestroyed(() => console.log('destroyed'))
    onErrorCaptured((err, vm, info) => {
      console.log('error captured')
      console.log(err, vm, info)
    })
    onMounted(() => console.log('mounted'))
    onUnmounted(() => console.log('unmounted'))
    onUpdated(() => console.log('updated'))
  }
}
</script>

onErrorCaptured関数に指定するハンドラについては、StandarAPIのerrorCapturedと同様の引数が渡ってきます。
また、各ハンドラ内でthisを通じてコンポーネントの値を使用したい場合には、ハンドラをアロー関数ではなくfunctionを使って定義することで、thisにアクセスすることが可能となります。

context

ここまで各種機能を見てきたのですが、こんな疑問を持った人も多いと思います。
「emitしたいときはどうするの?」、「refs使いたんだけど、どうしたら・・?」

setup関数内ではthisを参照できないため、Standart APIのように、this.$refsやthis.$emitを使用することができません。
ではどうするのかといいますと、setup関数の第2引数のcontextを使用していきます。

contextは以下のプロパティを含みます。
* parent
* root
* refs
* slots
* attrs
* emit

export default {
  setup(props, context) {
    console.log(context.attrs)
    console.log(context.slots)
    console.log(context.refs)
    console.log(context.emit)
    console.log(context.parent)
    console.log(context.root)
  }
}

これらの値を使用していきます。
ちなみにですが、VueRouterやVuexを使用する場合、$router, $route, $storeなどは、context.rootから取得することが出来ます。
* context.root.$rotue
* context.root.$rotuer
* context.root.$store

プラグインを作成し、Vueのprototypeに独自の値を設定した場合なども同様にcontext.root経由で取得することができます。

TypeScriptと一緒に使う

TypeScriptと一緒に使う場合、createComponentという関数を使ってコンポーネントを作成していきます。

<script lang="ts">
import Vue from 'vue'
import { createComponent, value } from 'vue-function-api'
export default createComponent({
  name: 'ValueSample',
  setup() {
    // ...
  }
})
</script>

createComponentはVue.extendと異なり引数のオブジェクトに対して何かするわけではないのですが、型定義を適切に行ってくれるようです。

Vuexを使った例

Vuexを組み込んだカウンターのサンプルを作ってみました。

// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    setCount(state, payload) {
      state.count = payload
    }
  },
  actions: {
    increment({ commit, state }) {
      commit('setCount', state.count + 1)
    },
    decrement({ commit, state }) {
      commit('setCount', state.count - 1)
    }
  }
})
<template>
  <div class="vuex-sample">
    <div class="count">現在のカウント: {{ count }}</div>
    <button @click="decrement">減らす</button>
    <button @click="increment">増やす</button>
  </div>
</template>
<script>
import Vue from 'vue'
import { computed } from 'vue-function-api'
export default {
  name: 'ValueSample',
  setup(props, context) {
    const store = context.root.$store
    const count = computed(() => store.state.count)
    const decrement = () => store.dispatch('decrement')
    const increment = () => store.dispatch('increment')
    return {
      count,
      decrement,
      increment
    }
  }
}
</script>

storeはcontext.root.$storeから取得します。
computedでstateの値を返すことで、stateの変更に対応しています。

まとめ

Function APIで試し書きしてみた感想としては、シンプルで良いなーと思う反面、一つのコンポーネントに多くの機能を載せて、それをsetup関数にバーっと書く、みたいな雑な作り方をするとあっという間にひどいことになりそうな予感がするなーという感じです。
Vue初心者が多い場面では、Standard APIを使ったほうが良いかもしれません。

こんな感じで共通モジュールを定義して、必要なコンポーネントで使用するみたいなことが簡単にできる点などはすごく良いですね。
mixinやHOCの代わりに使用すると便利な予感がします。

// @/lib/WindowSizeMixin.js
import { value, onMounted, onBeforeDestroy } from 'vue-function-api'
/**
 * 画面サイズに応じて変化するリアクティブな値を返す
 */
export const windowSize = () => {
  const height = value(window.innerHeight)
  const width = value(window.innerWidth)
  const handleResize = () => {
    height.value = window.innerHeight
    width.value = window.innerWidth
  }
  onMounted(() => window.addEventListener('resize', handleResize))
  onBeforeDestroy(() => window.removeEventListener('resize', handleResize))
  return {
    height,
    width
  }
}
<template>
  <div class="mixin-sample">
    <div>Window width: {{ windowWidth }}</div>
    <div>Window height: {{ windowHeight }}</div>
  </div>
</template>
<script>
import Vue from 'vue'
import { windowSize } from '@/lib/WindowSizeMixin'
export default {
  name: 'MixinSample',
  setup() {
    const { height: windowHeight, width: windowWidth } = windowSize()
    return {
      windowHeight,
      windowWidth
    }
  }
}
</script>

Vue3.0は今年中にリリースされる予定とのことです。
リリースまでにvue-function-apiプラグインを使って書き心地を試してみてはいかがでしょうか。

では!