Vue2 쓰세요? Composition API 한번 드셔보세요🙇‍♀️

AUSG·2020년 6월 29일
30
post-thumbnail
post-custom-banner

🐣 Intro

주니어 프론트엔드 개발자로 거듭나기 위한 노력 6개월 차..! 저의 고군분투를 조금씩 얘기해볼까합니다. 그 중 첫 번째로 Vue composition API를 소개하겠습니다. 사실 소개하기엔 좋은 글이 너무 많아서, 간단하게 어떻게 Composition API로 옮겨가는 지 예시로 설명하려고 합니다.

여러 Composition API를 활용한 코드 예시를 보고싶으신 분들은 제가 개발하는 프로젝트 SpaceONE(github.com/spaceone-dev/console)을 참고해주세요~

이 글은 Vue에 대한 기본 지식이 있는 분들을 대상으로 하는 튜토리얼 글입니다!
Vue의 반응형, 기존의 라이프사이클, 렌더링 방식 등에 대한 설명을 생략합니다!

Composition API?

Composition API

Composition API란 컴포넌트 로직을 유연하게 구성할 수 있는 API 모음으로 로직의 재사용성과 가독성을 높여줍니다.
위의 그림과 같이 기존에는 data, methods, computed에 각각 로직이 흩어져 있었는데 Composition API를 활용하면 로직이 모아지는 것을 알 수 있습니다.

Composition API의 가장 큰 특징 중 하나는 Setup입니다. 기존 Vue2의 Life Cycle과 비교하자면,

beforeCreate -> setup()
created -> setup()

beforeMount -> onBeforeMount
mounted -> onMounted
beforeUpdate -> onBeforeUpdate
updated -> onUpdated
beforeDestroy -> onBeforeUnmount
destroyed -> onUnmounted
activated -> onActivated
deactivated -> onDeactivated
errorCaptured -> onErrorCaptured

위와 같이 beforeCreate나 created는 모두 setup() 안으로 들어가고, 나머지 life cycle은 onXXX function들로 적용할 수 있습니다.
즉, setup()이라는 일종의 훅에서 초기화를 진행합니다.
setup에서 반응형 데이터를 바인딩해주고, 기존에는 computed에 따로 명시되어 있던 computed 속성들을 정의해줄 수 있습니다.

vue-next에서는 더 나은 타입스크립트의 지원, this 바인딩의 변화, portal의 등장, fragment를 통한 불필요한 wrapper용 root element 제거 등 여러 변화가 제시되었습니다. 하지만 이 글은 vue composition API에 집중한 글이기 때문에 생략하도록 하겠습니다!

🤔 그래서 어떻게 적용해요?

구구절절 Composition API가 이렇게 좋아요~ 라는 설명은 각설하고, 직접 한번 적용해보겠습니다.
준비물은 Vue2로 작성되어 있는 기존의 코드입니다. 글을 쓰는 김에 기존 프로젝트의 Vue2 코드를 리팩토링하기로 마음먹고 진행해보겠습니다. (css는 생략!)

(Deprecated 예정인 코드..)

<template>
    <div ref="container" class="p-menu-list">
  			(생략)
    </div>
</template>

<script>
import PMenuItem from '@/components/molecules/menu-item/MenuItem.vue';
import PTooltipButton from '@/components/organisms/buttons/tooltip-button/TooltipButton.vue';
import PButton from '@/components/atoms/buttons/Button.vue';
import {
    computed, getCurrentInstance, onMounted, onUnmounted, reactive, toRefs,
} from '@vue/composition-api';

const ACTIVATOR_MENU_SPACE = -8;

export default {
    name: 'PMenuList',
    events: ['change', 'show', 'hide', 'select'],
    components: { PMenuItem, PTooltipButton, PButton },
    props: {
        listItems: {
            type: Array,
            default: () => ([]),
            validator(listItems) {
                return listItems.every((listItem) => {
                    const keys = Object.keys(listItem);
                    return keys.every(key => ['key', 'contents', 'indent', 'selected'].includes(key));
                });
            },
        },
        contents: {
            type: String,
            default: '',
        },
        tooltip: {
            type: String,
            default: '',
        },
        tooltipOptions: {
            type: Object,
            default: () => ({ offset: '12px' }),
        },
    },
    data() {
        return {
            visible: false,
            selectedIdx: null,
        };
    },
    computed: {
        activatorElement() {
            return this.$refs.container;
        },
        position() {
            return `${this.activatorElement.clientWidth + ACTIVATOR_MENU_SPACE}px`;
        },
    },
    created() {
        this.setSelectedIdx();
        document.addEventListener('click', this.hide, true);
        document.addEventListener('click', this.hide, false);
    },
    destroyed() {
        document.removeEventListener('click', this.hide, true);
        document.removeEventListener('click', this.hide, false);
    },
    methods: {
        setSelectedIdx() {
            this.listItems.some((item, idx) => {
                if (item.selected) this.selectedIdx = idx;
                return item.selected;
            });
        },
        show() {
            this.visible = true;
        },
        hide() {
            this.visible = false;
        },
        toggle() {
            this.visible = !this.visible;
        },
        select(item, idx, e) {
            this.hide();
            if (this.selectedIdx !== null) {
                this.$set(this.listItems[this.selectedIdx], 'selected', false);
                this.$set(this.listItems[idx], 'selected', true);
                this.selectedIdx = idx;
            }
            this.$emit('select', item, idx, e);
        },
    },
};
</script>

기존에 이렇게 일반적인 vue2로 작성된 코드가 있습니다. 이 코드를 재작성해보도록 하겠습니다.

우선, composition api를 설치해줍니다!(vue3에는 내장되어있을테지만, 아직 vue-3는 베타 단계이기 때문에 미리 써볼 땐 설치를 해줍니다. ⚙️)

npm install @vue/composition-api
# or
yarn add @vue/composition-api

그 후, 아래와 같이 코드 작성에 필요한 api들을 import합니다.


import {
    computed, getCurrentInstance, onMounted, onUnmounted, reactive, toRefs,
} from '@vue/composition-api';

이제 본격적으로 마이그레이션해보겠습니다! 🚚 (제가 마이그레이션할 때 편한 주관적인 순서입니다..😅)

1. data()와 computed() 바꾸기

export default {
    name: 'PMenuList',
    events: ['change', 'show', 'hide', 'select'],
    components: { PMenuItem, PTooltipButton, PButton },
    props: {
        listItems: {
            type: Array,
            default: () => ([]),
            validator(listItems) {
                return listItems.every((listItem) => {
                    const keys = Object.keys(listItem);
                    return keys.every(key => ['key', 'contents', 'indent', 'selected'].includes(key));
                });
            },
        },
        contents: {
            type: String,
            default: '',
        },
        tooltip: {
            type: String,
            default: '',
        },
        tooltipOptions: {
            type: Object,
            default: () => ({ offset: '12px' }),
        },
    },

    setup(props, context) {
        const vm = getCurrentInstance();

        const state = reactive({
            visible: false,
            selectedIdx: null,
            container: null,
        });

        const position = computed(() => `${state.container ? state.container.clientWidth + ACTIVATOR_MENU_SPACE : undefined}px`);
}

      return {
            ...toRefs(state),
            position,
        };

name, components, props는 그대로 두고 기존의 data()에 있던 초기화 속성들을 setup 안으로 넣어줍니다. setup은 props, context를 매개변수로 받고 context는 attrs, slots, emit, parent, root 속성을 갖습니다. (context.root.$route, context.emit 등등으로 쓰입니다..)

state를 정의하는 방법은 ref로 할 수도 있고, reactive로 할 수도 있는데 저는 여러 개의 reactive한 값들을 정의하고 초기화해줄때는 reactive가 편리해서 reactive로 하는 편입니다.
reactive는 객체를 받지만, ref는 여러 타입을 받을 수 있고 ref는 .value로 접근해야 한다는 차이점이 있습니다.

예를 들어,

 const capacity = ref(4);
    const attending = ref(["Tim", "Bob", "Joe"]);
    const spacesLeft = computed(() => {
      return capacity.value - attending.value.length;
    });

요렇게도 할 수 있고,

 const event = reactive({
      capacity: 4,
      attending: ["Tim", "Bob", "Joe"],
      spacesLeft: computed(() => { return event.capacity - event.attending.length; })
    });

요렇게도 할 수 있습니다. (전 후자를 선호하고, interface를 이용합니다)
reactive로 초기화해준 이후에는 toRefs를 이용해서 destructuring을 합니다. 일반적인 destructuring을 이용하면 반응형으로 동작하지 않기 때문입니다.

2. methods 바꾸기

기존의 methods에 명세되어 있던 함수들을 꺼내줍니다.

(생략)
    setup(props, context) {
        const vm = getCurrentInstance();

        const state = reactive({
            visible: false,
            selectedIdx: null,
            container: null,
        });

        const position = computed(() => `${state.container ? state.container.clientWidth + ACTIVATOR_MENU_SPACE : undefined}px`);

        const setSelectedIdx = () => {
            props.listItems.some((item, idx) => {
                if (item.selected) state.selectedIdx = idx;
                return item.selected;
            });
        };

        const show = () => {
            state.visible = true;
        };

        const hide = () => {
            state.visible = false;
        };

        const toggle = () => {
            state.visible = !state.visible;
        };

        const select = (item, idx, e) => {
            hide();
            if (state.selectedIdx !== null) {
                vm.$set(props.listItems[state.selectedIdx], 'selected', false);
                vm.$set(props.listItems[idx], 'selected', true);
            }
            vm.$emit('select', item, idx, e);
        };

        return {
            ...toRefs(state),
            setSelectedIdx,
            show,
            hide,
            toggle,
            select,
            position,
        };

기존 Vue2에서는 props든 data든 상관 없이 모두 this에 바인딩 되었는데, Composition API를 활용하면 위와 같이 state의 속성은 state에, props의 속성은 props에 바인딩되서 보다 명시적인 것을 알 수 있습니다.
methods를 바꿀 때 가장 중요한 것은 꼭 return을 해주기!입니다.

vm = getCurrentInstance()는 composition api에서 추가된 함수인데, 현재 vue instance를 참조할 수 있도록 해줍니다.

3. Lifecycle 반영하기

(생략)
        onMounted(() => {
            setSelectedIdx();
            document.addEventListener('click', hide, true);
            document.addEventListener('click', hide, false);
        });

        onUnmounted(() => {
            document.removeEventListener('click', hide, true);
            document.removeEventListener('click', hide, false);
        });

        return {
            ...toRefs(state),
            setSelectedIdx,
            show,
            hide,
            toggle,
            select,
            position,
        };

앞서 설명에서 말했듯이 created, destroyed라는 라이프 사이클이 onMounted, onUnmounted로 바뀌었기 때문에 해당 사항을 적용해줍니다!

추가) DOM 접근

이제 마이그레이션이 끝났습니다! 그런데 마이그레이션을 하는 도중 한 가지 의문이 생기지 않으셨나요?
바로

    computed: {
        activatorElement() {
            return this.$refs.container;
        },
        position() {
            return `${this.activatorElement.clientWidth + ACTIVATOR_MENU_SPACE}px`;
        },
    }

요 코드의 activatorElement가 어디로 사라진건데?! 라는 의문입니다.
사실 저 activatorElement는 그저 html의 <div ref="container"> 라는 DOM을 참조하기 위해서 사용되었던 코드입니다.. 😅
그런데 setup 내의 state에서 초기화를 해주면 해당 DOM에 접근할 수 있기 때문에 해당 코드가 삭제되었습니다. 대신 state내에 container: null,이라는 속성이 추가되고 position에서 state.container로 접근할 수 있습니다! 🤓

결론 🥳

다소 장황한 설명이었는데 잘 따라오셨나요~? 기존에 가지고 계신 Vue2 코드에 적용하신다면 보다 편하게 적용할 수 있을거라고 생각합니다!
제가 느끼기에 Composition API는

  1. 로직을 모아줌으로써 가독성 향상
  2. 죄다 this에 때려박는 바인딩에서의 탈피 ✨ (props가 props라고 왜 말을 못하고 다 this였니..)
  3. this.$refs에서 벗어난 DOM 접근
  4. 이 글엔 명시하진 않았지만 Typescript 적용에 용이

이런 장점들을 가지고 있다고 생각합니다! 다들 Composition API와 함께 Vue-next로 가요~ 🚀

글쓴이 🐶

  • 이시연 - 👨‍👩‍👧‍👧 AUSG (AWS University Student Group) 3기로 활동 중
  • 관심사
    • Vue.js, PostCSS, SCSS 등 최신 프론트엔드 기술
    • ES6, Typescript
    • AWS를 비롯한 클라우드 네이티브 개발
    • Database Indexing, Query Optimization, Graph Database 등
  • Github - github.com/siyeons
  • Email - siyeonleeme@gmail.com

레퍼런스 📖

[API Reference] https://composition-api.vuejs.org/api.html#setup
[Vue 3에 도입될 composition api 간단 리뷰] https://velog.io/@ashnamuh/Vue-3%EC%97%90-%EB%8F%84%EC%9E%85%EB%90%A0-composition-api-%EA%B0%84%EB%8B%A8-%EB%A6%AC%EB%B7%B0
[VUE 3 COMPOSITION API: REF VS REACTIVE] https://www.danvega.dev/blog/2020/02/12/vue3-ref-vs-reactive/
[VUE 3 COMPOSITION API
CHEAT SHEET] https://www.vuemastery.com/pdf/Vue-3-Cheat-Sheet.pdf
(이거 꼭 보세요!!)
[[Vue.js] Composition API 살펴보기] https://geundung.dev/102
[How to use the new composition API in Vue] https://medium.com/javascript-in-plain-english/how-to-use-composition-api-in-vue-967fc9b8393c

profile
AWSKRUG University Student Group의 공식 벨로그 계정입니다. 멤버들이 돌아가며 글을 쓰고 있습니다.
post-custom-banner

0개의 댓글