◆ fetch で得られる ReadableStream は Uint8Array でデータを受け取れる
◆ stream で処理するとき マルチバイト文字の途中で分割されてたら文字列化でおかしくなる
◆ TextDecoder の stream モードでデコードもできるけど TextDecoderStream を使うほうが便利

stream とマルチバイト文字列

stream でバイナリデータを受け取って 全部を受け取るのを待たず 受け取るたびに文字列として処理をするとき 日本語が入るとうまくいかないときがあります
UTF-8 で日本語は 3 バイト文字なので 3 バイト中のどこかでチャンクが分かれてしまう場合です
こういう感じです

text: abあcd



chunk1: [a, b, あ(1), あ(2)]
chunk2: [あ(3), c, d]

Node.js の場合の対処方法は stream に encoding を設定すれば 文字列として有効な単位で受け取れます(詳細)

ブラウザで

これをブラウザでもやりたいです
stream から受け取れるデータは Uint8Array になってます

const { body } = await fetch("")
const reader = body.getReader()

while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value)
}
Uint8Array(740) […]
Uint8Array(4096) […]
Uint8Array(65536) […]
Uint8Array(872) […]

バイナリデータとして複数に分かれているので このままそれぞれを文字列化したら問題が出る場合があるはずです
Node.js みたいに encoding を設定できることを期待したのですが response.body で得られる ReadableStream を見ても encoding を設定できそうなプロパティやメソッドがありません

TextDecoder 側で対応できないかなと思って引数を調べてみると stream モードがありました
普通に TextDecoder を使うと マルチバイト文字の途中で別れたらこうなります

const decoder = new TextDecoder()
console.log(decoder.decode(Uint8Array.from([227, 129, 130, 227])))
console.log(decoder.decode(Uint8Array.from([129, 132, 227, 129, 134])))
あ�
��う

stream に対応させるとこうなります

const decoder = new TextDecoder()
console.log(decoder.decode(Uint8Array.from([227, 129, 130, 227]), { stream: true }))
console.log(decoder.decode(Uint8Array.from([129, 132, 227, 129, 134]), { stream: true }))

いう

中途半端な部分は decoder のインスタンス内部に残って 次の decode メソッドの呼び出し時に結合されてデコードされます
TextEncoder や TextDecoder はインスタンス化しなくてもただの関数で十分だと思ってましたが こういうところで内部状態を持つことが考慮されてたのですね

これに対応させるとこうなります

const { body } = await fetch("")
const reader = body.getReader()
const decoder = new TextDecoder()

while (true) {
const { value, done } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
console.log(text)
}

正常な場合なら↑のコードで最後まで読み取ったとき decoder 内に途中までのデータが残らないので大丈夫ですが 末尾にマルチバイト文字の途中までがあるような壊れたデータも考えるなら while を抜けたあとや if (done) のブロック内で

const text = decoder.decode(new Uint8Array())
console.log(text)

を実行して残ったデータがあれば�を出力できます

const decoder = new TextDecoder()
console.log(decoder.decode(Uint8Array.from([227, 129, 130, 227]), { stream: true }))
console.log(decoder.decode(new Uint8Array()))



TextDecoderStream

ここまでで満足してたのですが MDN で TextDecoder を見てたときに TextDecoderStream というのを見つけました
stream のまま変換できるみたいです

const { body } = await fetch("")
const stream = body.pipeThrough(new TextDecoderStream())
const reader = stream.getReader()

while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value)
}
<!doctype html><html ....

変換後の stream も ReadableStream なので getReader で reader を作って中身を読めます
このとき read で読み取ったデータは文字列になってます

fetch だと分割されたときにうまくいってるのか確認しづらいので ReadableStream を自作して確認します
まず Uint8Array のままの場合です

const stream = new ReadableStream({
async start(controller) {
await new Promise(r => setTimeout(r, 1000))
controller.enqueue(Uint8Array.from([227, 129, 130, 227]))
await new Promise(r => setTimeout(r, 1000))
controller.enqueue(Uint8Array.from([129, 132, 227, 129, 134]))
await new Promise(r => setTimeout(r, 1000))
controller.close()
},
})

const reader = stream.getReader()

while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value)
}
Uint8Array(4) [227, 129, 130, 227]
Uint8Array(5) [129, 132, 227, 129, 134]

これに TextDecoderStream を通します

const stream = new ReadableStream({
async start(controller) {
await new Promise(r => setTimeout(r, 1000))
controller.enqueue(Uint8Array.from([227, 129, 130, 227]))
await new Promise(r => setTimeout(r, 1000))
controller.enqueue(Uint8Array.from([129, 132, 227, 129, 134]))
await new Promise(r => setTimeout(r, 1000))
controller.close()
},
})
const str_stream = stream.pipeThrough(new TextDecoderStream())
const reader = str_stream.getReader()

while (true) {
const { value, done } = await reader.read()
if (done) break
console.log(value)
}

いう

文字化けしたりせず 「い」 が表示されてますね

結構前から使えた

MDN を見ると TextDecoderStream は Chrome 71 から使えるようなってたようです
TextDecoder は 38 なので それよりはずっと後ですが 今はもう 115 なので結構前から使えた機能だったようですね