Vue3でComposition APIを使ってみよう

Vue.js

今回はVue3で正式にリリースされたComposition APIを紹介したいと思います。

Composition APIは関数ベースのコンポーネントの定義方法で、従来までのOption APIに比べて、以下のような特徴があります。

  1. ロジックを機能毎にまとめることが可能になる
  2. ロジックを外部化し、再利用しやすくなる
  3. thisからの開放
  4. TypeScriptとの親和性の高さ

公式サイトはこちら

Composition APIはPluginを追加することでVue2でも利用することができます。基本的な使用方法はVue3系と変わりませんが、内部的なリアクティブな値の扱いがVue3とは若干異なり、Vue3では正常に動作していてもVue2だとうまく動かないことが多々ありますので、注意が必要です。

また、Composition APIのシンタックスシュガーとなるscript setupについての記事もありますので、よろしければこちらもご覧ください。

環境

今回のサンプルはVue CLIを使って構築したTypeScriptベースのアプリを想定しています。

Vue3.2.14
Vue CLI4.5.0
TypeScript4.3.5

Composition APIの各機能

今回の記事では従来のJavaScript + Option APIベースで書かれたコンポーネントをComposition APIに置き換える形で各機能の紹介をしたいと思います。

Javascript + Options APIで書かれたコンポーネント

<template>
    <h1>{{ name }}さんのタスク</h1>
    <div>
        <input v-model="task">
        <button @click="add">追加</button>
    </div>
    <div v-if="openCount > 0">未完タスク: {{ openCount }}件</div>
    <div v-else>タスクはありません</div>
    <div><button @click="removeDoneTask">完了済を削除</button></div>
    <ul>
        <li v-for="todo in todoList" :key="todo.id">
            <input type="checkbox" v-model="todo.done">
            <span :class="{done: todo.done}">{{ todo.task }}</span>
        </li>
    </ul>
    <todo-footer/>
</template>
<script>
import TodoFooter from "@/components/TodoFooter"
export default {
    name: "OptionTodo",
    components: {TodoFooter},
    props: {
        name: {type:String, required: true, default: "Anonymous" },
    },
    data: () => {
        return {
            task: "",
            todoList: [],
            nextId: 1,
        }
    },
    methods: {
        add() {
            if (this.task.length === 0) return

            this.todoList.push({
                id: this.nextId++,
                task: this.task,
                done: false,
            })

            this.emit("add-todo", this.task)

            this.task = ""
        },
        removeDoneTask() {
            this.todoList = this.todoList.filter(value => !value.done)
        },
    },
    computed: {
        openCount() {
            return this.todoList.filter(value => !value.done).length
        },
    },
    watch: {
        task(newTask, oldTask) {
            if (oldTask === "A" && newTask === "B") {
                console.log("A to B")
            }
            
        }
    }
}
</script>

<style>
.done {
    text-decoration: line-through;
    color: #888888;
}

ul {
    list-style: none;
}
</style>

よくあるTodoアプリです。
サンプルの作成にあたり、久しぶりにJavascriptとOption APIでコンポーネントを書きましたが、型が弱いのでかなり辛かったです・・
また、本サンプルは説明用に作成したものですので、考慮が甘い部分がありますが、ご容赦ください。

コンポーネントの定義方法

それではコンポーネントの定義から始めてみましょう。
Composition APIではdefineComponentという関数を使ってコンポーネントを定義します。

<script lang="ts">
export default defineComponent({
    setup() {
        return {
            // templateに公開する変数を返却する
        }
    }
})
</script>

defineComponentのsetup関数の中に従来のdata, methods, computedなどに相当する処理を定義していきます。
setup関数の戻り値で返却された変数はtemplateから参照することが可能です。

data

Option APIではリアクティブなデータを定義するにはdataを使っていましたが、Compotion APIではsetup関数でreactiveまたはrefを使用します。

reactive

reactiveはオブジェクトをリアクティブデータとして宣言する際に使用します。

import { defineComponent, reactive } from "vue"
 
class State {
    name = ""
    age?: number
    job?: string
}

export default defineComponent({
    setup() {
        const state = reactive(new State())

        console.log(state.name)
        state.name = "HOGE"

reactiveで宣言した変数は宣言時に渡したオブジェクトとして使用することができます。

reactiveを使用する際の注意ですが、stateのプロパティを分割代入や他の変数に代入してしまうとリアクティブが切れてしまい、template側に変更が反映されません。

const state = reactive(new State()) // OK
const {name, age} = reactive(new State()) // NG
const job = state.job // NG
ref

refはプリミティブな値(number, string, booleanなど)をリアクティブデータとして宣言する際に使用します。(実際はオブジェクトでも使用することは可能)

<script lang="ts">
import { defineComponent, ref } from "vue"

export default defineComponent({
    setup() {
        const name = ref("") 

        name.value = "HOGE"
        console.log(name.value)   
        return {
            name,
        }
    }
})
</script>
<template>
<h1>Hello {{name}}</h1><!-- .valueは不要 -->
</template>

値の参照・更新にはvalueプロパティを使用します。
また、テンプレートから参照・更新する場合はvalueは不要です。

ref関数の引数に初期値を渡すことでrefのvalueの型が推論されますが、明示的に型を宣言することも可能です。

const hoge = ref<string>() // string | undefined として推論
const fuga = ref<string | null>() // string | undefined | null として推論
const bar = ref<string | null>(null) // string | null として推論

ref関数に初期値を渡さない場合はundefinedを許容するようになります。

reactiveからrefへの変換 (toRef, toRefs)

reactiveの説明の際にreactiveで宣言した変数を分割代入や別の変数に代入するとリアクティブが切れてしまう事を書きましたが、reactiveで宣言した変数のプロパティをrefに変換することでリアクティブを維持することが可能です。

import { defineComponent, reactive, toRef, toRefs } from "vue"
 
class State {
    name = ""
    age?: number
    job?: string
}

export default defineComponent({
    setup() {
        const state = reactive(new State())

        // プロパティ1つをrefに変換
        const name = toRef(state, "name")

        // すべてのプロパティをrefに変換(分割代入)
        const {age, job} = toRefs(state)

また、下記のようにsetupのreturnにtoRefsをスプレッド演算子を使って展開することでreactiveの値を展開してtemplateに公開することも可能です。

setup() {
    const state = reactive(new State())

    return {
        ...toRefs(state),
    }

methods

Option APIのmethodsに相当する機能を定義するにはsetup関数内で関数を定義します。

<script lang="ts">
import { defineComponent, ref } from "vue"
export default defineComponent({
    setup() {
        const count = ref(0)

        const increment = (): void => {
           count.value += 1
        }

        const decrement = (): void => {
           count.value -= 1
        }

        return { count, increment, decrement }
    }
})
</script>
<template>
<div>
    <button @click="decrement">-</button>
    <span>{{count}}</span>
    <button @click="increment">+</button>
</template>

これは個人的なルールなのですが、関数がtemplateに公開されているかを分かりやすくするためにtempleteに公開しない関数は_で始まるようにする事でprivateメソッド的な感じにしています。

setup() {
    const count = ref(0)

    // templateに公開しない関数(privateメソッドみたいな感じ)
    const _updateCount = (value: number): void => {
        count.value += value
    }

    const increment = (): void => {
       _updateCount(1)
    }

    const decrement = (): void => {
       _updateCount(-1)
    }

    return { count, increment, decrement }
}

computed

computed(算出)プロパティを定義するにはcomputed関数を使用します。

import { defineComponent, ref, computed } from "vue"
export default defineComponent({
    setup() {
        const name = ref("")

        const upperName = computed(() => name.value.toUpperCase())

        console.log(upperName.value) // 値の参照
        upperName.value = "HOGE" // 読み取り専用のためNG

computedで宣言した変数は読み取り専用のref(ComputedRef)になり、値の参照にはrefと同様にvalueプロパティを使用します。

値の更新が可能なcomputedを宣言する場合はcomputedの宣言時にgetterとsetterの2つの関数を渡します。

import { defineComponent, ref, computed } from "vue"
export default defineComponent({
    setup() {
        const str = ref("")

        const cNumber = computed({
            get: () => Number(str.value), 
            set: (value: number) => (str.value = value.toString()),
        })

        console.log(cNumber.value) // 値の参照
        cNumber.value = 10 // 値の更新

※ 雑な処理でごめんなさい・・・

props

propsの定義方法は基本的にOption APIと同じです。setup関数内で使用するにはsetup関数の第1引数として受け取ることができます。

import {computed, defineComponent, reactive, ref, toRef, toRefs} from "vue"

export default defineComponent({
    props: {
        name: {type:String, required: true, default: "Anonymous" },
    },
    setup(props) {

        console.log(props.name) // プロパティの参照

reactiveと同様にpropsのプロパティを分割代入したり、別の変数に代入してしまうとリアクティブが切れてしまいますので、注意してください。
もし、propsを分割代入する場合はtoRefsを使う事でリアクティブを維持することができます。

export default defineComponent({
    props: {
        name: {type:String, required: true, default: "Anonymous" },
        job: {type:String, required: false},
    },
//    setup({name, job}) { // これはだめ!
    setup(props) {
        const {name, job} = toRefs(props)

また、propsはsetup関数内でreturnしなくてもtemplateで直接参照可能です。

emit (カスタムイベント)

カスタムイベントを発行するにはsetupContextのemitを使用します。
setupContextはsetup関数の第2引数として受け取ることができます。

export default defineComponent({
    setup(_, context) {   
        const someEvent = (id: number): void => {
            context.emit("some", id)
        }

使い方はOption APIと同じです。

watch

watch(値の監視)を行うにはwatch関数を使用します。

setup() {
    const state = reactive({
        name: "",
        job: "",
    })

    // stateのnameかjobが変更されたら呼ばれる
    watch(state, (newState, oldState) => {
        console.log("old", oldState)
        console.log("new", newState)
    })

watchの第1引数には監視対象を渡します。監視対象はリアクティブなオブジェクト(reactive, ref, computed, propsなど)を指定する必要があります。
第2引数には第1引数が変更された際に実行される関数を指定します。この関数では変更後の値と変更前の値を受け取ることができます。(ref, computedの場合はvalueプロパティが直接渡されます)

const name = ref("")
watch(name, (newName, oldName) => {
    // .valueは不要
    console.log("old", oldName) 
    console.log("new", newName)
})

reactiveやpropsの個別のプロパティを監視する場合は、第1引数に監視対象のプロパティを返す関数を指定します。

watch(() => state.name, (newName, oldName) => { /* 監視処理 */ })

watch(() => props.hoge, (newHoge, oldHoge) => { /* 監視処理 */ })

また、第1引数に配列を渡すことで複数の変数を監視することもできます。

const firstName = ref("")
const lastName = ref("")

watch([firstName, lastName], ([newFirstName, newLastName], [oldFirstName, oldLastName]) => {
    // 監視処理             
})

監視を停止するにはwatch関数の戻り値を実行します。

const unwatch = watch(name, () => { /* 監視処理 */ })

// 監視を停止
unwatch()

// 1回だけ監視
const watchOnece = watch(hoge, (newHoge) => {
    console.log("Watch Once", newHoge)
    watchOnece()
})

監視対象を指定せず、関数内で参照されている変数が変更された時に実行されるwatchEffect関数もあります。

const name = ref("")

// nameが変更されたら実行される
watchEffect(() => {
    if (name.value === "JOHN") {
        console.log("I found JOHN!!")
    }
})

ライフサイクル

Option APIと同様にComposition APIでもライフサイクル毎に処理を行うことができます。

Option APIComposition API
beforeCreate
created
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
renderTrackedonRenderTracked
renderTriggeredonRenderTriggered
activatedonActivated
deactivatedonDeactivated

beforeCreateとcreatedに相当する関数はありませんが、setup関数の実行されるタイミングがこれに当たるため、直接setup関数内に処理を書く事で実現できます。

import { defineComponent, onMounted } from "vue"
export default defineComponent({
    setup() {
        onMounted(() => {
            // 処理
        })

関数の外部化

従来までのOption APIでは1つのコンポーネント内にdataやmethods, computedなどを定義して使用してきましたが、これらは機能ごとの分割や再利用が難しく、システムが大きくなるにつれ保守性が悪くなっていました。ここではComposition APIを使って関数を外部化する方法と注意点について、説明したいと思います。

例えばTodoアプリに担当者を選択できるような機能を追加する場合、担当者の一覧取得や検索機能などを提供する関数を外部化するケースを考えます。

import {onMounted, ref} from "vue"

interface User {
    id: number
    name: string
}

export const useUser = () => {
    const users = ref<User[]>([])

    onMounted(async () => {
        // ユーザー取得処理
    })

    return { users }
}

ユーザー関数はonMountedのタイミングでユーザー一覧をサーバーから取得し、一覧を保持する想定です。ユーザー関数は.vueではなくTypeScriptファイル(.ts)として作成しています。

それではこのユーザー関数をコンポーネントから使用してみましょう。

import { defineComponent, ref } from "vue"
import { useUser } from "./user-hook"

export default defineComponent({
    setup() {
        const name = ref("")
        const { users } = useUser()

        return {
           name, users,
        }

ユーザー関数を外部化することでコンポーネント内の処理が外部に追い出され、再利用可能になりました。

Composition API変換後のイメージ

冒頭で紹介しましたOption APIベースのコンポーネントをComposition APIで書き直したものを紹介しておきます。

<template>
    <h1>{{ name }}さんのタスク</h1>
    <div>
        <input v-model="task">
        <button @click="add">追加</button>
    </div>
    <div v-if="openCount > 0">未完タスク: {{ openCount }}件</div>
    <div v-else>タスクはありません</div>
    <div><button @click="removeDoneTask">完了済を削除</button></div>
    <ul>
        <li v-for="todo in todoList" :key="todo.id">
            <input type="checkbox" v-model="todo.done">
            <span :class="{done: todo.done}">{{ todo.task }}</span>
        </li>
    </ul>
    <todo-footer/>
</template>
<script lang="ts">
import {computed, defineComponent, reactive, toRefs, watch} from "vue"
import TodoFooter from "@/components/TodoFooter.vue"

interface Todo {
    id: number
    task: string
    done: boolean
}

class State {
    task = ""
    todoList: Todo[] = []
    nextId = 1
}

export default defineComponent({
    components: {TodoFooter},
    props: {
        name: {type:String, required: true, default: "Anonymous" },
    },
    setup(props, context) {
        const state = reactive(new State())

        const add = (): void => {
            if (state.task.length === 0) return

            state.todoList.push({
                id: state.nextId++,
                task: state.task,
                done: false,
            })

            context.emit("add-todo", state.task)

            state.task = ""
        }

        const removeDoneTask = (): void => {
            state.todoList = state.todoList.filter(value => !value.done)
        }

        const openCount = computed(() => state.todoList.filter(value => !value.done).length)

        watch(() => state.task, (newTask, oldTask) => {
            if (oldTask === "A" && newTask === "B") {
                console.log("A to B")
            }
        })

        return {
            ...toRefs(state),
            add, removeDoneTask,
            openCount,
        }
    }
})
</script>

<style>
.done {
    text-decoration: line-through;
    color: #888888;
}

ul {
    list-style: none;
}
</style>

まとめ

今回はComposition APIの基本的な使い方を説明させていただきました。

最後までお読みいただき、ありがとうございました。

コメント

タイトルとURLをコピーしました