Reactで、ファイルの大きさを読みやすい単位で表示する方法

​​React​File API,Intl.NumberFormat,Playwright,TypeScript

File の size はバイト数なので、そのまま出すと「157286400」のような読みにくい数字になります。読みやすくするには、1000 か 1024 で割りながら単位を上げていきます。気をつけたいのは2点です。1つは、1000 ごとに割るのか 1024 ごとに割るのかを決め、単位の書き方をそれに合わせることです。もう1つは、小数を丸めた結果が「1000.0 kB」にならないようにすることです。

ここでは、バイト数を単位付きの文字列にする formatFileSize という関数と、選んだファイルの大きさを一覧にする FileSizeList というコンポーネントを作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。選ばせたファイルは、大きさだけを決めた中身のないファイルと、Playwright で中身を渡した小さなテキストファイルです。Firefox と Safari、スマートフォンのブラウザ、ファイル選択の画面を人が操作する場合は試していません。

白い背景に「ファイルを選ぶ」というラベルと、ファイル選択のボタン(Chromium の英語の表示で「Choose Files」「4 files」)、「単位」のラジオボタン(「1000 ごと(kB、MB)」が選ばれている)。その下の表に photo.jpg 2.5 MB、memo.txt 1.0 MB、video.mp4 157.3 MB、empty.txt 0 B が並び、最後の行に水色の文字で「合計 4 件 160.7 MB」と出ている
4つのファイルを選び、1000 ごとの単位で表示したところ

kB と KiB は、割る数が違う

Linux の man ページの units(7) では、SI の接頭辞の k(キロ)は 1000、M(メガ)は 1000000 です。1024 ごとの単位には、1998年に IEC が定めた2進の接頭辞を使い、Ki(キビ)が 1024、Mi(メビ)が 1048576 です。同じページには、いまでは MB は 1000000 バイト、MiB は 1048576 バイトだと書かれています。

どちらで表示するかで、同じファイルでも数字が変わります。検証環境の ls(GNU coreutils 9.4)でも、-h と --si で表示が分かれました。

バイト数 ls -lh ls -l --si
999,960 977K 1.0M
2,457,600 2.4M 2.5M
157,286,400 150M 158M

ls --help には、--si は -h と同じ表示を 1024 ではなく 1000 の累乗で行う、と書かれています。どちらも単位は「K」「M」だけで、表示を見ただけではどちらで割ったのか分かりません。2,457,600 バイトは 1024 ごとなら 2.34375 で、ls -lh では切り上げた「2.4M」になっていました。

画面に出すときは、1000 ごとなら kB・MB、1024 ごとなら KiB・MiB と書き分けておくと、読む人が迷いません。

バイト数を単位付きの文字列にする関数

ts
// バイト数を、読みやすい単位の文字列にする

export type Base = 1000 | 1024;

const UNITS: Record<Base, readonly string[]> = {
  1000: ['B', 'kB', 'MB', 'GB', 'TB', 'PB'],
  1024: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'],
};

export function formatFileSize(bytes: number, base: Base = 1000, digits = 1, locale?: string): string {
  if (!Number.isFinite(bytes) || bytes < 0) return '-';
  const units = UNITS[base];
  let i = 0;
  let value = bytes;
  while (value >= base && i < units.length - 1) {
    value /= base;
    i++;
  }
  // バイトの桁は小数にしない
  const d = i === 0 ? 0 : digits;
  // 丸めると base に届くとき(999.96 kB → 1000.0 kB)は、次の単位にする
  const p = 10 ** d;
  if (Math.round(value * p) / p >= base && i < units.length - 1) {
    value /= base;
    i++;
  }
  const number = new Intl.NumberFormat(locale, {
    minimumFractionDigits: d,
    maximumFractionDigits: d,
  }).format(value);
  return `${number} ${units[i]}`;
}

// 丸める前の値(バイト数)を、桁区切りで書く
export function formatBytes(bytes: number, locale?: string): string {
  return `${new Intl.NumberFormat(locale).format(bytes)} バイト`;
}

値が base 以上のあいだ base で割り、そのたびに単位を1つ上げます。PB より上の単位は用意していないので、それより大きい値は PB のまま数字が大きくなります。バイト数がマイナスや NaN のときは「-」を返します。

丸めると 1000 に届く値は、次の単位に上げる

割る処理だけだと、999,960 バイトは 999.96 kB で止まります。これを小数1桁にすると「1000.0 kB」です。割る処理を先にして、丸めるのを後にしているために起きます。

比べるために、丸めたあとの確認をしない関数(toFixed(1) で丸めるだけのもの)も用意し、検証環境の Chromium で同じ値を渡しました。

バイト数 丸めたあとを確かめない formatFileSize(1000 ごと)
999,949 999.9 kB 999.9 kB
999,950 1000.0 kB 1.0 MB
999,960 1000.0 kB 1.0 MB
999,999,999,999,999 1000.0 TB 1.0 PB

formatFileSize では、丸めた値が base 以上になったら、もう一度 base で割って単位を上げています。小数を2桁にした場合(digits に2を渡す)は、999,950 バイトは丸めても 1000 に届かないので「999.95 kB」のままでした。

1024 ごとの場合は、1000 以上 1024 未満の値が、単位を上げずに4桁で出ます。1000 バイトは「1,000 B」、1,048,524 バイトは「1,023.9 KiB」になりました。1024 に届くまでは単位を上げないので、これは割り方どおりの結果です。

数字の書き方は Intl.NumberFormat に任せる

数字の部分は Intl.NumberFormat で書いています。minimumFractionDigits と maximumFractionDigits を同じ値にすると、小数の桁数がそろいます。「1.0 MB」の「.0」が消えないので、単位が同じ行どうしなら、右寄せにしたときに小数点の位置がそろいます。

locale を渡すと、小数点や桁区切りがその地域の書き方になります。1,234,567 バイトを渡すと、次のようになりました。

locale formatFileSize formatBytes
ja 1.2 MB 1,234,567 バイト
de 1,2 MB 1.234.567 バイト
fr 1,2 MB 1 234 567 バイト

MDN の Intl.NumberFormat() コンストラクターの説明によると、locale を省いて undefined を渡すと、実行環境の既定のロケールが使われます。検証では Playwright でページを locale: 'ja-JP' で開いたので、navigator.language は ja-JP でした。locale を省いて呼んでいる、このあと作るコンポーネントでは、桁区切りがカンマになりました。

Intl.NumberFormat の単位の表記は、日本語では「KB」になる

Intl.NumberFormat には、style: 'unit' で単位まで付けて書く機能もあります。MDN の Intl.NumberFormat() コンストラクターの説明によると、unit に使える値は Intl.supportedValuesOf() のページにまとめられています。Intl.supportedValuesOf() の、対応している単位識別子の一覧にあるバイトの単位は、byte、kilobyte、megabyte、gigabyte、terabyte、petabyte で、kibibyte のような 1024 ごとの単位はありません。

検証環境の Chromium で、1.5 をそれぞれの単位で書かせると、次のようになりました。

locale と unitDisplay byte kilobyte megabyte
ja、short 1.5 byte 1.5 KB 1.5 MB
ja、narrow 1.5B 1.5KB 1.5MB
ja、long 1.5 バイト 1.5 キロバイト 1.5 メガバイト
en、short 1.5 byte 1.5 kB 1.5 MB

日本語の short では、キロバイトが大文字の「KB」になり、バイトは「byte」になりました。英語の short では「kB」です。unit に kibibyte や mebibyte を渡すと、RangeError になりました。

text
kibibyte: RangeError: Invalid unit argument for Intl.NumberFormat() 'kibibyte'

Intl.NumberFormat は、渡した数に指定した単位を付けて書くだけで、バイト数から単位を選んで割る処理はしません。units(7) には、2進の接頭辞ができる前は、k を 1000、K を 1024 の意味で使うことが多かったと書かれています。「1.5 KB」と出ても、1000 ごとの値か 1024 ごとの値かは表示から分かりません。そこで formatFileSize では、単位は自前の文字列で付け、Intl.NumberFormat は数字の部分だけに使いました。

選んだファイルの大きさを一覧にするコンポーネント

tsx
import { useId, useState } from 'react';
import { formatBytes, formatFileSize, type Base } from './filesize';

export function FileSizeList() {
  const id = useId();
  const [files, setFiles] = useState<File[]>([]);
  const [base, setBase] = useState<Base>(1000);

  const total = files.reduce((sum, f) => sum + f.size, 0);

  return (
    <div className="filesize">
      <label htmlFor={`${id}-file`}>ファイルを選ぶ</label>
      <input
        id={`${id}-file`}
        type="file"
        multiple
        onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
      />

      <fieldset>
        <legend>単位</legend>
        <label>
          <input type="radio" name={`${id}-base`} checked={base === 1000} onChange={() => setBase(1000)} />
          1000 ごと(kB、MB)
        </label>
        <label>
          <input type="radio" name={`${id}-base`} checked={base === 1024} onChange={() => setBase(1024)} />
          1024 ごと(KiB、MiB)
        </label>
      </fieldset>

      {files.length > 0 && (
        <table>
          <tbody>
            {files.map((f, i) => (
              <tr key={`${i}-${f.name}`}>
                <td>{f.name}</td>
                <td className="size">
                  <span title={formatBytes(f.size)}>{formatFileSize(f.size, base)}</span>
                </td>
              </tr>
            ))}
          </tbody>
          <tfoot>
            <tr>
              <th scope="row">合計 {files.length} 件</th>
              <td className="size">
                <span title={formatBytes(total)}>{formatFileSize(total, base)}</span>
              </td>
            </tr>
          </tfoot>
        </table>
      )}
    </div>
  );
}
css
.filesize {
  display: grid;
  gap: 8px;
  max-width: 420px;
}
.filesize fieldset {
  display: grid;
  gap: 4px;
  border: none;
  padding: 0;
}
.filesize table {
  border-collapse: collapse;
}
.filesize td,
.filesize th {
  border-bottom: 1px solid #cfe9ef;
  padding: 6px 4px;
  text-align: left;
  font-weight: normal;
}
.filesize .size {
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.filesize tfoot {
  color: #067a98;
  font-weight: bold;
}

MDN の input type="file" の説明にあるとおり、選んだファイルは files プロパティの FileList で受け取れます。配列として扱うために、Array.from() で配列にして state に入れました。それぞれの File の size は、MDN の Blob.size の説明のとおり、バイト単位の大きさです。

state に持つのはファイルと「1000 ごとか 1024 ごとか」だけで、表示する文字列は描画のたびに formatFileSize で作ります。ラジオボタンを切り替えると、ファイルを選び直さなくても、表の単位がすべて変わります。

前の画像と同じ4つのファイルを、「1024 ごと(KiB、MiB)」を選んで表示したところ。photo.jpg 2.3 MiB、memo.txt 976.5 KiB、video.mp4 150.0 MiB、empty.txt 0 B、合計 4 件 153.3 MiB
同じファイルを、1024 ごとの単位に切り替えたところ

検証環境の Chromium で4つのファイルを選ぶと、表の値は次のようになりました。memo.txt の 999,960 バイトは、1000 ごとなら「1.0 MB」で、「1000.0 kB」にはなっていません。

ファイル バイト数 1000 ごと 1024 ごと
photo.jpg 2,457,600 2.5 MB 2.3 MiB
memo.txt 999,960 1.0 MB 976.5 KiB
video.mp4 157,286,400 157.3 MB 150.0 MiB
empty.txt 0 0 B 0 B
合計 4 件 160,743,960 160.7 MB 153.3 MiB

丸めた値だけでは正確な大きさが分からないので、span の title 属性に、桁区切りを付けたバイト数(「2,457,600 バイト」など)を入れています。大きさの列は右寄せにし、font-variant-numeric: tabular-nums で数字の幅をそろえました。

ファイルを選び直すと、onChange で state が新しい一覧に置き換わります。「こんにちは」を UTF-8 で書いたテキストファイル(a.txt)1つを選ぶと、表は a.txt の「15 B」と、合計 1 件の行だけになりました。選択を空にすると、表は消えました。

​Posted by 86