JavaScriptでCSVを取り込む:文字コード(Shift_JIS/UTF-8)とダブルクォートに対応

#javascript
にメンテナンス済み
記事のトップ画像

いきなりですが、CSV 取込って難しくありませんか?

取り込む際は File オブジェクトを取り扱わないといけませんし、string として取得できた後も、データの中にカンマが含まれていたり、ダブルクォートで囲まれてたり囲まれていなかったり…

考慮することが多いです。

そこでこの記事では、文字コード(Shift_JIS / UTF-8 など)の違いと、ダブルクォートの有無や中身(カンマ・改行・エスケープ)に対応して CSV を二次元配列に変換するコードを紹介します。 取り込む CSV データは、

<input type="file" />

上記のような入力から取得できる File オブジェクトを使うことを前提としています。

CSV ファイルからテキストデータを取得する

CSV ファイルから String 型のデータを取得する部分です。

まずコード全文をご覧ください。

/**
 @param {File} file
 @param {string} encoding
 @return {Promise<string | undefined>}
*/
const getTextFromCSV = (file, encoding = 'Shift_JIS') => {
  if (encoding === 'UTF-8') {
    return file.text();
  }

  return new Promise((resolve, reject) => {
    try {
      const reader = new FileReader();

      reader.readAsText(file, encoding);

      reader.onload = (event) => resolve(event.target?.result);
      reader.onerror = (event) => reject(event);
    } catch (error) {
      reject(error);
    }
  });
};

CSV ファイルの文字コードが UTF-8 であれば、Blob.text()を使用できます。 File は Blob のラッパークラスなので、そのまま File.text()として使用できます。

文字コードが UTF-8 以外の場合は、イベントベースの FileReader を使うしかありません。

なお Blob.text() は常に UTF-8 として読み込むため、Shift_JIS などの CSV には使えません。UTF-8 の場合に限り、Promise で簡潔に書ける Blob.text() を使っています。

呼び出し側で扱いを統一できるよう、戻り値はいずれの場合も Promise です。

指定できる encoding は、Shift_JIS や UTF-8 などの Encoding Standard のラベルです。日本の業務システムが出力する CSV は Shift_JIS(Windows では CP932)が多いため、既定値を Shift_JIS にしています。

UTF-8 の CSV は先頭に BOM(\uFEFF)が付くことがあります。FileReader は BOM を取り除きますが、念のため次のパース処理でも除去しています。

データを配列に変換する

取得した文字列を二次元配列に変換します。行ごとに split する方法は手軽ですが、フィールド内にカンマや改行、""(エスケープされたダブルクォート)が含まれると壊れてしまいます。そこで 1 文字ずつ読む簡易パーサーにしています(RFC 4180 の主要な仕様に準拠)。

/**
 @param {string} text
 @return {string[][]}
*/
const parseCSV = (text) => {
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;

  // BOM を除去
  const src = text.replace(/^\uFEFF/, '');

  for (let i = 0; i < src.length; i++) {
    const char = src[i];

    if (inQuotes) {
      if (char === '"') {
        if (src[i + 1] === '"') {
          field += '"'; // エスケープされたダブルクォート
          i++;
        } else {
          inQuotes = false;
        }
      } else {
        field += char; // 引用符内のカンマ・改行もそのまま保持
      }
      continue;
    }

    if (char === '"') {
      inQuotes = true;
    } else if (char === ',') {
      row.push(field);
      field = '';
    } else if (char === '\n' || char === '\r') {
      if (char === '\r' && src[i + 1] === '\n') i++; // CRLF
      row.push(field);
      rows.push(row);
      row = [];
      field = '';
    } else {
      field += char;
    }
  }

  // 末尾に改行がないファイルの最終行
  if (field !== '' || row.length > 0) {
    row.push(field);
    rows.push(row);
  }

  return rows;
};

/**
 @param {File} file
 @param {string} encoding
 @return {Promise<string[][]>}
*/
const getArrayFromCSV = async (file, encoding = 'Shift_JIS') => {
  const text = await getTextFromCSV(file, encoding);

  if (typeof text !== 'string') {
    throw new Error('ファイルを配列に変換できませんでした');
  }

  return parseCSV(text);
};

ポイントは次のとおりです。

  • 改行コード:CRLF・LF・CR のいずれも行の区切りとして扱います。
  • ダブルクォート:フィールドごとに判定するため、特定の行や列だけ囲まれている CSV でも読み込めます。囲まれたフィールド内のカンマ・改行は値の一部になります。
  • エスケープ:"" は " 1 文字として復元します。

使い方

input の change イベントで File を受け取り、配列に変換します。

document.querySelector('input[type="file"]').addEventListener('change', async (event) => {
  const [file] = event.target.files;
  if (!file) return;

  const data = await getArrayFromCSV(file, 'Shift_JIS');
  console.log(data); // [['id', 'name'], ['1', 'Taro, Yamada'], ...]
});

読み込んだ値はすべて文字列です。数値や日付として扱う場合は、取り込み後に変換してください。

大容量 CSV や複雑な要件の場合

数十 MB を超える CSV や、区切り文字の自動判定、ヘッダー行をキーにしたオブジェクト化などが必要な場合は、Papa Parse などの実績あるライブラリの利用をおすすめします。Papa Parse でも、Shift_JIS の CSV は FileReader と同様に encoding を指定して読み込む必要があります。