さぁ!検索しよう!

ECサイトの仕組みが知りたい&WordPressでプラグインを利用せずにECサイトのCMSを構築してみたいと思い、このような記事を書くに至りました。

前回に続いて第3回は、ZOZOTOWN のような、検索ワードに当てはまる商品名がリアルタイムで一覧表示される検索機能を実装する方法です。

尚、index.phpやsingle.php等のテンプレートは既に用意されているものとします。

検索フォームを作成する

はじめに検索フォームを作成します。

スタイルについてですが、今回は検索フォームのスタイルをBootstrap4で指定するため、functions.phpに
Bootstrap4を読み込むコードを記述します。

functions.php

add_action( 'wp_enqueue_scripts', 'theme_enqueue_styles' );
function theme_enqueue_styles() {
  wp_enqueue_style( 'bootstrap', 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css', array(), null );
}

尚、bootstrap4ではなく独自でスタイルを指定する場合は、theme_enqueue_styles関数内の

wp_enqueue_style( 'bootstrap', 'https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css', array(), null );

は記述する必要ないため、theme_enqueue_styles関数を空の状態にしておいてください。

続いて検索フォームのHTMLを定義します。searchform.phpを作成して以下のようにしてください。

searchform.php

<?php $unique_id = esc_attr( uniqid( 'search-form-' ) ); ?>
<form role="search" method="get" class="search-form" action="<?php echo esc_url( home_url( '/' ) ); ?>">
    <label for="<?php echo $unique_id; ?>">
        <span class="sr-only sr-only-focusable screen-reader-text">Search for</span>
    </label>
    <div class="d-flex">
    <input type="text" id="<?php echo $unique_id; ?>" class="search-field form-control" placeholder="Search" value="<?php the_search_query(); ?>" name="s" autocomplete="off" />
    <input type="submit" class="search-submit btn btn-outline-primary" value="検索"><span class="sr-only sr-only-focusable">Search</span>
    </div>
    <?php
    $args = array(
        'show_option_all' => 'すべての商品',
        'class' => 'form-control',
        'hide_empty' => 0
    );
    wp_dropdown_categories($args);
    ?>
    <ul class="result list-group list-group-flush"></ul>
</form>

テキストボックスのvalue属性にthe_search_query関数を指定することで、検索して検索結果ページに移動した後もテキストボックス内に入力したワードを残すことができます(そもそも指定しなければいけません)。

同じくテキストボックスにautocomplete="off"を指定することで、ブラウザのフォーム自動補完機能を無効にしています。テキストボックス下に一致した商品名を一覧表示するため、有効のままだと一覧が隠れてしまいます。

wp_dropdown_categories関数で、検索時にカテゴリーを選択して商品を絞る機能を実装しています。
この関数で指定したパラメータについては以下の通りです。

キー
show_option_all 先頭のoption要素を全カテゴリーを選択するリンクにする
class select要素に追加するクラス
hide_empty 投稿のないカテゴリーを表示するかどうか。1で非表示、0で表示

他のパラメータについてはCodexをご覧ください。

.resultで一致した商品名を一覧表示します。

最後に検索フォームを設置したい場所のテンプレートでget_search_form関数を実行すれば、searchform.phpの内容がそのまま出力されます。

<?php get_search_form(); ?>

ECサイトの検索フォームは大体ヘッダーかサイドバーに設置されていると思います。。

検索結果の一覧ページを作成する

検索したあとに一致した商品を一覧表示するページを作成します。

テンプレートsearch.phpを作成します。

search.php

<?php get_header(); ?>
    <section id="primary" class="content-area">
        <main id="main" class="site-main" role="main">
        <?php if ( have_posts() ) : ?>
            <header class="page-header mb-5">
                <h1 class="page-title"><?php printf( __( 'Search Results for: %s', 'twentyfifteen' ), get_search_query() ); ?></h1>
                <p>カテゴリー:
                <?php 
                    if(@$_GET['cat'] != 0):
                        $cat = get_category($_GET['cat']);
                        echo $cat->name; 
                    else:
                        echo 'すべての商品';
                    endif;
                ?>
                </p>
            </header><!-- .page-header -->
            <div class="row site-main-inner">
            <?php
            while ( have_posts() ) : the_post(); ?>
                <?php
                get_template_part( 'content', 'list' );
            endwhile;
            ?>
            </div>
            <?php
        else :
            get_template_part( 'content', 'none' );
        endif;
        ?>
        </main><!-- .site-main -->
    </section><!-- .content-area -->
<?php get_footer(); ?>

ページ上部に検索したワードと検索時に選択したカテゴリーを表示しています。

検索したワードはget_search_query関数で出力します。

検索時に選択したカテゴリーは以下のようにして出力します。

  • $_GET['cat']が0ではない、つまりカテゴリーが選択されていた場合、そのカテゴリー情報を$catとして取得し、カテゴリー情報からカテゴリー名を出力する。
  • 逆に$_GET['cat']が0であった場合は、カテゴリーを「すべての商品」に選択したということになるため「すべての商品」と出力する。

検索に一致した商品の一覧表示は第1回で作成したテンプレートのcontent-list.phpで行うため、get_template_part関数でインクルードします。

content-list.php

<div class="col-sm-6 col-md-4 col-lg-3 mb-4">
    <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
        <?php
            $cfName = get_post_meta($post->ID, 'name', true);
            $cfPrice = get_post_meta($post->ID, 'price', true);
            $cfStock = get_post_meta($post->ID, 'stock', true);
            $cfImage = get_post_meta($post->ID, 'image', true);
        ?>
        <div class="card">
            <div class="bg-light embed-responsive embed-responsive-16by9">
            <?php if($cfImage): ?>
                <?php echo wp_get_attachment_image(esc_html($cfImage[0]), 'full', false, array('class' => 'embed-responsive-item')); ?>
            <?php else: ?>
                No image
            <?php endif; ?>
            </div>
            <div class="card-body">
                <?php the_title( sprintf( '<h5 class="card-title"><a href="%s" rel="bookmark">', esc_url( get_permalink() ) ), '</a></h5>' ); ?>
                <h6 class="card-subtitle mb-2 text-muted">¥<?php echo number_format(esc_html($cfPrice)) ?></h6>
                <p class="card-text">
                <?php 
                    if($cfStock <= 0){
                        echo '<strong class="text-danger">在庫なし</strong>';   
                    }else if($cfStock < 10) {
                        echo '<strong class="text-warning">残り'.esc_html($cfStock).'点</strong>';
                    }else {
                        echo '<strong class="text-success">在庫あり</strong>';
                    }
                ?>
                </p>
            </div>
        </div>
    </article><!-- #post-## -->
</div>

検索に一致した商品が見つからなかった場合は、第1回で作成したテンプレートのcontent-none.phpでその旨のメッセージを表示します。そのため、get_template_part関数でcontent-none.phpをインクルードします。

content-none.php

<header class="page-header">
        <h1 class="page-title"><?php _e( 'Nothing Found'); ?></h1>
    </header><!-- .page-header -->
    <div class="page-content">
        <?php if ( is_home() && current_user_can( 'publish_posts' ) ) : ?>
            <p><?php printf( __( 'Ready to publish your first post? <a href="%1$s">Get started here</a>.' ), esc_url( admin_url( 'post-new.php' ) ) ); ?></p>
        <?php elseif ( is_search() ) : ?>
            <p><?php _e( 'Sorry, but nothing matched your search terms. Please try again with some different keywords.' ); ?></p>
            <?php get_search_form(); ?>
        <?php else : ?>
            <p><?php _e( 'It seems we can’t find what you’re looking for. Perhaps searching can help.' ); ?></p>
            <?php get_search_form(); ?>
        <?php endif; ?>
    </div><!-- .page-content -->
</section><!-- .no-results -->

データベースに保存されている商品名を全て取得する

WordpPressにおいて、一度に全ての投稿からカスタムフィールドの値を取得する関数は存在しないため、SQL文を発行してデータベースから直接取得します。

先程作成したtheme_enqueue_styles関数に以下のコードを追加します。

functions.php

    global $wpdb;

    //投稿
    $post_type = 'post';

    //公開中の投稿
    $post_status = 'publish';

    //全投稿のタイトルを取得
    $all_post_title = $wpdb->get_col($wpdb->prepare(
        "SELECT DISTINCT post_title FROM $wpdb->posts WHERE post_type = %s AND post_status = %s;",
        $post_type, $post_status
    ));

WordPressのPHPコードからデータベースとやり取りするには$wpdbオブジェクトにアクセスする必要があります。
そのため、初めに$wpdbをグローバル変数として宣言します。

$post_typepost$post_statuspublishは、SQL文でデーブルから選択するデータの条件であり、それぞれ変数にしておきます。

prepare()の第一引数でSQL文を準備します。SQL文はwp_postsテーブルから公開中(publish)である投稿(post)のタイトル(post_title)を全て選択するという命令です。全てと言いましたが、正確にはSELECTの後にDISTINCTと続けることで、重複したフィールド(データ)(post_title)が見つかった場合は、先に見つかった方を選ぶようにします。

また、WHEREの後に続く条件の値が「%s」となっています。これはプレースホルダと言い、ここには第二引数に指定した値が、後から入るようになっています。

何故後から入れるのか。それはprepareという名前にあります。prepare()はプリペアステートメントと呼ばれるものを利用するための関数です。
プリペアステートメントとは、SQL文(クエリ)を先に用意しておき、その後SQL文内のパラメータの値だけを変更してクエリを実行することができる機能です。
今回でいえば、第一引数でSQL文(クエリ)を先に用意して第二引数でパラメータである$post_type$post_statusの値を後から指定しています。

プリペアステートメントを利用することで、クエリの解析やコンパイルなどにかかる時間は最初の一回だけで済むため、より高速に実行することができます。
また、SQLインジェクション対策であるパラメータのエスケープ処理も自動で行ってくれるため、安全です。

$wpdbget_colメソッドでSQL文で選択したpost_titleのカラム(全ての商品名)を取得し、これを$all_post_titleとします。

PHPの配列をJavaScriptの配列に変換する

取得した全商品名が格納されている配列$all_post_titleは、この後JavaScriptで操作します。しかし、この配列はPHPの配列であるため、JavaScriptで扱える配列に変換する必要があります。そのため、まずjson_encodeでJSON形式(単なる文字列)に変換し、これを$all_post_title_jsonとします。

theme_enqueue_styles関数に以下のコードを追加します。

functions.php

$all_post_title_json = json_encode($all_post_title);

これでPHPの配列がJSON形式に変換されましたが、JSONは単なる文字列であるため、JSON形式に変換しただけではJavaScriptで配列として扱うことができません。なので、JavaScript側でJSONデータ$all_post_title_jsonJSON.parse()で解析し、単なる文字列をJavaScriptのオブジェクト(配列)に変換します。

theme_enqueue_styles関数に以下のコードを追加します。

functions.php

?>
<script>
    const allPostTitle = JSON.parse('<?php echo $all_post_title_json; ?>');
</script>
<?php

検索フォームに機能を実装する

ここからがメインとなります。作成した検索フォームに、入力中に当てはまる商品名があればリアルタイムで一覧表示させる機能をJavaScriptで実装します。

それではsearchform.jsを作成し、コードを書く前にtheme_enqueue_styles関数にsearchform.jsを読み込むコードを追加します。

functions.php

wp_enqueue_script('searchform', get_stylesheet_directory_uri().'/js/searchform.js', array('jquery'), false, true);

searchform.jsに以下のコードを記述します。

searchform.js

(function(){

    //検索フォーム
    const searchform = document.querySelector('.search-form');

    //入力フィールド
    const searchField = document.querySelector('.search-field');

    //一覧
    const result = document.querySelector('.result');

    searchField.addEventListener('keyup', (e) => validation(e));
    searchField.addEventListener('focus', (e) => validation(e));
    searchField.addEventListener('paste', (e) => {
        setTimeout((e) => {
            validation(e);
        }, 100, e);
    });
    searchField.addEventListener('cut', (e) => {
        setTimeout((e) => {
            validation(e);
        }, 100, e);
    });
    function validation(e) {
        const good = [];

        //inputのvalueが空になったら
        if(searchField.value == '') {

            //結果一覧を削除する
            result.innerHTML = '';

            //validation関数から抜ける
            return;
        }

        //allPostTitleを走査
        allPostTitle.forEach((name) => {
            //走査対象(name)が変わる度にリセット
            //データベース内の入力値に一致した商品名を一文字ずつ代入するための変数
            let nameChars = '';

            //inputのvalueを一文字ずつ代入するための変数
            let searchChars = '';

            //nameを先頭から一文字ずつ調べる
            /*
            1文字目
            商品名:ザ
            入力値:ザ
            ok
            2文字目
            商品名:・
            入力値:プ
            bad
            */

            //商品名nameを走査
            for(let i = 0; i <= name.length; i++) {
                //nameの先頭から1文字を取得していく(調べていく)
                const nameChar = name.substring(i, i+1);

                //inputのvalueの先頭から1文字を取得していく(調べていく)
                const searchChar = searchField.value.substring(i, i+1);

                //一文字ずつ代入していく
                nameChars += nameChar;
                searchChars += searchChar;

                //searchField.valueを調べ終わったら
                if(searchChar === '') {
                    //途中までのnameとinputのvalueが一致していれば

                    //nameCharsをsearchCharsの文字数で切り抜く。searchCharsと同じ文字数にする。途中まで一致しているかどうかを調べるため。
                    const nameChars2 = nameChars.substring(0, searchChars.length);
                    if(nameChars2 === searchChars){
                        //goodに格納する
                        good.push(`<li class="list-group-item"><a class="search-link" href="?s=${name}">${name}</a></li>`);

                        //これ以上調べる必要がないので
                        break;
                    }else{
                        break;
                    }
                }
            }

            //searchField.valueを調べきれなかったら。name.length回のループの間でsearchCharが空にならなかった場合。つまり、searchField.valueの文字数がnameの文字数を超えた場合。収まりきらなかった場合
            //つまりnameとsearchField.valueが一致していない
            if(name.length < searchField.value.length) {
                //nameとsearchField.valueが一致しなければ
                if(name === searchField.value.replace(/\s+$/g, '')) {//空白を消すのはnameを調べ終わった後。なぜなら空白が入った商品名があるかもしれないから
                    good.push(`<li class="list-group-item"><a class="search-link" href="?s=${name}">${name}</a></li>`);
                }
            }

            result.innerHTML = good.toString().replace(/,/g, '');
            if(document.querySelector('.search-link')) {
                const searchLinks = document.querySelectorAll('.search-link');
                searchLinks.forEach((searchLink, i) => {
                    searchLink.addEventListener('click', (e) => {
                        e.preventDefault();
                        searchField.value = e.target.innerHTML;
                        searchform.submit();
                    });
                });
            }
        });
    }
})();

上記では主に、先程取得した全商品名を基に、入力フィールドに入力した値と各商品名を一文字ずつ比較します。一文字目から一致している間はその商品名を一覧に表示し、途中で不一致となれば表示されていた商品名を一覧から削除します。また、一覧表示されている商品名をクリックするとそのまま送信されるようにしています。

それではコードを詳しく見ていきましょう。

グローバル変数を宣言

全体で使うグローバル変数を宣言しています。

//検索フォーム
const searchform = document.querySelector('.search-form');

//入力フィールド
const searchField = document.querySelector('.search-field');

//一覧
const result = document.querySelector('.result');

searchformは検索フォーム.search-formsearchFieldは入力フィールド.search-field、そしてresultは一致した商品名の一覧.resultの参照です。

入力フィールド操作したときの処理

サイトの閲覧者は、入力フィールドに対して以下の操作を行うことが想定されます。

  • 入力フィールドをフォーカス
  • 入力フィールドに文字を入力
  • 入力フィールドに文字を貼り付ける
  • 入力フィールドの文字を切り取る

これらの操作が行われたときに今回実装する機能を働かせます。

addEventListenerメソッドで、入力フィールドsearchFieldに対してイベントが配信される(操作が行われる)たびに呼び出される関数(今回実装する機能)を設定しています。

searchField.addEventListener('keyup', (e) => validation(e));
searchField.addEventListener('focus', (e) => validation(e));
searchField.addEventListener('paste', (e) => {
    setTimeout((e) => {
        validation(e);
    }, 100, e);
});
searchField.addEventListener('cut', (e) => {
    setTimeout((e) => {
        validation(e);
    }, 100, e);
});

入力フィールドsearchFieldに対するイベントはキーボードで文字を入力したときkeyupときを始め、フォーカスされたときfocus、文字列が貼り付けられたときpaste、そして入力されている文字が切り取られたときcutです。

pasetcutイベントは、それぞれ文字が貼り付け(切り取)られる直前に発火されます。なのでそれでは早いため、貼り付け(切り取)られた入力フィールドの文字を取得できません。なのでsetTimeout()validation関数の実行を遅らせることで、貼り付けた又は切り取られた後の文字を取得するようにしています。

入力フィールドsearchFieldに対してこれらのイベントが配信されると、validation関数が実行されます。

入力した値に一致した商品名を格納する配列

ここからはvalidation関数についてです。validation関数は、入力フィールドに入力した値と各商品名を一文字ずつ比較します。

始めにgood変数を宣言し、空の配列を代入しています。この配列には入力した値に一致した商品名を格納します。

let good = [];

入力フィールドに値が何も入力されていなければ一覧から商品名を全て削除して、validation関数を終了します。

//inputのvalueが空になったら
        if(searchField.value == '') {

            //結果一覧を削除する
            result.innerHTML = '';

            //validation関数から抜ける
            return;
        }

forEach関数で全商品名allPostTitleを一つずつ調べていきます。forEach関数の引数nameは現在処理対象の商品名です。

allPostTitle.forEach((name) => {
            //走査対象(name)が変わる度にリセット
            //データベース内の入力値に一致した商品名を一文字ずつ代入するための変数
            let nameChars = '';

            //inputのvalueを一文字ずつ代入するための変数
            let searchChars = '';

            //nameを先頭から一文字ずつ調べる
            /*
            1文字目
            商品名:ザ
            入力値:ザ
            ok
            2文字目
            商品名:・
            入力値:プ
            bad
            */    

更にfor文で商品名nameと入力された値searchFieldValueを同時に先頭から一文字ずつそれぞれnameCharsearchCharとして取得します。

nameCharsには商品名namesearchCharsには入力された値searchFieldValueを一文字ずつ連結していきます。

//商品名nameを走査
            for(let i = 0; i <= name.length; i++) {
                //nameの先頭から1文字を取得していく(調べていく)
                const nameChar = name.substring(i, i+1);

                //inputのvalueの先頭から1文字を取得していく(調べていく)
                const searchChar = searchField.value.substring(i, i+1);

                //一文字ずつ代入していく
                nameChars += nameChar;
                searchChars += searchChar;

商品名より先に入力された値の連結が終わったら、途中まで一致しているかどうかを判定するためにsubstringnameCharssearchCharsの文字数で切り抜いて、searchCharsと同じ文字数にします。また、切り抜かれた後のnameCharsnameChars2として取得します。

 //searchField.valueを調べ終わったら
                if(searchChar === '') {
                    //途中までのnameとinputのvalueが一致していれば

                    //nameCharsをsearchCharsの文字数で切り抜く
                    const nameChars2 = nameChars.substring(0, searchChars.length);

nameChars2searchCharsが同じであれば商品名と入力された値が現時点で一致しているということであるため、good<li class="list-group-item"><a class="search-link" href="?s=${name}">${name}</a></li>をイれます。そしてbreakでfor文から抜けます。こじ時点ではまだ一覧表示されません。goodにイれるだけです。反対に一致してなければ何もせずにそのままfor文から抜けます。

if(nameChars2 === searchChars){
                        //goodに格納する
                        good.push(`<li class="list-group-item"><a class="search-link" href="?s=${name}">${name}</a></li>`);

                        //これ以上調べる必要がないので
                        break;
                    }else{
                        break;
                    }

逆に入力した値より先に商品名の連結が終わった場合は文字数からして一致していないということです。

if(name.length < searchField.value.length) {

ですが、多い理由はスペースのせいかもしれません。なので、replaceでスペースを除いた入力された値で再び商品名と比較し、同じであった場合は、スペースを除いた入力値と商品名が一致しているということなので、goodに商品名とそのリンクを持つリスト項目要素を追加します。

if(name === searchField.value.replace(/\s+$/g, '')) {//空白を消すのはnameを調べ終わった後。なぜなら空白が入った商品名があるかもしれないから
                    good.push(`<li class="list-group-item"><a class="search-link" href="?s=${name}">${name}</a></li>`);
                }

一致した商品名を一覧に追加する処理

toStringで配列goodの内容を文字列にして、replaceでその文字列から配列要素を区切っていたカンマを取り除いて一覧に追加します。

result.innerHTML = good.toString().replace(/,/g, '');

商品名をクリックしたときの処理

商品名のリンクが存在する(商品名が一覧に表示されている)場合、商品名のリンクをsearchLinksとして取得します。

if(document.querySelector('.search-link')) {
    const searchLinks = document.querySelectorAll('.search-link');

取得する際にいきなり

const searchLinks = document.querySelectorAll('.search-link');

と書いてしまうと、商品名が一覧に表示されていない場合に.search-linkが存在しないというエラーが発生します。

次に、商品名のリンク.search-linkをクリックしたときにe.preventDefault();でリンクによるページ移動を無効にします。リンクによるページ無効にしないと、フォームの送信より先にリンク先へページ移動してしまうため、それ以降の処理が行われず、フォームが送信されないからです。

    searchLinks.forEach((searchLink, i) => {
        searchLink.addEventListener('click', (e) => {
            e.preventDefault();
            searchField.value = e.target.innerHTML;
            searchform.submit();
        });
    });
}

その後、クリックしたリンクの文字列を入力フィールドに表示(value属性に指定)し、searchform.submit()でフォームを送信します。

以上で検索フォームに検索キーワードに当てはまる商品名がリアルタイムで一覧表示される機能を実装する方法を終わります。

参考文献