ゼロ幅スペースで文字列比較が一致しない原因を突き止める

"api" === namefalse になる。ログに出力すると両方とも api に見える。長さを取ると片方だけ 4 である。

こうなったときの犯人はだいたい不可視文字である。ゼロ幅スペース(U+200B)、BOM(U+FEFF)、全角スペース(U+3000)、ノーブレークスペース(U+00A0)あたりが、Web ページからのコピー、翻訳サービスの出力、エディタの折り返し補助などに紛れて入る。

やっかいなのは、画面に何も表示されないので目視で見つからないことと、エラーメッセージが原因を指さないことである。JSON パースエラーは「予期しないトークン」としか言わないし、比較の不一致にいたっては例外すら出ない。

この記事では、不可視文字を位置つきで洗い出して種別ごとに掃除する手順をまとめる。ブラウザで済ませたいときの不可視文字チェッカーと、CI やスクリプトに組み込むための Node / perl のコードを両方置く。

何を「不可視文字」とみなすか

一括で消せばよい、とはならない。用途によって残すべきものが変わるからだ。分類しないと、必要な文字まで巻き添えになる。

上のツールが実際に持っている分類表がそのまま出発点になる。

1
2
3
4
5
6
7
8
9
10
11
12
13
var TARGETS = [
{ code: 0x3000, kind: 'ideographic-space', fix: ' ' },
{ code: 0x00A0, kind: 'nbsp', fix: ' ' },
{ code: 0x200B, kind: 'zero-width', fix: '' },
{ code: 0x200C, kind: 'zero-width', fix: '' },
{ code: 0x200D, kind: 'zero-width', fix: '' },
{ code: 0x2060, kind: 'zero-width', fix: '' },
{ code: 0xFEFF, kind: 'bom', fix: '' },
{ code: 0x00AD, kind: 'soft-hyphen', fix: '' },
{ code: 0x2028, kind: 'line-separator', fix: '\n' },
{ code: 0x2029, kind: 'line-separator', fix: '\n' },
{ code: 0x0009, kind: 'tab', fix: ' ' }
];

範囲で拾うものも別にある。

1
2
3
4
5
6
7
if (cp >= 0x2000 && cp <= 0x200A) return { code: cp, kind: 'thin-space', fix: ' ' };
if (cp >= 0xE0100 && cp <= 0xE01EF) return { code: cp, kind: 'variation-selector', fix: '' };
if (cp >= 0xFE00 && cp <= 0xFE0F) return { code: cp, kind: 'variation-selector', fix: '' };
if (cp < 0x20 && cp !== 0x0A && cp !== 0x0D && cp !== 0x09) return { code: cp, kind: 'control', fix: '' };
if (cp === 0x7F) return { code: cp, kind: 'control', fix: '' };
if (cp >= 0x202A && cp <= 0x202E) return { code: cp, kind: 'bidi', fix: '' };
if (cp >= 0x2066 && cp <= 0x2069) return { code: cp, kind: 'bidi', fix: '' };

この表の作りで、注意して読むべき判断が3つある。

改行(U+000A / U+000D)は検出対象に入っていない。 行の区切りとして必要だからで、これを消す実装にすると1行に潰れる。一方で行区切り(U+2028 / U+2029)は対象に入っている。多くの環境で改行として扱われないためで、置き換え先は削除ではなく \n になっている。

異体字セレクタ(U+FE00〜U+FE0F / U+E0100〜U+E01EF)は既定で消さない設計になっている。 直前の漢字の字形を切り替える文字なので、人名や地名を扱う列で消すと表記が変わる。「見えないから消す」でひとまとめにしてはいけない代表例である。

BOM(U+FEFF)は消してよいとは限らない。 UTF-8 のファイル先頭にある BOM は、Excel が文字コードを判定する手がかりとして使われることがある。一方 JSON やシェルスクリプトの先頭にあると解析に失敗する。渡す相手で決まるので、ツール側も「消す/残す」を選ばせる形にしている。

位置は「コードポイント単位」で数える

見つけたあと、それが何行目の何桁目かを出す必要がある。ここで数え方を間違えると、報告した位置とエディタの表示がずれる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function scan(text){
var s = String(text == null ? '' : text);
var found = [], line = 1, col = 1, idx = 0;
for(var i = 0; i < s.length; ){
var cp = s.codePointAt(i);
var w = cp > 0xFFFF ? 2 : 1;
idx += 1;
var t = targetOf(cp);
if(t) found.push({ cp: cp, kind: t.kind, index: idx, line: line, col: col });
if(cp === 0x0A){ line += 1; col = 1; } else { col += 1; }
i += w;
}
return found;
}

s.lengthUTF-16 のコード単位の数である。JavaScript の文字列は UTF-16 なので、絵文字や一部の漢字(サロゲートペアで表される文字)は 2 と数えられる。s[i] で1文字ずつ回すと、そこで位置がずれる。

上の実装は codePointAt で読み、cp > 0xFFFF なら 2 進める。進む量は UTF-16 の単位、数える量はコードポイントの単位、と分けているのが要点である。for...of は同じことを言語側でやってくれるので、後述の Node 版ではそちらを使う。

実際に試す

1. その場で1件だけ見たいとき

貼り付けるだけなら不可視文字チェッカーが速い。見つかった文字が U+200B のようなバッジになって、周りの文脈と一緒に位置が見える。掃除する種別はチェックボックスで選べて、既定では見えないまま害になりやすいもの(ゼロ幅文字・BOM・ソフトハイフン・制御文字・書字方向の制御)だけが入っている。全角スペースと異体字セレクタは既定では外れている。処理はブラウザ内で完結するので、貼り付けた内容が送信されることはない。

2. ファイルを走査する(perl)

macOS の /usr/bin/grep-P(PCRE)に対応していない。

1
2
$ /usr/bin/grep -P '\x{200b}' config.json
grep: invalid option -- P

Homebrew で GNU grep や ugrep を入れていれば通るが、環境に依存させたくない。**perl はどの環境にもほぼ入っていて、-CSD で入出力を UTF-8 として扱える**ので、こちらのほうが確実である。

1
2
3
perl -CSD -ne 'while (/([\x{200B}-\x{200D}\x{2060}\x{FEFF}\x{00AD}\x{00A0}\x{3000}\x{2028}\x{2029}\x{202A}-\x{202E}\x{2066}-\x{2069}])/g) {
printf "%s:%d:%d: U+%04X\n", $ARGV, $., pos($_), ord($1);
}' config.json

BOM とゼロ幅スペースと全角スペースが混ざった config.json に対する出力はこうなる。

1
2
3
4
config.json:1:1: U+FEFF
config.json:2:13: U+200B
config.json:3:1: U+3000
config.json:3:2: U+3000

$ARGV がファイル名、$. が行番号、pos($_) がマッチ直後の位置なので、1文字マッチなら桁がそのまま出る。file:line:col: の形なので、エディタやエラーパーサにそのまま食わせられる。複数ファイルなら引数を並べればよい。

1
perl -CSD -ne '...' $(git ls-files '*.json' '*.yml' '*.md')

3. スクリプトに組み込む(Node.js)

CI で落としたい、あるいは掃除まで自動でやりたいなら Node のほうが扱いやすい。Node.js 20 以降があれば依存は要らない。上のツールと同じ分類表を最小構成で写したものを inv.js として置く。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
"use strict";
const TARGETS = new Map([
[0x3000, ["ideographic-space", " "]],
[0x00a0, ["nbsp", " "]],
[0x200b, ["zero-width", ""]],
[0x200c, ["zero-width", ""]],
[0x200d, ["zero-width", ""]],
[0x2060, ["zero-width", ""]],
[0xfeff, ["bom", ""]],
[0x00ad, ["soft-hyphen", ""]],
[0x2028, ["line-separator", "\n"]],
[0x2029, ["line-separator", "\n"]],
[0x0009, ["tab", " "]],
]);

function targetOf(cp) {
if (TARGETS.has(cp)) return TARGETS.get(cp);
if (cp >= 0x2000 && cp <= 0x200a) return ["thin-space", " "];
if (cp >= 0xfe00 && cp <= 0xfe0f) return ["variation-selector", ""];
if (cp >= 0xe0100 && cp <= 0xe01ef) return ["variation-selector", ""];
if (cp < 0x20 && cp !== 0x0a && cp !== 0x0d && cp !== 0x09) return ["control", ""];
if (cp === 0x7f) return ["control", ""];
if (cp >= 0x202a && cp <= 0x202e) return ["bidi", ""];
if (cp >= 0x2066 && cp <= 0x2069) return ["bidi", ""];
return null;
}

// 位置は「コードポイント単位」で数える。サロゲートペアは1文字。
function scan(text) {
const found = [];
let line = 1, col = 1;
for (const ch of String(text)) {
const cp = ch.codePointAt(0);
const t = targetOf(cp);
if (t) found.push({ cp, kind: t[0], line, col });
if (cp === 0x0a) { line++; col = 1; } else { col++; }
}
return found;
}

// 選んだ種別だけを置き換える。それ以外はそのまま残す。
function clean(text, kinds) {
const allow = new Set(kinds);
let out = "";
for (const ch of String(text)) {
const t = targetOf(ch.codePointAt(0));
out += t && allow.has(t[0]) ? t[1] : ch;
}
return out;
}

const hex = (cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0");

// --- 実際に壊れている例 ---
// 先頭に BOM、"api" の中にゼロ幅スペース、3行目の字下げが全角スペース。
// 不可視文字はコピーで壊れるので、ここではエスケープで書いてある。
const pasted = "\uFEFF{\n \"name\": \"a\u200Bpi\",\n\u3000\u3000\"port\": 8080\n}";

console.log("見つかった不可視文字:");
for (const f of scan(pasted)) console.log(` ${f.line}${f.col}${hex(f.cp)} ${f.kind}`);

try { JSON.parse(pasted); } catch (e) { console.log("JSON.parse:", e.message); }

const fixed = clean(pasted, ["bom", "zero-width", "ideographic-space", "control"]);
console.log("掃除後 parse:", JSON.parse(fixed));
console.log("掃除前後の長さ:", pasted.length, "→", fixed.length);
console.log('"api" と一致するか:', JSON.parse(fixed).name === "api");

実行する。

1
node inv.js
1
2
3
4
5
6
7
8
9
10
見つかった不可視文字:
1行 1桁 U+FEFF bom
2行 13桁 U+200B zero-width
3行 1桁 U+3000 ideographic-space
3行 2桁 U+3000 ideographic-space
JSON.parse: Unexpected token '', "{
"name"... is not valid JSON
掃除後 parse: { name: 'api', port: 8080 }
掃除前後の長さ: 37 → 35
"api" と一致するか: true

この出力の読みどころを3つ挙げる。

JSON.parse のエラーメッセージが役に立たない。 「Unexpected token ‘‘」の引用符の中には BOM が入っているのだが、表示すると何も見えない。エラー本文を見て原因が分かる形になっていないので、パースが落ちたら文字列を先に走査するほうが早い。

長さが 37 → 35 で、減ったのは 2 文字だけである。 BOM とゼロ幅スペースが消えて 2 文字。全角スペース 2 つは半角スペースに置き換わっているので長さが変わらない。「消す」と「置き換える」を種別ごとに分けているのはこのためで、字下げまで消すと今度は整形が崩れる。

bidivariation-selector を掃除対象に入れていない。 上の例では ["bom", "zero-width", "ideographic-space", "control"] だけを渡している。書字方向の制御は本当に必要な文書がありうるし、異体字セレクタは前述のとおり字形の指定である。掃除する種別は明示して渡す設計にしておくと、あとで「なぜ消えたのか」を追える。

4. CI で落とす

scan() の結果が空でなければ終了コードを 1 にすればよい。上の inv.js から TARGETS / targetOf / scan / hex をそのまま持ってきて、末尾だけ次に差し替える(inv-ci.js として保存する)。

1
2
3
4
5
6
7
8
9
10
11
const files = process.argv.slice(2);
let bad = 0;
for (const f of files) {
for (const g of scan(require("fs").readFileSync(f, "utf8"))) {
// 字下げの全角スペースなど、通したい種別はここで除く
if (g.kind === "variation-selector") continue;
console.log(`${f}:${g.line}:${g.col}: ${hex(g.cp)} ${g.kind}`);
bad++;
}
}
process.exit(bad ? 1 : 0);
1
node inv-ci.js $(git ls-files '*.json' '*.md')

問題があればファイル名つきで並び、終了コードが 1 になる。

1
2
3
4
config.json:1:1: U+FEFF bom
config.json:2:13: U+200B zero-width
config.json:3:1: U+3000 ideographic-space
config.json:3:2: U+3000 ideographic-space

ここで「全部の種別を落とす」設定にしないほうがよい。 タブや全角スペースは意図して入っている文書がある。落とす対象は、まずゼロ幅文字・BOM・制御文字の3つから始めて、誤検知が出ないことを確かめてから広げるのが安全である。

まとめ

  • 「画面上は同じなのに比較が通らない」「JSON パースが落ちる」の犯人は、ゼロ幅スペース・BOM・全角スペースであることが多い
  • 一括で消してはいけない。 改行は必要、行区切りは \n に置換、異体字セレクタは字形の指定、BOM は渡す相手で判断が変わる
  • 位置はコードポイント単位で数える。s.lengths[i] は UTF-16 のコード単位なのでサロゲートペアでずれる。for...of を使う
  • コマンドラインでは grep -P を当てにしない(macOS の /usr/bin/grep は非対応)。**perl -CSD のほうが確実**で、file:line:col: 形式で出せる
  • CI に入れるなら、まずゼロ幅文字・BOM・制御文字の3種別だけから始める
  • 1件だけ確かめたいなら不可視文字チェッカーに貼るのが速い。処理はブラウザ内で完結する

本記事の分類表とロジックは、ハシトシステムの不可視文字チェッカーの実装に基づいている。出力例は Node.js 20.17.0 と macOS 標準の perl で実際に実行した結果である。


ゼロ幅スペースで文字列比較が一致しない原因を突き止める
https://blog.hashito.biz/2026/08/30/zero-width-space-invisible-chars-detect-clean/
著者
hashito
作成日
2026年8月30日
著作権