【JavaScript】文字エンコーディングの橋渡しを行う Encoding API

[2023-12-02 更新]

近年ウェブの世界で文字エンコーディングといえば UTF-8 一択となりました。そのためウェブ開発、とりわけフロンドエンドにおいて文字コードを意識することはかなり減ったのではないでしょうか。しかし、たとえば HTTP などの通信によって外部からテキストデータを入出力したり、ストレージにテキストファイルを読み書きしたりするなど、デスクトップアプリに近いことができるようになった昨今、文字エンコーディングを意識せざるを得ないシーンが増えてきました。今回は、その文字エンコーディングを扱う Encoding API について紹介します。Encoding API は Chrome, Edge, Firefox, Safari すべてでサポートされています。

文字コードと文字エンコーディング

Encoding API を解説する前に、文字コードと文字エンコーディングの基礎について簡単に説明します。すでにご存じの方はこの章を飛ばして次の章からご覧ください。

コンピュータ上の文字とは単なるバイトの塊です。2 バイトで 1 文字を表す場合もあれば 3 バイトで 1 文字を表す場合もありますが、いずれにせよ、単なるビット列にすぎません。人にとって分かりやすいよう、16 進数表記で表すこともあります。たとえば、0xE38182 は 3 バイト(24 ビット)の何の変哲もないバイナリーデータです。しかし、ある状況下では「あ」と解釈されます。このように文字と解釈される状況において、0xE38182文字コードと呼びます。そして、0xE38182 を「あ」と解釈させる規則のことを文字エンコーディングと呼びます。これはコードと文字の対応表みたいなものです。ちなみに 0xE38182 を「あ」と解釈する文字エンコーディングは UTF-8 です。

日本語を扱える文字エンコーディングは UTF-8 のほかにも Shift_JIS や EUC-JP などがあります。Shift_JIS の「あ」の文字コードは 0x82A0 です。そして、EUC-JP の「あ」の文字コードは 0xA4A2 です。

このようにバイナリーデータを扱う場合、それがテキストなのか、そして、テキストならばどの文字エンコーディングが使われているのかを事前に知らないと、そのバイナリーデータの意味を理解することはできません。もし文字エンコーディングを間違えると、コードから文字への変換に失敗し、いわゆる文字化けが発生します。

内部文字列と外部文字列

ブラウザーに組み込まれている JavaScript エンジンは内部では文字データを何らかの文字エンコーディングによってコード化して処理しています。一般的には UTF-16 で処理されていると言われています。しかし、我々ウェブアプリ開発者は JavaScript エンジンがどんな文字エンコーディングで処理しているのかを知る必要はありません。しかし、少なくとも我々が良く使う UTF-8 や Shift_JIS ではありません。この JavaScript エンジンが内部で使っている文字列をここでは「内部文字列」と呼びます。これは一般的な用語ではなく、筆者が説明のために便宜的に使っている用語です。

一方、ウェブサーバーに保存されている HTML ファイルなどのテキストファイルは、私たちになじみがある UTF-8 や Shift_JIS といった文字エンコーディングが使われています。そして、PC 内に保存されたファイルも同様です。これらのファイルに格納された文字データは、JavaScript エンジンから見れば、なんら意味を持たないバイト列にすぎません。そのため、ここではそのような文字列を「外部文字列」と呼びます。これも一般的な用語ではなく、筆者が説明のために便宜的に使っている用語です。

なお、WHATWG の Encoding API の仕様では、内部文字列のことをスカラー値 (scalar value)、外部文字列のことをバイト列 (byte sequence) と呼んでいます。

ブラウザーは、HTML などの外部文字列を読み取った段階では、それを文字列と理解できていません。何らかの方法で HTML の文字エンコーディングを把握して内部文字列に変換することで、はじめてその HTML を解釈することができます。

ブラウザーは、HTML を読み取るときだけでなく、XMLHttpRequest(AJAX)で JavaScript を使って XML データやテキストデータを読み取る場合も同じことが行われます。しかし、XMLHttpRequest はいわゆる高レベル API のため、外部文字列と内部文字列を開発者に意識させないよう、自動的に変換されます。しかし、近年登場した API や今後登場するであろう API の中には低レベル API もあり、外部データをバイナリーデータのまま読み取る場合も考えられます。その場合は、開発者が自らコード変換を行う必要があります。そこで必要になるのが、今回紹介する Encoding API です。

多くの人は文字コード変換といえば、Shift_JIS から UTF-8 へ変換するなど、外部文字列を外部文字列に変換することを想像するのではないでしょうか。しかし、Encoding API は、外部文字列を内部文字列に変換、または、内部文字列を外部文字列に変換する役割を担います。どちらも文字エンコーディングの橋渡しであることには変わりはありませんが、Encoding API の場合は、起点があくまでも内部文字列という点に注意してください。そして、私たちは内部文字列がどんな文字エンコーディングなのかを知る必要もありません。

エンコードとデコード

Encoding API は外部文字列と内部文字列の橋渡しをするわけですが、その変換の方向に呼び方があります。外部文字列を内部文字列に変換して JavaScript エンジンに文字列として理解できるようにすることをデコードと呼びます。逆に、内部文字列を UTF-8 や Shift_JIS などの文字エンコーディングで変換することをエンコードと呼びます。この変換の方向に対する呼び方は良く理解してください。

Encoding API は、様々な文字エンコーディングの文字列データを内部文字列にデコードできます。しかし、内部文字列は UTF-8 にしかエンコードすることができません。

デコードを行う TextDecoder クラス

では、Encoding API の使い方を見ていきましょう。まずはデコードからです。次のコードサンプルは、外部文字列となるバイナリーデータを生成し、それをデコードしたうえで、コンソールに出力します。

// 外部文字列となるバイナリーデータを生成
const bin = new Uint8Array([0xE3, 0x81, 0x82]);

// TextDecoder オブジェクトを生成
const decoder = new TextDecoder('utf-8');

// 外部文字列を内部文字列にデコード
const str = decoder.decode(bin);
console.log(str); // あ

デコードするには、まず TextDecoder オブジェクトを生成します。コンストラクタに "utf-8" を引き渡している点に注目してください。この値は外部文字列の文字エンコーディングを指すのですが、これをラベルと呼びます。ラベルの指定がなければ "utf-8" が指定されたものとして処理されます。

TextDecoder オブジェクトを生成したら、その decode() メソッドで外部文字列を内部文字列に変換します。decode() メソッドにはデコードしたい外部文字列となるバイナリーデータを引き渡します。引き渡すことができるオブジェクトは ArrayBuffer または ArrayBufferView (TypedArray) です。ここでは ArrayBufferView となる Uint8Array オブジェクトを引き渡しています。decode() メソッドで外部文字列が内部文字列に変換されれば、コンソールや DOM に出力が可能になります。

エンコーディング名とラベル

前述の通り、TextDecoder オブジェクトの decode() メソッドにはラベルを引き渡しますが、実は同じエンコーディングに対して複数のラベルが用意されています。日本語に関連するエンコーディング名とラベルの対応を下表に抜粋します。

エンコーディング名ラベル
UTF-8“unicode-1-1-utf-8”
“unicode11utf8”
“unicode20utf8”
“utf-8”
“utf8”
“x-unicode20utf8”
EUC-JP“cseucpkdfmtjapanese”
“euc-jp”
“x-euc-jp”
ISO-2022-JP“csiso2022jp”
“iso-2022-jp”
Shift_JIS“csshiftjis”
“ms932”
“ms_kanji”
“shift-jis”
“shift_jis”
“sjis”
“windows-31j”
“x-sjis”
UTF-16BE“unicodefffe”
“utf-16be”
UTF-16LE“csunicode”
“iso-10646-ucs-2”
“ucs-2”
“unicode”
“unicodefeff”
“utf-16”
“utf-16le”

たとえば、UTF-8 の外部文字列をデコードしたい場合、そのラベルは "utf-8" でも "utf8" でもどちらでも構いません。また、ラベル名は大文字と小文字を区別しませんので、"UTF-8""UTF8" でも認識されます。

Encoding API がサポートするエンコーディング名とラベルの対応は、WHATWG の Encoding API 仕様をご覧ください。

エンコードを行う TextEncoder クラス

内部文字列を UTF-8 の外部文字列に変換するには、TextEncoder クラスを使います。次のサンプルコードは、内部文字列 “あ” を UTF-8 のバイナリーデータに変換します。

// TextEncoder オブジェクトを生成
const encoder = new TextEncoder();

// 内部文字列を UTF-8 のバイナリーデータに変換
const bin = encoder.encode("あ"); // Uint8Array: 0xE3, 0x81, 0x82

TextEncoder オブジェクトを生成したら、その encode() メソッドで内部文字列を UTF-8 の外部文字列に変換します。encode() メソッドは Uint8Array オブジェクトを返します。

fetch でダウンロードしたテキストファイルをデコードする

ここでは少し実用的なサンプルを見てみましょう。次のサンプルは、fetch で UTF-8 のテキストファイルをダウンロードし、その内容をコンソールに出力するシンプルなコードです。以降のコードは async ブロックの中であることを前提としています。

// テキストファイルを fetch でダウンロード開始
const response = await fetch('sample.txt');

// 外部文字列(バイナリーデータ)を内部文字列にデコード
const text = await response.text();
console.log(text);

このコードは何ら問題なくテキストファイルの内容をコンソールに出力してくれますので、Encoding API の出る幕はありません。しかし、sample.txt の文字エンコーディングが Shift_JIS だったらどうでしょう。お察しの通り文字化けしてしまいます。fetchResponse オブジェクトの text() メソッドは UTF-8 のデコードにしか対応していないのです。当然ながら、自動的に文字エンコーディングを判定してデコードしてくれることはありませんし、エラーにもなりません。

もしダウンロードするテキストファイルの文字エンコーディングが UTF-8 でないなら、fetchResponse オブジェクトの arrayBuffer() メソッドを使ってダウンロードデータをバイナリーデータとして取得します。そして、それを Encoding API を使ってデコードします。

// Shift_JIS のテキストファイルを fetch でダウンロード開始
const response = await fetch('sample.txt');

// バイナリーデータを取得 (ArrayBuffer)
const bin = await response.arrayBuffer();

// バイナリーデータを内部文字列にデコード
const decoder = new TextDecoder('shift_jis');
const text = decoder.decode(bin);
console.log(text);

なお、fetchResponse オブジェクトの text() メソッドだけでなく、json() メソッドも UTF-8 のデコードにしか対応していません。Shift_JIS や EUC-JP の JSON を扱うことはまずないでしょうが、もしそのような状況では期待通りに動作しませんので注意してください。

UTF-8 で CSV をファイルに保存

次に、エンコードの実用例を見てみましょう。実はエンコードの文字エンコーディングが UTF-8 に限定されていることもあり、意外に使いどころがありません。というのも、多くの場合、Encoding API を使わずとも、自動的に UTF-8 でエンコードしてくれるためです。次の例は fetch で JSON をサーバーにアップロードするコードですが、JSON となる文字列は自動的に UTF-8 でエンコードされたうえでサーバーに送信されます。

const response = await fetch('/exec/doSomething', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: '太郎', pref: '東京都' })
});

このような自動エンコードは、通信のみならず、ファイル生成 (Blob オブジェクトの生成) でも行われます。

const blob = new Blob(['こんにちは'], { type: 'text/plain; charset=utf-8' });

ここではあえて charsetutf-8 を指定していますが、仮に指定しなかったとしても、さらには、shift_jis を指定したとしても、UTF-8 エンコードされて Blob オブジェクトが生成されます。

Encoding API のエンコードを使うとしたら、バイナリーデータを個別に操作しないといけない場合でしょう。

次の例は、CSV ファイルをデスクトップに保存します。CSV を UTF-8 でファイル保存する場合、ファイルの先頭に BOM(Byte Order Mark)を付加しないと Excel で期待通りにファイルを開くことができません。UTF-8 の BOM は 16 進数表記にすると 0xEFBBBF です。このバイト列を UTF-8 エンコード後の CSV データの先頭に付加します。

なお、次のサンプルコードは Chrome と Edge で動作します。Firefox と Safari では動作しませんので注意してください。これは Firefox と Safari が showSaveFilePicker() をサポートしていないためです。

// CSV データ
const csv = "名前,性別,都道府県\n"
    + "太郎,男性,東京都\n"
    + "花子,女性,大阪府\n";

// ファイル保存ダイアログを表示して FileSystemFileHandle オブジェクトを取得
const fh = await window.showSaveFilePicker({ suggestedName: 'sample.csv' });

// FileSystemWritableFileStream オブジェクトを取得
const stream = await fh.createWritable();

// BOM (0xEFBBBF) をファイルに書き込む
await stream.write(new Uint8Array([0xEF, 0xBB, 0xBF]));

// CSV データを UTF-8 エンコードしてファイルに書き込む
const encoder = new TextEncoder();
await stream.write(encoder.encode(csv));

// ファイルを閉じる
await stream.close();

ストリームを使って巨大なテキストファイルをデコード

Encoding API は、Streams API のストリームをサポートしています。デコードの場合は TextDecoderStream クラスを使います。基本的に前述の TextDecoder クラスと使い方は同じです。

const decoder = new TextDecoderStream('shift_jis');

TextDecoderStream クラスから生成されたオブジェクト (上記コードでは変数 encoder) は TransformStream オブジェクトで、Streams API の pipeThrough() メソッドとともに使います。

次のサンプルコードは、巨大な Shift_JIS のテキストファイルを fetch でストリームでダウンロードします。その際に、ストリームのまま Shift_JIS のテキストデータを内部文字列にデコードし、それをコンソールに出力します。

// Shift_JIS のテキストファイルを fetch でダウンロード開始
const response = await fetch('big.txt');

// fetch の ReadableStream (response.body) に
//  TextDecoderStream (TransformStream) を連結
const rstream = response.body.pipeThrough(
    new TextDecoderStream('shift_jis')
);

// ReadableStreamDefaultReader オブジェクト
const reader = await rstream.getReader();

while (true) {
    // チャンクを取得
    const { done, value } = await reader.read();

    // ストリームが終わったらループを抜ける
    if (done === true) {
        break;
    }

    // value は内部文字列
    console.log(value);
}

Streams API の使い方については、当ブログの記事「Streams API を使って fetch の逐次ダウンロード & ファイル保存」と「GZIP ファイルをダウンロードして Compression Streams API で解凍する」もぜひご覧ください。

ストリームを使って巨大なデータをファイルに保存

Encoding API はエンコードでも Streams API のストリームもサポートしています。エンコードの場合は TextEncoderStream クラスを使います。

const encoder = new TextEncoderStream();

TextEncoderStream クラスから生成されたオブジェクト (上記コードでは変数 encoder) は TransformStream オブジェクトで、Streams API の pipeThrough() メソッドとともに使います。pipeThrough() メソッドの使い方などの詳細は当ブログ記事「Streams API を使って fetch の逐次ダウンロード & ファイル保存」をご覧ください。

次のサンプルコードは、ボタンを押すと、ファイル保存ダイアログが出力されます。ユーザーが保存ボタンを押すと、fetch で Shift_JIS の巨大なテキストファイルをダウンロードしてファイルに保存します。その際、ストリームでダウンロードしながら内部文字列にデコードし、UTF-8 の外部文字列にエンコードし、最後にファイルに書き込みます。この一覧の処理で、巨大なファイルを丸ごと読み込むことなしに、終始ストリームのママ処理している点が特徴です。

なお、次のサンプルコードは Chrome と Edge で動作します。Firefox と Safari では動作しませんので注意してください。これは Firefox と Safari が showSaveFilePicker() をサポートしていないためです。

<button onclick="saveTextFile()">ファイルを保存</button>
async function saveTextFile() {
    // ファイル保存ダイアログを表示して FileSystemFileHandle オブジェクトを取得
    const fh = await window.showSaveFilePicker({ suggestedName: 'sample.txt' });

    // FileSystemWritableFileStream オブジェクトを取得
    const wstream = await fh.createWritable();

    // Shift_JIS のテキストファイルを fetch でダウンロード開始
    const response = await fetch('big.txt');

    // fetch の ReadableStream (response.body) に
    //  TextDecoderStream (TransformStream) を連結
    await response.body.pipeThrough(
        new TextDecoderStream('shift_jis')
    ).pipeThrough(
        new TextEncoderStream()
    ).pipeTo(wstream);
}

Streams API の使い方については、当ブログの記事「Streams API を使って fetch の逐次ダウンロード & ファイル保存」と「GZIP ファイルをダウンロードして Compression Streams API で解凍する」をぜひご覧ください。また、ファイル保存ダイアログを表示してファイルを保存する方法については「File System Access API – ファイルピッカーを表示してファイルを保存する方法 showSaveFilePicker() 編」をご覧ください。

まとめ

近年は UTF-8 以外の文字エンコーディングを見なくなりましたが、ごく稀に Shift_JIS から逃げられないときがあります。Encodinfg API を実際に使うことはないかもしれませんが、もし UTF-8 以外の文字エンコーディングに遭遇することがあったら、Encodinfg API を思い出してください。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share