導入 — ブラウザでファイルを処理する時代
最近のフロントエンドでは、ファイルをサーバーに送らずにブラウザ内だけで処理するパターンが増えてきた。PDF圧縮、画像変換、CSV文字コード変換、Excel生成——これらをすべてクライアントサイドで完結させると、次のメリットがある。
- プライバシー: ファイルがネットワークを流れないので、機密データでも安心して扱える
- 速度: アップロード・ダウンロードの往復がなく、体感が速い
- コスト: サーバー側の変換処理が不要で、インフラコストを抑えられる
- スケーラビリティ: 処理負荷がユーザーの端末に分散される
実際にこの方針でツール集を運用しているのが、ぱんだツールズだ。PDF・画像・CSV・テキスト処理を69種類以上、すべてブラウザ内で完結させている。
この記事では、ブラウザでファイルを扱う際に必ず出くわす Blob / ArrayBuffer / Uint8Array の使い分け、ライブラリごとのデータ受け渡しパターン、Web Worker によるUI保護、メモリ管理の注意点 を体系的に整理する。
ブラウザのファイル関連オブジェクトを整理する
ブラウザでファイルを扱うと、似たようなオブジェクトが大量に登場する。まずは関係を整理しよう。
各オブジェクトの役割
| オブジェクト | 役割 | 特徴 |
|---|---|---|
File |
ユーザーが選択したファイル |
Blob を継承。ファイル名・更新日時を持つ |
Blob |
バイナリデータの塊 | イミュータブル。MIME type を持つ |
ArrayBuffer |
固定長のバイナリバッファ | 直接読み書きできない。View を通して操作 |
Uint8Array |
ArrayBuffer のビュー(符号なし8ビット整数) | バイト単位でデータを読み書きできる |
DataView |
ArrayBuffer の汎用ビュー | エンディアンを指定してデータを読める |
継承関係
File extends Blob
└─ .arrayBuffer() → ArrayBuffer
└─ new Uint8Array(buffer) → Uint8Array
└─ new DataView(buffer) → DataView
File は Blob のサブクラスなので、Blob を受け取るAPIにはそのまま渡せる。ArrayBuffer は生のメモリ領域で、中身を読むには Uint8Array などの TypedArray や DataView といったビューを被せる必要がある。
どれをいつ使うか — 判断フロー
-
ユーザーから受け取った直後 →
File(input[type=file]や Drag & Drop で得られる) - ライブラリに渡す → ライブラリが要求する型に変換(後述)
-
バイト単位で中身を操作する →
Uint8Array -
ダウンロードさせる / プレビュー表示する →
Blob+URL.createObjectURL() -
エンディアンを気にしてバイナリヘッダーを読む →
DataView
相互変換コード
// File / Blob → ArrayBuffer
const buffer: ArrayBuffer = await file.arrayBuffer()
// ArrayBuffer → Uint8Array
const bytes = new Uint8Array(buffer)
// Uint8Array → ArrayBuffer
// Uint8Array がバッファ全体を参照している場合
const buf = bytes.buffer as ArrayBuffer
// 部分参照の場合は slice で安全にコピー
const safeBuf = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer
// ArrayBuffer → Blob
const blob = new Blob([buffer], { type: 'application/pdf' })
// Blob → File
const newFile = new File([blob], 'output.pdf', { type: 'application/pdf' })
// 文字列 → ArrayBuffer (UTF-8)
const encoded: Uint8Array = new TextEncoder().encode('こんにちは')
// ArrayBuffer → 文字列 (UTF-8)
const text: string = new TextDecoder('utf-8').decode(buffer)
// ArrayBuffer → 文字列 (Shift_JIS)
const sjisText: string = new TextDecoder('shift_jis').decode(buffer)
注意すべきは Uint8Array の buffer プロパティだ。Uint8Array が元の ArrayBuffer の一部だけを参照している場合、.buffer は元のバッファ全体を返す。byteOffset と byteLength を使って正確にスライスする必要がある。実際にぱんだツールズの PDF 処理でもこのパターンを使っている。
// pdf-lib の save() が返す Uint8Array から ArrayBuffer を取り出す
const saved: Uint8Array = await pdfDoc.save()
const result = saved.buffer.slice(
saved.byteOffset,
saved.byteOffset + saved.byteLength
) as ArrayBuffer
ファイル読み込みパターン
input[type=file] からの取得
最も基本的なパターン。React では onChange イベントから File を取得する。
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) processFile(file)
}
// JSX
<input type="file" accept=".pdf" onChange={handleChange} />
accept 属性でファイル選択ダイアログのフィルタを指定できる。ただし、これはあくまでUIのヒントで、バリデーションではない。実際のファイル種別チェックはアプリケーション側で行う必要がある。
Drag & Drop での取得
ドラッグ&ドロップで受け取る場合は onDrop と onDragOver を組み合わせる。
function handleDrop(e: React.DragEvent) {
e.preventDefault()
const file = e.dataTransfer.files[0]
if (file) processFile(file)
}
// JSX
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
ファイルをドロップ
</div>
onDragOver で preventDefault() を呼ばないとドロップイベントが発火しない。忘れがちなポイントだ。
File から中身を読み出す方法
File から中身を取り出す方法は3つある。
// 方法1: arrayBuffer() — バイナリ処理向け(推奨)
const buffer: ArrayBuffer = await file.arrayBuffer()
// 方法2: text() — テキストファイル向け
const text: string = await file.text()
// 方法3: FileReader — レガシーだが細かい制御が可能
const reader = new FileReader()
reader.onload = () => {
const buffer = reader.result as ArrayBuffer
}
reader.readAsArrayBuffer(file)
現在のブラウザでは arrayBuffer() と text() が使えるので、基本はこの2つで十分だ。FileReader はコールバックベースで扱いにくいため、特別な理由がなければ避けてよい。
大容量ファイルの読み込み戦略
20MBを超えるファイルを扱う場合、一気に arrayBuffer() で読み込むとメモリ消費が大きくなる。その場合は Blob.slice() でチャンク分割する方法がある。
async function readInChunks(
file: File,
chunkSize: number = 1024 * 1024 // 1MB
): Promise<void> {
let offset = 0
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize)
const buffer = await chunk.arrayBuffer()
// chunk ごとの処理(ハッシュ計算、ストリーミングアップロードなど)
processChunk(buffer)
offset += chunkSize
}
}
ただし、pdf-lib や SheetJS のようにファイル全体を一度に読む必要があるライブラリでは、この戦略は使えない。そのため、ぱんだツールズでは入力ファイルサイズに上限(10〜20MB)を設け、ブラウザが無理なく処理できる範囲に収めている。
ライブラリ別のデータ受け渡しパターン
ブラウザでファイル処理をする際、各ライブラリが期待する入出力の型がバラバラなのが厄介だ。ここでは主要ライブラリごとのパターンを整理する。
pdf-lib — Uint8Array / ArrayBuffer で入出力
pdf-lib は PDF の生成・編集を行うライブラリ。入力に ArrayBuffer か Uint8Array、出力に Uint8Array を使う。
import { PDFDocument } from 'pdf-lib'
// 入力: ArrayBuffer で PDF を読み込む
const buffer: ArrayBuffer = await file.arrayBuffer()
const pdfDoc = await PDFDocument.load(buffer)
// 何らかの編集(ページ削除、結合、圧縮など)
// 出力: Uint8Array → ArrayBuffer → Blob
const saved: Uint8Array = await pdfDoc.save({ useObjectStreams: true })
const resultBuffer = saved.buffer.slice(
saved.byteOffset,
saved.byteOffset + saved.byteLength
) as ArrayBuffer
const blob = new Blob([resultBuffer], { type: 'application/pdf' })
PDF の結合では、複数の ArrayBuffer を順番に読み込んでページをコピーしていく。
async function mergePdf(buffers: ArrayBuffer[]): Promise<ArrayBuffer> {
const merged = await PDFDocument.create()
for (const buffer of buffers) {
const doc = await PDFDocument.load(buffer)
const indices = Array.from({ length: doc.getPageCount() }, (_, i) => i)
const copied = await merged.copyPages(doc, indices)
for (const page of copied) {
merged.addPage(page)
}
}
const saved = await merged.save()
return saved.buffer.slice(
saved.byteOffset,
saved.byteOffset + saved.byteLength
) as ArrayBuffer
}
画像を PDF に埋め込む場合は、画像データを Uint8Array で渡す。
// JPEG / PNG を Uint8Array として読み込む
const imageBytes = new Uint8Array(await imageFile.arrayBuffer())
// pdf-lib は embedJpg / embedPng で画像形式を明示する
const image = file.type === 'image/png'
? await pdfDoc.embedPng(imageBytes)
: await pdfDoc.embedJpg(imageBytes)
PDF.js (pdfjs-dist) — ArrayBuffer で入力
PDF.js は PDF のレンダリング(表示)に使うライブラリ。入力は ArrayBuffer で受け取り、Canvas に描画して画像として取り出す。
import * as pdfjsLib from 'pdfjs-dist'
// Worker の設定(後述の Web Worker セクション参照)
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString()
// ArrayBuffer で PDF を読み込む
const arrayBuffer = await file.arrayBuffer()
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
// 各ページを Canvas に描画 → Blob に変換
const page = await pdfDoc.getPage(1)
const viewport = page.getViewport({ scale: 2 })
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d')!
await page.render({ canvasContext: ctx, viewport }).promise
// Canvas → Blob
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => b ? resolve(b) : reject(new Error('変換失敗')),
'image/jpeg',
0.92
)
})
ここで注目したいのは、canvas.toBlob() がコールバック API だという点。Promise でラップして使うのが定番パターンだ。
browser-image-compression — File で入力、Blob で出力
画像圧縮ライブラリの browser-image-compression は、入出力ともに File / Blob ベースで、バイナリを意識する必要がない。
import imageCompression from 'browser-image-compression'
const options = {
maxSizeMB: 0.8,
maxWidthOrHeight: 2560,
useWebWorker: true, // Web Worker で処理(後述)
}
// 入力: File、出力: Blob
const compressedBlob: Blob = await imageCompression(file, options)
このライブラリは内部で Canvas API を使って画像を再エンコードしている。useWebWorker: true を指定すると、重い処理を Web Worker に逃がしてくれる(ライブラリ側で自動的に Worker を生成する)。
encoding-japanese — Uint8Array で文字コード変換
encoding-japanese は日本語の文字コード変換ライブラリ。Shift_JIS ↔ UTF-8 の変換に使う。入力は数値配列か Uint8Array。
import Encoding from 'encoding-japanese'
// UTF-8 テキスト → Shift_JIS バイナリ
const text: string = new TextDecoder('utf-8').decode(buffer)
const unicodeArray = Encoding.stringToCode(text)
const sjisArray = Encoding.convert(unicodeArray, {
to: 'SJIS',
from: 'UNICODE',
})
const sjisBuffer = new Uint8Array(sjisArray).buffer as ArrayBuffer
// Shift_JIS → UTF-8 は TextDecoder だけで可能
const sjisText = new TextDecoder('shift_jis', { fatal: true }).decode(bytes)
TextDecoder は Shift_JIS のデコードに対応しているが、エンコード(UTF-8 → Shift_JIS)には対応していない。そのため、Shift_JIS への変換だけ encoding-japanese が必要になる。
SheetJS (xlsx) — ArrayBuffer で入力、配列バッファで出力
SheetJS は Excel / CSV の読み書きを行うライブラリ。入力は ArrayBuffer(type: 'array')、出力も配列バッファで返す。
import * as XLSX from 'xlsx'
// CSV → Excel 変換
const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, {
type: 'array',
codepage: 932, // Shift_JIS 対応
})
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
})
const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
// Excel → CSV 変換
const worksheet = workbook.Sheets[workbook.SheetNames[0]]
const csvContent: string = XLSX.utils.sheet_to_csv(worksheet)
const csvBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
codepage: 932 を指定することで、Shift_JIS の日本語 CSV も文字化けなく読み込める。これを忘れると日本語が全部壊れる。
パターンまとめ
| ライブラリ | 入力型 | 出力型 | 用途 |
|---|---|---|---|
| pdf-lib |
ArrayBuffer / Uint8Array
|
Uint8Array → ArrayBuffer
|
PDF生成・編集 |
| PDF.js | ArrayBuffer |
Canvas → Blob
|
PDFレンダリング |
| browser-image-compression | File |
Blob |
画像圧縮 |
| encoding-japanese |
Uint8Array (数値配列) |
Uint8Array |
文字コード変換 |
| SheetJS | ArrayBuffer |
配列バッファ → Blob
|
CSV/Excel処理 |
共通するのは、ほぼすべてのライブラリが ArrayBuffer を入力として受け取る という点。File → arrayBuffer() でバッファを取得し、そこから各ライブラリに渡す、というのが基本フローになる。
Web Worker でメインスレッドを守る
なぜ重い処理で UI がフリーズするか
JavaScript はシングルスレッドで動作する。PDF の解析や画像の再エンコードのような CPU 集約的な処理をメインスレッドで実行すると、その間 DOM の更新やユーザー操作が一切ブロックされる。ボタンが押せない、プログレスバーが動かない、スクロールできない——ユーザーからは「フリーズした」ように見える。
Web Worker の基本
Web Worker は別スレッドでスクリプトを実行する仕組みだ。メインスレッドと Worker は postMessage / onmessage でデータをやり取りする。
// worker.ts — Worker 側
self.onmessage = async (e: MessageEvent) => {
const { buffer, quality } = e.data
// 重い処理をここで実行
const result = await heavyProcess(buffer, quality)
// 結果をメインスレッドに返す
self.postMessage({ result })
}
// main.ts — メインスレッド側
const worker = new Worker(
new URL('./worker.ts', import.meta.url)
)
worker.onmessage = (e: MessageEvent) => {
const { result } = e.data
// UI更新
setResult(result)
}
// Worker に処理を依頼
worker.postMessage({ buffer, quality })
postMessage でオブジェクトを送ると、デフォルトでは構造化クローン(ディープコピー)が行われる。大きな ArrayBuffer をコピーするとオーバーヘッドが発生するので、Transferable Objects を使えば所有権を移転してゼロコピーで受け渡せる。
// Transferable で ArrayBuffer を移転(コピーなし)
worker.postMessage({ buffer }, [buffer])
// この時点で元の buffer は使えなくなる(byteLength が 0 になる)
ライブラリ内蔵の Worker 対応
自前で Worker を書かなくても、ライブラリ側が Worker 対応を持っている場合がある。
browser-image-compression はオプションひとつで Worker を使える。
const options = {
maxSizeMB: 0.8,
maxWidthOrHeight: 2560,
useWebWorker: true, // これだけで Worker を使ってくれる
}
const compressed = await imageCompression(file, options)
内部では、ライブラリがインラインで Worker を生成し、Canvas を使った画像処理を別スレッドで実行する。開発者は useWebWorker: true を渡すだけでよい。
PDF.js は Worker ファイルのパスを指定して使う。
import * as pdfjsLib from 'pdfjs-dist'
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString()
PDF.js の Worker は PDF のパース処理を別スレッドで行う。workerSrc を設定しないと、メインスレッドでパースが走り、大きな PDF でフリーズの原因になる。Next.js では import.meta.url を使って Worker ファイルのパスを解決するのがポイントだ。
Worker を使うかどうかの判断基準
すべての処理を Worker に逃がす必要はない。判断基準はシンプルで、処理に100ms以上かかるかどうか だ。
- 100ms 未満 → メインスレッドで OK(ユーザーは遅延を感じない)
- 100ms〜1秒 → 可能なら Worker に逃がす
- 1秒以上 → 必ず Worker を使い、プログレス表示も入れる
CSV の文字コード変換のような軽い処理はメインスレッドで問題ない。PDF の圧縮や画像の再エンコードは Worker を使うべきだ。
メモリ管理の注意点
ブラウザでファイルを扱うと、メモリリークの原因になりやすいポイントがいくつかある。
URL.createObjectURL() と revokeObjectURL() のペア管理
URL.createObjectURL() は Blob から一時的な URL を生成する。この URL はブラウザ内部で Blob への参照を保持するため、明示的に revokeObjectURL() で解放しないとメモリリークになる。
// ダウンロード用の一時 URL を作成
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
// ダウンロード開始後すぐに解放
URL.revokeObjectURL(url)
ぱんだツールズの DownloadButton コンポーネントは、この create → click → revoke のパターンを1つの関数にまとめている。
const handleClick = () => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
プレビュー表示時のライフサイクル管理
画像プレビューのように、URL を一定時間保持する必要がある場合は、コンポーネントのアンマウント時に確実に解放する。
const prevUrl = useRef<string | null>(null)
// アンマウント時にクリーンアップ
useEffect(() => {
return () => {
if (prevUrl.current) {
URL.revokeObjectURL(prevUrl.current)
}
}
}, [])
// 新しいファイルが来たら、古い URL を解放してから新しい URL を作る
const handleFile = useCallback((f: File) => {
if (prevUrl.current) {
URL.revokeObjectURL(prevUrl.current)
}
const url = URL.createObjectURL(f)
prevUrl.current = url
setPreviewUrl(url)
}, [])
PDF → 画像変換のように複数の URL を管理する場合は、配列で保持してまとめて解放する。
const resultsRef = useRef<{ url: string }[]>([])
// アンマウント時に全 URL を解放
useEffect(() => {
return () => {
resultsRef.current.forEach((r) => URL.revokeObjectURL(r.url))
}
}, [])
// 再変換時に古い結果を解放
results.forEach((r) => URL.revokeObjectURL(r.url))
大きな ArrayBuffer の参照切り
JavaScript のガベージコレクタは、参照が残っている限りメモリを解放しない。大きな ArrayBuffer を処理した後は、不要になった変数に null を代入するか、スコープを限定して GC に回収させる。
async function processFile(file: File) {
// buffer はこのスコープ内でだけ使う
const buffer = await file.arrayBuffer()
const result = await someHeavyProcess(buffer)
// result だけ返す(buffer への参照は関数終了で消える)
return result
}
ファイルダウンロード後のクリーンアップ全体像
処理の流れ全体を見ると、メモリ管理が必要なポイントは3箇所ある。
1. File → arrayBuffer() ← buffer のスコープを限定する
↓
2. ライブラリで変換処理 ← 中間データは処理後に参照を切る
↓
3. 結果を Blob にまとめる ← state に保持
↓
4. createObjectURL(blob) ← ダウンロード後に revokeObjectURL()
React のコンポーネントでは、state に Blob を持ち、ダウンロード(または画面遷移)のタイミングで解放する。useEffect のクリーンアップ関数で revokeObjectURL() を呼ぶのが鉄板パターンだ。
まとめ
ブラウザ完結のファイル処理は、データ型の使い分けさえ押さえれば怖くない。要点を振り返る。
-
File→arrayBuffer()で中身を取得し、ライブラリが要求する型に変換して渡す - 出力は
BlobにまとめてURL.createObjectURL()でダウンロードさせる -
Uint8Arrayの.bufferは部分参照の可能性があるのでslice()で安全に取り出す - 重い処理は Web Worker に逃がす。ライブラリ内蔵の Worker 機能(
useWebWorker/workerSrc)があれば活用する -
createObjectURL()したら必ずrevokeObjectURL()で解放する
これらのパターンを組み合わせれば、PDF・画像・CSV・Excelなど、かなり幅広いファイル処理をサーバーなしで実現できる。
ぱんだツールズでは、PDF圧縮・画像変換・CSV文字コード変換など69種類以上のツールを無料で公開中。すべてブラウザ内で処理が完結し、ファイルがサーバーに送信されることはない。
この記事は Zenn にも同じ内容を投稿しています。