Composition APIとは、
Composition APIとは、2020年09月18に正式リリースされた、Vue 3に追加された目玉機能です。
Composition APIはコンポーネントを構築するための新しい手法です。
Vueが抱えていた以下の課題を解決するためのものです。
- TypeScriptのサポート
- ロジックの再利用の難しさ
- アプリケーションが巨大になると、コードの把握が難しくなる
Vue CLIでVue 3を導入する
早速、Vue CLIでVue 3を使っていきましょう。
まずは、最新のVue CLIをインストールします。
yarn global add @vue/cli@next
# OR
npm install -g @vue/cli@next
vue -V
@vue/cli 4.5.4
次に、いつも通りプロジェクトを作成します。
Vue 3が選べるようになっています。
vue create vue3-project
Vue CLI v4.5.4
? Please pick a preset:
Default ([Vue 2] babel, eslint)
❯ Default (Vue 3 Preview) ([Vue 3] babel, eslint)
Manually select features
今回は、Manually select featuresから選択します。
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Pick a linter / formatter config: Standard
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
プロジェクトの作成が完了したら、早速起動してみましょう。
cd vue3-project
npm run serve
TODOアプリの作成を例にして、Composition APIを使っていきましょう。
コンポーネントの例
まずは、Composition APIで作成されたコンポーネントの全体像を見ていきましょう。
次のように、以前の元とは大きく変わっていることがわかります。
// MyTodo.vue
<template>
<todo-list
v-for="todo in sortTodo"
:todo="todo"
:key="todo.id"
@toggle="toggleTodo"
@remove="removeTodo"
/>
<add-todo
@add="addTodo"
/>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, watchEffect, onMounted } from 'vue'
import TodoList from '@/components/TodoList.vue'
import AddTodo from '@/components/AddTodo.vue'
import { fetchTodo } from '@/api'
import { Todo } from '@/types/todo'
import { v4 as uuid } from 'uuid'
interface State {
todos: Todo[];
}
export default defineComponent({
components: {
TodoList,
AddTodo
},
setup () {
const state = reactive<State>({
todos: []
})
onMounted(async () => {
state.todos = await fetchTodo()
})
const sortTodo = computed(() => state.todos.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}))
const addTodo = (title: string) => {
state.todos = [...state.todos, {
id: uuid(),
title,
done: false,
createdAt: new Date()
}]
}
const removeTodo = (id: string) => {
state.todos = state.todos.filter(todo => todo.id !== id)
}
const toggleTodo = (id: string) => {
const todo = state.todos.find(todo => todo.id === id)
if (!todo) return
todo.done = !todo.done
}
watchEffect(() => console.log(state.todos))
return {
sortTodo,
addTodo,
removeTodo,
toggleTodo
}
}
})
</script>
// TodoList.vue
<template>
<div>
<span>{{ todo.title }}</span>
<input type="checkbox" value="todo.done" @change="toggle" />
</div>
<div>
{{ date }}
</div>
<div>
<button @click="remove">削除</button>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue'
import { Todo } from '@/types/todo'
export default defineComponent({
props: {
todo: {
type: Object as PropType<Todo>
}
},
emits: ['toggle', 'remove'],
setup (props, context) {
const date = computed(() => {
if (!props.todo) return
const { createdAt } = props.todo
return `${createdAt.getFullYear()}/${createdAt.getMonth() + 1}/${createdAt.getDate()}`
})
const toggle = () => {
context.emit('toggle', props.todo!.id)
}
const remove = () => {
context.emit('remove', props.todo!.id)
}
return {
date,
toggle,
remove
}
}
})
</script>
// addTodo
<template>
<input type="text" v-model="state.inputValue" />
<button @click="onClick" :disabled="state.hasError">追加</button>
<p v-if="state.hasError" class="error">タイトルが長すぎ!</p>
</template>
<script lang="ts">
import { defineComponent, reactive, watchEffect } from 'vue'
interface State {
inputValue: string;
hasError: boolean;
}
export default defineComponent({
emits: ['add'],
setup (_, context) {
const state = reactive<State>({
inputValue: '',
hasError: false
})
const onClick = () => {
context.emit('add', state.inputValue)
state.inputValue = ''
}
watchEffect(() => {
if (state.inputValue.length > 10) {
state.hasError = true
} else {
state.hasError = false
}
})
return {
state,
onClick
}
}
})
</script>
<style scoped>
.error {
color: red;
}
</style>
以前のような構造(これをOptions APIと呼びます)と異なり、data・methods・computed・ライフサイクルメソッドなどの区別がなくなり、全てがsetupメソッドの中に記述されています。一方で、componentsやpropsの記述は以前と変わりありません。
全てがsetupメソッド内に記述された結果、他のプロパティにアクセスする際にthis
が不要になりました。以前までのVueではこのthis
の制約によりアロー関数で記述することが敬遠されていたのですが、Composition APIではアロー関数により記述が可能になりました。
setupメソッド内のデータは、returnされたものだけがtemplate内で使用できるようになります。
そのため、例えばMyTodo.vue
のsetupメソッドではstate
が宣言されていますがreturnされていないためこれを使用することはできません。state
の値は直接使用せずに、computed
を通して使用するという意図を伝えることができます。
それでは、もう少し具体的にここのプロパティを見ていきましょう。
コンポーネントの宣言
Vue 3では、今までのVue.extend
の宣言に変わり、defineComponent
が使われます。これにより型推論が効くようになります。JavaScriptで記述する場合には必要ありません。
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({})
</script>
リアクティブなデータ reactive または ref
reactive
またはref
は以前のdata
に相当するものです。どちらもジェネリクスで型定義を渡すことができます。
reactive
個人的に、reactive
のほうが以前のdataに近い印象を受けます。reactiveは1つのオブジェクトとしてデータを定義します。
interface State {
inputValue: string;
hasError: boolean;
}
const state = reactive<State>({
inputValue: '',
hasError: false
})
reactive
の値にはオブジェクト形式でアクセスします。
state.inputValue
state.hasError
分割代入すると、リアクティブにならない
reactive
を使用する注意点として、分割代入した値に対してはリアクティブ性が失われるという点があります。
AddTodo
コンポーネントを例に試してみましょう。
<template>
<input type="text" v-model="inputValue" />
<button @click="onClick" :disabled="hasError">追加</button>
<p v-if="hasError" class="error">タイトルが長すぎ!</p>
</template>
<script lang="ts">
import { defineComponent, reactive, watchEffect } from 'vue'
interface State {
inputValue: string;
hasError: boolean;
}
export default defineComponent({
emits: ['add'],
setup (_, context) {
// 分割代入でここのプロパティを受け取るように変更
let { inputValue, hasError } = reactive<State>({
inputValue: '',
hasError: false
})
const onClick = () => {
context.emit('add', inputValue)
inputValue = ''
}
watchEffect(() => {
if (inputValue.length > 10) {
hasError = true
} else {
hasError = false
}
})
return {
inputValue,
hasError,
onClick
}
}
})
</script>
<style scoped>
.error {
color: red;
}
</style>
リアクティブ性が失われていることがわかります。
このような状況に対する解決策として、toRefs
関数が用意されています。toRefs
はリアクティブオブジェクトをプレーンオブジェクトに変換します。結果のオブジェクトの各プロパティは、元のオブジェクトの対応するプロパティの参照です。
<template>
<input type="text" v-model="inputValue" />
<button @click="onClick" :disabled="hasError">追加</button>
<p v-if="hasError" class="error">タイトルが長すぎ!</p>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, watchEffect } from 'vue'
interface State {
inputValue: string;
hasError: boolean;
}
export default defineComponent({
emits: ['add'],
setup (_, context) {
const { inputValue, hasError } = toRefs(reactive<State>({
inputValue: '',
hasError: false
}))
const onClick = () => {
context.emit('add', inputValue)
inputValue.value = ''
}
watchEffect(() => {
if (inputValue.value.length > 10) {
hasError.value = true
} else {
hasError.value = false
}
})
return {
inputValue,
hasError,
onClick
}
}
})
</script>
<style scoped>
.error {
color: red;
}
</style>
toRefs
を適用すると、後述するref
で宣言されたものと同等の状態になります。そのため、template以外の場所からアクセスする際には、inputValue.value
のように.valueの値に対してアクセスします。
ref
リアクティブなデータを宣言するもう一つの方法はref
を使用することです。
ref
はそれぞれの変数として宣言されます。
let inputValue = ref('')
let hasError = ref(false)
ref
で宣言された値にtemplate以外の場所からアクセスする場合、.valueからアクセスします。
inputValue.value
hasError.value
reactiveとrefどっちを使えばいい?
このように、Composition APIではリアクティブなデータの宣言にreactive
とref
どちらも使用することができます。しかし、どちらを使用するかのベストプラクティスはまだ存在していないようです。
Composition APIを効率的に理解するためには、どちらの方法を理解しておくべきだと述べられています。
メソッド
以前のmethods
に相当するものは、通常のJavaScriptの関数の宣言にになります。
const addTodo = (title: string) => {
state.todos = [...state.todos, {
id: uuid(),
title,
done: false,
createdAt: new Date()
}]
}
const removeTodo = (id: string) => {
state.todos = state.todos.filter(todo => todo.id !== id)
}
const toggleTodo = (id: string) => {
const todo = state.todos.find(todo => todo.id === id)
if (!todo) return
todo.done = !todo.done
}
メソッドの宣言も同様に、returnしていないものはtemplate内で使用できません。
computed
computedは関数をcomputedで包むことで実装できます。
const sortTodo = computed(() => state.todos.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}))
何度も言うように、computedの値もreturnする必要があります。
データの監視 watch watchEffect
watchもdataのようにwatch
とwatchEffect
2つの方法が使えるようになりました。
以前のような使い方と近いのはwatch
です。
watch
watch
は第一に引数に監視する対象のリアクティブな値を指定します。リアクティブな値とは、ref
、reactive
、computed
で宣言された値を指します。
第二引数に実行するメソッドを渡します。
watch(state.todos, (newTodos, oldTodos) => {
console.log(oldTodos, newTodos)
})
監視対象が複数ある場合、配列で指定します。
watch([state.inputValue, state.hasError], ([newInputValue, newHasError], [oldInputValue, oldHasError]) => {
})
watchEffect
watchEffect
は、監視対象を指定しません。computedのように、関数内の値が変更されたときに実行されます。
watchEffect(() => console.log(state.todos))
基本的に、watchEffect
よりもwatch
のほうが機能が優れているようで、例えばwatchEffect
と比べてwatch
は次のような機能を提供します。
- 副作用の遅延実行。
- 監視対象の値を渡すので、意図が明確。
- 監視状態の以前の値と現在の値の両方にアクセスできる。
ライフサイクルメソッド
Composition APIのライフサイクルメソッドはon
というプレフィックスがつけられました。
onMounted(async () => {
state.todos = await fetchTodo()
})
Options APIとの対応は以下の通りです。
Options API | Conposition API |
---|---|
beforeCreate | use setup() |
created | use setup() |
beforeMount | onBeforeMount |
mounted | onMounted |
beforeUpdate | onBeforeUpdate |
updated | onUpdated |
beforeDestroy | onBeforeUnmount |
destroyed | onUnmounted |
activated | onActivated |
deactivated | onDeactivated |
errorCaptured | onErrorCaptured |
beforeCrate
、crate
に相当するフックはなくなり、setup()で記述するようになりました。
さらに、以下のライフサイクルフックが追加されました。
- onRenderTracked
- onRenderTriggered
props
propsををsetupメソッド内で使用するために、setupメソッドは引数を受け取ります。第一引数はpropsそのものです。
export default defineComponent({
props: {
todo: {
type: Object as PropType<Todo>
}
},
setup (props, context) {
const date = computed(() => {
if (!props.todo) return
const { createdAt } = props.todo
return `${createdAt.getFullYear()}/${createdAt.getMonth() + 1}/${createdAt.getDate()}`
})
return {
date
}
})
propsプロパティは今まで通りです。
propsプロパティに型定義がされている場合、そのとおりに推論されます。
注意点として、以下のようにpropsを分割して受け取ると、リアクティブ性が失われてしまいます。
setup({ todo }, context) {}
emit
setupメソッドの第二引数には、contextオブジェクトを受け取ります。contextオブジェクトからはOptions APIでthis
からアクセスできた一部のプロパティを提供します。
context.attr
context.slots
context.emit
どのプロパティも、先頭の$
が外れていることに注意してください。
propsは使用せずに、contextオブジェクトだけ使用したいときには、第一引数を_
にして受け取らないようにします。
setup(_, context) {}
contextオブジェクトの中にemit
が含まれているので、以前のように使用することができます。
export default defineComponent({
emits: ['add'],
setup (_, context) {
const state = reactive<State>({
inputValue: '',
hasError: false
})
const onClick = () => {
context.emit('add', state.inputValue)
state.inputValue = ''
}
return {
state,
onClick
}
}
})
さらに注目すべき変更点として、Vue 3からはemits
オブジェクトとしてそのコンポーネントがemitする可能性があるイベントを配列として宣言するようになりました。
必須のオプションではないですが、コードを自己文章化することができ、推論もされるようになります。
モジュールに分割する
ここまでただ単にComposition APIに書き換えてみただけなので、結局コンポーネント内で宣言されており、またstate
の値に依存しているのであまり恩恵を感じないように思えます。
ここからは、Composition APIの真骨頂であるコードの分割にコマを進めていきましょう。
Composition APIではdata、computed等の区切りがなくなったことで関心ことの分離が可能になりました。
分割した関数は、src/composables
配下に配置していきます。
それでは、まずはsortTodo
を切り出してみましょう。
実装は次のようになります。
// src/composable/use-sort-todo.ts
import { computed, isRef, Ref } from 'vue'
import { Todo } from '@/types/todo'
export default (todos: Ref<Todo[]>) => {
const sortTodo = computed(() => todos.value.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}))
return {
sortTodo
}
}
元々state.todo
でアクセスしていた箇所を引数で受け取るようにしました。引数の型Refとしています。ロジック部分に変わりはありません。
使用側では以下のように使います。
import { toRefs, defineComponent, reactive, watchEffect, onMounted } from 'vue'
import useSortTodo from '@/composables/use-sort-todo'
interface State {
todos: Todo[];
}
export default defineComponent({
setup () {
const state = reactive<State>({
todos: []
})
const { todos } = toRefs(state)
const { sortTodo } = useSortTodo(todos)
// 中略
return {
sortTodo,
addTodo,
removeTodo,
toggleTodo
}
}
})
これで、sortTodoはVueオブジェクトに依存することなく、再利用可能な関数として取り出すことができました。
その他のメソッドも切り出してみると、だいぶコンポーネントがスッキリしました。
<template>
<todo-list
v-for="todo in sortTodo"
:todo="todo"
:key="todo.id"
@toggle="toggleTodo"
@remove="removeTodo"
/>
<add-todo
@add="addTodo"
/>
</template>
<script lang="ts">
import { defineComponent, watchEffect } from 'vue'
import TodoList from '@/components/TodoList.vue'
import AddTodo from '@/components/AddTodo.vue'
import useTodos from '@/composables/use-todos'
import useSortTodo from '@/composables/use-sort-todo'
import useActionTodo from '@/composables/use-action-todo'
export default defineComponent({
components: {
TodoList,
AddTodo
},
setup () {
const { todos } = useTodos()
const { sortTodo } = useSortTodo(todos)
const { addTodo, removeTodo, toggleTodo } = useActionTodo(todos)
watchEffect(() => console.log(todos.value))
return {
sortTodo,
addTodo,
removeTodo,
toggleTodo
}
}
})
</script>
変更はこちらのレポジトリから参照できます。
https://github.com/azukiazusa1/vue3-project
参考
Vue Composition API
Vue3への移行は可能?Vue2のコードをComposition APIで書き換えてみた
Vue.js 3.0で搭載される Composition APIをリリースに先駆けて試してみた
Vue 3 に向けて Composition API を導入した話
Vue 2.xのOptions APIからVue 3.0のComposition APIへの移行で知っておくと便利なTips
Vue3リリース直前!導入されるcomposition APIを使ってみよう