356
295

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vue 3 Composition API を使ってみよう

Last updated at Posted at 2020-10-18

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

スクリーンショット 20201017 20.19.14.png

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>

reactive.gif

リアクティブ性が失われていることがわかります。

このような状況に対する解決策として、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の値に対してアクセスします。

reactiveref.gif

ref

リアクティブなデータを宣言するもう一つの方法はrefを使用することです。
refはそれぞれの変数として宣言されます。

let inputValue = ref('')
let hasError = ref(false)

refで宣言された値にtemplate以外の場所からアクセスする場合、.valueからアクセスします。

inputValue.value
hasError.value

reactiveとrefどっちを使えばいい?

このように、Composition APIではリアクティブなデータの宣言にreactiverefどちらも使用することができます。しかし、どちらを使用するかのベストプラクティスはまだ存在していないようです。

Ref vs Reactive

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のようにwatchwatchEffect2つの方法が使えるようになりました。
以前のような使い方と近いのはwatchです。

watch

watchは第一に引数に監視する対象のリアクティブな値を指定します。リアクティブな値とは、refreactivecomputedで宣言された値を指します。

第二引数に実行するメソッドを渡します。

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

beforeCratecrateに相当するフックはなくなり、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プロパティに型定義がされている場合、そのとおりに推論されます。

スクリーンショット 20201018 14.41.51.png

注意点として、以下のように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する可能性があるイベントを配列として宣言するようになりました。

必須のオプションではないですが、コードを自己文章化することができ、推論もされるようになります。

スクリーンショット 20201018 14.53.53.png

モジュールに分割する

ここまでただ単に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を使ってみよう

356
295
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
356
295

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?