JavaScriptでCSVファイルを構文解析（Parse）する方法

はいさい！ ちゅらデータ ぬ オースティン やいびーん どー！ んな、 がんじゅー やいびーみ？

# 概要
`.csv`拡張子のファイルをJavaScriptで解析し、ヘッダーと行を抽出する方法を紹介します。

## 背景
npmに公開しているCSV構文解析ソフトは他にあります。が、<strong>今回は使いません</strong>。

https://github.com/adaltas/node-csv/tree/master/packages/csv-parse/

<strong>なぜこれを使わないか？</strong>
筆者は不要であれば、第三者が作ったパッケージを極力組み入れたくないからです。

<strong>なぜ第三者パッケージを避けたいか？</strong>
複数の理由があります。
1. セキュリティの脆弱性が懸念される。Leftpad事件のような事件は今後も起きる。
1. そのパッケージがいつ放棄されるかわからない。放棄された時に、自分の手で維持しなければいけなくなる。
1. 仕事で携わっているプロジェクトの特徴で、第三者パッケージを入れると稟議を通すのに時間がかかり、にりー（だるい）
1. 第三者パッケージの使い方を学ぶより、JavaScriptを学べばいいさ！

上記のような理由があるので、JavaScriptとブラウザAPIだけで解析できるようにしたいのです。

# ダミーページを作成する
今回使うコードを試すために、ダミーページを作っておきましょう。
```
mkdir your-new-directory
cd your-new-directory/
touch index.html
touch index.js
```
ここで、IDEで上記作ったファイルを開き、以下のように`<form>`を作成します。
```html:index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CSV Parser</title>
  <script type="module" src="index.js" defer></script>
</head>
<body>
  <table></table>
  <form>
    <label for="file">CSV File</label>
    <input type="file" name="file" id="file" accept=".csv">
    <button type="submit">Process</button>
  </form>
</body>
</html>
```
`index.js`でフォームが送信された時に、CSVを開いて解析し、`<table>`要素にレンダーします。

筆者はTypeScript信者なので、<a href="https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html">JSDocs</a>を活用してJavaScriptで書かざるを得ない時でさえ、工夫してTypeScriptを使わせてもらっています。
```javascript:index.js
// VS CodeでTypeScriptのチェックをしてもらう
//@ts-check

const table = document.querySelector("table");
const form = document.querySelector("form");
if (!(table && form)) throw Error("Did not find table or form!");

/** @type {EventListener} */
const handleCSVSubmit = (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  const CSVFile = formData.get("file");
  if (!(CSVFile instanceof File)) throw TypeError("Input must be a file.");
};

form.addEventListener("submit", handleCSVSubmit);
```
重要ではないので、後のコードでは含めませんが、参考のために以下のようにレンダーするロジックを書きました。
```javascript:index.js
...

/**
 * Renders headers and rows into a table Element.
 * @param {HTMLTableElement} table
 * @param {string[]} headers
 * @param {string[][]} rows
 */
const renderTable = (table, headers, rows) => {
  // ヘッダーを作る
  const renderedHeaderCells = headers.reduce((prev, currentHeader) => prev + `<th>${currentHeader}</th>`, "");
  const renderedTableHeader = `<thead><tr>${renderedHeaderCells}</tr></thead>`;

  // 行を作る
  const renderedTableRows = rows.reduce(
    (prev, row) => prev + `<tr>${row.reduce((prev, cell) => prev + `<td>${cell}</td>`, "")}</tr>`,
    ""
  );
  const renderedTableBody = `<tbody>${renderedTableRows}</tbody>`;

  table.innerHTML = renderedTableHeader + renderedTableBody;
};
```

# CSVを解析するロジックを作成する
次、`<form>`に入っているCSVファイルを、ヘッダーと行に分けるロジックを書きます。

## FileReader APIでCSVを文字列に変換する
最初に必要なのは、CSVを文字列に変換することです。<a href="https://developer.mozilla.org/en-US/docs/Web/API/FileReader">FileReader API</a>を使えばできます。
```javascript:index.js
/** @type {EventListener} */
const handleCSVSubmit = (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  const CSVFile = formData.get("file");
  if (!(CSVFile instanceof File)) throw TypeError("Input must be a file.");

  /** @type {Promise<string>} */
  const fileReaderPromise = new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.addEventListener("error", () => reject(reader.error));
    reader.addEventListener("load", () => {
      const { result } = reader;
      if (typeof result !== "string") throw TypeError("Not String");
      resolve(result);
    });

    reader.readAsText(CSVFile);
  });
};
```
FileReader APIのPromise化については、筆者の他の記事も参考にできるかと思います。

https://qiita.com/tronicboy/items/69e4ddb03c10f53ff18c

## ヘッダーを抽出する
次、文字列からヘッダを抽出します。最初の改行までのところを取りたいので以下のようなロジックになります。
```javascript:index.js
...

  /** @type {Promise<string>} */
  const fileReaderPromise = new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.addEventListener("error", () => reject(reader.error));
    reader.addEventListener("load", () => {
      const { result } = reader;
      if (typeof result !== "string") throw TypeError("Not String");
      resolve(result);
    });

    reader.readAsText(CSVFile);
  });

  fileReaderPromise.then((CSVRaw) => {
    const endOfFirstLineIndex = CSVRaw.indexOf("\n");
    const headerRawLine = CSVRaw.slice(0, endOfFirstLineIndex);
    const headers = headerRawLine.split(",");
  });
};
```
`\n`の最初の改行がどこなのかを探して、そこまでの文字列を抽出して、最後に、CSVの列を隔てるのに使う`,`句読点で分けて配列にします。

## 行を抽出する
そして次に、行を配列に変換します。
```javascript:index.js
...

  fileReaderPromise.then((CSVRaw) => {
    const endOfFirstLineIndex = CSVRaw.indexOf("\n");
    const headerRawLine = CSVRaw.slice(0, endOfFirstLineIndex);
    const headers = headerRawLine.split(",");

    const rowsRaw = CSVRaw.slice(endOfFirstLineIndex + 1);
    const rowsNoCells = rowsRaw.split("\n");
    const rows = rowsNoCells.map(row => row.split(","));
  });
};
```
ここで、Indexに1を足すのは、最初の開業の後を取りたいからです。

そして、まず最初に、改行をベースに、行の文字列を抽出します。最後に、それらの行をさらに`,`句読点で分けて、配列にします。

つまり、文字列を持った配列を持った、配列を作ります。TypeScriptでいうと以下のような型になります。
```typescript
string[][]
```
### レンダー関数を読んで、DOMに反映させる
最後に、CSVのデータを持って、上記の`renderTable`に渡しましょう。
```javascript:index.js
// VS CodeでTypeScriptのチェックをしてもらう
//@ts-check
const table = document.querySelector("table");
const form = document.querySelector("form");
if (!(table && form)) throw Error("Did not find table or form!");

/** @type {EventListener} */
const handleCSVSubmit = (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  const CSVFile = formData.get("file");
  if (!(CSVFile instanceof File)) throw TypeError("Input must be a file.");

  /** @type {Promise<string>} */
  const fileReaderPromise = new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.addEventListener("error", () => reject(reader.error));
    reader.addEventListener("load", () => {
      const { result } = reader;
      if (typeof result !== "string") throw TypeError("Not String");
      resolve(result);
    });

    reader.readAsText(CSVFile);
  });

  fileReaderPromise.then((CSVRaw) => {
    const endOfFirstLineIndex = CSVRaw.indexOf("\n");
    const headerRawLine = CSVRaw.slice(0, endOfFirstLineIndex);
    const headers = headerRawLine.split(",");

    const rowsRaw = CSVRaw.slice(endOfFirstLineIndex + 1);
    const rowsNoCells = rowsRaw.split("\n");
    const rows = rowsNoCells.map((row) => row.split(","));

    renderTable(table, headers, rows.slice(0, 10));
  });
};

form.addEventListener("submit", handleCSVSubmit);
```

# 試してみましょう
ここまで作ってくると、やはり試したくなりませんか？

### ダミーデータ: ニュージランド政府からいただこう
NZ政府がさまざまな統計データをCSV形式で公開してくれているので、そのどれかをダウンロードして使ってみてください。

https://www.stats.govt.nz/large-datasets/csv-files-for-download/

## 試してみた結果
ローカルでブラウザから開いて、ダウンロードしたCSVを送信してみましょう。
![ezgif.com-gif-maker (12).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/853c36b6-8f56-88e6-2b7b-203738ed9a4b.gif)
見事に作動しました。

ちなみに、データ量が膨大なので、`slice(0,10)`を追加しないと、DOMにレンダーするのに数秒がかかります。

実際、CSVファイルをJavaScriptで解析するのは、数秒で終わりますが、DOMに落とすのはやはり重いので、実装では、<a href="https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API">Intersection Observer</a>を使って、最後までスクロールすると次の10件を読み込むような実装が望ましいかと思います。

# まとめ
ここまで、FileReaderを使って、CSVをDOMにレンダーする方法を紹介してきましたが、いかがでしょうか？

Vanilla JavaScriptでできることは実に多いですね。

次は、Fetch APIで取得したCSVをレンダーする方法を紹介します。