開発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.5 | Excelファイルパース |
| CDN | jsDelivr | XLSX.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/か:
- ルート相対パス: Nuxtが
/excel/*.xlsxとして自動配信 - ビルド不要: バイナリファイルをビルドプロセスで処理しない
- 日本語対応: 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
🧪 動作フロー
初期化シーケンス
- Vueコンポーネントマウント:
onMountedフックが実行 - XLSX.js待機: 100msごとにポーリングで確認
- 初期化開始:
initExcelViewer()実行 - イベントリスナー登録: ファイル入力、ドラッグ&ドロップ
- 自動読み込み:
autoLoadExcelFile()実行 - Excelファイルfetch:
/excel/*.xlsxからダウンロード - パース: XLSX.jsで解析
- テーブル生成: DOM要素を動的生成
- 描画完了: ユーザーに表示
ユーザーインタラクション
// シートタブのクリック
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()なしで)
📊 パフォーマンス
読み込み時間
| フェーズ | 時間 | 内訳 |
|---|---|---|
| ページレンダリング | ~100ms | Nuxt SSR |
| XLSX.js読み込み | ~500ms | CDN (800KB) |
| Excelファイルfetch | ~100ms | ローカルアセット |
| パース + 描画 | ~200ms | ファイルサイズ依存 |
| 合計 | ~900ms | 初回表示まで |
バンドルサイズ
| アセット | サイズ | 配信方法 |
|---|---|---|
| excel-viewer.vue | ~15KB | Nuxtバンドル |
| XLSX.js | ~800KB | CDN (非同期) |
| 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操作
理由:
- パフォーマンス: 大量のセル(数百〜数千)をリアクティブデータにすると重い
- シンプルさ: XLSX.jsのデータ構造をそのまま使える
- 段階的移行: 将来的にリアクティブ化する余地を残す
なぜCDNなのか
判断: npm installせずCDN経由でXLSX.js読み込み
理由:
- バンドルサイズ: 800KBをメインバンドルから除外
- キャッシュ: ブラウザキャッシュで2回目以降高速
- 非同期: 初期レンダリングをブロックしない
なぜpublic/なのか
判断: Excelファイルをpublic/に配置
理由:
- 静的配信: ビルドプロセスを経由しない
- 日本語ファイル名: UTF-8がそのまま使える
- 直接アクセス:
/excel/*.xlsxで直接アクセス可能