Skip to content

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.tsmeta.affix 라우트를 고정 탭으로 추출
src/stores/tags-view.ts방문 탭 목록, 커스텀 라벨, keep-alive 캐시 세대

탭 라벨 바꾸기

같은 상세 화면을 여러 개 열면 탭이 전부 같은 이름(meta.title)이라 구분이 안 됩니다. 상담 업무처럼 고객 화면을 여러 개 띄우고 오가는 경우, 탭에 누구인지가 보여야 합니다.

방법은 두 가지이고, 저장소는 하나입니다 — meta 선언은 common-detail.paneluseTabLabel() 을 대신 불러주는 단축 문법입니다.

코드에 두 방식이 다 들어 있습니다

  • 선언 방식: board-detail 라우트 (src/router/routes.ts)
  • composable 방식: src/views/user/user-detail.vue

게시판 상세와 회원 상세를 각각 탭으로 열어 비교해 보세요.

1. 라우트에 선언 (대부분 이걸로 끝)

common-detail.panel 로 여는 상세는 @loaded 로 받은 모델을 그대로 넘겨받습니다. routes.ts 에 두 줄이면 됩니다.

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타입설명
tabLabelstring | (model, route) => string탭 문구. 함수면 상세가 @loaded 로 넘긴 모델을 받습니다
tabIcon문구 앞 q-icon 이름
tabIconColorQuasar 색상명 (green, red, primary …)

detail-button 으로 열든 행 클릭으로 열든 결국 같은 라우트로 push 하므로 동작은 같습니다.

2. 화면에서 직접 (열린 뒤에 바뀌는 표시)

"상담 중인 고객만 아이콘을 초록으로" 처럼 연 뒤에 상태가 바뀌는 경우는 useTabLabel() 을 씁니다. 위의 meta 방식도 결국 common-detail.panel 이 이걸 대신 불러주는 단축 문법입니다.

ts
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) 양쪽에서 같은 컴포넌트로 쓰입니다. 목록에서 모달로 열면 그 순간 현재 라우트는 목록이라, 가드 없이 부르면 목록 탭 이름이 고객 이름으로 바뀝니다.

ts
// 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') 근처)에 부르세요.

metauseTabLabel() 을 한 화면에 같이 쓰지 말 것

둘 다 같은 저장소에 쓰기 때문에 나중에 부른 쪽이 이깁니다. 어느 쪽이 이길지는 @loaded 순서에 달려 있어 헷갈립니다. 상세 컴포넌트에서 직접 라벨을 걸기로 했다면 그 라우트의 meta.tabLabel/tabIcon 은 지우세요.

라우트 metauseTabLabel()
쓰는 때로드된 모델에서 문구만 뽑으면 되는 보통의 경우화면 상태에 따라 라벨/아이콘이 바뀌는 경우
상세 컴포넌트 수정불필요필요
모달 함정없음display 가드 필요
다른 탭 지정불가가능 (path 인자)

라우트 meta.title 을 직접 고치지 말 것

ts
route.meta.title = `고객 상세 - ${name}`   // ❌

vue-router 는 네비게이션마다 meta새 객체로 만들고(mergeMetaFields), updateVisitedView 가 그 새 객체로 갈아끼웁니다. 그래서 그 탭으로 다시 이동하는 순간 라벨이 원래 값으로 되돌아갑니다. 탭을 오가는 업무에서는 반드시 useTabLabel() 을 쓰세요.

keep-alive 계약

탭 화면은 레이아웃의 keep-alive 가 살려 둡니다.

html
<!-- 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> 이면 파일명)입니다.

라우트 이름 ≠ 컴포넌트 파일명이면 조용히 캐시가 안 된다

ts
{ 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: truekeep-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). 닫기 계열이 자동 호출