Reactで、ファイルのハッシュ値を計算して表示する方法

​​React​Playwright,TypeScript,useEffect,Web Crypto API,Web Worker

ブラウザだけでファイルのハッシュ値を出すには、<input type="file"> で選んだファイルを arrayBuffer() で読み込み、crypto.subtle.digest() に渡します。React では、計算を useEffect で始めて、ファイルが変わったら前の計算の結果を捨てるようにします。こうすると、大きいファイルの直後に小さいファイルを選び直しても、表示中のファイル名とハッシュ値が食い違いません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright でファイルを選んで、表示された値を sha256sum などのコマンドの出力と比べました。試したファイルは 300MB までです。Firefox、Safari、スマートフォンのブラウザ、開発用ビルドで StrictMode を使ったときの動き(Effect が2回動く)、Vite などのビルドツールでの Worker の書き方は試していません。

ファイル、方式、比べる値の欄が縦に並んだ画面。「ファイルを選択」を押して hello.txt を選ぶと、「hello.txt(6 バイト)」と、5891b5b5 で始まる64桁のハッシュ値が出て、ハッシュ値がピンクになる。比べる値の欄に sha256sum の出力を貼ると「一致しました」がピンクで出る。続けて random-1mb.bin を選ぶと、ハッシュ値が e0a39a2e で始まる値に変わり、「一致しません」に変わる。下の帯に、何回目の操作かが出ている
hello.txt を選ぶ、sha256sum の出力を貼る、別のファイルを選ぶ、の3つの操作。変わった表示が、その瞬間だけピンクになる

ファイルを読み込んで、16進数の文字列にする

計算の部分は、React から切り離して関数にしておきます。

ts
export const ALGORITHMS = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'] as const;
export type Algorithm = (typeof ALGORITHMS)[number];

// ArrayBuffer を、1バイトを2桁の16進数にした文字列にする
export function toHex(buffer: ArrayBuffer): string {
  return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')).join('');
}

export async function hashFile(file: Blob, algorithm: Algorithm): Promise<string> {
  if (!globalThis.crypto?.subtle) {
    throw new Error('この画面では Web Crypto API が使えません(HTTPS か localhost で開いてください)');
  }
  const data = await file.arrayBuffer(); // ファイル全体をメモリに読み込む
  const digest = await crypto.subtle.digest(algorithm, data);
  return toHex(digest);
}

// sha256sum の出力(「ハッシュ値  ファイル名」)を貼っても比べられるように、先頭の語だけを取る
export function normalizeHash(text: string): string {
  return (text.trim().split(/\s+/)[0] ?? '').toLowerCase();
}

crypto.subtle.digest() が受け取るのは、ArrayBuffer や Uint8Array などのデータです。File をそのまま渡すことはできないので、先に file.arrayBuffer() で中身を読み込みます。MDN の説明にあるとおり、この API はデータを少しずつ渡す使い方(ストリーミング)に対応していません。ファイル全体を一度メモリに読み込んでから渡します。

結果は ArrayBuffer で返ってきます。画面に出したり比べたりするには、1バイトずつ2桁の16進数にしてつなげます。検証環境の Chromium 153 では、英語版の MDN の例にある new Uint8Array(digest).toHex() でも同じ文字列になりました。

方式に指定できるのは SHA-1、SHA-256、SHA-384、SHA-512 の4つです。SHA-1 は、暗号の用途には使わないよう MDN に書かれています。MD5 は用意されておらず、'MD5' を渡すと次のエラーになりました。

text
NotSupportedError: Failed to execute 'digest' on 'SubtleCrypto': Algorithm: Unrecognized name

ブラウザで計算した値は、sha256sum などのコマンドの出力と一致しました。中身が hello と改行だけの6バイトのファイル(hello.txt)、空のファイル、1MB のファイル(random-1mb.bin)で比べています。3つのファイルの sha256sum の出力は次のとおりです。

text
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  hello.txt
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  empty.bin
e0a39a2e2f96a231a23747cce09164c600a161f0b1c976c89ce970cf82ff3860  random-1mb.bin

ブラウザの画面には、SHA-256 で 5891b5b5…、e3b0c442…、e0a39a2e… と、同じ値が出ました。hello.txt で方式を SHA-1、SHA-384、SHA-512 に変えた値も、sha1sum、sha384sum、sha512sum の出力と同じです。

計算は useEffect で始め、古い結果は捨てる

コンポーネントは次のとおりです。

tsx
import { useEffect, useId, useState } from 'react';
import { ALGORITHMS, hashFile, normalizeHash, type Algorithm } from './hash';

type Result = {
  file: File;
  algorithm: Algorithm;
  hash?: string;
  error?: string;
};

export function FileHash() {
  const [file, setFile] = useState<File | null>(null);
  const [algorithm, setAlgorithm] = useState<Algorithm>('SHA-256');
  const [result, setResult] = useState<Result | null>(null);
  const [expected, setExpected] = useState('');
  const id = useId();

  useEffect(() => {
    if (!file) return;
    let ignore = false;
    hashFile(file, algorithm).then(
      (hash) => {
        if (!ignore) setResult({ file, algorithm, hash });
      },
      (e: unknown) => {
        if (!ignore) setResult({ file, algorithm, error: e instanceof Error ? e.message : String(e) });
      },
    );
    return () => {
      ignore = true; // ファイルか方式が変わったら、前の計算の結果は捨てる
    };
  }, [file, algorithm]);

  // 今選んでいるファイルと方式の結果がまだなければ、計算中
  const current = result && result.file === file && result.algorithm === algorithm ? result : null;
  const want = normalizeHash(expected);

  return (
    <div className="file-hash">
      <p>
        <label htmlFor={`${id}-file`}>ファイル</label>
        <input id={`${id}-file`} type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      </p>
      <p>
        <label htmlFor={`${id}-alg`}>方式</label>
        <select id={`${id}-alg`} value={algorithm} onChange={(e) => setAlgorithm(e.target.value as Algorithm)}>
          {ALGORITHMS.map((a) => (
            <option key={a}>{a}</option>
          ))}
        </select>
      </p>
      {file && (
        <section aria-live="polite">
          <p className="name">
            {file.name}({file.size.toLocaleString('ja-JP')} バイト)
          </p>
          {!current && <p className="status">計算中…</p>}
          {current?.error && <p className="error">{current.error}</p>}
          {current?.hash && <output className="hash">{current.hash}</output>}
        </section>
      )}
      <p>
        <label htmlFor={`${id}-expected`}>比べる値</label>
        <input id={`${id}-expected`} value={expected} onChange={(e) => setExpected(e.target.value)} placeholder="公開されているハッシュ値を貼る" />
      </p>
      {current?.hash && want && (
        <p className={want === current.hash ? 'match' : 'mismatch'}>{want === current.hash ? '一致しました' : '一致しません'}</p>
      )}
    </div>
  );
}

state に持つのは、選んだファイル(file)、方式(algorithm)、計算の結果(result)、比べる値(expected)の4つです。結果には、どのファイルをどの方式で計算したものかも一緒に入れています。今選んでいるファイルと方式の結果がまだ届いていなければ、その間は「計算中…」を出します。「計算中かどうか」は state に持たず、描画のたびに求めています。

計算は useEffect の中で始めます。ファイルか方式が変わると、React は前の Effect のクリーンアップ関数を呼んでから、新しい Effect を動かします。クリーンアップで ignore を true にしておくと、前の計算があとから終わっても、その結果は state に入りません。この書き方は、React の公式ドキュメントにあるデータを取得する Effect のクリーンアップと同じです。

イベントハンドラーの中で計算すると、前のファイルの結果で上書きされる

比べるために、ファイルを選んだときのイベントハンドラーの中で計算して、終わった順に state を書きかえる版も作りました。

tsx
// 比べる版:選んだときのイベントで計算し、終わった順に state を書きかえる
import { useState } from 'react';
import { hashFile } from './hash';

export function NaiveFileHash() {
  const [file, setFile] = useState<File | null>(null);
  const [hash, setHash] = useState('');

  async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const f = e.target.files?.[0] ?? null;
    setFile(f);
    setHash('');
    if (f) setHash(await hashFile(f, 'SHA-256'));
  }

  return (
    <div>
      <input type="file" aria-label="ファイル" onChange={handleChange} />
      {file && <p className="name">{file.name}</p>}
      {hash && <output className="hash">{hash}</output>}
    </div>
  );
}

300MB のファイルを選んだ直後に hello.txt を選び直し、画面の表示が変わるたびに、ファイル名とハッシュ値の先頭16桁を記録しました。1回目の結果は次のとおりです。

text
NaiveFileHash(1回目)
     39ms large-300mb.bin | (なし)
     58ms hello.txt | (なし)
     62ms hello.txt | 5891b5b522d5df08
   2855ms hello.txt | 04e8a1c905133b5c

hello.txt の正しい値(5891b5b5…)は一度出ますが、約3秒後に 300MB のファイルの値(04e8a1c9…)で上書きされました。ファイル名は hello.txt のままなので、別のファイルのハッシュ値を hello.txt の値として見せてしまいます。2回目も同じように上書きされました。

useEffect で書いた FileHash で同じ操作をすると、表示は hello.txt の正しい値のまま変わりませんでした(8秒間記録)。

text
FileHash(1回目)
     47ms large-300mb.bin(314,572,800 バイト) | 計算中…
     60ms hello.txt(6 バイト) | 計算中…
     65ms hello.txt(6 バイト) | 5891b5b522d5df08

公開されているハッシュ値と比べる

ソフトウェアの配布サイトでは、ダウンロードするファイルの SHA-256 の値を公開していることがあります。手元のファイルから計算した値と比べれば、ファイルが配布元の意図したものと同じかを確かめられます。この使い方は、MDN の SubtleCrypto を暗号以外の用途に使う例でも紹介されています。

比べる値は、normalizeHash() で空白を取り、小文字にそろえてから比べています。sha256sum の出力は「ハッシュ値、空白2つ、ファイル名」の形なので、先頭の語だけを使います。大文字で書かれた値も、sha256sum の出力の1行をそのまま貼った場合も「一致しました」になり、最後の1文字だけを変えた値では「一致しません」になりました。

大きいファイルは Worker で計算する

FileHash で 300MB のファイルを選び、ハッシュ値が出るまでの間に、50ms ごとに動くタイマーがどれだけ待たされるかを測りました。3回の結果は次のとおりです。

計算する場所 ハッシュ値が出るまで タイマーの最大の間隔
メインスレッド(FileHash) 960〜2501ms 606〜2292ms
Worker(FileHashWorker) 841〜1869ms 50〜52ms

メインスレッドで計算すると、タイマーが最大で2秒あまり動きませんでした。その間、メインスレッドはほかの処理を進められませんでした。Worker で計算すると、タイマーはほぼ 50ms ごとに動き続けました。

Worker の中では、同じ hashFile() を呼びます。File は structured clone アルゴリズムで複製できる型なので、postMessage() でそのまま Worker に渡せます。

ts
// Worker の中でファイルを読み込み、ハッシュ値を計算して返す
import { hashFile, type Algorithm } from './hash';

type Request = { file: File; algorithm: Algorithm };

self.onmessage = async (e: MessageEvent<Request>) => {
  try {
    postMessage({ hash: await hashFile(e.data.file, e.data.algorithm) });
  } catch (err) {
    postMessage({ error: err instanceof Error ? err.message : String(err) });
  }
};

呼び出す側は、AbortSignal を受け取って、中止されたら Worker を止めるようにしました。terminate() は、Worker の処理が途中でも、その場で止めます。

ts
import type { Algorithm } from './hash';

// signal が中止されたら Worker を止める(計算も途中でやめる)
export function hashFileInWorker(file: File, algorithm: Algorithm, signal: AbortSignal): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker('hash.worker.js');
    const stop = () => {
      worker.terminate();
      reject(signal.reason);
    };
    signal.addEventListener('abort', stop, { once: true });
    worker.onmessage = (e: MessageEvent<{ hash?: string; error?: string }>) => {
      signal.removeEventListener('abort', stop);
      worker.terminate();
      if (e.data.hash) resolve(e.data.hash);
      else reject(new Error(e.data.error));
    };
    worker.postMessage({ file, algorithm });
  });
}

hash.worker.js は、src/hash.worker.ts を esbuild で別にビルドしたファイルです。

コンポーネントは、FileHash の import と Effect を次のように変えるだけです。hashFile() の代わりに hashFileInWorker() を呼び、クリーンアップで controller.abort() を呼びます。

tsx

useEffect(() => {
    if (!file) return;
    let ignore = false;
    const controller = new AbortController();
    hashFileInWorker(file, algorithm, controller.signal).then(
      (hash) => {
        if (!ignore) setResult({ file, algorithm, hash });
      },
      (e: unknown) => {
        if (!ignore) setResult({ file, algorithm, error: e instanceof Error ? e.message : String(e) });
      },
    );
    return () => {
      ignore = true; // ファイルか方式が変わったら、前の計算の結果は捨てる
      controller.abort(); // 前の計算をしている Worker も止める
    };
  }, [file, algorithm]);

300MB のファイルの直後に hello.txt を選び直すと、表示は hello.txt の正しい値になりました。Worker の作成と terminate() の呼び出しを記録すると、「作成、terminate()、作成、terminate()」の順でした。1つめの terminate() は、2つめの Worker が作られる前に呼ばれています。300MB の計算には Worker でも 800ms 以上かかるので、これは計算の途中でクリーンアップから呼ばれたものです。前のファイルの計算は、結果を捨てるだけでなく、途中で止まります。

HTTPS か localhost で開く

crypto.subtle は、セキュアコンテキスト(HTTPS のページや http://localhost など)でしか使えません。検証環境で、example.test という名前を同じマシンに向け、同じページを http://example.test で開くと、isSecureContext は false、crypto.subtle は undefined でした。hashFile() の最初の確認でエラーになり、画面には「この画面では Web Crypto API が使えません(HTTPS か localhost で開いてください)」と出ました。

http://127.0.0.1 で開いた場合は、isSecureContext が true でした。同じページでも、localhost や 127.0.0.1 以外のホスト名を使って HTTP のまま開くと計算できません。公開するページは HTTPS で配信します。

​Posted by 86