クリーンな JSONP リクエスト

基本概念

JSONP (JSON with Padding) とは、HTML 文書において、Javascript を使って SCRIPT 要素を動的に追加することにより、リモートサーバへのリクエスト・データの取得を可能にする技術である。
JSON という名前はついているが、取得できるデータは JSON 形式に限らない。(XMLHttpRequest の取得データが XML 形式と限らないのと、皮肉にも似ている) SCRIPT 要素を追加すると、ブラウザは Javascript ファイルをリモートサーバから読み込もうとするが、この Javascript ファイルは、読み込まれたと同時に実行されるので、ここで読み込み元のインターフェイスとなる関数を呼び出し、サーバから取得したデータを受け渡すのである。

もっとも単純化された例を示す。

<!-- ブラウザ側(jsonp_client.html) -->
<html>
<head>
<script type="text/javascript">

// グローバル変数 
var JSONPHandler;

function requestJSONP(url, oncomplete) {
  var sc = document.createElement("script");
  sc.setAttribute("type","text/javascript");
  sc.setAttribute("charset","UTF-8");
  sc.setAttribute("src", url );
  
  JSONPHandler = oncomplete;
  
  // HEAD 要素に SCRIPT 要素を追加する。
  document.getElementsByTagName("head")[0].appendChild(sc);
}

</script>
<body>
<!-- url の末尾の ? の後ろにパラメータを記述する -->
<a href="javascript:void" onclick='requestJSONP("http://example.com:3000/site/jsonp_server?name=Eiji", function(response) { alert(response); });'>Click here!</a>
</body>
</html>
# サーバ側 (RAILS_ROOT/app/views/jsonp_server.rjs / Rails 1.2.5)
# (Rails 2.0 では CSRF 対策のため、うまく動かないかも・・・)
# params[:name] には URL で ?name=xxx と指定した xxx が入っている。
script =<<-EOS
(function() {
  JSONPHandler("Hello, #{params[:name]}!");
})();
EOS
page << script

本質的なことは、クライアント側は、サーバが返す Javascript のコードが実行可能なデータ受け取り用の関数を用意しなければならない、ということである。(上の例では JSONPHandler) これは、サーバが返す Javascript コードがアクセスできるように、グローバルな名前空間に登録されていなければならない。

より一般的なインターフェイス

上のコードは動くが、次の点が改善可能だ。

  1. サーバ側では JSONP リクエストのたびにレスポンスをラップする Javascript コードを生成しなければならないこと。
  2. コールバック関数がリクエスト完了後も残ってしまうこと。
  3. 追加した SCRIPT 要素が残ってしまうこと。
  1. の対策としては、こうしたルーチンワークをこなすヘルパメソッドを作っておく。
  2. の対策としては、必ず存在することが保証されている window オブジェクトにコールバック関数を登録し、実行後ただちに削除すればよい。
  3. の対策としては、やはりコールバック関数の実行直後にSCRIPT 要素を消去するようにする。(自分自身を破壊するみたいで気持ちがわるいがとりあえずうまく行くようだ)

この対策を施したものが以下のコードである。

<!-- ブラウザ側(jsonp_client.html) -->
<html>
<head>
<script type="text/javascript">

// グローバル変数を使用していないことに注意

function requestJSONP(url, oncomplete) {
  var sc = document.createElement("script");
  sc.setAttribute("type","text/javascript");
  sc.setAttribute("charset","UTF-8");
  sc.setAttribute("src", url );

  // 名前の衝突を避けてランダムな名前を採用する。
  sc.id = "__jsonpscript_xd6EY9c23w";
  window["__jsonphandler__Absd23Xf87"] = oncomplete;
  
  // HEAD 要素に SCRIPT 要素を追加する。
  document.getElementsByTagName("head")[0].appendChild(sc);
}

</script>
<body>
<!-- url の末尾の ? の後ろにパラメータを記述する -->
<a href="javascript:void" onclick='requestJSONP("http://example.com:3000/site/jsonp_server?name=Eiji", function(response) { alert(response); });'>Click here!</a>
</body>
</html>
# サーバ側 

# RAILS_ROOT/app/helpers/application_helpr.rb
module ApplicationHelper
  def respond_jsonp(response)
    render :update do |page| 
      # 実行後ただちにコールバック関数と SCRIPT 要素を消去
      script =<<-EOS
        (function() {
          window["__jsonphandler__Absd23Xf87"](#{response.to_json});
          delete window["__jsonphandler__Absd23Xf87"];
          var sc = document.getElementById("__jsonpscript_xd6EY9c23w");
          if(sc) sc.parentNode.removeChild(sc);
        })();
      EOS
      page << script
    end
  end
end

# RAILS_ROOT/app/controllers/site_controller.rb
def jsonp_server
  respond_jsonp("Bonjour, #{params[:name]}!")
end

追記

2008/02/23 一部サンプルコード修正。