ローカルのcsvファイルを読み込んで配列に変換する【TypeScript解説】
この記事では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
という関数を実行する」という意味になります。
ファイルが選択されたときの処理を書く
イベントオブジェクトからファイルの情報を探す
前項で「ファイルが選択されたときに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
の情報が入っていることがわかります。
ここまでで受け取ったEventのどこに読み込みたいファイルの情報があるのか確認することができました。
Event型の型エラーを解決する
console.log(e.target.files)
のようにEvent型のtarget
やfiles
を参照しようとすると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が持つメソッドを使えるようになっています。
そしてPromiseとは非同期処理の最終的な完了もしくは失敗を表すオブジェクト のことです。
通常のプログラムではコードは上から順に実行されます。今回の場合、「csvファイルを文字列に変換してfile
という変数に格納する」→「コンソールにfile
の内容を表示する」という順番に処理されるはずです。
しかしtext()
は非同期処理なのでその通りにはいきません。非同期処理とは、ある処理が実行されてから終わるまで待たずに、次に控えている別の処理を行うことで、今回の場合「csvファイルを文字列に変換する処理が始まる」→「コンソールにfile
の内容を表示する」→「file
に文字列を格納する」という順番に処理されます。文字列処理が完了する前にコンソールの処理が行われてしまうので期待していた結果が返ってこないのです。
Promiseの場合、文字列ではなく以下のいずれかの状態が返ってきます。
- 待機 (pending): 初期状態
- 履行 (fulfilled): 処理が成功して完了したことを意味する
- 拒否 (rejected): 処理が失敗したことを意味する
async/awaitなしで実行すると文字列ではなくfullfilledという状態が返ってくる
そこで、文字列変換処理が完了してからコンソールの処理を実行するために、async/awaitを利用します。
const file = await e.target.files[0].text()
await
を非同期処理の前につけることで、Promiseの処理(今回でいうtext()
)が完了してから次(console.log(file)
)の処理が実行されるようになります。そしてawait
を使うには関数の前にasync
をつける必要があります。
async handleFileSelect(e: Event) {
これでfile
にsample.csvを一つの文字列にしたものが格納されるようになりました。
文字列を配列に変換する
最後に文字列を配列に変換する処理を書きましょう。csvファイルを文字列にした値を渡すと配列にパースしてくれるparseCSV
という関数を新たに定義します。
parseCSV(data: string): string[][] {
// ここに文字列を配列に変換する処理を書く
}
具体的な処理を書く前に完成形について確認します。
名前,好きな技術
かに,Rust
ねずみ,Go
ぞう,PHP
ぺんぎん,Linux
このような文字列が引数に渡されたときは
[
["名前","好きな技術"],
["かに","Rust"],
["ねずみ","Go"],
["ぞう","PHP"],
["ぺんぎん","Linux"]
]
このような5行×2列の二次元配列を返したいです。(文字列の中のカンマなのか配列の要素を区切るためのカンマなのかはっきりさせるために、配列の各要素をダブルクォーテーションで囲っています)。
文字列の改行が行、カンマが列に対応していることを考えると
-
改行区切りで要素数5の1次元配列を作る
例えば「名前,好きな技術」はそれで一つの文字列になります。
[
"名前,好きな技術",
"かに,Rust",
"ねずみ,Go",
"ぞう,PHP",
"ぺんぎん,Linux"
]
- 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()
メソッドでFileList
をArray
に変換します。
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であるかのように処理できるということです。
先ほどの処理を以下のように書き直します。
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 HTMLInputElement
やe.target.files
の存在確認を行い、条件に当てはまらないものは事前にreturn
させるようにする - ネットで調べるとよく出てくる
FileReader.readAsText()
は古いAPIなのでBlob.text()
を使うようにする - 非同期処理や
Promise
が何なのか理解する
この実装を通して、ライブラリを使わずとも意外とスッキリとしたコードが書けるなあということと、csvパースは奥が深く本気でやろうとするとなかなか複雑なことがわかりました。いつかその辺りもちゃんと実装してみたいですね。
本記事が誰かのお役に立てれば幸いです。
Discussion