【Vue.js】CSVファイルを読み込んで表示する方法|アップロード+テーブル表示

【Vue.js】CSVファイルを読み込んで表示する方法|アップロード+テーブル表示 Vue.js

CSVファイルをブラウザで読み込んで、テーブルに表示する機能は、インポート機能のプレビューや、簡易ビューアで使われます。ところが、「改行で分けて、カンマで分ける」だけの実装は、実際のCSVではすぐに壊れます。この記事では、Vue 3で、セル内のカンマや改行とShift_JISの文字化けに対応した実装を解説します。

流れは3つです。①ファイルをArrayBufferで受け取る、②文字コードを判定して文字列にする、③CSVとして解析して、配列をテーブルに出す。読み込みはすべてブラウザの中で完結し、サーバーには送りません。

スポンサーリンク

split(‘,’)では壊れる理由

よくある解説は、split('\n')で行に分け、split(',')で列に分けます。ですが、CSVの規則(RFC 4180)では、ダブルクォートで囲んだセルの中に、カンマや改行を含められます。

JavaScript:NGな分割
// NG: これは壊れる
const rows = text.split('\n').map(row => row.split(','))

// 入力
// 名前,住所
// "山田, 太郎","東京都
// 新宿区"

// split(',') の結果:1行目は 2 列だが、2行目は
// ['"山田', ' 太郎"', '"東京都'] になり、列も行もずれる

住所や備考欄を含むCSVでは、これが頻繁に起こります。ダブルクォートの中かどうかを覚えながら、1文字ずつ読むパーサが必要です。

CSVパーサを書く

引用符の中のカンマ・改行・""(引用符のエスケープ)、改行コードのCRLFに対応した、短いパーサです。

JavaScript:parseCsv.js
// 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が残ったままだと、先頭の列名に見えない文字が付いてしまいます。

JavaScript:decodeCsv.js
// 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として読めてしまうことは、まれです。判定に頼らず確実にしたいなら、画面に文字コードの選択欄を設ける方法もあります。

コンポーネントを作る

ファイルを選んだら、読み込み、解析し、先頭の行をテーブルに表示します。列数が行によって違っても崩れないように、セルは列見出しの数を基準に取り出しています。

Vue:CsvViewer.vue
<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は、引用符、区切り文字の自動判定、エラーの報告、大きなファイルの分割読み込みに対応しています。

JavaScript: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)

Q日本語が文字化けします。
AファイルがShift_JISで保存されている可能性が高いです。TextDecoder('shift_jis')で読み直してください。この記事のdecodeCsvは、UTF-8で読めなかったときに自動で切り替えます。
Q列名の先頭に、見えない文字が付いています。
AUTF-8のBOMが残っている可能性があります。TextDecoderは既定でBOMを取り除くので、readAsTextから乗り換えると解消します。
Qタブ区切り(TSV)やセミコロン区切りも読めますか?
AparseCsv(text, '\t')のように、区切り文字を第2引数で渡せます。欧州のExcelはセミコロン区切りになることがあります。
Q数十MBのCSVを読むと、画面が固まります。
Aメインスレッドで一度に解析しているためです。PapaParseのworker: trueやchunkで分割して読み込むか、表示は先頭の数百行に絞ってください。

まとめ

CSVの読み込みで壊れやすいのは、セル内のカンマや改行と、文字コードです。引用符を理解するパーサと、UTF-8からShift_JISへのフォールバックを入れれば、業務で使われる実際のファイルでも、安定して表示できます。規模が大きくなったり、要件が増えたりしたら、PapaParseに切り替えてください。