2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Base64エンコード/デコードをブラウザで実装する — 日本語で崩れるbtoa/atobをTextEncoderで直す

2
Last updated at Posted at 2026-08-27

3行まとめ

  • ブラウザ標準の btoa("日本語") は例外を投げる。btoa は各文字を0〜255の1バイトとみなすバイナリ文字列専用で、UTF-16のマルチバイト文字を渡せないため
  • 正しくは「文字列 →(TextEncoder)→ UTF-8バイト列 →(fromCharCode)→ バイナリ文字列 → btoa」の一段挟む。デコードはその逆をたどる
  • URLセーフBase64は +- /_・末尾 = 除去。デコード時は逆変換して length % 4 からパディングを復元する。日本語・絵文字対応のBase64ツールとして実装した

Base64は開発でしょっちゅう使う。データURIを作る、APIのレスポンスに埋まったBase64を覗く、JWTのペイロードをデコードして中身を確認する。ところがブラウザ標準の btoa に日本語を渡すと、いきなりこう言われる。

Uncaught DOMException: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range.

(投げられるのは nameInvalidCharacterErrorDOMException。ブラウザによって表示ラベルは異なる。)

「なんで日本語がBase64にできないの?」——これはBase64の問題ではなく、btoa という関数の設計が古いことに由来する。ぱんだツールズのBase64変換は、この落とし穴を踏まずに日本語・絵文字も正しく扱えるようにしたブラウザ完結ツール。

この記事では、なぜ btoa で日本語が壊れるのか、そしてTextEncoderを挟んでどう正しく直すかを、実装ベースで書く。

なぜ btoa で日本語が壊れるのか

btoa の名前は "binary to ASCII" の略。ここでいう "binary" は、**1文字が1バイト(0〜255)に収まる「バイナリ文字列」**を指す。JavaScriptに専用のバイト型が無かった時代、バイナリを文字列で表現していた名残のAPIだ。

だから btoa は、渡された文字列の各文字のコードを見て「0〜255(Latin1の範囲)に収まっているか」を確認する。"A"(65)はOK、"é"(U+00E9 = 233)もぎりぎりOK。だが "日" はUTF-16のコード単位が 0x65E5(26085)で、255をはるかに超える。コード単位が255を超える文字が来た時点で例外(InvalidCharacterError)を投げる、という仕組み。境界は「マルチバイトか」ではなく「Latin1の範囲を超えるか」なので、Ā(U+0100 = 256)以上はすべて弾かれる。

つまり btoa は「文字列」ではなく「バイト列を文字で表したもの」を食う関数。文字列をBase64にしたければ、先に自分でバイト列(=UTF-8エンコード)に変換して、それを1バイト1文字のバイナリ文字列にしてから渡す必要がある。

TextEncoderを挟んで正しくエンコードする

やることは3段階。文字列をUTF-8のバイト列にして、そのバイト列を1バイト=1文字のバイナリ文字列に組み直し、それを btoa に渡す。

function encodeBase64(text: string, urlSafe: boolean): string {
  const bytes = new TextEncoder().encode(text)          // ① UTF-8バイト列(Uint8Array)
  const binaryString = String.fromCharCode(...bytes)    // ② 各バイトを0〜255の文字に
  let encoded = btoa(binaryString)                      // ③ ここでようやくBase64
  if (urlSafe) {
    encoded = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  }
  return encoded
}
  • TextEncoder().encode(text) … 文字列をUTF-8のバイト列(Uint8Array)にする。"日"[0xE6, 0x97, 0xA5] の3バイトになる。ここで「文字」から「バイト」への変換が確定する
  • String.fromCharCode(...bytes) … そのバイト列を、1バイト=1文字のバイナリ文字列に組み直す。各要素は0〜255なので、btoa が受け付けられる形になる
  • btoa(binaryString) … 255以下の文字しか含まないので、今度こそ例外を出さずにBase64化できる

要は「btoa が求めるバイナリ文字列を、UTF-8で自前で用意してあげる」だけ。この一段を挟むことで、日本語でも絵文字でも中国語でも、どんな文字でもBase64にできる。

デコードは逆順にたどる

デコードは完全に逆の手順。atob でバイナリ文字列に戻し、そこから1文字ずつバイトを取り出して Uint8Array に詰め直し、TextDecoderでUTF-8として解釈する。

function decodeBase64(b64: string, urlSafe: boolean): string {
  let normalized = b64
  if (urlSafe) {
    normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
    const pad = normalized.length % 4
    if (pad === 2) normalized += '=='
    else if (pad === 3) normalized += '='
  }
  const binaryString = atob(normalized)                 // ① バイナリ文字列に戻す
  const bytes = new Uint8Array(binaryString.length)
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i)               // ② 1文字ずつバイトを取り出す
  }
  return new TextDecoder().decode(bytes)                // ③ UTF-8として復号
}

atob が返すのもバイナリ文字列(1文字=1バイト)なので、charCodeAt で各バイトの数値を取り出して Uint8Array に詰める。あとは TextDecoder がUTF-8として文字列に戻す。エンコードで挟んだTextEncoderの対称になっている。

URLセーフBase64とパディングの復元

通常のBase64は A-Z a-z 0-9 + / の64文字と、長さを4の倍数に揃えるパディング = を使う。だが + / = はURLの中で意味を持つ文字なので、クエリパラメータやJWTに埋め込むと壊れる。そこでURLセーフBase64では +-/_ に置換し、末尾の = を落とす。

エンコード側は上のコードの .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') の一行。問題はデコード側で、落とした = を復元しないと atob が正しく解釈できない。ここで使うのが length % 4

Base64は4文字ごとに3バイトを表すので、パディングを除いた長さを4で割った余りから、必要な = の数が逆算できる。

  • 余り 2 … 本来 == が付いていた → == を足す
  • 余り 3 … 本来 = が付いていた → = を足す
  • 余り 0 … パディング無しでちょうど4の倍数 → 何もしない

(余り 1 はBase64として不正な長さ)。- _+ / に戻し、パディングを復元してから atob に渡す。JWTのペイロードがURLセーフBase64なので、JWTデコーダでも同じパディング復元をやっている。

学び:fromCharCode(...bytes) は巨大入力で落ちる

この実装、テキスト変換としては十分だが、String.fromCharCode(...bytes)スプレッド展開には落とし穴がある。スプレッドはバイト列を関数の「引数」に展開するので、入力が非常に大きい(数十万バイト規模の)とき、引数の数が処理系の上限を超えて RangeError: Maximum call stack size exceeded で落ちることがある。

テキストのBase64変換ならまず問題にならない範囲だが、もし大きなバイナリ(画像ファイルなど)をBase64化する用途なら、fromCharCode をチャンクに分けて回すか、そもそも FileReader.readAsDataURL() にデータURI化を任せるのが安全。「文字列→バイト列→バイナリ文字列」という橋渡しの都合が、規模が大きくなると顔を出す、という一例。

なお、こうした煩わしさを解消する Uint8Array.prototype.toBase64 / Uint8Array.fromBase64 がJavaScriptの標準に入りつつある。バイト列とBase64を直接変換できるので、将来的には btoa/atob の橋渡しは不要になっていく方向。ただし対応環境が揃うまでは、上のTextEncoder方式が確実に動く。

最後に念のため——Base64はエンコードであって暗号化ではない。3バイトを4文字で表すのでデータ量は約33%増えるが、誰でも一瞬でデコードできる。機密情報を隠す用途には使えない。中身を守りたいならテキスト暗号化(AES-256)のような本物の暗号を使う。

まとめ

  • btoa は "binary to ASCII"。各文字を0〜255の1バイトとみなすバイナリ文字列専用なので、UTF-16のマルチバイト文字を直接渡すと InvalidCharacterError になる
  • 正しくは「文字列 →(TextEncoder)→ UTF-8バイト列 →(String.fromCharCode)→ バイナリ文字列 → btoa」。デコードは atobcharCodeAt でバイト取り出し → TextDecoder の逆順
  • URLセーフBase64は +- /_・末尾 = 除去。デコードでは逆変換し、length % 4 の余り(2→==, 3→=)からパディングを復元する
  • fromCharCode(...bytes) のスプレッドは巨大入力でスタック上限に当たる。大きなバイナリは分割か readAsDataURL を使う。標準の Uint8Array.toBase64 が普及すればこの橋渡し自体が不要になる

日本語・絵文字入りのBase64を確認したいときにどうぞ。入力はブラウザの外に出ない。

ぱんだツールズ では他にも JWTデコーダ・テキスト暗号化・URLエンコード・ハッシュ生成など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com


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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?