Reactで、ファイルのハッシュ値を計算して表示する方法
ブラウザだけでファイルのハッシュ値を出すには、<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 の書き方は試していません。

ファイルを読み込んで、16進数の文字列にする
計算の部分は、React から切り離して関数にしておきます。
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' を渡すと次のエラーになりました。
NotSupportedError: Failed to execute 'digest' on 'SubtleCrypto': Algorithm: Unrecognized name
ブラウザで計算した値は、sha256sum などのコマンドの出力と一致しました。中身が hello と改行だけの6バイトのファイル(hello.txt)、空のファイル、1MB のファイル(random-1mb.bin)で比べています。3つのファイルの sha256sum の出力は次のとおりです。
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 で始め、古い結果は捨てる
コンポーネントは次のとおりです。
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 を書きかえる版も作りました。
// 比べる版:選んだときのイベントで計算し、終わった順に 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回目の結果は次のとおりです。
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秒間記録)。
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 に渡せます。
// 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 の処理が途中でも、その場で止めます。
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() を呼びます。
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 で配信します。





