오르막길 잊기전에 기록하기

[번역]Vue.js 3: Future-Oriented Programming

unsplash-logoKyaw Tun

들어가며

실무에 Vue를 사용하면서 재사용 할 수 있는 로직들은 주로 믹스인으로 분리하여 사용하고 있었다.

믹스인은 많은 편리함을 가져다 주었지만 늘 몇가지 마음을 무겁게 하는 부분이 있었는데, 특히 컴포넌트가 복잡해지고 이로 인해 믹스인이 중첩될 때 이 속성이 혹은 이 메소드가 대체 어디서 왔는지 코드를 읽기가 쉽지 않다는 부분이었다.

아마 로직을 어떻게 깔끔하게 재사용할지에 대한 고민은 Vue를 꽤나 크고 복잡한 프로젝트에서 사용중이라면 아마 많은 사람들이 겪었을 고민이고 Vue는 이에 대한 해결책으로 Composition API를 제시했다.

이와 관련하여 쉽게 설명한 포스트가 있어 번역하여 공유한다.


이 글은 Vue.js 3: Future-Oriented Programming를 원저자의 허락을 받아 번역하였다.

Vue.js에 관심을 가지고 있다면 아마 곧 3번째 버전이 출시된다는 것을 알고 있을것이다. (미래에 이 글을 읽고 있다면, 이 글이 아직, 여전히 유효하기를 바란다. 😉)

활발하게 개발되고 있는 새로운 버전의 기능들은 다음 레파지토리의 RFC(request for comments)를 통해 확인할 수 있다. 그 중 하나인 functional-api는 Vue 어플리케이션을 개발하는 방식에 드라마틱한 변화를 가져올것 같다.

이 글은 JavaScript와 Vue에 관하여 어느정도 사전지식을 가지고 있다고 가정하고 쓰여졌다.

(역주) 현재는 Function-based Component API 에서 Composition API로 이름이 변경되고 약간의 변화가 생겼다. RFC는 여기 현재 버전의 도큐먼트는 여기를 참고하면 된다.

(광고) 시작하기 전에, Bit을 사용하면 Vue 컴포넌트를 의존하고 있는 모듈들과 함께 캡슐화 할 수 있습니다. 이는 더 쉽게 코드를 재사용하고 유지보수하며 더 적은 오버헤드를 갖도록 잘 모듈화 된 애플리케이션을 설계하는걸 돕습니다. Bit을 이용하여 독립작인 Vue 컴포넌트를 공유하여 협업하세요.

현재 API들의 문제

이해를 돕는 가장 좋은 방법은 예제를 보는것이니, 예제를 살펴보도록 하자. 유저의 데이터를 API로 부터 받아오고, 그 동안 로딩중이라는 상태를 표시하고 스크롤의 높이에 따라 변하는 헤더를 가진 컴포넌트를 구현해야 한다고 생각해보자. 아마 이렇게 생긴?

1_QQigXylzQ95jxCb_FI_dCQ

실제로 동작하는 예제는 이 곳에서 볼 수 있다.

여러 컴포넌트에서 재사용될 수 있는 로직은 추출하는것이 좋다. 현재 Vue 2.x의 API에서는 잘 알려진 몇가지 패턴이 있다.

스크롤의 위치를 쫓는 로직은 믹스인으로, 데이터를 가져오는 로직은 고차 컴포넌트로 옮겨보자. 일반적인 구현은 다음과 같을것이다.

Scroll Mixin:

const scrollMixin = {
    data() {
        return {
            pageOffset: 0
        }
    },
    mounted() {
        window.addEventListener('scroll', this.update)
    },
    destroyed() {
        window.removeEventListener('scroll', this.update)
    },
    methods: {
        update() {
            this.pageOffset = window.pageYOffset
        }
    }
}

여기서 스크롤 이벤트에 리스너를 추가하고, 페이지의 offset을 따라 pageOffset이라는 프로퍼티에 이 값을 저장한다.

유저 정보를 가져오는 고차 컴포넌트는 다음과 같이 구현할 수 있다.

import { fetchUserPosts } from '@/api'

const withPostsHOC = WrappedComponent => ({
    props: WrappedComponent.props,
    data() {
        return {
            postsIsLoading: false,
            fetchedPosts: []
        }
    },
    watch: {
        id: {
            handler: 'fetchPosts',
            immediate: true
        }
    },
    methods: {
        async fetchPosts() {
            this.postsIsLoading = true
            this.fetchedPosts = await fetchUserPosts(this.id)
            this.postsIsLoading = false
        }
    },
    computed: {
        postsCount() {
            return this.fetchedPosts.length
        }
    },
    render(h) {
        return h(WrappedComponent, {
            props: {
                ...this.$props,
                isLoading: this.postsIsLoading,
                posts: this.fetchedPosts,
                count: this.postsCount
            }
        })
    }
})

여기서 isLoading, posts 프로퍼티는 각각 로딩 상태와 유저의 포스트에 관한 데이터로 초기화된다. fetchPosts 메소드는 인스턴스가 생성된 후 props.id의 값이 변경될 때 마다 새로운 id를 가진 유저의 포스트를 가져오기 위해 호출된다.

위 코드는 HOC의 완전한 구현은 아니지만, 이 예제에는 충분할 것이다. 여기서 우리는 특정 컴포넌트를 감싸서 컴포넌트의 프로퍼티와 데이터를 가져오는것과 관련된 프로퍼티를 함께 전달한다.

감싸지는 컴포넌트는 다음과 같다.

export default {
    name: 'PostsPage',
    mixins: [scrollMixin],
    props: {
        id: Number,
        isLoading: Boolean,
        posts: Array,
        count: Number
    }
}

해당 프로퍼티에 접근하려면 HOC로 감싸야 한다.

const PostsPage = withPostsHOC(PostsPage)

(역주) 해당 컴포넌트의 전체 코드는 여기서 확인하시면 된다.

특정 로직을 믹스인과 고차 컴포넌트를 통해 분리해 냈으므로, 이제 이 로직들은 다른 그 어떤 컴포넌트에서도 재사용 가능하다. 모든게 아름답게 끝나면 참 좋겠다만, 이 접근법들에는 몇가지 문제가 존재한다.

1. 네임스페이스의 충돌 ⚔️

PostPage 컴포넌트에 update 라는 메소드를 추가해야한다고 생각해보자.

export default {
    name: 'PostsPage',
    mixins: [scrollMixin],
    props: {
        id: Number,
        isLoading: Boolean,
        posts: Array,
        count: Number
    },
    methods: {
        update() {
            console.log('some update logic here')
        }
    }
}

페이지를 다시 열고 스크롤을 하게 되면, 상단 영역은 더 이상 보여지지 않는다. 우리가 추가한 메소드가 믹스인의 update 메소드를 덮어쓰기 때문이다.

고차 컴포넌트에서도 마찬가지이다. 만약 datafetchedPostsposts로 변경하게 되면 문제가 발생하게 된다.

const withPostsHOC = WrappedComponent => ({
    props: WrappedComponent.props, // ['posts', ...]
    data() {
        return {
            postsIsLoading: false,
            posts: [] // fetchedPosts -> posts
        }
    },

아마 이런 에러를 보게 될 것이다.

스크린샷 2019-09-25 오전 9 15 46

그 이유는 감싸려고 했던 컴포넌트가 이미 posts 라는 이름의 프로퍼티를 사용하고 있기 때문이다.

2. 명확하지 않은 출처 📦

얼마 후에 이 컴포넌트에 다른 믹스인을 사용해야겠다고 결심한다면 어떻게 될까?

export default {
    name: 'PostsPage',
    mixins: [scrollMixin, mouseMixin],

이 코드를 보고 pageOffset 프로퍼티가 어느 믹스인으로부터 주입되었는지 정확히 설명할수 있는가? 또는 두 믹스인 모두 yOffset 프로퍼티를 가질수도 있다. 이럴 경우 마지막 믹스인이 이전 믹스인에 정의되어 있던 yOffset 프로퍼티를 오버라이드 하게된다. 이는 좋은 코드가 아니며 때로는 예상치 못한 많은 버그를 만들어 낼지도 모른다. 😕

3. 성능 ⏱

고차 컴포넌트의 또다른 문제는 단지 비즈니스 로직을 재사용하기 위한 목적의 별개의 컴포넌트 인스턴스를 생성해야한다는 것이고, 이 또한 비용임을 명심해야 한다.

준비 🏗

똑같은 문제를 다음 버전에서 제공될 function-based API 를 통해 해결해 보자.

Vue3는 아직 릴리즈되지 않았지만, vue-function-api 라는 플러그인을 통해 2.x에서도 3.x의 함수형 API를 사용할 수 있다.

(역주) rfc의 변화에 맞춰 플러그인 이름도 composition-api 로 변경되었다.

우선 이 플러그인을 설치해야 한다.

$ npm install vue-function-api

그런다음 Vue.use()를 통해 이 플러그인을 설치할것을 명시한다.

import Vue from 'vue'
import { plugin } from 'vue-function-api'

Vue.use(plugin)

함수 기반의 API가 제공하는 주요한 기능은 setup() 이라는 새로운 컴포넌트 옵션이다. 이름에서 알 수 있듯 새로운 API의 함수를 사용하여 컴포넌트의 로직을 설정한다. 이를 통해 스크롤 위치에 따른 상단 영역을 구현해 보자. 기본적인 컴포넌트는 다음과 같다.

<template>
    <div class="container">
        <p
            v-if="isLoading"
            class="loading"
        >Posts loading...</p>
        <template v-else>
            <div
                class="topbar"
                :class="{ open: pageOffset > 120 }"
            >
                <div class="container content">
                    <h3>User # posts</h3>
                    <span class="count">8 items</span>
                </div>
            </div>
            <h1>User # posts</h1>
            <span class="count">8 items</span>
            <div
                v-for="post in posts"
                :key="post.id"
                class="box"
            >
                <h3></h3>
                <p></p>
            </div>
        </template>
    </div>
</template>
<script>
export default {
  setup(props) {
    const pageOffset = 0
    return {
      pageOffset
    }
  }
}
</script>

setup 함수는 첫번째 인자로 props 객체를 받고, 이 props 객체는 반응적이다. 또한 템플릿의 렌더 컨텍스트에 노출될 pageOffset 프로퍼티를 포함하는 객체를 반환한다. pageOffset 도 반응형으로 동작하지만, 그 범위는 오로지 렌더 컨텍스트로 제한된다. 템플릿 내에서 다른 프로퍼티를 사용하듯이 사용할 수 있다.

<div class="topbar" :class="{ open: pageOffset > 120 }">...</div>

그러나 이 프로퍼티는 모든 스크롤 이벤트에 의해 변경되어야 한다. 이를 구현하기 위해서는 컴포넌트가 마운트 될때 스크롤 이벤트 리스너를 등록하고, 언마운트 될 때 이 리스너를 제거해 주어야한다. 이럴 때 사용하기 위해서 value, onMounted, onUnmounted API 함수들이 존재한다.

(역주) value는 현재 ref로 이름이 변경되었다.

<template>생략..</template>
<script>
import { value, onMounted, onUnmounted } from 'vue-function-api'
export default {
  setup(props) {
    const pageOffset = value(0)
    const update = () => {
        pageOffset.value = window.pageYOffset
    }

    onMounted(() => window.addEventListener('scroll', update))
    onUnmounted(() => window.removeEventListener('scroll', update))

    return {
      pageOffset
    }
  }
}
</script>

2.x 버전의 뷰가 가진 모든 라이프사이클훅에 해당하는 onXXX 함수를 setup() 내에서 사용할 수 있다는 것을 기억해라.

위 예제를 통해 pageOffset 변수는 .value 라는 반응형 속성을 포함하고 있다는것도 알아차렸을 것이다. 이렇게 한번 감싸진 프로퍼티를 사용하는 이유는 자바스크립트에서 숫자나 문자열같은 원시값들은 참조 reference 로 전달되지 않기때문이다. 값 래퍼는 임의의 값 유형들에 대해 변경 가능하고 반응형인 참조를 전달할 수 있는 방법을 제공한다.

pageOffset 객체가 어떻게 구성되어 있는지는 다음을 참고한다.

스크린샷 2019-09-25 오전 9 17 21

다음은 사용자의 데이터를 서버에서 가져오는 기능을 구현할 차례다. 옵션 기반의 API를 사용할 때 뿐만 아니라 함수 기반 API를 사용할 때도 계산된 속성과 와쳐를 선언할 수 있다.

<script>
import {
    value,
    watch,
    computed,
    onMounted,
    onUnmounted
} from 'vue-function-api'
import { fetchUserPosts } from '@/api'
export default {
  setup(props) {
    const pageOffset = value(0)
    const isLoading = value(false)
    const posts = value([])
    const count = computed(() => posts.value.length)
    const update = () => {
      pageOffset.value = window.pageYOffset
    }

    onMounted(() => window.addEventListener('scroll', update))
    onUnmounted(() => window.removeEventListener('scroll', update))

    watch(
      () => props.id,
      async id => {
        isLoading.value = true
        posts.value = await fetchUserPosts(id)
        isLoading.value = false
      }
    )

    return {
      isLoading,
      pageOffset,
      posts,
      count
    }
  }
}
</script>

계산된 속성은 2.x 에서와 동일하게 동작한다. 의존하고 있는 값을 추적하고 오로지 이 값이 변경될 때만 다시 계산된다.

watch 함수에 전달되는 첫번째 인자는 “source” 라고 하는데 다음중 하나를 넘겨줄 수 있다.

두번째 인자는 getter나 value wrapper의 값이 변경되었을 때 호출될 콜백이다.

함수 기반의 API를 사용하여 합성될 컴포넌트를 구현하였다. 다음 단계는 이 로직들은 재사용 가능하게 만드는 것이다.

분해 🎻 ✂️

이 부분이 가장 흥미로운 부분인데, 논리와 관련된 코드를 재사용하기 위해 “composition function” 을 이용해 추출한 다음 반응형 상태를 반환할 수 있다.

import {
    value,
    watch,
    computed,
    onMounted,
    onUnmounted
} from 'vue-function-api'
import { fetchUserPosts } from '@/api'
function useScroll() {
    const pageOffset = value(0)
    const update = () => {
        pageOffset.value = window.pageYOffset
    }
    onMounted(() => window.addEventListener('scroll', update))
    onUnmounted(() => window.removeEventListener('scroll', update))
    return { pageOffset }
}
function useFetchPosts(props) {
    const isLoading = value(false)
    const posts = value([])
    watch(
        () => props.id,
        async id => {
            isLoading.value = true
            posts.value = await fetchUserPosts(id)
            isLoading.value = false
        }
    )
    return { isLoading, posts }
}
export default {
    props: {
        id: Number
    },
    setup(props) {
        const { isLoading, posts } = useFetchPosts(props)
        const count = computed(() => posts.value.length)
        return {
            ...useScroll(),
            isLoading,
            posts,
            count
        }
    }
}

어떻게 useFetchPostsuseScroll 함수를 사용하여 반응형 속성을 반환하도록 하였는지 주목하라.

이러한 함수들은 별도의 파일로 분리해낸 뒤 다른 컴포넌트에서도 사용할 수 있다. 기존의 옵션 기반의 해결책들과 비교해보면 다음과 같다.

공식 RFC 페이지를 참고하면 이보다 더 많은 이점들을 찾을 수 있다. 이 아티클에서 사용된 모든 예제 코드는 여기서 확인할 수 있다. 동작하는 예제는 여기에서 확인할 수 있다.

comments powered by Disqus