1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScript Blob・ArrayBuffer・Web Worker 完全ガイド — ブラウザ完結ファイル処理の設計パターン

1
Posted at

導入 — ブラウザでファイルを処理する時代

最近のフロントエンドでは、ファイルをサーバーに送らずにブラウザ内だけで処理するパターンが増えてきた。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

FileBlob のサブクラスなので、Blob を受け取るAPIにはそのまま渡せる。ArrayBuffer は生のメモリ領域で、中身を読むには Uint8Array などの TypedArray や DataView といったビューを被せる必要がある。

どれをいつ使うか — 判断フロー

  1. ユーザーから受け取った直後Fileinput[type=file] や Drag & Drop で得られる)
  2. ライブラリに渡す → ライブラリが要求する型に変換(後述)
  3. バイト単位で中身を操作するUint8Array
  4. ダウンロードさせる / プレビュー表示するBlob + URL.createObjectURL()
  5. エンディアンを気にしてバイナリヘッダーを読む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)

注意すべきは Uint8Arraybuffer プロパティだ。Uint8Array が元の ArrayBuffer の一部だけを参照している場合、.buffer は元のバッファ全体を返す。byteOffsetbyteLength を使って正確にスライスする必要がある。実際にぱんだツールズの 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 での取得

ドラッグ&ドロップで受け取る場合は onDroponDragOver を組み合わせる。

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>

onDragOverpreventDefault() を呼ばないとドロップイベントが発火しない。忘れがちなポイントだ。

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 の生成・編集を行うライブラリ。入力に ArrayBufferUint8Array、出力に 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 の読み書きを行うライブラリ。入力は ArrayBuffertype: '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 Uint8ArrayArrayBuffer PDF生成・編集
PDF.js ArrayBuffer Canvas → Blob PDFレンダリング
browser-image-compression File Blob 画像圧縮
encoding-japanese Uint8Array (数値配列) Uint8Array 文字コード変換
SheetJS ArrayBuffer 配列バッファ → Blob CSV/Excel処理

共通するのは、ほぼすべてのライブラリが ArrayBuffer を入力として受け取る という点。FilearrayBuffer() でバッファを取得し、そこから各ライブラリに渡す、というのが基本フローになる。

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() を呼ぶのが鉄板パターンだ。

まとめ

ブラウザ完結のファイル処理は、データ型の使い分けさえ押さえれば怖くない。要点を振り返る。

  • FilearrayBuffer() で中身を取得し、ライブラリが要求する型に変換して渡す
  • 出力は Blob にまとめて URL.createObjectURL() でダウンロードさせる
  • Uint8Array.buffer は部分参照の可能性があるので slice() で安全に取り出す
  • 重い処理は Web Worker に逃がす。ライブラリ内蔵の Worker 機能(useWebWorker / workerSrc)があれば活用する
  • createObjectURL() したら必ず revokeObjectURL() で解放する

これらのパターンを組み合わせれば、PDF・画像・CSV・Excelなど、かなり幅広いファイル処理をサーバーなしで実現できる。

ぱんだツールズでは、PDF圧縮・画像変換・CSV文字コード変換など69種類以上のツールを無料で公開中。すべてブラウザ内で処理が完結し、ファイルがサーバーに送信されることはない。


この記事は Zenn にも同じ内容を投稿しています。

1
0
1

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?