JavaScriptでBase64エンコードする

NodeはBufferにbase64エンコード機能があるのだが

Buffer.from("あいうえお", "utf-8").toString("base64")

ブラウザのJSにはこういう便利な実装がないのだった。どうするか?

window.btoa

ブラウザにはbtoaというbase64エンコードを行う関数がある。しかしこいつはascii文字列またはBinary Stringしか受け付けず、日本語を渡すと例外を投げるという英語圏仕様のクソ関数。このためbtoaを使うには一度binary stringに変換しなくてはならない。

文字列からbinary stringに変換する最も簡単な方法は、URLエンコードしてunescape

const str = "🐱";
const binStr = unescape(encodeURIComponent(str))
btoa(binStr);

FileReader#readAsBinaryStringは、FileやBlobをbinary stringに変換するメソッド。これで変換してbtoaに渡せばbase64文字列を得られる。

const blob = new Blob(["🐱"]);
const fr = new FileReader()
fr.readAsBinaryString(blob);
fr.onload = () => {
  btoa(fr.result);
};

String.fromCharCodeを使うとUint8Arrayからbinary stringに変換できる。文字列からUint8Arrayに変換するにはTextEncoderというオブジェクトを使う。

const str = "🐱";
const uint8arr = new TextEncoder("utf-8").encode(str);
const binStr = Array.from(uint8arr).map(b => String.fromCharCode(b)).join("");
btoa(binStr);

binary stringはTyped Arrayが実装される以前のバイナリを扱うためのレガシーな形式なので、できればUint8Arrayのまま処理したい。

readAsDataURLを使う

FileReader#readAsDataURLは FileやBlobをデータURIスキームに変換するメソッドである。

data:application/octet-stream;base64,8J+QsQ==

データ部分はbase64エンコードされるので、そこだけ切り出せば良い。

const blob = new Blob(["🐱"]);
const fr = new FileReader()
fr.readAsDataURL(blob);
fr.onload = () => {
  const r = fr.result;
  r.slice(r.indexOf(',') + 1);
};

base64エンコーダーを実装する

Uint8Arrayを直接base64にできないなら自分でエンコード処理を書いてしまえ。

function encodeBase64(bytes) {
  const table = [];
  for (let i = 65; i < 91; i++) table.push(String.fromCharCode(i))
  for (let i = 97; i < 123; i++) table.push(String.fromCharCode(i))
  for (let i = 0; i < 10; i++) table.push(i.toString(10))
  table.push("+");
  table.push("/");

  let base64 = "";
  let i = 0;
  const len = bytes.byteLength;
  for (i = 0; i < len; i += 3) {
    if (len === i + 1) {  // last 1 byte
      const a = (bytes[i] & 0xfc) >> 2;
      const b = ((bytes[i] & 0x03) << 4);
      base64 += table[a];
      base64 += table[b];
      base64 += "==";
    } else if (len === i + 2) { // last 2 bytes
      const a = (bytes[i] & 0xfc) >> 2;
      const b = ((bytes[i] & 0x03) << 4) | ((bytes[i+1] & 0xf0) >> 4);
      const c = ((bytes[i+1] & 0x0f) << 2);
      base64 += table[a];
      base64 += table[b];
      base64 += table[c];
      base64 += "=";
    } else {
      const a = (bytes[i] & 0xfc) >> 2;
      const b = ((bytes[i] & 0x03) << 4) | ((bytes[i+1] & 0xf0) >> 4);
      const c = ((bytes[i+1] & 0x0f) << 2) | ((bytes[i+2] & 0xc0) >> 6);
      const d = bytes[i+2] & 0x3f;
      base64 += table[a];
      base64 += table[b];
      base64 += table[c];
      base64 += table[d];
    }
  }
  return base64;
}

const uint8Arr = Uint8Array.of(0xF0, 0x9F, 0x90, 0xB1);
encodeBase64(uint8arr);

const uint8arr2 = new TextEncoder("utf-8").encode("🐱");
encodeBase64(uint8arr2);

Symbol.iteratorでユーザクラスを反復可能にする

JavaScriptのユーザクラスをfor of構文でループ可能にする方法。Symbol.iteratoriteratorプロトコルを実装したオブジェクトを返す関数をセットするか、ジェネレータ関数を実装すれば良い。楽なのは後者

class Hoge {
  *[Symbol.iterator]() {
     yield 1;
     yield 2;
     yield 3;   
  }  
}

const o = new Hoge();

for (const i of o) {
  console.log(i)   // => 1, 2, 3    
}

Symbol.iteratorを使ってRangeクラスを作成。挙動はpythonのrange関数を参考にした。

class Range {
  constructor(begin = 0, end = 0, step = 1) {
    if (arguments.length === 0) {
      throw new Error('expected at least 1 arguments, got 0')
    } else if (arguments.length === 1) {
      end = begin;
      begin = 0;
    }
    this._begin = begin;
    this._end = end;
    this._step = step;
    if (step === 0) throw new Error('step argument must not be 0')
  }

  *[Symbol.iterator]() {
    if (this._step > 0) {
      for (let i = this._begin; i < this._end; i += this._step) yield i;
    } else {
      for (let i = this._begin; i > this._end; i += this._step) yield i;
    }
  }

  get length() {
    const len = Math.ceil((this._end - this._begin) / this._step);
    return len > 0 ? len : 0;
  }
}
const r = new Range(1, 9, 2);

for (const i of r) {
   console.log(i);           // => 1, 3, 5, 7
}

// 配列に変換
console.log(Array.from(r));  // [1 ,3, 5, 7]

// 分割代入
const [head, ...tail] = r;
console.log(head);           // 1
console.log(tail);           // [3, 5, 7]

// 分割代入で配列化
console.log(Array.of(...r));
console.log([...r]);

正直rangeはネイティブオブジェクトとして提供していいと思うのだが…