CSVファイルをブラウザで読み込んで、テーブルに表示する機能は、インポート機能のプレビューや、簡易ビューアで使われます。ところが、「改行で分けて、カンマで分ける」だけの実装は、実際のCSVではすぐに壊れます。この記事では、Vue 3で、セル内のカンマや改行とShift_JISの文字化けに対応した実装を解説します。
流れは3つです。①ファイルをArrayBufferで受け取る、②文字コードを判定して文字列にする、③CSVとして解析して、配列をテーブルに出す。読み込みはすべてブラウザの中で完結し、サーバーには送りません。
split(‘,’)では壊れる理由
よくある解説は、split('\n')で行に分け、split(',')で列に分けます。ですが、CSVの規則(RFC 4180)では、ダブルクォートで囲んだセルの中に、カンマや改行を含められます。
// NG: これは壊れる
const rows = text.split('\n').map(row => row.split(','))
// 入力
// 名前,住所
// "山田, 太郎","東京都
// 新宿区"
// split(',') の結果:1行目は 2 列だが、2行目は
// ['"山田', ' 太郎"', '"東京都'] になり、列も行もずれる
住所や備考欄を含むCSVでは、これが頻繁に起こります。ダブルクォートの中かどうかを覚えながら、1文字ずつ読むパーサが必要です。
CSVパーサを書く
引用符の中のカンマ・改行・""(引用符のエスケープ)、改行コードのCRLFに対応した、短いパーサです。
// utils/parseCsv.js
// 引用符で囲まれたカンマ・改行・"" (引用符のエスケープ)に対応した簡易パーサ
export function parseCsv(text, delimiter = ',') {
const rows = []
let row = []
let cell = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
cell += '"' // "" は " 1文字
i++
} else {
inQuotes = false
}
} else {
cell += c // 引用符の中は、カンマも改行もそのまま
}
} else if (c === '"') {
inQuotes = true
} else if (c === delimiter) {
row.push(cell)
cell = ''
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++ // CRLF
row.push(cell)
rows.push(row)
row = []
cell = ''
} else {
cell += c
}
}
// 末尾に改行がないファイルの最後の行
if (cell !== '' || row.length) {
row.push(cell)
rows.push(row)
}
// 空行だけ取り除く(1列のCSVは残す)
return rows.filter((r) => !(r.length === 1 && r[0] === ''))
}
これは学習用の簡易版です。引用符の途中で文字が混ざるような壊れたCSVや、巨大なファイルには向きません。本格的に使うなら、後述のPapaParseを使うほうが確実です。
文字コードを判定する(Shift_JISの文字化け対策)
日本のExcelで「CSV」として保存すると、文字コードがShift_JISになることがよくあります。FileReader.readAsTextの既定はUTF-8なので、そのまま読むと文字化けします。
そこで、まずUTF-8として厳密に読み、失敗したらShift_JISとして読み直します。fatal: trueを付けると、UTF-8として不正なバイト列で例外が出ます。また、UTF-8のBOM(ファイル先頭の目印)は自動で取り除かれます。BOMが残ったままだと、先頭の列名に見えない文字が付いてしまいます。
// utils/decodeCsv.js
// UTF-8として厳密に読めなければ、Shift_JISとして読み直す
export function decodeCsv(buffer) {
try {
// fatal: true なら、不正なバイト列で例外になる。BOMは自動で取り除かれる
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch {
return new TextDecoder('shift_jis').decode(buffer)
}
}
この判定は「UTF-8として読めるか」を見るだけです。Shift_JISの文字列が、たまたまUTF-8として読めてしまうことは、まれです。判定に頼らず確実にしたいなら、画面に文字コードの選択欄を設ける方法もあります。
コンポーネントを作る
ファイルを選んだら、読み込み、解析し、先頭の行をテーブルに表示します。列数が行によって違っても崩れないように、セルは列見出しの数を基準に取り出しています。
<script setup>
import { ref, computed } from 'vue'
import { parseCsv } from '@/utils/parseCsv'
import { decodeCsv } from '@/utils/decodeCsv'
const MAX_ROWS = 200 // 画面に出す最大行数
const rows = ref([])
const fileName = ref('')
const error = ref('')
async function onFileChange(event) {
const file = event.target.files[0]
if (!file) return
error.value = ''
try {
const buffer = await file.arrayBuffer()
const parsed = parseCsv(decodeCsv(buffer))
if (!parsed.length) throw new Error('empty')
rows.value = parsed
fileName.value = file.name
} catch (e) {
rows.value = []
error.value = 'CSVを読み込めませんでした。ファイルの内容を確認してください。'
}
}
const headers = computed(() => rows.value[0] ?? [])
const body = computed(() => rows.value.slice(1, MAX_ROWS + 1))
const total = computed(() => Math.max(rows.value.length - 1, 0))
</script>
<template>
<div>
<input type="file" accept=".csv,text/csv" @change="onFileChange" />
<p v-if="error" role="alert">{{ error }}</p>
<template v-if="rows.length">
<p>{{ fileName }}:{{ total }}行(先頭{{ body.length }}行を表示)</p>
<table>
<thead>
<tr>
<th v-for="(h, i) in headers" :key="i">{{ h }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, r) in body" :key="r">
<td v-for="(_, c) in headers" :key="c">{{ row[c] }}</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
ポイントは次のとおりです。
file.arrayBuffer()で、バイト列のまま受け取り、文字コードの判定に渡しています。- セルの値は
{{ }}で出力しているので、CSVの中に<script>などが入っていても、HTMLとしては解釈されません。v-htmlは使わないでください。 - 数千行のCSVを全部描画すると重くなるため、
MAX_ROWSで表示を制限しています。続きを見せたい場合は、ページネーションや無限スクロールと組み合わせます。 - 行数が多いときの再描画は、v-once・v-memoの使い方で減らせます。
PapaParseを使う方法
実務では、実績のあるライブラリを使うのが安心です。PapaParseは、引用符、区切り文字の自動判定、エラーの報告、大きなファイルの分割読み込みに対応しています。
import Papa from 'papaparse'
function parse(file) {
Papa.parse(file, {
header: true, // 1行目を列名にして、各行をオブジェクトにする
skipEmptyLines: true, // 空行を無視
encoding: 'Shift_JIS', // Fileを読むときの文字コード
complete: (result) => {
console.log(result.meta.fields) // 列名
console.log(result.data) // [{ 名前: '...', 住所: '...' }, ...]
console.log(result.errors) // 列数の不一致などのエラー
}
})
}
header: trueにすると、各行が{ 列名: 値 }のオブジェクトになり、列の順番に依存しない処理が書けます。Papa.parseにFileを渡すときのencodingは、固定の指定になります。UTF-8とShift_JISが混在する場合は、上のdecodeCsvで文字列にしてから、Papa.parse(text, ...)に渡す方法もあります。
読み込んだデータの絞り込みは検索フィルター付きリストの作成、ファイル選択まわりの基本はファイルアップロード機能の実装、Excelファイルを扱う場合はExcelファイルをアップロードして解析する方法をご覧ください。
よくある質問(FAQ)
TextDecoder('shift_jis')で読み直してください。この記事のdecodeCsvは、UTF-8で読めなかったときに自動で切り替えます。TextDecoderは既定でBOMを取り除くので、readAsTextから乗り換えると解消します。parseCsv(text, '\t')のように、区切り文字を第2引数で渡せます。欧州のExcelはセミコロン区切りになることがあります。worker: trueやchunkで分割して読み込むか、表示は先頭の数百行に絞ってください。まとめ
CSVの読み込みで壊れやすいのは、セル内のカンマや改行と、文字コードです。引用符を理解するパーサと、UTF-8からShift_JISへのフォールバックを入れれば、業務で使われる実際のファイルでも、安定して表示できます。規模が大きくなったり、要件が増えたりしたら、PapaParseに切り替えてください。

