◆ btoa は日本語対応してない
◆ TextEncoder でバイト列化して自分で base64 変換
◆ バイト列化後に 1 バイトずつ文字列化して btoa でもできる

ブラウザで base64 変換するには btoa 関数を使います

btoa("foobar")
// "Zm9vYmFy"

btoa で日本語を変換しようとすると

btoa("あいうえお")
// Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.

Latin1 の範囲外の文字があると言ってエラーが出ます
UTF-8 として扱ってくれないみたいです

Node.js だと

> Buffer.from("あいうえお").toString("base64")
'44GC44GE44GG44GI44GK'

という感じで変換できるのですけどね

自作する

base64 変換は簡単に作れるものなので 日本語対応版を自作します

まず 普通の base64 変換はこれでできます

const _btoa = str =>
[...str]
.map(x => x.charCodeAt().toString(2).padStart(8, "0"))
.join("")
.padEnd(Math.ceil(str.length * 8 / 6) * 6, "0")
.split(/(.{6})/)
.filter(x => x)
.map(x => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[parseInt(x, 2)])
.join("")

btoa("foobar") === _btoa("foobar")
// true

base64 では文字数によっては末尾にパッディングの 「=」 が入ることがあります
これはなくてもデコードできるのでここでは省略しています
「=」 が入る文字数だと btoa と完全には一致しません

やってることは 1 バイトずつ 8 桁の 2 進数表記に変換します
それを 6 ビットずつのグループに分割して それぞれの数値に対応する文字に置き換えて結合します

日本語の場合は charCodeAt が 1 バイトにならず 8 桁の 2 進数にならないのでおかしくなります
なので TextEncoder を使い UTF-8 エンコードしたバイト列を取得し 各バイトに対して同じ処理を行います

const _btoa = str =>
Array.from(new TextEncoder().encode(str))
.map(x => x.toString(2).padStart(8, "0"))
.join("")
.padEnd(Math.ceil(str.length * 8 / 6) * 6, "0")
.split(/(.{6})/)
.filter(x => x)
.map(x => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[parseInt(x, 2)])
.join("")

btoa("foobar") === _btoa("foobar")
// true

_btoa("あいうえお")
// 44GC44GE44GG44GI44GK

最初に Node.js で日本語を base64 変換したものと同じ結果になっていますね

> Buffer.from("あいうえお").toString("base64")
'44GC44GE44GG44GI44GK'

最後に 今回は省略しましたが パッディングも入れるなら最後に

"=".repeat([0, 2, 1][str.length % 3])

をくっつけます

TextEncoder + btoa

Latin1 の範囲内であれば btoa 関数が使えるので TextEncoder を使って取得したバイト列から日本語も 1 バイトずつに分けたバイナリ文字列を作ってそれを btoa にかけるということもできます

const _btoa = str =>
btoa(
Array.from(
new TextEncoder().encode(str),
x => String.fromCharCode(x),
).join("")
)
_btoa("あいうえお")
// 44GC44GE44GG44GI44GK