実践JavaScript XMLHttpRequest/JSONP

「プログラミング経験はそこそこだけど、JavaScriptはあんまり」な人向け社内勉強会資料vol.7

Shogo Ohta, 2009-10-27

JSONPとXMLHttpRequest

Ajaxとは

Ajax(Asynchronous JavaScript + XML)は、「ウェブブラウザ内で非同期通信とインターフェイスの構築などを行う技術の総称。」(Wikipedia)です。

WEB2.0ブームで注目されたバズワードのひとつですが、広義の意味で捉えるならば、要はユーザーを待たせないインターフェースという解釈が適切ではないかと思います。本質は「ユーザーを待たせない」という点にあります。

JSON/JSONPの基本

JSONとは

JSON(JavaScript Object Notation)とはJavaScript由来のデータ記述言語です。

JSONはシンプル且つ柔軟性の高いフォーマットであり、最近では多くの言語でサポートされているため、特にWEB関連ではポピュラーなデータフォーマットです。

配列表現

["one","two", "three"]

ハッシュ表現

{"one":1, "two":2 , "three":3}

ハッシュの中に配列

{"num":[1,2,3], "abc":["a","b","c"]}

当然ながら、JavaScriptとJSONの書式は良く似ていますが、完全に同じというわけではありません。JavaScriptではシングルクオートが使えたり、プロパティのクオートが省略できたりと柔軟なところがありますが、JSONはそういった曖昧さを排除してデータ記述言語としての精度と、解析のし易さを確保している点に注意が必要です。JSONフォーマットの詳細はjson.orgを参照してください。なお、IE8やFirefox3.5や、Google ChromeなどでJSON.stringifyメソッドを使用するとJavaScriptのオブジェクトから手軽にJSONを出力することができます。

JSONPとは

JSONPはJSON with Paddingの略称です。Paddingは(本来は不要なものの)付け足しという意味です。よって純粋なJSONではないので、基本的にJSONPはJSONとしてパースできません。多くの場合JSONPはクロスドメインの制限を超えるために使用されます。

先ほどのハッシュ配列をJSONPらしくすると下記のようになります。

callback({"num":[1,2,3], "abc":["a","b","c"]});

このcallback()は関数の呼び出しです。予めcallback関数を定義しておけば、下記のようになります。

function callback(data){
  console.dir(data);
}
callback({"num":[1,2,3], "abc":["a","b","c"]});

このように、JSONPでは関数呼び出しを使用します。(一応、関数呼び出しでなくてはいけないということではありません。)

JSONPの理解

ここでJSONPがなぜクロスドメインでデータのやり取りを行うことが出来るかという理解のために、適当な外部スクリプトをscript要素で読み込んでみます。a.jsはa='a';とだけ書いてあるファイルです。このa.jsを読み込んだ後ろでは、グローバル変数aが定義されていて、その値はaという文字列が入っており、この変数に自由にアクセスできる点に注目してください。

<script src="a.js"></script>
<script>
console.log(window.a);//a
</script>

実はこれがJSONPの仕組みです。話は本当に簡単で、script要素のsrcで読み込むJavaScriptは外部ドメインから読み込むことが可能で、そのスクリプト内のデータは書き方次第で他のスクリプトからも扱うことが可能です。つまり、ドメインの異なるサーバーからデータを受け取っていることになります。

もちろん、上記のように静的に読み込むだけでは応用が効かないので、動的にscriptを読み込んでみます。

var script = document.createElement('script');
script.src="a.js";
script.onload = function(){
  console.log(window.a);//a
}
document.body.appendChild(script);

ただし、IEではonloadが効かないので、onreadystatechangeを使用する必要があるなど少々手間がかかります。

そこで、呼び出された側が特定の関数を呼び出すようにします。呼び出す側がその関数を定義しておけば、その関数を介してデータを受け取ることが出来ます。

api.jsという呼び出される側のファイルを用意し、下記の通りcallbackという関数を呼び出すようにします。

callback({"num":[1,2,3], "abc":["a","b","c"]})

呼び出す側では、api.jsを作成し、グローバル変数にcallbackを定義します。

var script = document.createElement('script');
script.src="api.js";
window.callback = function(data){
  console.log(data);
}
document.body.appendChild(script);

関数名がcallbackで固定されていると柔軟な呼び出しができないので、関数名を変更できるようにするのが一般的です。

var script = document.createElement('script');
script.src="api?callback=JSONP_API";
window.JSONP_API = function(data){
  console.log(data);
}
document.body.appendChild(script);
if(defined (my $p = $q->param('callback'))){
  $json = "$p($json);" if($p =~ /^[a-zA-Z0-9._\[\]]+$/);
}

サーバー側で引数のcallbackに指定された関数名をコールバック関数の名前として返します。

JSONP_API({"num":[1,2,3], "abc":["a","b","c"]})

サーバー側の処理例(Perl)、callbackに使用できる文字はある程度制限する必要がありますが、制限しすぎると呼び出し時に困るので、[a-zA-Z0-9._\[\]]くらいが良いと思います。+-も加えても良いかもしれません。セキュリティ関連は第4回 [気になる]JSONPの守り方を参照してください。

XMLHttpRequest

XMLHttpRequestはJavaScriptでHTTP通信を行うことが出来るAPIです。

XMLHttpRequestはIE6以外の所要ブラウザで使用できます。

var request = new XMLHttpRequest();
request.open('GET'/*GET or POST*/, location.href/*URL*/, true/*非同期か否か*/);
request.onload = function(){//通信が成功した場合の処理
  alert(request.reaponseText);
};
request.onerror = function(){//通信が失敗した場合の処理
  alert(request.status);
};
request.send(null);//POSTする場合はこの引数で渡す
//request.send('q=ajax&date=10');

IE6に対応する場合、ActiveXObjectを使用します。

var url = 'api?p=' + new Date()*1;
var xhr;
if (window.XMLHttpRequest) {
  xhr = new XMLHttpRequest();
} else if (window.ActiveXObject) {
  try {
    xhr = new ActiveXObject("Msxml2.XMLHTTP");
  } catch(e){
    xhr = new ActiveXObject("Microsoft.XMLHTTP");
  }
} else{
  throw new Error('not supported');
}
xhr.open('GET', url, true);
xhr.onreadystatechange = function(){
  if ((xhr.readyState === 4) && (200 <= xhr.status && xhr.status < 300)){
  } else {
  }
}
xhr.send(null);

XMLHttpRequestのレスポンスはresponseXML、responseTextがあります。XMLは用途が限られるので、Textを使用することが多いですが、こちらは名前の通りテキストなので、JSONをうけっとた際はjson2.jsを使用するなどしてパースしたり、HTMLを受け取った場合もXSSなどに注意して扱う必要があります。特に理由がなければ同一ドメインでもJSONPを使ったほうが手軽です。ただし、前述の通りJSONPはドメインを越えてしまいますので、オープンにして問題のない情報だけを扱うようにする必要があります。

JSONPの利用

ここからはJSONPを扱う上での注意点を実例を交えて実践的な解説をします。

JSONPを扱う上での注意

実際に扱う際に注意しなければいけないのは、JSONPが非同期である点と、名前空間の問題です。まずは失敗例を見てみましょう。

JSONPを使った短縮URLの展開例

APIはTinyurlなどのURLを復元するJSON(P) APIを使用します。

<ul id="bitly_test">
<li><a href="http://bit.ly/UPmcS">http://bit.ly/UPmcS</a></li>
<li><a href="http://bit.ly/1qgF3W">http://bit.ly/1qgF3W</a></li>
<li><a href="http://bit.ly/3mmQ2P">http://bit.ly/3mmQ2P</a></li>
<li><a href="http://bit.ly/2cCc71">http://bit.ly/2cCc71</a></li>
<li><a href="http://bit.ly/1sjJqM">http://bit.ly/1sjJqM</a></li>
<li><a href="http://bit.ly/3en88R">http://bit.ly/3en88R</a></li>
<li><a href="http://bit.ly/1mkWiP">http://bit.ly/1mkWiP</a></li>
</ul>
var API = 'http://ss-o.net/api/reurl.json';
var biturls = document.getElementById('bitly_test').getElementsByTagName('a');
for (var i = 0, len = biturls.length;i < len; i++){
	var biturl = biturls[i];
	var url = biturl.href;
	var script = document.createElement('script');
	script.src = API+'?url=' + url + '&callback=callback';
	window.callback = function(json){
		biturl.title = json.url;
	};
	document.body.appendChild(script);
}

シンプルに書いてみましたが、これでは上手くいかないことはすぐにわかると思います。window.callbackというグローバル関数がループ内で毎回上書きされているためこのままでは動きません。

var API = 'http://ss-o.net/api/reurl.json';
var biturls = document.getElementById('bitly_test').getElementsByTagName('a');
for (var i = 0, len = biturls.length;i < len; i++)(function(i){
	var biturl = biturls[i];
	var url = biturl.href;
	var script = document.createElement('script');
	var callbackName = 'callback' + i;
	script.src = API + '?url=' + url + '&callback=' + callbackName;
	window[callbackName] = function(json){
		biturl.title = json.url;
	};
	document.body.appendChild(script);
})(i);

callback名にループ変数を含めつつ、無名関数でスコープを切る事で、一応の解決ができました。しかし、グローバルな関数をたくさん使うというのはあまりスマートではありません。

そこで、Bitlyというグローバルプロパティを1つ作り、コールバック関数をそのプロパティとして登録するようにしてみます。

var API = 'http://ss-o.net/api/reurl.json';
var biturls = document.getElementById('bitly_test').getElementsByTagName('a');
window.Bitly = {};
for (var i = 0, len = biturls.length;i < len; i++)(function(i){
	var biturl = biturls[i];
	var url = biturl.href;
	var script = document.createElement('script');
	var callbackName = 'callback' + i;
	script.src = API + '?url=' + url + '&callback=Bitly.' + callbackName;
	Bitly[callbackName] = function(json){
		biturl.title = json.url;
	};
	document.body.appendChild(script);
})(i);

これでこのケースでは問題ないレベルになりました。しかし、もう少し複雑なアプリケーションになるとAPIの処理中に次の処理が入ってくることも考えられます。そういった場合、やはりコールバック名のユニークさの保障するのはなかなか悩ましい問題です。

そこで、配列を使ってみます。

var API = 'http://ss-o.net/api/reurl.json';
window.Bitly = {callbacks:[]};
var biturls = document.getElementById('bitly_test').getElementsByTagName('a');
for (var i = 0, len = biturls.length;i < len; i++)(function(i){
	var biturl = biturls[i];
	var url = biturl.href;
	var script = document.createElement('script');
	var index = Bitly.callbacks.length;
	script.src = API + '?url=' + url + '&callback=Bitly.callbacks[' + index + ']';
	Bitly.callbacks.push(function(json){
		biturl.title = json.url;
	});
	document.body.appendChild(script);
})(i);

Bitly.callbacksという配列は予め定義しておき、そのlengthとpushで常に最後にコールバック関数を登録していくことで、非同期にAPIを叩いても破綻することなく結果を受け取ることができます。

ついでに、展開したURLをtitleだけでなく、hrefやテキストにも反映してみます。この場合、セキュリティに十分注意する必要があります。

例えば、安易に以下のようなコードを書いてしまうと…

Bitly.callbacks.push(function(json){
	if (json.url) {
		biturl.title = json.url;
		biturl.innerHTML = json.url;//XSS!
		biturl.href = json.url;
	}
});

短縮URLサービスの中にはテキストを圧縮・展開するものもあります(tinyurlなど)。上記のコードではhtmlをinnerHTMLに入れることになり、script要素があればそのまま実行されてしまいますのでXSSが起こります。hrefについても javascript スキームでBookmarkletを実行できるので、innerHTMLほどではありませんが注意が必要です。

対策としては、innerHTMLではなく、IE以外ならtextContent、IEではinnerTextを使うか、createTextNodeを使うなどの方法があります。どちらを使うかは好みですが、textContent/innerTextはブラウザによって分岐が必要なので、createTextNodeを使ったほうが良いかもしれません。

var API = 'http://ss-o.net/api/reurl.json';
window.Bitly = {callbacks:[]};
var biturls = document.getElementById('bitly_test2').getElementsByTagName('a');
for (var i = 0, len = biturls.length;i < len; i++)(function(i){
	var biturl = biturls[i];
	var url = biturl.href;
	var script = document.createElement('script');
	var index = Bitly.callbacks.length;
	script.src = API + '?url=' + url + '&callback=Bitly.callbacks[' + index + ']';
	Bitly.callbacks.push(function(json){
		if (json.url) {
			while(biturl.firstChild) {
				biturl.removeChild(biturl.firstChild);
			}
			biturl.title = json.url;
			biturl.appendChild(document.createTextNode(json.url));
			if (json.url.indexOf('http') === 0) {
				biturl.href = json.url;
			}
		}
	});
	document.body.appendChild(script);
})(i);

JSONPを使ったサンプル

TwitterのAPIを使う簡単なサンプルです。

まずはCSSで箱を用意します。position:relative;とoverflow:hidden;がポイントです。

#rjMain{
width:275px;
height:220px;
border:1px solid #0099ff;
position:relative;
overflow:hidden;
}
#rjRoot div.box{
display:block;
width:55px;
height:55px;
position:absolute;
cursor:pointer;
background-repeat: no-repeat;
text-decoration:none;
text-align:center;
overflow:hidden;
}
#rjRoot div.box img{
border:1px solid #ccccff;
z-index:2;
position:relative;
}
<div id="rjRoot">
<div id="rjMain"></div>
</div>
(function(window, document){
   var SIZE = 110;
   var AP = window.SampleAPP = {
      api:'http://twitter.com/statuses/public_timeline.json?1=1',
      CENTER_TOP:Math.floor(12 / 5) * SIZE,//中心点
      CENTER_LEFT:( 12 % 5) * SIZE,//中心点
      api_callback:null,//コールバック関数の入れ物
   };
   var head = document.getElementsByTagName('head')[0];
   var root = document.getElementById('rjRoot');
   var main = document.getElementById('rjMain');

   API();//最初の呼び出し

   function API(){
      var s = document.createElement('script');
      s.type = 'text/javascript';
      s.src = AP.api + '&callback=SampleAPP.api_callback';
      AP.api_callback = function(data){
         view(data);
         if (head.parentNode) head.removeChild(s);
      };
      head.appendChild(s);
   }
   function view(data){
      for (var i = 0, len = data.length, box; i < len; ++i){
         set_item(data[i], i);
      }
   }
   function set_item(item, i){
      var box = document.createElement('div');
      box.className = 'ajaxbox';
      box.style.backgroundImage = 'url(' + item.user.profile_image_url.replace('normal','bigger') + ')';
      box.style.zIndex = 100 - i;
      Tween3(box.style,{
          time: 1
         ,transition:easeOutExpo
         ,left: {to:(i % 5) * SIZE + 20, from:AP.CENTER_LEFT, tmpl:'$#px'}
         ,top : {to:Math.floor(i / 5) * SIZE + 20, from:AP.CENTER_TOP, tmpl:'$#px'}
      });
      box.onclick = function(evt){
         var boxs = main.getElementsByTagName('div');
         for (var i = 0, len = boxs.length; i < len; ++i){
            Tween3(boxs[i].style,{
               time:0.5
               ,delay:i/len/2
               ,transition:easeOutExpo
               ,top:{to:-100,tmpl:'$#px'}
               ,onComplete:function(item, that){
                  if (that.parentNode) main.removeChild(that);
               }
            },boxs[i]);
         }
         API();
      };
      main.appendChild(box);
   }
})(window, document);

Twitterのパブリックタイムラインを叩いて、プロフィール画像を表示しているだけの処理です。

もし、「クリック→画面からアイコンを消す→API・画像読み込み→表示」とすると、クリック後に何も表示されない空白が出来てしまいます。クリック後に適当なアニメーションをさせつつバックグラウンドで読み込み→画像を読み込みながら表示として空白が出来ないようにしています。

ちなみに、クリック後、何も反応がないとユーザーは何度もクリックしてしまったりするので、アクション自体はすぐに見せる必要があります。