【テクニカル・上級編】 ::highlight擬似要素によるカスタムハイライト – CSS実践ガイド

こんにちは。フロントエンドの迷宮を日夜彷徨い、ブラウザの描画パイプラインとメモリリークの夢を見るあなたへ。

DOMノードを汚染することなく、任意のテキスト範囲をCSSだけで自由自在にハイライトしたいと思ったことはないだろうか?
検索ワードのハイライト、コードエディタの構文解析、あるいはリッチなテキストエディタでの範囲選択のカスタマイズ。これまで、私たちは何をしてきたか。テキストノードを細切れにして、一つひとつご丁寧に `` でラップし、DOMツリーを肥大化させ、ReactやVueの差分検出アルゴリズムに余計な負荷をかけ、レイアウトシフト(CLS)に怯えて生きてきたはずだ。

もう、DOMを汚すハックはやめよう。
現代のブラウザには、CSS Custom Highlight API と、それをスタイリングするための `::highlight()` 擬似要素という、真の救済が用意されている。

今回は、この `::highlight()` の内部挙動と、現場の修羅場をくぐり抜けてきたシニアエンジニアが知るべき「メモリ効率、レンダリング最適化、そして非同期処理の罠」について、徹底的に深掘りしていこう。

—

1. なぜ `::highlight()` なのか?(DOM汚染という名の原罪からの解放)

従来のハイライト処理の最大の悪夢は、「DOMの構造を書き換えること」にあった。
例えば、「JavaScript」という文字列の「Script」部分だけにスタイルを当てたい場合、親ノードの `childNodes` を走査し、`splitText()` を使ってテキストノードを分割し、要素を生成して挿入するという、DOM操作のフルコンボをかます必要があった。

これは、メモリを消費するだけでなく、以下のような致命的な問題を引き起こす。

  • フレームワークとの競合: Reactの仮想DOMが管理するツリーの外側で勝手にDOMを書き換えるため、再レンダリング時に例外や整合性の崩壊(Hydration Errorなど)を招く。
  • パフォーマンスの劣化: テキストが長大になるほど、DOMノード数が増加し、スタイル計算やレイアウトフェーズのコストが跳ね上がる。

Highlight API の基本アーキテクチャ

`::highlight()` は、DOMツリーとは完全に直交する(別レイヤーの)概念として設計されている。JavaScriptで `Range` オブジェクトの集合体(`Highlight` オブジェクト)を作り、それを `CSS.highlights` レジストリに登録する。そして、CSS側で `::highlight(identifier)` を使ってスタイリングを適用するのだ。

// 1. ハイライトしたい範囲(Range)を生成
const range = new Range();
const textNode = document.querySelector(‘#target’).firstChild;
range.setStart(textNode, 5); // 5文字目から
range.setEnd(textNode, 12); // 12文字目まで

// 2. Highlightオブジェクトに包む(複数のRangeを持てる)
const searchHighlight = new Highlight(range);

// 3. グローバルなレジストリに登録
CSS.highlights.set(‘search-results’, searchHighlight);

これに対応するCSSは、たったこれだけだ。DOMには一切手を加えず、ブラウザのペイントエンジンに直接「ここからここまでをこう塗れ」と指示を出している。

/ 登録した識別子を擬似要素の引数に渡す /
::highlight(search-results) {
background-color: #ffe066;
color: #1a1a1a;
}

美しい。だが、ここからが本題だ。実務の荒波にもまれ、数万行のテキストや非同期のストリーミングデータを扱うアプリケーションにこれを導入する際、私たちはどのような壁にぶつかるのだろうか。

—

2. メモリ効率とガベージコレクションの罠

「DOMを汚さないからメモリに優しい」と安易に考えてはいけない。`Range` オブジェクトや `Highlight` オブジェクトの管理を誤ると、確実にメモリリークを引き起こす。

DOMの変異(Mutation)とRangeの寿命

ユーザーがタイピングしたり、外部からデータが挿入されたりして、ハイライト対象のテキストノードが変更された瞬間、古い `Range` オブジェクトはどうなるか?
ブラウザは賢いので、テキストノードが削除されたりすると自動的に `Range` を無効化(collapsed状態にするなど)してくれたりするが、JavaScript側の参照が残っていると、GC(ガベージコレクション)の対象外になることがある。

特に、SPA(Single Page Application)で画面遷移を行う際、コンポーネントのアンマウント時に `CSS.highlights` をクリアし忘れると、巨大な `Highlight` インスタンスがメモリ上に残留し続ける。

【回避策:クリーンアップの徹底】

// コンポーネントの破棄時や再検索のトリガーの最初に必ず実行する
function clearAllHighlights() {
for (const key of CSS.highlights.keys()) {
CSS.highlights.delete(key);
}
}

「とりあえず消す」ではなく、アプリケーションのライフサイクルに完全に同期させたレジストリ管理層(Manager)を設計すべきだ。

—

3. レンダリング負荷とレイアウト・ペイントの最適化

`::highlight()` はDOMを変更しないため、レイアウト(Reflow)のコストは発生しない。しかし、ペイント(Paint)およびコンポジット(Composite)のコストは確実に発生する。

特に注意すべきは、適用できるCSSプロパティの制限だ。仕様上、`::highlight()` で使用できるプロパティは限定されている。

  • `background-color`
  • `color`
  • `text-decoration` 関連
  • `text-shadow`
  • `text-emphasis`
  • `caret-color`

レイアウトを伴うプロパティ(`width`, `padding`, `font-size` など)は一切指定できない。これはブラウザがレイアウト再計算を回避して高速に描画するための賢明な制限である。

大量ハイライト時のパフォーマンスチューニング

例えば、1万行のログファイルから特定のキーワード(例: “ERROR”)をすべてハイライトする場合、1万個の `Range` を1つの `Highlight` に突っ込んで登録するとどうなるか?
ブラウザのペイントエンジンが、数千〜数万のハイライト境界を計算する際にメインスレッドをブロックし、スクロールがカクつく(Jankの発生)原因になる。

【実践的アプローチ:仮想化(Virtualization)との統合】
画面内に表示されている(ビューポート内にある)要素、あるいは仮想スクロールのチャンク単位で `CSS.highlights` の内容を動的に差し替えるべきだ。

// ビューポート内に出現したブロックのRangeだけを登録するIntersectionObserverの活用
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const blockId = entry.target.dataset.blockId;
if (entry.isIntersecting) {
// 表示領域に入ったらハイライトを適用
applyHighlightForBlock(blockId);
} else {
// 画面外に出たらメモリとペイント負荷軽減のために削除
removeHighlightForBlock(blockId);
}
});
});

—

4. 非同期の競合(Race Condition)とデバウンスの実装

リアルタイム検索(インクリマルサーチ)や、サーバーからServer-Sent Events (SSE) / WebSocketで流れてくるストリーミングテキストに対して `::highlight()` を適用する場合、非同期の競合(Race Condition)という悪夢が待ち受けている。

ユーザーが高速にタイピングしたとき、以下のようなシナリオが頻発する。
1. 「A」で検索 → 非同期でマッチ箇所の計算を開始
2. すぐに「AB」と追加入力 → 新たなマッチ箇所の計算を開始
3. 2の結果が先に返り、ハイライトが適用される
4. 1の「遅かった計算結果」が後から返り、古いハイライトで画面が上書きされる(バグ)

堅牢なハイライト・パイプラインの構築

これを防ぐためには、リクエストのキャンセル(`AbortController`)と、最新のトークン(世代管理)によるガードが不可欠だ。

class AsyncHighlightManager {
constructor(registryKey) {
this.registryKey = registryKey;
this.currentAbortController = null;
this.generation = 0;
}

async update(textNode, searchTerm) {
// 進行中の非同期処理があればキャンセル
if (this.currentAbortController) {
this.currentAbortController.abort();
}
this.currentAbortController = new AbortController();
const signal = this.currentAbortController.signal;

const currentGen = ++this.generation;

// 重い検索処理を模擬(Web Workerに移譲するのが理想)
const ranges = await this.computeRangesAsync(textNode, searchTerm, signal);

// キャンセルされていたり、世代が古くなっていたら破棄
if (signal.aborted || currentGen !== this.generation) {
return;
}

const highlight = new Highlight(…ranges);
CSS.highlights.set(this.registryKey, highlight);
}

computeRangesAsync(node, term, signal) {
return new Promise((resolve, reject) => {
// 実務ではここでWeb Workerに処理を投げ、シグナルを監視する
setTimeout(() => {
if (signal.aborted) return reject(new Error(‘Aborted’));

const ranges = [];
const text = node.textContent;
let index = text.indexOf(term);

while (index !== -1) {
const range = new Range();
range.setStart(node, index);
range.setEnd(node, index + term.length);
ranges.push(range);
index = text.indexOf(term, index + term.length);
}
resolve(ranges);
}, 50); // 擬似的な遅延
});
}
}

—

5. 実際のコード:堅牢なカスタムハイライト・コンポーネント

最後に、これまで語った知見(パフォーマンス、クリーンアップ、擬似要素のスタイリング)を統合した、実務でそのまま使える堅牢な実装パターンを提示しよう。

HTML / CSS

フェニックス・フレームワークは、Elixir言語で作られた驚異的なスループットを誇るWebアプリケーションフレームワークです。
リアルタイム機能や分散処理において、その真価を発揮します。

/ カスタムハイライトのスタイリング /
::highlight(phoenix-search) {
background-color: #ff922b;
color: #ffffff;
text-decoration: underline wavy #ffd43b 2px;
}

/ 複数のハイライトを重ねがけする場合の優先順位(仕様で定義可能) /
:root {
highlight-name: –search-1, –search-2;
}

JavaScript

/

  • 堅牢なハイライトコントローラー

/
class RobustHighlighter {
constructor(highlightName) {
this.highlightName = highlightName;
this.ranges = [];
}

/

  • 指定したテキストノード群からキーワードに一致するRangeを生成して登録

/
highlightText(containerElement, keyword) {
// 1. 既存の同名ハイライトをクリア
this.clear();

if (!keyword || keyword.trim() === ”) {
return;
}

const walker = document.createTreeWalker(
containerElement,
NodeFilter.SHOW_TEXT,
null
);

let node;
while ((node = walker.nextNode())) {
const text = node.nodeValue;
let pos = 0;

// 文字列一致のインデックスを走査
while ((pos = text.indexOf(keyword, pos)) !== -1) {
const range = new Range();
try {
range.setStart(node, pos);
range.setEnd(node, pos + keyword.length);
this.ranges.push(range);
} catch (e) {
console.error(‘Rangeの生成に失敗しました:’, e);
}
pos += keyword.length;
}
}

// 2. Highlightオブジェクトを作成してレジストリへ登録
if (this.ranges.length > 0) {
const highlight = new Highlight(…this.ranges);
CSS.highlights.set(this.highlightName, highlight);
}
}

/

  • メモリリークを防ぐための確実な解放処理

/
clear() {
// Rangeオブジェクトの参照を断つ
this.ranges = [];
if (CSS.highlights.has(this.highlightName)) {
CSS.highlights.delete(this.highlightName);
}
}
}

// — 実行例 —
const container = document.getElementById(‘content-container’);
const highlighter = new RobustHighlighter(‘phoenix-search’);

// 「フレームワーク」というキーワードをハイライトする
highlighter.highlightText(container, ‘フレームワーク’);

// 破棄する時は highlighter.clear() を呼ぶ

—

結び:未来のCSSを見据えて

`::highlight()` 擬似要素と Highlight API は、単なる「便利なAPI」ではない。これは、「DOMの構造(Structure)と、スタイリング・装飾(Presentation)の完全な分離」という、Webアーキテクチャの原点にして最高峰の思想を、テキストの範囲選択という最もプリミティブな領域において実現するための強力な武器だ。

フレームワークの内部都合に振り回され、DOM操作のオーバーヘッドに泣かされてきた時代は終わった。ブラウザのプリミティブなエンジンを信じ、メモリとスレッドの動きを緻密にコントロールする。それこそが、上級フロントエンドエンジニアの仕事である。

さあ、エディタを開き、その無駄な `` タグの山をコードベースからパージしよう。美しいCSSとJSの調和が、そこには待っている。

コメント

タイトルとURLをコピーしました