IT/WEB

[Vue3] Vue3의 Composition API

개발자 두더지 2023. 9. 3. 22:37
728x90

일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 의역, 직역이 있을 수 있으며 틀린 내용은 지적해주시면 감사하겠습니다.

 

 

Composition API이란?


 Composition API는 2020년 9월 18일에 정식으로 릴리즈된 Vue3에 추가된 핵심기능이다.

 Composition API는 컴포넌트를 구축하기 위한 새로운 방법이며,  Vue가 다음의 문제를 해결하기 위해 제안한  것이다.

  • TypeScript의 서포트
  • 로직 재이용이 어려운 문제
  • 어플리케이션이 커지면 코드의 파악이 어려워지는 문제

 

 

컴포넌트의 예


 먼저는 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를 통해서 사용한다는 의도를 전달할 수 있다.

 그럼 조금 더 구체적으로 속성에 대해서 살펴보자.

 

 

컴포넌트의 선언


 Vue3에서는 지금까지의 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

 주의할 점은 불한 대입하면 리액티브가 되지 않는다. 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>

 리액티브한 성질이 없어진 것을 알 수 있다. 이러한 상황에 대해서 해결책은 toRefts 함수를 준비하는 것이다. 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로 액세스한다. 

 

reactive와 ref 어느쪽을 쓰는 것이 좋을까?

 어느쪽을 사용하는 것이 베스트인가에 대해서는 개발자마다 생각이 다르다. 따라서 프로젝트에 맞게 팀원들과 의논해서 결정하는 것이 좋을 것 같다.

 

 

메소드


 이전의 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 두 가지 방법을 쓸 수 있게 됐다. Vue2에 가까운 사용법은 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 Composition 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

 beforeCreate, create에 해당하는 훅은 없어지고, 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
    }
  }
})

 더욱이 주목해야할 변경점으로는 Vue3에서부터는 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://qiita.com/azukiazusa/items/1a7e5849a04c22951e97

728x90