Vue でページ内検索 + 自動スクロール機能をつくる(デモページあり)

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、ここのところLaravelをメインとした記事ばかり書いていたのですが、せっかく少し前に新バージョンの3がリリースされたので、Vue.jsがメインの記事も書いてみたくなりました。

というのも、とある方から「こんな機能をつくってみたい」というお話をしていただき、私も「それは面白そう」と感じたからなんですね。(アイデア、ありがとうございます❗)

そして、その機能とは・・・・・・

ページ内検索🔎

機能です。

もしかすると、「いや、ページ内検索なら Ctrl + f でいけるじゃん❗」とおっしゃるかもしれません。

そのとおりなんですです!
でも、この機能すごく便利なのに知らない人が結構多かったりするんですよね。

※ もし知らない人は、ぜひ一度以下の手順で試してみてください。

  1. Ctrl を押しながら f を押す(mac の場合は command を押しながら f)
  2. 検索ボックスが表示される
  3. 好きなキーワード(例えば「click」など)を入力

すると、該当するキーワードがハイライトされ、もし複数あるときはエンターキーやF3を押すと次の場所まで自動スクロールしてくれます。

開発する場合には検索することがとても多いので、ページ内検索を知っていると開発効率がとてもあがると思いますよ✨

・・・ということで、今回はそんなテクニックを知らなくても「ページ内検索」ができるような機能をVue 3で作ってみたいと思います。

ぜひ学習の参考になりましたら嬉しいです😊
(最後にデモページ、そして今回実際に作成したソースコードをダウンロードできますよ👍)

Twitterのフォロワーさんが
やっと100人を超えました。
素直に嬉しいです😊」

開発環境: Vue 3.0.5、TailwindCSS 2.0.3

やりたいこと

今回開発する詳細は次のとおりです。

  • 入力したキーワードの背景をハイライトする
  • 該当件数を表示する
  • Enter キー、次へ・前へボタンで該当位置へ自動スクロール
  • 複数ブロックに別れていてもページ内検索ができるようにする

では楽しくやっていきましょう❗

※ 今回はVueだけなので1ファイルですが、逆にコードが長くなってしまったので分割してご紹介します。

コンテンツ表示コンポーネントをつくる

では、まずコンテンツを表示するコンポーネントをつくります。

直接書いてもいいのですが、今回は複数ブロックにも対応させたいので使い回しができるようコンポーネント化します。

使い方は以下を想定しています。

<v-find-in-page-content v-model="searchText">
    ここにHTMLコンテンツ(該当箇所がハイライトされます)
</v-find-in-page-content>

では、JavaScriptコードです。

const findInPageContentComponent = {
    props: {
        modelValue: { // 1-1: v-model を使えるようにする
            type: String,
            default: ''
        }
    },
    data() {
        return {
            delimiter: '___FIND_IN_PAGE___', // 区切り文字
            contentParts: []
        }
    },
    methods: {
        parseContents() { // 1-2: タグ内にセットされたHTMLコンテンツを分類(タグ&テキスト)して配列にする

            this.contentParts = [];
            const content = this.container.innerHTML
                .replace(/[<>]/g, match => {

                    return (match === '<')
                        ? this.delimiter +'<'
                        : '>'+ this.delimiter;

                });
            const contents = content.split(this.delimiter);

            contents.forEach(contentPart => {

                if(contentPart !== '') {

                    const type = (contentPart.startsWith('<'))  // コンテンツの種類
                        ? 'tag'
                        : 'text';
                    this.contentParts.push({
                        type: type,
                        value: contentPart
                    });

                }

            });

        }
    },
    computed: {
        content() { // 1-3: 表示するコンテンツを作成する(ハイライトする)

            const keyword = this.modelValue;

            return this.contentParts.map(contentPart => {

                let value = contentPart.value;

                if(keyword !== '') {

                    const type = contentPart.type;

                    if(type === 'text' && value.includes(keyword)) {   // 通常のテキストでキーワードを含む場合

                        const highlight = `<span class="find-in-page-highlight bg-yellow-400">${keyword}</span>`;
                        value = value.replace(keyword, highlight);

                    }

                }

                return value;

            }).join('');

        }
    },
    mounted() {

        this.parseContents();

    },
    setup() {

        return {
            container: Vue.ref(null)
        };

    },
    // 1-4: テンプレート
    template: `
        <div ref="container" class="hidden">
            <slot></slot>
        </div>
        <div v-html="content"></div>
    `
};

ではひとつずつ見ていきましょう。

1-1: v-model を使えるようにする

Vue 3からは、valueではなくmodelValuev-modelとペアになっていますので、このように書きます。

なお、型や初期値をセットしない場合は以下のように省略してもOKです。

props: ['modelValue'], // 👈 省略バージョン

1-2: タグ内にセットされたHTMLコンテンツを分類(タグ&テキスト)して配列にする

今回の開発でここが一番悩みました。

というのも、検索キーワードが入力され、該当する部分をハイライトするときに、何も考えずにreplace()を使ってしまうとHTMLタグまで置き換えられてしまうからです。

例えば、以下の例をご覧ください。

<h1>これは、タイトルです</h1>

もしキーワードが「タイトル」だった場合、以下のように置き換えてして背景のハイライトをすることになります。

<h1>これは、<span class="bg-yellow-400">タイトル</span>です</h1>

しかし、キーワードがもし「>タイトル」だった場合はどうでしょうか。
以下のようにHTMLタグの構成が壊れてしまいます。

<h1これは、<span class="bg-yellow-400">>タイトル</span>です</h1>
<!-- ↑↑↑ h1タグが欠けていて、余分な > も含んでいます -->

これではきちんとした表示ができなくなってしまいます。

そこで、解決策としてコンテンツを文章とタグに分け、配列化するようにしました。

例えば、以下のようなコンテンツがあるとします。

サンプルは、<a href="http://example.com">こちら</a>をご覧ください。

そして、これを分割するため以下2つのようにいったん区切り文字付きのタグに置き換えます。

  • <」を「|<」に置き換える
  • >」を「>|」に置き換える

※ なお、区切り文字は説明しやすいように「|」にしていますが実際のコードでは「___FIND_IN_PAGE___」です。

つまり、実際には以下のようになります。

サンプルは、|<a href="http://example.com">|こちら|</a>|をご覧ください。

そして、次にこの「|」で分割すると正しい位置でコンテンツを分割することができるというわけです。

分割したものがこちら。

[
    'サンプルは、',
    '<a href="http://example.com">',
    'こちら',
    '</a>',
    'をご覧ください。',
]

そして最後に「テキスト」と「タグ」に分類します。

[
    {
        type: 'text',
        value: 'サンプルは、'
    },
    {
        type: 'tag',
        value: '<a href="http://example.com">'
    },
    {
        type: 'text',
        value: 'こちら'
    },
    {
        type: 'tag',
        value: '</a>'
    },
    {
        type: 'text',
        value: 'をご覧ください。'
    },
]

こうなれば、後はtypetextになっているものだけ背景ハイライトの置き換えをすれば、HTMLタグが破壊されることはありません。

1-3: 表示するコンテンツを作成する(ハイライトする)

ここでは、先ほど作成した分類データを使って実際に表示する(ハイライト化した)コンテンツをつくります。

例えば、先ほどの例で検索キーワードが「サンプル」の場合、以下のようになります。

[
    {
        type: 'text', // 👈 text だけハイライト化する
        value: '<span class="bg-yellow-400">サンプル</span>は、'
    },
    
    // 以下省略

]

そして、次にこの配列のvalueすべてをjoin()で結合すれば実際に表示するコンテンツになります。

1-4: テンプレート

テンプレート構造は、以下2つのブロックになります。

  • 元々のHTMLコード
  • ハイライト化したHTMLコード

そして、「元々のHTMLコード」は表示されることはありません。
単にslotを通してデータ取得しているだけです。

そのため、以下の太字部分のようにしてref参照が使えるように設定しています。

return {
    container: Vue.ref(null)
};
<div ref="container" class="hidden">

詳しいVue 3refについては以下をご覧ください。

📝参考URL: refの使い方が変わった

検索ボックスのコンポーネントをつくる

続いて、ページ内検索で使う検索ボックスもコンポーネントでつくります。

実際に使うタグは以下のようなカンジを想定しています。

<v-find-in-page-search
    class="fixed top-6 right-6"
    v-model="searchText"></v-find-in-page-search>

では、JavaScriptコードです。

const findInPageSearchComponent = {
    props: {
        modelValue: {
            type: String,
            default: ''
        }
    },
    data() {
        return {
            keyword: '',
            highlights: [],
            highlightIndex: -1
        }
    },
    methods: {
        onInput() { // 2-1: 検索キーワードが変更になったら v-model に伝える

            this.highlightIndex = -1;
            this.$emit('update:modelValue', this.keyword);

        },
        onMove(direction) { // 2-2: 該当する部分の色を変え、スクロール位置を自動で変更する

            if(this.highlights.length > 0) {

                let targetIndex = this.highlightIndex;
                const maxIndex = this.highlights.length - 1;

                if(direction === 'prev') {

                    targetIndex--;

                    if(targetIndex < 0) {

                        targetIndex = maxIndex;

                    }

                } else if(direction === 'next') {

                    targetIndex++;

                    if(targetIndex > maxIndex) {

                        targetIndex = 0;

                    }

                }

                this.highlightIndex = targetIndex;

                for(let i =  0 ; i < this.highlights.length ; i++) {

                    const target = this.highlights[i];
                    target.classList.remove('bg-yellow-400');
                    target.classList.remove('bg-red-400');

                    if(i === targetIndex) {

                        target.classList.add('bg-red-400');
                        const offsetTop = target.offsetTop - 10;
                        const scrollTop = Math.max(0, offsetTop); // 数値がマイナスの場合はゼロにする
                        window.scrollTo(0, scrollTop);

                    } else {

                        target.classList.add('bg-yellow-400');

                    }

                }

            }

        }
    },
    computed: {
        hasHighlight() {

            return (
                this.highlights.length > 0
            );

        }
    },
    watch: {
        modelValue: { // 2-3: キーワードが変更になったら必ず全てのハイライト部分を取得するようにする
            immediate: true,
            handler(value) {

                this.keyword = value;

                Vue.nextTick(() => {

                    this.highlights = document.querySelectorAll('.find-in-page-highlight');

                });

            }
        }
    },
    template: `
        <div class="bg-gray-100 rounded px-4 py-3 shadow-lg">
            <div class="text-gray-500 text-sm">ページ内検索</div>
            <input
                type="text"
                class="border-2 border-gray-300 p-1"
                placeholder="ページ内検索"
                v-model="keyword"
                @input="onInput"
                @keypress.enter="onMove('next')">
            <br>
            <div class="grid grid-cols-2">
                <div class="px-1 py-2 text-sm text-gray-700">
                    <div v-if="hasHighlight">
                        <span v-text="highlightIndex + 1"></span> / <span v-text="highlights.length"></span>
                    </div>
                </div>
                <div class="pt-2 text-right">
                    <button type="button" class="text-xs bg-blue-600 text-blue-50 px-2 py-1 rounded mr-1" @click="onMove('prev')">前へ</button>
                    <button type="button" class="text-xs bg-blue-600 text-blue-50 px-2 py-1 rounded" @click="onMove('next')">次へ</button>
                </div>
            </div>
        </div>
    `
};

ではこちらもひとつずつご紹介します。

2-1: 検索キーワードが変更になったら v-model に伝える

onInput()は、検索キーワードに変化があったとき呼ばれるメソッドです。

そして、この中ではupdate:modelValueという名前のイベントが実行されることになるのですが、これはすなわち「v-model に設定されている変数に変更を伝える」という役目があります。

つまり、コンポーネント内の入力ボックスに変更が合った場合、結果として以下のsearchTextも同じ値になります。

const app = Vue.createApp({
    data() {
        return {
            searchText: '' // 👈 親側の変数に変化が伝わる
        }
    },

// 以下省略

2-2: 該当する部分の色を変え、スクロール位置を自動で変更する

onMoveは、ハイライトされた検索キーワードへ移動する場合に使われるメソッドで、「前へ」「次へ」ボタンがクリックされたとき、そして、検索ボックスにフォーカスが当たっていてEnterキーが押されたときに実行されます。

そして、このメソッドの中では該当する場所のスクロール位置を計算し、その位置へ移動するようにしています。

2-3: キーワードが変更になったら必ず全てのハイライト部分を取得するようにする

この部分はwatchに入っているので、「値に変化があったときに実行される」コードになります。

なお、modelValuev-modelとペアになっている変数なので、v-modelの中身(つまり、外から入ってきたキーワード)が変更になったら必ずこの中にあるコードが呼ばれることになります。

やっていることは、以下の2つです。

  • 検索キーワードの橋渡し
  • 全ハイライト部分の取得

まず「検索キーワードの橋渡し」ですが、簡単に言うと「v-modelの値をそのまめ、変数 keyword に入れかえる」という意味になります。

しかし、ここで疑問が浮かんでくるかもしれません。

いや、直接modelValueを使えばいいんじゃ・・・

できれば私もそうしたいのですが、実は「v-model はコンポーネントの内部には通知されるが、外に変更は通知されない」というルールとなっています。つまり、検索キーワードの入力ボックスにonInput()がセットされていて、その中で以下のコードが実行されているのはそのためです。

this.$emit('update:modelValue', this.keyword); // 👈 外に変更を通知しています

※ 詳しくは以下のURLをご覧ください。

📝参考URL: Vueの定番エラー「Avoid mutating a prop directly…」の原因と対処方法

そして、次に「全ハイライト部分の取得」です。
このデータを使って、

  • 検索キーワードに該当する部分が何箇所あるのか
  • 該当している箇所の背景色を目立たせる

などの表示ができるようになっています。

コンポーネントを有効する

では、ここまででつくった2つのコンポーネントを実際に使用するコードを書いていきましょう。

<html>
<head>
    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-3">
    <!-- 表示コンポーネント:1つ目 -->
    <v-find-in-page-content v-model="searchText">
        <h1 class="text-2xl font-bold mb-5">吾輩わがはいは猫である</h1>
        吾輩は猫である。<br>
         (中略 ...)
        掌の上で少し落ち付いて書生の顏を見たが所謂人間といふものゝ見始であらう。<br>
    </v-find-in-page-content>
    <hr class="my-5">
    <!-- 表示コンポーネント:2つ目 -->
    <v-find-in-page-content v-model="searchText">
        此の時妙なものだと思つた感じが今でも殘つて居る。<br>
         (中略 ...)
        吾輩は藁の上から急に笹原の中へ棄てられたのである。
    </v-find-in-page-content>
    <hr class="my-5">
    <!-- 表示コンポーネント:3つ目 -->
    <v-find-in-page-content v-model="searchText">
         漸くの思ひで笹原を這ひ出すと向ふに大きな池がある。<br>
        (中略 ...)
        かくして吾輩は遂に此家を自分の住家と極める事にしたのである。
    </v-find-in-page-content>
    <!-- 検索ボックス・コンポーネント -->
    <v-find-in-page-search
        class="fixed top-6 right-6"
        v-model="searchText"></v-find-in-page-search>

    <hr class="my-5">
    引用: <a class="underline" href="https://www.aozora.gr.jp/cards/000148/files/790.html">青空文庫</a>
</div>
<script src="https://unpkg.com/vue@3.0.5/dist/vue.global.prod.js"></script>
<script>

    // コンポーネントは省略

    const app = Vue.createApp({
        data() {
            return {
                searchText: ''
            }
        }
    })
    .component('v-find-in-page-content', findInPageContentComponent) // 👈 表示コンポーネントをセット
    .component('v-find-in-page-search', findInPageSearchComponent)   // 👈 検索ボックス・コンポーネントをセット
    .mount('#app');

</script>
</body>
</html>

コンポーネントの詳細は以下をご覧ください。

📝 参考URL: グローバル・コンポーネントの設定方法も変わった

デモページをつくりました

せっかくページ内検索機能をつくったのでデモページをつくってみました。
ぜひ試してみてください。

【追記:2021.7.28】訪問ユーザーさんからご指摘をいただき、リンク切れを直しました。皆さま、ホントにいつもご協力ありがとうございます!m(_ _)m

📝 ページ内検索:デモページ

ちなみに:JavaScriptイベントについて

ちなみに今回のコンポーネントはv-htmlを使っている関係上、Vueのイベント実行は使えません。

ただし、以下のようにすることでイベントを実行することができます。

<v-find-in-page-content v-model="searchText">

    <!-- 省略 -->

    <a id="link" href="#" class="underline" onclick="return app.onLinkClick();">名前はまだ無い。</a><br>
    
    <!-- 以下省略 -->
const app = Vue.createApp({
    
    // 省略

    methods: {
        onLinkClick() { // 👈 ここが呼ばれます

            alert('クリックされました');
            return false;

        }
    }
})

// 省略

ダウンロードする

今回実際に開発したソースコードを以下からダウンロードすることができます。

Vue でページ内検索をつくる

CDNを使っているので展開したらすぐ使えますよ👍

おわりに

ということで、今回は「ページ内検索」をVue 3で実装してみました。

正直なことを言うとコードを書き始めた当初は「うーん、あのやり方で簡単にいけるだろう(ニッコリ)」と余裕でいたのですが、実際に開発を初めてみると途中でも紹介した「HTMLタグが壊れてしまう」問題が発覚し、2・3回アプローチを変えることになりました。

開発の現場ではよく起こることかもしれませんが、「しまった、そっか… 💦」はキチンと想定しておかないといけませんね😂

ちなみに、最近検証用にmac miniを購入したんですけど、ショートカットキーがCtrlキーではなくコマンドキーなので指の形が適応できていません。

あれって、変更できるんでしょうか?(こっちは変更しましたが、スクロール方向が逆なのは衝撃でした(笑))

今後アプリを作るならmacも必要になるのでなんとか慣れなきゃですね。

ではでは〜❗

「ずいぶん久しぶりですが、
ワーケーションしよっかな😊」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ