• #Nuxt
  • #Vue
  • #Excel
  • #XLSX
  • #TypeScript
  • #実装ガイド
開発excel-viewer完了

Nuxt 3 Excel Viewer実装ガイド

Vue + SheetJSによるExcel表示機能

📋 実装概要

Nuxt 3のVueページコンポーネントとして、SheetJS (XLSX.js)を使用したExcel Viewerを実装しました。ページ読み込み時に特定のExcelファイルを自動的に読み込み、表示する機能を備えています。

実装日: 2025年11月9日 ページパス: /excel-viewer自動読み込みファイル: 【狙い筋Aと狙い筋Bの話】.xlsx


🏗️ アーキテクチャ

ファイル構成

apps/web/
├── app/
│   └── pages/
│       └── excel-viewer.vue          # メインコンポーネント
└── public/
    └── excel/
        └── 【狙い筋Aと狙い筋Bの話】.xlsx  # 静的アセット

技術スタック

技術バージョン用途
Nuxt 3-アプリケーションフレームワーク
Vue 3-UIフレームワーク (Composition API)
TypeScript-型安全性
SheetJS (XLSX.js)0.18.5Excelファイルパース
CDNjsDelivrXLSX.jsの配信

💡 実装の核心

1. CDNライブラリの動的読み込み

useHeadでのスクリプト読み込み:

useHead({
  title: 'Excel Viewer v3 - 真のデータ範囲検出',
  script: [
    {
      src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js',
      async: true,
    }
  ]
})

なぜCDN経由か:

  • npmインストールでバンドルサイズが約800KB増加するのを回避
  • 非同期読み込みでページレンダリングをブロックしない
  • グローバル変数window.XLSXとして利用可能

2. ライブラリ読み込み待機パターン

onMounted(() => {
  // ポーリングでXLSXライブラリの読み込みを待機
  const checkXLSX = setInterval(() => {
    if (typeof (window as any).XLSX !== 'undefined') {
      clearInterval(checkXLSX)
      initExcelViewer()
    }
  }, 100)
})

ポーリングを使う理由:

  • async: trueのため、スクリプトの読み込み完了タイミングが不定
  • onloadイベントがuseHeadでは利用しにくい
  • 100ms間隔でのチェックは実用上十分高速

🚀 自動読み込み機能

実装コード

async function autoLoadExcelFile() {
  try {
    loading.classList.add('active')

    // publicディレクトリから直接fetch
    const response = await fetch('/excel/【狙い筋Aと狙い筋Bの話】.xlsx')
    if (!response.ok) throw new Error('ファイルの読み込みに失敗しました')

    const arrayBuffer = await response.arrayBuffer()
    const data = new Uint8Array(arrayBuffer)

    currentFileName = '【狙い筋Aと狙い筋Bの話】.xlsx'
    fileName.textContent = `📄 ${currentFileName}`
    fileInfo.classList.add('active')

    // XLSX.jsでパース
    workbook = XLSX.read(data, {
      type: 'array',
      cellStyles: true,
      cellDates: true
    })

    displayWorkbook()
    loading.classList.remove('active')
  } catch (error: any) {
    loading.classList.remove('active')
    showError('自動読み込みに失敗しました: ' + error.message)
  }
}

// 初期化関数の最後で実行
function initExcelViewer() {
  const XLSX = (window as any).XLSX
  // ... 各種初期化処理 ...

  // 最後に自動読み込み
  autoLoadExcelFile()
}

ファイル配置戦略

Excelファイルの配置:

mkdir -p apps/web/public/excel
cp "元のパス/【狙い筋Aと狙い筋Bの話】.xlsx" apps/web/public/excel/

なぜpublic/か:

  1. ルート相対パス: Nuxtが/excel/*.xlsxとして自動配信
  2. ビルド不要: バイナリファイルをビルドプロセスで処理しない
  3. 日本語対応: UTF-8ファイル名がそのまま使用可能

アクセスURL:

/excel/【狙い筋Aと狙い筋Bの話】.xlsx
→ ブラウザが自動的にURLエンコード
→ /excel/%E3%80%90%E7%8B%99%E3%81%84%E7%AD%8BA%E3%81%A8...

📊 Excelデータの処理

真のデータ範囲検出

function findActualDataRange(worksheet: any) {
  const fullRange = XLSX.utils.decode_range(worksheet['!ref'] || 'A1')
  let lastDataRow = -1
  let lastDataCol = -1

  // 全セルを走査して実際のデータがある範囲を特定
  for (let R = fullRange.s.r; R <= fullRange.e.r; R++) {
    for (let C = fullRange.s.c; C <= fullRange.e.c; C++) {
      const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
      const cell = worksheet[cellAddress]

      // 値があるセルの最大行・列を記録
      if (cell && cell.v !== undefined && cell.v !== null && cell.v !== '') {
        if (R > lastDataRow) lastDataRow = R
        if (C > lastDataCol) lastDataCol = C
      }
    }
  }

  return {
    s: fullRange.s,
    e: { r: lastDataRow, c: lastDataCol }
  }
}

なぜ独自実装か:

  • XLSX.jsの!refは空白セルを含む「定義範囲」
  • 実際のデータがある範囲を正確に検出するため
  • 余計な空白行・列を表示しないため

テーブルの動的生成

function createTable(worksheet: any, displayRange: any, actualRange: any) {
  const table = document.createElement('table')
  table.className = 'excel-table'

  // 列幅設定
  const colgroup = document.createElement('colgroup')
  const cols = worksheet['!cols'] || []

  for (let C = displayRange.s.c; C <= displayRange.e.c; C++) {
    const col = document.createElement('col')
    if (cols[C]?.wpx) {
      col.style.width = cols[C].wpx + 'px'
    } else if (cols[C]?.wch) {
      col.style.width = Math.max(10, cols[C].wch * 8) + 'px'
    } else {
      col.style.width = '100px'
    }
    colgroup.appendChild(col)
  }
  table.appendChild(colgroup)

  // ヘッダー行生成
  const headerRow = document.createElement('tr')
  for (let C = displayRange.s.c; C <= displayRange.e.c; C++) {
    const th = document.createElement('th')
    th.textContent = XLSX.utils.encode_col(C) // A, B, C...
    headerRow.appendChild(th)
  }
  table.appendChild(headerRow)

  // データ行生成
  for (let R = displayRange.s.r; R <= displayRange.e.r; R++) {
    const tr = document.createElement('tr')

    // 行番号セル
    const rowHeader = document.createElement('td')
    rowHeader.className = 'row-header'
    rowHeader.textContent = (R + 1).toString()
    tr.appendChild(rowHeader)

    // データセル
    for (let C = displayRange.s.c; C <= displayRange.e.c; C++) {
      const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
      const cell = worksheet[cellAddress]
      const td = document.createElement('td')

      if (cell) {
        let cellValue = cell.w || cell.v || ''

        // 数値セルの処理
        if (cell.t === 'n') {
          td.classList.add('cell-number')
          if (cell.z?.includes('%')) {
            cellValue = (cell.v * 100).toFixed(1) + '%'
          } else if (cell.z?.includes('¥') || cell.z?.includes('$')) {
            cellValue = cell.w || cellValue.toLocaleString()
          }
        }

        td.textContent = cellValue
        if (cell.s) applyCellStyle(td, cell.s)
      }
      tr.appendChild(td)
    }
    table.appendChild(tr)
  }

  return table
}

セルスタイルの適用

function applyCellStyle(td: HTMLElement, style: any) {
  if (!style) return

  // フォントスタイル
  if (style.font) {
    if (style.font.bold) td.classList.add('cell-bold')
    if (style.font.italic) td.classList.add('cell-italic')
    if (style.font.underline) td.classList.add('cell-underline')
    if (style.font.sz) td.style.fontSize = style.font.sz + 'px'

    // フォント色
    if (style.font.color?.rgb) {
      td.style.color = '#' + style.font.color.rgb.substring(2)
    }
  }

  // 背景色
  if (style.fill?.fgColor?.rgb) {
    td.style.backgroundColor = '#' + style.fill.fgColor.rgb.substring(2)
  }

  // 配置
  if (style.alignment) {
    if (style.alignment.horizontal === 'center') {
      td.classList.add('cell-center')
    } else if (style.alignment.horizontal === 'right') {
      td.classList.add('cell-right')
    }
  }
}

🎨 スタイリング

Scoped CSSと動的DOM

課題: JavaScriptで動的生成される要素にスタイルを適用

解決: :deep()疑似クラスの使用

<style scoped>
/* 静的要素 */
.container {
  max-width: 1400px;
  margin: 0 auto;
}

/* 動的生成要素 - :deep()が必須 */
:deep(.excel-table) {
  border-collapse: collapse;
  font-size: 14px;
}

:deep(.excel-table th) {
  background: #f8f9fa;
  border: 1px solid #dee2e6;
  position: sticky;
  top: 0;
  z-index: 10;
}

:deep(.excel-table td) {
  border: 1px solid #dee2e6;
  padding: 8px 12px;
}

:deep(.excel-table tr:hover) {
  background: #e8ebff;
}

:deep(.sheet-tab.active-tab) {
  background: white;
  border-color: #667eea;
  color: #667eea;
}
</style>

:deep()の仕組み:

  • Vueのscoped属性は通常、コンポーネント直下の要素にのみスタイルを適用
  • :deep()を使うと、動的生成された子孫要素にもスタイルが適用される
  • コンパイル後: [data-v-xxx] .excel-tableのようなセレクタになる

レスポンシブ対応

#tableContainer {
  width: 100%;
  overflow: auto;
  max-height: 600px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}

.excel-table {
  border-collapse: collapse;
  font-size: 14px;
  background: white;
}

/* 固定ヘッダー */
:deep(.excel-table th) {
  position: sticky;
  top: 0;
  z-index: 10;
}

/* 固定行番号列 */
:deep(.row-header) {
  position: sticky;
  left: 0;
  z-index: 5;
  background: #f8f9fa !important;
}

🔧 TypeScript実装

型定義

// XLSX.jsはany型として扱う
const XLSX = (window as any).XLSX
let workbook: any = null
let currentFileName = ''

// DOM要素は明示的に型付け
const fileInput = document.getElementById('fileInput') as HTMLInputElement
const dragDropZone = document.getElementById('dragDropZone') as HTMLElement
const loading = document.getElementById('loading') as HTMLElement
const errorMessage = document.getElementById('errorMessage') as HTMLElement

イベントハンドラ

function handleFileSelect(e: Event) {
  const target = e.target as HTMLInputElement
  const file = target.files?.[0]
  if (file) handleFile(file)
}

function handleFile(file: File) {
  if (!file.name.match(/\.(xlsx|xls)$/i)) {
    showError('対応していないファイル形式です')
    return
  }

  const reader = new FileReader()
  reader.onload = function(e) {
    const data = new Uint8Array(e.target?.result as ArrayBuffer)
    workbook = XLSX.read(data, {
      type: 'array',
      cellStyles: true,
      cellDates: true
    })
    displayWorkbook()
  }
  reader.readAsArrayBuffer(file)
}

any型の使用判断

any型を使う箇所:

  • XLSX.jsのオブジェクト(型定義が不完全)
  • workbookデータ構造(複雑すぎて型付けのコストが高い)

型を付ける箇所:

  • DOM要素(HTMLInputElement, HTMLElement等)
  • イベントハンドラの引数(Event, File等)
  • 関数のパラメータと戻り値

🛣️ Nuxtルーティング

ページ自動登録

apps/web/app/pages/excel-viewer.vue
↓
/excel-viewer

Nuxt 3のPages自動ルーティングにより、ファイル名がそのままURLパスになります。

アクセス方法

開発環境:

http://localhost:3000/excel-viewer

本番環境(Cloudflare Pages):

https://your-domain.pages.dev/excel-viewer

🧪 動作フロー

初期化シーケンス

  1. Vueコンポーネントマウント: onMountedフックが実行
  2. XLSX.js待機: 100msごとにポーリングで確認
  3. 初期化開始: initExcelViewer()実行
  4. イベントリスナー登録: ファイル入力、ドラッグ&ドロップ
  5. 自動読み込み: autoLoadExcelFile()実行
  6. Excelファイルfetch: /excel/*.xlsxからダウンロード
  7. パース: XLSX.jsで解析
  8. テーブル生成: DOM要素を動的生成
  9. 描画完了: ユーザーに表示

ユーザーインタラクション

// シートタブのクリック
tab.onclick = () => {
  document.querySelectorAll('.sheet-tab').forEach(t =>
    t.classList.remove('active-tab')
  )
  tab.classList.add('active-tab')
  displaySheet(index)
}

// ファイルアップロード
fileInput.addEventListener('change', handleFileSelect)

// ドラッグ&ドロップ
dragDropZone.addEventListener('drop', (e) => {
  e.preventDefault()
  const files = e.dataTransfer?.files
  if (files && files.length > 0) handleFile(files[0])
})

🚨 トラブルシューティング

問題1: ページが真っ白

デバッグ:

onMounted(() => {
  console.log('Excel Viewer mounting...')
  const checkXLSX = setInterval(() => {
    console.log('XLSX available:', typeof (window as any).XLSX)
    if (typeof (window as any).XLSX !== 'undefined') {
      clearInterval(checkXLSX)
      console.log('Initializing Excel Viewer')
      initExcelViewer()
    }
  }, 100)
})

原因と対策:

  • XLSX.jsのCDN読み込み失敗 → ネットワークタブで確認
  • JavaScriptエラー → コンソールログで確認

問題2: 自動読み込み失敗

デバッグ:

async function autoLoadExcelFile() {
  console.log('Fetching Excel file...')
  const response = await fetch('/excel/【狙い筋Aと狙い筋Bの話】.xlsx')
  console.log('Response:', response.status, response.statusText)
}

原因と対策:

  • 404エラー → public/excel/にファイルがあるか確認
  • CORS エラー → publicディレクトリなら発生しないはず
  • 文字エンコーディング → ファイル名がUTF-8であることを確認

問題3: スタイルが効かない

チェックリスト:

  • :deep()セレクタを使っているか
  • scoped属性が付いているか
  • クラス名が正しいか
  • 動的生成要素に適用しようとしていないか(:deep()なしで)

📊 パフォーマンス

読み込み時間

フェーズ時間内訳
ページレンダリング~100msNuxt SSR
XLSX.js読み込み~500msCDN (800KB)
Excelファイルfetch~100msローカルアセット
パース + 描画~200msファイルサイズ依存
合計~900ms初回表示まで

バンドルサイズ

アセットサイズ配信方法
excel-viewer.vue~15KBNuxtバンドル
XLSX.js~800KBCDN (非同期)
Excelファイル可変静的アセット

最適化済みポイント:

  • XLSX.jsをバンドルに含めない(CDN経由)
  • 非同期読み込みで初期レンダリングをブロックしない
  • 静的アセットはビルドプロセスを経由しない

🔄 拡張アイデア

1. 複数ファイル切り替え

const excelFiles = ref([
  { name: 'ファイル1.xlsx', path: '/excel/file1.xlsx' },
  { name: 'ファイル2.xlsx', path: '/excel/file2.xlsx' }
])

async function loadExcelFile(filePath: string) {
  const response = await fetch(filePath)
  const arrayBuffer = await response.arrayBuffer()
  const data = new Uint8Array(arrayBuffer)

  workbook = XLSX.read(data, {
    type: 'array',
    cellStyles: true,
    cellDates: true
  })

  displayWorkbook()
}

2. Vueリアクティブシステムへの移行

const workbookData = ref<any>(null)
const currentSheetIndex = ref(0)
const fileInfo = reactive({
  name: '',
  sheetCount: 0,
  rowCount: 0,
  colCount: 0
})

// DOM操作ではなくVueの状態管理で表示を制御
watch(currentSheetIndex, (newIndex) => {
  // シート切り替え時の処理
})

3. コンポーネント分割

components/
├── ExcelViewer/
│   ├── FileUploader.vue     # ファイルアップロード部分
│   ├── SheetTabs.vue        # シートタブ
│   ├── TableRenderer.vue    # テーブル描画
│   └── InfoPanel.vue        # 情報パネル

4. Excelファイルの編集機能

function updateCellValue(row: number, col: number, value: string) {
  const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
  worksheet[cellAddress] = { v: value, t: 's' }

  // 再描画
  displaySheet(currentSheetIndex.value)
}

function exportToExcel() {
  const wbout = XLSX.write(workbook, {
    bookType: 'xlsx',
    type: 'binary'
  })

  // ダウンロード処理
  const blob = new Blob([s2ab(wbout)], {
    type: 'application/octet-stream'
  })
  // ...
}

📝 実装チェックリスト

実装時の確認項目:

  • XLSX.jsをCDN経由で読み込み設定
  • onMountedでライブラリ読み込み待機
  • Excelファイルをpublic/excel/に配置
  • 自動読み込み関数の実装
  • テーブル動的生成ロジック
  • :deep()セレクタでスタイル適用
  • TypeScript型アノテーション
  • エラーハンドリング
  • ファイルアップロード機能(オプション)
  • ドラッグ&ドロップ機能(オプション)

🎯 重要な設計判断

なぜDOM操作を直接行うのか

判断: Vueのリアクティブシステムを使わず、直接DOM操作

理由:

  1. パフォーマンス: 大量のセル(数百〜数千)をリアクティブデータにすると重い
  2. シンプルさ: XLSX.jsのデータ構造をそのまま使える
  3. 段階的移行: 将来的にリアクティブ化する余地を残す

なぜCDNなのか

判断: npm installせずCDN経由でXLSX.js読み込み

理由:

  1. バンドルサイズ: 800KBをメインバンドルから除外
  2. キャッシュ: ブラウザキャッシュで2回目以降高速
  3. 非同期: 初期レンダリングをブロックしない

なぜpublic/なのか

判断: Excelファイルをpublic/に配置

理由:

  1. 静的配信: ビルドプロセスを経由しない
  2. 日本語ファイル名: UTF-8がそのまま使える
  3. 直接アクセス: /excel/*.xlsxで直接アクセス可能

参考資料