Hatena::Groupjavascript

JavaScriptで遊ぶよ

|

2010-03-29

ユーザースクリプトとページ側とのやりとり

01:54

というのを作った。

最初は Opera だけで動けばいいかなと思ったんだけど、せっかくなので Greasemonkey も Chrome も対応したいと思って頑張った。

面倒だったところは、JSONP を使う必要があって、どうしてもページ側のグローバル領域に関数を定義しないといけなかった。

  • GM_xmlhttpRequest は Opera と Chrome に無いので却下。
  • YQL には "Access-Control-Allow-Origin: *" が付いていたので、Chrome と Firefox で XMLHttpRequest が使えるかと思ったんだけど、どういうわけか Chrome では security violation が出た。(たぶん UserJS の自動変換だったので permission が付いていないため)どうやら思い違いだったみたい。これでもいける。
  • やっぱり JSONP しかない。

まあ @include で厳しく指定してるし、グローバルで何でも好きにやりたきゃやれるんだけど、そこは意地でもページのコンテクスト侵食は最低限にしようと思った。(ただし Opera では別の部分で侵食しまくり)

ここでちょっと Opera と Chrome と Greasemonkey のスクリプトのおさらい。

  • Opera
    • スクリプトはグローバルで実行。
      • なので基本はスクリプト全体をクロージャにする。
      • ただし、.js で保存した場合には opera.addEventListener('BeforeEvent.load',...) などのリスナーが使えて、これはページのスクリプトからは使われることはないので、上記のクロスドメイン GET などが使える。
  • Greasemonkey
    • スクリプトはサンドボックスで実行。
      • unsafeWindow がページの window への参照。
  • Chrome
    • スクリプトはサンドボックスで実行。
      • unsafeWindow はサンドボックスの window への参照。
      • ページ内に関数を定義する方法は、location.href='javascript:〜' とするか、script.textContent='〜', document.body.appendChild(script) とするしかない。
      • ページ内で発火したイベントにオレオレプロパティを付けてもサンドボックスからは見えない。

Chrome は徹底してサンドボックス化してある。一番厄介。


さて、結論から言うと、JSONP のコールバック関数は MessageEvent を使うことにした。(os0x さんの助言と nanto_vi さんの記事が参考になりました)

MessageEvent と言っても、ページ側で window.postMessage(...) してスクリプト側で window.onmessage = ... (または unsafeWindow) とする方法だと Chrome では動かないと思うので (試してないけど、同じ window オブジェクトではないからこれも使えたみたい。)、 document.createEvent('MessageEvent') して document で発火させる。document は Chrome でも Greasemonkey でもページ側と共有されるので。

ただし Opera では 10.50 になっても document.createEvent('MessageEvent') が Not Supported Error を出すので、仕方なく document.createEvent('Event') してオレオレプロパティを付けることにした。

つまり、ページ内で定義するのは以下のような関数。

function myCallback(obj) {
  if (window.opera) {
    var ev = document.createEvent('Event');
    ev.initEvent('MyJSONPReady', true, false);
    ev.mydata = obj;
  } else {
    var ev = document.createEvent('MessageEvent');
    ev.initMessageEvent('MyJSONPReady', true, false,
      JSON.stringify(obj), // data
      location.protocol + '//' + location.host, // origin
      '', // lastEventId (Server-sent Event に使われるもの)
      window // source
    );
  }
  document.dispatchEvent(ev);
}

これを文字列として script.textContent にして、document.body.appendChild(script) する。

受け手のほうは

document.addEventListener('MyJSONPReady', function(ev) {
  var data = ev.mydata || JSON.stringify(ev.data);
}, false);

とやれば JSONP で要求したオブジェクトが得られるというわけ。

MessageEvent で文字列だけじゃなくてオブジェクトが送れるようになるのは最近の環境だけなので、JSON にする。Opera だけ別にしたことによって、ネイティブ JSON のない Opera 10.10 でも使える。


ハイハイ、バッドノウハウバッドノウハウ。

もっとブラウザ分岐が少ない方法があれば知りたい。(自分としてはこれで満足だけど)


ちゃんと確認してみた。

"Access-Control-Allow-Origin: *"が付いている場合は、Firefox と Chrome では XMLHttpRequest で OK。

Opera では、.js で保存したスクリプト (.user.js ではなくて) の実行時のみ (それ以後のイベント内ではなくて) において、opera.addEventListener が使える。

こんな感じ。(実際には文字コードが違うとうまく動かなかったりするけど)

(function() {
  var scripts = [];
  var callbacks = [];

  opera.addEventListener('BeforeScript', function(e) {
    var s = e.element;
    var index = scripts.indexOf(s);
    if (index >= 0) {
      callbacks[index].call(null, s.text);
      scripts.splice(index, 1);
      callbacks.splice(index, 1);
      e.preventDefault(); // スクリプトの実行をキャンセル
      s.parentNode.removeChild(s); // スクリプトを削除
    }
  }, false);

  // クロスドメイン GET 関数
  function xGet(url, callback) {
    var s = document.createElement('script');
    s.src = url; 
    document.body.appendChild(s);
    scripts.push(s);
    callbacks.push(callback);
  }

  // このクロージャ内では安全にクロスドメイン GET が使える
  window.addEventListener('load',function() {
    xGet('http://query.yahooapis.com/v1/public/yql?q=show%20tables&format=json', alert);
  }, false);
})();

os0xos0x2010/03/30 03:41> ページ側で window.postMessage(...) してスクリプト側で window.onmessage = ... (または unsafeWindow) とする方法
は動きますよ。この前のと同じ理由であまりオススメはできませんが…。
あと、scriptを挿入するとき、関数をtoStringする方法がお薦めです。文字列リテラルで書くのは面倒なので。
function myFunction(){
//
}
script.textContent = '(' + myFunction + ')();';

> どういうわけか Chrome では security violation が出た
これ、こちら(5.0.360.0 dev/WinXP )では再現しないっぽいです。Content ScriptsからYQLにアクセスできました。
http://gyazo.com/6e1d91f5e40c3f840201a70a52753a9f.png
元々、Content Scriptsはmanifest.jsonのpermissionsとは関係ありません。
あとで他のバージョンやMacでも検証してみます。

edvakfedvakf2010/03/30 04:35http://f.hatena.ne.jp/edvakf/20100330043156
動きました。何が間違っていたんだろう。お恥ずかしい。

それにしても Chrome は同じ UserJS を何回もインストールしたら後からインストールしたほうを尊重してくれてもいいのにと思いました。
Opera だとファイル編集→Refresh display アクションでいけるので UserJS 書くのがラクでラクで。

2010-03-15

SharedWorkerの簡単な例

07:07

SharedWorker について、仕様にある例は大きすぎて何が起こってるのかつかみにくいので、簡単な例を載せてはどうか、というメールがあったので紹介。

僕も SharedWorker は例が面倒なので今日までちゃんと読んだことがなかった。


簡単な順に3段階。


step 1

  • test.html
<pre id="log">Log:</pre>
<script>
var worker = new SharedWorker('test.js');
var log = document.getElementById('log');
worker.port.onmessage = function(e) { // note: not worker.onmessage!
   log.textContent += '\n' + e.data;
}
</script>
  • test.js
onconnect = function(e) {
   var port = e.ports[0];
   port.postMessage('hello');
}

test.html を開くと test.js が SharedWorker として呼ばれる。

普通の Worker と違って worker.port.onmessage で通信するらしい。

test.js のほうで var port = e.ports[0] とやっているのは、MessageChannel のほうに詳しく書いた。

ただしそこは var port = e.target でもいいらしい。


step 2

  • test.html
<pre id="log">Log:</pre>
<script>
var worker = new SharedWorker('test.js');
var log = document.getElementById('log');
worker.port.addEventListener('message', function(e) {
   log.textContent += '\n' + e.data;
}, false);
worker.port.start(); // note: need this when using addEventListener
worker.port.postMessage('ping');
</script>
  • test.js
onconnect = function(e) {
   var port = e.ports[0];
   port.postMessage('hello');
   port.onmessage = function(e) {
     port.postMessage('pong'); // not e.ports[0].postMessage!
   }
}

worker.port.onmessage をセットすると暗黙の了解で Worker スレッドを走らせてくれるけど、addEventListener('message',..) の場合は start() を明示的に呼んでやらないといけない。

The first time a MessagePort object's onmessage IDL attribute is set, the port's port message queue must be enabled, as if the start() method had been called.

http://www.w3.org/TR/html5/comms.html#messageport

step 3

  • test.html
<pre id="log">Log:</pre>
<script>
var worker = new SharedWorker('test.js');
var log = document.getElementById('log');
worker.port.addEventListener('message', function(e) {
   log.textContent += '\n' + e.data;
}, false);
worker.port.start();
worker.port.postMessage('ping');
</script>
<iframe src=other.html></iframe>
  • other.html
<pre id=log>Inner log:</pre>
<script>
var worker = new SharedWorker('test.js');
var log = document.getElementById('log');
worker.port.onmessage = function(e) {
   log.textContent += '\n' + e.data;
}
</script>
  • test.js
var i = 0;
onconnect = function(e) {
   i++;
   var port = e.ports[0];
   port.postMessage('hello, ' + i);
   port.onmessage = function(e) {
     port.postMessage('pong');
   }
}

test.html と other.html から同じ URL の SharedWorker が呼び出され、それらは共有される。どちらかを開いたままもう一方をリロードすると、数字が増えていくはず。


感想

何に使えるか。たしか Chrome のドキュメントのどこかで、Gmail のようなアプリケーションでタブを複数開くと、それぞれが独立にサーバーと通信して面倒なことになるので、セッションをまとめる役割で使えるとか読んだ気がする。

普通の Worker だったら単にグローバルに onmessage = function() ... と書くところを、onconnect = function(e) { e.ports[0].onmessage = ...} として port を自分で収集しないといけないらしい。大変気持ち悪い。

例えば step 3 の場合、test.html と other.html を両方開いた状態で片方をリロードすると、そのたびに var port = e.ports[0] されて port.onmessage = function(e) { port.postMessage('pong'); } が呼ばれることになるけど、そのぶんのメモリはいつ解放されるんだろう。たぶん全部の親ウィンドウを閉じたときに Worker ごと消える? だったら適当な間隔で port が繋がっているかを監視する必要があると思うんだけど、isConnected のようなプロパティは無いみたいだし、実際に送ってみないと相手が存在するか分からないようになっているっぽい。

Hixie さん曰く、上のケースでは適切に解放されるらしい。下のケースでは手動で解放しないといけないらしい。

別の例を考えてみる。以下のような Shared Worker Script があったとして、

var myPorts = [];

onconnect = function(e) {
  var port = e.ports[0];
  myPorts.push(port);

  port.onmessage = function(e) { // あるタブからメッセージを受け取ったら
    myPorts.forEach(function(p) {
      if (p !== port)
        p.postMessage(e.data); // 他のすべてのタブに同じメッセージを送る
    });
  }
}

複数の親タブのうちの一つがリロードされたとする。すると、myPorts がどんどん増えていくことになる。だけど、リロードされる前のタブと繋がっている port は明らかに不要なので、どこかのタイミングで消してあげたい。これを簡単にやる方法は、今のところないっぽい。

このへんは気が向いたらメールを投げてみようと思う。↓メールした。

トラックバック - http://javascript.g.hatena.ne.jp/edvakf/20100315

2010-03-06

JSDeferredで考えるモナド

16:52

という素敵な記事を見て、おもしろかったので JavaScript Monad とかでググってみたら、↓の記事を発見した。

僕は Haskell をかじった程度には知っていた (しかしモナドは説明できるかと言われれば自信が無い) ので、概念としては理解できたもりなんだけど、いかんせん英語が難解なので訳すのは諦めて、自分なりの解釈を書いてみることにする。(間違ってたらやさしく指摘してくれるとありがたいです)

元記事は dojo.Deferred での説明だけど、ここでは JSDeferred で考える。単純に僕が dojo.Deferred を使ったことがないから。


Deferredモナド

Monad - Enjoy Programming を参考にした。

まず、JavaScript変数を Deferred 型 (モナド) とそれ以外という2つの型に分ける。

モナドの定義として、return と bind という2つの関数がある。


return はモナドでない値を引数に取って、モナドでラップして返す関数。

JSDeferred で書いてみるならこんな感じ。(return については元記事の dojo.Deferred の書き方のほうが直感的)

Deferred.monadReturn = function(val) {
  return Deferred.next(function() { return val }); // Deferred 型のインスタンスを返す
}

bind は2つの引数を取る。第一引数はモナド型の値。第二引数は、モナドでない値を取ってモナド型を返す関数。返り値はモナド型。

JSDeferred にあてはめるなら、

Deferred.monadBind = function(deferred, func) {
  return deferred.next(func); // func には deferred でラップされた普通の値が渡される
}

となる。つまり単なる Deferred.prototype.next メソッドのこと。ただし Deferred.prototype.next に渡す関数は「Deferred でない型を取って Deferred 型を返す」こともできるし、「Deferred でない型を取って Deferred でない型を返す」こともできる。これは Deferred.prototype.next の内部でまず func を実行し、その結果が Deferred 型ならそれを返し、Deferred 型でなければ上の monadReturn 関数相当のもので結果を包む、という操作を行っているため。(JavaScript が Haskell 違って型がないからこういうことができる。とりあえず Deferre.prototype.next の中で使われているぶんにはどっちでも良いと自分は解釈した)


モナド則

次にモナド則というものを考える。return と bind はモナド則を満たすように定義されていなければならない。

モナド則の一つめは、Deferred.monadBind(Deferred.monadReturn(val), func)func(val) と同じであるということ (ただし func は Deferred 型を返すとする)。上の定義に従って書いてみる。

Deferred.monadBind(Deferred.monadReturn(val), func) 

== Deferred.next(function(){ return val }).next(func) // func には val が渡される

== func(val)

同じであることが確認できた。(今 JSDeferred のソースを見たら、Deferred.call(func,val) というのを使えば func が Deferred 型を返しても普通の値を返してもうまくいくっぽい)


モナド則の二つめは、Deferred.monadBind(deferred, Deferred.monadReturn)deferred と等価であるということ。確認してみる。

Deferred.monadBind(deferred, Deferred.monadReturn)

== deferred.next(function(val){ return Deferred.next(function(val) {return val}) });

== deferred.next(function(val){ return val }); // 厳密に同じではないけど、val をラップした Deferred 型という点で同じ

== deferred

ちょっとチートな感じがするけど、全体として Deferred 型で、ラップされた値は deferred がラップした値と同じだと考えるといい。


モナド則の3つめは、Deferred.monadBind( Deferred.monadBind(deferred, func1), func2)Deferred.monadBind( deferred, function(x){ return Deferred.monadBind(func1(x), func2) }) と同じであるということ。これは複雑なので2つに分けて考える。

Deferred.monadBind( Deferred.monadBind(deferred, func1), func2)

== Deferred.next(function(){ return Deferred.monadBind(deferred, func1) }).next(func2)

== Deferred.monadBind(deferred, func1).next(func2)

== deerred.next(func1).next(func2)
Deferred.monadBind( deferred, function(x){ return Deferred.monadBind(func1(x), func2) })

== deferred.next(function(x){ return Deferred.monadBind(func1(x), func2) })

== deferred.next(function(x){ return func1(x).next(func2) })

== deferred.next(func1).next(func2)

というわけで、3つのモナド則が満たされた。


モナドから出ることはできない

一旦普通の値を Deferred 型でラップしてしまうと、通常のコンテキストにその値を戻すことはできない。ラップされた値を操作するのは、Deferred.prototype.next を通してしかできない。これは Haskell のモナドを使ってるときは「なんで??」という感じだったけど、Deferred で考えるとすんなり理解できる。

理想的にはすべての操作を関数で書きたいんだけど、現実にはそれが無理なので (非同期 API というものが存在するので)、汚い部分を全部 Deferred モナドに任せてしまって、本質の部分は綺麗な関数だけでやろうというのがモナドなんだと思う。

図にするとこんな感じかな。

f:id:edvakf:20100308012107p:image

Deferred を使って非同期処理をする場合、一つ一つの小さな処理は (普通の世界の) 単なる関数として書けるけれど、全体としては Deferred の世界から抜けることはできないという意味。

Haskell のモナドの場合は、図の下向きの矢印 (モナドでラップされた値を取り出す) は bind の中で担当してくれて、それに続く右向きと上向きの矢印までを関数として与えなければいけないけど、Deferred モナドの場合は右向きの矢印の関数を与えてあげると単純に return を継ぎ足してくれるのでそういうところは心配しなくても大丈夫というわけ。


結論 (jQueryはモナドだの受け売り)

  1. モナドは秘術的で難解な計算機科学ではない - 便利なものだ
  2. あなたはおそらく気がつかないうちにモナドを使ったことがあるだろう
  3. Deferred は素晴らしい
トラックバック - http://javascript.g.hatena.ne.jp/edvakf/20100306

2010-03-01

JSDeferredをnode.jsで試す

17:27

node.js を試してみた。

と言っても僕の興味は、JSDeferred と CommonJS の非同期 API である Promise をどう連携させるか、というアレゲなことなんだけど。

Node.js は

git clone git://github.com/ry/node.git
cd node
./configure
make
sudo make install

で /usr/local/ 以下にインストールした。

次に、JSDeferred にちょこっと手を入れる。まず、高速化のために Deferred.next_faster_way_** を定義してる部分に (typeof window === 'object') && というチェックを入れ、

jsdeferred.js の一番下に

exports.Deferred = Deferred;

という一行を足す。

jsdeferred.js と同じディレクトリに、公式サイトにある Hello World の例をちょっと弄って、こういうスクリプトを置いた。

  • hello_jsdeferred.js
sys = require('sys');
http = require('http');
Deferred = require('./jsdeferred').Deferred;

http.createServer(function (req, res) {
  var i = 0;
  res.writeHeader(200, {'Content-Type': 'text/html'});
  Deferred.loop(10, function() {
    return Deferred.wait(1)
    .next(function () {
      res.write('<p>' + (i++) + '</p>');
    })
  })
  .next(function() {
    res.close();
  })
}).listen(8000);
sys.puts('Server running at http://127.0.0.1:8000/');

あとは

node hello_jsdeferred.js

してブラウザで http://127.0.0.1:8000/ に接続すると、動いてることは確認できた。

簡単だね。


Promise について

Promise はほぼ JSDeferred と同じようなもので、今のところ API が提案されているだけ。

requestSomeData("http://example.com/foo") // returns a promise for the response
    .then(function(response){ // ‘then’ is used to provide a promise handler 
        return JSON.parse(response.body); // parse the body
    }) // returns a promise for the parsed body
    .then(function(data){
        return data.price; // get the price
    }) // returns a promise for the price
    .then(function(price){ // print out the price when it is fulfilled
        print("The price is " + price);
    });
CommonJS/JSGI: The Emerging JavaScript Application Server Platform | SitePen Blog

というようなことが出来るようになる。

then の引数は、1つ目が callback で、2つ目が error callback、3つ目が progress callback となる。

あと when とか emit とかよくわからないメソッド

以下、あとで読む的 reference。

トラックバック - http://javascript.g.hatena.ne.jp/edvakf/20100301

2010-02-28

Deferred.next_faster_way_postMessageを作ってみた

05:16

の続き。

JSDeferred の next を img.onerror よりさらに速くするために postMessage が使えるとわかったので、早速書いてみた。

Deferred.next_faster_way_postMessage = function(fun) {
  var d = new Deferred();
  var id = + new Date() + Math.random() + '';
  var handler = function (e) {
    if (e.data === id) {
      d.canceller();
      d.call()
    }
  };
  d.canceller = function () {
    window.removeEventListener('message', handler, false);
  }
  window.addEventListener('message', handler, false);
  window.postMessage(id, location.protocol + "//" + location.host);
  if (fun) d.callback.ok = fun;
  return d;
};

テストページ。

問題は、速すぎて Opera ではまったく動いてるところが見えなくて、Chromium と Safari でもほとんどのコマが飛んで見えること。

Deferred.next_faster_way_postMessage_help_redraw というやつでは Deferred.next_faster_way_readystatechange のように、一定時間以上立ったら setTimeout に切り替えるようになっているのだけど、Opera とか Chromium ではこれでも速すぎて、Firefox ではこれだと setTimeout が呼ばれすぎて意味ない。そもそも Firefox ではちゃんと描画されるので help_redraw な処理を入れる必要は無いのだけど。


擁護しておくと、JSDeferred の next でアニメーションするなんてことは普通はありえないので、気にするところはそこではないとも言える。アニメーションするなら 50ms とかでタイムアウトするはずだから。


安全にしてみた

コメント欄の指摘を受けて、iframe.contentWindow.addEventListener('message') を使うようにしてみた。

テストページで試せる。

Deferred.next_faster_way_postMessage2 = (function() {
  var iframe = document.createElement('iframe');
  iframe.style.display = 'none';
  document.body.appendChild(iframe);
  var win = iframe.contentWindow;

  return function(fun) {
    var d = new Deferred();
    var id = + new Date() + Math.random() + '';
    var handler = function (e) {
      if (e.data === id) {
        d.canceller();
        d.call()
      }
    };
    d.canceller = function () {
      win.removeEventListener('message', handler, false);
    }
    win.addEventListener('message', handler, false);
    win.postMessage(id, location.protocol + "//" + location.host);
    if (fun) d.callback.ok = fun;
    return d;
  }
}());

速度はほとんど変わらず。しかし DOM を弄るのはちょっと不本意。document.body 構築前かもしれないし。

でもこの方法だと Chromium + file: URL でセキュリティエラーになるのね…

os0xos0x2010/03/01 12:51問題は、すでにMessageEventが使われている/使うかもしれないところですね。
window.addEventListener('message', がいっぱいあったり、その中身次第では大惨事に…。
やはり、カスタムイベントが使えないのが痛いですね。
Namespace的なものをないみたいですし。

edvakfedvakf2010/03/01 12:55そういえばそうですね。JSON.stringify(e.data) 決め打ちしてたりしてエラーコンソールが大変なことになったり。
やっぱりあんまり良い方法ではないですね。

トラックバック - http://javascript.g.hatena.ne.jp/edvakf/20100228
|