normalize と splitText でテキストノードを精密に制御する
DOM 操作を繰り返していると、目に見えないところでテキストノードが断片化していきます。一つの段落に見えるテキストが内部的には複数のテキストノードに分裂していたり、空のテキストノードが紛れ込んでいたりする状態です。normalize と splitText は、このテキストノードの結合と分割を明示的に制御するメソッドです。Range API によるハイライト処理、テキストエディタの実装、DOM の差分検出など、テキストノードの構造が処理結果に影響する場面では、この二つの理解が不可欠になります。
テキストノードの断片化とは
HTML 上では一つの連続したテキストに見えていても、DOM ツリー上では複数のテキストノードに分かれている場合があります。
const p = document.createElement('p');
p.appendChild(document.createTextNode('Hello'));
p.appendChild(document.createTextNode(' '));
p.appendChild(document.createTextNode('World'));
console.log(p.textContent); // "Hello World"
console.log(p.childNodes.length); // 3textContent で見れば一つの文字列ですが、childNodes は 3 つのテキストノードを返します。この状態が「テキストノードの断片化」です。人間の目には違いが見えませんが、プログラムにとっては大きな違いがあります。
たとえば childNodes[0].textContent を取得しても Hello しか得られず、World を含む処理をするには別のノードにアクセスしなければなりません。テキスト検索やカーソル位置の計算が断片をまたぐケースを考慮する必要が出てくるため、コードの複雑さが一気に増します。
断片化が起こる典型的な場面
意図的にテキストノードを分けて作ることは稀ですが、DOM 操作の副作用として断片化は頻繁に発生します。
Range.insertNode でテキストノードの途中にノードを挿入すると、元のテキストが前後に分割される。ハイライト処理を解除した後に断片が残りやすい。
innerHTML を使わずに DOM メソッドでテキストを編集すると、追加のたびにテキストノードが増える。append を繰り返すループ処理で顕著に発生する。
ユーザーがリッチテキスト領域で文字を入力・削除すると、ブラウザが内部的にテキストノードを分割・生成する。編集操作を重ねるほど断片化が進行する。
Range.surroundContents で要素を囲み、後からその要素を除去すると、元は一つだったテキストが複数のノードに分かれたまま残る。
normalize:断片を結合する
Node.normalize は、ノードの子孫にある隣接したテキストノードをすべて結合し、空のテキストノードを除去するメソッドです。
const p = document.createElement('p');
p.appendChild(document.createTextNode('Hello'));
p.appendChild(document.createTextNode(' '));
p.appendChild(document.createTextNode('World'));
console.log(p.childNodes.length); // 3
p.normalize();
console.log(p.childNodes.length); // 1
console.log(p.firstChild.textContent); // "Hello World"3 つのテキストノードが 1 つに結合されました。normalize は呼び出したノードの子孫すべてに対して再帰的に作用するため、深くネストされた構造でも一回の呼び出しで全体が整理されます。
断片化したテキストノードが複数存在する
normalize を呼び出す
隣接するテキストノードが結合され、空ノードが除去される
normalize の実際の効果を確認する
ハイライトを追加してから解除すると断片化が起こり、normalize で修復できることを確認してみましょう。
<p id="demo-p" style="font-size:15px; line-height:1.8; padding:8px; border:1px solid #ddd; border-radius:4px;">テキストノードの断片化を確認するデモです。</p>
<div style="margin-top:10px; font-family:monospace; font-size:13px;">
<div>childNodes.length: <span id="count">-</span></div>
<div>ノード一覧: <span id="nodes">-</span></div>
</div>
<div style="margin-top:10px;">
<button id="break-btn" style="padding:5px 12px; cursor:pointer;">断片化させる</button>
<button id="norm-btn" style="padding:5px 12px; cursor:pointer;">normalize で修復</button>
<button id="reset-btn" style="padding:5px 12px; cursor:pointer;">リセット</button>
</div>const p = document.getElementById('demo-p');
const original = p.innerHTML;
function update() {
document.getElementById('count').textContent = p.childNodes.length;
const list = [];
p.childNodes.forEach(n => {
if (n.nodeType === 3) list.push('"' + n.textContent + '"');
else list.push('<' + n.tagName.toLowerCase() + '>');
});
document.getElementById('nodes').textContent = list.join(' | ');
}
update();
document.getElementById('break-btn').addEventListener('click', () => {
const text = p.firstChild;
if (!text || text.nodeType !== 3) return;
if (text.textContent.length > 6) {
const latter = text.splitText(6);
const mark = document.createElement('mark');
mark.style.backgroundColor = '#fff59d';
mark.textContent = latter.textContent.substring(0, 4);
latter.textContent = latter.textContent.substring(4);
p.insertBefore(mark, latter);
}
update();
});
document.getElementById('norm-btn').addEventListener('click', () => {
p.querySelectorAll('mark').forEach(m => {
const parent = m.parentNode;
while (m.firstChild) parent.insertBefore(m.firstChild, m);
parent.removeChild(m);
});
p.normalize();
update();
});
document.getElementById('reset-btn').addEventListener('click', () => {
p.innerHTML = original;
update();
});「断片化させる」を押すと mark 要素の挿入によってテキストノードが分割され、childNodes の数が増えます。「normalize で修復」を押すと mark を除去した後に normalize が隣接するテキストノードを結合し、元の 1 つに戻ります。
splitText:テキストノードを分割する
Text.splitText は、テキストノードを指定したオフセットで前後に分割するメソッドです。元のノードには前半が残り、後半が新しいテキストノードとして直後に挿入されます。
const p = document.getElementById('target');
// p の中身: "Hello World" というテキストノード 1 つ
const textNode = p.firstChild;
const latter = textNode.splitText(5);
console.log(textNode.textContent); // "Hello"
console.log(latter.textContent); // " World"
console.log(p.childNodes.length); // 2splitText の戻り値は後半のテキストノードです。元のノードは前半部分に短縮され、DOM ツリー上では元のノードの直後に後半ノードが兄弟として挿入されます。
オフセットより前の文字列が残る。ノード参照はそのまま有効で、parentNode も変わらない。
オフセット以降の文字列を持つ新しいテキストノード。元ノードの直後に自動挿入される。
splitText の活用:部分ハイライト
splitText が真価を発揮するのは、テキストノードの一部だけを要素で囲みたい場面です。surroundContents はテキストノード全体を囲んでしまうため、部分的なハイライトには splitText で事前に分割する必要があります。
function highlightRange(textNode, start, end) {
// 後ろから分割しないとオフセットがずれる
const after = textNode.splitText(end); // end 以降を切り出す
const target = textNode.splitText(start); // start 以降を切り出す(= ハイライト対象)
const mark = document.createElement('mark');
mark.style.backgroundColor = '#fff59d';
target.parentNode.insertBefore(mark, target);
mark.appendChild(target);
return mark;
}
// "JavaScript" の部分だけをハイライト
// テキスト: "私はJavaScriptが好きです"
const text = document.getElementById('msg').firstChild;
highlightRange(text, 2, 12);分割の順序がポイントです。先に end で分割し、次に start で分割します。逆にすると最初の splitText でオフセットがずれてしまいます。end の位置は元のテキストノード全体に対する値ですが、start で先に分割すると元のノードが短くなるため、end のオフセットが元の位置と一致しなくなるからです。
end の位置で splitText する(後半を切り離す)
start の位置で splitText する(対象部分を切り離す)
対象テキストノードを mark 要素で囲む
空テキストノードの問題
splitText でオフセット 0 やテキスト末尾で分割すると、空文字列のテキストノードが生まれます。
const text = document.createTextNode('Hello');
const latter = text.splitText(0);
console.log(text.textContent); // ""(空テキストノード)
console.log(latter.textContent); // "Hello"
console.log(text.parentNode.childNodes.length); // 2空テキストノードは画面に表示されないため見落としやすいですが、childNodes.length や Range のオフセット計算に影響します。TreeWalker でテキストノードを走査するときに空ノードが紛れ込むと、意図しない挙動を引き起こすこともあります。
// 空テキストノードが混在すると走査結果が変わる
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT
);
let node;
while (node = walker.nextNode()) {
// 空テキストノードも列挙される
console.log('[' + node.textContent + ']'); // "[]" が混じる
}normalize を呼べば空テキストノードは除去されます。splitText を使った処理の後には normalize を呼ぶ習慣をつけると、こうした問題を防げます。ただし、分割した直後に各ノードへの参照を保持して処理を続ける場合は、normalize を呼ぶタイミングに注意が必要です。normalize は参照しているノードを消滅させる可能性があるため、すべての処理が完了してから呼ぶのが安全です。
wholeText と連結テキスト
テキストノードの wholeText プロパティは、隣接するテキストノードをまたいだ連結テキストを返します。normalize せずに論理的なテキスト全体を取得したいときに便利です。
const p = document.createElement('p');
p.appendChild(document.createTextNode('Hello'));
p.appendChild(document.createTextNode(' World'));
p.appendChild(document.createTextNode('!'));
const middle = p.childNodes[1];
console.log(middle.textContent); // " World"
console.log(middle.wholeText); // "Hello World!"wholeText は読み取り専用で、隣接するすべてのテキストノードの内容を連結した文字列を返します。DOM 構造を変更せずに論理的な全文を把握できるため、検索処理の実装で使えます。ただし wholeText はどのノードに対して呼んでも同じ結果を返すので、元のノードの境界情報は失われる点に留意してください。
normalize と splitText の組み合わせパターン
実際のアプリケーションでは、splitText で分割して処理を行い、最後に normalize で整理するというパターンが頻出します。テキスト検索でヒット箇所をハイライトし、ハイライト解除後に DOM を元に戻す流れを見てみましょう。
function searchAndHighlight(container, query) {
// まず normalize して断片化を解消
container.normalize();
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT
);
const matches = [];
let node;
// テキストノードを走査して一致箇所を収集
while (node = walker.nextNode()) {
let index = node.textContent.indexOf(query);
while (index !== -1) {
matches.push({ node, index });
index = node.textContent.indexOf(query, index + query.length);
}
}
// 後ろから処理してオフセットのずれを防ぐ
for (let i = matches.length - 1; i >= 0; i--) {
const { node, index } = matches[i];
highlightRange(node, index, index + query.length);
}
}
function clearHighlights(container) {
container.querySelectorAll('mark').forEach(mark => {
const parent = mark.parentNode;
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
});
// mark 除去後の断片化を修復
container.normalize();
}検索の前に normalize を呼ぶのは、断片化したテキストノードをまたぐ検索語を見逃さないためです。断片の境界で文字列が分断されていると、indexOf では発見できません。検索結果のハイライトでは後ろから処理することで、splitText によるオフセットのずれを回避しています。ハイライト解除後にも normalize を呼んで DOM を清潔な状態に戻します。
splitText(5) を呼んだとき、元のテキストノードに残るのはどの部分ですか?
- オフセット 0 から 4 まで(先頭 5 文字)
- オフセット 5 以降(残りの文字列)
- 元のテキスト全体がそのまま残る
- 元のテキストノードは削除される
splitText は指定オフセットで分割し、前半を元ノードに残します。後半は新しいテキストノードとして返され、元ノードの直後に挿入されます。