🔍

素のJavaScriptでHTML要素をインクリメンタルにフィルタする

に公開

たとえばこんなとき

こんなページがあったとします。

<!-- 検索ボックス -->
<nav>
    <input type="search" incremental id="searchbox" placeholder="Search...">
</nav>
<!-- 記事一覧 -->
<main class="articles">
    <!-- 記事1 -->
    <article class="article">
        <h2 class="article-title">記事1のタイトル</h2>
        <section class="article-summary">
            <p>記事内容の要約</p>
        </section>
    </article>

    <!-- 記事2 -->
    <article class="article">
        <h2 class="article-title">記事2のタイトル</h2>
        <section class="article-summary">
            <p>記事内容の要約</p>
        </section>
    </article>

    <!-- 以下省略 -->
</main>

Reactとか入れてないけど、.articleたちをタイトルや要約でフィルタしたいよなぁ~と考えるのが人間です。
それを素のJavaScriptでちゃちゃっと作ります。

方針

検索には<input type="search" incremental>を使います。incremental属性を指定することで入力ごとにsearchイベントが発生するため、都度検索を行って.articleの表示・非表示を切り替えます。

incremental属性は非標準ですが、WebkitもBlinkも対応しているため、ほぼ動きます。
searchイベントは標準のinputイベントと同様に発生しますが、検索ボックスのクリアボタンが押された際にも発火したり、入力をdebounceしたりなど、より検索に特化した挙動をします。

https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/input/search#incremental

論理属性 incremental は WebKit および Blink 拡張で(そのため Safari, Opera, Chrome, などが対応)、もし存在すれば、ユーザーエージェントに入力をライブ検索として処理します。ユーザーがフィールドの値を編集すると、ユーザーエージェントは search イベントを検索ボックスを表す HTMLInputElement オブジェクトへ送信します。これにより、ユーザーが検索を編集するたびに、コードからリアルタイムに検索結果を更新することができます。

incremental が指定されていない場合、 search イベントはユーザーが明示的に検索を実行した時のみ(フィールドを編集中に Enter または Return キーを押すなど) 送信されます。

search イベントは発生頻度が制限されているため、実装により定義された間隔よりも頻繁に送信されることはありません。

.articleの表示・非表示の切り替えにはCSSのdisplay: none;を使います。検索ワードにマッチしない.articlehiddenクラスを付与してdisplay: none;を適用することで非表示にします。

.article {
    display: block;

    &.hidden {
        display: none;
    }
}

実装

まずは.articleが検索ワードにマッチするか調べる関数を書きます。今回は記事のタイトルか要約に検索テキストが含まれるならマッチしたと判定します。

function match(article, text) {
    return (
        article.querySelector('.article-title')?.textContent.includes(text) ||
        article.querySelector('.article-summary')?.textContent.includes(text)
    )
}

ページロード時に各要素を取得し、検索ボックスでsearchイベントが発火するたびに検索を行います。
以下のコードではIME変換中の入力を無視しています(無視しないという選択肢もあるにはある)。

document.addEventListener('DOMContentLoaded', () => {
    // 検索ボックスを探す
    const searchbox = document.querySelector('#searchbox')
    if (searchbox == null) {
        console.error('unable to find searchbox')
        return
    }
    // 記事一覧を探す
    const articles = document.querySelector('.articles')
    if (articles == null) {
        console.error('unable to find articles')
        return
    }
    // searchイベントが発火するたびに検索する
    searchbox.addEventListener('search', (event) => {
        // IME変換中は無視する
        if (event.isComposing) { return }

        // 検索する
        setFilter(articles, event.target.value)
    })
})

発火時に呼ばれるsetFilterでは.articleを順に辿り、.articleにクラスを付けたり外したりします。

function setFilter(articles, text) {
    for (const article of articles.querySelectorAll('.article')) {
        if (match(article, text)) {
            article.classList.remove('hidden')  // マッチしたら表示
        } else {
            article.classList.add('hidden')     // マッチしなかったら非表示
        }
    }
}

debounce

レガシーな環境やWeb標準に従う必要がある場合はincremental属性を使えないため、inputイベントで代用します。とはいえinputイベントが発生する度に検索を実行するのは無駄が多いので、なるべく入力が終わったタイミングで検索したいと思います。

そのためのパターンがdebounceです。入力があっても検索は行わず、最後の入力から一定時間経過してから「入力が終了した」とみなして検索を実行します。

function debounce(f, ms) {
    let timer;
    return (...args) => {
        // 呼ばれる度にタイマーをセットし直す
        clearTimeout(timer)
        timer = setTimeout(() => f(...args), ms)
    }
}
const setFilterDebounced = debounce(setFilter, 50)  // 最後の入力から50ms経過後に入力が終了したとみなす

// inputイベントが発火するたびに呼ばれる
searchbox.addEventListener('input', (event) => {
    // IME変換中は無視する
    if (event.isComposing) { return }

    // 検索する
    setFilterDebounced(articles, event.target.value)
})

データからDOMを作ろう

上記のコードはmatchが呼ばれる度にquerySelectorでDOMを検索していたので結構重いです。

普通はArticleクラスなどのデータ構造を定義しておき、そのデータを基にDOMを作ります。

// hyperscript風のヘルパ
function h(tagName, attrs = {}, ...children = []) {
    const el = document.createElement(tagName)
    for (const [k, v] of Object.entries(attrs)) {
        if (k.startsWith('on')) {
            el.addEventListener(k.slice(2), v)
        } else {
            el.setAttribute(k, v)
        }
    }
    el.append(...children)
    return el
}

// 記事を表すデータ構造
class Article {
    #element;  // 紐づいているDOM要素
    constructor(title, summary) {
        this.title = title
        this.summary = summary
    }
    get element() {
        if (!this.#element) {
            // 最初のアクセスでレンダリング
            this.#element = this.#render()
        }
        return this.#element
    }
    #render() {
        return h('article', { class: 'article' },
            h('h2', { class: 'article-title' }, this.title),
            h('section', { class: 'article-summary' },
                h('p', {}, this.summary),
            ),
        )
    }
}

function mount(parent, articles) {
    parent.append(...articles.map(article => article.element))
}
const articles = [
    new Article('記事1のタイトル', '記事内容の要約'),
    // ...
]

document.addEventListener('DOMContentLoaded', () => {
    // ...

    // 記事一覧を入れる要素を探す
    const articlesElement = document.querySelector('.articles')

    // ...

    // articlesElementに記事をマウントする
    mount(articlesElement, articles)
})

検索は頻繁に行われるので、都度DOMを探すよりも早いです。

function match(article, text) {
    return (
        article.title.includes(text) ||
        article.summary.includes(text)
    )
}

まとめ

ReactかSolid.jsを使いましょう。

Discussion