7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

最近はフロントエンドを触ることが多いのですが、今までファイルを扱う機能の実装をしたことがなく、しかも担当しているシステムは画像ファイルやcsvファイルを扱う箇所があり、そこのコードが理解できず...
ということで頑張って勉強してみました。

ファイルの扱い方の基本

ファイルの選択

inputtype="fileを指定することで、ユーザーにファイルを選択させることができます。

<input name="file1" type="file" />
<input name="file2" type="file" />
<input name="files" type="file" multiple />

image.png

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 />

image.png

ファイルの情報へのアクセス

選択されたファイルの情報には、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 />
    </>
  );
};

ドラッグ&ドロップでファイル選択

ドラッグ&ドロップでファイル選択させる場合、handleDropdataTransferからfilesにアクセスできます。
なお、ブラウザのデフォルトの動作を防ぐために、handleDrophandleDragOverpreventDefault()を設定しています。

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>
    </>
  );
};

画像ファイルの扱い

画像のプレビュー表示

次に、ユーザーが選択した画像ファイルのプレビュー機能を作ってみます。
大まかな流れは以下の通りです。

  1. ユーザーが画像ファイルを選択し、画像ファイルの情報がfilesFileオブジェクトの配列)に格納
  2. 画像ファイルからオブジェクトURLを生成する1
  3. オブジェクト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>
    </>
  );
};

このように、「ファイル選択」で選択した画像が表示されるようになりました。

image.png

画像の幅・高さを取得

続いて、あまり需要はないかもしれませんが、選択した画像の幅、高さを取得してみようと思います。

  1. ユーザーが画像ファイルを選択し、画像ファイルの情報がfilesFileオブジェクトの配列)に格納
  2. new Image()でインスタンスを作成
  3. 画像の読み込みに成功した場合と、失敗した場合の処理を設定
  4. 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ファイルのデータを取得してみます。

  1. ユーザーがcsvファイルを選択し、csvファイルの情報がfileFileオブジェクト)に格納
  2. FileReader()でインスタンスを作成
  3. ファイルの読み込みに成功した場合と、失敗した場合の処理を設定
    ファイルの読み込みに成功すると、resultにデータが格納される
  4. ファイルの読み込み方法を指定(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ファイルをダウンロードする機能を作ってみます。

大まかな流れは以下の通りです。

  1. ユーザーがcsvファイルを選択し、csvファイルの情報がfileFileオブジェクト)に格納
  2. ダウンロードボタンが押される
  3. fileをもとに、選択されたcsvファイルを読み込み、取得した文字列をcsvStringに格納
    csvStringの中身は、例えば 1,foo\n2,bar\n3,baz
  4. 4quxを追加した、新しいcsv用の文字列を生成し、newCsvStringに格納
    newCsvStringの中身は、1,foo\n2,bar\n3,baz\n4,qux\n
  5. newCsvStringから新しいcsvファイルを作成
  6. 新しい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>
    </>
  );
};
csv関連の関数
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

ダウンロードボタンを押すと、

image.png

以下のようなhoge.csvがダウンロードできました!

id name
1 foo
2 bar
3 baz
4 qux

(ひとりごと)

奥が深かった画像プレビュー表示の実装

画像プレビュー表示について、最初は以下のように書いていました。
objectUrlsをstate変数にしている)

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)から計算していると思い2objectUrlsを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を再生成するように改良できました。

useMemo()を使って少し改良
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()のセットアップ関数は、レンダー処理が終わったあとに実行されるらしい)

レンダー処理が2回?
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)が実行されないという欠点があったので、これも却下しました。

アンマウント時に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)の扱いについて、もっといい方法があれば教えてください...

  1. オブジェクトURLではなくデータURLを使用する方法もありますが、生成AIいわくURLの生成はオブジェクトURLのほうが早いらしいので、オブジェクトURLを採用しています。

  2. fileが同じでもURL.createObjectURL(file)が実行されるたびに、異なるオブジェクトURLを生成しているので、計算といえるかはよくわからないですが...

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?