概要
JavaScriptでバイナリデータを扱おうとするとBuffer
とArraryBuffer
という似た名前のオブジェクトが登場して混乱したり、Uint8Array
という耳慣れないオブジェクトが登場して途方に暮れたりすることがあると思います。本記事ではこれらのオブジェクトの概念を整理することでJavaScriptでのバイナリデータの扱いの見通しを良くしていきます。
概念の整理
まずバイナリデータを処理しようとしている環境がNode.jsなのかブラウザ環境なのかが重要です。Node.js環境の場合はBuffer
クラスを利用することになります。Buffer
クラスはNode.jsの標準ライブラリとして実装されています。
ブラウザ環境ではJavaScript自体に実装されているArraryBuffer
オブジェクトを利用することになります。Buffer
は実装されていません。
これらは名前は似ていますが別物です。
またUint8Array
はJavaScript標準で実装されているArraryBuffer
のビューです。生のバイナリデータに近いArraryBuffer
をより使いやすい形にしたものという位置付けです。
以上のように環境によって利用できるオブジェクトが異なるため、この記事では環境ごとにバイナリデータの扱い方を解説していきます。
(補足として最後にDenoでのバイナリデータ処理について触れます。基本的にブラウザ環境と同じAPIを利用します。)
Node.jsでのバイナリデータ処理
Node.jsでバイナリデータを扱うならBuffer
クラスを利用します。後述するJavaScript標準のArraryBuffer
より便利なので利用できる場合は基本的にこちらを使うのが良いと思います。グローバルスコープで利用できるのでインポートしなくても利用できますが、以下のように明示的にインポートすることが推奨されています。
// cjs
const { Buffer } = require('node:buffer');
// esm
import { Buffer } from 'node:buffer';
コンパイルしてブラウザ環境でコードを実行する場合はポリフィルライブラリを利用するとNode.js環境と同じように実装することができます。
npm install buffer
// cjs
const Buffer = require('buffer/').Buffer;
// esm
import { Buffer } from 'buffer';
Buffer
Buffer
クラスは固定長のメモリ領域を表現したオブジェクトです。Buffer.alloc()
で長さを指定して生成したり、Buffer.from()
で文字列などのデータから生成することができます。
// 10バイトのメモリ領域を確保する(各バイトは0で初期化される)
Buffer.alloc(10);
// <Buffer 00 00 00 00 00 00 00 00 00 00>
// バイトの配列から生成
// 値の範囲は0~255
Buffer.from([1, 255, 256]);
// <Buffer 01 ff 00>
// 文字列から生成
Buffer.from('テスト');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
各バイトにはインデックスによってアクセスすることができます。それぞれの値は10進数の数値型で表現されます。
const buf = Buffer.from('hoge');
> buf[0];
104
> buf[1];
111
> buf[2];
103
> buf[3];
101
取得するだけではなく上書きすることもできます。
buf[0] = 102;
buf[1] = 117;
buf[2] = 103;
buf[3] = 97;
buf;
// <Buffer 66 75 67 61>
// toString()について詳しくは後述
buf.toString();
// 'fuga'
文字列から生成する場合はエンコーディングを指定することができます。デフォルトではUTF-8でエンコードされたものとして解釈されます。
Buffer.from('テスト', 'utf8');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
16進数文字列から生成することもできます。
Buffer.from('e38386e382b9e38388', 'hex');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
サポートされている形式は以下に挙げられています。
Buffer.toString()
を利用することでバイナリデータを文字列として出力することができます。その際もデフォルトの形式はUTF-8です。引数によって出力形式を指定することができます。UTF-8文字列とバイナリデータの16進数文字列を相互変換したり、文字列をbase46エンコードして出力することができます。
const buf1 = Buffer.from('e38386e382b9e38388', 'hex');
buf1.toString();
// > 'テスト'
const buf2 = Buffer.from('テスト', 'utf8');
buf2.toString('hex');
// > 'e38386e382b9e38388'
buf2.toString('base64');
// > '44OG44K544OI'
Node.jsでファイルを読み込む場合、エンコーディングを指定しない場合はこのBuffer
型のデータとして扱われます。
import { readFile } from 'node:fs';
readFile('/path/to/file', (err, data) => {
console.log(data);
// <Buffer ...>
data.toString('hex');
// '...'
})
サーバサイドのヘッドレスブラウザなどでローカルファイルの画像のバイナリデータをimg
タグで表示するとします。base64エンコーディング文字列への変換を活用して以下のように実装することができます。
<img
src={`data:image/png;base64,${data.toString('base64')}`}
/>
Buffer
クラスはnew
によって生成することもできますが、非推奨とされています。引数に渡す値の型によって挙動が変わるため扱いが難しく、またNode.jsのバージョン8以前では割り当てられたメモリが初期化されない場合があるためセキュリティ上の危険性があります。
常にnew Buffer()
ではなくBuffer.alloc()
やBuffer.from()
を使うようにしましょう。
JavaScriptでのバイナリデータ処理
JavaScriptにはArrayBuffer
オブジェクトが実装されてます。こちらも固定長のメモリ領域を表現しています。ブラウザ環境で直接実行するコード上でバイナリデータを扱う場合はこのオブジェクトを利用します。ただしこのArrayBuffer
ではデータの内容にアクセスすることはできません。
ArrayBuffer
の内容にアクセスするにはTypedArray
というオブジェクトを利用します。TypedArray
にはUint8Array
やFloat32Array
といったいくつかの種類が存在します。これらはArrayBuffer
のビューとして機能します。
ArrayBuffer
ArrayBuffer
は確保するメモリのバイト長を指定して生成します。メソッドはほとんどないため、このArrayBuffer
を直接操作することはありません。
new ArrayBuffer(10);
// ArrayBuffer {
// [Uint8Contents]: <00 00 00 00 00 00 00 00 00 00>,
// byteLength: 10
// }
TypedArray
バイナリデータを操作するためにはArrayBuffer
からTypedArray
ビューを生成する必要があります。その際に最もよく利用されるのがUint8Array
です。Uint8Array
は符号なしの8ビット=1バイト単位でメモリ領域を扱います。メモリは1バイト単位で扱われるのが一般的なのでこのUint8Array
がメモリ領域を表現するのに一番自然です。
TypedArray
はコンストラクタにArrayBuffer
を入力することで生成できます。
const buffer = new ArrayBuffer(8);
new Uint8Array(buffer);
// Uint8Array(8) [
// 0, 0, 0, 0,
// 0, 0, 0, 0
// ]
またArrayBuffer
を経由せず直接TypedArray
を生成することもできます。この場合TypedArray
の種類に応じた単位の領域が指定した長さ分確保されます。また数値の配列から生成することもできます。その場合の数値の範囲はTypedArray
の種類によって変わります。Uint8Array
なら値は8ビットなので0~255になります。
内部的にはArrayBuffer
が生成された上でそのビューが返されていますが、ArrayBuffer
自体を意識する必要はあまりありません。
new Uint8Array(8);
// Uint8Array(8) [
// 0, 0, 0, 0,
// 0, 0, 0, 0
// ]
// こちらは32ビット=4バイト単位
// 8*4=32バイトのメモリ領域が確保される
new Int32Array(8);
// Int32Array(8) [
// 0, 0, 0, 0,
// 0, 0, 0, 0
// ]
// 符号なし8ビットなので値の範囲は0~255
new Uint8Array([0, 255, 256]);
// Uint8Array(3) [ 0, 255, 0 ]
TypedArray
はインデックスによってデータの内容にアクセスすることができます。その際の単位はTypedArray
の種類によって異なります。
const u8 = new Uint8Array(8);
u8[0];
// 0
u8[1];
// 0
u8[0] = 255;
u8;
// Uint8Array(8) [
// 255, 0, 0, 0,
// 0, 0, 0, 0
// ]
u8[1] = 256;
u8;
// Uint8Array(8) [
// 255, 0, 0, 0,
// 0, 0, 0, 0
// ]
その他、Array
に似たメソッドが実装されています。
TypedArray
単体ではNode.jsのBuffer
のように文字列からバイナリデータを作成したり、逆にバイナリデータを文字列に変換ことはできません。TextEncoder
/TextDecoder
を利用します。
文字列からバイナリデータを得るにはTextEncoder.encode()
を使います。Uint8Array
が返ってきます。エンコーディングはUTF-8のみ対応しています。
const encoder = new TextEncoder();
encoder.encode('テスト');
// Uint8Array(9) [
// 227, 131, 134,
// 227, 130, 185,
// 227, 131, 136
// ]
バイナリデータを文字列に変換するにはTextDecoder.decode()
を使います。
const encoded = encoder.encode('テスト');
const decoder = new TextDecoder();
decoder.decode(encoded);
// 'テスト'
こちらはTextDecoder
のコンストラクタの引数で指定することでUTF-8以外のエンコーディングを利用することができます。対応しているものの一覧は以下にあります。
バイナリデータと16進数文字列の変換手段は実装がありません。少しく工夫すれば自分で実装することもできます。
まずはバイナリデータから16進数文字列への変換です。
Array.from(encoded).map(
(byte) => byte.toString(16).padStart(2, '0')
).join('');
// 'e38386e382b9e38388'
Uint8Array
を配列に変換して各値を取り出すと10進数の数値になります。それらをNumber.prototype.toString()
を利用して16進数文字列にします。さらに二桁で固定するためString.prototype.padStart()
を使います。あとはArray.prototype.join()
で繋げれば16進数文字列表記が得られます。
逆に16進数文字列からバイナリデータを生成する処理です。まずなんとかして16進数文字列を2文字ずつの配列に変換します。それらを10進数の数値に変換します。JavaScriptでは16進数は0x83
といった形式で扱われるため、16進数文字列の頭に0x
をつけてNumber()
に渡せばOKです。最後にその数値の配列をTypedArray
のコンストラクタに渡せば完成です。
const hexStrings = ['e3', '83', '86', 'e3', '82', 'b9', 'e3', '83', '88'];
const hexNumbers = hexStrings.map((hex) => Number('0x' + hex));
const u8 = new Uint8Array(hexNumbers);
decoder.decode(u8);
// 'テスト'
base64エンコードについてはかなり複雑な事態が発生します。
base64文字列を生成する手段としてbtoa()
とatob()
が実装されていますが、ASCII文字列との変換にしか対応していません。
上記のリンクの例を参考に実装するのであれば、Uint8Array
のそれぞれのバイトを無理やりASCII文字列に変換してこのbtoa()
に入力します。
const encoder = new TextEncoder();
const encoded = encoder.encode('テスト');
let result = '';
for (let i = 0; i < encoded.byteLength; i++) {
result += String.fromCharCode(encoded[i]);
}
btoa(result);
// '44OG44K544OI'
逆にUint8Array
にデコードするならatob()
でデコードした文字列をString.prototype.charCodeAt()
を使って一文字ずつバイトに変換していきます。String.prototype.charCodeAt()
はUTF-16でエンコードしますがatob()
の戻り値はASCII文字なので0~255の範囲で表現できます。なのでそのままUint8Array
に入れて問題ありません。文字列に変換したければTextDecoder
を使います。
const ascii = atob('44OG44K544OI');
const bytes = new Uint8Array(ascii.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = ascii.charCodeAt(i);
}
const decoder = new TextDecoder();
decoder.decode(bytes);
// 'テスト'
以上のように16進数文字列やbase64を利用する場合は複雑になるので可能な場合はNode.jsのBuffer
を利用した方がいいでしょう。
またはサードパーティライブラリのjs-base64を利用する手段もあります。
(補足)Denoでのバイナリデータ処理
DenoにはNode.jsのBuffer
クラスはありません。基本的にJavaScript標準のUint8Array
を利用します。ファイルリーダの戻り値もPromise<Uint8Array>
となっています。
バイナリデータと文字列との変換はTextEncoder
/TextDecoder
を利用します。
上の節で悩むことになったUint8Array
に不足する機能は標準ライブラリによって補われています。例えばバイナリデータと16進数文字列との変換にはstd/encoding/hex
ライブラリが利用できます。
base64との変換にはstd/encoding/base64
ライブラリが利用できます。
参考文献