はじめに (対象読者・この記事でわかること)

この記事は、JavaScriptの基本的な知識があるWeb開発者、特にページ内検索機能を実装したいと考えている方を対象としています。実際のWebサイト開発において、長いページや動的にコンテンツが追加されるページでは、「Ctrl+F」や検索ボックスを使っても、現在表示されている範囲の要素しか検索対象にされないという問題が発生します。この記事を読むことで、表示されている範囲だけでなく、スクロール範囲全体を対象にしたページ内検索機能を実装できるようになります。また、パフォーマンスを考慮した効率的な検索方法や、実装中によく発生する問題の解決策についても学べます。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - 前提となる知識1: JavaScriptの基本的な文法とDOM操作の理解 - 前提となる知識2: HTML/CSSの基本的な知識 - 前提となる知識3: 非同期処理(Promise, async/await)の基礎

ページ内検索の課題と背景

多くのWebサイトでは、ページ内検索機能を実装する際に、window.find()メソッドやカスタムの検索スクリプトを使用します。しかし、これらの方法はデフォルトで表示されているビューポート内の要素のみを検索対象とします。特に長いページや動的にコンテンツが追加されるSPA(シングルページアプリケーション)では、ユーザーが検索結果を見つけにくいという問題が発生します。

この課題を解決するには、ページ全体のDOMを一度取得し、それに対して検索を実行する必要があります。しかし、大量のコンテンツがある場合、パフォーマンスの問題が発生する可能性があります。そのため、効率的にページ全体を検索対象にする方法を理解することが重要です。

実装方法:スクロール範囲全体を対象にしたページ内検索

ステップ1:基本的なページ内検索の実装

まずは、基本的なページ内検索機能を実装しましょう。以下はシンプルなページ内検索の実装例です。

Javascript
// 基本的なページ内検索関数 function simplePageSearch(keyword) { // ページ内のすべてのテキストノードを取得 const textNodes = getTextNodes(document.body); // 検索キーワードに一致するテキストノードを探す const results = []; textNodes.forEach(node => { if (node.textContent.includes(keyword)) { results.push({ node: node, text: node.textContent }); } }); return results; } // ページ内のすべてのテキストノードを取得する関数 function getTextNodes(element) { const textNodes = []; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null, false ); let node; while (node = walker.nextNode()) { // 空白のみのノードは除外 if (node.textContent.trim() !== '') { textNodes.push(node); } } return textNodes; }

この実装では、document.body以下のすべてのテキストノードを取得し、検索キーワードが含まれるノードを探しています。しかし、この方法では大量のコンテンツがある場合にパフォーマンスの問題が発生する可能性があります。

ステップ2:スクロール範囲全体を検索対象にする最適化方法

ページ全体を検索対象にしつつ、パフォーマンスを考慮した実装方法を紹介します。以下は、Intersection Observer APIを利用して、表示されていない要素も含めて検索対象にする方法です。

Javascript
// Intersection Observerを使った最適化検索関数 function optimizedPageSearch(keyword) { return new Promise((resolve) => { const results = []; const textNodes = getTextNodes(document.body); let processedCount = 0; // 処理が完了したかどうかを追跡 const checkCompletion = () => { processedCount++; if (processedCount >= textNodes.length) { resolve(results); } }; // Intersection Observerの設定 const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const text = entry.target.textContent; if (text.includes(keyword)) { results.push({ node: entry.target, text: text }); } observer.unobserve(entry.target); checkCompletion(); } }); }, { threshold: 0.1 }); // テキストノードを監視対象に追加 textNodes.forEach(node => { // テキストノードをラップする要素を作成 const wrapper = document.createElement('span'); wrapper.style.display = 'inline'; wrapper.textContent = node.textContent; // 元のノードを置き換え node.parentNode.replaceChild(wrapper, node); // 監視対象に追加 observer.observe(wrapper); }); // すべての要素が監視対象に追加されたら完了 if (textNodes.length === 0) { resolve(results); } }); }

さらに、大量のコンテンツを効率的に処理するために、チャンク処理(分割処理)を実装する方法も有効です。

Javascript
// チャンク処理を使った大規模コンテンツの検索 async function chunkedPageSearch(keyword, chunkSize = 100) { const textNodes = getTextNodes(document.body); const results = []; let currentIndex = 0; while (currentIndex < textNodes.length) { // チャンクを取得 const chunk = textNodes.slice(currentIndex, currentIndex + chunkSize); // チャンク内で検索を実行 for (const node of chunk) { if (node.textContent.includes(keyword)) { results.push({ node: node, text: node.textContent }); } } // 次のチャンクに進む currentIndex += chunkSize; // ブラウザのUIスレッドを解放するため、短い待機を挟む await new Promise(resolve => setTimeout(resolve, 0)); } return results; }

ハマった点やエラー解決

問題1: 大量のコンテンツがあるページで検索が重い - 原因: 一度にすべてのDOM要素を処理しようとすると、メモリ使用量が増え、ブラウザが応答しなくなることがあります。 - 解決策: 上記のチャンク処理やIntersection Observer APIを利用して、処理を分割します。

問題2: 動的に追加されるコンテンツが検索対象に含まれない - 原因: ページ読み込み時にDOMを取得するため、その後追加される要素は検索対象になりません。 - 解決策: MutationObserverを使って、DOMの変更を監視し、新しいコンテンツが追加されたら検索対象に追加します。

Javascript
// MutationObserverを使って動的に追加されるコンテンツを検索対象に追加 function setupDynamicContentSearch(keyword, onResult) { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { // 追加されたノード内のテキストを検索 const textNodes = getTextNodes(node); textNodes.forEach(textNode => { if (textNode.textContent.includes(keyword)) { onResult({ node: textNode, text: textNode.textContent }); } }); }); }); }); // 変更を監視 observer.observe(document.body, { childList: true, subtree: true }); return observer; }

問題3: 検索結果のハイライト表示でレイアウトが崩れる - 原因: テキストノードに直接スタイルを適用すると、要素のサイズが変化し、レイアウトが崩れることがあります。 - 解決策: テキストノードをspan要素でラップし、そのspan要素にスタイルを適用します。

Javascript
// 検索結果をハイライト表示する関数 function highlightSearchResults(results) { results.forEach(result => { const text = result.text; const keyword = // 検索キーワードを取得 const regex = new RegExp(`(${keyword})`, 'gi'); const highlightedText = text.replace(regex, '<mark>$1</mark>'); // 元のノードを置き換え const wrapper = document.createElement('span'); wrapper.innerHTML = highlightedText; result.node.parentNode.replaceChild(wrapper, result.node); }); }

完成した検索機能の実装例

最後に、これまでの要素を組み合わせた完全な実装例を示します。

Javascript
class PageSearch { constructor() { this.results = []; this.observer = null; this.keyword = ''; } // 検索を実行 async search(keyword) { this.keyword = keyword; this.results = []; // 既存のハイライトを削除 this.clearHighlights(); // 検索を実行 await this.chunkedSearch(keyword); // 結果をハイライト this.highlightResults(); return this.results; } // チャンク処理を使った検索 async chunkedSearch(keyword, chunkSize = 50) { const textNodes = this.getTextNodes(document.body); let currentIndex = 0; while (currentIndex < textNodes.length) { const chunk = textNodes.slice(currentIndex, currentIndex + chunkSize); for (const node of chunk) { if (node.textContent.includes(keyword)) { this.results.push({ node: node, text: node.textContent }); } } currentIndex += chunkSize; await new Promise(resolve => setTimeout(resolve, 0)); } } // テキストノードを取得 getTextNodes(element) { const textNodes = []; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null, false ); let node; while (node = walker.nextNode()) { if (node.textContent.trim() !== '') { textNodes.push(node); } } return textNodes; } // 検索結果をハイライト highlightResults() { this.results.forEach(result => { const text = result.text; const regex = new RegExp(`(${this.keyword})`, 'gi'); const highlightedText = text.replace(regex, '<mark class="search-highlight">$1</mark>'); const wrapper = document.createElement('span'); wrapper.innerHTML = highlightedText; result.node.parentNode.replaceChild(wrapper, result.node); }); } // ハイライトをクリア clearHighlights() { const highlights = document.querySelectorAll('.search-highlight'); highlights.forEach(highlight => { const parent = highlight.parentNode; parent.replaceChild(document.createTextNode(highlight.textContent), highlight); parent.normalize(); }); } // 動的コンテンツの監視を開始 startDynamicContentSearch(onResult) { this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { const textNodes = this.getTextNodes(node); textNodes.forEach(textNode => { if (textNode.textContent.includes(this.keyword)) { onResult({ node: textNode, text: textNode.textContent }); } }); }); }); }); this.observer.observe(document.body, { childList: true, subtree: true }); } // 動的コンテンツの監視を停止 stopDynamicContentSearch() { if (this.observer) { this.observer.disconnect(); this.observer = null; } } } // 使用例 const pageSearch = new PageSearch(); // 検索ボックスのイベントリスナー const searchInput = document.getElementById('search-input'); searchInput.addEventListener('input', async (e) => { const keyword = e.target.value.trim(); if (keyword) { const results = await pageSearch.search(keyword); console.log(`検索結果: ${results.length}件`); } else { pageSearch.clearHighlights(); } }); // 動的コンテンツの監視を開始 pageSearch.startDynamicContentSearch((result) => { console.log('動的に追加された検索結果:', result); });

まとめ

本記事では、JavaScriptでページ全体を検索対象にする実装方法について解説しました。

  • ポイント1: Intersection Observer APIとチャンク処理を組み合わせることで、大量のコンテンツを効率的に検索できます。
  • ポイント2: MutationObserverを使うことで、動的に追加されるコンテンツも検索対象に含めることができます。
  • ポイント3: 検索結果のハイライト表示では、テキストノードを適切に置き換えることでレイアウト崩れを防ぎます。

この記事を通して、表示されている範囲だけでなく、スクロール範囲全体を対象にしたページ内検索機能を効率的に実装できるようになったことでしょう。特に長いページや動的にコンテンツが追加されるWebアプリケーションでは、この機能がユーザビリティを大きく向上させます。

今後は、検索結果のナビゲーション機能や、大規模なデータセットを扱う場合のさらなる最適化手法についても記事にする予定です。

参考資料