【JavaScript 入門講座】 文字化けさよなら:JavaScriptで安全にUnicodeを扱う方法

1. イントロダクション

「文字化け」――それはWeb開発における、かつての“風物詩”のようなものです。
メールの件名が謎の記号列になる、外部サービスと通信すると文字がぐちゃぐちゃになる……
こうした現象の多くは、「文字コード」の理解不足から生じています。


前回の記事で触れたこと

前回の記事「[Unicodeの歴史]」では、文字コードの発展とUnicodeの登場までを振り返りました。

  • ASCII や Shift_JIS のようなローカル文字コードの限界
  • 多言語化(国際化)の流れの中で Unicode が生まれた背景
  • 「1文字=1バイト」では成り立たない世界へ突入したこと

この知識は、現在のJavaScript開発においても不可欠な土台となります。


なぜUnicodeは避けて通れないのか?

JavaScriptでは、すべての文字列が内部的にUnicode(UTF-16)で扱われています。
一見すると「文字列はただの文字列」と思いがちですが、実際には
1文字が2つの単位(コードユニット)に分かれていたり
見た目は同じなのに内部表現が異なることもあります。

その結果:

  • .length が期待とズレる
  • 絵文字や特殊文字が正しく切り出せない
  • 正規表現で意図したマッチができない
  • 通信やデータ保存で文字化けが起きる

……といったトラブルが起きます。


本記事の目的

この講座では、「JavaScript × Unicode」の基本を丁寧に押さえながら、実践で避けたい罠や、便利なテクニックを紹介します。
難解に見えるUnicodeの世界を、JavaScriptの文法やAPIを通じて、楽しく・安全に扱えるようになることが目標です。


👉 次章では、まずJavaScriptがどのようにUnicodeを扱っているかの基本構造から見ていきましょう。


2. JavaScriptの文字列はUnicodeが基本

JavaScriptでは、すべての文字列(String型)は内部的にUnicode(UTF‑16)で表現されています
これはつまり、「JavaScriptの文字列操作=Unicodeの扱い」と言っても過言ではありません。


文字列の正体:UTF‑16コードユニットの配列

JavaScriptの文字列は、厳密には「UTF‑16でエンコードされたコードユニットの配列」としてメモリに格納されています。

const str = "A";
console.log(str.charCodeAt(0)); // 65(=U+0041)

この例の "A" は1つのコードユニット(16bit)で表現できる、いわゆる BMP(基本多言語面) に含まれる文字です。

const emoji = "😄"; // U+1F604
console.log(emoji.length); // 2

一方、"😄" のような絵文字は サロゲートペア(後述) で表現され、2つの16bitユニット(計32bit) で1文字が構成されます。


.length に潜む罠

console.log("A".length);      // 1
console.log("😄".length);     // 2 ← 見た目は1文字なのに!
console.log("👨‍👩‍👧‍👦".length); // 11 ← ファミリー絵文字(ZWJシーケンス)
  • String.prototype.lengthコードユニット数であり、「見た目の文字数」ではありません。
  • つまり、「1文字」=「1 length」ではない ことに注意が必要です。

なぜUTF‑16なのか?

UTF‑16は、Unicodeを効率的に扱うために開発された可変長エンコーディング方式です。

  • U+0000〜U+FFFF(BMP) の範囲は1ユニット(16bit)で表現
  • U+10000以上(補助面) の文字はサロゲートペア(2ユニット=32bit)で表現

JavaScriptではECMAScript仕様により、文字列はこのUTF‑16方式で扱われることが決まっており、エンジン(V8、SpiderMonkeyなど)に関係なく共通の挙動になります。


実際の影響は?

この仕組みを知らずに文字列を扱うと、以下のようなバグが起きやすくなります:

  • 文字を1つずつ処理しているつもりが、絵文字や一部の漢字で文字化けや分割エラーが発生
  • 正規表現で意図したマッチができない(例:. が絵文字を分割する)
  • スプレッド演算子や Array.from() を使って正しく処理しないと、ユーザー入力のバリデーションが壊れる

結論

JavaScriptの文字列は見た目通りではない。
「見える1文字」が、内部では複数のコードユニットにまたがっていることを常に意識することが重要です。

3. サロゲートペアと結合文字

「JavaScriptの文字列はUnicodeで表現される」と聞いても、最初はピンとこないかもしれません。 しかし実際に扱うと、「1文字扱いしたのに分裂した」「思ったより .length が多い」といった問題に直面します。 その正体こそが――サロゲートペア結合文字です。


🔹 サロゲートペアとは?

定義:

  • UTF-16では、U+10000 以上の文字(補助面)を表すために、**2つの16bitの値(=コードユニット)**で1文字を構成します。
  • この「2つで1文字」をまとめたものが、サロゲートペアです。

🧪 例:

const emoji = "😄"; // U+1F604

console.log(emoji.length);       // 2
console.log(emoji.charCodeAt(0).toString(16)); // d83d
console.log(emoji.charCodeAt(1).toString(16)); // de04
  • "😄"\uD83D\uDE04 の2つで構成されており、見た目は1文字でも .length は2になります。

落とし穴

console.log(emoji[0]); // "\uD83D" → 無意味な半分だけの文字

これを無理に .slice().split("") で処理すると、サロゲートが分離されてバグる原因になります。


🔹 結合文字とは?

定義:

  • 2つ以上の文字が視覚的には1つの文字として表示される組み合わせ。
  • 例:e + アクセント記号 ◌́ → 見た目は é

例:

const combined = "e\u0301"; // U+0065 + U+0301
console.log(combined);      // 表示は é
console.log(combined.length); // 2(コードユニットでも2)

一方、"é" のプリミティブ文字(U+00E9)は .length === 1。 つまり:

見た目 内部構造 .length
é(単体) U+00E9 1
e + ◌́ U+0065 + U+0301 2

この2つがもたらす罠

処理 問題の例
split("") サロゲートペアや結合文字を分解して壊す
.charAt(n) 見た目と違う文字を返す("👨‍👩‍👧‍👦" は分割不能)
.slice() Unicodeの境界を壊しやすい
バリデーション 絵文字入り名前などの「1文字制限」で落ちる

🛠 正しく扱うには?

今後の章で詳しく紹介しますが、ここでは軽く対処法を紹介します:

// スプレッドで安全に分解(見た目の文字単位)
const graphemes = [..."👨‍👩‍👧‍👦"];
console.log(graphemes.length); // 1(見た目どおり)

または、より厳密に扱いたい場合は、 Intl.Segmentergrapheme-splitter ライブラリの活用も選択肢です。


まとめ

  • サロゲートペア:補助面の文字(例:絵文字)は2コードユニットで構成される
  • 結合文字:複数のUnicode文字が1つの“見た目の文字”になる
  • JavaScriptでは、.length.slice()が「見た目通りに動かない」ことがある
  • 文字列操作では、常に“Unicode境界”に注意が必要

4. エスケープシーケンスと文字リテラル

JavaScriptのコード中で直接書けない文字や特殊文字を表す方法が、**エスケープシーケンス(escape sequences)**です。 Unicode文字も、さまざまな方法でエスケープして記述できます。 この章では、3種類の表記法とその違い、使いどころを整理します。


\xHH(1バイト・2桁の16進数)

console.log("\x41"); // "A"
  • 範囲:\x00\xFF(=0〜255)
  • ASCII範囲のみ対応
  • 日本語やEmojiなど、多バイト文字は使えない
  • ほぼ過去互換・限定用途向け

\uXXXX(4桁の16進数)

console.log("\u3042"); // "あ"
console.log("\u2764"); // "❤"
  • 範囲:\u0000\uFFFF(BMP内)
  • 基本多言語面(BMP)までのUnicode文字を記述可能
  • サロゲートペアが必要な補助面の文字(Emojiなど)には不十分

例:😄(U+1F604)は \uD83D\uDE04 のペアで表現する必要あり

console.log("\uD83D\uDE04"); // 😄

\u{XXXXX}(コードポイント形式:ES2015以降)

console.log("\u{1F604}"); // 😄
console.log("\u{2764}\u{FE0F}"); // ❤️
  • Unicodeコードポイントそのものを指定できる
  • 1〜6桁の16進数まで対応(補助面もOK)
  • サロゲートペアを意識せず、自然な書き方ができる
  • use strict モードでも利用可能(ES2015+)

よくある誤解:「文字コード=見た目」ではない

console.log("\u00e9");         // é (1文字)
console.log("e\u0301");        // é (合成:2文字)
console.log("é".length);       // 1
console.log("e\u0301".length); // 2

両方とも表示は「é」ですが、内部表現も.lengthも異なります。 この違いが、**文字列比較や検証での“罠”**になります。


注意点

記法 サポート 備考
\xHH 古いJSから対応 ASCIIのみ/実用性は低い
\uXXXX 全環境対応 BMPまでOK/サロゲート手動必要
\u{XXXXX} ES2015以降 一番推奨。コードポイントをそのまま扱える

まとめ

  • JSではエスケープシーケンスを使って文字を数値として記述できる
  • 特に \u{} 記法を使うと、補助面の文字やEmojiも簡潔に書ける
  • 見た目が同じでも、内部構造は異なることがある(正規化・比較に注意)

5. 安全に文字列操作する方法

前章までで、JavaScriptの文字列は単なる“1文字ずつ”の配列ではなく、
サロゲートペア結合文字など、視覚と内部構造が一致しないケースがあると紹介しました。

この章では、それらを安全に扱うための実践的な手法をご紹介します。


危険な操作例(やりがち)

const emoji = "😄";
console.log(emoji.length);   // 2
console.log(emoji[0]);       // "\uD83D" ← サロゲートの“半分”

const chars = "👨‍👩‍👧‍👦".split("");
console.log(chars); // バラバラに壊れる

これらの操作は、「1見た目の文字=1コードユニット」 という誤解に基づくものです。 絵文字・合成文字(ZWJ)・アクセント文字などは壊れてしまいます


安全な操作方法


[…str](スプレッド構文)

const chars = [..."😄👍🏽é"];
console.log(chars); // ["😄", "👍", "🏽", "é"]
  • コードポイント単位で文字を分解
  • サロゲートペアを1つの見た目文字として扱える
  • for...of と同じ動き(後述)

Array.from(str)

const chars = Array.from("😄👍🏽é");
console.log(chars); // ["😄", "👍", "🏽", "é"]
  • スプレッド構文と同様にコードポイントベース
  • .map().filter() などと組みやすい

for…of ループ

for (const ch of "👨‍👩‍👧‍👦") {
  console.log(ch);
}
// 1回だけ実行される(文字単位で正しく処理)
  • 内部的にコードポイントを認識
  • .length に頼らない文字数処理が可能

正規表現 + u フラグ(Unicode aware)

const str = "😄👍🏽";
const matched = str.match(/./gu);
console.log(matched); // ["😄", "👍", "🏽"]
  • g はグローバル、uUnicodeモード
  • サロゲートペアや合成文字も安全に分割

正規表現で Unicode プロパティクラスを使う(ES2018+)

const letters = "あA1".match(/\p{L}/gu);
console.log(letters); // ["あ", "A"]
  • \p{L}文字にマッチ(L=Letter)
  • 他にも \p{Emoji}\p{Script=Katakana} など高度な分類が可能
  • u フラグ必須

Intl.Segmenter(より正確な分割)

const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
const segments = [...segmenter.segment("こんにちは👋")];
console.log(segments.map(s => s.segment)); // ["こ", "ん", "に", "ち", "は", "👋"]
  • グラフェム単位(見た目の1文字)で分割可能
  • 多言語対応、高精度
  • モダンブラウザ(Chrome/Edge/Firefox/Safari)対応済

目的別まとめ

目的 使うべき手法
見た目の文字ごとに分割 [...str], Array.from(), for...of, Intl.Segmenter
高度なフィルタ(絵文字/漢字など) 正規表現 + \p{}
「1文字ずつ処理」でも壊したくない for...of, match(/./gu), Segmenter

注意:String.prototype.charAt() や .slice() などは非推奨

これらはサロゲートペアや結合文字を正しく扱えません:

"😄".charAt(0); // "\uD83D" ← NG
"😄".slice(0, 1); // "\uD83D" ← NG

安全に使いたい場合は、.codePointAt().fromCodePoint() との組み合わせが必要です(次章で紹介)。


まとめ

JavaScriptの文字列を扱うには、「見た目」と「内部構造」のズレを前提にする必要がある。

  • .length.charAt()は信用しない
  • for...ofArray.from() を使う
  • 正規表現や Intl.Segmenter を活用すれば、正確な文字列処理ができる

👉 次章では、Unicode文字の**コードポイントの取得・変換方法(.codePointAt().fromCodePoint())**にフォーカスします。

6. コードポイントの取得・変換方法

JavaScriptでは、文字のコードポイント(Unicodeの値)を取得・変換するために、次の2つのメソッドが用意されています。

  • String.prototype.codePointAt()
  • String.fromCodePoint()

これらを使うことで、サロゲートペアを意識せず、正確にUnicode文字を扱うことが可能になります。


codePointAt(index) – コードポイントの取得

基本構文

const code = "😄".codePointAt(0);
console.log(code); // 128516
console.log(code.toString(16)); // "1f604"
  • "😄" は U+1F604(十進で 128516)
  • .charCodeAt() とは異なり、サロゲートペアでも1文字として認識

注意点

const emoji = "😄";
console.log(emoji.charCodeAt(0));    // 0xD83D(上位サロゲート)
console.log(emoji.charCodeAt(1));    // 0xDE04(下位サロゲート)

console.log(emoji.codePointAt(0));   // 0x1F604(正しいコードポイント)

charCodeAt() だとサロゲートが分かれてしまうのに対し、 codePointAt()2ユニットを統合してコードポイントを返します。


String.fromCodePoint(cp) – コードポイントから文字を生成

基本構文

const emoji = String.fromCodePoint(0x1F604);
console.log(emoji); // 😄
  • 補助面でも \uD83D\uDE04 を意識せず、コードポイントのみで生成可能
  • 通常の String.fromCharCode() ではサロゲートペアの処理ができない

複数の文字もOK

console.log(String.fromCodePoint(0x1F604, 0x2764)); // 😄❤

応用:文字列からコードポイント列へ → 再生成

const text = "🎉ありがとう";
const cps = [...text].map(ch => ch.codePointAt(0));
console.log(cps); // [127881, 12354, 12426, 12364, 12392, 12358]

const reconstructed = String.fromCodePoint(...cps);
console.log(reconstructed); // 🎉ありがとう

.charCodeAt() / fromCharCode() との違い

メソッド 対応範囲 補助面(Emojiなど)
charCodeAt() UTF‑16コードユニット ❌ サロゲート分離される
codePointAt() Unicodeコードポイント ✅ 正しく取得
fromCharCode() 16bit単位(BMPまで) ❌ 補助面不可
fromCodePoint() コードポイント対応 ✅ 補助面もOK

まとめ

  • 補助面の文字(例:Emoji)を扱うなら、必ず → codePointAt() / fromCodePoint() を使う
  • サロゲートペアを意識せず、見た目どおりの文字単位で処理が可能
  • .charCodeAt()fromCharCode() は基本的に使わない方が安全

7. ファイル・通信における文字エンコーディング

JavaScript内部では、すべての文字列がUTF-16のコードユニットの配列として扱われています(前章まで参照)。 しかし、実際の現場では――

  • 📄 テキストファイルに保存する
  • 🌐 ネットワーク経由で送信する
  • 📦 APIレスポンスでJSONを受け取る

といった場面では、UTF-8その他の文字コードに変換されて扱われます。 つまり、JavaScriptの外側では「エンコーディングの選択」が不可欠になるのです。


JavaScriptの中と外の違い

観点 JavaScript内部 通信・ファイル
表現 UTF-16(コードユニット) 主にUTF-8(バイト列)
操作 文字列API (slice, codePointAt など) バイナリ変換が必要 (TextEncoder, Blob, Buffer)
目的 ユーザー操作、描画 保存、転送、圧縮など

文字列をUTF-8に変換(TextEncoder)

const text = "こんにちは";
const encoder = new TextEncoder(); // UTF-8 by default
const uint8 = encoder.encode(text);
console.log(uint8);
// → Uint8Array([...]) → バイト列としてのエンコード結果
  • TextEncoderUTF-8に変換(UTF-16 → UTF-8)
  • 通信(fetch, WebSocket)やファイル保存時に使用される形式

UTF-8 → 文字列へ(TextDecoder)

const decoder = new TextDecoder("utf-8");
const decoded = decoder.decode(uint8);
console.log(decoded); // こんにちは
  • 受信した バイト列を再び文字列へ復元する
  • 他にも "shift_jis""utf-16le" を指定可能(対応ブラウザ確認要)

通信時の文字コード(fetchの例)

fetch("/data.json")
  .then(res => res.text()) // UTF-8として読み込まれる
  .then(data => console.log(data));
  • HTMLやHTTPレスポンスのContent-Type: application/json; charset=utf-8 が重要
  • JavaScriptが自動でUTF-8として解釈する

ファイル保存・読み込み(Blob / FileReader)

// UTF-8でファイルダウンロード
const blob = new Blob(["こんにちは"], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hello.txt";
a.click();
  • Webアプリ上でUTF-8ファイルを生成・保存できる
  • 逆に読み込む場合はFileReader.readAsText(file, "utf-8")を指定

エンコーディングミスが起きる場面

シーン 問題例
保存時の文字化け UTF-16のまま保存 → 開いた時に文字化け
通信先がSJIS UTF-8で送信 → APIが対応せず「���」になる
BOM有無の違い UTF-8 with BOMとwithout BOMが混在して問題に

※ 特に古いCSV処理Excel連携ではShift_JISが絡みやすく要注意です。


ユースケース:日本語CSVの保存

const csv = "名前,年齢\n山田太郎,28";
const sjisBlob = new Blob([new TextEncoder("shift-jis").encode(csv)], {
  type: "text/csv;charset=shift-jis"
});
// 上記は一部ブラウザで非対応 → 実用ではサーバー側でエンコード処理推奨

まとめ

  • JavaScript内部は常に UTF-16
  • 通信や保存では、主に UTF-8(ときに Shift_JIS や UTF-16)
  • TextEncoder / TextDecoder を使えば、安全に相互変換できる
  • ファイル形式・通信APIごとに適切なエンコーディング指定が必要

8. Unicode処理の実践Tipsと外部ライブラリ紹介

ここまで、JavaScriptにおけるUnicodeの内部構造、表記方法、そして安全な操作方法まで一通り解説してきました。 この最終章では、実際のプロジェクトやアプリ開発で役立つ実践Tipsと、Unicodeをより安全に扱える便利なライブラリをご紹介します。


よくあるUnicodeトラブルと回避策

トラブル 原因 解決策
絵文字が途中で切れる サロゲートペアを分割 Array.from() / for...of を使う
文字数制限バグ .length がコードユニット数 Intl.Segmenter または grapheme-splitter を使う
正規表現でマッチしない ., \w がBMP前提 u フラグ + \p{} 使用
ファイル保存で文字化け UTF-8/Shift_JIS/BOMの違い TextEncoder / Blob に明示的なエンコーディング指定
入力欄の「1文字制限」破壊 合成文字が複数ユニット grapheme単位でカウント・制限する

1. grapheme-splitter

  • 「見た目の1文字(Grapheme Cluster)」単位で分割できる
  • 合成文字やZJW文字にも対応
  • 軽量・依存なし
import GraphemeSplitter from 'grapheme-splitter';

const splitter = new GraphemeSplitter();
const chars = splitter.splitGraphemes("👨‍👩‍👧‍👦こんにちは");
console.log(chars); // ["👨‍👩‍👧‍👦", "こ", "ん", "に", "ち", "は"]

2. xregexp

  • 正規表現のUnicode拡張サポート
  • \p{Letter}\p{Han} などをES6以前でも使用可能に
  • Unicodeカテゴリベースのフィルタリングに最適

3. unorm

  • Unicode正規化(NFC/NFD/NFKC/NFKD)をサポート
  • 結合文字と単体文字の比較処理に必須
"é".normalize() === "e\u0301".normalize(); // true

4. Intl.Segmenter

  • グラフェム単位分割や単語・文単位の分割も可能
  • 多言語対応・精度高
  • モダンブラウザ対応(Polyfill未対応)

開発現場でのTips

フォーム入力のバリデーション

const input = "👩‍❤️‍👨";
const graphemes = [...input];
if (graphemes.length > 10) {
  alert("入力は10文字以内にしてください");
}

データ通信での明示的encoding

fetch("/submit", {
  method: "POST",
  headers: {
    "Content-Type": "application/json; charset=utf-8"
  },
  body: JSON.stringify({ message: "こんにちは😄" })
});

ファイル保存時のBOM有無に注意

  • Windows環境下のExcelで開くCSVには BOM付きUTF-8 が必要な場合あり
  • \uFEFF を文字列の先頭に追加して出力する
const csv = "\uFEFF名前,年齢\n山田,28";

まとめ

  • Unicodeは複雑ですが、理解とツールがあれば安全に扱えます
  • JavaScriptの標準APIはだんだん進化しており、ES2015以降の機能を活用すればかなり安全
  • 外部ライブラリと組み合わせることで、合成文字・絵文字・多言語対応も現実的に