[Nuxt3] NuxtPage에 keepalive를 적용한 Tab형 화면 구현하기(Element Plus el-tabs)
개요
탭으로 화면을 보는 것은 일상에서 많이 사용하는 기능 중 하나이다.
따라서 Web 개발 시에 탭을 활용한 화면 전환을 만들어야 할 경우가 생기는데
이를 Nuxt3에서 어떻게 구현할 수 있는지 살펴보고자 한다.
UI 컴포넌트 라이브러리로는 Element Plus를 사용했으며, el-tabs를 사용했다.
<NuxtPage>
<NuxtPage> · Nuxt Components
The <NuxtPage> component is required to display pages located in the pages/ directory.
nuxt.com
루트 디렉터리인 pages에 생성한 vue 파일을 router를 통해 표시하는 Component이다.
keepalive
Vue.js
Vue.js - The Progressive JavaScript Framework
vuejs.org
Vue.js 3에서 지원하는 태그이며, 내부에 생성된 html은 캐시를 통해 저장되어 상태가 보존된다.
KeepAliveProps에 include, exclude, max를 선택할 수 있다.
<NuxtPage> props에도 keepalive를 지원하며 KeepAliveProps를 그대로 사용할 수 있다.
interface KeepAliveProps {
/**
* If specified, only components with names matched by
* `include` will be cached.
*/
include?: MatchPattern
/**
* Any component with a name matched by `exclude` will
* not be cached.
*/
exclude?: MatchPattern
/**
* The maximum number of component instances to cache.
*/
max?: number | string
}
type MatchPattern = string | RegExp | (string | RegExp)[]
include에 열려 있는 페이지 정보를 넣어서 KeepAlive로 관리하는 캐시를 동적으로 관리할 수 있지 않을까 수많은 시도를 해보았으나 결국 실패하였다.
시점으로 보면 페이지가 캐싱이 될지 안될지 결정되는 시점은 NuxtPage가 해당 페이지를 바인딩 하는 시점이다. 따라서 다른 페이지로 이동하고 이전에 봤던 페이지 정보를 include에서 지운다 한들 캐싱된 페이지가 지워지지 않는다.
해당 페이지가 열리는 시점에 include에 정보가 포함되어 있으면 계속 열리고, 캐시데이터가 모두 날아가는 형태로 작동되기 때문에 우리가 원하는 동적 관리가 되지 않아 새로운 방법을 탐구하게 되었다.
주요 요구 기능
- 열었던 페이지는 탭에 표시되어 다시 돌아갈 수 있음.
- 탭에 있던 메뉴를 클릭하여 화면을 전환하면 기존에 조회하거나 입력했던 데이터가 보존되어야 함
- 탭을 완전히 닫고 그 메뉴를 다시 들어가면 해당 화면이 초기화 되어 있어야 함
- 기타 탭 기본 기능(닫기, 열었던 화면 다시 열기 등)
Idea 1. tab 내부에 <NuxtPage> 생성
<template>
<el-tabs
v-model="editableTabsValue"
type="card"
editable
class="demo-tabs"
@edit="handleTabsEdit"
>
<el-tab-pane
v-for="item in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
<NuxtPage :keepalive />
</el-tab-pane>
</el-tabs>
</template>
위와 같은 원리로 el-tab-pane 내부에 NuxtPage를 집어넣어 관리하는 방법이다.
결과부터 말하자면 기능은 확실하지만 성능이 망해서 사용할 수 없다.
위에 기술한 주요 요구 기능을 완벽하게 구현한다.
하지만 NuxtPage와 keepalive로 인해 캐시된 화면 정보가 열려있는 탭의 n^2만큼 생성된다.
분석해본 바에 따르면 Nuxt3에서 라우트가 작동되면 프로젝트에 있는 모든 NuxtPage에 페이지를 띄운다.
이 때 keepalive로 인해 모든 NuxtPage에 캐시도 저장하게 된다.
tab이 v-for로 생성되므로 tab 개수만큼 NuxtPage가 생성되니까, 총 n^2개 만큼의 Page 캐시를 저장하게 되는것이다.
이는 프로젝트가 무거워지면 말도 안될 수준의 성능 저하를 유발할 수 있으므로 제외하게 되었다.
Idea 2. tab 외부에 단일 <NuxtPage> 관리
<template>
<el-tabs
v-model="editableTabsValue"
type="card"
editable
class="demo-tabs"
@edit="handleTabsEdit"
>
<el-tab-pane
v-for="item in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
/>
</el-tabs>
<NuxtPage :keepalive />
</template>
다음은 tab 바깥에 NuxtPage를 생성해서 tab이 늘어나는 것과 상관 없이 NuxtPage가 단 하나만 존재하는 것이다.
이는 화면을 생성하고, 화면을 다시 띄울 때 데이터가 유지되는것 까지는 정상적으로 작동한다.
하지만 탭을 지우고 화면을 다시 들어갈 때 조차 cache에 있는 데이터가 유지되서 화면이 초기화 되지 않는다.
이는 화면별로 추가적인 작업을 요구해 상당히 불편해질 수 있다.
하지만 NuxtPage를 하나만 유지하고, cache데이터도 열었던 페이지 만큼만 유지할 수 있게 되기 때문에 이 방법을 채택하게 되었다.
설계
pinia store을 이용한 tab 정보 관리
새로고침 시 탭정보 유지를 위해선 로컬스토리지 등과 같은 저장 수단을 고려해도 좋다.
// sotres/tabs.ts
// composition API 형태
import { defineStore } from 'pinia'
type Tab = {
title: string
path: string
params?: Record<string, any>
}
export function useTabsStore() {
// State
const tabs = ref<{ title: string; path: string; params?: any }[]>([]);
const activeTab = ref('');
// Actions
const addTab = (tab: { title: string; path: string; params?: any }) => {
if (!tabs.value.find((t) => t.path === tab.path)) {
tabs.value.push(tab);
}
activeTab.value = tab.path;
};
const removeTab = (path: string) => {
const index = tabs.value.findIndex((t) => t.path === path);
if (index > -1) {
tabs.value.splice(index, 1);
if (activeTab.value === path) {
activeTab.value = tabs.value[index - 1]?.path || tabs.value[0]?.path || '';
}
}
};
const getParams = (path: string) => {
return tabs.value.find((t) => t.path === path)?.params;
};
const setParams = (path: string, params: any) => {
const index = tabs.value.findIndex((t) => t.path === path);
if (index > -1) {
tabs.value[index].params = params;
}
};
return {
tabs,
activeTab,
addTab,
removeTab,
getParams,
setParams,
};
}
middleware로 페이지 이동 시(router 사용) tab정보 저장
// middleware/tabs.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
const tabsStore = useTabsStore()
const pageName = to.meta?.name ?? 'unnamed page'
// 탭이 이미 열려있지 않으면 추가
if (!tabsStore.tabs.some((tab) => tab.path === to.path)) {
tabsStore.addTab({ title: pageName, path: to.path })
} else if (tabsStore.activeTab !== to.path) {
tabsStore.activeTab = to.path
}
})
store의 tab을 바인딩한 el-tabs
// components/DynamicTabs.vue
<template>
<div>
<el-tabs v-model="activeTab" closable @tab-click="handleTabClick" @edit="handleTabsEdit">
<el-tab-pane v-for="tab in tabs" :key="tab.path" :label="tab.title" :name="tab.path">
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { useTabsStore } from '@/stores/tabs'
import { computed } from 'vue'
import type { TabsPaneContext, TabPaneName } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
const tabsStore = useTabsStore()
const tabs = computed(() => tabsStore.tabs)
const activeTab = computed({
get: () => tabsStore.activeTab,
set: (value) => (tabsStore.activeTab = value),
})
const handleTabClick = (tab: TabsPaneContext) => {
tabsStore.activeTab = tab.paneName as string
router.push(tab.paneName as string)
}
const handleTabsEdit = (targetName: TabPaneName | undefined, action: 'remove' | 'add') => {
if (action === 'remove') {
if (targetName === tabsStore.activeTab) {
const tabs = tabsStore.tabs
const index = tabs.findIndex((a) => a.path === targetName)
const newTab =
index === 0 ? tabs[1]?.path || '/' : tabs[index - 1]?.path || tabs[0]?.path || '/'
router.push(newTab).then(() => {
tabsStore.removeTab(targetName as string)
})
} else {
tabsStore.removeTab(targetName as string)
router.push(tabsStore.activeTab as string)
}
}
}
</script>
composables에 onactivate을 활용한 화면 이동 시 초기화 판단 및 초기화
// composables/useRefreshOnActivated.ts
export const useRefreshOnActivated = (refreshMethod: () => void) => {
onActivated(() => {
const route = useRoute()
if (route.meta.refresh) {
refreshMethod()
}
})
}
결론
위 코드는 Nuxt3에서 Element Plus의 el-tabs 컴포넌트를 활용해서 동적 탭 기능을 구현하는 가장 기본적인 형태입니다. 이후 정책에 따라 새로고침 시 처리 여부, 화면 초기화 여부, 화면 actviated 상태 관리 등 추가 기능을 구현하여 개선해 나가면 좋을 듯 싶습니다.