Vue.js タブ分割とURLクエリパラメータによる状態管理
背景と課題
税務アシスタントアプリの index.vue が700行以上に肥大化していた。6つのタブ(読取一覧、科目別一覧、結果、重複チェック、月次推移表、Square明細)の全ロジックが1ファイルに集中し、以下の問題が発生していた。
発見された問題点
| 重要度 | 問題 | 詳細 |
|---|---|---|
| 中 | 重複チェック→レシート移動が失敗 | batch_id引数を無視。バッチ/年月を跨ぐと遷移できない |
| 中 | 結果タブとCSVエクスポートの対象不一致 | 結果タブは全バッチ表示だが、CSVはcurrentBatchIdのみ対象 |
| 低 | タブ間で状態が共有されている | selectedYear/selectedMonthを読取一覧と結果タブで共有(副作用あり) |
根本原因は「タブごとに状態が分離されていないため、あるタブでの操作が別タブに影響する」こと。
解決策:タブ分割とcomposable抽出
ディレクトリ構成
既存の XXXView.vue パターンを活かし、新規作成は最小限に抑えた。
components/
duplicate/DuplicateView.vue # 既存: 重複チェックタブ
matrix/MatrixView.vue # 既存: 月次推移表タブ
miller/MillerColumnsView.vue # 既存: 科目別一覧タブ
square/SquareMatchingView.vue # 既存: Square明細タブ
tabs/ResultTab.vue # 新規: 結果タブ(年月フィルタ独立)
tabs/ReceiptTab.vue # 新規: 読取一覧タブ
composables/
useBatches.ts # 既存
useReceipts.ts # 既存
useResultItems.ts # 新規: 結果タブ専用
pages/
index.vue # タブ切り替え + グローバル状態のみ
責務の分離
| コンポーネント | 責務 | 使用するcomposable |
|---|---|---|
index.vue | タブ切り替え、帳票種別、バッチ一覧のprovide | useBatches |
tabs/ReceiptTab.vue | 読取一覧タブ全体 | useReceipts |
tabs/ResultTab.vue | 全データの一覧表示、年月フィルタ(独立) | useResultItems(新規) |
各XXXView.vue | それぞれのタブ固有ロジック | 専用composable |
useResultItems composableの実装
結果タブ専用のcomposableを新規作成。読取一覧タブのselectedYear/selectedMonthとは完全に独立させた。
// composables/useResultItems.ts
interface UseResultItemsOptions {
documentType: Ref<string>
}
export function useResultItems(options: UseResultItemsOptions) {
const api = useApi()
const { documentType } = options
// 全データ(ResultTab専用)
const allItems = ref<Receipt[]>([])
const loading = ref(false)
// タブ固有のフィルター状態(index.vueのselectedYear/selectedMonthとは独立)
const selectedYear = ref<string | null>(null)
const selectedMonth = ref<string | null>(null)
// 帳票種別でフィルタリングした全アイテム(日付順ソート)
const itemsByDocType = computed(() =>
allItems.value
.filter(item => (item.document_type || 'receipt') === documentType.value)
.sort((a, b) => {
const dateA = (a['日付'] as string) || ''
const dateB = (b['日付'] as string) || ''
return dateA.localeCompare(dateB)
})
)
// 年リスト(昇順)
const years = computed(() => {
const yearSet = new Set<string>()
itemsByDocType.value.forEach((item) => {
const year = parseYear(item['日付'] as string | undefined)
if (year) yearSet.add(year)
})
return Array.from(yearSet).sort()
})
// 年ごとの統計
const yearStats = computed(() => {
const stats: Record<string, { total: number, confirmed: number }> = {}
itemsByDocType.value.forEach((item) => {
const year = parseYear(item['日付'] as string | undefined)
if (year) {
if (!stats[year]) stats[year] = { total: 0, confirmed: 0 }
stats[year].total++
if (item.confirmed) stats[year].confirmed++
}
})
return stats
})
// 選択年内の月リスト(昇順)
const months = computed(() => {
if (!selectedYear.value) return []
const monthSet = new Set<string>()
itemsByDocType.value.forEach((item) => {
const parsed = parseYearMonth(item['日付'] as string | undefined)
if (parsed && parsed.year === selectedYear.value) {
monthSet.add(parsed.month)
}
})
return Array.from(monthSet).sort()
})
// 年月でフィルタリングしたアイテム(表示用)
const items = computed(() => {
let filtered = itemsByDocType.value
if (selectedYear.value) {
filtered = filtered.filter((item) => {
const year = parseYear(item['日付'] as string | undefined)
return year === selectedYear.value
})
}
if (selectedMonth.value) {
filtered = filtered.filter((item) => {
const parsed = parseYearMonth(item['日付'] as string | undefined)
return parsed && parsed.month === selectedMonth.value
})
}
return filtered
})
// 年選択
function selectYear(year: string | null) {
selectedYear.value = year
if (year) {
// 年が変わったら最初の月を自動選択
nextTick(() => {
const availableMonths = months.value
selectedMonth.value = availableMonths.length > 0 ? availableMonths[0]! : null
})
} else {
selectedMonth.value = null
}
}
return {
allItems,
loading,
selectedYear,
selectedMonth,
years,
yearStats,
months,
monthStats,
items,
loadAllItems,
selectYear,
selectMonth,
}
}
グローバル状態の管理(provide/inject)
Piniaは使用せず、provide/inject パターンで実装。理由は以下の通り。
- 既存コードがprovide/injectパターン
- タブ間共有は2-3個の状態のみで、Piniaほどの機能は不要
- 追加の依存・学習コストを避けたい
index.vueでprovideする状態
// index.vue
provide('documentTypes', documentTypes)
provide('currentDocumentType', currentDocumentType)
provide('selectDocumentType', selectDocumentType)
provide('batches', batches)
provide('loadBatches', loadBatches)
provide('currentTab', currentTab)
provide('goToReceipt', handleGoToReceipt)
設計ルール:タブ固有状態の共有禁止
タブ固有の状態(フィルター、選択状態など)は必ずタブ内で完結させる。provide/inject や Pinia に移しても、タブ間で共有しない。違反すると「あるタブでの操作が別タブに影響する」問題が再発する。
URLクエリパラメータによる状態管理
タブ分割により各タブが独自の状態を持つようになったため、クエリパラメータの実装が安全に行えるようになった。
解決するユーザビリティ問題
- 重複チェック → レシート遷移後、ブラウザ「戻る」で元の画面に戻れない
- URLを共有しても同じ画面を再現できない
対応パラメータ
| パラメータ | 値の例 | 用途 |
|---|---|---|
tab | receipt, result, duplicate, matrix, miller, square | 表示中のタブ |
docType | receipt, sales_slip | 帳票種別 |
batch | 20260114_123456 | バッチID(読取一覧タブ) |
receiptYear | 2024 | 読取一覧タブの年フィルター |
receiptMonth | 02 | 読取一覧タブの月フィルター |
receiptFile | 20250121_0327_0001.jpg | 読取一覧タブの選択ファイル |
resultYear | 2024 | 結果タブの年フィルター |
resultMonth | 02 | 結果タブの月フィルター |
dupYear | 2024 | 重複チェックの年フィルター |
dupMonth | 02 | 重複チェックの月フィルター |
注意: 年月はタブごとに別キーにすることで「タブ固有状態の共有禁止ルール」を遵守
URL例
# 読取一覧タブ(特定ファイル指定)
/?batch=20260109_144208&receiptYear=2024&receiptMonth=02&receiptFile=20250121_0327_0001.jpg
# 重複チェックタブ(2024年2月)
/?tab=duplicate&dupYear=2024&dupMonth=02
# 結果タブ(売上伝票、2024年1月)
/?tab=result&docType=sales_slip&resultYear=2024&resultMonth=01
実装詳細:履歴管理の使い分け
router.replace() vs router.push()
| メソッド | 用途 | 使用箇所 |
|---|---|---|
router.replace() | 履歴を汚さない | 通常の状態変更(年月選択、タブ内操作) |
router.push() | 履歴に残す | 意図的な遷移(goToReceipt) |
updateQueryParams()の実装
// 履歴を汚さないURL更新
function updateQueryParams() {
if (!import.meta.client || !isInitialized.value) return
// このタブがアクティブな時だけURLを更新(他タブの履歴を汚染しない)
if (currentTab?.value !== 'result') return
// このタブ専用のクエリを構築(他タブのパラメータは含めない)
const query: Record<string, string> = {
tab: 'result',
docType: currentDocumentType.value,
}
if (selectedYear.value) {
query.resultYear = selectedYear.value
}
if (selectedMonth.value) {
query.resultMonth = selectedMonth.value
}
router.replace({ query })
}
ブラウザ戻る/進む対応
route.fullPath を監視してURLの変更を検知し、内部状態を同期する。
// index.vue
watch(() => route.fullPath, () => {
if (!import.meta.client || !isInitialized.value) return
const query = route.query
const validTabs = ['receipt', 'result', 'duplicate', 'matrix', 'miller', 'square', 'creditcard']
// タブの同期
const queryTab = query.tab as string | undefined
if (queryTab && validTabs.includes(queryTab) && currentTab.value !== queryTab) {
currentTab.value = queryTab as typeof currentTab.value
} else if (!queryTab && currentTab.value !== 'receipt') {
currentTab.value = 'receipt'
}
// 帳票種別の同期(URLに明示的にdocTypeがある場合のみ変更)
const queryDocType = query.docType as string | undefined
if (queryDocType && documentTypes.some(t => t.id === queryDocType) && currentDocumentType.value !== queryDocType) {
currentDocumentType.value = queryDocType
}
})
DuplicateViewからの遷移・戻り機能
重複チェックタブからレシートに遷移する際、goToReceipt関数を使用する。この関数はindex.vueでprovideされている。
goToReceipt関数の実装
// index.vue
function handleGoToReceipt(fileName: string, batchId: string) {
const key = `${batchId}::${fileName}`
// 現在のURLを履歴に保存してから遷移(戻るボタンで戻れるように)
if (import.meta.client) {
const currentUrl = new URL(window.location.href)
window.history.pushState({ ...window.history.state }, '', currentUrl.toString())
}
currentTab.value = 'receipt'
if (import.meta.client) {
localStorage.setItem('ocr_checker_current_tab', 'receipt')
}
// nextTickでReceiptTabがアクティブになってからselectByKeyを呼ぶ
nextTick(() => {
if (receiptTabRef.value) {
receiptTabRef.value.goToReceiptByKey(key)
}
})
}
provide('goToReceipt', handleGoToReceipt)
DuplicateViewでの使用
// DuplicateView.vue
const goToReceiptFn = inject<(fileName: string, batchId: string) => void>('goToReceipt')!
function handleItemClick(item: DuplicateItem) {
goToReceiptFn(item.file_name, item.batch_id)
}
localStorageとURLパラメータの役割分担
両方を使用することで、異なるユースケースに対応する。
| 用途 | localStorage | クエリパラメータ |
|---|---|---|
| 通常のリロード | 復元される | 復元される(優先) |
| ブラウザ戻る/進む | 対応不可 | 対応 |
| URL共有 | 共有不可 | 共有可能 |
| タブクリックで直接移動時の復元 | 対応 | 非対応 |
優先順位
クエリパラメータ > localStorage > デフォルト値
ユースケース別の動作
1. 戻るボタン派(履歴を辿る)
Square明細(2024/02) → 結果タブ → 戻るボタン → Square明細(2024/02)に戻る
URLクエリパラメータが履歴に残っているため、戻るボタンで元の状態に戻れる。
2. タブクリック派(直接移動)
Square明細(2024/02) → 結果タブ → Square明細タブをクリック → Square明細(2024/02)に戻る
localStorageに「前回の位置」が保存されているため、タブをクリックしても元の位置に復元される。
発生したバグと修正
1. 履歴汚染の問題
問題: 非アクティブなタブ(v-showで隠れているだけ)が、バックグラウンドでupdateQueryParams()を呼び出し続け、URLに不要なパラメータが追加されていた。
修正: 各タブコンポーネントでcurrentTabをinjectし、自分のタブがアクティブな時だけURLを更新するようにした。
const currentTab = inject<Ref<string>>('currentTab')
function updateQueryParams() {
if (!import.meta.client || !isInitialized.value) return
if (currentTab?.value !== 'result') return // 追加
// ...
}
2. Alt+矢印キーが効かない問題
問題: ブラウザの戻る/進むショートカット(Alt+Left/Right)が効かなかった。
原因: キーボードハンドラが矢印キーをe.preventDefault()でキャンセルしており、Alt修飾キーのチェックがなかった。
修正: Altキーが押されている場合はブラウザに処理を委譲。
// 修正後
if (e.altKey) return // Alt+矢印はブラウザの戻る/進むなので処理しない
if (e.key === 'ArrowLeft') {
e.preventDefault()
goPrev()
}
3. タブ切り替えが1回目で効かない問題
問題: 読取一覧→科目別一覧など、タブを切り替える際に1回目のクリックでは切り替わらず、2回目で切り替わる。
原因: タイミング競合。switchTab()実行後、タブコンポーネントのwatchがupdateQueryParams()を呼び、その結果index.vueのroute.fullPathwatchが発火してタブを元に戻そうとしていた。
修正: タブアクティブ時のupdateQueryParams()をnextTick()で遅延実行。
watch(() => currentTab?.value, (newTab) => {
if (newTab === 'result' && isInitialized.value) {
nextTick(() => {
updateQueryParams()
})
}
})
リファクタリング効果
| 指標 | Before | After |
|---|---|---|
| index.vue 行数 | 859行 | 356行 |
| タブ間状態共有 | あり(バグの原因) | なし |
| ブラウザ戻る対応 | なし | あり |
| URL共有 | 不可 | 可能 |
検証項目
| 項目 | 手順 | 期待結果 |
|---|---|---|
| 読取一覧タブの独立性 | 結果タブで年月を変更 → 読取一覧タブに戻る | 読取一覧タブの年月フィルタは変更されていない |
| 帳票種別の共有 | 読取一覧タブで「売上伝票」を選択 → 結果タブに移動 | 結果タブも「売上伝票」が選択されている |
| 重複チェック→レシート移動 | 重複チェックタブで異なるバッチのレシートをクリック | 読取一覧タブに遷移し、該当レシートが表示される |
| 戻るボタン | 重複チェック→レシート遷移後、Alt+← | 重複チェックタブに戻る |
まとめ
700行超のindex.vueを356行に削減し、タブごとに状態を分離することで保守性が向上した。URLクエリパラメータによる状態管理を追加し、ブラウザの戻る/進むボタン対応とURL共有機能を実現した。
設計上の重要なポイントは「タブ固有状態の共有禁止ルール」で、これを守ることでタブ間の副作用を防止できる。また、router.replace()とrouter.push()の使い分けにより、履歴の汚染を防ぎつつ必要な遷移は履歴に残すことができた。