JavaScript/Browser での Base64 デコード:完全ガイド
Base64 は 12 通りもの異なる仮面を被って現れます:Authorization ヘッダーにしのばされた JWT、JSON レスポンスの中の image/png blob、ハンドシェイクログにある Sec-WebSocket-Accept の値、MIME で包まれたメールの添付ファイル、バックエンドが丁寧な顔でクエリ文字列に詰め込んだ値。文字列そのものはいつも同じ顔です:アルファベットと数字が長く連なり、ときどき + や / が挟まり、末尾に = がひとつふたつ付いている。このサイトのホームページで Base64 が何であるか - 表示できる 4 文字がバイト 3 バイトに代わり、最後のグループは = パディングで締める - を学んだなら、この記事はコードで実際に手を動かす部分についてです。その文字をバイトに戻し、バイトを意味に戻す作業。使うのは、ブラウザが最初から備えているものだけです。
始める前に、さっそく基本ルールを 2 つ。第一に、デコードは縮む方向です:4 文字読むたびに 3 バイトが出てくるので、出力は常に入力より少ないメモリで済みます。第二に、デコードされた Base64 文字列が、自動的にテキストになるわけではありません。それはバイトであり、そのバイトは UTF-8 でも、Windows-1252 でも、PNG ヘッダーでも、暗号署名でもあり得ます。Base64 コードで最も多い 1 つのバグは、自分が今どのバイトを手にしているのかを忘れることで、下記の各セクションは、まさにその問いを中心に組まれています。
デコードの 3 つのレイヤー
モダンなブラウザは 3 つのネイティブなレイヤーを与えてくれます。良いニュースは、パッケージが絶対に不要だということです。それぞれが少し異なる問いに答え、正しいものを選べば、Stack Overflow からコピペしてきたスニペットの山から解放されます:
| レイヤー | 何を食うものか | 何を手渡すか | 性格 | 使える環境 |
|---|---|---|---|---|
atob() |
標準 Base64 文字列 | 「バイナリ文字列」(1 文字 = 1 バイト) | かなり寛大:ASCII 空白をスキップし、足りないパディングも許す | 2000 年代以降のすべてのブラウザ、IE 10 以降、Node 16 以降 |
TextDecoder |
バイト(Uint8Array) |
読める JavaScript のテキスト | 設定可能:文字コードのラベルと、厳格さの fatal フラグ |
Firefox 18、Chrome 38、Safari 10.1 以降(IE には一切なし) |
Uint8Array.fromBase64() |
Base64 文字列とオプション | 本物の Uint8Array |
厳格だがダイヤル付き:アルファベットと最後のチャンクの扱い | Baseline 2025: Chrome 140、Firefox 133、Safari 18.2、Node 25 |
この記事全体の形は、その表から導かれます。 atob() は、古いコードを含めてどこにでも現れる主力です。 TextDecoder はバイトから言葉への橋です。そして Uint8Array.fromBase64() は、最初から欲しかったのがバイトだけなら、中間の手順をまるごと飛ばしてくれる 2025 年のアップグレードです。
atob:速くて、寛大で、すごく古い
契約全体が 1 行に収まります:atob(encodedData)。Base64 でエンコードされた文字列を受け取り、「バイナリ文字列」を返します。これは通常の JavaScript の文字列で、各文字がちょうど 1 つのデコード済みバイト、0 から 255 までのコードポイントを保持します。この戻り型が重要なのは、それが読めるテキストとは同じものではないからです(詳しくは後述)。関数自体は最速級で、しかもかなり前からいます:Chrome 4、Firefox 1、Safari 3、そして - 大半の人が覚えているのがこれ - Internet Explorer はバージョン 10 以降のみに、というわけです。だから 2012 年以前に書かれたコードには、手作りの Base64 テーブルが溢れています。
atob() を好ましくしているのは、諦めるまでどれほど許してくれるかということです。WHATWG の HTML 標準は、デコード前にすべての ASCII 空白 - スペース、タブ、改行、フォームフィード、キャリッジリターン - を無視すると定めています。そのため、76 文字ごとに改行された MIME 折り返しの文字列も、あなたのクリーンアップなしでデコードできます。足りないパディングも許されます。しかし、アルファベットの外の文字が見えたり、あり得ない長さが来たりした瞬間、DOMException の一種 InvalidCharacterError を投げます。黙ってゴミを返すことも、部分的な結果を渡すこともありません。
これが被害レポートです。一行ずつ確認しましょう:
| 入力 | 結果 |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - 教科書通りのケース |
"aGVsbG8"(パディングなし) |
"hello" - 足りない = は許される |
"SGVs\nbG8s\nIFdvcmxkIQ=="(折り返された行) |
"Hello, World!" - ASCII 空白は先にスキップされる |
""(空文字列) |
"" - 空の入力は有効で、往復もきれいにできる |
"A"(残った 1 文字) |
InvalidCharacterError を投げる - 1 文字では何もエンコードできない |
"Zm9vYmFy!"(迷い込んだ !) |
InvalidCharacterError を投げる - アルファベットの外 |
"ZGFua29nYWk-"(URL 安全な文字が混入) |
InvalidCharacterError を投げる - 2 つのアルファベットは混ぜてはいけない |
"Zm9v===="(パディング過多) |
InvalidCharacterError を投げる - 末尾の = は最大 2 つ |
実践的な注意点ひとつ:エラーメッセージそのものはエンジンごとに異なります(Firefox は「String contains an invalid character」と言い、Chrome は非 Latin1 の入力に対して文字列が「contains characters outside of the Latin1 range」といい、無効な base64 に対しては「is not correctly encoded」といいます)。そのため、メッセージのテキストではなく、例外の名前でキャッチしてください。
生のバイトから本物のテキストへ
その「バイナリ文字列」という戻り型には、一度立ち止まる価値があります。デコードの混乱の大半は、そこから生まれるからです。JavaScript の文字列は UTF-16 なので、atob() が手渡す文字列の各文字は、読めるグリフではなく、バイト値です。ペイロードが「hello 你好」というテキストの UTF-8 エンコードだった場合、結果をそのまま表示するともじばけになります。修正は 2 ステップのデコードです:まず Base64 からバイトへ、それからバイトからテキストへ。
まず、Base64 からバイトへのステップ。この小さなヘルパーは定番のレシピで、この記事のほとんどの例の要となる部分なので、懐に入れておく価値があります:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
次に、TextDecoder を使ったバイトからテキストへのステップ。UTF-8 の場合(デフォルトで、JSON や JWT のペイロード、Web のデータの大半には正解)は、呼び出しは 1 行です:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
なぜ 2 ステップも必要なのか?atob() は、そのバイトがどの文字コードで生成されたのかを一切知らないからです。純粋なビット変換器です。TextDecoder がバイトを文字コードとして解釈する部品で、その仕事にラベルを受け取ります:utf-8、windows-1252、iso-8859-1、utf-16le、ほかに 220 種類ほどのラベル。1990 年代のアプリケーションから出てきたデータはたいてい Windows-1252 で、コンストラクタの引数を 1 つ足すだけです:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // 同じバイト、解釈が異なる
TextDecoder のコンストラクタは fatal フラグも受け取ります。デコードしたテキストが重要な何かに渡されるたびに、true に設定する価値があります。デフォルトのデコーダーは寛容です:無効なバイト列は静かに Unicode の置換文字 U+FFFD に置き換えられ、何も知らされません。fatal: true にすれば、同じ破損は隠れる代わりに TypeError を投げます:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
これはドキュメントでは取るに足らなさそうに見えて、プロダクションではデータ事故として姿を変える、そうしたスイッチの 1 つです。入力がユーザーから来てもネットワークから来ても、厳格にデコードし、エラーも意図的に扱ってください。
URL 安全な入力は回り道が必要
Base64 のあるバリアントは、専用のセクションに値します。実世界で絶えず現れ、しかも atob() が読めないからです。それが RFC 4648 の 5 節にある、URL とファイル名で安全なアルファベットで、たいてい base64url と呼ばれます。同じ 64 文字ですが、+ と / が - と _ に置き換わり、データの長さは暗黙にわかっているので = パディングはよく落とされます。この交換には具体的な理由があります:URL では + はスペースを意味し、/ はパスセグメントの始まりなので、標準アルファベットのままだと 1 文字ずつパーセントエンコードしなくてはなりません。Base64url は、クエリ文字列、パスセグメント、フラグメント、ファイル名の中で、きれいに旅をします。
ただし、2 つのアルファベットは互換ではなく、atob() が話せるのは標準の方だけです。- や _ を渡すと InvalidCharacterError になります。きれいな選択肢は 2 つあります。
オプション 1:どこでも動きます。アルファベットを変換し、パディングを復元してから atob() を呼びます:
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
(4 - (s.length % 4)) % 4 という式が全部のトリックです:その長さの、正しくパディングされた文字列に必要な = の数を 0 から 2 まで計算します。
オプション 2: 2025 年以降のブラウザなら。新しいネイティブなデコーダーはアルファベットをオプションで受け取るため、文字列の手術はまったく不要です:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
トラブルを避けるためのルールは 2 つです。1 つの値の中でアルファベットを混ぜないこと - + も - も見るデコーダーには、どちらの系統を読んでいるのか知る術がなく、仕様に正しい挙動は失敗することです。そして、パディングがあるかどうかを、通信の相手側と取り決めましょう:落とすことは base64url では合法なので、受信側は両方の形に備えなければなりません。atob() はもう備えています。下のネイティブなオプションは、そのためのダイヤルをくれます。
2025 年の近道:Uint8Array.fromBase64
base64ToBytes ヘルパーを振り返れば、それが 2 つのことをしているのに気づきます:Base64 をデコードし、それから JavaScript で文字を 1 文字ずつバイト配列にコピーする。そのコピーのループこそが遅く、避けられる部分で、新しい ECMAScript のメソッドが取り除くのはまさにそこです。Uint8Array.fromBase64(string, options) は、エンコードされた文字列からバイト配列へ直行します。Chrome 140、Edge 140、Firefox 133、Safari 18.2、Node 25、Deno 2.5 に搭載されており、ブラウザ各社の Baseline プログラムで Baseline Newly available とマークされた、同種の JavaScript プラットフォーム機能としては初となるものです。
オプションオブジェクトには 2 つのダイヤルがあります。1 つ目は alphabet:"base64"(デフォルト)か "base64url"。2 つ目は lastChunkHandling で、文字の最後の部分的なグループに何が起きるかを決めます:
| モード | 最後のチャンクのルール |
|---|---|
"loose"(デフォルト) |
2 つか 3 つの文字、またはパディング付きの 4 つ;余ったオーバーフローのビットは無視される |
"strict" |
ちょうど 4 文字(長さが求める場合だけパディング)、かつオーバーフローのビットはすべて 0 でなければならない |
"stop-before-partial" |
完全な 4 文字グループだけがデコードされる;部分的な末尾は読まれないまま |
atob() と同様に、このメソッドは入力の中の ASCII 空白を無視するので、折り返された行も問題ありません。atob() と異なるのは、それ以外のすべてに意見を持っている点です:選択したアルファベットの外の文字、あるいは選択したモードに違反する最後のチャンクは SyntaxError を投げ、文字列でないものを渡すと TypeError を投げます。これが strict モードの実演で、パディングが足りないチャンクを拒否しています:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
パフォーマンスも、それを優先するもう一つの理由です。著者のマシンの最新 Firefox では、10 メガバイトのペイロードのデコードは fromBase64 で 1 桁ミリ秒ですが、定番の atob に文字ごとのバイト変換をくわえると約 20 倍かかります。遅いのは Base64 の計算ではなく、JavaScript レベルのループだからです。データがバイトなら、文字列ごとスキップしましょう。
古いブラウザでは、状況はシンプルです:上記の base64ToBytes ヘルパーを残すか、新スタイルのコードをどこでも書きたいなら小さなポリフィルを取り込みます(core-js と es-shims プロジェクトの es-arraybuffer-base64 パッケージは、どちらも fromBase64 用のものを用意しています)。この API は安定しています - すでに ECMAScript の仕様に載っている - なので、それに向けて書いたものは非推奨になることはありません。
JWT を読む
アプリケーションログで最も多い「謎の文字列」は JSON Web Token です:ドットで区切られた 3 つのセグメント、header.payload.signature で、最初の 2 つは base64url でエンコードされた JSON オブジェクトです。1 つをデコードするのは 5 行の仕事で、これまでに扱ったすべてのための完璧なウォーミングアップになります:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
ここから、初心者は飛ばして、本番システムが身をもって学ぶ部分です:ペイロードはデコードできたからといって検証されたわけではありません。誰でも好きなペイロードの JWT を書けます。シークレットに結びつけているのは署名セグメントです。ブラウザで HS256 トークンを検証するには Web Crypto API を使いますが、そこでは署名をバイトとして必要とします - セグメントからバイトへのヘルパーが役目を果たす、もう一つの理由です:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // 署名がシークレットと一致した場合にのみ true
名前を挙げる価値のある落とし穴が 3 つあります。第一に、検証前にヘッダーをチェックすること:alg: "none" と主張するトークンは、署名なしでペイロードを信頼するようあなたに言い、素朴なコードはまさにそれに乗せられてきました。第二に、時間クレーム - exp、nbf、iat - を尊重するのは検証の後で、前ではありません。第三に、古典的なキー混同攻撃:RS256 向けに設定しながらも HS256 を受け入れたりするサーバーがあると、攻撃者は公開鍵(意図的に公開されているもの)を HMAC のシークレットとして使ってトークンに署名できます。要するに:デコードは自由に、何も信頼せず、すべてを検証せよ。
data URL を開く
data URL は URL の中に丸ごとファイルを埋め込みます:data:、任意のメディアタイプ、任意の ;base64 フラグ、カンマ、それからペイロード。テキストのペイロードはパーセントエンコード、バイナリのペイロードは Base64 で、ブラウザは HTTP リクエストなしでレンダリングします - fetch もなければ、サーバーへの往復もなければ、キャッシュに置くものもありません。ブラウザは各 data URL を独自の不透明なオリジンとして扱い、そのためこっそり中身を忍ばせる内容の大好物でもあります:iframe で開いた data:text/html ドキュメントはスクリプトを実行し、厳しめの Content-Security-Policy は data URL を完全にブロックできます。これらのようなものをユーザーが制御するマークアップに渡すようになるなら、CSP を気に留めておいてください。
1 つをデコードするのは、大半が文字列の手術で、その後はいつものバイトのパイプラインです:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
meta の部分がメディアタイプを知らせてくれます(ここでは image/png で、;base64 マーカーがペイロードが Base64 であることを確認)。ペイロードが Blob になれば、すべてが通常通りになります:<img> 用のオブジェクト URL、ダウンロードリンク、サーバーへの POST。data URL の経路に実コストがあるならそれはサイズだけで - ペイロードは元のファイルより約 33% 大きい - URL 中の大きな画像はページの文字列制限に負担をかけ得るので、ファイルがブラウザを出る必要がないなら、これはオブジェクト URL にもう一票を投じる理由になります。
テキストとして届くファイルをデコードする
ファイルは 2 つの経路でブラウザに届きます。モダンな経路は生のバイトです:ArrayBuffer として読む fetch、または file.arrayBuffer() で読むピッカーからの File。その経路にいるなら、おめでとう - 絵の中で Base64 は一切登場しません。その経路にとどまるべきです。バイトは運ぶのに何もかからず、Base64 はその代わりに余分に 3 分の 1 の帯域とメモリを払うからです。もう一つの経路は、チャネルがテキストのみのときです:{"attachment": "data:application/pdf;base64,JVBERi..."} を返す JSON API、メールの添付ファイル、設定文字列、データベースの列にある値。そのとき Base64 がプロトコルになり、あなたの仕事はバイトを取り出すだけです:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
そのスニペットへの注釈が 3 つあります。最初のカンマで分割すれば、data URL ヘッダーが剥がれます(メディアタイプにカンマは含まれないため、最初のカンマが常に区切りです)。値が data URL プレフィックスのないプレーンな Base64 なら、分割はスキップしてください。最後に、あの素の await はトップレベルのものです。ブラウザがそれを許すのはモジュールの中だけなので、このスニペットには <script type="module"> タグか、その 2 行を包む非同期ラッパーが必要です。メールの MIME パートは、手順が少し増えた同じ物語です:添付ファイルの本体は 1 行 76 文字で折り返された Base64 ですが、atob() は空白をスキップするので、生のメッセージに届いたままの折り返し済みテキストをそのまま渡せます - 戻す必要はありません。その 1 つの挙動が、静かにたくさんの正規表現を節約してくれます。
WebSocket ハンドシェイクを検証する
ブラウザでのデコードの、より魅力的な用途のひとつは、WebSocket ハンドシェイクそのものをチェックすることです。RFC 6455 では、クライアントが Sec-WebSocket-Key ヘッダー(ランダムな 16 バイトの Base64 エンコード)を送ること、サーバーが Sec-WebSocket-Accept で答えることが要求されます:キーに固定のマジック GUID を連結したものの SHA-1 ハッシュを、Base64 でエンコードしたもの。値が一致しなければ、ハンドシェイクは失敗し、接続はアップグレードされません。この一連の儀式の肝は、HTTP しか話さないサーバーがうっかりそれを完成させてしまえないようにすることです - マジック GUID の存在意義は、計算がわざと大げさに見えるようにすることです。そして、ハッシュもエンコーディングもブラウザに揃っているので、期待される答えを自分で計算できます。プロキシやゲートウェイのデバッグが 1 行で済むわけです:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
最後の行は偶然ではありません - これは RFC の例そのもので、バイト単位まで忠実に再現したものです。あなたのゲートウェイがそれ以外で答えたとき、方程式のどの側が嘘をついているのかが、正確にわかるようになりました。
HTTP ヘッダーとクエリ文字列
Base64 は HTTP ヘッダーの定番です。ヘッダーは ASCII でなければならないためで、最も有名なケースは Basic 認証です:Authorization: Basic の後に、username:password の Base64 エンコードが続きます。そんなヘッダーを読むとき(リクエストが何を持っているかを表示している場合など)は、分割 1 回とデコード 1 回です:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
スプレッドして再結合するこのパターンは、コロンを含むパスワードという厄介だが合法的なケースに対応します。分割点は常にユーザー名の後ろの最初のコロンだからです。同じパターンは、ヘッダーが構造化された値を密輸しているあらゆる場所に適用できます:Proxy-Authorization、いくつかのベンダー固有のヘッダー、ときどきのクッキー。クエリ文字列やディープリンクでは、アプリがサーバーなしで状態を共有したいときに Base64 が現れます:OAuth の state 値、復元された検索フォーム、「続きから再開」マーカー。防衛的にデコードしてください - try/catch で包んで。その値はネットワークの境界を越えてきたので、何が起きたかわからないからです - そして、手に入れたものを信頼できない入力として扱う。それまでです。
これで見えてくるのは、すべての端末の上に貼っておくべき一文です:Base64 は暗号化ではありません。どんな真の意味での難読化ですらありません。「復号」は地球上のすべての言語が実装する関数呼び出し 1 回だからです。値を秘密に保つ必要があるなら、先に Base64 でエンコードするのは安全を高めるのではなく、むしろ下げます - プライバシーの錯覚を作り、オリジナルを欲する者にとって自明のステップをちょうど 1 つ増やすだけです。
URL とストレージの状態
同じ論理は、ページの再読み込みや共有リンクで生き延びなければならないあらゆるものに及びます。いつもの容疑者たち:構造化データやバイナリデータを持つ localStorage と sessionStorage の値、シングルページアプリのルーティング状態用の URL のハッシュフラグメント、ビルドツールがページに埋め込む設定 blob。ストレージの物語には具体例 1 つが値します。読む側は、覚えておきたい書く側と対になるからです:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
心に留めておくべきことが 3 つあります。第一に、予算:ブラウザは各オリジンに localStorage としてだいたい 5 メガバイトを与え、保存された Base64 文字列は元のデータより約 33% 多く食うので、3.5 メガバイトのファイルは静かに 4.6 メガバイトのストレージになります - そして、その文字列はページが開かれている間、メモリの中で UTF-16 として生き、フットプリントをさらに倍にします。第二に、一貫性:両側で同じ文字コードでエンコードしデコードしてください。さもなくば、完璧なバイトを保存しておいて、読み出すともじばけになります。第三に、共有リンク:状態が URL で運ばれるなら、値がコピー & ペーストを生き延びられるよう URL 安全なアルファベットを使って、短く保ってください。2,000 字を超える URL 長は、古いクライアントやログ記録ツールを不安にさせ始めるからです。
データが断片で届くとき
ときどき、Base64 は 1 つの文字列として届きません:WebSocket メッセージの境界がそれを半分に切り、サーバーシークエンスイベントのストリームがそれを滴り落とすように送り、チャンク化されたアップロードが数 KB ずつ供給する。atob() を断片に呼び出すことはできません。Base64 のグループは 3 バイト単位で 4 文字ブロックで表現されるため、グループの真ん中で切ると、つり上がった不完全な部分が残るからです。旧来の修正は、4 の倍数になるまで文字をバッファリングし、バッファをスライスごとにデコードすることでした。2025 年の API はこれをきれいにします:Uint8Array.prototype.setFromBase64(string, options) はデコードされたバイトを既存の配列に書き込み、2 つの数字を持つオブジェクトを返します:read(消費した文字数)と written(生成したバイト数)。lastChunkHandling: "stop-before-partial" なら、完全なグループだけをデコードし、不完全な末尾は読まれないまま残ります。ストリームデコーダーが望むのは、まさにその挙動です:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
内側のループはゆっくり読みましょう。これがパターン全体だからです:引き継いだ残りと新しい断片を投入し、デコーダーが収まるだけ完全なグループを消費させ、result.read 文字を切り取ってどれだけ残ったかを覚えておき、完全なものがもう何も残らないとき(result.read === 0)に、残りを新しい carry として退けて次の断片を待つ。Uint8Array(8) はただのスクラッチバッファです - 4 文字の 1 グループは最大 3 バイトしか生成しないので、8 は余裕を持っています。最後に、carry にはストリームが決して終わらせられなかったものが残りますが、それはエラーのシグナルか、「接続がきれいに終わった」チェックのどちらかになります。
Base64 をデコードしないとき
役立つ参考書は、いつツールを置けばよいかを教えてくれます。チャネルの両端を自分が制御できるなら、代わりに生のバイトを取りましょう:ダウンロードには response.arrayBuffer() を付けた fetch、ピッカーのファイルには file.arrayBuffer()、WebSocket には ArrayBuffer のペイロード、アップロードにはマルチパートの FormData。そのどれも Base64 に触れず、サイズ税もなければメモリ内の文字列のフットプリントもなくなり、データは全速力で手に入ります。Base64 がその価値を発揮するのは、まさにチャネルがテキストのみのときです:JSON の本体、クエリ文字列、メール、ストレージ、レガシーな API、そして契約が「ASCII でなければダメ」と言っているもの。1 つのバイトで足りる瞬間に、Base64 文字列は表示可能である特権に対して 33% の割増料金を払っており、その割増は帯域、メモリ、CPU の 3 つの請求書として徴収されます - すべて避けられる請求書です。
よくあるデコードの落とし穴
すべての快調な経路のあとに、これが咬む方法の一覧です。だいたいは出会う順番どおりです:
atob()の結果をテキストとして扱うこと。それはバイナリ文字列です。TextDecoderを通せばテキストになりますが、そのまま表示するともじばけになります。この 1 つの誤解が、「Base64 が動かない」報告の大半を引き起こします。- Unicode が勝手に動くと思い込むこと。「你好」のバイトは喜んでデコードされますが、デコーダーがそれが UTF-8 だと教えてくれるまで、それはただのバイトです。両側で同じ文字コードでエンコードしデコードしてください。
- base64url を
atob()に食べさせること。-か_が 1 つあれば例外が投げられます。先にアルファベットを変換するか、正しいオプション付きのfromBase64を使ってください。 - 長い文字列なら何でも Base64 だと信じること。有効なパディング付き Base64 文字列は長さが 4 の倍数で、使うのは最大 1 つのアルファベット(パディングなしの base64url は 2 または 3 で終わる)。4 で割って 1 余る長さは即座の失敗です - try/catch を使う前に、それをチェックしてください。
- 取り決めていないパディングを信頼すること。いくつかのシステムは
=を剥ぎ、いくつかは残し、いくつかは折り返された文字列の真ん中にあるべきでないところに足します。送信者と取り決め、それから寛容か(atob)厳格か(fromBase64)を決めてください。 - 寛容なデコーダーによる無音の破損。デフォルトの
TextDecoderは無効なバイトを U+FFFD に置き換え、何も言いません。データが大事ならfatal: trueに設定してください。 - Base64 が何かを守ると思い込むこと。そうではありません。それは直列化形式で、プレーンテキストから関数呼び出し 1 分の距離にあり、「ユーザーに見せないように Base64 している」はセキュリティ上の姿勢であって、コントロールではありません。
- メモリを忘れること。デコードされた 1 メガバイトのバイナリ文字列は UTF-16 文字列として 2 メガバイトを占有しますが、同じデータの
Uint8Arrayは 1 メガバイトです。大きなペイロードなら、fromBase64に直行してください。 - レンダリングのたびに再デコードすること。数メガバイトのデコードは速いが、無料でありません - そして 1 フレームごとにやるべきものではありません。1 回デコードし、バイトをキャッシュし、キャッシュからレンダリングしてください。
パフォーマンスのメモ
短縮版:ネイティブなデコーダーは速く、古いコードの遅い部分はたいてい Base64 自体ではなく、その周りの JavaScript です。大事なサイズでなら、絵は同じです:10 メガバイトのペイロードは Uint8Array.fromBase64 で 1 桁ミリ秒でデコードされます。atob だけでも数倍遅く、文字をバイト配列にマップする定番の後続ループは同じ入力に対して fromBase64 より約 20 倍かかります。メインスレッドで約 1,300 万回のプロパティ書き込みを行うからです。実践的な帰結:読者が持っている環境では fromBase64 を優先し、持っていない環境では atob ヘルパーを残し、ループの中で文字列を連結してバイト配列を作ることは絶対にやめ、巨大なペイロードを処理しなければならなくなったら、デコードされた Uint8Array を Web Worker に渡すことを検討してください - バイトはコピーなしで転送され、メインスレッドは UI を毎秒 60 フレームに保つために自由のままです。そして、計算の方向性を覚えておいてください:デコードは縮むので、デコードされたバッファは、それが来た文字列よりも常に少ないメモリを使います。デコードすることでメモリ不足になることは決してありません。文字列とバイトの両方を必要以上に長くとっておいたときに、はじめてメモリ不足になるのです。
ブラウザでのデコードの短い歴史
Base64 は、モダンな Web の大部分より古いです。しかしブラウザのデコーダーには知る価値のある物語があります。エコシステムが遺物で溢れている理由を説明してくれるからです。atob とその兄弟 btoa は、今それらをカバーしている仕様のより前にいます:WHATWG の HTML 標準がそれらを定義したのは 2011 年 2 月で、その頃までの定着したブラウザの挙動が逆解析されて標準へ取り込まれたときです。エンジンたちはいずれも早い段階でそれらを搭載していました:Firefox は 2004 年のバージョン 1 から、Safari 3、Chrome 4。Internet Explorer は 2012 年の IE 10 までそれらを完全にスキップしたので、2012 年以前の JavaScript は手作りの Base64 の博物館です - 参照テーブル、String.fromCharCode の体操、そして悪名高き Unicode 用の unescape(encodeURIComponent()) の呪文。この関数の組は言語で非推奨とされたのに、慣性の法則だけでブラウザで 10 年間生き延びました。それからやってきたのは文字コードレイヤーです:エンコーディング標準の TextEncoder と TextDecoder は 2013 年から 2017 年の間に到着し(Firefox 18、Chrome 38、Safari 10.1、そしてあらゆる IE には一切なし)、ついにプラットフォームに、バイトを言葉に変える原理のある方法を与えました。atob や btoa をグローバルとして持ったことがなかった Node.js は、2021 年のバージョン 16 まで、Buffer と小さな npm シムの 2 つで前の人生を送っていました。そしてループは閉じました:Firefox 133(2024 年 11 月)と Safari 18.2(2024 年 12 月)が最初に Uint8Array.fromBase64、toBase64 と仲間たちをリリースし、2025 年後半に Chrome 140(9 月)と Node 25(10 月中旬)が到着してセットが完成し、Baseline プログラムはそれらを Newly available とマークしました。これは初めて、言語そのものが - Web プラットフォームではなく - Base64 を内蔵した瞬間です。何十年も前の形式が、まさに言語の標準ライブラリ機能になったばかりで、次の 10 年のコードはヘルパーをコピーし回すことから解放されます。
おもしろい事実
- 存在する中で最も速い「これ、そもそも Base64 なのか?」テストは
string.length % 4 === 0です。有効なパディング付き Base64 文字列はすべて通ります。それ以外は見知らぬ者です。 atob('')は''を返します。空文字列はバイトを 1 つも持たない唯一の入力で、パイプライン全体をきれいに往復します - 特別なケース処理は、永遠に不要です。- WebSocket のマジック GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11は、RFC に焼き込まれた固定値で、素の HTTP サーバーがうっかりハンドシェイクを完成させてしまえないように選ばれました。プロトコル工学で、誰にも生成されないのに最も有名な定数です。 - Chrome と Firefox は同じ失敗に対して同じ例外を投げますが、メッセージは異なります。
error.nameでキャッチしてください。メッセージ文字列ではキャッチしないでください。さもなくば、あなたのエラーハンドリングにブラウザのアクセントがつきます。 - 1 メガバイトのバイナリ文字列はメモリ上で 2 メガバイトの重さです。JavaScript の文字列は UTF-16 だからで、デコードされた各バイトは、使われていない余白 1 バイトを同伴しています。
Uint8Arrayにはそんな税はありません。 - 「Data URI」は引退した名前です。WHATWG が、URI から URL への名称の大統一の一環として「data URL」に改名したため、仕様、投稿、パッケージ名で両方の綴りに出会います。
- RFC 4648 にはテストベクトルの表が付いてきます -「f」、「fo」、「foo」、「foob」、「fooba」、「foobar」と仲間たち、それぞれに既知のエンコーディ付き - で、ディコーダーの著者は 20 年間にわたりこれらと照合してきました。あなたのディコーダーがその各行をパスするなら、ほぼ確実に正しいです。
- コンピューティングの歴史の中で最も生産された Base64 文字列は、ほぼ確実に
aGVsbG8=、つまり「hello」のエンコードです。世界中の「はじめて」チュートリアル、テストスイート、Stack Overflow の回答が、それぞれ 1 票を投じています。
まとめ
つまり、ブラウザでのデコードという技芸全体が 1 ページに収まります:素早く、寛大で、どこでも使えるデコードには atob();欲しい言葉そのものへバイトを変換するには TextDecoder(データが大事なら fatal: true 付き);文字列をまるごと飛ばす、モダンで厳格で速い経路には Uint8Array.fromBase64。そのあいだには、バリアントたちに名前とルールがあります:URL で旅するものには base64url、あってもなくてもよいパディング、古いデコーダーがこっそり食べちゃう空白。そしてそのすべてのはかに、2 つの態度があります:バイトはテキストではなく、テキストは秘密ではない。意図してデコードし、信頼する前に検証し、チャネルが許すなら Base64 をスキップして、バイトを取りなさい。
旅のもう半分 - あなたのバイトとテキストを取り、これらすべての始まりとなった表示できる文字列へ変換すること - は、JavaScript での Base64 エンコードのコンパニオンガイド(下記にリンク)で詳しく扱っています。
最終更新: 2026-10-09