IT/언어

[Vue3] Composition API Best Practices

개발자 두더지 2023. 10. 3. 21:22
728x90

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

 

https://www.youtube.com/watch?v=6D58SI9P-aU&t=1237s 영상의 내용을 번역했다. 

 

ref() vs reactive()


 ref()와 reactive() 중 어느 쪽을 쓰는 것이 좋을까? Composition API를 이용한 라이브러리의 코드를 집계한 결과는 다음과 같다. 거의 ref()를 사용하고 있다는 것을 알 수 있다.

 생각할 수 있는 주된 이유는 "일관성(consistency)"일 것이다. reactive에는 제한이 있지만 ref는 어디에서 사용할 수 있으며 대부분의 개발자들은 일관성을 중시하기 때문에 ref를 선택하는 경우가 많다고 예상할 수 있다.

 

computed propery는 refs

 예를 들어, computed로 얻은 값이 Boolen이어도 String이어도 리액티브일 필요가 있으므로 내부적으로는 ref르 사용하고 있다(reactive()는 인수로 객체만 전달할 수 있다). 

 아래와 같이 squared이라는 computed property를 정의했을 때, state는 reactive()에 의해 직접 값에 액세스할 수 있음에도 computed의 sum은 .value를 사용해야한다. 이런 상태는 혼란을 야기한다.

export function exampleWithComputed() {
  const state = reactive({
    a: 1,
    b: 2,
    x: 3,
  })

  const sum = computed(() => state.a + state.b)

  const squared = computed(() => sum.value ** state.x) // ref와 reactive의syntax가 섞여있다.

  return toRefs({
    ...state,
    sum,
    squared
  })
}

 대신에 모두 ref를 사용하면 깔끔하게 일관성 있는 코드를 쓸 수 있다. 그러기 위해 이전은 a나 b를 state 오브젝트의 속성으로써 정의한 것을 직접 정의하는 것으로 "state."가 없어서 ".value"를 붙여도 문자 수가 바뀌지 않는 결과가 된다.

export function exampleWithComputed() {
  const a = ref(1)
  const b = ref(2)
  const x = ref(2)

  const sum = computed(() => a.value + b.value)

  const squared = computed(() => sum.value ** x.value)

  return toRefs({
    a, b, x,
    sum,
    squared
  })
}

 

DOM의 참조

 Vue2에서 있었던 DOM의 참고기능은 물론 Vue3의 ref()가 받았다. setup()내의 ref로 정의한 변수를 template내의 tag에 ref속성으로 전달하는 것으로 DOM요소를 획득할 수 있으나, 이러한 것은 reactive가 할 수 없다.

 아래는 input요소의 DOM를 획득하여 이벤드 리스러를 추가하는 샘플코드이다.

setup() {
  const inputEl = ref<HTMLInputElement>(null)

  onMounted(() => {
    inputEl.value.addEventListener(/* */)
  })

  return {
    inputEl
  }
}
<template>
  <div>
     <input type="text" ref = "inputEl" />
  </div>
</template>

 

결론

 개발자는 일관성을 중시하므로 ref()를 좋아하는 경향이 있다. ref를 사용하면 상태에 따라 어느것을 쓸지를 판단하지 않아도 된다. 또한 첫인상에서는 ref를 쓰면 장황해지는 것 같지만 실제로 그렇지 않다.

 그렇다면 reactive()는 필요가 없는가? 그렇지는 않다. ref와의 병용이 필수이지만 사용하고 싶은 부분에는 사용해도 좋다. 예를 들어 대량의 속성을 가진 객체를 setup()에서 꺼낸 함수에서 리턴할 때에는 reactive로 감싸면 사용하기 쉬워진다.

 

 

Best practive of setup()


 composition api는 이 그림처럼 하나의 컴포넌트에 대하여 여러 로직을 같은 장소에 고정해서 쓸 수 있다는 장점이 있지만 결국 setup()안에 대량의 코드를 쓰는 것은 동일하다.

 예를 들어 검색창 하나만 해도 https://awesomejs.dev/ 의 경우 Auto complete나 GrapQL를 이용하여 package 검색등등 다양한 처리가 동시에 실시된다.

 이 사이트를 만들고 있는 프로그래머는 긴 코드를 이해할 수 있지만 다른 사람들은 그렇지 않다. 그러므로 실제 처리는 setup()에서 벗어나 함수로서 다른 파일로 잘라내는 것을 추천하고 싶다.

 setup()내에서는 usePackageCheck이라는 "받은 formData에 대해 package가 존재하는지 아닌지 등 필요한 처리를 하는 함수"에 실제로 값을 전달해, 그 후의 처리에 필요한 스테이터스를 받는 것만 기재한다. 그로인해, 실제로는 어떠한 처리를 하고 있는지에 대한 흐름을 쉽게 쫓을 수 있게 된다.

 위에서는 package check가 끝난 후 validation을 실시하고, 그 결과를 submit용 처리나  함수를 만들어 뒀다. 개인적인 우선적 필요한 처리를 setup()안에 쓰고, 너무 길어서 읽기 힘들어지면 그때 파일을 나누는 방법을 추천하고 싶다. 

 Composition api를 통해 보통의 함수 형태로 기능을 나눠 꺼낼 수 있게 되었으므로 여기서 부터는 아래의 함수 각 파트에대해서 어떻게 할 것인가를 생각해보다.

  • 인수
  • 내부처리
  • 반환값

 

 

Handling Refs in arguments


 먼저 composition api와 인수의 핸들링에 대해서 특히 ref의 값을 받는 함수에 대해서 살펴보자. 예를 들어 이러한 Boolean값을 가진 ref를 인수로 받아, 그 값의 변화를 트리거로 무언가를 하는 함수가 있다고 한다.

export function useWithRef(someFlag: Ref<boolean>) {

  watch(ref, val => {
    /* do something */
  })

  return {}
}

 호출한 쪽은 이렇게 쓰면 문제 없다.

const isActive = ref(true)
const result = useWithRed(isActive)  😊

 그러나 실제로 사용하는 사람은 그 값이 변화하지 않는 것을 알고있고, 직접 Boolean에 넘기려고 할 수도 있다. TypeScript를 사용하면 에디터상에서 오류가 날 수 있지만 그렇지 않을 경우 아래와 같이 오류 핸들링을 하면된다.

export function useWithRef(someFlag: Ref<boolean>) {
  if (!isRef(someFlag)) warn('Needs a ref');
  watch(ref, (val) => {
    /* do something */
  });

  return {};
}

 한편으로 호출하는 측에서 리액티브한 값을 사용할 일이 없는 경우, 일부러 ref로 싸서 잔달하게 되게 되는데 아무래도 이상한한 느낌이든다.

const result = useWithRed(Ref(true));  🤔

 이러한 경우 고정값도 ref도 모두 인수로 받는 함수로 만드는 것이 개발 친화적일 것이다.

 

리액티브한 값도 고정값도 인수로 받을 수 있게 만들 수 있을까?

 아래와 같이 "ref값이 온 경우는 그대로, 고정 값이 온 경우는 ref로 감싸는" 처리를 넣으면 간단하게 된다. 이 함수에서는 DOM Element를 그대로 전달하든 ref로 리액티브한 값을 전달하든 지정한 이벤트 리스너를 설치하는 함수로써 기능한다.

const wrap = (value) => (isRef(value) ? value : ref(value));

export function useEvent(
  el: Ref<Element> | Element,
  name: string,
  listener: EventListener,
) {
  const element = wrap(el as Element);
  onMounted(() => element.value.addEventListener(name, listener));
  onUnmounted(() => element.value.removeEventListener(name, listener));
}

 

 

Lifecycle vs watch


 Composition api에서 또 하나 봐야 할 항목은 라이프사이클 훅이라 watch 어느 쪽을 사용해야하는가이다. 개인적인 의견으로는 option API에서의 watch와 비교해 Composition API에서의 watch는 매우 편리하다. 그 이유는 분리된 함수내에서 부작용등을 다루기 위한 기능을 제공하고 있기 때문이다.

 방금 봤던 함수로 돌아가자. 이 함수는 보다시피 HTML의 Element를 받아 ref로 리액티브하게  한다. 마운트시에 그 요소에 어떠한 이벤트 리스너를 설치해두고, 언마운트시에 그 리스너를 파기하는 기능을 가지고 있다.

export function useEvent(_el, name, listener) {
  const element = wrap(_el);
  onMounted(() => element.value.addEventListener(name, listener));
  onUnmounted(() => element.value.removeEventListener(name, listener));
}

 이 Element는 브라우저의 window나 document와 같은 global object일 수도 있고 Vue의 가상 DOM에서 핸들링할 수 없는 DOM 요소일 수도 있다. 또는 template 내의 ref 속성에서 참조하고 있는 DOM 요소일 수도 있다.

그렇다면 만약 마운트시에 그 ref에서 참조한 부분이 empty라면 어떨까? v-if를 사용하고 있어 첫 렌더링 시에는 표시되지 않는 요소와 같은 경우엔 오류가 나고 이벤트를 리슨할 수 없다.

 또는 ref값으로 되어 있는 변수 element의 내용물이 리액티브하게 변경되는 경우는 어떨까? 예를 들어 그 요소가 key 속성으로 바인드된 리스트 아이템이거나 v-if로 내보내거나 지웠을 때 요소는 리프레시되고 리스너는 파기되어 버린다.

 

여기서 watch()의 차례

 위 코드를 watch를 사용하면 아래와 같이 바꿔 쓸 수 있다.

export function useEvent(_el, name, listener) {
  const element = wrap(_el);

  watch(element, (el, _, onCleanup) => {
    el && el.addEventListener(name, listener)
    onCleanup(() => el && el.removeEventListener(name, listener))
  })
}

element를 감시하고, 새로운 값 el를 살펴보고 그것이 존재했을 때에만 이벤트 리스너를 설치함으로써, 우선 on Mounted 때의 문제 그  "mount 시 ref가 empty로 실패한다"하는 사태를 회피할 수 있다.

 그리고 watch의 콜백의 제3 인수로 받을 수 있는 on Cleanup이라는 함수를 사용하여 감시 중인 element가 갱신 또는 파기되었을 때의 처리를 기술할 수 있다.즉, remove Event Listener를 unmounted가 아닌 이 자리에 작성할 수 있는 것이다.이로써 두 번째 문제도 해결된 셈이다.element 참조처가 업데이트될 때마다 워치 콜백이 달리고 새로운 값에 addEventListener를, 오래된 값에 removeEventListener를 작동시킬 수 있기 때문이다.

 watch는 물론 DOM 요소의 핸들링 이외에도 모든 면에서 도움이 된다. 당신이 지금까지 무언가를 onMounted로 진행한 후 값에 업데이트가 있을 경우 처리를 하고 싶었던 경우 watch가 모든 문제를 커버할 수 있는 기능을 갖추고 있다.

 

 

Return computed > ref


 또 하나 이야기해두고 싶은 것은 잘라 낸 함수로부터 return하는 값은 단순 ref값보다는 computed 속성 혹은 read only의 값이어야한다는 것이다.

 예를 들어 setup()내에서 이와 같이 useOnline이라는 함수에서 브라우저 API를 통해 현재 온라인인지 아닌지를 리액티브한 플래그로서 template에 전달하고자 한다고 하자. 그리고 그 반환값 isOnline은 일단 ref값이라고 하자.

createComponent({
  setup() {
    const isOnline = useOnline()

    return {
      isOnline,
    }
  }
})

 그러한 경우 useOnline()의 구현은 이렇게 될 것이다. 함수에서 봐야할 부분은 true를 초기값으로 정의한 ref()의 변수 isOnline과 그것을 return하고 있는 2줄의 코드뿐이다.

export function useOnline() {
  const isOnline = ref(true)

  isOnline.value = window.navigator ? window.navigator.onLine : true

  /* 'online'이벤트 리스너로 isOnline를 갱신하는 처리 */

  return isOnline;
}

 먼저 ref에서 플래그를 정의함으로써 함수 내에서 브라우저 접속 상황을 감지하여 isOnline 값을 갱신할 수 있다. 그리고 보시다시피 마지막에는 그대로 ref 값을 return 하고 있다.

 하지만 ref는 뮤터블(변경 가능)한 값이므로, 이 함수의 바깥쪽, 호출측에서 값을 변경할 수 있다. 그렇게 할 수 있게 하는 것이 바람직한 경우(사용자가 값을 조작할 수 있도록 뮤터블한 값을 반환하고자 하는 경우 등)도 있겠지만, 이 경우는 컴포넌트 측에서의 뮤테이션이 스테이트의 일관성을 깨뜨릴 가능성이 있다. 그러니까 다음의 방식으로 구현하자.

  return computed(() => isOnline.value);

 이렇게 하면 함수 내에서는 ref 값을 갱신할 수 있지만, 호출 측에 readonly의 리액티브한 값을 제공할 수 있다.이를 통해 스테이트는 보다 안전하게 다룰 수 있을 것이다. 참고로 만약 값이 객체라면 readonly()를 사용해서 이런 식으로 쓰는 것도 가능하다.

  return readonly({
    isOnline,
    a: 'A',
    b: 'B'
  })

 

 

Name returned properties in context


 마지막으로 이야기할 것은 반환 값 명명에 대한 제안이다. 다음의 함수를 예로 들어 설명하겠다. 이 함수는 DOM Element가 존재했을 때에 그것을 풀스크린으로 할지를 핸드링하는 플래그나 함수를 제공해준다.

export function useFullscreen(target: Ref<HTMLElement | null>) {
  const isFullscreen = ref(false)

  function exitFullscreen() {
    if (document.fullscreenElement) {
      document.exitFullscreen()
    }

    isFullscreen.value = false
  }

  async function enterFullscreen() {
    exitFullscreen()

    if (!target.value) return

    await target.value.requestFullscreen()
    isFullscreen.value = true
  }

  return {
    isFullscreen,
    exitFullscreen,
    enterFullscreen
  }
}

 하지만 바라보면 너무 자주 full screen이라는 단어가 나와서 장황하게 느껴진다. 만든 사람이 호출측에서 분할대입을 사용할지도 모른다고 생각해서 이런 것이 아닐까라고 생각 할 수 있지만, 개인적으로는 그러한 목적을 위해 이런 명명을 하는 것을 좋은 아이디어라고 생각하지 않는다.

const fullscreen = useFullscreen()
  onMounted(() => fullscreen.enterFullscreen())  🤔

 이렇게 되지 않도록 함수안에서는 그 함수 문맥이 있는 것을 의식하여 아래와 같이 바꿔쓰면 어떨까?

export function useFullscreen(target: Ref<HTMLElement | null>) {
  const isActive = ref(false)

  function exit() {
    if (document.fullscreenElement) {
      document.exitFullscreen()
    }

    isActive.value = false
  }

  async function enter() {
    exit()

    if (!target.value) return

    await target.value.requestFullscreen()
    isActive.value = true
  }

  return {
    isActive,
    exit,
    enter
  }
}

이제 이 반환 값을 사용하는 쪽은 명명의 더블을 생략한 형태로 사용할 수 있다.

 const fullscreen = useFullscreen()
  onMounted(() => fullscreen.enter()) 😊

 게다가 분할 대입을 사용하고 싶다면 이렇게 리네임할 수 있다.

  const { enter: enterFullscreen } = useFullscreen()
  onMounted(() => enterFullscreen ()) 😊

참고자료

https://qiita.com/herishiro/items/bacfc84af95a1819a794

728x90