tags-view (라우터 탭)
방문한 화면을 상단 탭 바로 유지해 주는 컴포넌트입니다. 레이아웃(
src/layouts/default.vue)이 앱 설정appOptions.appTagsView가 켜져 있을 때만 그립니다.
| 파일 | 역할 |
|---|---|
src/components/plugins/tags-view/src/tags-view.vue | 탭 바 본체 (스크롤, 우클릭 메뉴, 새로고침, 설정 메뉴) |
src/components/plugins/tags-view/src/use-tab-label.ts | 탭 라벨/아이콘을 화면에서 바꾸는 useTabLabel() |
src/components/plugins/tags-view/src/helper.ts | meta.affix 라우트를 고정 탭으로 추출 |
src/stores/tags-view.ts | 방문 탭 목록, 커스텀 라벨, keep-alive 캐시 세대 |
탭 라벨 바꾸기
같은 상세 화면을 여러 개 열면 탭이 전부 같은 이름(meta.title)이라 구분이 안 됩니다. 상담 업무처럼 고객 화면을 여러 개 띄우고 오가는 경우, 탭에 누구인지가 보여야 합니다.
방법은 두 가지이고, 저장소는 하나입니다 — meta 선언은 common-detail.panel 이 useTabLabel() 을 대신 불러주는 단축 문법입니다.
코드에 두 방식이 다 들어 있습니다
- 선언 방식:
board-detail라우트 (src/router/routes.ts) - composable 방식:
src/views/user/user-detail.vue
게시판 상세와 회원 상세를 각각 탭으로 열어 비교해 보세요.
1. 라우트에 선언 (대부분 이걸로 끝)
common-detail.panel 로 여는 상세는 @loaded 로 받은 모델을 그대로 넘겨받습니다. routes.ts 에 두 줄이면 됩니다.
{
path: 'board/:id', name: 'board-detail',
props: route => ({id: route.params.id, componentName: 'board/board-detail'}),
component: () => import('src/views/common-detail.panel.vue'),
meta: {
title: 'Board Detail',
componentName: 'common-detail.panel',
tabLabel: (model: any) => model?.name || 'Board Detail', // 문자열도 가능
tabIcon: 'article'
}
}모델이 아직 없을 때(신규 등록)를 대비해 폴백을 두세요. 상세를 조회하기 전까지는 라우트 meta.title 이 그대로 쓰이고, @loaded 시점에 이 값으로 바뀝니다.
| meta | 타입 | 설명 |
|---|---|---|
tabLabel | string | (model, route) => string | 탭 문구. 함수면 상세가 @loaded 로 넘긴 모델을 받습니다 |
tabIcon | 〃 | 문구 앞 q-icon 이름 |
tabIconColor | 〃 | Quasar 색상명 (green, red, primary …) |
detail-button 으로 열든 행 클릭으로 열든 결국 같은 라우트로 push 하므로 동작은 같습니다.
2. 화면에서 직접 (열린 뒤에 바뀌는 표시)
"상담 중인 고객만 아이콘을 초록으로" 처럼 연 뒤에 상태가 바뀌는 경우는 useTabLabel() 을 씁니다. 위의 meta 방식도 결국 common-detail.panel 이 이걸 대신 불러주는 단축 문법입니다.
import {useTabLabel} from 'src/components/plugins/tags-view';
const {setTabLabel, resetTabLabel} = useTabLabel();
// 이 화면의 탭
setTabLabel({title: model.loginId, icon: 'person'});
// 상담이 시작되면 아이콘 색만 바꾼다 (넘긴 항목만 덮어쓴다)
setTabLabel({iconColor: 'green'});
// 다른 탭 — 목록 화면에서 특정 고객 탭에 표시를 켜는 식
setTabLabel({iconColor: 'green'}, `/member/active/${id}`);
// 라우트 meta 기본값으로 되돌리기
resetTabLabel();- 넘긴 항목만 병합하므로
title과 아이콘을 따로 갱신해도 됩니다. - 대상 탭이 아직 없어도 담아뒀다가 탭이 생길 때 적용됩니다.
- 활성 탭의 라벨은 브라우저 제목(
document.title)에도 반영됩니다(default.vue의 watch). - 라벨이 길어지면 220px 에서 잘리고, 전체 문구는 마우스 오버 시 네이티브 툴팁으로 보입니다.
- 탭을 닫으면 라벨은 자동으로 지워집니다.
상세 컴포넌트에서 쓸 땐 display === 'panel' 가드가 필수다
useTabLabel() 은 대상 탭을 현재 라우트(useRoute().path) 로 잡습니다. 그런데 상세 구현(*-detail.vue)은 탭(common-detail.panel)과 모달(common-detail.modal) 양쪽에서 같은 컴포넌트로 쓰입니다. 목록에서 모달로 열면 그 순간 현재 라우트는 목록이라, 가드 없이 부르면 목록 탭 이름이 고객 이름으로 바뀝니다.
// user-detail.vue — 탭으로 열렸을 때만 라벨을 건드린다
const applyTabLabel = () => {
if (props.display !== 'panel') {
return;
}
setTabLabel({title: model.value.loginId ?? '회원 상세', icon: 'person'});
};두 호스트가 넘기는 display prop('panel' / 'modal')이 정확히 이 구분용입니다. 라우트 meta 방식은 패널 호스트만 해석하므로 이 문제가 없습니다.
확인해 보려면: 회원 목록에서 같은 회원을 모달로 열어 보세요. 가드를 빼면 '회원 관리' 탭 이름이 회원 아이디로 바뀝니다.
호출 시점
useTabLabel()자체는useRoute()를 쓰므로 setup 안에서 불러야 합니다.- 반환된
setTabLabel()은 이후 아무 때나(이벤트 핸들러, watch, async 콜백) 부를 수 있습니다. onMounted시점엔 아직 모델이 없습니다. 상세 조회가 async 라props.id밖에 못 씁니다. 로그인 아이디처럼 조회 결과가 필요하면 조회가 끝난 뒤(보통emit('loaded')근처)에 부르세요.
meta 와 useTabLabel() 을 한 화면에 같이 쓰지 말 것
둘 다 같은 저장소에 쓰기 때문에 나중에 부른 쪽이 이깁니다. 어느 쪽이 이길지는 @loaded 순서에 달려 있어 헷갈립니다. 상세 컴포넌트에서 직접 라벨을 걸기로 했다면 그 라우트의 meta.tabLabel/tabIcon 은 지우세요.
라우트 meta | useTabLabel() | |
|---|---|---|
| 쓰는 때 | 로드된 모델에서 문구만 뽑으면 되는 보통의 경우 | 화면 상태에 따라 라벨/아이콘이 바뀌는 경우 |
| 상세 컴포넌트 수정 | 불필요 | 필요 |
| 모달 함정 | 없음 | display 가드 필요 |
| 다른 탭 지정 | 불가 | 가능 (path 인자) |
라우트 meta.title 을 직접 고치지 말 것
route.meta.title = `고객 상세 - ${name}` // ❌vue-router 는 네비게이션마다 meta 를 새 객체로 만들고(mergeMetaFields), updateVisitedView 가 그 새 객체로 갈아끼웁니다. 그래서 그 탭으로 다시 이동하는 순간 라벨이 원래 값으로 되돌아갑니다. 탭을 오가는 업무에서는 반드시 useTabLabel() 을 쓰세요.
keep-alive 계약
탭 화면은 레이아웃의 keep-alive 가 살려 둡니다.
<!-- src/layouts/default.vue -->
<keep-alive :include="getCaches" :max="KEEP_ALIVE_MAX">
<component :is="Component" :key="tagsViewStore.getCacheKey(route)" />
</keep-alive>캐시 키는 path#epoch
getCacheKey(route) 는 `${route.path}#${cacheEpochs[route.path] ?? 0}` 입니다.
fullPath가 아니라path— 탭 dedupe 기준이path라,?backTo=만 다른 같은 화면이 캐시 엔트리를 두 개 차지하지 않게 합니다.- 탭을 닫거나 새로고침하면
bumpCacheEpoch(path)로 세대가 올라갑니다. 키가 바뀌므로 같은 화면을 다시 열면 새 인스턴스가 만들어집니다.
이게 없으면 이런 일이 생깁니다 — 상세 컴포넌트는 onMounted 에서만 조회하므로, 캐시된 옛 인스턴스가 복원되면 재조회가 안 되고 저장하지 않은 옛 입력이 그대로 뜹니다. 탭 라벨을 거는 코드(@loaded)도 다시 돌지 않습니다.
include 는 컴포넌트 이름 매칭이다
addCachedView() 는 meta.componentName 이 있으면 그 값을, 없으면 라우트 이름을 넣습니다. 하지만 keep-alive 가 비교하는 건 컴포넌트 이름(<script setup> 이면 파일명)입니다.
라우트 이름 ≠ 컴포넌트 파일명이면 조용히 캐시가 안 된다
{ path: 'active', name: 'active-user-list',
component: () => import('src/views/user/user-list.vue'),
meta: { title: '회원 관리', componentName: 'user-list' } } // ← 없으면 캐시 안 됨상세 라우트가 componentName: 'common-detail.panel' 을 갖는 것도 같은 이유입니다.
새로고침은 include 를 건드리지 않는다
탭 우클릭 → Reload 는 bumpCacheEpoch 만 합니다(refreshSelectedTag).
cachedViews 에서 이름을 빼면 안 된다
include 는 이름 매칭이고 상세 탭은 컴포넌트가 전부 common-detail.panel 로 같습니다. Vue 의 pruneCache 는 이름 기준이라, 이름 하나를 빼는 순간 열려 있는 모든 상세 탭의 인스턴스가 통째로 파괴됩니다(한 고객 탭 새로고침 → 다른 고객 탭 전부 초기화). 이 때문에 delCachedView() 는 제거했습니다. /redirect 왕복도 더 이상 하지 않습니다.
상한
KEEP_ALIVE_MAX(기본 20)를 넘으면 오래 안 쓴 화면부터 파괴되고, 다시 열 때 새로 조회합니다. 상담처럼 상세를 계속 여는 업무에서 인스턴스가 무한정 쌓이는 걸 막습니다.
상세 패널 안에는 keep-alive 를 두지 않는다
상세 라우트는 id 가 path 에 있어 id 마다 패널 인스턴스 자체가 다릅니다. 그래서 common-detail.panel.vue 안에 keep-alive 를 두면 자기 것 하나만 들어가 아무 일도 하지 않습니다(예전 :max="5" 가 그랬습니다). 캐싱은 레이아웃 쪽 하나로 충분합니다.
탭에서 제외하기
| meta | 효과 |
|---|---|
affix: true | 앱 시작 시 고정 탭으로 추가되고 개별 닫기가 막힘 |
noTagsView: true | 방문해도 탭이 생기지 않음 |
noCache: true | keep-alive 대상에서 제외 |
store API
| 액션/게터 | 설명 |
|---|---|
setTabLabel(path, label) | 커스텀 라벨 병합 (useTabLabel() 이 감싸고 있음) |
clearTabLabel(path) | 커스텀 라벨 제거 |
getTabLabel(path) | 커스텀 라벨 조회 |
bumpCacheEpoch(path) | 그 화면의 keep-alive 캐시 무효화 |
getCacheKey(route) | keep-alive :key (path#epoch) |
dropTab(path) | 탭 닫힘 뒷정리 (라벨 제거 + epoch bump). 닫기 계열이 자동 호출 |