CSS実践入門第6回:JavaScript検索エンジン実装 - 高度な検索機能でサイトの使いやすさを向上

本格的なJavaScript検索エンジンの実装方法を解説。スコアリングアルゴリズム、ハイライト機能、リアルタイム検索など、実用的な検索機能の作り方を詳しく紹介します。

Webサイトにとって検索機能は、ユーザーが目的のコンテンツを素早く見つけるために欠かせない機能です。今回は、JavaScriptを使用して本格的な検索エンジンを実装し、サイトの使いやすさを大幅に向上させる方法を詳しく解説します。

この記事で学べること

  • 高性能なJavaScript検索エンジンの設計・実装方法
  • スコアリングアルゴリズムによる関連度の高い検索結果表示
  • リアルタイム検索とデバウンス処理の実装
  • 検索結果のハイライト機能とUX向上テクニック
  • パフォーマンス最適化とメモリ効率の良い実装

1. 検索エンジンの基本設計

全体のアーキテクチャ

まず、検索エンジンの全体像を理解しましょう。

 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
class SearchEngine {
    constructor() {
        this.searchData = null;
        this.searchIndex = new Map();
        this.isInitialized = false;
    }

    async initialize() {
        try {
            await this.loadSearchData();
            this.buildSearchIndex();
            this.isInitialized = true;
        } catch (error) {
            console.error('検索エンジンの初期化に失敗しました:', error);
        }
    }

    async loadSearchData() {
        const response = await fetch('/index.json');
        this.searchData = await response.json();
    }

    performSearch(query) {
        if (!this.isInitialized || !query) return [];
        
        const results = this.findMatches(query);
        return this.rankResults(results, query);
    }
}

検索データの構造設計

効率的な検索のため、データ構造を最適化します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 検索用JSONデータの例
{
    "articles": [
        {
            "title": "CSS実践入門第5回:JavaScript入門",
            "url": "/posts/css-series-05-javascript-basics/",
            "content": "JavaScriptの基本的な構文から...",
            "tags": ["JavaScript", "CSS", "フロントエンド"],
            "categories": ["CSS実践入門", "JavaScript"],
            "date": "2025-09-13",
            "summary": "JavaScriptの基礎を学ぶ初心者向けガイド"
        }
    ]
}

2. スコアリングアルゴリズムの実装

検索結果の関連度を数値化するスコアリングシステムを実装します。

基本スコアリングロジック

 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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class SearchScorer {
    calculateScore(article, query) {
        const queryTerms = this.normalizeQuery(query);
        let totalScore = 0;

        // タイトルマッチング(重み:高)
        const titleScore = this.calculateTitleScore(article.title, queryTerms);
        totalScore += titleScore * 3.0;

        // タグマッチング(重み:中)
        const tagScore = this.calculateTagScore(article.tags, queryTerms);
        totalScore += tagScore * 2.0;

        // カテゴリマッチング(重み:中)
        const categoryScore = this.calculateCategoryScore(article.categories, queryTerms);
        totalScore += categoryScore * 2.0;

        // 本文マッチング(重み:低)
        const contentScore = this.calculateContentScore(article.content, queryTerms);
        totalScore += contentScore * 1.0;

        // 日付による減衰(新しい記事ほど高スコア)
        const dateScore = this.calculateDateScore(article.date);
        totalScore *= dateScore;

        return totalScore;
    }

    normalizeQuery(query) {
        return query.toLowerCase()
            .replace(/[^\w\s\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, '')
            .split(/\s+/)
            .filter(term => term.length > 0);
    }

    calculateTitleScore(title, queryTerms) {
        const normalizedTitle = title.toLowerCase();
        let score = 0;

        queryTerms.forEach(term => {
            if (normalizedTitle.includes(term)) {
                // 完全一致の場合、高得点
                if (normalizedTitle === term) {
                    score += 10;
                }
                // 単語境界での一致
                else if (new RegExp(`\\b${this.escapeRegex(term)}\\b`).test(normalizedTitle)) {
                    score += 5;
                }
                // 部分一致
                else {
                    score += 2;
                }
            }
        });

        return score;
    }

    calculateTagScore(tags, queryTerms) {
        let score = 0;
        tags.forEach(tag => {
            const normalizedTag = tag.toLowerCase();
            queryTerms.forEach(term => {
                if (normalizedTag.includes(term)) {
                    score += normalizedTag === term ? 5 : 2;
                }
            });
        });
        return score;
    }

    calculateContentScore(content, queryTerms) {
        const normalizedContent = content.toLowerCase();
        let score = 0;
        
        queryTerms.forEach(term => {
            const matches = (normalizedContent.match(new RegExp(this.escapeRegex(term), 'g')) || []);
            // 出現回数に応じてスコア加算(上限あり)
            score += Math.min(matches.length, 5);
        });

        return score;
    }

    calculateDateScore(dateString) {
        const articleDate = new Date(dateString);
        const now = new Date();
        const daysDiff = (now - articleDate) / (1000 * 60 * 60 * 24);
        
        // 新しい記事ほど高スコア(最大1.0、最小0.5)
        return Math.max(0.5, 1.0 - (daysDiff / 365) * 0.5);
    }

    escapeRegex(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }
}

3. リアルタイム検索とデバウンス処理

ユーザーが入力するたびに瞬時に検索結果を更新するリアルタイム検索を実装します。

デバウンス機能付きリアルタイム検索

  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
class RealtimeSearch {
    constructor(searchEngine, resultContainer) {
        this.searchEngine = searchEngine;
        this.resultContainer = resultContainer;
        this.debounceTimer = null;
        this.currentQuery = '';
    }

    setupEventListeners() {
        const searchInput = document.getElementById('search-input');
        
        searchInput.addEventListener('input', (e) => {
            this.handleSearchInput(e.target.value);
        });

        searchInput.addEventListener('focus', () => {
            if (this.currentQuery) {
                this.showResults();
            }
        });

        searchInput.addEventListener('keydown', (e) => {
            this.handleKeyNavigation(e);
        });

        // 検索結果外クリックで結果を非表示
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.search-container')) {
                this.hideResults();
            }
        });
    }

    handleSearchInput(query) {
        // デバウンス処理(300ms待機)
        clearTimeout(this.debounceTimer);
        this.debounceTimer = setTimeout(() => {
            this.performSearch(query);
        }, 300);
    }

    async performSearch(query) {
        this.currentQuery = query;
        
        if (!query.trim()) {
            this.hideResults();
            return;
        }

        // ローディング表示
        this.showLoadingState();

        try {
            const results = await this.searchEngine.performSearch(query);
            this.displayResults(results, query);
        } catch (error) {
            this.showErrorState();
            console.error('検索エラー:', error);
        }
    }

    displayResults(results, query) {
        if (results.length === 0) {
            this.showNoResults(query);
            return;
        }

        const maxResults = 10;
        const displayResults = results.slice(0, maxResults);

        const resultsHTML = displayResults.map((result, index) => {
            return this.renderResultItem(result, query, index);
        }).join('');

        this.resultContainer.innerHTML = `
            <div class="search-results-header">
                <span class="results-count">${results.length}件の結果</span>
                ${results.length > maxResults ? 
                    `<span class="more-results">(上位${maxResults}件を表示)</span>` : 
                    ''
                }
            </div>
            <div class="search-results-list">
                ${resultsHTML}
            </div>
        `;

        this.showResults();
    }

    renderResultItem(result, query, index) {
        const highlightedTitle = this.highlightMatches(result.title, query);
        const highlightedSummary = this.highlightMatches(result.summary, query);
        
        return `
            <div class="search-result-item" data-index="${index}">
                <a href="${result.url}" class="result-link">
                    <div class="result-title">${highlightedTitle}</div>
                    <div class="result-summary">${highlightedSummary}</div>
                    <div class="result-meta">
                        <span class="result-date">${this.formatDate(result.date)}</span>
                        <span class="result-tags">${result.tags.slice(0, 3).join(', ')}</span>
                        <span class="result-score">関連度: ${Math.round(result.score)}</span>
                    </div>
                </a>
            </div>
        `;
    }
}

4. 検索結果のハイライト機能

検索キーワードを検索結果内でハイライトする機能を実装します。

高度なハイライト処理

 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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class SearchHighlighter {
    highlightMatches(text, query) {
        if (!text || !query) return text;

        const queryTerms = this.normalizeQuery(query);
        let highlightedText = text;

        // 長い単語から先に処理して、部分マッチの重複を避ける
        const sortedTerms = queryTerms.sort((a, b) => b.length - a.length);

        sortedTerms.forEach(term => {
            const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
            highlightedText = highlightedText.replace(regex, '<mark class="search-highlight">$1</mark>');
        });

        return highlightedText;
    }

    // 検索結果のコンテキスト抽出
    extractContext(content, query, maxLength = 200) {
        const queryTerms = this.normalizeQuery(query);
        const normalizedContent = content.toLowerCase();
        
        // 最初にヒットした位置を探す
        let bestPosition = 0;
        let bestScore = 0;

        queryTerms.forEach(term => {
            const position = normalizedContent.indexOf(term.toLowerCase());
            if (position !== -1) {
                // 複数のキーワードが近くにある位置を優先
                let score = 1;
                queryTerms.forEach(otherTerm => {
                    if (otherTerm !== term) {
                        const otherPosition = normalizedContent.indexOf(otherTerm.toLowerCase(), position);
                        if (otherPosition !== -1 && Math.abs(otherPosition - position) < 100) {
                            score += 2;
                        }
                    }
                });

                if (score > bestScore) {
                    bestScore = score;
                    bestPosition = position;
                }
            }
        });

        // コンテキストを切り出し
        const start = Math.max(0, bestPosition - maxLength / 2);
        const end = Math.min(content.length, start + maxLength);
        let context = content.substring(start, end);

        // 文の境界で切り取る
        if (start > 0) {
            const firstPeriod = context.indexOf('。');
            if (firstPeriod !== -1 && firstPeriod < 50) {
                context = '...' + context.substring(firstPeriod + 1);
            } else {
                context = '...' + context;
            }
        }

        if (end < content.length) {
            const lastPeriod = context.lastIndexOf('。');
            if (lastPeriod !== -1 && lastPeriod > context.length - 50) {
                context = context.substring(0, lastPeriod + 1) + '...';
            } else {
                context = context + '...';
            }
        }

        return this.highlightMatches(context, query);
    }

    normalizeQuery(query) {
        return query.toLowerCase()
            .replace(/[^\w\s\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, '')
            .split(/\s+/)
            .filter(term => term.length > 0);
    }

    escapeRegex(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }
}

5. キーボードナビゲーション

検索結果をキーボードで操作できる機能を実装します。

 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
68
69
70
71
72
class KeyboardNavigation {
    constructor(searchContainer) {
        this.searchContainer = searchContainer;
        this.currentIndex = -1;
        this.setupKeyboardEvents();
    }

    setupKeyboardEvents() {
        const searchInput = document.getElementById('search-input');
        
        searchInput.addEventListener('keydown', (e) => {
            const results = this.searchContainer.querySelectorAll('.search-result-item');
            
            switch (e.key) {
                case 'ArrowDown':
                    e.preventDefault();
                    this.navigateDown(results);
                    break;
                case 'ArrowUp':
                    e.preventDefault();
                    this.navigateUp(results);
                    break;
                case 'Enter':
                    e.preventDefault();
                    this.selectCurrent(results);
                    break;
                case 'Escape':
                    this.clearSelection();
                    searchInput.blur();
                    break;
            }
        });
    }

    navigateDown(results) {
        if (results.length === 0) return;

        this.clearSelection();
        this.currentIndex = Math.min(this.currentIndex + 1, results.length - 1);
        this.highlightResult(results[this.currentIndex]);
    }

    navigateUp(results) {
        if (results.length === 0) return;

        this.clearSelection();
        this.currentIndex = Math.max(this.currentIndex - 1, 0);
        this.highlightResult(results[this.currentIndex]);
    }

    selectCurrent(results) {
        if (this.currentIndex >= 0 && results[this.currentIndex]) {
            const link = results[this.currentIndex].querySelector('.result-link');
            if (link) {
                window.location.href = link.href;
            }
        }
    }

    highlightResult(resultElement) {
        resultElement.classList.add('keyboard-selected');
        resultElement.scrollIntoView({ 
            behavior: 'smooth', 
            block: 'nearest' 
        });
    }

    clearSelection() {
        this.searchContainer.querySelectorAll('.keyboard-selected')
            .forEach(el => el.classList.remove('keyboard-selected'));
    }
}

6. 検索結果のCSS

美しい検索結果UIのためのスタイルを定義します。

  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
.search-container {
    position: relative;
    max-width: 600px;
    margin: 0 auto;
}

.search-input {
    width: 100%;
    padding: 12px 16px;
    font-size: 16px;
    border: 2px solid #e1e5e9;
    border-radius: 8px;
    outline: none;
    transition: all 0.3s ease;
}

.search-input:focus {
    border-color: #4285f4;
    box-shadow: 0 4px 12px rgba(66, 133, 244, 0.2);
}

.search-results {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: white;
    border: 1px solid #e1e5e9;
    border-radius: 8px;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
    max-height: 400px;
    overflow-y: auto;
    z-index: 1000;
    margin-top: 8px;
}

.search-results-header {
    padding: 12px 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
    font-size: 14px;
    color: #666;
}

.results-count {
    font-weight: 600;
}

.more-results {
    color: #999;
    margin-left: 8px;
}

.search-result-item {
    border-bottom: 1px solid #f0f0f0;
    transition: background-color 0.2s ease;
}

.search-result-item:hover,
.search-result-item.keyboard-selected {
    background-color: #f8f9fa;
}

.result-link {
    display: block;
    padding: 16px;
    text-decoration: none;
    color: inherit;
}

.result-title {
    font-size: 16px;
    font-weight: 600;
    color: #1a73e8;
    margin-bottom: 8px;
    line-height: 1.4;
}

.result-summary {
    font-size: 14px;
    color: #555;
    line-height: 1.5;
    margin-bottom: 8px;
}

.result-meta {
    font-size: 12px;
    color: #999;
    display: flex;
    gap: 16px;
    flex-wrap: wrap;
}

.search-highlight {
    background-color: #fff2cc;
    color: #b45309;
    padding: 1px 2px;
    border-radius: 2px;
    font-weight: 600;
}

/* ローディングアニメーション */
.search-loading {
    padding: 20px;
    text-align: center;
    color: #666;
}

.search-loading::after {
    content: '';
    display: inline-block;
    width: 16px;
    height: 16px;
    border: 2px solid #e1e5e9;
    border-top: 2px solid #4285f4;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin-left: 8px;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}

.no-results {
    padding: 20px;
    text-align: center;
    color: #666;
}

/* ダークモード対応 */
@media (prefers-color-scheme: dark) {
    .search-results {
        background: #2d2d2d;
        border-color: #444;
    }
    
    .search-results-header {
        background: #333;
        border-color: #444;
        color: #ccc;
    }
    
    .search-result-item:hover,
    .search-result-item.keyboard-selected {
        background-color: #333;
    }
    
    .result-title {
        color: #8ab4f8;
    }
    
    .result-summary {
        color: #bbb;
    }
    
    .search-highlight {
        background-color: #3c4043;
        color: #fdd663;
    }
}

7. 統合とパフォーマンス最適化

全ての機能を統合し、パフォーマンスを最適化します。

 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
// メイン検索システム
class AdvancedSearchSystem {
    constructor() {
        this.searchEngine = new SearchEngine();
        this.scorer = new SearchScorer();
        this.highlighter = new SearchHighlighter();
        this.realtimeSearch = null;
        this.keyboardNav = null;
    }

    async initialize() {
        try {
            await this.searchEngine.initialize();
            
            const searchContainer = document.querySelector('.search-container');
            const resultContainer = document.querySelector('.search-results');
            
            this.realtimeSearch = new RealtimeSearch(this, resultContainer);
            this.keyboardNav = new KeyboardNavigation(searchContainer);
            
            this.realtimeSearch.setupEventListeners();
            
            console.log('高度検索システムが初期化されました');
        } catch (error) {
            console.error('検索システムの初期化に失敗:', error);
        }
    }

    async performSearch(query) {
        if (!query || query.length < 2) return [];

        const results = this.searchEngine.searchData
            .map(article => ({
                ...article,
                score: this.scorer.calculateScore(article, query)
            }))
            .filter(article => article.score > 0)
            .sort((a, b) => b.score - a.score);

        // 結果の前処理(サマリーのコンテキスト抽出)
        results.forEach(result => {
            if (result.content) {
                result.summary = this.highlighter.extractContext(
                    result.content, 
                    query, 
                    150
                );
            }
        });

        return results;
    }
}

// システム初期化
document.addEventListener('DOMContentLoaded', async () => {
    const searchSystem = new AdvancedSearchSystem();
    await searchSystem.initialize();
});

まとめ

今回実装したJavaScript検索エンジンには、以下の高度な機能が含まれています:

実装した主要機能

  • スコアリングアルゴリズム: タイトル、タグ、本文の重み付け検索
  • リアルタイム検索: デバウンス処理による快適な検索体験
  • ハイライト機能: 検索キーワードの視覚的強調
  • キーボードナビゲーション: 矢印キーでの結果操作
  • コンテキスト抽出: 関連部分のスマートな切り出し

パフォーマンスの最適化ポイント

  • 検索データのメモリ効率的な管理
  • デバウンス処理による無駄なリクエスト削減
  • インデックスによる高速検索
  • 結果の段階的表示とページング

UX向上のテクニック

  • ローディング状態の明確な表示
  • エラーハンドリングとユーザーフィードバック
  • レスポンシブデザインとダークモード対応
  • アクセシビリティに配慮したキーボード操作

この検索エンジンは、小規模から中規模のWebサイトで十分に実用的な性能を発揮し、ユーザーエクスペリエンスを大幅に向上させることができます。

次回の記事では、さらに視覚的な魅力を追加する「CSS実践入門第7回:アニメーション・トランジション実装」について解説予定です。ページの要素に滑らかな動きを加える方法を詳しく紹介します。


関連記事:

技術ネタ、趣味や備忘録などを書いているブログです
Hugo で構築されています。
テーマ StackJimmy によって設計されています。