【JS体操第3問ヒント④】「コードポイント」を「文字列」に変換する22の方法

こんにちは!面白プロデュース事業部のおばらです。
今回は JS体操第3問「Zalgo Text の生成」の問題のヒントにもなるかもしれないシリーズ第4弾。

JavaScript で「Unicode のコードポイント」を「文字列」に変換する方法を、実用性のあるものからほぼネタのものまで22通りご紹介します。


JS体操第3問「Zalgo Text の生成」 とこれまでのヒント記事①②③については以下をご覧ください。

JS体操第3問「Zalgo Text の生成」
JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com

techblog.kayac.com


目次


コードポイントとは

「コードポイント」は文字コードで文字に紐づく固有の数字、ID のことです。詳しくは以下の記事をご覧ください。 techblog.kayac.com

以降本記事では「コードポイント」は「Unicode のコードポイント」を指します。


コードポイントを文字列に変換する22の方法

サンプルコードの説明

本記事で紹介するコードポイントを文字列に変換する方法は、以下のざっくりなテストを行っています。ざっくりなテストしか行っていないのでコードポイントの組み合わせや順番によっては変換が上手くいかない場合があるかもしれませんがご了承ください。

async function test(fromCodePoint) {
  for (let p = 0; p <= 0x10FFFF;) {
    const codePoints = [];

    for (let i = 0; i < 100; ++i, ++p) {
      if (p < 0x000000 || 0x10FFFF < p) { // Unicode 範囲外
        break;
      }

      if (
          (0x000000 <= p && p <= 0x00001F) || // 制御文字(Control Characters)
          (0x00007F === p                ) || // 制御文字(DEL)
          (0x000080 <= p && p <= 0x00009F) || // 制御文字(PAD, HOP, BPH, NBH, IND, NEL, SSA, ESA, HTS, HTJ, VTS, PLD, PLU, RI, SS2, SS3DCS, PU1, PU2, STS, CCH, MW, SPA, EPA, SOS, SGCI, SCI, CSI, ST, OSC, PM, APC)
          (0x00D800 <= p && p <= 0x00DB7F) || // 上位サロゲート(High Surrogates)
          (0x00DB80 <= p && p <= 0x00DBFF) || // 上位私用サロゲート(High Private Use Surrogates)
          (0x00DC00 <= p && p <= 0x00DFFF) || // 下位サロゲート(Low Surrogates)
          (0x00E000 <= p && p <= 0x00F8FF) || // 私用領域(Private Use Area)
          (0x00FFF0 <= p && p <= 0x00FFFF) || // 特殊用途文字(Specials)
          (0x0F0000 <= p && p <= 0x0FFFFF) || // 補助私用領域A(Supplementary Private Use Area-A)
          (0x100000 <= p && p <= 0x10FFFF)    // 補助私用領域B(Supplementary Private Use Area-B)
      ) {
        ; // 何もしない
      } else {
        codePoints.push(p);
      }
    }

    const a = String.fromCodePoint(...codePoints);
    const b = await fromCodePoint(...codePoints);

    console.assert(a === b, codePoints);
    console.log('Testing...');
  }

  console.log('COMPLETE');
}

test(fromCodePoint);

※ ネストが深くなり読みづらくなるのを避けるため一部ブラウザでは非対応の Promise.withResolvers() を使用しています
※ 最新の Chrome でのみ確認しています

developer.mozilla.org ja.wikipedia.org


① String.fromCodePoint()

まずは王道、ビルトインで用意されている String.fromCodePoint() を素直に使いましょう。

String.fromCodePoint(
  171581, 12362, 12356, 12375, 12356, 128031,
); // => '𩸽おいしい🐟'

developer.mozilla.org


② eval() + Unicode エスケープシーケンス

\x12\u1234\u{12345} などの「Unicode エスケープシーケンス」を動的に作ってみます。

「Unicode エスケープシーケンス」には以下の3つの記法があります。

  • 16進数で2桁の場合は \x12
  • 16進数で4桁の場合は \u1234
  • 16進数で2桁でも4桁でもない場合は \u{12345}


普通に文字列結合するとうまくいきいません。

'\\u' + '1234' // => '\\u1234'

テンプレートリテラルでも無理。

`\\u${1234}` // => '\\u1234'


よって動的に生成した文字列を、eval() で JavaScript の(静的な)文字列として評価します。

eval(`\\u${1234}`); // => 'ሴ'


汎用的にすると以下です。

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');

  return eval(`"${ str }"`);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を eval() にわたすのは危険なのでやめましょう

developer.mozilla.org


③ Function() + Unicode エスケープシーケンス

Function コンストラクタも文字列を JavaScript として評価してくれます。

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');
  const fnc = Function(`return "${ str }";`);

  return fnc();
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を Function() にわたすのは危険なのでやめましょう

developer.mozilla.org


④ setTimeout() + Unicode エスケープシーケンス

setTimeout() も文字列を JavaScript として評価してくれます。非同期になってしまうのが厄介ですが、無理やり使ってみましょう。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();
  const globalResolveAlias = `__resolve_${ Date.now() }_${ Math.random()*1e9 | 0 }__`; // あんまりかぶらなそうなエイリアス名
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');

  window[globalResolveAlias] = resolve; // setTimeout() に渡した文字列はグローバルコンテキストで評価されるので

  setTimeout(`${ globalResolveAlias }("${ str }")`);

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を setTimeout() にわたすのは危険なのでやめましょう
setInterval でも頑張ればできそうですが本記事では割愛します

developer.mozilla.org developer.mozilla.org


⑤ JSON.parse() + Unicode エスケープシーケンス

まず、JSON の仕様を JSON RFC 8259 Section 7 で確認してみます。

JSON ではコードポイントが 0x000xFF までの「基本多言語面(BMP: Basic Multilingual Plane)」の文字は \u1234 のような(6文字の)エスケープ記法で記述できます。

Any character may be escaped. If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF), then it may be represented as a six-character sequence: a reverse solidus, followed by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point.

一方コードポイントが「基本多言語面(BMP: Basic Multilingual Plane)」以外の文字(コードポイントが 0xFF を超える文字)は UTF-16 のサロゲートペアで、つまり \uD867\uDE3D のような(12文字の)エスケープ記法で記述できます。

To escape an extended character that is not in the Basic Multilingual Plane, the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair.

試しに JavaScript の文字列で使える前述の3つの Unicode エスケープ記法を JSON.parse() に渡してみます。仕様通り \u0041 の16進数4桁限定の記法のみをパースしてくれます。

JSON.parse('"\\x41"'); // => SyntaxError: Bad escaped character in JSON
JSON.parse('"\\u0041"'); // => 'A'
JSON.parse('"\\u{41}"'); // => SyntaxError: Bad Unicode escape in JSON

「𩸽」(U+29E3D)でも試してみましょう。やはり16進数表記が4桁を超える場合は UTF-16 のサロゲートペアの2つのコードユニットで記述する必要があります。

JSON.parse('"\\u{29E3D}"'); // => SyntaxError: Bad Unicode escape in JSON
JSON.parse('"\\uD867\\uDE3D"'); // => '𩸽'


以上より、0x10000 以上のコードポイントを UTF-16 のサロゲートペア、つまり2つの UTF-16 のコードユニットに分割したうえで、\u1234 のような4桁限定の Unicode エスケープシーケンス記法で書き換えて JSON.parse() に渡してみます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const str = codeUnits.map(n => `\\u${n.toString(16).padStart(4, '0')}`).join('');

  return JSON.parse(`"${ str }"`);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

www.rfc-editor.org developer.mozilla.org ja.wikipedia.org


⑥ CSS + content プロパティ + Unicode エスケープシーケンス

CSS の content プロパティにも Unicode エスケープシーケンスは使えます。CSS での Unicode エスケープシーケンスの記法は \12\123\1234\12345 のように Unicode のコードポイントの16進数表記の左にバックスラッシュ \ のみを加えたものです。(u{} は不要)

const style = document.createElement('style');

document.head.appendChild(style);

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => '\\' + n.toString(16)).join('');

  style.textContent = `
    body::after {
      content: "${ str }";
    }
  `.trim();

  const content = document.defaultView.getComputedStyle(
    document.body,
    '::after',
  ).content;

  return (
    content
      .replace(/^"/, '')
      .replace(/"$/, '')
      .replace(/\\"/g, '"')
      .replace(/\\\\/g, '\\')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'


content プロパティは before / after 疑似要素専用のプロパティではないので疑似要素を介さず、例えば直接 document.body.stylecontent プロパティを使っても良いです。

const style = document.body.style;

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => '\\' + n.toString(16)).join('');

  style.content = `"${ str }"`;

  return (
    style.content
      .replace(/^"/, '')
      .replace(/"$/, '')
      .replace(/\\"/g, '"')
      .replace(/\\\\/g, '\\')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑦ DOMParser + HTML 文字参照

「Unicode エスケープシーケンス」に代わり、ここからしばらくは「HTML 文字参照(HTML Character Reference)」シリーズです。
コードポイントを &#1234; のような「数値文字参照(Numeric Character Reference)」にエンコードし、それを DOMParser にデコードさせてみましょう。

function fromCodePoint(...codePoints) {
  return new DOMParser().parseFromString(
    codePoints.map(n => `&#${n};`).join(''),
    'text/html',
  ).body.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

なお、よくわからない文字列、第三者が入力した文字列を安易に HTML としてパースし、生成されたノードを元のコンテキストなどに挿入するのは危険です。以下にその例を示します。

document.body.appendChild(new DOMParser().parseFromString(
  '<img src="" onerror="alert(`hoge`)">',
  'text/html',
).body.firstChild); // => alert() (などの JavaScript)が実行されてしまう

developer.mozilla.org ja.wikipedia.org developer.mozilla.org


⑧ innerHTML + HTML 文字参照

DOMParser の代わりに innerHTML も使えます。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.innerHTML = codePoints.map(n => `&#${n};`).join('');

  return (
    div.innerHTML
      .replace(/&amp;/g,  '&')
      .replace(/&lt;/g,   '<')
      .replace(/&gt;/g,   '>')
      .replace(/&nbsp;/g, '\xa0')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

パース後の文字列を取り出すのに innerHTML を使うと&amp;&lt;&gt;&nbsp; を特別扱いする必要がありますが textContent を使うとその必要はなくなります。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.innerHTML = codePoints.map(n => `&#${n};`).join('');

  return div.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

ちなみに入力に textContent を使うと、innerHTML or textContent のどちらで取得しても「HTML 文字参照」のままになります。

※ よくわからない文字列、第三者が入力した文字列を安易に innerHTML に突っ込むのは危険です。ちなみに渡された文字列を無害化(Sanitize)する setHTML() メソッドは Experimental → Deprecated となったようです

developer.mozilla.org developer.mozilla.org


⑨ setHTMLUnsafe() + HTML 文字参照

innerHTML の代わりに setHTMLUnsafe() でも良いでしょう。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.setHTMLUnsafe(codePoints.map(n => `&#${n};`).join(''));

  return div.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に setHTMLUnsafe() に渡すのは("Unsafe" の名のとおり)危険です

developer.mozilla.org


⑩ createContextualFragment() + HTML 文字参照

RangecreateContextualFragment() でも。

const range = document.createRange();

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `&#${n};`).join('');
  const df = range.createContextualFragment(str);

  return df.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に createContextualFragment() に渡すのは危険です

developer.mozilla.org developer.mozilla.org


⑪ iframe + HTML 文字参照

iframe も試してみましたが 半角スペース '&#32;'(U+0020)が空文字 '' となってしまいます。 U+0020 の「文字実体参照(Character Entity Reference)」があれば良いのですが、残念ながら「数値文字参照(Numeric Character Reference)」しかありません。 (※ ちなみに '&nbsp;' は U+0020 ではなく U+00A0 の文字実体参照です)

そこで '&#32;'(U+0020)が '' になってしまう問題はかなり強引に hogehogehoge で解決してみました。

const iframe = document.createElement('iframe');

document.body.appendChild(iframe);

const idoc = iframe.contentDocument;

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => n === 32 ? 'hogehogehoge' : `&#${n};`).join('');

  idoc.open();
  idoc.write(str);
  idoc.close();

  return idoc.body.innerText.replace('hogehogehoge', ' ');
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に HTML としてパースするのは危険です

developer.mozilla.org developer.mozilla.org ja.wikipedia.org

ja.wikipedia.org


⑫ UTF-16 エンコード + String.fromCharCode()

コードポイントを UTF-16 エンコードすれば、つまり UTF-16 のコードユニットに変換すれば String.fromCharCode() が使えます。 0x10000 以上のコードポイントを UTF-16 のサロゲートペア、つまり2つのコードユニットに分割します。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  return String.fromCharCode(...codeUnits);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org ja.wikipedia.org ja.wikipedia.org


⑬ UTF-16 エンコード + FileReader

非同期にはなってしまいますが FileReader も使えそうです。 FileReader が UTF-32 でパースしてくれたら楽だったのですが、仕様的に難しそう(たぶん)なので UTF-16 にエンコードしちゃいましょう。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();

  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const bin = new Uint16Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const reader = new FileReader;

  reader.onload = () => {
    resolve(reader.result);
  };
  reader.readAsText(blob, 'UTF-16');

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org developer.mozilla.org developer.mozilla.org


⑭ UTF-8 エンコード + FileReader

FileReader を使うなら UTF-8 にエンコードしても良さそうですね。UTF-16 にエンコードするより面倒ですが。
UTF-8 の仕様は https://en.wikipedia.org/wiki/UTF-8 の以下の図を参考にします。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();

  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const reader = new FileReader;

  reader.onload = () => {
    resolve(reader.result);
  };
  reader.readAsText(blob, 'UTF-8');

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

en.wikipedia.org


⑮ UTF-8 エンコード + Blob + URL.createObjectURL() + fetch()

UTF-8 なら Blob から URL.createObjectURL() で動的に「オブジェクト URL」を生成し、それを fetch() しても良さそうです。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const url = URL.createObjectURL(blob);

  return fetch(url).then(res => res.text());
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ 実務では不要になった「オブジェクト URL」を URL.revokeObjectURL() で解放するのを忘れずに

developer.mozilla.org developer.mozilla.org


⑯ UTF-8 エンコード + Blob + URL.createObjectURL() + XMLHttpRequest

fetch() を(非同期/同期の指定が可能な)XMLHttpRequest で置き換えれば非同期ではなく同期にできますね。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob([bin], { type: 'text/plain' });
  const url = URL.createObjectURL(blob);
  const xhr = new XMLHttpRequest;

  xhr.open('GET', url, false);
  xhr.send(null);

  return xhr.responseText;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ メインのスレッドで XMLHttpRequest を同期で使うとブラウザが一時的に固まるなどの可能性があるので控えましょう

developer.mozilla.org


⑰ UTF-8 エンコード + Blob + text()

回りくどいことをしてきましたが、UTF-8 であれば Blob の(UTF-8 専用の)text() で良いですね。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob([bin], { type: 'text/plain' });

  return blob.text();
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑱ UTF-8 エンコード + TextDecoder

なんと TextDecoder を使えばシンプルに、且つ同期での変換が可能です。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const arr = new Uint8Array(codeUnits);
  const utf8 = new TextDecoder();

  return utf8.decode(arr);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑲ UTF-16 エンコード + TextDecoder

UTF-16 でも TextDecoder にパースしてもらえます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const arr = new Uint16Array(codeUnits);
  const utf16 = new TextDecoder('UTF-16');

  return utf16.decode(arr);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

じゃあもしかして TextDecoder は UTF-32 でもパースしてくれるかな?と試してみると、、、

new TextDecoder('UTF-32'); // => RangeError: Failed to construct 'TextDecoder': The encoding label provided ('UTF-32') is invalid.

残念。

developer.mozilla.org


⑳ UTF-16 エンコード + % エンコード + unescape()

%XX%uXXXX などの「% エンコード」を使ってみます。 unescape() は UTF-16 のコードユニットを「% エンコード」した文字列をデコードします。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const str = codeUnits.map(n => `%u${n.toString(16).padStart(4, '0')}`).join('');

  return unescape(str);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ ただしunescape() は「非標準」の関数で現在は(ほぼ)「非推奨(Deprecated)」です。

developer.mozilla.org


㉑ UTF-8 エンコード + % エンコード + decodeURIComponent()

unescape() は「非標準」なので代わりに decodeURIComponent() を使ってみます。

unescape() が UTF-16 のコードユニットを「% エンコード」した文字列をデコードするのと違い
decodeURIComponent() は UTF-8 のコードユニットを「% エンコード」した文字列をデコードすることに注意してください。

「% エンコード」の記法も %XX のみです。

unescape('%u0041'); // => 'A'
decodeURIComponent('%u0041'); // => URIError: URI malformed
unescape('%41'); // => 'A'
decodeURIComponent('%41'); // => 'A'
function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const str = codeUnits.map(n => `%${n.toString(16).padStart(2, '0')}`).join('');

  return decodeURIComponent(str);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

decodeURI() は一部の文字をデコードしないことにも注意が必要です。

ja.wikipedia.org developer.mozilla.org developer.mozilla.org


㉒ UTF-8 エンコード + % エンコード + URLSearchParams

いよいよ最後!
URLSearchParams も「% エンコード」された文字列をデコードしてくれます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const str = codeUnits.map(n => `%${n.toString(16).padStart(2, '0')}`).join('');
  const params = new URLSearchParams(`q=${str}`);

  return params.get('q');
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

ちなみに URLhashsearch(の getter)は「% エンコード」された文字列をデコードしてくれませんでした。

const url = new URL('https://www.kayac.com/?q=%F0%A9%B8%BD');

url.search; // => '?q=%F0%A9%B8%BD'
url.searchParams.get('q'); // => '𩸽'
const url = new URL('https://www.kayac.com/');

url.search = '?q=%F0%A9%B8%BD';
url.searchParams.get('q'); // => '𩸽'
const url = new URL('https://www.kayac.com/#%F0%A9%B8%BD');

url.hash; // => '#%F0%A9%B8%BD'

developer.mozilla.org



まとめ

とても長くなりましたが最後まで読んでいただき、ありがとうございます。

今回は「コードポイント」を「文字列」にする22の方法をまとめてみました。 のっぴきならない理由で String.fromCodePoint() が使えない、使いたくない、という方には有用な記事となっております。 変換する方法はまだまだあると思うので、他にもこんな面白い方法がある!という方はぜひ教えてください。

本記事の内容は、もしかしたら現在開催中のJS体操第3問「Zalgo Textの生成」のヒントにもなるかもしれません。

『JS体操』とは面白法人カヤックが主催する JavaScript のコードゴルフ大会。
その第3問は任意の文字列を「Zalgo Text」に変換する JavaScript の長〜いコードをできるだけ短くする!というお題です。 元々2222文字もあるコードを、なんと115文字以下まで減らせます。(現状の最短記録は114文字です)

みなさまのご参加をお待ちしております!

JS体操第3問「Zalgo Text の生成」

JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com

techblog.kayac.com

【JS体操第3問ヒント③】「疎」な配列を「密」にする12の方法

こんにちは!面白プロデュース事業部のおばらです。
今回は JS体操第3問「Zalgo Text の生成」の問題のヒントにもなるかもしれないシリーズ第3弾。

JavaScript で「疎」な配列を(要素数は変えずに)「密」にする12の方法を

  • コードゴルフ(文字数を短くしたい場合)
  • ワンライナ(1行で書きたい場合)

的な視点も交え、まとめてみます。


JS体操第3問「Zalgo Text の生成」 とこれまでのヒント記事については以下をご覧ください。

JS体操第3問「Zalgo Text の生成」
JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com


目次


疎な配列とは

配列の要素の値として undefined でも null でもない概念「空(empty)」があります。

「空(empty)」 の「要素(スロット、slot)」を1つ以上持つ配列は、「疎(sparse)」な配列、「疎配列(sparse array)」と呼ばれます。つまり歯抜けの配列、隙間がある配列です。

一方「空(empty)」の要素を1つも持たない配列、つまり「疎(sparse)」ではない配列は「密(dense)」な配列と呼ばれることもあります。つまりぎっしり詰まった配列です。

developer.mozilla.org


疎な配列の作り方

中身がすべて or 一部「空(empty)」な「疎配列」の作り方の例を以下にいくつか示します。実務では意図して「疎」な配列を作りたい機会はあまり無いかもしれませんが、逆に意図せず配列を「疎」にしてしまう可能性に注意しましょう。

なおサンプルコードでは配列の長さ(length)、要素数は 10 とします。


配列リテラル

配列リテラルで要素の値を指定せずコンマのみで。

const arr = [,,,,,,,,,,];

arr; // => [empty × 10]
const arr = [0,,1,,2,,3,,4,,];

arr; // => [0, empty, 1, empty, 2, empty, 3, empty, 4, empty]


length プロパティの指定

配列リテラルで長さ 0 の配列を生成し、後から length を指定。

const arr = [];

arr.length = 10;
arr; // => [empty × 10]
const arr = [0,1,2,3,4];

arr.length = 10;
arr; // => [0, 1, 2, 3, 4, empty × 5]


添字アクセス

配列リテラルで長さ 0 の配列を生成し、後から添字アクセスで指定。

const arr = [];

arr[9] = 0;
arr; // => [empty × 9, 0]
const arr = [0,1,2,3,4];

arr[9] = 0;
arr; // => [0, 1, 2, 3, 4, empty × 4, 0]


delete で要素を削除

密な配列の要素を delete 演算子で削除。

const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

delete arr[0];

arr; // => [empty, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

delete arr[5];

arr; // => [0, 1, 2, 3, 4, empty, 6, 7, 8, 9]
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

delete arr[9];

arr; // => [0, 1, 2, 3, 4, 5, 6, 7, 8, empty]

developer.mozilla.org


Array() コンストラクタ

Array() コンストラクタの引数で配列の長さ、要素数を指定。

const arr = new Array(10);

arr; // => [empty × 10]


なお new 演算子は省略できます。

const arr = Array(10);

arr; // => [empty × 10]


developer.mozilla.org


他にも「疎」な配列の作り方、意図せず「疎」になってしまう処理があるので注意しましょう。



疎な配列のデメリット

特にコードゴルフやワンライナにおいては「疎」な配列を「密」な配列にしたいことがよくあります。 例えば何らかの処理を任意の回数だけ繰り返したい場合です。

console.log()w と10回だけ出力したいとします。
長さが 10 の配列と forEach() で楽勝!と思いきや、以下のコードではなぜか1回も出力されません。

const arr = Array(10); // => [empty × 10]

arr.forEach(() => console.log('w')); // => 1回も出力されない

forEach() は「空(empty)」の要素に対して処理を行ってくれない、つまり「空」の要素をスキップしてしまうからです。


10回出力されるためには「空」の要素をすべて何らかの値で埋める必要があります。この場合、埋める値は何でも良いので 0 で埋めてみます。

const arr = Array(10).fill(0); // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

arr.forEach(() => console.log('w')); // => ちゃんと10回出力される!


このように、特にコードゴルフやワンライナでは「疎」な配列を「密」にしたい、 つまり値はなんでも良いからとにかく「空(empty)」の要素を「空」以外の何かにしたいケースはよくあります。


配列のメソッドにおける「空(empty)」要素の扱いの違い

配列のインスタンスメソッドはメソッドによって「空(empty)」の扱いに違いがあります。

以下の(古めの)メソッドは配列の「空」の要素をスキップしたり(undefined ではなく)「空」のままにしたりします。 (MDNより)


以下の(新しめの)メソッドは「空(empty)」の要素を undefined と同様に扱います。 (MDNより)
join()toLocaleString() などは新しめのメソッドではないですが)


配列のメソッドによって 「空(empty)」の要素の扱いが異なることに注意しましょう。

developer.mozilla.org


「疎」な配列を「密」にする12の方法

さていよいよ本題。コードゴルフにも役立つかもしれない、「疎」な配列を(要素数を変えずに)「密」にする12の方法をご紹介します。

① for ループ

まずは一番シンプルな方法から。

const arr = new Array(10);

for (let i = 0; i < arr.length; ++i) {
  arr[i] = 0;
}

arr; // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

for 文は式、値として扱えないのでコードゴルフでは使いづらいですね。

developer.mozilla.org


② fill()

すでにサンプルコードでも登場した fill()。 その名の通り配列の要素を任意の値で埋めるメソッドです。

const arr = new Array(10).fill(0);

arr; // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

この方法はよく使われており、コードの意図もわかりやすいです。


fill() の引数を省略すると undefined を指定したことになります。

const arr = new Array(10).fill();

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]


developer.mozilla.org


③ スプレッド構文で展開(コピー)

配列は反復可能(iterable)です。 具体的に言うと Array.prototype[Symbol.iterator] が定義・実装されています。

粗配列をスプレッド構文で展開すると「空(empty)」の要素が undefined になります。

const arr = [...Array(10)];

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

配列リテラルとスプレッド構文の組み合わせは、配列を(手軽に)コピーする方法としてもよく使われます。


配列自体が反復可能ですが、あえて「イテレータ(Iterator)」を返すメソッドを挟んでみるとこうなります。

const arr = [...Array(10).keys()];

arr; // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const arr = [...Array(10).values()];

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]
const arr = [...Array(10).entries()];

arr; // => [Array(2), Array(2), Array(2), Array(2), Array(2), Array(2), Array(2), Array(2), Array(2), Array(2)]


ちなみにスプレッド構文が無かった時代によく用いられていた配列のコピー方法、 concat()slice() を使う方法では「疎」の配列を「密」にすることはできません。 concat()slice() は「空(empty)」を undefined と同等に扱わない(古い)メソッドだからです。

const arr = [].concat(Array(10));

arr; // => [empty × 10]
const arr = [].slice.apply(Array(10));

arr; // => [empty × 10]


developer.mozilla.org

developer.mozilla.org


④ Array() + Array()

Array() を二重に使ってみます。

const arr = Array(...Array(10));

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑤ Array.apply()

const arr = Array.apply(null, Array(10));

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

④ とやっていることはほぼ同じですね。


ちなみに apply() の兄弟、call() を無理やり使うとしたらこう。

const arr = Array.call(null, ...Array(10));

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

apply()call() はクラス記法が登場する前はサブクラスのコンストラクタ関数内でスーパークラスのコンストラクタ関数を実行するときによく使っていました。


developer.mozilla.org

developer.mozilla.org


⑥ Array.of() + スプレッド構文

const arr = Array.of(...Array(10)); 

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑦ Array.from()

Array.from() は「反復可能(iterable)なオブジェクト」 or「array-like なオブジェクト」を(本物の)配列にしてくれるクラスメソッドです。 この場合の array-like とは length プロパティを持つオブジェクトのことです。

Array(10) は反復可能で且つ array-like なオブジェクトです。(本物の配列を array-like と呼ぶもおかしいですが)

const arr = Array.from(Array(10));

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

同時に値も指定したい場合は第2引数に渡す関数で。

const arr = Array.from(Array(10), () => 0);

arr; // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
const arr = Array.from(Array(10), (_, i) => i);

arr; // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]


イテレータからでも。

const arr = Array.from(Array(10).keys());

arr; // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]


型付き配列からでも。

const arr = Array.from(new Int8Array(10));

arr; // => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]


ためしに自作の array-like オブジェクト、つまり length プロパティを持つオブジェクトを渡してみます。

const arr = Array.from({ length: 10 });

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]
const arr = Array.from({ length: Number(10) });

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]
const arr = Array.from({ length: '10' });

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]
const arr = Array.from({ length: '    10    ' });

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]


ちなみに、Array.from() が「反復可能(iterable)であること」と「array-like であること」のどちらを優先するかは、例えば以下のコードでチェックすることができます。

// 反復可能なオブジェクト
const obj = {
  [Symbol.iterator]() {
    return ['反復可能'][Symbol.iterator]();
  },
};

Array.from(obj); // => ['反復可能']
// array-like なオブジェクト
const obj = {
  0: 'array-like',
  length: 1,
};

Array.from(obj); // => ['array-like']
// 反復可能で且つ array-like なオブジェクト
const obj = {
  0: 'array-like が優先',
  length: 1,
  [Symbol.iterator]() {
    return ['反復可能が優先'][Symbol.iterator]();
  },
};

Array.from(obj); // => ['反復可能が優先']


developer.mozilla.org

tc39.es


⑧ toSorted()

sort() の非破壊版、toSorted() も使えます。 toSorted() は「空(empty)」を undefined と同等に扱ってくれます。

const arr = Array(10).toSorted();

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑨ toReversed()

reverse() の非破壊版、toReversed() も。

const arr = Array(10).toReversed();

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑩ toSpliced()

splice() の非破壊版、toSpliced() も。

const arr = Array(10).toSpliced();

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑪ with()

const arr = Array(10).with();

arr; // => [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined]

developer.mozilla.org


⑫ JSON.parse() + JSON.stringify()

ちょっと強引ですがこんな方法も。

const arr = JSON.parse(JSON.stringify(Array(10))); 

arr; // => [null, null, null, null, null, null, null, null, null, null]


JSON.parseevalFunction で代替しても良いですね。

const arr = eval(JSON.stringify(Array(10))); 

arr; // => [null, null, null, null, null, null, null, null, null, null]
const arr = Function(`return${JSON.stringify(Array(10))}`)();

arr; // => [null, null, null, null, null, null, null, null, null, null]

developer.mozilla.org developer.mozilla.org


本記事で紹介する方法は以上です。まだまだあるかもしれません。まだまだあるでしょう。
他にもこんな面白い方法がある!という方はぜひ教えてください。



おまけ:「粗」な配列の「空(empty)」の要素のみを取り除く方法

filter()

const arr = [0,,1,,2,,3].filter(() => true);

arr; // => [0, 1, 2, 3]

developer.mozilla.org


flat()

const arr = [0,,1,,2,,3].flat();

arr; // => [0, 1, 2, 3]

developer.mozilla.org


flatMap();

const arr = [0,,1,,2,,3].flatMap(item => item);

arr; // => [0, 1, 2, 3]


なお map() では NG です。

const arr = [0,,1,,2,,3].map(item => item);

arr; // => [0, empty, 1, empty, 2, empty, 3]


developer.mozilla.org developer.mozilla.org



まとめ

最後まで読んでいただき、ありがとうございます。

今回は「疎」な配列を「密」にする12の方法をまとめてみました。 まだまだあると思うので、他にもこんな面白い方法がある!という方はぜひ教えてください。

本記事の内容は、もしかしたら現在開催中のJS体操第3問「Zalgo Textの生成」のヒントにもなるかもしれません。

『JS体操』とは面白法人カヤックが主催する JavaScript のコードゴルフ大会。
その第3問は任意の文字列を「Zalgo Text」に変換する JavaScript の長〜いコードをできるだけ短くする!というお題です。 元々2222文字もあるコードを、なんと115文字以下まで減らせます。(現状の最短記録は114文字です)

みなさまのご参加をお待ちしております!

JS体操第3問「Zalgo Text の生成」

JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com