🐽

ローカルのcsvファイルを読み込んで配列に変換する【TypeScript解説】

2021/12/26に公開約12,800字

この記事ではTypeScriptでローカルのcsvファイルを読み込んで配列に変換する方法について1から詳しく解説します。
ライブラリは使わず、できるだけシンプルになるように実装しました。また、できる限り「型」を意識したり、古いAPI等は使わないように意識しました。
サンプルコードはVue(2系)+TypeScriptで書かれていますが、Reactなど他のフレームワークでも応用しやすいと思います。

完成形

1つのcsvファイルを読み込む場合

<template>
  <input type="file" accept="text/csv" @change="handleFileSelect" />
</template>

<script lang="ts">
export default {
  name: 'CsvUpload',
  methods: {
    async handleFileSelect(e: Event) {
      // 型エラーを回避するための記述
      if (!(e.target instanceof HTMLInputElement)) return
      if (!e.target.files) return

      // 読み込んだCSVファイルを配列に変換
      const file = this.parseCSV(await e.target.files[0].text())

      // 配列の中身を開発者ツールのコンソールに表示
      console.log(file)
    },
    parseCSV(data: string): string[][] {
      return data.split('\r\n').map((row) => row.split(','))
    },
  },
}
</script>

複数のcsvファイルを読み込む場合

<template>
  <input type="file" accept="text/csv" multiple @change="handleFileSelect" />
</template>

<script lang="ts">
export default {
  name: 'CsvUpload',
  methods: {
    async handleFileSelect(e: Event) {
      // 型エラーを回避するための記述
      if (!(e.target instanceof HTMLInputElement)) return
      if (!e.target.files) return

      // 読み込んだCSVファイルを配列に変換
      const files = await Promise.all(
        Array.from(e.target.files).map(async (f) =>
          this.parseCSV(await f.text())
        )
      )

      // 配列の中身を開発者ツールのコンソールに表示
      console.log(files)
    },
    parseCSV(data: string): string[][] {
      return data.split('\r\n').map((row) => row.split(','))
    },
  },
}
</script>

1つのcsvファイルを読み込む場合

HTMLを書く

まずはHTMLを書きます。Vueならtemplateの中の部分ですね。
今回はシンプルにinputタグだけにします。

<input type="file" accept="text/csv" @change="handleFileSelect" />


type="file"とすることでファイルをアップロードできるようになります。
さらに accept="text/csv"と指定することでcsvファイル以外は選択できないようにできます。

csvファイル以外は選択できなくなっている

@change="handleFileSelect"は 「changeイベントが起こったときにhandleFileSelectという関数を実行する」という意味です。(Reactの場合は@changeではなくonChangeと書きます)。
changeイベントというのは「ユーザーによる要素の値の変更が確定したときに発生するイベントのことです。つまり今回の場合は「ファイルが選択されたときにhandleFileSelectという関数を実行する」という意味になります。

https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/change_event

ファイルが選択されたときの処理を書く

イベントオブジェクトからファイルの情報を探す

前項で「ファイルが選択されたときにhandleFileSelectという関数を実行する」という処理は書いたのですが、肝心のhandleFileSelect関数が定義されていないのでその部分の処理を書いていきましょう。
changeイベントではイベントハンドラ(今回でいうhandleFileSelect関数)の中でEvent型のオブジェクトを参照することができます。ざっくりいうと読み込んだファイルの情報を引数として受け取れるということです。ファイル選択時の処理を書く前にまずは受け取ったイベントの中身をコンソールで見てみましょう。

handleFileSelect(e: Event) {
  console.log(e)
}

Google Chromeで開発している場合、コンソールは「表示」→「開発/管理」→「JavaScriptコンソール」で表示することができます。

コンソール画面:Eventオブジェクトの中身
上の画像はsample.csvという名前のファイルを選択したときのコンソール画面ですが、確かにEventオブジェクトに値が入っていることがわかります。そしてsample.csvの情報はtargetというところの中にあるfilesというところに入っています。わかりやすくするためにe.target.filesだけをコンソールに出力してみましょう。

handleFileSelect(e: Event) {
  console.log(e.target.files)
}


コンソール画面:e.target.filesの中身
FileList型という配列のようなオブジェクトの0番目の要素sample.csvの情報が入っていることがわかります。

https://developer.mozilla.org/ja/docs/Web/API/FileList

ここまでで受け取ったEventのどこに読み込みたいファイルの情報があるのか確認することができました。

Event型の型エラーを解決する

console.log(e.target.files)のようにEvent型のtargetfilesを参照しようとするとtargetの部分には

Object is possibly 'null'.

filesの部分には

Property 'files' does not exist on type 'EventTarget'.

というエラーが出てくると思います。
Event型というのはinputタグのchangeイベントだけではなく様々な使い方があり、targetがnullや様々な型をとる可能性もあります。私たち人間から見たら今回の場合、inputイベントでのみ使われることは自明ですが、コンパイラから見たら「これはEvent型です」という情報しかないため、「targetがnullになるかもしれない」「targetの型がハッキリしてないしfilesなんて要素はないかも」と型エラーを出してしまうというわけです。

この問題に対して引数をany型にすることでエラーを握りつぶすという方法もありますが、型があるというTypeScriptの恩恵を消してしまうのであまりいいやり方ではありません。
そこで 「今回はinputイベントでのみ使われますよ」と明記することで型エラーを解決したい と思います。
以下の2行を追加してください。

handleFileSelect(e: Event) {
  // 型エラーを回避するための記述
  if (!(e.target instanceof HTMLInputElement)) return
  if (!e.target.files) return
  
  console.log(e.target.files)
}

処理を一つ一つ見ていきましょう。

if (!(e.target instanceof HTMLInputElement)) return

targetがinput要素(HTMLInputElement)以外の場合はここで処理を終了させるという処理になります。この処理によってtargetの型が確定して、inputイベント以外でのイベントが渡ってくるという可能性がなくなりました。
しかしそれだけではファイルが何も選択されてない、つまりnullである可能性が考えられるので

if (!e.target.files) return

という処理を追加します。

これでエラーを消すことができました。

読み込んだファイルを文字列に変換する

input要素からファイルを受け取ることはできましたがEvent型のままではデータを扱いにくいので配列に変換しようと思います。しかしEvent型から直接配列に変換することはできないので、まずは文字列に変換することになります。

async handleFileSelect(e: Event) {
  // 型エラーを回避するための記述
  if (!(e.target instanceof HTMLInputElement)) return
  if (!e.target.files) return
  
    // 読み込んだCSVファイルを文字列に変換
  const file = await e.target.files[0].text()
  console.log(file)
}

「イベントオブジェクトからファイルの情報を探す」の項よりcsvファイルの情報はe.target.files[0]にあり、text()メソッドを実行することで1つの文字列に変換します。

例えば以下のようなcsvファイルなら

名前 好きな技術
かに Rust
ねずみ Go
ぞう PHP
ぺんぎん Linux
コンソールに以下のように出力されます。
名前,好きな技術
かに,Rust
ねずみ,Go
ぞう,PHP
ぺんぎん,Linux

これだけ見るとすでに配列に分かれてるようにも見えますが、「名前」から「Linux」まで改行含めて1つの文字列になっています。

さて、文字列に変換するために使ったtext()というメソッドはBlobという型のメソッドであり、Promiseを返します。
Blobというのはファイルを直接扱うためのオブジェクトです。今回の場合はFileというオブジェクトにsample.csvが格納されていますが、FileはBlobの機能を継承しているため、Blobが持つメソッドを使えるようになっています。

https://developer.mozilla.org/ja/docs/Web/API/Blob
そしてPromiseとは非同期処理の最終的な完了もしくは失敗を表すオブジェクト のことです。

通常のプログラムではコードは上から順に実行されます。今回の場合、「csvファイルを文字列に変換してfileという変数に格納する」→「コンソールにfileの内容を表示する」という順番に処理されるはずです。
しかしtext()非同期処理なのでその通りにはいきません。非同期処理とは、ある処理が実行されてから終わるまで待たずに、次に控えている別の処理を行うことで、今回の場合「csvファイルを文字列に変換する処理が始まる」→「コンソールにfileの内容を表示する」→「fileに文字列を格納する」という順番に処理されます。文字列処理が完了する前にコンソールの処理が行われてしまうので期待していた結果が返ってこないのです。
Promiseの場合、文字列ではなく以下のいずれかの状態が返ってきます。

  • 待機 (pending): 初期状態
  • 履行 (fulfilled): 処理が成功して完了したことを意味する
  • 拒否 (rejected): 処理が失敗したことを意味する

    async/awaitなしで実行すると文字列ではなくfullfilledという状態が返ってくる

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise

そこで、文字列変換処理が完了してからコンソールの処理を実行するために、async/awaitを利用します。

const file = await e.target.files[0].text()

awaitを非同期処理の前につけることで、Promiseの処理(今回でいうtext())が完了してから次(console.log(file))の処理が実行されるようになります。そしてawaitを使うには関数の前にasyncをつける必要があります。

async handleFileSelect(e: Event) {

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await


これでfileにsample.csvを一つの文字列にしたものが格納されるようになりました。

文字列を配列に変換する

最後に文字列を配列に変換する処理を書きましょう。csvファイルを文字列にした値を渡すと配列にパースしてくれるparseCSVという関数を新たに定義します。

parseCSV(data: string): string[][] {
  // ここに文字列を配列に変換する処理を書く
}

具体的な処理を書く前に完成形について確認します。

名前,好きな技術
かに,Rust
ねずみ,Go
ぞう,PHP
ぺんぎん,Linux

このような文字列が引数に渡されたときは

[
  ["名前","好きな技術"],
  ["かに","Rust"],
  ["ねずみ","Go"],
  ["ぞう","PHP"],
  ["ぺんぎん","Linux"]
]

このような5行×2列の二次元配列を返したいです。(文字列の中のカンマなのか配列の要素を区切るためのカンマなのかはっきりさせるために、配列の各要素をダブルクォーテーションで囲っています)。
文字列の改行が行、カンマが列に対応していることを考えると

  1. 改行区切りで要素数5の1次元配列を作る
    例えば「名前,好きな技術」はそれで一つの文字列になります。
[
  "名前,好きな技術",
  "かに,Rust",
  "ねずみ,Go",
  "ぞう,PHP",
  "ぺんぎん,Linux"
]
  1. 1で作った1次元配列の要素一つ一つに対して、カンマ区切りで要素数2の配列に分割する。
"名前,好きな技術" → ["名前","好きな技術"]

のような流れで実現できそうです。

それでは早速実装していきます。

まずsplit()メソッドでdataを改行区切りに分割し、1を実装します。

parseCSV(data: string): string[][] {
  return data.split('\r\n')
}

その後、1の配列に対してmapを適用します。配列の各要素("名前,好きな技術"など)をrowとし、それぞれに対してsplit()メソッドでカンマで分割し、2を実装します。

parseCSV(data: string): string[][] {
  return data.split('\r\n').map((row) => row.split(','))
}

これでparseCSV関数が完成しました。

最後にhandleFileSelectからparseCSVを呼び出す処理を書きます。

async handleFileSelect(e: Event) {
  (中略)
  
  // 読み込んだCSVファイルを配列に変換
  const file = this.parseCSV(await e.target.files[0].text())
  
  // 配列の中身を開発者ツールのコンソールに表示
  console.log(file)
},

csvを文字列にしたもの(e.target.files[0].text())を他に使う場所はないと思うので、そのままparseCSV関数の引数に渡してしまいます。
ちなみにparseCSVの前についているthisはVue特有の書き方でこうしないと関数を呼び出せないので特に大きな意味はないです。他のフレームワークだとなくてもいいかもしれないです。


コンソール画面

コンソールを見るとちゃんと配列になっていることがわかります。
これで1つのcsvファイルを読み込んで配列に変換する処理が完成しました!

複数のcsvファイルを読み込む場合

次に複数のcsvファイルを読み込む場合について解説していきます。基本的には1つのファイルの場合と同じでそこから少し拡張していく形になります。

HTMLを書く

<input type="file" accept="text/csv" multiple @change="handleFileSelect" />

HTMLは1つのファイルのときとほぼ変わりません。唯一違うのは multipleがついていることでこれがあることでファイルを複数選択できるようになります。


csvファイルを一度に複数選択できるようになっている

ファイルが選択されたときの処理を書く

イベントオブジェクトからファイルの情報を探す

コード自体はファイルが1つのときと同じです。ですが、今後の実装を考えるために一旦ファイルがどのような形でEvent型に格納されているか確認してみましょう。

handleFileSelect(e: Event) {
  // 型エラーを回避するための記述
  if (!(e.target instanceof HTMLInputElement)) return
  if (!e.target.files) return
  
  console.log(e.target.files)
}

これはsample.csvというファイルとsample2.csvというファイルを選択した時のコンソール画面です。

FileListという配列のようなオブジェクトにファイルの情報が格納されていることがわかります。
ならばFileListからファイルを1つ1つ取り出してそれぞれにcsvファイルが1つだけの場合と同じ処理をしてあげたら良さそうです。

文字列を配列に変換する

parseCSV関数の部分も全く同じです。

parseCSV(data: string): string[][] {
  return data.split('\r\n').map((row) => row.split(','))
}

読み込んだファイルを文字列に変換する

csvファイルが1つだけの場合と大きく変わるのはこの部分です。

csvファイルが1つだけのときは

const file = await e.target.files[0].text()

e.target.filesの0番目の要素を指定していたのに対し、今回はe.target.filesの中の要素が複数あるため、mapを使って1つ1つのファイルに対して

const files = e.target.files.map((f) => this.parseCSV(await f.text()))

などとすれば良いと思うかもしれません。
しかしこれではエラーが発生してしまいます。

Property 'map' does not exist on type 'FileList'.

まずe.target.filesの型であるFileListというオブジェクトは配列によく似ているものの配列ではないのでmapというメソッドは存在しません。
そこでArray.from()メソッドでFileListArrayに変換します。

const files = Array.from(e.target.files).map((f) =>
  this.parseCSV(await f.text())
)

これでmapが使えるようになりましたが、エラーはまだなくなっていません。
awaitの部分で

'await' expressions are only allowed within async functions and at the top levels of modules

というエラーがでています。awaitはasync関数でしか使えなかったのですね。
(f) => this.parseCSV(await f.text())という関数にasyncがついていなかったのでつけてあげましょう。

const files = Array.from(e.target.files).map(async (f) =>
  this.parseCSV(await f.text())
)

これでエラーがなくなりました。
それではコンソールでfilesの中身を確認してしましょう。

配列が格納されている...と思いきや、Promiseのfulfilledという状態が返ってきてしまっています。

これを解決するために Promise.all を使います。
今回、1つ目のcsvファイルに対してawait f.text()を実行し、2つ目のcsvファイルに対してawait f.text()を実行し...といった具合に 非同期処理が複数個存在します。 そして全ての非同期処理が完了してから次の処理(console.log(files))を実行させたいです。
Promise.all()はまさにそのようなときに使うメソッドで、具体的に言うと、Promise.allは単一のPromiseを返し、引数で渡した全てのPromiseが解決されれば状態はfulfilled、1つでもrejectされれば状態はrejectになります。簡単に言えば複数のPromiseをグループ化し、1つのPromiseであるかのように処理できるということです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/all

先ほどの処理を以下のように書き直します。

const files = await Promise.all(
  Array.from(e.target.files).map(async (f) =>
    this.parseCSV(await f.text())
  )
)

csvが一つのときと同様、Promiseの処理が完了してから次の処理(console.log(files))を実行させたいのでPromise.allの前にawaitをつけています。


コンソール画面

これで複数のcsvファイルを読み込んで配列に変換する処理も完成です!

終わりに

本記事ではTypeScriptで1つ〜複数のローカルのcsvファイルを読み込み配列に変換する方法について解説しました。
個人的にポイントだと感じたのは以下の4点です。

  • changeイベントで受け取ったファイルの情報を文字列に変換してから分割することで配列にする
  • Eventの型エラーを防ぐためにinstanceof HTMLInputElemente.target.filesの存在確認を行い、条件に当てはまらないものは事前にreturnさせるようにする
  • ネットで調べるとよく出てくるFileReader.readAsText()は古いAPIなのでBlob.text()を使うようにする
  • 非同期処理やPromiseが何なのか理解する

この実装を通して、ライブラリを使わずとも意外とスッキリとしたコードが書けるなあということと、csvパースは奥が深く本気でやろうとするとなかなか複雑なことがわかりました。いつかその辺りもちゃんと実装してみたいですね。

本記事が誰かのお役に立てれば幸いです。

Discussion

ログインするとコメントできます