[JS] 10 min read

[JS] JavaScriptでのBase64 — atob、btoa、Node Buffer

JavaScriptでのBase64の実践ガイド。btoa/atobをいつ使うべきか、なぜUnicodeで壊れるか、Node.jsでBufferがどう動くか、そしてクロスランタイムなbase64urlヘルパーを紹介します。

April 2026 | javascript

// 全体像

JavaScriptとBase64の歴史は断片的です。ブラウザは1990年代のNetscapeから btoa() / atob() を継承しました。これらの名前は文字通り binary-to-ASCIIASCII-to-binary を意味します。これらはLatin-1文字しか扱えず、そのため絵文字や中国語のテキストを btoa() に貼り付けるとエラーが発生します。

Node.jsは Buffer クラスを通じて独自の経路を追加しました。必要と思われるあらゆるエンコーディング(utf8、base64、base64url、hex、latin1、ascii)を理解し、バイナリデータをネイティブに扱えるため、サーバーサイドコードでは明らかな選択肢となります。

最近のランタイム(Node 18+、Deno、Bun、現代のブラウザ)には、Unicode文字列をBase64エンコード前にバイト列へ変換する標準ベースの方法として TextEncoder / TextDecoder も搭載されています。どのツールに手を伸ばすべきかを知ることが、成功の半分を占めます。

// ブラウザ: btoa / atob — 正しい使い方

btoa(str) は、すべての文字が1バイトに収まる文字列(Latin-1範囲、U+0000からU+00FFまで)を受け取り、Base64文字列を返します。atob(b64) はその逆です。同期的で、高速で、古くからあらゆるブラウザに組み込まれています。

正しい使い方は次の通りです:すでにバイト状のデータをBase64化するFileReader から読み取った生のバイト、バイナリ XMLHttpRequest(responseType: 'arraybuffer')からのバイト、あるいは Uint8Array からのバイトで、各バイトの数値が0〜255に収まるものです。

// Encode a Latin-1 string
const latin1 = 'Hello, world!';
const encoded = btoa(latin1);   // 'SGVsbG8sIHdvcmxkIQ=='

// Decode it back
const decoded = atob(encoded);  // 'Hello, world!'

// Encode a binary Uint8Array (the common real-world case)
const bytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]);  // JPEG magic
const binStr = String.fromCharCode(...bytes);            // 'ÿØÿà' (Latin-1)
const b64 = btoa(binStr);                                // '/9j/4A=='

// なぜ btoa() はUnicodeで例外を投げるのか

ブラウザのコンソールで btoa('héllo') を試してみてください。InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range が返ってきます。é(U+00E9)は問題ありませんが、btoa('日本語') は失敗します。(U+65E5)がLatin-1の範囲外だからです。この関数はUTF-8の概念を持たず、JavaScriptの各 String コードユニットを1バイトとして読み取ります。

解決策は、Unicode文字列をまずUTF-8バイト列に変換し、それらのバイトをBase64化することです。現代のブラウザはすべてこの目的のために TextEncoder を搭載しています。

// ❌ broken
// btoa('日本語')  → throws InvalidCharacterError

// ✅ correct: encode to UTF-8 first
function b64encodeUtf8(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const byte of bytes) bin += String.fromCharCode(byte);
  return btoa(bin);
}

b64encodeUtf8('日本語');   // '5pel5pys6Kqe'

// ✅ round-trip decode
function b64decodeUtf8(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

b64decodeUtf8('5pel5pys6Kqe');  // '日本語'

// NODE.JS: BUFFER による方法

Nodeでは Buffer に手を伸ばしましょう。デフォルトでUTF-8を認識し、あらゆるエンコーディングをサポートし、バイナリをきれいに扱えます。Node 16+ では 'base64url' もエンコーディング名として理解するため、URLセーフな出力のために別のヘルパーを書く必要はありません。

// String → Base64
const encoded = Buffer.from('héllo 世界').toString('base64');
// 'aMOpbGxvIOS4lueVjA=='

// Base64 → string (UTF-8 by default)
const decoded = Buffer.from(encoded, 'base64').toString();
// 'héllo 世界'

// URL-safe (Node 16+): +/- omitted padding
const safe = Buffer.from('héllo 世界').toString('base64url');
// 'aMOpbGxvIOS4lueVjA'  ← no = padding, uses -_

// Binary file → Base64
const fs = require('node:fs');
const imgB64 = fs.readFileSync('logo.png').toString('base64');

// Base64 → binary file
fs.writeFileSync('out.png', Buffer.from(imgB64, 'base64'));

// BUFFER VS btoa/atob — クイック比較

  • > Unicode:BufferはUTF-8をネイティブに扱う。btoa/atobはLatin-1バイトしか扱えない。
  • > バイナリ:BufferはNodeのネイティブなバイナリ型。ブラウザではUint8Array + String.fromCharCodeが必要。
  • > URLセーフ:Bufferは 'base64url' をファーストクラスのエンコーディングとしてサポート(Node 16+)。ブラウザではカスタムな正規表現置換が必要。
  • > パフォーマンス:BufferはC++で実装されており、大きな入力に対してatob/btoaの約3〜5倍高速。
  • > ストリーミング:Bufferは変換ストリームを経由して大きなファイルをパイプ処理できる。btoa/atobはストリーミングできない。
  • > 利用可能性:btoa/atobはNode 16+でもグローバルとして存在するが、選択できるならBufferに手を伸ばそう — エッジケースも処理してくれる。

// ブラウザでバイナリファイルをエンコード・デコードする

ファイルピッカーの選択をBase64文字列に変換するブラウザの定型パターンは FileReader.readAsDataURL() です。これはすぐに使えるデータURIを返します。生のBase64ペイロードだけが欲しい場合は、先頭の data:...;base64, を削除します。

非常に大きなファイルの場合は readAsArrayBuffer() で読み込み、チャンク単位でエンコードしてください。String.fromCharCode(...bytes) は約65,000要素を超える配列で「maximum call stack size exceeded」エラーに遭遇します。

// ▸ Simple: file → data URI → strip prefix
async function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const dataUri = reader.result;                      // 'data:image/png;base64,iVBOR...'
      resolve(dataUri.split(',')[1]);                     // raw Base64 only
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// ▸ Large files: chunked btoa() to avoid stack overflow
async function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  const chunkSize = 0x8000;      // 32 KB chunks
  let binary = '';
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode.apply(null,
      bytes.subarray(i, i + chunkSize));
  }
  return btoa(binary);
}

// Usage
const file = inputEl.files[0];
const buf = await file.arrayBuffer();
const b64 = await arrayBufferToBase64(buf);

// ブラウザでBase64をバイト列にデコードする

// ▸ Base64 → Uint8Array (binary bytes)
function base64ToBytes(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

// ▸ Base64 → Blob → downloadable
function base64ToBlob(b64, mime = 'application/octet-stream') {
  const bytes = base64ToBytes(b64);
  return new Blob([bytes], { type: mime });
}

// Trigger a download
const blob = base64ToBlob(b64, 'image/png');
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.png' }).click();
URL.revokeObjectURL(url);

// JavaScriptでのURLセーフBase64(base64url)

標準のBase64は +/ を使いますが、どちらもURLで予約された文字です。また、末尾の1〜2個の = はパーセントエンコードされて %3D となり、スペースを浪費します。RFC 4648 §5はURLセーフなバリアント(base64url)を定義しています:+- に、/_ に入れ替え、パディングを削除します。

Node 16+では Buffer.from(x).toString('base64url') を使うだけです。ブラウザではbtoa()の上の後処理ステップとして実装します。

// ▸ Browser: standard Base64 → URL-safe Base64
function toBase64Url(b64) {
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// ▸ URL-safe Base64 → standard Base64 (before feeding atob)
function fromBase64Url(b64u) {
  let b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
  while (b64.length % 4) b64 += '=';   // re-add padding
  return b64;
}

// Round-trip
const token = toBase64Url(btoa('hello+world/'));
// 'aGVsbG8rd29ybGQv'
atob(fromBase64Url(token));  // 'hello+world/'

// よくある落とし穴とそれを見つける方法

  • > btoa()からのInvalidCharacterError — 入力がLatin-1の範囲外の文字を含んでいます。修正:まず TextEncoder でUTF-8にエンコードする。
  • > atob()からのInvalidCharacterError — 入力が有効なBase64ではありません。通常はパディング不足(base64urlの場合)か、空白文字の混入です。空白を削除し、= パディングを追加してください。
  • > デコード時のUnicodeの文字化けTextDecoder を通さず、atob() で直接デコードしています。UTF-8のマルチバイト列が文字化け(mojibake)として表示されます。
  • > 「Maximum call stack size exceeded」 — 約65,000バイトを超える配列に String.fromCharCode(...bytes) を使いました。修正:チャンク化してください(上の32 KBループを参照)。
  • > ブラウザとNodeで出力が異なる — 通常、片方がURLセーフでもう片方が標準です。境界でどちらかのバリアントに正規化してください。
  • > 二重エンコードされたペイロード — データが2回Base64化されています。チェック:atob(input) の結果がさらにBase64に見えますか?ならもう1回デコードしてください。
  • > JSON内のBase64が誤ってJSONエンコードされた — Base64文字列をJSON.stringifyでラップしたところ、受信側がJSONをパースして文字列をそのまま取得しますが、サーバーコードが誤ってそれを再エンコードしていました。ワイヤー上のペイロードを確認してください。

// クロスランタイムヘルパー(ブラウザ + Node + Deno + Bun)

// A portable Base64/base64url helper. Uses Buffer when available,
// falls back to btoa/atob + TextEncoder in the browser / Deno.

const hasBuffer = typeof Buffer !== 'undefined';

export function encode(str, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(str, 'utf8').toString(urlSafe ? 'base64url' : 'base64');
  }
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64 = btoa(bin);
  return urlSafe
    ? b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
    : b64;
}

export function decode(b64, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(b64, urlSafe ? 'base64url' : 'base64').toString('utf8');
  }
  if (urlSafe) {
    b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
    while (b64.length % 4) b64 += '=';
  }
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

// Usage
encode('héllo 世界');                      // 'aMOpbGxvIOS4lueVjA=='
encode('héllo 世界', { urlSafe: true });    // 'aMOpbGxvIOS4lueVjA'
decode('aMOpbGxvIOS4lueVjA==');            // 'héllo 世界'

// パフォーマンスに関するメモ

  • > Bufferが最速 — NodeではC++で動作し、メガバイトサイズの入力でも問題なく処理します。
  • > btoa/atobも小さなペイロード(< 100 KB)では競争力あり。ブラウザで両方とも同期的で、大きな入力ではメインスレッドをブロックします。
  • > ブラウザで10 MB超の入力には、Workerを使ってエンコードをメインスレッドから切り離してください。あるいは内部的に最適化された Blob.text() + FileReader.readAsDataURL() を使います。
  • > エンコード/デコードの繰り返しを避ける — 各ラウンドトリップは時間とメモリの両方でO(n)です。複数回使う場合はBase64形式をキャッシュしましょう。
  • > ストリーミング — 本当に大きなペイロード(100 MB+)には、Nodeの stream.Transform やブラウザの TransformStream APIをチャンク単位のBase64エンコーダーと組み合わせてください。

// 関連する読み物

Base64とは何か、どのように動作するのか? — 6ビットグルーピングの仕組みをゼロから解説。
UTF-8とUnicodeのためのBase64 — TextEncoderの作法を詳しく。
URLセーフ vs 標準Base64+/ ではなく -_ が必要なとき。
ライブのBase64エンコーダーを試す — テキストを貼り付け、エンコーディングを見て、結果をコピー。

このページは、btoa() に手を伸ばすことがあればブックマークしてください。10回中9回、あなたがこれからGoogleしようとしている質問への答えがここにあります。