[JS] JavaScriptでのBase64 — atob、btoa、Node Buffer
JavaScriptでのBase64の実践ガイド。btoa/atobをいつ使うべきか、なぜUnicodeで壊れるか、Node.jsでBufferがどう動くか、そしてクロスランタイムなbase64urlヘルパーを紹介します。
// 全体像
JavaScriptとBase64の歴史は断片的です。ブラウザは1990年代のNetscapeから btoa() / atob() を継承しました。これらの名前は文字通り binary-to-ASCII と ASCII-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やブラウザのTransformStreamAPIをチャンク単位のBase64エンコーダーと組み合わせてください。
// 関連する読み物
• Base64とは何か、どのように動作するのか? — 6ビットグルーピングの仕組みをゼロから解説。
• UTF-8とUnicodeのためのBase64 — TextEncoderの作法を詳しく。
• URLセーフ vs 標準Base64 — +/ ではなく -_ が必要なとき。
• ライブのBase64エンコーダーを試す — テキストを貼り付け、エンコーディングを見て、結果をコピー。
このページは、btoa() に手を伸ばすことがあればブックマークしてください。10回中9回、あなたがこれからGoogleしようとしている質問への答えがここにあります。