본문 바로가기
front/vue

[vue3 공식문서 번역]Reusability.1.Composables

by juniKang 2022. 5. 4.

"Composable"이란 무엇일까?

뷰 애플리케이션의 context에서, "composable"은 뷰 컴포지션 API를 캡슐화하고 재사용가능한 stateful(상태가 있는) 로직으로 레버리지하는 함수이다. 

 

프론트엔드 애플리케이션을 만들 때, 일반적인 업무를 위한 재사용가능한 로직이 필요할 때가 종종 있다. 예를 들면, 날짜를 많은 곳에서 포매팅할 필요가 있어서, 그 것에 대한 재사용가능한 함수를 추출한다. 이 포매터 함수는 stateless (상태가없는) 로직으로 캡슐화 한다: 인풋값을 넣으면 즉시 기대한 아웃풋을 리턴한다. 시중에는 재사용가능한 stateless logic이 들어있는 많은 라이브러리들이 있다.- 예를들면, lodashdate-fns 등... 당신이 들어봄직한 ...

 

이에 비해, stateful 로직은 시간이 지남에 따라 변화하는 상태를 관리하는 것을 포함한다. 간단한 예는 페이지상에 마우스의 현재위치를 쫒는 것이 될 수 있다. 현실의 시나리오로는, 데이터베이스 연결상태나 터치 제스쳐 같은 더 복잡한 로직들이 있다.

 

Mouse Tracker Example

컴포넌트에 함수적으로 Composition API를 사용해서 마우스 트래킹을 구현하면, 다음과 같을 것이다:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

그런데 만약 같은 로직을 여러 컴포넌트들에서 재사용허갈 원하면 어떻게 할까? composable function으로 외부 파일로 로직을 추출할 수 있다:

// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 컨벤션에 의해, composable function 이름을 "use"로 시작해야 한다.
export function useMouse() {
  // 상태는 composable에 의해 캡슐화 되고 관리된다.
  const x = ref(0)
  const y = ref(0)
  
  // composable은 관리되는 상태가 시간이 지남에 따라 업데이트 될 수 있다.
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }
  
  // composable 은 side effects를 설정 및 해체 하기 위해 
  // 사용하는 컴포넌트의 라이프사이클에 hook(연결)할 수 있다.
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
  
  // 리턴값으로 관리되는 상태를 노출한다
  return { x, y }
}

다음이 컴포넌트에서 사용되는 방법이다:

<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

Try it in the playground

 

봤듯이, 핵심 로직은 완적히 똑같이 남는다. - 우리가 할일은 외부 함수로 옮기고, 노출될 상태를 리턴하는 것이다. 컴포넌트 안쪽에서도 똑같이, composable에 Composition API functions의 모든 기능을 사용할 수 있다. 동일한 useMouse() 기능이 이제 모든 컴포넌트에서 사용될 수 있다.

 

composables에대한 더 멋진 파트는 중첩할 수 있다는 것이다: 하나의 composable 함수는 하나 이상의 다른 composable 함수에서 호출될 수 있다. 이건 컴포넌트를 사용해서 애플리케이션 전체를 구성한 방식과 비슷하게, 작고 독립적인 유닛들로 복잡한 로직을 구성할 수 있다. 실은, 이것이 이 패턴을 가능하게 하는 API의 컬렉션을 Composition API라고 부르기로 결정한 이유이다.

 

예제와 같이, DOM 이벤트 리스너 자체를 composable에 추가하고, 정리하는 로직을 추출할 수 있다:

// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 원한다면, 
  // 이 support selector strings를 타겟으로 만들 수 있습니다.
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

이제 우리의 useMouse()은 더 간단해 집니다:

// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  useEventListener(windwo, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })
  
  return { x, y }
}
TIP
useMouse()를 호출하는 각각의 컴포넌트 인스턴스들은 x와 y 상태의 고유한 복사본을 만들것이다. 그래서 다른 컴포넌트들과 서로 간섭하지 않는다. 컴포넌트 사이에 공유하는 상태를 관리하고 싶다면, State Management 챕터를 보라.

Async State Example

useMouse() composable은 어떤 아규먼트로 받지 않는다. 그래서 하나의 아규먼트를 쓰는 다른 예제를 보자. 비동기적으로 데이터 fetching을 할 때는, 다른 상태를 핸들링하는게 종종 필요하다: loading, success, error같은:

<script setup>
import { ref } from 'vue'

const data = ref(null)
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) = > (error.value = err))
</script>

<template>
  <div v-if="error">Ooops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

다시, 데이터를 패치할 필요가있는 모든 컴포넌트에서 이 패턴을 반복하는건 지루할 수 있다. composable로 추출해보자:

// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  
  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))
  
  return { data, error }
}

이제 우리 컴포넌트에서는 이렇게만 쓰면 된다:

<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('...')
</script>

useFetch()는 input같은 static URL string을 받는다 - 그래서 한번 fetch를 수행하고 종료된다. URL이 바뀔떄마다 re-fetch를 하기를 원하면 어떻게 할까? 아규먼트로 refs를 받도록 해서 이룰 수 있다:

// fetch.js
import { ref, isRef, unref, watchEffect } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  
  function doFetch() {
    // fetching 전에 상태를 리셋
    data.value = null
    error.value = null
    // unref()가 잠재적인 refs를 unwraps한다.
    fetch(unref(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) =>> (error.value = err))
  }
  
  if isRef(url)) {
    // 만약 inpu URL이 ref면 reactive re-fetch를 setup한다.
    watchEffect(doFetch)
  } esle {
    // 아니면, 한번만 fetch 한다.
    // 그리고 watch의 오버헤드를 피한다.
    doFetch()
  }
  
  return { data, error }
}

useFetch()의 지금 버전은 이제 static URL strings와 URL strings의 refs 둘 다 수용할 수 있다. URL이 동적인 ref면 isRef()를 사용해서 감지하고, watchEffect()를 사용해서 반응형 이펙트를 세팅한다. 이펙트는 즉시 실행되며, 프로세스의 dependency로서 URL ref를 추적한다. URL ref가 변경되면 언제든지, 데이타는 다시 리셋되고 패치된다.

 

여기 데모 목적으로 인위적인 딜레이와 랜덤의 에러를 추가한 useFetch()의 업데이트 버전이 있다. 

 

Conventions and Best Practices

Naming

"use"로 시작하는 camelCase 이름으로 composable functions의 이름을 지정하는게 컨벤션이다.

 

Input Arguments

composable은 reactivity에 기대지 않아도 ref 아규먼트를 수용할 수 있다. 만약 다른 개발자에 의해 사용되는 composable을 개발하고 있다면, raw values 대신에 refs인 인풋 아규먼트의 경우를 다루는 것도 좋은 아이디어다. unref() 유틸리티 함수는 이런 용도로 유용하다:

import { unref } from 'vue'

function useFeature(maybeRef) {
  // maybeRef가 정말 ref면, .value는 리턴된다.
  // 아니면, maybeRef는 as-is를 리턴한다.
  const value = unref(maybeRef)
}

input이 ref일 때 composable이 reactive effects를 만들었다면, 명시적으로, watch()로 ref를 watch하거나, watchEffect9)안에 unref()를 호출해라, 그래야 적절하게 추적된다.

 

Return Values

composables에서 reactive() 대신에 ref()를 독점적으로 사용한 것을 알아챘을 지도 모르겠다. 추천하는 컨벤션은 composables에서는 refs의 객체를 항상 리턴하는것이다. 그렇게 해야 반응형을 남기면서 컴포넌트에서 구조분할 할 수 있다:

// x와 y는 refs다.
const { x, y } = useMouse()

composable에서 reactive object를 리턴하는 것은 composable안에 있는 상태의 반응형 커넥션을 잃은 구조분한을 초래한다. 반면에 refs는 커넥션이 남아있는다.

 

object properties로서 composables로 부터 리턴된 상태를 사용하길 원하면, reactive()로 리턴 객체를 감싸는 방법이 있다. 그러면 refs는 unwrapped 된다. 예를 들면:

const mouse = reactive(useMouse())
// mouse.x is linked to original ref
console.log(mouse.x)
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}

 

Side Effects

composables에서 사이드 이펙트를 수행하는건 괜찮다. 하지만 다음 규칙을 주의해야 한다:

  • Server-Side Rendering (SSR) 을 활용하는 애플리케이션에서 개발한다면, psot-mount lifecycle hooks에서 돔에 특정한 사이드이펙트를 수행해야 한다. 즉, onMounted(). 이 훅은 브라우저에서만 호출된다. 그래서 이 안에 넣은 코드는 DOM에 접근하는걸 보장할 수 있다.
  • onUnmounted()에서 사이드이펙트를 확실하게 정리해라. 예를들면, composable이 DOM event listener를 셋업 한다면, onUnmounted()에서 그 리스너를 지워야 한다 ( useMouse() 예제에서 봣듯이). useEventListener() 예제처럼 자동적으로 그렇게 하는건 composable을 사용하는 좋은 아이디어 이다.

Usage Restrictions (제약사항)

Composables는 <script setup>이나 setup()훅에서 동기적으로 호출해야만 한다. onMounted()같이 라이프사이클 훅에서 호출할 수도 있다.

뷰가 현재 active component 인스턴스를 결정할 수 있는 contexts가 있다. 엑티브 컴포넌트 인스턴스에 접근은 다음이유로 필요하다:

  1. 라이프사이클 훅이 등록될 수 있다.
  2. Computed 프로퍼티들과 watchers는 component unmount 처리를 위해 연결될 수 있다.
TIP
<script setup>은 await의 사용이후 당신이 comsables를 호출할 수 있는 유일한 장소다. 컴파일러는 자동적으로 엑티브 인스턴스 컨텍스트를 비동기 작업 이후에 저장한다.

Extracting Composables for Code Organization

Composables는 재사용을 위해서 뿐만아니라 코드 조직화를 위해서도 추출될 수 있다. 컴포넌트 성장의 복잡성에 따라, 결국 너무 커서 추적하거나 추론할 수 없는 컴포넌트가 될 수 있다. Composition API는 논리적인 관심사(logical concerns) 에 따라 더 작은 함수로 컴포넌트 코드를 조직화해서 전적인 유연성을 제공할 수 있다:

<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

어느 정도까지는, 서로 소통할 수 있는 컴포넌트 스콥 서비스로서 추출된 composables를 생각할 수 있다.

 

Using composables in Options API

Options API를 사용한다면, composables는 setup() 안에서만 호출될 수 있고, 리턴 바인딩은 setup()으로 부터만 리턴될 수 있다. 따라서, this와 템플릿으로 노출될 수 있다.

import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // setup() 'this'로 접근할 수 있는 프로퍼티를 노출한다.
    console.log(this.x)
  }
  // ...다른 옵션들
}

Comparisons with Other Techniques

다른 기술과의 비교는 생략...

 

Further Reading

  • Reactivity In Depth : 뷰의 reactivity ststem 동작의 원리를 이해하기 위해
  • State Management : 여러개의 컴포넌트에 의해 공유되는 상태관리 패턴을 위해
  • Testing Composables : composables의 유닛 테스트에 대한 힌트
  • VueUse : 뷰 composables의 끊임없이 증가하는 컬렉션. 소스코드는 배울수 있는 훌륭한 학습자료

댓글