インクリメンタルサーチ

Javascript

こんにちは、kazutoです。今回は、WPのRest APIを用いて、インクリメンタルサーチを実装していきます。

インクリメンタルサーチとは

画像に alt 属性が指定されていません。ファイル名: cfa3798274fa393b7764395672841d32.gif

インクリメンタルサーチとは、アプリケーションにおける検索方法のひとつです。検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させます。今回は「検索機能」の記事を参考にし、前方一致検索という検索手法を用いて、インクリメンタルサーチを実装し、WPの記事のタイトル名が、検索フォームに入力されたキーワードと前方一致した記事を検索候補として表示していきます。

事前準備

まずは、実装の準備をしていきましょう。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <link rel="stylesheet" href="Incremental_search.css">
  <script src="Incremental_search.js"defer></script>
</head>
<body>
  <h1>Search</h1>
  <main>
    <div class="inputBox">
      <form id="SearchEventForm" >
      <input type="text">
      </form>
    </div>
    <div class="searchValue"></div>
  </main>
<div class="modalwindow">
</div>
</body>
</html>
body{
  background-color: #222222;
  color: white;
  font-family: cursive;
}
main{
  width: 800px;
  height: 500px;
  margin: auto;
  text-align: center;

}
h1{
  text-align: center;
  font-size: 60px;
}
.inputBox{
  width: 100%;
  height: 50px;
  margin: 0 auto ;
}

.SearchEventForm{
  width: 100px;
  height: 500px;
  margin: auto;
  background-color: darkcyan;
}

input[type="text"]{
  width: 250px;
  border-radius: 30px;
  background-color: #111111;
  border: 10px solid black;
  color: white;
  font-size: 20px;
  padding:2px 10px;
  font-family: cursive;
}

button[type="submit"]{
  height: 40px;
  width: 40px;
  border-radius: 30px;
  background-color: #111111;
  border: 10px solid black;
  color: white;
}


#typed{
  color: aqua;
}

#text{
  margin-top: 20px;
  font-size: 40px;
  z-index: 10;
}

.transparent{
  opacity: 0.4;
}

.modalwindow{
  position: fixed;
  background-color:black;
  width: 600px;
  height: 400px;
  top: 50px;
  left: 400px;
  right: 400px;
  display: none;
  color: white;
  text-align: center;
  font-size: 40px;
  overflow:scroll;
  padding: 40px;
  padding-top:0 ;
  border-radius: 40px;
}

.block{
  display: block;
}

.searchValue{
  margin-top:30px ;
  height: 400px;
  overflow: scroll;
 }
.resultBox{
  margin: auto;
  margin-top: 30px;
  width: 280px;
  height: 350px;
  border-radius: 10px;
  border: solid black  3px;
  background-color: white;
}
img{
  width: 100%;
  border-radius: 10px;
}

#target{
  font-size: 20px;
  /* color:white; */
  color: black;
  text-decoration: none;
  font-weight: bold;
}
#target:hover{
  /* opacity: 0.8; */
  color: red;
}

※必ずコピペをしてください。正常に動作しない恐れがあります。
今回は、インクリメンタルサーチ以外の機能は、実装しません。したがって使用するイベントは、keyupイベントのみになります。

インクリメンタルサーチ[実装]

それでは本格的な実装を初めていきましょう。

画像に alt 属性が指定されていません。ファイル名: cfa3798274fa393b7764395672841d32.gif
  • ノードの取得
  • イベント登録
  • 非同期処理を制御しよう
  • イベント発火後の処理を実装しよう

ノードの取得

まずはノードを取得していきましょう。

ノードリスト 役割
form フォーム
searchFiled 検索フォーム
main mainタグ
const form          = document.querySelector("#SearchEventForm")
const searchFiled   = document.querySelector("input[type='text']")
const main          = document.querySelector("main")

const  elment = ".searchValue"
const  noSearchResult =`<p id = "text">検索結果はありません<p>`

let reg

では、実際にノードが取得できたのか、確認をするためにデバックを行いましょう。

[form,searchFiled,main].forEach(elment=>console.log(elment))

正しくノードが取得できているのが確認できたのなら、次のトピックに進みましょう。

イベント登録

ノードを正しく取得できた所で、イベントを登録していきましょう。今回は動的に検索候補を取得していきたいので、keyupイベントを用いて実装していきます。なので、定数searchFiledにkeyupイベントを登録していきます。

searchFiled.addEventListener('keyup',async function(e){ })

今回は、addEventListenerのコールバック関数の中でasync awaitを用いるので、functionの前にasyncと命名をして非同期関数である事を宣言をしています。

実際にイベントが登録できたかデバックしてみましょう。

searchFiled.addEventListener('keyup',async function(e){ 
 console.log("test")
})

無事testという文字列が出力されているのでイベントが登録できました。なので次のステップに進みましょう。

非同期処理を制御しよう

イベント発火後の処理を実装する前に、非同期処理を制御をしましょう。今回は、Propmiseオブジェクトに非同期処理を登録をし、WPの記事を取得します。

const getWpAtricles = function(e){
    return new Promise(function(resovle,rejected){
        fetch('https://taketon-blog.com/kazugramming/wp-json/wp/v2/posts?per_page=100&page=1')
          .then((res)=>{
            const wps= res.json()
            resovle(wps)
          })
          .catch(e=>rejected(e))
      }
    )}

それでは処理内容を確認をしていきましょう。

 return new Promise(function(resovle,rejected){
    )}

まず、Promiseオブジェクトのインスタンス化します。抑えて欲しいポイントは、Promiseオブジェクトの無名関数の中で非同期処理の返り値が決まるという事です。この部分の前提が抜けてしまうと、以降の処理を読解するのが困難になるますので、覚えておきましょう。

引数についての概要は下記の表にまとめておきました。

引数 概要
resovle 成功した時に呼び出される関数、状態をfultiledに変更
rejected 失敗した時に呼び出される関数、状態をrejectedに変更

WEB APIを叩いてみよう」をPromiseについて触れましたが、Promiseは自身の状態を評価する事で非同期処理を制御をしています。

Promise状態 意味
pending 初期状態
fulfiled 処理が完了
rejected 処理が失敗

Promiseの状態は上記の3つになります。デフォルトでは、pending(初期状態)で、その後、非同期処理の成否によって状態が変わって、非同期処理を完了した事を知らせます。

少し、抽象的な内容でしたので、パッとしない部分があると思います。なので、処理内容を確認をして、理解を深めていきましょう。

fetch('https://taketon-blog.com/kazugramming/wp-json/wp/v2/posts?per_page=100&page=1')
 .then((res)=>{
  const wps= res.json()
  resovle(wps)
 })
 .catch(e=>rejected(e))
fetch('https://taketon-blog.com/kazugramming/wp-json/wp/v2/posts?per_page=100&page=1')

まずは、fetch APIを用いて、GETリクエストを送ります。
GETメソッドは、HTTPメソッドの一つで「リソースを取得する」という意味になり、今回の場合は、WPの記事を取得する様、サーバーにリクエストをしています。

 .then((res)=>{
  const wps= res.json()
  resovle(wps)
})
 .catch(e=>rejected(e))

リクエストを送ったら、サーバーからレスポンスが返ってきます。なので、レスポンスを受け取れる様、

  • thenメソッド
  • catchメソッド

でメソッドチェーンを組みます。

.then((res)=>{
  const wps= res.json()
  resovle(wps)
})

HTTP通信に成功した場合は、こちらのthenメソッドでレスポンス結果を受け取ります。

 const wps= res.json()

json()でレスポンス値をJSON形式に整形して、人間が扱いやすいデータに直しています。

resovle(wps)

resovleを呼び出して、Promiseの状態をfultiledに変更しています。したがって、非同期処理は完了し、成功したという事になります。なお、resovleの引数のwpsが、非同期処理に成功した時の返り値になります。

.catch(e=>rejected(e))

HTTP通信に失敗した場合は、こちらのcatchメソッドでレスポンス結果を受け取ります。

e=>rejected(e)

rejectedを呼び出して、Promiseの状態をrejectedに変更しています。したがって非同期処理は完了し、失敗した事になります。rejectedの引数のeが、非同期処理に失敗した時の返り値になります。

以上で、処理内容の解説は終了します。最後に非同期処理が成功するか、テストしていきましょう。

searchFiled.addEventListener('keyup',async function(e){ 
  const wps = await getWpAtricles(e)
   console.log(wps);
})

JSON形式でデータを受け取れたら、非同期処理を成功している事になりますので、次のトピックに進みましょう。

イベント発火後の処理を実装しよう

非同期処理の制御ができたので、イベント発火後の処理を実装していきましょう。

関数 役割
CheckBlank 空白のチェック&要素の削除
KeyboardInputValidation 入力制御
FilteringWpArticles 配列の整形
getWpAtricles WPの記事を取得
const getWpAtricles= function(e){
    return new Promise(function(resovle,reject){
        fetch('https://taketon-blog.com/kazugramming/wp-json/wp/v2/posts?per_page=100&page=1')
          .then((res)=>{
            const wps= res.json()
            console.log(wps);
            resovle(wps)
          })
          .catch(e=>reject(e))
      }
    )}


function CheckBlank(e){

  if(this.value==''&& document.querySelectorAll(".resultBox")){
    document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

  if (this.value.length < 0 &&e.key=="Backspace" ) {
      document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

}

function KeyboardInputValidation(e){
  switch (e.code) {
    case "Equal":
      return true
    case "ArrowUp":
      return true
    case "Enter":
      return true
    case "Space":
      return true
    case "ShiftRight":
      return true
      case "Shiftleft":
      return true
    case "ArrowLeft":
      return true
    case "ArrowRight":
      return true
    case "MetaLeft":
      return true
    case "MetaRight":
      return true
    default:
      return false
  }

}


function FilteringWpArticles(wps){
    const VALUE = this.value.split("").map((vl)=>{
        if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}"|| vl=="|" || vl=="("||vl==")") {
          return `\\${vl}`
        }
        return vl
      }).join("")

      reg = new RegExp("^"+VALUE)
      return  wps.filter((wp)=>{
        if(wp.title.rendered.match(reg)){
         return wp
         }
       })
  }

searchFiled.addEventListener('keyup',async function(e){
    CheckBlank.bind(this,e)()
    const KIV= KeyboardInputValidation(e)
    if(KIV)return
        const wps = await getWpAtricles(e)
        const targets= FilteringWpArticles.call(this,wps)
        if ( document.querySelectorAll(".resultBox")) {
            document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
        }
        if(targets.length>0 && this.value.length >0 ){
          targets.forEach(target => {
            document.querySelector(elment).insertAdjacentHTML(`beforeend`, `
              <div class="resultBox">
                <img src="${target.thumbnail_url.medium.url}" height="${target.thumbnail_url.medium.height}" width="${target.thumbnail_url.medium.width}">
                  <p>
                    <a id="target" href=${target.guid.rendered}>${target.title.rendered}</a>
                  </p>
              </div>`
            )
          })
          }else{
            (this.value==0 ?
                document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult"></div>`)
              :
                document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult">${ noSearchResult}</div>`)
             )
          }
  })
  

それでは、解説していきます。

function CheckBlank(e){

  if(this.value==''&& document.querySelectorAll(".resultBox")){
    document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

  if (this.value.length < 0 &&e.key=="Backspace" ) {
      document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

}
CheckBlank.bind(this,e)()

CheckBlankを呼び出して、空白のチェックをし、以下の条件に該当する場合に指定した要素を削除します。

  • 検索フォームが空白&&resultBoxが存在する場合
  • 検索フォームが空白&&キーコードがBackspaceだった場合

となります。検索フォームが空白だった場合、今回の実装方法だと、入力して空白になるタイミングに意図しない挙動になってしまうので、keyupイベントが発火したタイミングで、CheckBlankを呼び出しています。

CheckBlank.bind(this,e)()

CheckBlankの呼び出し方が普段の関数の呼び方が異なりますので、その部分を解説していきます。

//thisArg==this
//arg1,arg2==関数の引数
.bind(thisArg,arg1,arg2)()

bindは、thisの値を意図的に設定ができ、返り値として、thisの値が書き変わった、新しい関数を返します。なお、元の関数に引数がある場合、引数についても設定が可能になります。
末尾の()は即時関数といいます。即時関数とは、関数を定義すると同時に実行するための構文です。bindは、thisの値が書き変わった、新しい関数を返すだけなので、関数を呼び出すわけでは、ありません。

const CV =CheckBlank.bind(this,e)
CV()

bindを用いる方法として、一度、変数に格納してから関数を呼び出す方法がありますが、即時関数を用いれば、ワンライナーで記述する事ができ記述量が減らせるので、即時関数を用いました。

  function KeyboardInputValidation(e){
  switch (e.code) {
    case "Equal":
      return true
    case "ArrowUp":
      return true
    case "Enter":
      return true
    case "Space":
      return true
    case "ShiftRight":
      return true
      case "Shiftleft":
      return true
    case "ArrowLeft":
      return true
    case "ArrowRight":
      return true
    case "MetaLeft":
      return true
    case "MetaRight":
      return true
    default:
      return false
  }

}
const KIV= KeyboardInputValidation(e)
if(KIV)return

KeyboardInputValidationを呼び出して、キーボード入力を制御をしています。具体的には、キーコードという、各キーに割り当てられたコードを取得し、case文を用いて、条件分岐を行います。ユーザーによって入力されたキーコードが、制御したいキーコードだった場合にtrueを返し、バリデーションを作動させます。キーコード入力を制御する理由は、意図しない挙動になるのを防ぐためです

default:
      return false

なお、デフォルトでは、falseがKeyboardInputValidationの返り値になる様に設定しており、バリデーションは、作動しません。条件に当てはまらない場合は、default部分の処理が読み込まれます。

バリデーションが作動しなかった場合は、入力されたキーコードが有効である事を示し、次のフェーズに移ります。

 const getWpAtricles= function(e){
    return new Promise(function(resovle,reject){
        fetch('https://taketon-blog.com/kazugramming/wp-json/wp/v2/posts?per_page=100&page=1')
          .then((res)=>{
            const wps= res.json()
            resovle(wps)
          })
          .catch(e=>reject(e))
      }
    )}

const wps = await getWpAtricles.bind(e)

入力されたキーコードが有効である場合は、WPの記事一覧を取得します。要するにWP REST APIにGETリクエスト送ります。

const wps = await getWpAtricles(e)

await式を用いて、getWpAtriclesの非同期処理が終わるのを待って、返り値(WP記事一覧)を定数wpsに格納します。

function FilteringWpArticles(wps){
    const VALUE = this.value.split("").map((vl)=>{
        if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}"|| vl=="|" || vl=="("||vl==")") {
          return `\\${vl}`
        }
        return vl
      }).join("")

      reg = new RegExp("^"+VALUE)
      return  wps.filter((wp)=>{
        if(wp.title.rendered.match(reg)){
         return wp
         }
       })
    }
const targets= FilteringWpArticles.call(this,wps)

FilteringWpArticlesを呼び出し、検索フォームに入力した文字列とWPの記事のタイトル名が前方一致した記事のみを抽出し、配列を作り直しています。

const VALUE = this.value.split("").map((vl)=>{
        if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}"|| vl=="|" || vl=="("||vl==")") {
          return `\\${vl}`
        }
        return vl
      }).join("")

まず初めに、文字列のパターンを評価するために、検索フォームに入力した文字列を最適化した形に整形します。

処理内容としては、splitで文字列を分割をし、mapで文字列を最適化して、最後にJoinを用いて、文字を連結させるとなります。少し難しく感じると思いますが、大事な部分は、mapで行っている処理です。

.map((vl)=>{
    if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}" || vl ==" ||vl=="|") {
      return `\\${vl}`
    }
    return vl
  })
if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}" || vl ==" ||vl=="|"||vl=="("||vl==")") {
      return `\\${vl}`
   }

こちらの条件に該当する文字列や記号は、正規表現を使用する際に特別な意味を持ちます。したがって、ただの文字列とは、扱われずに正規表現特有の意味合いになってしまいます。この問題を改善するため”\”バックスラッシュを用いて、「通常の文字列・記号として扱ってください」と宣言をします。

※正規表現とは文字列内で文字の組み合わせを照合するために用いられるパターンです

 return `\\${vl}`

なので、上記の様に元の文字に対して、バックスラッシュを追加する形を返り値として返しています。

 reg = new RegExp("^"+VALUE)

RegExpオブジェクトのインスタンスを生成をして、文字列のパターンを確認をする準備を行います、(”^”は先頭の文字とマッチするという意味になります)

 return  wps.filter((wp)=>{
        if(wp.title.rendered.match(reg)){
         return wp
         }
       })
const targets= FilteringWpArticles.call(this,wps)

最終的にfilterとmatchを用いて、検索フォームに入力した文字列とWPの記事のタイトル名が前方一致した記事のみを抽出をし、FilteringWpArticlesの返り値になります。

FilteringWpArticles.call(this,wps)

FilteringWpArticlesの呼び出し方が普段の関数の呼び方が異なりますので、その部分を解説していきます。

thisArg==this
arg1,arg2==引数
.call(thisArg,arg1,arg2)

callは、thisの値と引数を意図的に設定ができ、その関数を呼び出します。少し難しい説明になってしまいましたが、bindと類似した関数になります。2つの関数の違う点は、bindは、新しい関数を返り値として返しますが、callは、そのまま関数を呼び出します。もし、この解説で理解できなかった場合は、デベロッパーツールなどで両者を実行してみてください。違いがわかると思います。

検索結果を抽出ができたら、次のステップに進みます。

 if ( document.querySelectorAll(".resultBox")) {
 document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
 }

resultBoxクラスが存在する場合は、その要素を削除します。resultBoxクラスは 、検索結果を持った要素になります。この要素を削除しないと、同じWPの記事のタイトル名が重複して表示されてしまいます。

最終的に定数targetsに格納されている値によって、挙動を変えていきます。

  • 検索ヒット数がある場合
    検索結果を表示する
  • 検索結果が無い場合
    →「検索結果はありません」と表示
    → 何も表示しない

となります。

 if(targets.length>0 && this.value.length >0 ){
          targets.forEach(target => {
            document.querySelector(elment).insertAdjacentHTML(`beforeend`, `
              <div class="resultBox">
                <img src="${target.thumbnail_url.medium.url}" height="${target.thumbnail_url.medium.height}" width="${target.thumbnail_url.medium.width}">
                  <p>
                    <a id="target" href=${target.guid.rendered}>${target.title.rendered}</a>
                  </p>
              </div>`
            )
          })
          }

検索結果がヒットした場合は、定数targetsに格納されている値をforEachを用いて、配列から値を取り出し、insertAdjacentHTMLを用いて、searchValueクラスに要素を挿入していきます。

}else{
 (this.value==0 ?
    document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult"></div>`)
  :
    document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult">${ noSearchResult}</div>`)
   )
}

検索結果が無い場合は、以下の様な挙動を取ります。

  • 検索フォームに文字が無い場合
    →何も表示しない
  • 検索フォームに文字がある場合
    →「検索結果はありません」と表示

となります。

以上で実装は終わりになります。完成したソースコードは下記に貼り付けておきますので、ご活用ください。

const form          = document.querySelector("#SearchEventForm")
const searchFiled   = document.querySelector("input[type='text']")
const main          = document.querySelector("main")

const  elment = ".searchValue"
const  noSearchResult =`<p id = "text">検索結果はありません<p>`

function KeyboardInputValidation(e){
  console.log(e.code);
  switch (e.code) {
    case "Equal":
      return true
    case "ArrowUp":
      return true
    case "Enter":
      return true
    case "Space":
      return true
    case "ShiftRight":
      return true
      case "Shiftleft":
      return true
    case "ArrowLeft":
      return true
    case "ArrowRight":
      return true
    case "MetaLeft":
      return true
    case "MetaRight":

      return true
    default:
      return false
  }

}
function CheckBlank(e){

  if(this.value==''&& document.querySelectorAll(".resultBox")){
    document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

  if (this.value.length < 0 &&e.key=="Backspace" ) {
      document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
  }

}

function FilteringWpArticles(wps){
    const VALUE = this.value.split("").map((vl)=>{
        if (vl== "[" || vl =="]"|| vl=="+"||vl=="?" || vl=="." || vl=="{" || vl=="}"|| vl=="|" || vl=="("||vl==")") {
          return `\\${vl}`
        }
        return vl
      }).join("")

      reg = new RegExp("^"+VALUE)
      return  wps.filter((wp)=>{
        if(wp.title.rendered.match(reg)){
         return wp
         }
       })
    }
searchFiled.addEventListener('keyup',async function(e){
    CheckBlank.bind(this,e)()
    const KIV= KeyboardInputValidation(e)
    if(KIV)return
    console.log("dd");
        const wps = await getWpAtricles(e)
        const targets= FilteringWpArticles.call(this,wps)
        console.log(targets);
        if ( document.querySelectorAll(".resultBox")) {
            document.querySelectorAll(".resultBox").forEach((target=>target.remove()))
        }
        if(targets.length>0 && this.value.length >0 ){
          targets.forEach(target => {
            document.querySelector(elment).insertAdjacentHTML(`beforeend`, `
              <div class="resultBox">
                <img src="${target.thumbnail_url.medium.url}" height="${target.thumbnail_url.medium.height}" width="${target.thumbnail_url.medium.width}">
                  <p>
                    <a id="target" href=${target.guid.rendered}>${target.title.rendered}</a>
                  </p>
              </div>`
            )
          })
          }else{
            (this.value==0 ?
                document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult"></div>`)
              :
                document.querySelector(elment).insertAdjacentHTML(`beforeend`, `<div class="resultBox noSearchResult">${ noSearchResult}</div>`)
             )
          }
  })

まとめ:インクリメンタルサーチ

今回は、WPの記事一覧を検索するインクリメンタルサーチを実装しました。かなり難しい内容でしたが、インクリメンタルサーチは、様々な、WEBサイトに活用されています。皆さんも、ご自身でアプリを作成する際は、インクリメンタルサーチを組み込むのをチャレンジしてみてください。

以上,kazutoでした。