はじめに
最近はフロントエンドを触ることが多いのですが、今までファイルを扱う機能の実装をしたことがなく、しかも担当しているシステムは画像ファイルやcsvファイルを扱う箇所があり、そこのコードが理解できず...
ということで頑張って勉強してみました。
ファイルの扱い方の基本
ファイルの選択
input
にtype="file
を指定することで、ユーザーにファイルを選択させることができます。
<input name="file1" type="file" />
<input name="file2" type="file" />
<input name="files" type="file" multiple />
accept
を指定することで、選択できるファイルを制限することもできます。
<input name="file1" type="file" accept="image/jpeg" />
<input name="file2" type="file" accept="image/jpeg, image/png" />
<input name="files" type="file" accept="image/*" multiple />
ファイルの情報へのアクセス
選択されたファイルの情報には、files
でアクセスできます。
(例:e.target.files
)
files
にはFileList
という形式でデータが保持されています。
FileList
のままだと扱いにくいので、File
の部分を取り出します。
今回は、取り出したFile
(単一・複数)をuseState
のそれぞれfile
, files
に格納しています。
型はFile
、およびFile
の配列です。
取り出したものについては、name
でファイル名、size
でサイズ(バイト)を確認することができます。
import React, { useState } from "react";
export const FileInputs = () => {
const [file, setFile] = useState<File | undefined>(undefined);
const [files, setFiles] = useState<File[]>([]);
console.log(file?.name);
// sample_image_1.jpg
console.log(file?.size);
// 2769524
// 単一ファイルの場合
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setFile(e.target.files[0]);
} else {
setFile(undefined);
}
};
// 複数ファイルの場合
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
<>
<input onChange={handleFileChange} name="file" type="file" />
<input onChange={handleFilesChange} name="files" type="file" multiple />
</>
);
};
ドラッグ&ドロップでファイル選択
ドラッグ&ドロップでファイル選択させる場合、handleDrop
のdataTransfer
からfiles
にアクセスできます。
なお、ブラウザのデフォルトの動作を防ぐために、handleDrop
とhandleDragOver
にpreventDefault()
を設定しています。
import React, { useState } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
files.forEach((file) => {
console.log(file.name);
// sample_image_1.jpg
console.log(file.size);
// 2769524
});
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (e.dataTransfer.files) {
setFiles(Array.from(e.dataTransfer.files));
}
};
return (
<>
<div onDragOver={handleDragOver} onDrop={handleDrop} style={{border: "1px dashed black", height: "5em"}}>
ここにファイルをドラッグ&ドロップしてください
</div>
</>
);
};
画像ファイルの扱い
画像のプレビュー表示
次に、ユーザーが選択した画像ファイルのプレビュー機能を作ってみます。
大まかな流れは以下の通りです。
- ユーザーが画像ファイルを選択し、画像ファイルの情報が
files
(File
オブジェクトの配列)に格納 - 画像ファイルからオブジェクトURLを生成する1
- オブジェクトURLを
img
要素のsrc
属性に渡す
オブジェクトURLとは、File
オブジェクトやBlob
オブジェクトで指定されたファイルなどを参照するためのURLらしいです。
なお、ファイルが同じでもURL.createObjectURL()
が実行されるたび、異なるオブジェクトURLが生成されます。また、不要になったオブジェクトURLは、URL.revokeObjectURL()
でメモリから解放しています。
import React, { useState, useEffect } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const objectUrls = files.map(file => URL.createObjectURL(file));
useEffect(() => {
// objectUrls変更時とアンマウント時にオブジェクトURLを解放する
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objectUrls]);
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
<>
<input onChange={handleFilesChange} name="files" type="file" accept="image/*" multiple />
<div>
{objectUrls.map((url, index) => (
<img key={index} src={url} style={{ maxWidth: '300px', height: 'auto', margin: '10px' }} alt={`Preview ${index}`} />
))}
</div>
</>
);
};
このように、「ファイル選択」で選択した画像が表示されるようになりました。
画像の幅・高さを取得
続いて、あまり需要はないかもしれませんが、選択した画像の幅、高さを取得してみようと思います。
- ユーザーが画像ファイルを選択し、画像ファイルの情報が
files
(File
オブジェクトの配列)に格納 -
new Image()
でインスタンスを作成 - 画像の読み込みに成功した場合と、失敗した場合の処理を設定
-
src
に画像のURLを指定
画像の読み込みに成功すると、画像の高さや幅に関するプロパティにアクセスできます。
今回はimg.naturalWidth
, img.naturalHeight
で元の画像の高さと幅を取得しています。
(生成AIいわく、ハンドラ設定の前にURLを設定した場合、ハンドラの設定前に画像読み込みが完了しハンドラが実行されない可能性があるだとか)
import React, { useState } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const objectUrls = files.map(file => URL.createObjectURL(file));
// 画像を1つずつ読み込む
objectUrls.forEach((url) => {
const img = new Image();
img.onload = () => {
console.log(`画像の幅: ${img.naturalWidth}, 画像の高さ: ${img.naturalHeight}`);
// 画像の幅: 4284, 画像の高さ: 5712
};
img.onerror = () => {
console.error("画像の読み込みに失敗しました");
};
img.src = url;
});
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
objectUrls.forEach((url) => URL.revokeObjectURL(url));
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
<>
<input onChange={handleFilesChange} name="files" type="file" accept="image/*" multiple />
</>
);
};
cvsファイルの扱い
csvファイルのデータの取得
ここからは、csvファイルの扱いについて解説します。
まずは、csvファイルのデータを取得してみます。
- ユーザーがcsvファイルを選択し、csvファイルの情報が
file
(File
オブジェクト)に格納 -
FileReader()
でインスタンスを作成 - ファイルの読み込みに成功した場合と、失敗した場合の処理を設定
ファイルの読み込みに成功すると、result
にデータが格納される - ファイルの読み込み方法を指定(
readAsText(file)
)
今回はcsvファイル(カンマと改行で区切られているテキストデータ)を読み込むので、readAsText(file)
でcsvファイルを読み込みます。
読み込みが成功すると、result
に文字列が格納されます。
import React, { useState } from "react";
export const FileInputs = () => {
const [file, setFile] = useState<File | undefined>(undefined);
if (file && file.type === "text/csv") {
const reader = new FileReader();
reader.onload = (event) => {
const csvString = event.target?.result;
console.log(csvString);
};
reader.onerror = () => {
console.error("failed to read file");
};
reader.readAsText(file);
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setFile(e.target.files[0]);
} else {
setFile(undefined);
}
};
return (
<>
<input onChange={handleFileChange} name="file" type="file" accept=".csv" />
</>
);
};
試しに、以下のようなcsvファイルを選択すると、
id | name |
---|---|
1 | foo |
2 | bar |
3 | baz |
コンソールでは、以下のように表示されます。
id,name
1,foo
2,bar
3,baz
csvファイルの作成、ダウンロード
最後に、選択したcsvファイルに
4 | qux |
---|
という行を追加し、そのcsvファイルをダウンロードする機能を作ってみます。
大まかな流れは以下の通りです。
- ユーザーがcsvファイルを選択し、csvファイルの情報が
file
(File
オブジェクト)に格納 - ダウンロードボタンが押される
-
file
をもとに、選択されたcsvファイルを読み込み、取得した文字列をcsvString
に格納
csvString
の中身は、例えば1,foo\n2,bar\n3,baz
-
4
とqux
を追加した、新しいcsv用の文字列を生成し、newCsvString
に格納
newCsvString
の中身は、1,foo\n2,bar\n3,baz\n4,qux\n
-
newCsvString
から新しいcsvファイルを作成 - 新しいcsvファイルをダウンロード
import React, { useState } from "react";
import {
convertCsvToString,
addCsvStringRow,
convertStringToCsv,
downloadFile
} from "csv関連の関数";
export const FileInputs = () => {
const [file, setFile] = useState<File | undefined>(undefined);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setFile(e.target.files[0]);
} else {
setFile(undefined);
}
};
const handleDownload = async() => {
if (file) {
try {
const csvString = await convertCsvToString(file);
const newCsvString = addCsvStringRow(csvString, ["4", "qux"]);
const csv = convertStringToCsv(newCsvString);
downloadFile(csv, "hoge");
} catch (error) {
console.error(error);
}
} else {
console.error("ファイルが選択されていません");
}
};
return (
<>
<input onChange={handleFileChange} name="file" type="file" accept=".csv" />
<button onClick={handleDownload}>ダウンロード</button>
</>
);
};
export const convertCsvToString = async(file: File): Promise<string> => {
if (!(file && file.type === "text/csv")) {
throw new Error("csvファイルではありません");
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const csvString = event.target?.result as string;
resolve(csvString);
};
reader.onerror = () => {
reject(new Error("ファイルの読み込みに失敗しました"));
};
reader.readAsText(file);
});
};
export const addCsvStringRow = (csvString: string, newRow: string[]): string => {
if (!csvString.endsWith("\n")) {
csvString += "\n";
}
csvString += newRow.join(",") + "\n";
return csvString;
};
export const convertStringToCsv = (csvString: string): Blob => {
const blob = new Blob([csvString], { type: 'text/csv' });
return blob;
};
export const downloadFile = (blob: Blob, fileName: string): void => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
以下のようなsample_csv_1.csv
を選択し、
id | name |
---|---|
1 | foo |
2 | bar |
3 | baz |
ダウンロードボタンを押すと、
以下のようなhoge.csv
がダウンロードできました!
id | name |
---|---|
1 | foo |
2 | bar |
3 | baz |
4 | qux |
(ひとりごと)
奥が深かった画像プレビュー表示の実装
画像プレビュー表示について、最初は以下のように書いていました。
(objectUrls
をstate変数にしている)
import React, { useState, useEffect } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const [objectUrls, setObjectUrls] = useState<string[]>([]);
useEffect(() => {
// objectUrls変更時とアンマウント時にオブジェクトURLを解放する
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objectUrls]);
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setFiles(files);
setObjectUrls(files.map(file => URL.createObjectURL(file)));
} else {
setFiles([]);
setObjectUrls([]);
}
};
return (
// 省略
);
};
React公式チュートリアルを見返していたところ、
「レンダー中にコンポーネントの props や既存の state 変数から情報を計算できる場合、その情報をコンポーネントの state に入れるべきではありません。」
という記載がありました。
objectUrls
も既存のstate変数(files
)から計算していると思い2、objectUrls
をstate変数から普通の変数にしてみたコードが、冒頭で紹介したものです。
changeハンドラの中で複数のset関数を呼び出す必要もなくなりました。
import React, { useState, useEffect } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
// objectUrlsはfilesから生成
const objectUrls = files.map(file => URL.createObjectURL(file));
useEffect(() => {
// objectUrls変更時とアンマウント時にオブジェクトURLを解放する
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objectUrls]);
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
// 省略
);
};
しかし上記の場合、レンダリングされるたびにURL.createObjectURL()
が実行されます。
つまりfiles
に変更がなくても、(他のstate変数やpropsの変更などにより)レンダリングされるたびに、objectUrls
の値も再生成されてしまいます(そして値も変わる)。
問題なく動くので大丈夫かもしれませんが、気になったので色々調べてみました。
結論、React公式チュートリアルにもあるように、useMemo()
を使うことによって、files
に変更が生じた場合のみ、オブジェクトURLを再生成するように改良できました。
import React, { useState, useEffect, useMemo } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const [foo, setFoo] = useState<string>(""); // 更新の多いstate変数があると仮定
// マウント時とfilesの変更時にのみobjectUrlsを生成
const objectUrls = useMemo(() => {
return files.map(file => URL.createObjectURL(file));
}, [files]);
useEffect(() => {
// objectUrls変更時とアンマウント時にオブジェクトURLを解放する
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objectUrls]);
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
// 省略
);
};
ちなみに、以下のようにuseEffect()
内でsetObjectUrls()
するコードは却下しました。
React公式チュートリアルの内容を踏まえて理由を説明すると、setFiles()
が呼び出された後、更新前のobjectUrls
でレンダー処理を最後まで行ない、その後useEffect()
のセットアップ関数内のsetObjectUrls()
が呼び出され、更新後のobjectUrls
で再レンダーをやり直すことになり、効率が悪いらしいからです。
(useEffect()
のセットアップ関数は、レンダー処理が終わったあとに実行されるらしい)
import React, { useState, useEffect } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const [objectUrls, setObjectUrls] = useState<string[]>([]);
useEffect(() => {
setObjectUrls(files.map(file => URL.createObjectURL(file)));
}, [files])
useEffect(() => {
// objectUrls変更時とアンマウント時にオブジェクトURLを解放する
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [objectUrls]);
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
// 省略
);
};
また、URL.revokeObjectURL(url)
についてはuseEffect()
を使って、objectUrl
変更時に実行しています。files
変更時にURL.revokeObjectURL(url)
を実行するほうが直感的かもしれないですが、useEffect()
の依存配列の関係でobjectUrls
変更時にURL.revokeObjectURL(url)
を実行しています。
useEffect()
を使わない実装も以下のように考えてみました。具体的にはsetFiles()
でfiles
が変更される前に実行しています。
しかし、以下のコードの場合、コンポーネントのアンマウント時にURL.revokeObjectURL(url)
が実行されないという欠点があったので、これも却下しました。
import React, { useState } from "react";
export const FileInputs = () => {
const [files, setFiles] = useState<File[]>([]);
const objectUrls = files.map(file => URL.createObjectURL(file));
const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// files変更前にオブジェクトURLを解放する
objectUrls.forEach(url => URL.revokeObjectURL(url));
if (e.target.files) {
setFiles(Array.from(e.target.files));
} else {
setFiles([]);
}
};
return (
// 省略
);
};
URL.createObjectURL(file))
とURL.revokeObjectURL(url)
の扱いについて、もっといい方法があれば教えてください...