
๐ฏ Teleport๊ฐ ํด๊ฒฐํ๋ ๊ทผ๋ณธ์ ์ธ ๋ฌธ์
Vue 3์ Teleport๋ ๋จ์ํ ํธ์ ๊ธฐ๋ฅ์ด ์๋๋๋ค. ๋ชจ๋ ์น ๊ฐ๋ฐ์์ ๊ฐ์ฅ ๊น๋ค๋ก์ด ๋ฌธ์ ์ค ํ๋์ธ ์ปดํฌ๋ํธ ๊ณ์ธต๊ตฌ์กฐ์ DOM ๊ตฌ์กฐ์ ๋ถ์ผ์น๋ฅผ ์ฐ์ํ๊ฒ ํด๊ฒฐํ๋ ํ์ ์ ์ธ ์๋ฃจ์ ์ ๋๋ค.
๋ชจ๋ฌ, ํดํ, ๋๋กญ๋ค์ด, ํ ์คํธ ์๋ฆผ ๋ฑ์ ๊ตฌํํ ๋ ๊ฒช๋ z-index ์ง์ฅ, CSS ์์ ๋ฌธ์ , ์คํฌ๋กค ์ด์๋ค์ ํ ๋ฒ์ ํด๊ฒฐํ ์ ์์ต๋๋ค. ์ด ํฌ์คํ ์์๋ Teleport์ ๋ด๋ถ ๋์ ์๋ฆฌ๋ถํฐ ๊ณ ๊ธ ํ์ฉ ํจํด๊น์ง, ์ค๋ฌด์์ ๋ง์ฃผ์น๋ ๋ชจ๋ ์๋๋ฆฌ์ค๋ฅผ ์๋ฒฝํ๊ฒ ๋ค๋ฃน๋๋ค.
๐ ๋ชฉ์ฐจ
- Teleport์ ํต์ฌ ๊ฐ๋ ๊ณผ ๋์ ์๋ฆฌ
- ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ๊ณผ ์ค๋ฌด ์ ์ฉ
- ๊ณ ๊ธ ํจํด: ์กฐ๊ฑด๋ถ Teleport์ ๋์ ํ๊ฒ
- ๋ชจ๋ฌ ์์คํ ์์ ๊ตฌํ
- ํดํ๊ณผ ๋๋กญ๋ค์ด ๊ณ ๊ธ ํ์ฉ
- Teleport์ ์ ๋๋ฉ์ด์ ํตํฉ
- ์ฑ๋ฅ ์ต์ ํ์ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ
- ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง(SSR) ๋์ ์ ๋ต
๐ Teleport์ ํต์ฌ ๊ฐ๋ ๊ณผ ๋์ ์๋ฆฌ
Teleport ์ด์ ์ ๋ฌธ์ ์
<!-- โ Teleport ์์ด ๋ชจ๋ฌ์ ๊ตฌํํ ๋์ ๋ฌธ์ ์ -->
<template>
<div class="page-container">
<header class="header">
<h1>My App</h1>
</header>
<main class="main-content">
<div class="card">
<h2>์ฌ์ฉ์ ์ ๋ณด</h2>
<button @click="showModal = true">ํธ์ง</button>
<!-- ๋ชจ๋ฌ์ด ์ฌ๊ธฐ์ ๋ ๋๋ง๋จ -->
<div v-if="showModal" class="modal-backdrop">
<div class="modal">
<h3>์ฌ์ฉ์ ํธ์ง</h3>
<form @submit.prevent="handleSave">
<input v-model="userName" />
<button type="submit">์ ์ฅ</button>
<button @click="showModal = false">์ทจ์</button>
</form>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
const userName = ref('๊น๊ฐ๋ฐ')
const handleSave = () => {
console.log('์ ์ฅ:', userName.value)
showModal.value = false
}
</script>
<style scoped>
.page-container {
position: relative;
overflow: hidden; /* ์ด๊ฒ์ด ๋ชจ๋ฌ์ ๊ฐ๋ฆผ! */
}
.main-content {
transform: translateZ(0); /* ์๋ก์ด stacking context ์์ฑ */
}
.card {
position: relative;
z-index: 1;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999; /* ๋์ z-index๋ ์์ฉ์์! */
}
.modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 2rem;
border-radius: 8px;
}
</style>
Teleport์ ํด๊ฒฐ์ฑ
<!-- โ
Teleport๋ฅผ ์ฌ์ฉํ ๊น๋ํ ํด๊ฒฐ์ฑ
-->
<template>
<div class="page-container">
<header class="header">
<h1>My App</h1>
</header>
<main class="main-content">
<div class="card">
<h2>์ฌ์ฉ์ ์ ๋ณด</h2>
<button @click="showModal = true">ํธ์ง</button>
</div>
</main>
<!-- ๋ชจ๋ฌ์ด body ์งํ์๋ก ์ด๋๋จ -->
<Teleport to="body">
<div v-if="showModal" class="modal-backdrop" @click="handleBackdropClick">
<div class="modal" @click.stop>
<h3>์ฌ์ฉ์ ํธ์ง</h3>
<form @submit.prevent="handleSave">
<input v-model="userName" />
<button type="submit">์ ์ฅ</button>
<button @click="showModal = false">์ทจ์</button>
</form>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
const userName = ref('๊น๊ฐ๋ฐ')
const handleSave = () => {
console.log('์ ์ฅ:', userName.value)
showModal.value = false
}
const handleBackdropClick = () => {
showModal.value = false
}
</script>
<style scoped>
/* ๋ถ๋ชจ ์ปดํฌ๋ํธ์ CSS๋ ๊ทธ๋๋ก ์ ์ง */
.page-container {
position: relative;
overflow: hidden; /* ๋ ์ด์ ๋ฌธ์ ์์! */
}
.main-content {
transform: translateZ(0); /* stacking context์ ๋ฌด๊ดํจ! */
}
</style>
<style>
/* ์ ์ญ ์คํ์ผ๋ก ๋ชจ๋ฌ ์ ์ */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90%;
}
</style>
Teleport์ ๋ด๋ถ ๋์ ์๋ฆฌ
// Vue 3 ๋ด๋ถ์์ Teleport๊ฐ ๋์ํ๋ ๋ฐฉ์ (๋จ์ํ)
class TeleportRenderer {
constructor() {
this.teleportMap = new Map() // ํ๊ฒ๋ณ ํ
๋ ํฌํธ ๊ด๋ฆฌ
}
renderTeleport(vnode, container) {
const { to, children } = vnode.props
// 1. ํ๊ฒ ์๋ฆฌ๋จผํธ ์ฐพ๊ธฐ
const target = typeof to === 'string'
? document.querySelector(to)
: to
if (!target) {
console.warn(`Teleport target "${to}" not found`)
return
}
// 2. ํ
๋ ํฌํธ ์ปจํ
์ด๋ ์์ฑ
if (!this.teleportMap.has(target)) {
const teleportContainer = document.createElement('div')
teleportContainer.className = 'vue-teleport-container'
target.appendChild(teleportContainer)
this.teleportMap.set(target, teleportContainer)
}
const teleportContainer = this.teleportMap.get(target)
// 3. ์์ ์ปดํฌ๋ํธ๋ค์ ํ๊ฒ์ ๋ ๋๋ง
children.forEach(child => {
this.renderComponent(child, teleportContainer)
})
}
unmountTeleport(vnode) {
const { to } = vnode.props
const target = typeof to === 'string'
? document.querySelector(to)
: to
if (this.teleportMap.has(target)) {
const container = this.teleportMap.get(target)
container.remove()
this.teleportMap.delete(target)
}
}
}
// ์ค์ Vue 3์์๋ ๋ ๋ณต์กํ ์ต์ ํ์ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ํฌํจ๋จ
๐ ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ๊ณผ ์ค๋ฌด ์ ์ฉ
๋ค์ํ ํ๊ฒ ์ ํ์ ํ์ฉ
<template>
<div class="app">
<!-- 1. CSS ์ ํ์๋ก ํ๊ฒ ์ง์ -->
<Teleport to="#modal-root">
<Modal v-if="showModal1" @close="showModal1 = false" />
</Teleport>
<!-- 2. ํด๋์ค ์ ํ์ ์ฌ์ฉ -->
<Teleport to=".tooltip-container">
<Tooltip v-if="showTooltip" :content="tooltipContent" />
</Teleport>
<!-- 3. body์ ์ง์ ๋ ๋๋ง -->
<Teleport to="body">
<Toast v-for="toast in toasts" :key="toast.id" :toast="toast" />
</Teleport>
<!-- 4. ๋์ ํ๊ฒ (computed ํ์ฉ) -->
<Teleport :to="dynamicTarget">
<ContextMenu v-if="showContextMenu" :x="menuX" :y="menuY" />
</Teleport>
<!-- 5. DOM ์๋ฆฌ๋จผํธ ์ง์ ์ฐธ์กฐ -->
<Teleport :to="portalElement">
<CustomWidget v-if="showWidget" />
</Teleport>
<!-- ์ค์ ์ปจํ
์ธ -->
<main class="main-content">
<button @click="showModal1 = true">๋ชจ๋ฌ ์ด๊ธฐ</button>
<button @click="showTooltip = !showTooltip">ํดํ ํ ๊ธ</button>
<button @click="addToast">ํ ์คํธ ์ถ๊ฐ</button>
<div
@contextmenu.prevent="showContextMenu = true"
class="context-area"
>
์ฐํด๋ฆญํ์ฌ ์ปจํ
์คํธ ๋ฉ๋ด ํ์
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// ์ํ ๊ด๋ฆฌ
const showModal1 = ref(false)
const showTooltip = ref(false)
const showContextMenu = ref(false)
const showWidget = ref(false)
const tooltipContent = ref('๋์๋ง ํ
์คํธ')
const toasts = ref([])
const menuX = ref(0)
const menuY = ref(0)
// ๋์ ํ๊ฒ ๊ณ์ฐ
const dynamicTarget = computed(() => {
return window.innerWidth > 768 ? '.desktop-menu' : '.mobile-menu'
})
// DOM ์๋ฆฌ๋จผํธ ์ง์ ์ฐธ์กฐ
const portalElement = ref(null)
onMounted(() => {
// ํน์ ์๋ฆฌ๋จผํธ๋ฅผ ํฌํธ ํ๊ฒ์ผ๋ก ์ค์
portalElement.value = document.getElementById('widget-container')
})
// ํ ์คํธ ์ถ๊ฐ ํจ์
const addToast = () => {
const toast = {
id: Date.now(),
message: `ํ ์คํธ ๋ฉ์์ง ${toasts.value.length + 1}`,
type: 'info'
}
toasts.value.push(toast)
// 3์ด ํ ์๋ ์ ๊ฑฐ
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== toast.id)
}, 3000)
}
</script>
<!-- index.html์ ๋ฏธ๋ฆฌ ์ ์๋ ํฌํธ ์ปจํ
์ด๋๋ค -->
<!--
<div id="modal-root"></div>
<div class="tooltip-container"></div>
<div class="desktop-menu"></div>
<div class="mobile-menu"></div>
<div id="widget-container"></div>
-->
์ปดํฌ๋ํธ๋ณ ์ต์ ํ๋ Teleport ์ฌ์ฉ
<!-- BaseModal.vue - ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ฌ ์ปดํฌ๋ํธ -->
<template>
<Teleport to="body">
<Transition name="modal" appear>
<div v-if="modelValue" class="modal-backdrop" @click="handleBackdropClick">
<div
class="modal-container"
:class="[`modal-${size}`, customClass]"
@click.stop
role="dialog"
:aria-labelledby="titleId"
:aria-describedby="contentId"
>
<!-- ํค๋ -->
<header v-if="$slots.header || title" class="modal-header">
<slot name="header">
<h2 :id="titleId" class="modal-title">{{ title }}</h2>
</slot>
<button
v-if="closable"
@click="handleClose"
class="modal-close"
aria-label="๋ซ๊ธฐ"
>
โ
</button>
</header>
<!-- ๋ณธ๋ฌธ -->
<main :id="contentId" class="modal-content">
<slot />
</main>
<!-- ํธํฐ -->
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, nextTick, watch } from 'vue'
// Props ์ ์
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg', 'xl', 'full'].includes(value)
},
closable: {
type: Boolean,
default: true
},
closeOnBackdrop: {
type: Boolean,
default: true
},
customClass: {
type: String,
default: ''
},
lockScroll: {
type: Boolean,
default: true
}
})
// Events ์ ์
const emit = defineEmits(['update:modelValue', 'close', 'open'])
// ์ ๋ํฌํ ID ์์ฑ
const titleId = computed(() => `modal-title-${Math.random().toString(36).substr(2, 9)}`)
const contentId = computed(() => `modal-content-${Math.random().toString(36).substr(2, 9)}`)
// ์ด๋ฒคํธ ํธ๋ค๋ฌ
const handleClose = () => {
emit('update:modelValue', false)
emit('close')
}
const handleBackdropClick = () => {
if (props.closeOnBackdrop) {
handleClose()
}
}
// ์คํฌ๋กค ์ ๊ธ ๊ธฐ๋ฅ
watch(() => props.modelValue, async (newValue) => {
if (props.lockScroll) {
await nextTick()
if (newValue) {
document.body.style.overflow = 'hidden'
emit('open')
} else {
document.body.style.overflow = ''
}
}
})
// ESC ํค ์ฒ๋ฆฌ
const handleKeydown = (event) => {
if (event.key === 'Escape' && props.closable) {
handleClose()
}
}
watch(() => props.modelValue, (newValue) => {
if (newValue) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
})
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-container {
background: white;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
max-height: 90vh;
overflow: auto;
position: relative;
}
/* ํฌ๊ธฐ๋ณ ์คํ์ผ */
.modal-sm { max-width: 400px; width: 100%; }
.modal-md { max-width: 600px; width: 100%; }
.modal-lg { max-width: 900px; width: 100%; }
.modal-xl { max-width: 1200px; width: 100%; }
.modal-full { width: 100%; height: 100%; max-height: none; border-radius: 0; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 1.5rem 0;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f3f4f6;
color: #374151;
}
.modal-content {
padding: 1.5rem;
}
.modal-footer {
padding: 0 1.5rem 1.5rem;
border-top: 1px solid #e5e7eb;
margin-top: 1rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* ์ ๋๋ฉ์ด์
*/
.modal-enter-active, .modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
transition: transform 0.3s ease;
}
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(0.95) translateY(-20px);
}
</style>
๐จ ๊ณ ๊ธ ํจํด: ์กฐ๊ฑด๋ถ Teleport์ ๋์ ํ๊ฒ
์กฐ๊ฑด๋ถ Teleport ๊ตฌํ
<template>
<div class="responsive-layout">
<!-- ๋ฐ์คํฌํฑ: ์ฌ์ด๋๋ฐ์ ์์ ฏ ํ์ -->
<!-- ๋ชจ๋ฐ์ผ: ๋ชจ๋ฌ๋ก ์์ ฏ ํ์ -->
<Teleport :to="teleportTarget" :disabled="!shouldTeleport">
<div class="widget-container" :class="widgetClass">
<UserWidget
v-if="showWidget"
@close="showWidget = false"
:is-modal="shouldTeleport"
/>
</div>
</Teleport>
<main class="main-content">
<button @click="showWidget = true">์์ ฏ ํ์</button>
<!-- ๋ชจ๋ฐ์ผ์์๋ ์ฌ๊ธฐ์ ์์ ฏ์ด ๋ ๋๋ง๋จ (disabled=true) -->
<div v-if="!shouldTeleport" class="mobile-widget-space">
<!-- Teleport๊ฐ disabled์ผ ๋ ์ฌ๊ธฐ์ ๋ ๋๋ง -->
</div>
</main>
<!-- ๋ฐ์คํฌํฑ ์ฌ์ด๋๋ฐ -->
<aside class="sidebar">
<div id="sidebar-widgets"></div>
</aside>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const showWidget = ref(false)
const windowWidth = ref(window.innerWidth)
// ๋ฐ์ํ ๋ก์ง
const isMobile = computed(() => windowWidth.value < 768)
const shouldTeleport = computed(() => !isMobile.value)
const teleportTarget = computed(() => {
return isMobile.value ? 'body' : '#sidebar-widgets'
})
const widgetClass = computed(() => ({
'widget-modal': isMobile.value,
'widget-sidebar': !isMobile.value
}))
// ์๋์ฐ ํฌ๊ธฐ ๋ณํ ๊ฐ์ง
const handleResize = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
.responsive-layout {
display: flex;
height: 100vh;
}
.main-content {
flex: 1;
padding: 2rem;
}
.sidebar {
width: 300px;
background: #f8f9fa;
padding: 1rem;
}
.mobile-widget-space {
/* ๋ชจ๋ฐ์ผ์์ ์์ ฏ์ด ์ธ๋ผ์ธ์ผ๋ก ํ์๋ ๊ณต๊ฐ */
margin-top: 1rem;
}
/* ์์ ฏ ์คํ์ผ */
.widget-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
.widget-sidebar {
background: white;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@media (max-width: 767px) {
.sidebar {
display: none;
}
.main-content {
width: 100%;
}
}
</style>
๋ค์ค Teleport ๊ด๋ฆฌ ์์คํ
<!-- TeleportManager.vue - ์ค์์ง์ค์ Teleport ๊ด๋ฆฌ -->
<template>
<div>
<!-- ๋ชจ๋ฌ ๋ ์ด์ด -->
<Teleport to="body">
<div v-if="hasModals" class="modal-layer">
<component
v-for="modal in modals"
:key="modal.id"
:is="modal.component"
v-bind="modal.props"
@close="closeModal(modal.id)"
/>
</div>
</Teleport>
<!-- ํ ์คํธ ๋ ์ด์ด -->
<Teleport to="body">
<TransitionGroup
v-if="hasToasts"
name="toast"
tag="div"
class="toast-container"
>
<ToastComponent
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@close="removeToast(toast.id)"
/>
</TransitionGroup>
</Teleport>
<!-- ๋๋กญ๋ค์ด ๋ ์ด์ด -->
<Teleport to="body">
<div v-if="hasDropdowns" class="dropdown-layer">
<component
v-for="dropdown in dropdowns"
:key="dropdown.id"
:is="dropdown.component"
v-bind="dropdown.props"
@close="closeDropdown(dropdown.id)"
/>
</div>
</Teleport>
<!-- ํดํ ๋ ์ด์ด -->
<Teleport to="body">
<div v-if="hasTooltips" class="tooltip-layer">
<component
v-for="tooltip in tooltips"
:key="tooltip.id"
:is="tooltip.component"
v-bind="tooltip.props"
/>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useTeleportStore } from '@/stores/teleport'
// Pinia ์คํ ์ด ์ฌ์ฉ
const teleportStore = useTeleportStore()
// Computed properties
const modals = computed(() => teleportStore.modals)
const toasts = computed(() => teleportStore.toasts)
const dropdowns = computed(() => teleportStore.dropdowns)
const tooltips = computed(() => teleportStore.tooltips)
const hasModals = computed(() => modals.value.length > 0)
const hasToasts = computed(() => toasts.value.length > 0)
const hasDropdowns = computed(() => dropdowns.value.length > 0)
const hasTooltips = computed(() => tooltips.value.length > 0)
// ๋ฉ์๋
const closeModal = (id) => {
teleportStore.removeModal(id)
}
const removeToast = (id) => {
teleportStore.removeToast(id)
}
const closeDropdown = (id) => {
teleportStore.removeDropdown(id)
}
</script>
<script>
// stores/teleport.js - Teleport ์ํ ๊ด๋ฆฌ ์คํ ์ด
import { defineStore } from 'pinia'
export const useTeleportStore = defineStore('teleport', {
state: () => ({
modals: [],
toasts: [],
dropdowns: [],
tooltips: [],
zIndexCounter: 1000
}),
getters: {
getNextZIndex: (state) => {
return ++state.zIndexCounter
},
getActiveModal: (state) => {
return state.modals[state.modals.length - 1] || null
}
},
actions: {
// ๋ชจ๋ฌ ๊ด๋ฆฌ
addModal(modal) {
const modalWithDefaults = {
id: Date.now() + Math.random(),
zIndex: this.getNextZIndex,
closable: true,
closeOnBackdrop: true,
...modal
}
this.modals.push(modalWithDefaults)
// body ์คํฌ๋กค ์ ๊ธ
document.body.style.overflow = 'hidden'
return modalWithDefaults.id
},
removeModal(id) {
const index = this.modals.findIndex(modal => modal.id === id)
if (index > -1) {
this.modals.splice(index, 1)
// ๋ง์ง๋ง ๋ชจ๋ฌ์ด ๋ซํ๋ฉด ์คํฌ๋กค ๋ณต๊ตฌ
if (this.modals.length === 0) {
document.body.style.overflow = ''
}
}
},
closeAllModals() {
this.modals = []
document.body.style.overflow = ''
},
// ํ ์คํธ ๊ด๋ฆฌ
addToast(toast) {
const toastWithDefaults = {
id: Date.now() + Math.random(),
type: 'info',
duration: 3000,
closable: true,
...toast
}
this.toasts.push(toastWithDefaults)
// ์๋ ์ ๊ฑฐ
if (toastWithDefaults.duration > 0) {
setTimeout(() => {
this.removeToast(toastWithDefaults.id)
}, toastWithDefaults.duration)
}
return toastWithDefaults.id
},
removeToast(id) {
const index = this.toasts.findIndex(toast => toast.id === id)
if (index > -1) {
this.toasts.splice(index, 1)
}
},
// ๋๋กญ๋ค์ด ๊ด๋ฆฌ
addDropdown(dropdown) {
// ๊ธฐ์กด ๋๋กญ๋ค์ด ๋ชจ๋ ๋ซ๊ธฐ (ํ๋๋ง ์ด๋ฆผ)
this.dropdowns = []
const dropdownWithDefaults = {
id: Date.now() + Math.random(),
zIndex: this.getNextZIndex,
...dropdown
}
this.dropdowns.push(dropdownWithDefaults)
return dropdownWithDefaults.id
},
removeDropdown(id) {
const index = this.dropdowns.findIndex(dropdown => dropdown.id === id)
if (index > -1) {
this.dropdowns.splice(index, 1)
}
},
// ํดํ ๊ด๋ฆฌ
addTooltip(tooltip) {
const tooltipWithDefaults = {
id: Date.now() + Math.random(),
delay: 300,
...tooltip
}
this.tooltips.push(tooltipWithDefaults)
return tooltipWithDefaults.id
},
removeTooltip(id) {
const index = this.tooltips.findIndex(tooltip => tooltip.id === id)
if (index > -1) {
this.tooltips.splice(index, 1)
}
},
// ์ ์ฒด ์ ๋ฆฌ
clearAll() {
this.modals = []
this.toasts = []
this.dropdowns = []
this.tooltips = []
document.body.style.overflow = ''
}
}
})
</script>
<style scoped>
.modal-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dropdown-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1500;
pointer-events: none;
}
.dropdown-layer > * {
pointer-events: auto;
}
.tooltip-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 3000;
pointer-events: none;
}
.tooltip-layer > * {
pointer-events: auto;
}
/* ํ ์คํธ ์ ๋๋ฉ์ด์
*/
.toast-enter-active {
transition: all 0.3s ease-out;
}
.toast-leave-active {
transition: all 0.3s ease-in;
}
.toast-enter-from {
transform: translateX(100%);
opacity: 0;
}
.toast-leave-to {
transform: translateX(100%);
opacity: 0;
}
.toast-move {
transition: transform 0.3s ease;
}
</style>
๐ญ ๋ชจ๋ฌ ์์คํ ์์ ๊ตฌํ
๊ณ ๊ธ ๋ชจ๋ฌ ์ปดํฌ์ ๋ธ
// composables/useModal.js - ๋ชจ๋ฌ ๊ด๋ฆฌ ์ปดํฌ์ ๋ธ
import { ref, computed, nextTick, onUnmounted } from 'vue'
import { useTeleportStore } from '@/stores/teleport'
export function useModal() {
const teleportStore = useTeleportStore()
const activeModals = ref(new Map())
// ๋ชจ๋ฌ ์ด๊ธฐ
const openModal = async (component, props = {}, options = {}) => {
const modalConfig = {
component,
props: {
...props,
modelValue: true
},
...options
}
const modalId = teleportStore.addModal(modalConfig)
activeModals.value.set(modalId, modalConfig)
// DOM ์
๋ฐ์ดํธ ๋๊ธฐ
await nextTick()
// ํฌ์ปค์ค ๊ด๋ฆฌ
if (options.autoFocus !== false) {
const modalElement = document.querySelector(`[data-modal-id="${modalId}"]`)
if (modalElement) {
const focusableElement = modalElement.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
focusableElement?.focus()
}
}
return {
id: modalId,
close: () => closeModal(modalId),
update: (newProps) => updateModal(modalId, newProps)
}
}
// ๋ชจ๋ฌ ๋ซ๊ธฐ
const closeModal = (modalId) => {
if (activeModals.value.has(modalId)) {
teleportStore.removeModal(modalId)
activeModals.value.delete(modalId)
}
}
// ๋ชจ๋ฌ ์
๋ฐ์ดํธ
const updateModal = (modalId, newProps) => {
const modal = activeModals.value.get(modalId)
if (modal) {
modal.props = { ...modal.props, ...newProps }
teleportStore.updateModal(modalId, modal)
}
}
// ํ์ธ ๋ชจ๋ฌ
const confirm = (message, title = 'ํ์ธ') => {
return new Promise((resolve) => {
const modal = openModal('ConfirmModal', {
title,
message,
onConfirm: () => {
modal.then(m => m.close())
resolve(true)
},
onCancel: () => {
modal.then(m => m.close())
resolve(false)
}
})
})
}
// ์๋ฆผ ๋ชจ๋ฌ
const alert = (message, title = '์๋ฆผ') => {
return new Promise((resolve) => {
const modal = openModal('AlertModal', {
title,
message,
onClose: () => {
modal.then(m => m.close())
resolve()
}
})
})
}
// ํ๋กฌํํธ ๋ชจ๋ฌ
const prompt = (message, defaultValue = '', title = '์
๋ ฅ') => {
return new Promise((resolve) => {
const modal = openModal('PromptModal', {
title,
message,
defaultValue,
onConfirm: (value) => {
modal.then(m => m.close())
resolve(value)
},
onCancel: () => {
modal.then(m => m.close())
resolve(null)
}
})
})
}
// ๋ชจ๋ ๋ชจ๋ฌ ๋ซ๊ธฐ
const closeAllModals = () => {
activeModals.value.clear()
teleportStore.closeAllModals()
}
// ํ์ฌ ์ด๋ฆฐ ๋ชจ๋ฌ ์
const modalCount = computed(() => activeModals.value.size)
// ์ต์์ ๋ชจ๋ฌ ์ฌ๋ถ ํ์ธ
const isTopModal = (modalId) => {
const modals = Array.from(activeModals.value.keys())
return modals[modals.length - 1] === modalId
}
// ์ ๋ฆฌ
onUnmounted(() => {
closeAllModals()
})
return {
openModal,
closeModal,
updateModal,
confirm,
alert,
prompt,
closeAllModals,
modalCount,
isTopModal
}
}
// ์ ์ญ ๋ชจ๋ฌ ์ธ์คํด์ค
let globalModalInstance = null
export function useGlobalModal() {
if (!globalModalInstance) {
globalModalInstance = useModal()
}
return globalModalInstance
}
์ค๋ฌด์ฉ ๋ชจ๋ฌ ์ปดํฌ๋ํธ๋ค
<!-- ConfirmModal.vue -->
<template>
<BaseModal
:model-value="true"
:title="title"
size="sm"
@close="handleCancel"
>
<div class="confirm-content">
<div class="confirm-icon">
<component :is="iconComponent" />
</div>
<p class="confirm-message">{{ message }}</p>
</div>
<template #footer>
<button
class="btn btn-secondary"
@click="handleCancel"
:disabled="loading"
>
{{ cancelText }}
</button>
<button
class="btn btn-primary"
@click="handleConfirm"
:disabled="loading"
:class="{ 'btn-loading': loading }"
>
{{ confirmText }}
</button>
</template>
</BaseModal>
</template>
<script setup>
import { ref, computed } from 'vue'
import BaseModal from './BaseModal.vue'
const props = defineProps({
title: { type: String, default: 'ํ์ธ' },
message: { type: String, required: true },
type: { type: String, default: 'info' }, // info, warning, danger, success
confirmText: { type: String, default: 'ํ์ธ' },
cancelText: { type: String, default: '์ทจ์' },
onConfirm: { type: Function, required: true },
onCancel: { type: Function, required: true },
asyncAction: { type: Boolean, default: false }
})
const loading = ref(false)
const iconComponent = computed(() => {
const icons = {
info: 'InfoIcon',
warning: 'WarningIcon',
danger: 'DangerIcon',
success: 'SuccessIcon'
}
return icons[props.type] || icons.info
})
const handleConfirm = async () => {
if (props.asyncAction) {
loading.value = true
try {
await props.onConfirm()
} finally {
loading.value = false
}
} else {
props.onConfirm()
}
}
const handleCancel = () => {
if (!loading.value) {
props.onCancel()
}
}
</script>
<style scoped>
.confirm-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem 0;
}
.confirm-icon {
width: 64px;
height: 64px;
margin-bottom: 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
}
.confirm-message {
font-size: 1.1rem;
line-height: 1.5;
color: #374151;
margin: 0;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-loading::after {
content: '';
display: inline-block;
width: 14px;
height: 14px;
margin-left: 8px;
border: 2px solid currentColor;
border-radius: 50%;
border-right-color: transparent;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
๐จ Teleport์ ์ ๋๋ฉ์ด์ ํตํฉ
๊ณ ๊ธ ์ ๋๋ฉ์ด์ ํจํด
<!-- AnimatedTeleport.vue - ์ ๋๋ฉ์ด์
์ด ํฌํจ๋ Teleport ๋ํผ -->
<template>
<Teleport :to="to" :disabled="disabled">
<Transition
:name="transitionName"
:appear="appear"
:mode="mode"
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<slot />
</Transition>
</Teleport>
</template>
<script setup>
const props = defineProps({
to: { type: [String, Element], required: true },
disabled: { type: Boolean, default: false },
transitionName: { type: String, default: 'fade' },
appear: { type: Boolean, default: true },
mode: { type: String, default: undefined }
})
const emit = defineEmits([
'before-enter',
'enter',
'after-enter',
'before-leave',
'leave',
'after-leave'
])
// ์ ๋๋ฉ์ด์
์ด๋ฒคํธ ์ ๋ฌ
const onBeforeEnter = (el) => emit('before-enter', el)
const onEnter = (el, done) => emit('enter', el, done)
const onAfterEnter = (el) => emit('after-enter', el)
const onBeforeLeave = (el) => emit('before-leave', el)
const onLeave = (el, done) => emit('leave', el, done)
const onAfterLeave = (el) => emit('after-leave', el)
</script>
<style>
/* ๊ธฐ๋ณธ ํ์ด๋ ์ ๋๋ฉ์ด์
*/
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* ๋ชจ๋ฌ ์ ๋๋ฉ์ด์
*/
.modal-enter-active {
transition: all 0.3s ease-out;
}
.modal-leave-active {
transition: all 0.2s ease-in;
}
.modal-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-20px);
}
.modal-leave-to {
opacity: 0;
transform: scale(1.05);
}
/* ๋๋กญ๋ค์ด ์ ๋๋ฉ์ด์
*/
.dropdown-enter-active, .dropdown-leave-active {
transition: all 0.2s ease;
transform-origin: top;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: scaleY(0);
}
/* ํ ์คํธ ์ ๋๋ฉ์ด์
*/
.toast-enter-active {
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.toast-leave-active {
transition: all 0.3s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
/* ์ฌ๋ผ์ด๋ ์ ๋๋ฉ์ด์
*/
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(100%);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(100%);
}
.slide-down-enter-active, .slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from {
opacity: 0;
transform: translateY(-100%);
}
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
/* ํ๋/์ถ์ ์ ๋๋ฉ์ด์
*/
.zoom-enter-active, .zoom-leave-active {
transition: all 0.3s ease;
}
.zoom-enter-from, .zoom-leave-to {
opacity: 0;
transform: scale(0);
}
/* ํ์ ์ ๋๋ฉ์ด์
*/
.rotate-enter-active, .rotate-leave-active {
transition: all 0.5s ease;
}
.rotate-enter-from {
opacity: 0;
transform: rotate(-180deg) scale(0.5);
}
.rotate-leave-to {
opacity: 0;
transform: rotate(180deg) scale(0.5);
}
</style>
JavaScript ๊ธฐ๋ฐ ๋ณต์กํ ์ ๋๋ฉ์ด์
<!-- ComplexAnimatedModal.vue -->
<template>
<AnimatedTeleport
to="body"
:transition-name="''"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<div v-if="modelValue" ref="backdrop" class="modal-backdrop">
<div ref="modal" class="modal-container">
<slot />
</div>
</div>
</AnimatedTeleport>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { gsap } from 'gsap'
const props = defineProps({
modelValue: Boolean,
animationType: {
type: String,
default: 'bounce',
validator: (value) => ['bounce', 'elastic', 'flip', 'slide'].includes(value)
}
})
const backdrop = ref(null)
const modal = ref(null)
// ์ง์
์ ๋๋ฉ์ด์
์ ์ค์
const beforeEnter = (el) => {
gsap.set(el, { opacity: 0 })
gsap.set(modal.value, {
scale: 0,
rotation: props.animationType === 'flip' ? 180 : 0,
y: props.animationType === 'slide' ? 100 : 0
})
}
// ์ง์
์ ๋๋ฉ์ด์
const enter = (el, done) => {
const tl = gsap.timeline({ onComplete: done })
// ๋ฐฐ๊ฒฝ ํ์ด๋ ์ธ
tl.to(el, {
opacity: 1,
duration: 0.3,
ease: 'power2.out'
})
// ๋ชจ๋ฌ ์ ๋๋ฉ์ด์
(ํ์
๋ณ๋ก ๋ค๋ฆ)
switch (props.animationType) {
case 'bounce':
tl.to(modal.value, {
scale: 1,
duration: 0.6,
ease: 'back.out(1.7)'
}, 0.1)
break
case 'elastic':
tl.to(modal.value, {
scale: 1,
duration: 0.8,
ease: 'elastic.out(1, 0.75)'
}, 0.1)
break
case 'flip':
tl.to(modal.value, {
scale: 1,
rotation: 0,
duration: 0.6,
ease: 'power2.out'
}, 0.1)
break
case 'slide':
tl.to(modal.value, {
scale: 1,
y: 0,
duration: 0.5,
ease: 'power3.out'
}, 0.1)
break
}
}
// ์ข
๋ฃ ์ ๋๋ฉ์ด์
const leave = (el, done) => {
const tl = gsap.timeline({ onComplete: done })
// ๋ชจ๋ฌ ๋จผ์ ์ ๋๋ฉ์ด์
tl.to(modal.value, {
scale: 0.8,
opacity: 0,
duration: 0.2,
ease: 'power2.in'
})
// ๋ฐฐ๊ฒฝ ํ์ด๋ ์์
tl.to(el, {
opacity: 0,
duration: 0.2,
ease: 'power2.in'
}, 0.1)
}
// ๋ง์ฐ์ค ๋ฐ๋ผ๋ค๋๋ ํจ๊ณผ (์ ํ์ )
const handleMouseMove = (event) => {
if (!modal.value) return
const rect = modal.value.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const deltaX = (event.clientX - centerX) * 0.02
const deltaY = (event.clientY - centerY) * 0.02
gsap.to(modal.value, {
x: deltaX,
y: deltaY,
duration: 0.3,
ease: 'power2.out'
})
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
</style>
โก ์ฑ๋ฅ ์ต์ ํ์ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ
Teleport ์ฑ๋ฅ ๋ชจ๋ํฐ๋ง
// composables/useTeleportPerformance.js
import { ref, onMounted, onUnmounted, watch } from 'vue'
export function useTeleportPerformance() {
const renderCount = ref(0)
const averageRenderTime = ref(0)
const memoryUsage = ref(0)
const teleportElements = ref(0)
let renderTimes = []
let observer = null
// ๋ ๋๋ง ์ฑ๋ฅ ์ธก์
const measureRender = (callback) => {
const start = performance.now()
return new Promise((resolve) => {
callback()
// ๋ค์ ํ๋ ์์์ ์ธก์
requestAnimationFrame(() => {
const end = performance.now()
const renderTime = end - start
renderTimes.push(renderTime)
renderCount.value++
// ์ต๊ทผ 10ํ ํ๊ท ๊ณ์ฐ
if (renderTimes.length > 10) {
renderTimes.shift()
}
averageRenderTime.value = renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length
resolve(renderTime)
})
})
}
// ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋ ์ธก์
const measureMemory = () => {
if (performance.memory) {
memoryUsage.value = performance.memory.usedJSHeapSize / 1024 / 1024 // MB
}
}
// Teleport ์๋ฆฌ๋จผํธ ์ ๊ณ์ฐ
const countTeleportElements = () => {
teleportElements.value = document.querySelectorAll('[data-v-teleport]').length
}
// DOM ๋ณํ ๊ด์ฐฐ
const startObserving = () => {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Teleport ๊ด๋ จ DOM ๋ณํ ๊ฐ์ง
const addedTeleports = Array.from(mutation.addedNodes)
.filter(node => node.nodeType === 1 && node.hasAttribute?.('data-v-teleport'))
if (addedTeleports.length > 0) {
countTeleportElements()
measureMemory()
}
}
})
})
observer.observe(document.body, {
childList: true,
subtree: true
})
}
// ์ฑ๋ฅ ๋ฆฌํฌํธ ์์ฑ
const generateReport = () => {
return {
renderMetrics: {
totalRenders: renderCount.value,
averageRenderTime: averageRenderTime.value,
lastRenderTime: renderTimes[renderTimes.length - 1] || 0
},
memoryMetrics: {
currentUsage: memoryUsage.value,
teleportElements: teleportElements.value
},
recommendations: generateRecommendations()
}
}
// ์ฑ๋ฅ ๊ฐ์ ๊ถ์ฅ์ฌํญ
const generateRecommendations = () => {
const recommendations = []
if (averageRenderTime.value > 16) {
recommendations.push('๋ ๋๋ง ์๊ฐ์ด 16ms๋ฅผ ์ด๊ณผํฉ๋๋ค. ์ ๋๋ฉ์ด์
์ต์ ํ๋ฅผ ๊ณ ๋ คํ์ธ์.')
}
if (teleportElements.value > 10) {
recommendations.push('Teleport ์๋ฆฌ๋จผํธ๊ฐ ๋ง์ต๋๋ค. ๋ถํ์ํ Teleport ์ ๊ฑฐ๋ฅผ ๊ณ ๋ คํ์ธ์.')
}
if (memoryUsage.value > 100) {
recommendations.push('๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋์ด ๋์ต๋๋ค. ์ปดํฌ๋ํธ ์ ๋ฆฌ๋ฅผ ํ์ธํ์ธ์.')
}
return recommendations
}
// ์ ๋ฆฌ
const cleanup = () => {
if (observer) {
observer.disconnect()
observer = null
}
renderTimes = []
}
onMounted(() => {
startObserving()
// ์ฃผ๊ธฐ์ ์ผ๋ก ๋ฉํธ๋ฆญ ์
๋ฐ์ดํธ
const interval = setInterval(() => {
measureMemory()
countTeleportElements()
}, 1000)
onUnmounted(() => {
clearInterval(interval)
cleanup()
})
})
return {
renderCount,
averageRenderTime,
memoryUsage,
teleportElements,
measureRender,
generateReport,
cleanup
}
}
๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐฉ์ง ์ ๋ต
<!-- OptimizedTeleport.vue - ๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐฉ์ง Teleport -->
<template>
<Teleport :to="computedTarget" :disabled="disabled" ref="teleportRef">
<div v-if="shouldRender" :key="componentKey" class="teleport-content">
<slot />
</div>
</Teleport>
</template>
<script setup>
import {
ref,
computed,
watch,
onBeforeUnmount,
nextTick,
onMounted
} from 'vue'
const props = defineProps({
to: { type: [String, Element], required: true },
disabled: { type: Boolean, default: false },
visible: { type: Boolean, default: true },
lazy: { type: Boolean, default: false },
keepAlive: { type: Boolean, default: false }
})
const teleportRef = ref(null)
const componentKey = ref(0)
const shouldRender = ref(!props.lazy)
const cleanupTasks = ref([])
// ๋์ ํ๊ฒ ๊ณ์ฐ (๋ฉ๋ชจ์ด์ ์ด์
)
const computedTarget = computed(() => {
if (typeof props.to === 'string') {
// ์บ์๋ ์๋ฆฌ๋จผํธ ์ฐธ์กฐ ์ฌ์ฉ
return getOrCreateTargetElement(props.to)
}
return props.to
})
// ํ๊ฒ ์๋ฆฌ๋จผํธ ์บ์
const targetElementCache = new Map()
const getOrCreateTargetElement = (selector) => {
if (targetElementCache.has(selector)) {
const cached = targetElementCache.get(selector)
if (document.contains(cached)) {
return cached
} else {
targetElementCache.delete(selector)
}
}
const element = document.querySelector(selector)
if (element) {
targetElementCache.set(selector, element)
}
return element
}
// ์ง์ฐ ๋ ๋๋ง ์ฒ๋ฆฌ
watch(() => props.visible, (newVisible) => {
if (newVisible && props.lazy && !shouldRender.value) {
shouldRender.value = true
}
if (!newVisible && !props.keepAlive) {
// Keep-alive๊ฐ ๋นํ์ฑํ๋ ๊ฒฝ์ฐ ์ปดํฌ๋ํธ ์ฌ์์ฑ
componentKey.value++
}
}, { immediate: true })
// ResizeObserver๋ก ํ๊ฒ ์๋ฆฌ๋จผํธ ๋ณํ ๊ฐ์ง
let resizeObserver = null
const setupResizeObserver = () => {
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
// ํ๊ฒ ์๋ฆฌ๋จผํธ ํฌ๊ธฐ ๋ณํ ์ ์ฒ๋ฆฌ
entries.forEach(entry => {
if (entry.target === computedTarget.value) {
// ํ์ํ ๊ฒฝ์ฐ ๋ ์ด์์ ์ฌ๊ณ์ฐ
nextTick(() => {
// ํฌ์ง์
๋ ์
๋ฐ์ดํธ ๋ฑ
})
}
})
})
if (computedTarget.value) {
resizeObserver.observe(computedTarget.value)
cleanupTasks.value.push(() => {
resizeObserver?.disconnect()
})
}
}
}
// IntersectionObserver๋ก ๊ฐ์์ฑ ์ต์ ํ
let intersectionObserver = null
const setupIntersectionObserver = () => {
if (typeof IntersectionObserver !== 'undefined') {
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// ํ๋ฉด์ ๋ณด์ผ ๋๋ง ์
๋ฐ์ดํธ
shouldRender.value = true
} else if (!props.keepAlive) {
// ํ๋ฉด์์ ๋ฒ์ด๋๊ณ keep-alive๊ฐ ์๋ ๋
shouldRender.value = false
}
})
}, {
threshold: 0.1
})
cleanupTasks.value.push(() => {
intersectionObserver?.disconnect()
})
}
}
// ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ ๋ฆฌ
const cleanupEventListeners = () => {
cleanupTasks.value.forEach(cleanup => cleanup())
cleanupTasks.value = []
}
// MutationObserver๋ก DOM ๋ณํ ๊ฐ์ง
let mutationObserver = null
const setupMutationObserver = () => {
if (typeof MutationObserver !== 'undefined') {
mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// ํ๊ฒ ์๋ฆฌ๋จผํธ๊ฐ ์ ๊ฑฐ๋์๋์ง ํ์ธ
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
const removedNodes = Array.from(mutation.removedNodes)
const targetRemoved = removedNodes.some(node =>
node === computedTarget.value ||
(node.contains && node.contains(computedTarget.value))
)
if (targetRemoved) {
console.warn('Teleport target element was removed from DOM')
shouldRender.value = false
}
}
})
})
mutationObserver.observe(document.body, {
childList: true,
subtree: true
})
cleanupTasks.value.push(() => {
mutationObserver?.disconnect()
})
}
}
onMounted(() => {
setupResizeObserver()
setupIntersectionObserver()
setupMutationObserver()
})
// ์ปดํฌ๋ํธ ์ธ๋ง์ดํธ ์ ์ ๋ฆฌ
onBeforeUnmount(() => {
cleanupEventListeners()
// ์บ์ ์ ๋ฆฌ (ํ์ํ ๊ฒฝ์ฐ)
if (!props.keepAlive) {
targetElementCache.clear()
}
})
// ๊ฐ์ ์๋ก๊ณ ์นจ ๋ฉ์๋ ๋
ธ์ถ
const refresh = () => {
componentKey.value++
shouldRender.value = true
}
defineExpose({
refresh,
cleanup: cleanupEventListeners
})
</script>
<style scoped>
.teleport-content {
/* ๊ธฐ๋ณธ ์คํ์ผ */
}
</style>
๐ ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง(SSR) ๋์ ์ ๋ต
SSR ํธํ Teleport ๊ตฌํ
<!-- SSRSafeTeleport.vue -->
<template>
<div>
<!-- SSR ์ค์๋ ์ธ๋ผ์ธ์ผ๋ก ๋ ๋๋ง -->
<div v-if="!isClient" class="ssr-fallback">
<slot />
</div>
<!-- ํด๋ผ์ด์ธํธ์์๋ Teleport ์ฌ์ฉ -->
<Teleport v-else :to="target" :disabled="disabled">
<slot />
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const props = defineProps({
to: { type: [String, Element], required: true },
disabled: { type: Boolean, default: false },
ssrFallback: { type: Boolean, default: true }
})
// ํด๋ผ์ด์ธํธ ๊ฐ์ง
const isClient = ref(false)
// ์์ ํ ํ๊ฒ ๊ณ์ฐ
const target = computed(() => {
if (!isClient.value) return null
if (typeof props.to === 'string') {
// ํด๋ผ์ด์ธํธ์์ ์๋ฆฌ๋จผํธ๊ฐ ์กด์ฌํ๋์ง ํ์ธ
const element = document.querySelector(props.to)
if (!element) {
console.warn(`Teleport target "${props.to}" not found, falling back to body`)
return 'body'
}
return element
}
return props.to
})
onMounted(() => {
isClient.value = true
})
</script>
<style scoped>
.ssr-fallback {
/* SSR ์ ๋์ฒด ์คํ์ผ */
position: static;
}
</style>
Nuxt 3์์์ Teleport ์ต์ ํ
<!-- components/NuxtTeleport.vue -->
<template>
<div>
<!-- Nuxt์ <ClientOnly> ์ฌ์ฉ -->
<ClientOnly>
<Teleport :to="target" :disabled="disabled">
<slot />
</Teleport>
<template #fallback>
<div v-if="showFallback" class="teleport-fallback">
<slot name="fallback">
<!-- ๋ก๋ฉ ์คํผ๋ ๋๋ ํ๋ ์ด์คํ๋ -->
<div class="loading-placeholder">๋ก๋ฉ ์ค...</div>
</slot>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
to: { type: [String, Element], required: true },
disabled: { type: Boolean, default: false },
showFallback: { type: Boolean, default: true }
})
// Nuxt์ process.client ์ฌ์ฉ
const target = computed(() => {
if (process.server) return null
if (typeof props.to === 'string') {
return document.querySelector(props.to) || 'body'
}
return props.to
})
</script>
<!-- nuxt.config.ts ์ค์ -->
<script>
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
app: {
// Teleport๋ฅผ ์ํ ์ถ๊ฐ ๋ฃจํธ ์๋ฆฌ๋จผํธ
rootAttrs: {
id: 'nuxt-root'
}
},
// ํ๋ฌ๊ทธ์ธ์ผ๋ก ์ ์ญ ํฌํธ ์ปจํ
์ด๋ ์์ฑ
plugins: [
'~/plugins/teleport-containers.client.ts'
]
})
// plugins/teleport-containers.client.ts
export default defineNuxtPlugin(() => {
// ํด๋ผ์ด์ธํธ์์๋ง ์คํ
if (process.client) {
// ํฌํธ ์ปจํ
์ด๋๋ค ์์ฑ
const containers = [
{ id: 'modal-root', className: 'modal-layer' },
{ id: 'toast-root', className: 'toast-layer' },
{ id: 'tooltip-root', className: 'tooltip-layer' },
{ id: 'dropdown-root', className: 'dropdown-layer' }
]
containers.forEach(({ id, className }) => {
if (!document.getElementById(id)) {
const container = document.createElement('div')
container.id = id
container.className = className
document.body.appendChild(container)
}
})
}
})
</script>
<style scoped>
.teleport-fallback {
/* SSR ๋์ฒด ์ปจํ
์ธ ์คํ์ผ */
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
}
.loading-placeholder {
color: #6b7280;
font-size: 0.9rem;
}
</style>
๐ฏ ๊ฒฐ๋ก : Vue 3 Teleport
Vue 3์ Teleport๋ ๋จ์ํ DOM ์ด๋ ๊ธฐ๋ฅ์ ๋์ด ๋ชจ๋ ์น ์ ํ๋ฆฌ์ผ์ด์ ์ UX ๋ฌธ์ ๋ฅผ ๊ทผ๋ณธ์ ์ผ๋ก ํด๊ฒฐํ๋ ํ์ ์ ์ธ ๋๊ตฌ์ ๋๋ค. z-index ์ง์ฅ์์ ๋ฒ์ด๋ ๊น๋ํ ๋ ์ด์ด ๊ด๋ฆฌ๋ฅผ ๊ฐ๋ฅํ๊ฒ ํ๊ณ , ์ปดํฌ๋ํธ์ ๋ ผ๋ฆฌ์ ๊ตฌ์กฐ์ ์๊ฐ์ ํํ์ ๋ถ๋ฆฌํ ์ ์๊ฒ ํด์ค๋๋ค.
๐ ํต์ฌ ํฌ์ธํธ ์์ฝ
- ๋ฌธ์ ํด๊ฒฐ์ ํต์ฌ: CSS ์์ ๋ฌธ์ , stacking context ์ด์, ๋ฐ์ํ ๋ ์ด์์ ์ถฉ๋ ์๋ฒฝ ํด๊ฒฐ
- ์ฑ๋ฅ ์ต์ ํ: ์กฐ๊ฑด๋ถ ๋ ๋๋ง, ๋ฉ๋ชจ์ด์ ์ด์ , ๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐฉ์ง๋ก ์ต์ ์ ์ฑ๋ฅ ๋ฌ์ฑ
- ์ค๋ฌด ํ์ฉ: ๋ชจ๋ฌ, ํ ์คํธ, ๋๋กญ๋ค์ด, ํดํ ๋ฑ ๋ชจ๋ ์ค๋ฒ๋ ์ด ์ปดํฌ๋ํธ์ ์๋ฒฝํ ๊ตฌํ
- ์ ๋๋ฉ์ด์ ํตํฉ: Transition๊ณผ์ ์๋ฒฝํ ์กฐํฉ์ผ๋ก ๋ถ๋๋ฌ์ด ์ฌ์ฉ์ ๊ฒฝํ ์ ๊ณต
- SSR ๋์: ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง ํ๊ฒฝ์์๋ ์์ ์ ์ธ ๋์ ๋ณด์ฅ
๐ ์ค๋ฌด ์ ์ฉ์ ์ํ ์ฒดํฌ๋ฆฌ์คํธ
- โ ๊ธฐ๋ณธ ๊ตฌ์กฐ ์ค์ : ํฌํธ ์ปจํ ์ด๋๋ค์ index.html์ ๋ฏธ๋ฆฌ ์ ์
- โ ์ปดํฌ๋ํธ ์ค๊ณ: ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ Teleport ๋ํผ ์ปดํฌ๋ํธ ๊ตฌ์ถ
- โ ์ํ ๊ด๋ฆฌ: ์ค์์ง์ค์ ์ค๋ฒ๋ ์ด ์ํ ๊ด๋ฆฌ ์์คํ ๊ตฌํ
- โ ์ฑ๋ฅ ๋ชจ๋ํฐ๋ง: ๋ ๋๋ง ์ฑ๋ฅ๊ณผ ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋ ์ฃผ๊ธฐ์ ์ธก์
- โ ์ ๊ทผ์ฑ ํ๋ณด: ARIA ์์ฑ๊ณผ ํฌ์ปค์ค ๊ด๋ฆฌ๋ก ์๋ฒฝํ ์ ๊ทผ์ฑ ์ง์
- โ SSR ์ต์ ํ: ์๋ฒ ๋ ๋๋ง ํ๊ฒฝ์์์ ์์ ์ ์ธ ๋์ ๋ณด์ฅ
๐ฎ ๋ฏธ๋ ์ ๋ง๊ณผ ๋ฐ์ ๋ฐฉํฅ
Vue 3์ Teleport๋ ์์ผ๋ก Web Components์์ ๋์ฑ ๊ธด๋ฐํ ํตํฉ, View Transitions API์์ ๊ฒฐํฉ, Container Queries์์ ์ฐ๋ ๋ฑ์ ํตํด ๋์ฑ ๊ฐ๋ ฅํด์ง ๊ฒ์ผ๋ก ์์๋ฉ๋๋ค. ํนํ ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์์ ๋ค์ดํฐ๋ธ ์ฑ๊ณผ ๊ฐ์ UX ๊ตฌํ์ ์์ด์ ํต์ฌ์ ์ธ ์ญํ ์ ํ ๊ฒ์ ๋๋ค.
๐ก ๋ง๋ฌด๋ฆฌ ํ
Teleport๋ฅผ ๋์ ํ ๋๋ ๋จ๊ณ์ ์ ์ฉ์ ์ถ์ฒํฉ๋๋ค. ๋จผ์ ๊ฐ์ฅ ๋ฌธ์ ๊ฐ ๋๋ ๋ชจ๋ฌ๋ถํฐ ์์ํ์ฌ, ์ ์ฐจ ํ ์คํธ, ๋๋กญ๋ค์ด, ํดํ ๋ฑ์ผ๋ก ํ์ฅํด๋๊ฐ์ธ์. ๊ฐ ๋จ๊ณ์์ ์ฑ๋ฅ ์ธก์ ์ ํตํด ์ต์ ํ ํฌ์ธํธ๋ฅผ ์ฐพ์๊ฐ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
Vue 3 Teleport๋ก ๋ ๋์ ์ฌ์ฉ์ ๊ฒฝํ์ ์ ๊ณตํ๊ณ , DOM ์กฐ์์ ์๋ก์ด ํจ๋ฌ๋ค์์ ๊ฒฝํํด๋ณด์ธ์!
๋๊ธ