これでできる! クロスブラウザJavaScript入門

第13回簡単なアプリケーションの作成

こんにちは、太田です。前々回はJSONP、前回はXMLHttpRequestについて解説しました。今回は、ここまでの12回で取り上げた内容を使って簡単なアプリケーションを作成してみます。

アプリケーションの設計

第9回で取り上げたTwitter検索を行うJavaScriptをベースに、簡易Twitter検索クライアントを実装してみましょう。

機能は以下のとおりです。

  • 任意のキーワードで検索
  • 60秒おきに自動で新しい検索結果を取得
  • @ユーザー名はTwitterにリンク
  • URLと思われるところはリンクに
  • ハッシュタグをクリックしたときはそのハッシュタグで検索
  • 短縮されたURLを展開

なお、機能的にはIE 6~8もほかのブラウザと同等の実装にしますが、見た目について(具体的には角丸)はIEでは再現しません。

検索の骨組み

まずは任意のキーワードで検索する部分を見ていきましょう。まずはHTMLです。

検索フォームのHTML
<form id="searchform">
  <input type="search" id="searchvalue" value="JavaScript">
  <select id="searchlang">
    <option value="all">all</option>
    <option value="ja">ja</option>
  </select>
  <input type="submit" value="検索">
</form>

続いて、JavaScriptです。

検索フォームのJavaScript
var searchform = document.getElementById('searchform');
var searchvalue= document.getElementById('searchvalue');
var searchlang = document.getElementById('searchlang');
var timer = document.getElementById('timer');
var lang = '';
var TwitterAPI = 'http://search.twitter.com/search.json';
function getJSONP(query){
  var script = document.createElement('script');
  script.src = TwitterAPI + query;
  document.body.appendChild(script);
}
searchform.onsubmit = function(){
  lang = searchlang.value;
  getJSONP('?callback=TwitterCallback&lang='+lang+'&q=' +
    encodeURIComponent(searchvalue.value));
  return false; // submitをキャンセル(ページ遷移させない)
};

フォームのsubmitを使用している点がポイントで、これによりキーワードの入力後Enter(Return)したときにsubmit(検索)することができますし、⁠今回は実装していませんが)JavaScriptを無効にしていた場合にサーバー側で検索するように対応することも容易になっています。

なお、submitイベントの処理はaddEvent関数を定義して次のようにしてもよいでしょう。

submitイベントの処理
var addEvent = (document.addEventListener) ?
    function(node,type,handler){
      node.addEventListener(type,handler,false);
    }
  : function(node,type,handler){
      node.attachEvent('on' + type, function(evt){
        handler.call(node, evt);
      });
  };
addEvent(searchform,'submit',function(evt){
  lang = searchlang.value;
  getJSONP('?callback=TwitterCallback&lang='+lang+'&q=' +
    encodeURIComponent(searchvalue.value));
  // submitをキャンセル(ページ遷移させない)
  if (evt.preventDefault) {
    evt.preventDefault();
  } else {
    evt.returnValue = false;
  }
});

今回のようなシンプルなアプリケーションではaddEventListener、attachEventを使うメリットは特にないので、onsubmitのシンプルな記述がよいかもしれません。

タイムラインの実装

続いて、JSONPのコールバック部分を実装していきます。まず、検索結果を表示するためのHTMLです。

タイムラインの骨組み
<div id="search-result" class="twsrrlt"></div>
<div id="timer"></div>
<ul id="search-tmpl" style="display:none;">
 <li>
   <a class="usr" target="_blank">
     <img width="48" height="48"><br>
     <span></span>
   </a>
   <p class="entry"></p>
   <div class="time">
     <a class="source" target="_blank"></a>
     <a class="username" target="_blank"></a>
     <a class="timelink" target="_blank"></a>
   </div>
 </li>
</ul>

id="search-result" が検索結果を表示する入れ物で、id="search-tmpl" はテンプレートです。このあたりは第9回とほとんど同じです。

さてコールバックの処理ですが、今回は「60秒おきに自動で新しい検索結果を取得」という処理を行うので、元の結果を残したまま追加するパターンを用意します。

コールバックの処理
var tmpl = document.getElementById('search-tmpl').
    getElementsByTagName('li')[0];
var timeline = document.getElementById('search-result');
var tree;
var prev_result;
function TwitterCallback(data){
  if (prev_result){
    // 前回の結果に継ぎ足す場合
    write_timeline(data);
  } else {
    clear();
    write_timeline(data);
    start_timer();
  }
  prev_result = data;
}
function clear(){
  if(tree){
    timeline.removeChild(tree);
    tree = null;
  }
}

prev_resultという変数にJSONPで取得したデータを保持しておき、そのデータがあるときはclearせずにタイムラインの追加処理だけ行うようにしています。また、prev_resultがnullのときはtimerを起動するようにしています。

自動更新の実装

先にstart_timerの中を見てみましょう。

start_timerの処理
var TIME = 60, timerID;
function start_timer(){
  var time = TIME;
  timerID = setInterval(function(){
    time--;
    timer.innerHTML = time;
    if(time === 0){
      time = TIME;
      if (prev_result){
        getJSONP(prev_result.refresh_url +
            '&callback=TwitterCallback&lang='+lang);
      }
    }
  }, 1000);
}

setIntervalで1秒おきにカウントダウンを行い、0になったときにカウント用の変数を元の値に戻し、JSONPで次の結果を取得しています。

なお、timerIDとprev_resultは先程のonsubmitにおいてリセットするようにします。

submitイベントの処理#2
searchform.onsubmit=function(){
  prev_result = null;
  clearInterval(timerID);
  lang = searchlang.value;
  getJSONP('?callback=TwitterCallback&lang='+lang+'&q=' +
      encodeURIComponent(searchvalue.value));
  // submitをキャンセル(ページ遷移させない)
  return false;
};

HTMLの生成

では最後にwrite_timelineの処理を見ていきましょう。基本的な処理は第9回のcloneNodeによる要素の組み立てをベースに、自動更新用の処理とリンク関連の処理を加えています。

検索結果の作り込み処理
function write_timeline(data){
  var keyword = searchvalue.value;
  var results = data.results;
  if (!tree) {
    tree = document.createElement('ul');
    tree.className = 'twl';
  }
  var items = tree.childNodes.length;
  results.reverse();
  if(prev_result){// 前回の結果からnewクラスを取り除く
    var len = prev_result.results.length;
    for (var i = 0;i < len; i++){
      var _li = tree.childNodes[i];
      _li.className = _li.className.
        replace(/(\s)+new(\s*|$)/,'');
    }
  }
  for (i = 0, len = results.length;i < len; i++){
    var usr = results[i];
    var user = usr.from_user;
    /* 要素を作る */
    var li = tmpl.cloneNode(true);
    var link = li.getElementsByTagName('a')[0];
    var icon = link.getElementsByTagName('img')[0];
    var name = link.getElementsByTagName('span')[0];
    var entry = li.getElementsByTagName('p')[0];
    var time = li.getElementsByTagName('div')[0];
    var source   = time.getElementsByTagName('a')[0];
    var username = time.getElementsByTagName('a')[1];
    var timelink = time.getElementsByTagName('a')[2];
    /* CSS用にclassを設定 */
    li.className = (((i+1+items)%2) ?
        'odd' : 'even') + ' new';
    /* リンクや画像などの属性を設定 */
    username.href = link.href = 
        'http://twitter.com/' + user;
    var src = usr.profile_image_url;
    if (src.indexOf('http') === 0) {
      icon.src = src;
    }
    icon.width = 48;
    icon.height = 48;
    timelink.href = 'http://twitter.com/' +
                user +'/status/' + usr.id;
    var d = new Date(usr.created_at);
    var date = d.getFullYear() + '/' + (d.getMonth()+1) +
           '/' + d.getDate() + ' ' + d.getHours() + ':' +
           ('0'+d.getMinutes()).slice(-2);
    /* エスケープされた文字を戻す */
    var node = document.createTextNode(usr.text.
        replace(/&(lt|gt|quot|amp);/g,function(_$,_1){
          return {lt:'<', gt:'>', quot:'"', amp:'&'}[_1];
        })
    );
    /* テキストノードの挿入 */
    entry.appendChild(node);
    linkfy(entry, '@(\\w+)', '[^\\w@]|$',
        'http://twitter.com/');
    linkfy(entry, '#(\\w+)', '[^\\w#]|$',
        'http://search.twitter.com/search?q=%23');
    linkfy(entry, '(https?://.*)',
        '[  \\)\\]\'\"\n]|$', '');
    expandUrl(entry);
    highlight(entry, keyword);
    if (usr.source){
      var match = usr.source.match(/"(http.*?)"/);
      if(match){
      source.href = match[1];
        source.appendChild(document.createTextNode(
          'from '+usr.source.match(/>(.*?)</)[1]
        ));
      }
    }
    var at_usr = '@' + user;
    username.appendChild(document.createTextNode(at_usr));
    timelink.appendChild(document.createTextNode(date));
    name.appendChild(document.createTextNode(user));
    /* 要素の組み立て */
    tree.insertBefore(li, tree.firstChild);
  }
  /* 画面に反映 */
  if (!tree.parentNode || !tree.parentNode.parentNode){
    timeline.appendChild(tree);
  }
}

順番に見ていきましょう。

treeの用意
  if (!tree) {
    tree = document.createElement('ul');
    tree.className = 'twl';
  }

まず、treeがなければ作成します。id="search-result" な要素自身をul要素にしないのは、タイムラインをリセットする際にこのtreeを削除するだけですべての発言を削除できるからです。

続いて、前回結果のハイライトを消す処理です。

ハイライトの消去
  if(prev_result){// 前回の結果からnewクラスを取り除く
    var len = prev_result.results.length;
    for (var i = 0;i < len; i++){
      var _li = tree.childNodes[i];
      _li.className = _li.className.
        replace(/(\s)+new(\s*|$)/,'');
    }
  }

新しく追加した発言にnewクラスをつけるようにしていますが、次回の結果を得たときに前回の結果からnewクラスを削除するようにしています。新しい発言が上に来るようにしているので、前回の発言数分だけ上から処理するだけで大丈夫です。

個々の発言の作成#1
  var items = tree.childNodes.length;
  results.reverse();
  for (i = 0, len = results.length;i < len; i++){
    var usr = results[i];
    var user = usr.from_user;
    /* 要素を作る */
    var li = tmpl.cloneNode(true);
    var link = li.getElementsByTagName('a')[0];
    var icon = link.getElementsByTagName('img')[0];
    var name = link.getElementsByTagName('span')[0];
    var entry = li.getElementsByTagName('p')[0];
    var time = li.getElementsByTagName('div')[0];
    var source   = time.getElementsByTagName('a')[0];
    var username = time.getElementsByTagName('a')[1];
    var timelink = time.getElementsByTagName('a')[2];
    /* CSS用にclassを設定 */
    li.className = (((i+1+items)%2) ?
        'odd' : 'even') + ' new';
    /* 後述 */
  }

新しい発言が上にくるようにreverseをした上で、個々の発言を作っていきます。

TwitterのAPIでは<や&などがエスケープされた状態(&lt;、&amp;)になっています。それを信用するならinnerHTMLに入れれば意図通りの表示ができますが、何かしらの理由でエスケープ漏れがないとも限らないので、今回はテキストノードとして扱います。そこで、&lt;や&amp;などを<、&に戻す処理が必要となります。

個々の発言の作成#1
    /* エスケープされた文字を戻す */
    var node = document.createTextNode(usr.text.
        replace(/&(lt|gt|quot|amp);/g,function(_$,_1){
          return {lt:'<', gt:'>', quot:'"', amp:'&'}[_1];
        })
    );
    /* テキストノードの挿入 */
    entry.appendChild(node);
    linkfy(entry, '@(\\w+)', '[^\\w@]|$',
        'http://twitter.com/');
    linkfy(entry, '#(\\w+)', '[^\\w#]|$',
        'http://search.twitter.com/search?q=%23');
    linkfy(entry, '(https?://.*)',
        '[  \\)\\]\'\"\n]|$', '');
    expandUrl(entry);
    highlight(entry, keyword);

さて、テキストノードとして挿入した発言内のURLや@ユーザー名などをリンクにしてみましょう。といっても、この処理は第8回のHTML中の文字列を置換する方法で解説済みですね。

テキストのリンク化
function linkfy(element, start, end, prefix){
  for (var i =0,l = element.childNodes.length;i < l;i++){
    var node = element.childNodes[i];
    if(node.nodeType !== 3){
      continue;
    }
    if (node.nodeValue.search(start) >= 0) {
      var text = node.nodeValue, index;
      var parent = node.parentNode;
      while (text && (index=text.search(start)) >= 0 ){
        // テキストを分割し、後ろ側のノードを取得
        var _txt = node.splitText(index);
        // キーワードの終わりで再度分割
        var _end = _txt.nodeValue.search(end);
        var __txt = _txt.splitText(_end);
        var a = document.createElement('a');
        a.href = prefix + _txt.nodeValue.match(start)[1];
        a.target = '_blank';
        a.appendChild(_txt);
        if (!__txt.nodeValue || !__txt.parentNode){
          parent.appendChild(a);
        } else {
          parent.insertBefore(a, __txt);
        }
        // ループ用に初期化
        text = __txt.nodeValue;
        node = __txt;
      }
    }
  }
}

最後は短縮URLの展開処理です。こちらも第11回のJSONPの活用例で短縮URLを展開するAPIを紹介していますが、今回はあえて前回少しだけ触れたクロスオリジン通信を利用してみます。

短縮URLの展開(クロスオリジン対応)
function expandUrl(element){
  var links = element.getElementsByTagName('a');
  for (var i =0,l = links.length;i < l;i++){
    var a = links[i];
    // 長いURLやtwitter.comの内部リンクは対象外
    if (a.href.length < 30 && a.host !== 'twitter.com'){
      getCrossSiteXhrOrJsonP(a);
    }
  }
}
function getCrossSiteXhrOrJsonP(a){
  // 現在のURLとAPIのURLが同一オリジンかチェック
  var same_origin = location.hostname === 'ss-o.net' &&
        (location.port==='' || location.port==='80') &&
        location.protocol === 'http:';
  var xhr;
  var onload =  function(){
    var data = JSON.parse(xhr.responseText);
    if (data.url && data.url !== a.href){
      a.textContent = data.url;
      a.href = data.url;
    }
  };
  if (same_origin) {
    xhr = new XMLHttpRequest();
  } else if(window.XDomainRequest){
    xhr = new XDomainRequest();
  } else if(window.XMLHttpRequest) {
    xhr = new XMLHttpRequest();
    if (!('withCredentials' in xhr)){
      xhr = {
        open:function(method, url){
          var s = document.createElement('script');
          xhr.__script = s;
          var callback = ('JSONP_' + new Date().getTime() +
              Math.random()).replace(/\W/,'');
          s.src = url + '&callback=' + callback;
          window[callback] = function(data){
            xhr.responseText = JSON.stringify(data);
            onload();
            document.body.removeChild(s);
            delete window[callback];
          };
        },
        send:function(){
          document.body.appendChild(xhr.__script);
        }
      };
    }
  }
  xhr.open('GET', 'http://ss-o.net/api/reurl.json?url=' +
      encodeURIComponent(a.href), true);
  if (!('onload' in xhr)){
    xhr.onreadystatechange = function(){
      if(xhr.readyState === 4 && xhr.status === 200){
        onload();
      }
    };
  } else {
    xhr.onload = onload;
  };
  xhr.send(null);
}

まず、URLがある程度長い場合は短縮URLではないだろうと判断して対象外としています。今回は30文字としましたが、この値はマジックナンバーであり、改善の余地があります。/の数、ドメイン部分の長さ、?を含むかどうかなど、条件も工夫してみてもよいかもしれません。

続いて、現在のURLとAPIのURLが同一オリジンかチェックしています。同一オリジンであれば通常のXMLHttpRequestを使用します。

オリジンが異なる場合はXMLHttpRequest level 2かXDomainRequestを使用しますが、IE 6~7とOperaはそれらに対応していません。そこで、クロスオリジン通信ができない場合はJSONPを使用します。

XMLHttpRequestのインターフェースを持ったJSONPオブジェクト
      xhr = {
        open:function(method, url){
          var s = document.createElement('script');
          xhr.__script = s;
          var callback = ('JSONP_' + new Date().getTime() +
              Math.random()).replace(/\W/,'');
          s.src = url + '&callback=' + callback;
          window[callback] = function(data){
            xhr.responseText = JSON.stringify(data);
            onload();
            document.body.removeChild(s);
            delete window[callback];
          };
        },
        send:function(){
          document.body.appendChild(xhr.__script);
        }
      };

このようにXMLHttpRequestのインタフェースを実装したオブジェクトで、内部ではJSONPを行うという力技な実装にしてみました。わざわざこういったことをせずとも、最初からJSONPを使えばよいケースなのであまり実用的ではありませんが、クロスオリジン通信のサンプルとして見て頂ければと思います。

まとめ

今回はここまでの復習として12回で取り上げた内容を使って簡単なアプリケーションを作成してみました。もし忘れているところなどがあったら是非復習してみてください。次回からはJavaScriptの基礎に再び戻って、prototypeとthisについて見ていきたいと思います。

おすすめ記事

記事・ニュース一覧