Edited at

駅名のインクリメンタルサーチができる検索窓をVue.jsで作る

筆者は、Vue.jsの「双方向データバインディング」のお手軽さ(とその語呂のかっこよさ)に惹かれ、早速周囲に広めているVue.js初心者です。

データバインディングを勉強できる題材として、駅名をインクリメンタルに検索できる検索窓を自作したので、その手順をまとめました。長くなりましたが、目次を活用しながらお付き合いください!!


何ができるか

下記のような駅名をインクリメンタルサーチできる検索窓を作ります。

incremental_demo.gif

以下の機能を持っています。


  • 駅すぱあとWebサービス フリープラン」を使った駅名検索

  • 日本語変換確定前も含めたインクリメンタルサーチ

  • ユーザーの入力頻度に関わらず、2回/1秒以上の頻度での検索リクエストをしない

  • ユーザーが選択した駅名を表示


何に言及しないか


  • Vue.jsの包括的な説明(コード内に登場する機能のみ説明します。)

  • vue-cliを利用した環境構築(簡単のためCDN版を利用しています。)

  • アクセスキーの隠し方

  • バックエンドでAPIに問い合わせ、結果のみフロントエンドに伝える方法(今回はフロントから直に叩きます。)


誰が対象?

この記事は「ここ数ヶ月でVue.jsに出会い、小さなアプリを1つ作ってみた」くらいの初心者(要は筆者)が、ドキュメント片手に読める程度を目指して書いています。

中級者以上の方には冗長に感じる部分があると思いますので適時飛ばしてお読みください。


準備

以下のファイルを作ってください。


  • index.html

  • main.js

  • main.css

ディレクトリ構成はお好みで。各ファイルのリンクを済ませておいてください。

また当記事の駅名検索は、「駅すぱあとWebサービス フリープラン」といWebAPIから駅情報に関するデータを取得することで実現しています。そのためアクセスキーが必要です。


「駅すぱあとWebサービス フリープラン」について

経路探索サービス「駅すぱあと」が持っている情報・機能を取得できるAPIです。

「フリープラン」というその名の通り、無料で利用することができます。経路検索などができる「スタンダードプラン」より機能が制限されますが、回数や期間に制限はありません。ドキュメントやサンプルはこちらです。

利用にはアクセスキーが必要です。当記事の内容を手元で動かしたい方はこちらの案内からキーの発行を済ませてください。フォームから申し込むと数営業日でアクセスキーが書かれたメールを受け取れます。

ちなみに同様のことは「駅すぱあとWebサービス」のスタンダートプランやAmazon Saasストア版でも試していただけます。Saasストア版ならキー発行もすぐですので、ぜひご覧ください→駅すぱあとWebサービス for Amazon


実装

前置きが長くなりました。順番に実装していきましょう!

ぜひお手元でコードを動かしながらお読みください。


Vue.jsでHello World!

まずはVue.jsを使う準備をしましょう。以下のコードでHello Worldをしてみます。


index.html

<div id="app">

<input type="text" v-model="searchWord"></input>
<button v-on:click="searchStation"></button>
<p>{{searchWord}}</p>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="./js/main.js"></script>



main.js

var app = new Vue({

el: '#app',
data: {
searchWord: 'Hello World!'
},
methods: {
searchStation: function (e) {
alert(this.searchWord)
}
}
})

手始めにVueが保持するsearchWordというデータに関連づけられたinputとp、そしてその内容をalertするだけの関数searchStationを呼ぶbuttonを置きました。

これだけのコード量でデータバインディングをよしなにやってくれるのは、何度見ても感動しますね!!

それぞれのコード内で出てくるv-model{{hogehoge}}が見慣れない方は、Vue.jsのドキュメントのはじめにをご覧ください。Vueのドキュメントは初心者にも優しく書かれていていますので、JavaScriptを触ったことがある方なら簡単に入門することができるはずです!!困った時はドキュメント内「基本的な使い方」を活用すると色々捗ります。まずは「はじめに」を読んだらまた当記事に戻って来てください:wave:


検索窓に入力した値で検索し、該当駅名のリスト表示する

では早速、検索機能をつけていきましょう。

main.js内の【!YOUR_ACCESS_KEY!】は、準備段階で取得したアクセスキーで置き換えてください。


index.html

<div id="app">

<input type="text" v-model="searchWord"></input>
<button v-on:click="searchStation">検索</button>
<p>{{searchWord}}</p>
<ul>
<li v-for="station in stations">{{station.Station.Name}}</li>
</ul>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- 以下略 -->


main.js

var ACCESSKEY = !YOUR_ACCESS_KEY!

var app = new Vue({
el: '#app',
data: {
searchWord: '',
stations: []
},
methods: {
searchStation: function (e) {
var baseURI = 'http://api.ekispert.jp/v1/json/station/light?key=' + ACCESSKEY + '&name='
var URI = baseURI + this.searchWord
axios.get(URI)
.then(response => { this.stations = response.data.ResultSet.Point })
.catch(function (error) {
this.stations = [];
console.log('ERROR!! happend by Backend.')
});
}
}
})


入力窓に「東京」と入力し、検索ボタンを押してください。

スクリーンショット

画像のように「東京」を含む駅名が一覧表示されたでしょうか?東京周辺にお住いの方には見慣れた駅名ですね。

method内で定義したsearchStation関数について見ていきます。

1,2行目では「駅すぱあとWebサービス」に問い合わせるためのURIを作っています。今回は駅に関する情報をJSONで返してほしいため、/json/station/lightにアクセスキー(key=)と検索するワード(name=)のパラメータをつけています。

APIの詳しい仕様はこちらから確認してください。

3行目以降では作ったURIをaxiosというHTTPクライアントを使って投げています。

そのためのインクルードをindex.htmlで忘れないよう気をつけてください。こちらも簡単のためCDN版を使っています。

axiosはget(URI)でアドレスを投げると、その結果を.then(response => { 処理 })で受けることができます。また.catch(処理)ではリクエストが失敗した時の処理を書いています。誤ったリクエストをした時などにエラーをキャッチしてくれます。

返って来てからの処理this.stations = response.data.ResultSet.Pointでは、返って来たレスポンスの本体(連想配列)response.dataからResultSet.Pointにある駅情報の入った配列をstationsに代入しています。

駅すぱあとWebサービスからどのようなレスポンスが返っているのか気になる方は

http://api.ekispert.jp/v1/json/station/light?key=【!YOUR_ACCESS_KEY!】&name=東京をブラウザのアドレスバーに入れリクエストしてください。


入力が空だった時に対応する

もちろんこれだけでは十分な検索機能とは言えません。

例えば入力窓に何も入力しないまま検索ボタンを押してみましょう。DevToolsを開きコンソールを見ると、400 (Bad Request)というエラーが表示されるはずです。これはnameパラメータに何もつけないままAPIを叩いているからですね。

対策する方法は様々ありますが、今回はもっともお手軽な方法「入力が空の時、ボタンを押せなくする」を実装します。


index.html

<button v-on:click="searchStation" v-bind:disabled="searchWord===''">検索</button>


検索ボタンに属性v-bind:disabled="searchWord===''"をつけました。searchWord===''がtrueの時、つまり入力が空の時、buttonにはdisabled属性が付与され、押せないようになります。

スクリーンショット 2018-06-25 2.56.13.png


該当駅が1駅だった時に対応する

まだ対応すべきケースがあります。

試しに入力窓に東京テレポートのように、該当する駅が1駅しか無い文字列を入力し検索ボタンを押してください。

次はコンソールに"TypeError: Cannot read property 'Name' of undefined"が返ってくるのでは無いでしょうか。

これは「駅すぱあとWebサービス」の仕様に起因します。詳しくみてみましょう。


該当駅が複数の時

問題を単純にするため、ブラウザで「駅すぱあとWebサービス」からのレスポンスを確認してみます。

先ほど問題なく動作した際のリクエストhttp://api.ekispert.jp/v1/json/station/light?key=【!YOUR_ACCESS_KEY!】&name=東京を叩いてみましょう。

すると該当する駅の情報は下画像の通り

スクリーンショット 2019-02-27 20.55.16.png

Pointのvalueである配列の中に各駅の情報の連想配列が入る、という形で返って来ていることがわかります。(赤矢印の指すところに注目:eyes:)


該当駅が1駅の場合

次は該当駅が1駅となるようなリクエスト例えばhttp://api.ekispert.jp/v1/json/station/light?key=【!YOUR_ACCESS_KEY!】&name=東京テレポートを叩いてみましょう

すると

スクリーンショット 2019-02-27 20.55.35.png

ん?

Pointのvalueは、唯一の該当駅に関する情報がそのまま入った連想配列自体であることがわかります。(赤矢印の指すところに注目:eyes:)

つまり先ほどのコードのままだと、index.html内<li v-for="station in stations">{{station.Station.Name}}</li>の部分でv-forが走る際、undefinedエラーが返ってくるのですね。

ということでこれに対応したsearchStation関数がこちらです。(併せて結果が0駅だった際にも対応しました)


main.js

searchStation: function (e) {

var baseURI = 'http://api.ekispert.jp/v1/json/station/light?key=' + ACCESSKEY + '&name='
var URI = baseURI + this.searchWord;
axios.get(URI)
.then(response => {
var resultSet = response.data.ResultSet
if (typeof resultSet.Point === 'undefined') {
this.stations = [];
} else {
this.stations = (resultSet.Point instanceof Array) ? resultSet.Point : [resultSet.Point];
}
})
.catch(省略)
}

結果が0駅の時はresultSet.Pointが未定義、それ以外の時、resultSet.Pointが配列ならそのまま、配列でないなら配列に入れてstationsに格納しています。これで<li v-for="station in stations">{{station.Station.Name}}</li>でエラーが起きないようになりました。

ここまでで駅名検索として最低限の機能を実装することができました!!

ここからはさらに便利な検索にするための実装を続けていきます。


インクリメンタルサーチにする

google検索などですっかりお馴染みになっているインクリメンタルサーチです。

普段からVue.jsに慣れ親しんでいる方なら 「入力欄をv-modelで用意したデータと関連付けて、それをcomputedで監視して、変更があればAPIを叩いて結果を格納すればいいのでは?」と思われるかもしれません。

しかしこの方法だと思うようなインクリメンタルサーチになりません。(Vue.jsに慣れ親しんでいる方ならこの先もご想像の範囲内ですね・・・)

入力窓とVueの保持するデータを見比べたこちらのgifをご覧ください。

v-modeltest.gif

(このようにVueの保持するデータを見る事ができるVue.js devtoolsが非常に便利です。使い方は

@hashimoto-1202 さんのVue.js Devtoolsの導入方法と機能まとめ。Vue.jsを用いた開発を効率化させよう!などを参照してください。)

IMEを介さない英数字を打ち込んだ時は入力窓の内容にVueの保持するデータが追従しています。対して日本語入力の場合、このようにv-modelでデータに関連付けるだけでは、確定前の入力内容に追従する事が出来ません。

この事はVue.jsのドキュメントのフォームに関するページでも述べられています。


IME (中国語、日本語、韓国語、その他) が必須な言語に対しては、v-model は IME コンポジションの間は更新されないことに注意してください。これらの更新に対して対応したい場合は、input イベントを代わりに使用します。


という事で、今回はまさにこの注意書き通りの状況であり、inputイベントを用いて実装しました。


index.html

<div id="app">

<input type="text" id="inputArea" v-model="searchWord" v-on:input="searchStation"></input>

<ul>
<li v-for="station in stations">{{station.Station.Name}}</li>
</ul>
</div>



main.js

searchStation: function (e) {

this.searchWord = document.getElementById("inputArea").value;

if (this.searchWord == '') {
//省略
}
}


入力窓であるinputにv-on:input="searchStation"属性を付けました。これによってここにinputイベントが起こるごとにsearchStation関数が走るようになります。

またsearchStation関数の1行目にはthis.searchWord = document.getElementById("inputArea").value;を追記しました。実は日本語入力の確定前でも、inputのvalueは確定前の文字列も含めた値を持っています。よってここでは、その値をVueが保持するデータsearchWordに代入しています。そのあとの処理は変更ありません。

これでインクリメンタルサーチが完成しました!!


検索頻度を2回/1秒に制限する

次にAPIにリクエストする頻度を制限する機能を実装します。

ここまでの実装では入力窓の内容が変わるたび、すなわちユーザーが1文字入力するごとにリクエストを投げています。

devtoolsのNetwarkタブでもその様子を見る事が出来ます。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3135393931322f39386333346632382d653533652d653339642d313664362d6439346336356139356361392e706e67.png

今回利用している「駅すぱあとWebサービス」の駅簡易情報のドキュメントには


候補駅の確定(インクリメンタルサーチ)などで利用される場合、アクセスの回数は最大2回/1秒程度を目安にご利用ください。


と書かれています(こちら)

これにしたがい、ユーザーの入力頻度が2回/1秒を超えている場合でも、リクエストは0.5秒に1度だけ投げるように実装していきます。

今回はlodash.jsthrottleを使って実装します。

lodash.jsはイデオム的な実装を集めたユーティリティー系のライブラリです。throttle以外にも便利な関数が多くあるのでぜひ公式サイトを参照してみてください。

今回使うthrottleは、ある関数を呼び出すイベントが多数発火しても、実行回数を間引き、一定間隔以下で実行されないようにする関数です。実行対象の関数と間隔(ミリ秒)を渡すと関数を返してきます。

詳しい説明や使い方は@akifoさんのlodashの.throttleと.debounceの使用例を参考にしました。


main.js

var app = new Vue({

el: '#app',
data: {
searchWord: '',
stations: [],
},
watch: {
// この関数は searchWord が変わるごとに実行されます。
searchWord: function (newSearchWord, oldSearchWord) {
this.searchStationWithInterval()
}
},
created: function () {
this.searchStationWithInterval = _.throttle(this.searchStation, 500)
},
methods: {
inputSearchWord: function () {
this.searchWord = document.getElementById("inputArea").value;
},
searchStation: function (e) {
if (this.searchWord == '') {
this.stations = [];
} else{
//中略
}
}
}
})

(index.htmlに以下を追記)


index.html

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.js"></script>


searchStationからthis.searchWord = document.getElementById("inputArea").value;inputSearchWordという関数として抜き出しました。

またcreatedwatchというオブジェクトを新たに使用したので順に説明します。

createdはライフサイクルフックの1つで、この中の関数はVueインスタンスが作成された後、1度実行されます。詳しくはこちら

createdの中ではsearchStationWithIntervalという変数に、lodash.jsのthrottleが返す変数を代入しています。よってsearchStationWithIntervalは500ミリ秒以上の間隔をあけてsearchStation()を実行する関数になりました。

続いてwatchです。watchはVue.jsが提供するウォッチャで、データに変更があるたびに反応して実行されます。今回の例ではsearchWordが変更されるたびにsearchStationWithIntervalが実行されます。

その他の部分に変更はありません。

整理すると


  1. Vueインスタンスが出来た時、searchStationWithIntervalが作られる。

  2. ユーザーが検索窓に文字を入れるごとにinputイベントが発火し、inputSearchWordが呼ばれる。


  3. inputSearchWordは検索窓のvalueの文字列をsearchWordに代入する。


  4. searchWordの変更を監視しているウォッチャによってsearchStationWithIntervalが実行される。


  5. searchStationWithIntervalは適切な間隔でsearchStationを実行する。

という流れです。流れるデータを追っていくとそこまで複雑に感じないのではないでしょうか。

先ほど同様、devtoolsのNetwarkタブを見てみましょう

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3135393931322f38666133613838652d653666662d636539632d316432322d3435366636613064663030622e706e67.png

赤矢印の部分をみると、入力文字数よりもリクエスト回数が少ない事がわかります。また青矢印の部分から確かに0.5秒以上の間隔を開けてリクエストしている事がわかります。

さてこれでインクリメンタルサーチは完成しました!!

ここからは見た目を作っていきましょう。


見た目をよくする


liをdivに置き換える

ここまで作ってきたindex.htmlでは、該当した駅情報が格納されたstations配列を、v-forでli要素に書き出していました。これでも駅名を見ることは出来ますが、サイトの部品としては使いづらいでしょう。

ここからはindex.htmlとmain.cssを書いていきます。


index.html

<div id="app">

<input type="text" id="inputArea" v-model="searchWord" v-on:input="inputSearchWord"></input>

<!-- 検索結果表示部分 -->
<div v-bind:class="{ stationsWrap: stations.length != 0 }">
<div v-for="(station, index) in stations">
<div v-if="index < 10" class="item" v-bind:class="{ isEven: index%2 == 1 }">
<p>{{station.Station.Name}}</p>
</div>
</div>
</div>

</div>



main.css

body{

width: 900px;
margin: auto;
}

input{
width: 300px;
}

.stationsWrap{
width: 304px;
border: solid 1px #000000;
}

.item p{
margin: 0px;
}

.isEven{
background-color: #dddddd;
}


主な変更点は<div v-for="(station, index) in stations">です。

v-forに変数を2つ渡すと2番目の変数名でforのインデックスを使う事が出来ます。このdivの子要素のdivでは、そのindexを用いて描画件数の制限すると共に、偶数番目の要素へisEvenクラスを付与しています。

cssによる装飾はコードをご覧ください。

スクリーンショット 2018-06-25 20.12.35.png

偶数番目の要素へisEvenクラスを付けた事で結果がみやすくなりました!!


駅名の入ったpタグに選択機能をつける

最後の仕上げに、駅名の入ったpタグのクリックイベントから呼び出せるselect関数を実装しましょう。


index.html

<!-- 好きな位置に選択した駅名を表示するタグを追記 -->

<span v-if="selectedStation != ''">選択した駅は{{selectedStation}}</span>

<!-- 中略 -->

<p v-on:click="select(station.Station.Name)">
{{station.Station.Name}}
</p>



main.js

data: {

searchWord: '',
stations: [],
selectedStation: '' //追加
},
//中略
methods: {
//中略
select: function (stationName) {
this.selectedStation = stationName;
this.stations = []
}
}

クリックされたpタグが持つ駅名をselectedStationに代入しているだけですね。また同時にstationsを空にする事でインクリメンタルサーチの候補駅名を消しています。

スクリーンショット 2018-06-25 20.29.56.png


終わりに

あとはみなさんのアプリでの実装次第です。なお「駅すぱあとWebサービス」の他の機能を利用する場合、駅コードの利用が推奨されています。こちらは駅名の取得(Station.Name)と同様にStation.codeで取得できます。適時書き換えてください。

また今回のサンプルではフロントから直接APIに叩いてしまっていますが、実際に使うときはサーバー側で「駅すぱあと Webサービス」を問い合わせ、必要に応じてフロントからデータを取るのが良いと思います。

長くなりましたが、Vue.jsで駅名のインクリメンタルサーチを作る事ができました。

ここまでお付き合いいただきありがとうございました。

50行に満たないjsのコードでインクリメンタルサーチを実装できるのはVue.jsのおかげですね!

次はこの検索窓をコンポーネント化し、より大きなアプリの一部として使用したいと考えています。

やってみたよ!という報告、指摘、改善点等ありましたらコメントか編集リクエストをお待ちしています!!

ではでは:raised_hand:


参考にした記事・追記

axiosについては↓のブログを参考にしました。

Vue.jsとAxiosなら驚くほど簡単に作れる!外部APIを使ったWebアプリの実例

JSONをブラウザで見る場合はJSONviewというchromeの拡張機能がおすすめです。