[Vue] Teleport
포스트
취소

[Vue] Teleport

개요

Vue3에서는 정말 많은게 새로 추가되고 삭제 되었습니다.
Composition API, ref, reactive 등의 변경점이 있는데, 이들 중 제가 가장 관심이 있었던건 Teleport였습니다.

Teleport란 React의 Portal과 같은 기능을 합니다.
Teleport는 컴포넌트 내부의 특정 객체를 페이지 내 원하는 위치에 렌더링하는 기술입니다.
아마 SPA 개발 툴을 사용해보지 않은 분들께서는 “고작 엘리먼트 위치 변경하는게 그렇게 관심받을 일인가?”라고 생각 하실 수도 있습니다.
그런 분들은 이 문서를 꼭 읽어주세요!

컴포넌트 주도 개발의 문제점

컴포넌트 주도 개발이란, UI를 아주 작은 단위로 쪼개서 이를 서로 연결하여 하나의 기능을 만드는 형태의 개발 방식입니다.
React, Vue와 같은 프레임워크는 이 방식을 따르고 있어요.
그럼 여기서 예시를 하나 들어봅시다. Dialog Dialog 위 움짤과 같은 Dialog는 컴포넌트를 어떻게 구성하면 좋을까요?
아마 대부분 아래와 같은 구성 요소를 가질겁니다.

  • 백그라운드 마스크
  • 컨텐츠가 보여질 팝업

컨텐츠가 보여질 팝업이야 일반적인 UI를 만들 듯 구성하면 아무런 문제가 될 게 없을 것 같습니다.
그런데 백그라운드에 씌워질 마스크는 어떻게 구현해야 할까요?
깊게 생각할 것 없이 아래와 같은 CSS를 갖도록 구현될 것 같습니다.

1
2
3
4
5
6
7
8
.dialog-mask {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, .45);
}

일반적인 상황에서는 문제 없이 동작하겠지만, 아주 만약에 position: fixed가 적용 된 Element의 하위에서 Dialog를 사용해야 할 일이 생긴다면 어떻게 될까요?
아마 부모 Element에 종속적으로 동작하게 될 것입니다.
그렇기에 이 마스크만 부모의 간섭을 받지 않는 페이지의 최상단, 즉 body 태그의 직계 자식으로 올려 줄 필요가 있습니다.

Teleport 없이 마스크 옮기기

Javascript

1
2
3
4
5
6
7
8
9
10
11
12
export default {
  // ...
  mounted() {
    const mask = document.createElement('div');
    mask.setAttribute('class', 'mask');
    window.document.body.append(mask);
  },
  beforeDestroy() {
    document.querySelector('.mask').remove();
  },
  // ...
};

이런 식으로 컴포넌트가 마운트 되는 순간에 document.createElement를 이용하여 body의 뒤쪽에 추가하는 방법이 있을 수 있습니다.
물론 이렇게 하면 body 태그에 마스크를 추가하는 것 자체는 문제가 없을거예요.
그런데 이제 마스크에 이벤트를 추가하거나, 또 다른 Element를 자식으로 배치하는 등의 상황에서는 문제가 심각해집니다.
정상적인 프레임워크의 흐름을 따르지 않으므로 라이프사이클 및 Element Rendering 등의 상황에서 프레임워크가 주는 이점을 얻지 못하게 됩니다. 커스터마이징이 죽고 싶을 정도로 어려워지는건 덤이구요.
또한 Scoped CSS를 사용하지도 못하겠네요.

최상단 컴포넌트에 마스크를 배치하기

1
2
3
4
5
<template>
  <div class="mask">
    <child />
  </div>
</template>
1
2
3
<template>
  <div class="dialog"></div>
</template>

이런식으로 제어한다면 컴포넌트의 제어 하에 마스크를 그렸으니 위에서 말한 문제점은 해소가 된 듯 합니다.
parent.vue 파일에서 CSS를 정의하면 마스크에도 적용이 될거고, 컴포넌트에서 그려졌으니 생명주기도 정상적으로 적용이 될겁니다.

그렇지만 Dialog를 끄고 켜는 모든 상황에서 부모와 자식간 데이터와 이벤트를 전달받아야 한다는 점이 굉장히 큰 리스크로 다가오게 되었습니다.
parent.vue에서 isVisibleDialog 변수를 들고있고, 이 변수에 따라 마스크를 활성 / 비활성 해야한다면 child.vue에서 Dialog를 표시하는 버튼을 누를 때마다 emit으로 마스크가 존재하는 부모까지 이벤트를 전달해야 마스크의 활성 / 비활성 여부를 제어할 수 있어요.

1
2
3
4
5
6
7
8
9
<template>
  <Child>
    <Child>
    <Child>
      <!-- 여기에 버튼이 있다면? -->
    </Child>
    </Child>
  </Child>
</template>

만약 위와 같은 구조로 이루어져있다면 정말 너무 끔찍하네요.

또한, 인적 실수로 인해 이벤트를 상단까지 전달하는 것에 실패하여 마스크를 제어하지 못하게 된다면 Dialog는 표시 되는데 마스크는 표시 되지 않는 현상이 일어날 수 있어요.
Dialog와 마스크는 사실상 하나의 컴포넌트라고 봐도 무방한데도 서로의 행동에서 결과를 예측하기 어렵게 됩니다.

Teleport 이용하기

사용법

1
2
3
4
5
<template>
  <teleport to="css-selector">
    <component />
  </teleport>
</template>

위 처럼 teleport 태그를 활용하시면 됩니다.
teleport 태그는 to Property를 받는데, 이 Property에 CSS Selector를 넣으면 teleport 태그 안에 있는 Element들이 해당 셀렉터 내부로 이동합니다. scoped CSS도 정상적으로 동작하고, 컴포넌트의 Life cycle대로 탄생하고 소멸합니다.

예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<style scoped>
  .dialog__bg {
    position: fixed;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 10;
  }
  .dialog__bg.dialog__bg--active {
    background-color: rgba(0, 0, 0, .4);
    pointer-events: inherit;
  }
  .dialog__content {
    border-radius: 4px;
    background-color: #FFF;
    max-width: 500px;
    width: 100%;
    position: fixed;
    right: 50%;
    top: 50%;
    transform: translate(50%, -50%);
    z-index: 11;
  }
  .dialog__content .dialog-title {
    padding: 16px;
    font-weight: bold;
    font-size: 21px;
  }
  .dialog__content .dialog-content {
    padding: 16px;
    padding-top: 0;
    font-size: 14px;
  }
</style>

<template>
  <div
    v-if="visible"
    class="dialog"
  >
    <teleport to='body'>
      <div
        :class="{ 'dialog__bg': true, 'dialog__bg--active': visible }"
        @click="onClickModal" />
    </teleport>
    <div class="dialog__content">
      <div class="dialog-title">
        <slot name="title"></slot>
      </div>
      <div class="dialog-content">
        <slot name="content"></slot>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue'
export default defineComponent({
  props: {
    visible: {
      type: Boolean as PropType<boolean>,
      required: false,
      default: false,
    },
  },
  setup(props, { emit }) {
    const onClickModal = () => {
      emit('update:visible');
    };
    return {
      onClickModal,
    };
  },
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
  <div class="dialog-example">
    <button
      type="button"
      @click="onClickShowDialogButton">
      Dialog 표시
    </button>
    <Dialog
      v-model:visible="isVisible">
      <template v-slot:title>
        제목
      </template>
      <template v-slot:content>
        내용
      </template>
    </Dialog>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import Dialog from './components/Dialog/index.vue';
export default defineComponent({
  components: {
    Dialog,
  },
  setup() {
    const isVisible = ref(false);
    const onClickShowDialogButton = () => {
      isVisible.value = true;
    };
    return {
      isVisible,
      onClickShowDialogButton,
    };
  },
});
</script>

위 코드처럼 Teleport 태그를 이용하면 아주 깔끔하고 간단한 방법으로 Dialog를 만들 수 있습니다.
예시 코드는 여기에서 확인 가능합니다.

Vue2는 못써요?

안타깝지만 Vue2에서는 Element를 특정 위치로 보내는 기능이 기본적으로 내장 되어 있지 않습니다.
하지만 다행스럽게도 Vue Community에서 이 기능을 지원하는 라이브러리를 제작했습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.