スポンサーリンク

Prototype.js + Script.aculo.us のはまり所

もはやレガシー*1となりつつある,時代を作ったJavaScriptフレームワーク Prototype.js の盲点,注意点を書きためてゆく。


※「JavaScriptの動かないコード(JavaScriptエラー集)」のprototype.js版,という位置づけである。ただしネタは多くない。

Download Prototype
http://www.prototypejs.org/download

クリティカルな物ほど上に書く。

(1)セレクタが重い

Webアプリに実データを投入して動かしてみると,ブラウザ上で画面が固まる(ただしIE限定)。

負荷試験,あるいは実運用の段階になって,パフォーマンス上の問題が生じる。処理が遅い。


そうした事例を実際に複数まねいた直接の原因が,prototype.jsのCSSセレクタ だった。

jQueryに乗り換えようかな
http://soulvision.sitefashion.info/bl...
prototype.jsも

  • ショートカット関数$(),
  • クラス名から要素を特定するgetByClassName()

など、ある程度の拡張はなされていますが、既存の関数の「延長」からは抜けきれていないように思えます。
・・・
prototype.jsにもCSSセレクタに該当するDOMオブジェクトを特定する$$()がありますが、jQueryの方が洗練されています。


prototype.js の document.getElementsByClassName は重い(2006/2)
http://d.hatena.ne.jp/amachang/200602...


prototype.js vs jquery vs mootools vs YUI vs Dojo (2009/2)
http://tenderfeel.xsrv.jp/javascript/...
IEはもともとjavascriptの処理がもっさりしてるのでアレですが、
どのブラウザでもYUIとprototype.jsの重さが目立ちますねー。
Dojoは平均的に速いですね。prototype.jsはセレクタが不得意な様子。


大量のDOM要素が存在するページ中では,たとえ1回でも $$() を使ってはならない。

FirefoxはXPath内蔵なので比較的違和感なく処理をさばいてくれるが,IE6,IE7では「スクリプトの実行に時間がかかっています」のダイアログが出て画面が落ちる。



ユーザにとってはWebアプリもExcelも変わらない

テーブルで言えば,1画面内に数千セル,数万セルを表示する事が期待+要求されるのが当然になる。

そして当然,各セルはリッチUIの一部でなければならない。


たった1要素を探す手間といえど,それら膨大な数のDOM要素の中から探すという場合には,大変な時間のコストになるのである。



重さの対策として

  • 要素を遅延ロードする(Google Mapのように)
  • 要素をページングするように仕様を変えさせてもらう

などの手があるが,それ以前の手として, $$ でフレームワーク内部の処理を呼ぶ代わりに

  • $()して
  • getEkementsByTagName()でもして
  • forループでも呼ぶ

というような原始的な対策が可能。


補足:該当部分のコード
9   var Prototype = {
10     Version: '1.6.0.3',
   
22     BrowserFeatures: {
23       XPath: !!document.evaluate,
24       SelectorsAPI: !!document.querySelector,

MozillaとIE8にはdocument.querySelectorがある。IE7にはない。

IE8 で実装された Selectors API とは何か?
http://d.hatena.ne.jp/amachang/200803...

Document.querySelector
https://developer.mozilla.org/Ja/DOM/...

2830行目あたり:

  findElements: function(root) {
    root = root || document;
    var e = this.expression, results;

    switch (this.mode) {
      case 'selectorsAPI':
        // querySelectorAll queries document-wide, then filters to descendants
        // of the context element. That's not what we want.
        // Add an explicit context to the selector if necessary.
        if (root !== document) {
          var oldId = root.id, id = $(root).identify();
          e = "#" + id + " " + e;
        }

        results = $A(root.querySelectorAll(e)).map(Element.extend);
        root.id = oldId;

        return results;
      case 'xpath':
        return document._getElementsByXPath(this.xpath, root);
      default:
       return this.matcher(root);
    }
  },

セレクタAPIもxpathもない場合,独自にCSSセレクタ文字列をコンパイルし,独自matcherを動かす事になる。


3443行目あたり:

function $$() {
  return Selector.findChildElements(document, $A(arguments));
}

というのが,何気なく使っている$$の中身である。


(2)Ajaxで読み込まれるページ内に,任意のコードを実行する脆弱性を作りこむ

Ajax.Updaterで要素内に外部HTMLを流し込む処理を実装した際,XSS脆弱性を作りこんでしまった。


アプリ内の特定のデータの名称を「 <script> alert("X")</script>」という名前にして,そのページにアクセスすると,見事にアラートが表示されてしまった。


しかし,基本的にページ内の全箇所で,適切にHTMLエスケープは施されている。

なぜJavaScriptが実行されてしまうのか・・・?




原因は次のような箇所だった。

<!-- <script> alert("X")</script> -->


prototype.jsは,evalScriptsの際に,HTMLコメント内に書かれているscript要素も評価・実行してしまう。


バグではない。APIドキュメントには次のように説明されている:

http://api.prototypejs.org/ajax/ajax/...

More About `evalScripts`

If you use evalScripts: true, any inline <script> block will be evaluated. This does not mean it will be evaluated in the global scope; it won't, and that has important ramifications for your var and function statements. Also note that only inline <script> blocks are supported; external scripts are ignored. See String#evalScripts for the details.

落とし穴なのが,「DOMノードとして」scriptを探索しているのではない,という点。

下記の抜粋コードでわかるとおり,単に「文字列として」script要素を探索しているのだ。

var Prototype = {
  Version: '1.6.0.3',

  ScriptFragment: '<script[^>]*>([\\S\\s]*?)<\/script>',

...

  extractScripts: function() {
    var matchAll = new RegExp(Prototype.ScriptFragment, 'img');
    var matchOne = new RegExp(Prototype.ScriptFragment, 'im');
    return (this.match(matchAll) || []).map(function(scriptTag) {
      return (scriptTag.match(matchOne) || ['', ''])[1];
    });
  },

  evalScripts: function() {
    return this.extractScripts().map(function(script) { return eval(script) });
  },


しかし対策が必要。

押すとメッセージを表示するようなボタン

<input type="button" value="ボタン"
 onclick="alert('<script>alert(/x/.source)</script>')">


このページを読み込むと,onloadで,scriptタグ(を模した文字列)の中身が実行されてしまう。(「x」と表示される)



onloadで実行されないようにするためには,下記のようにエスケープする。

<input type="button" value="ボタン"
 onclick="alert('&lt;script&gt;alert(/x/.source)&lt;/script&gt;')">

「HTMLエスケープが本来必要ない場所(HTMLではない場所)で,HTMLエスケープする」必要がある。


Ajaxで送られてくるデータは,あくまでも単なる文字列

onclick="alert('〜')"

の〜の部分だけでなく,ページ全体がリテラル


発見に時間を要した仕様。



※なお,HTMLコメント内に任意文字列を埋め込むのはそれ自体NG。
例えば,XHTMLではコメント中にダブルハイフンを書いてはならない。(コメントの終端文字列なので)

正しいコメントを書こう
http://www.kanzaki.com/docs/html/vali...

(3)ajaxで読み込んだページ内の関数

Ajaxで読み込まれたページ内のJavaScriptを動かしたい場合,そのページ中では,通常の形式で関数を宣言することはできない。

function hoge(){
〜〜
}

だとhoge()は利用できず,かわりに

hoge = function(){
〜〜
};

と書かなければならない仕様。「var」もつけてはいけない。


詳しく述べると,Ajax.UpdaterとかでevalScriptsされるときのコンテキストをwindowにしてやればよい,ということ。

※eval()内でvarしたら,そのeval()を実行している関数内でしかアクセスできない変数になってしまう。

Ajaxで動的にインクルードされたJavascriptって動かせる?
http://detail.chiebukuro.yahoo.co.jp/...


eval Function
https://developer.mozilla.org/ja/Core...
eval のコードのスコープは呼び出し元コードのスコープと同じです。

見落としやすい仕様。



(4)配列とノードリスト

下記のコードは動く。

<script src="prototype.js"></script>

<div id="div_container">

	<div class="hoge">div1</div>
	<div class="hoge">div2</div>
	<div class="hoge">div3</div>

</div>

<input type="button" onclick="f()" value="クリック">


<script language="JavaScript">

function f(){

	alert( $$("div.hoge").length ); // 3
	
	$$("div.hoge").each(function( elem ){
		alert( elem.innerHTML );
	});
}

</script>

div1, div2, div3と順番に表示される。


ここで,前項(1)の教訓から「$$」を外す事になったとする。


下記のようなコードを書く。

<script src="prototype.js"></script>

<div id="div_container">

	<div class="hoge">div1</div>
	<div class="hoge">div2</div>
	<div class="hoge">div3</div>

</div>

<input type="button" onclick="f()" value="クリック">


<script language="JavaScript">

function f(){

	$("div_container").getElementsByTagName("div").each(function( elem ){
		alert( elem.innerHTML );
	});

}

</script>

このコードは動かない。

IEでは「オブジェクトでサポートされていないプロパティまたはメソッドです」のエラーメッセージが出る。


getElementsByTagNameの返り値は,Arrayではなく,NodeListである。

だから,Arrayにミックスインされたeach()メソッドは使えない。

$A( $("div_container").getElementsByTagName("div") ) のようにして,明示的にArrayに変換すればOK。

$A - エレメントのリスト形式を配列形式に変換
http://javascriptist.net/ref_prototyp...

NodeListの活用にはご注意を
http://jibun.atmarkit.co.jp/lskill01/...
NodeListとは、JavaScriptの配列、つまりArrayオブジェクトではありません。NodeListを配列として扱ってしまうと、期待する動作が得られず、デバッグに悩まされるでしょう。

これはDOMの基礎事項だが,ArrayとNodeListの違いをこうして意識するのは,片方が拡張され片方が拡張されなかったような場合。

なので,prototype.jsを使う時になって初めて出会う問題かもしれない。


(5)Script.aculo.usのSortableは,tableに適用できない

table内のtr要素をドラッグドロップ可能にしようとすると,ブラウザごと落ちた。

かわりにul + li要素の組み合わせにする。

Sortable.create
http://wiki.github.com/madrobby/scrip...
Important: You can use Sortable.create on any container element that contains Block Elements, with the exception of TABLE, THEAD, TBODY and TR. This is a technical restriction with current browsers.


マウスでソートできるリストを作るためのJavaScriptライブラリ(行追加・削除・固定行の設定が可能)
http://language-and-engineering.hatenablog.jp/entry/20090902/p1


もしリストではなくテーブルをどうしても使いたい場合,下記を利用するとよい。

Table Drag and Drop JQuery plugin
http://www.isocra.com/2008/02/table-d...

(6)jQueryの記法と間違えやすい

  • $("#dom要素のid")
  • $("CSSセレクタ")

のような書き方をしていて,動かないのはなぜだ,と苦戦する事がある。

IDを間違えている?要素が生成されていない?・・・などと悩んでしまったりする。


正しくは,上記の代わりに

  • $("dom要素のid")
  • $$("CSSセレクタ")

としなければならない。jQueryの頭を切り替える。


(7)並び替え可能なリストのonUpdateコールバックが実行されない


Sortable.createで並び替え可能なリストを作成する。

そのリストが並びかえられたタイミングで,並び順をAjaxでサーバに送信して保存させたい場合がある。


そういう時にonUpdateコールバックを使うわけだが,これが実行されない。

エラーになるわけでもない。


理由は以下サイト

script.aculo.usのSortable.createにおいてonUpdateが実行されない原因
http://blog.37to.net/2006/03/scriptac...

  • Sortable.CreateでonUpdateを実行するには、ドラッグ&ドロップを行う要素の id属性に'_'(アンダーバー)を含める必要があり、かつ'_'以降の文字列が要素ごとにユニークになるようにする必要がある。

はまった。

(8)並び替え可能なリストが擬似フレーム中で正常に動作しない

擬似フレーム中でスクロールバーが下に下がっている状態だと,その内部で Sortable.create すると,ドラッグ位置がずれてしまう。


仕方ないので,Sortableではなく,かわりにjQueryのプラグイン「tableDnD」を使う。

こちらは擬似フレーム中でもOK。

Table Drag and Drop JQuery plugin
http://www.isocra.com/2008/02/table-d...


I want to disable Jquery tablednd
http://stackoverflow.com/questions/44...

ドラッグドロップを動的に破棄する方法がない。

終了ボタン押下時に自動的にページをリロードするとかしてごまかす。

(9)table要素にidがあると,Ajaxによるリロード時にTableKitが効かなくなる

TableKitは,テーブルのソート・列幅のドラッグドロップによる調整などを可能にする prototype.js ベースのライブラリ。


このライブラリは,1度目は問題なく呼べる。

2度目が大変。

超簡単なはずのTableKitを実際に使ってみたら、ちょっとした苦労が
http://d.hatena.ne.jp/shunsuk/2007061...

TableKitでいったん拡張したテーブルを再び拡張することはできないのです。

テーブルが拡張されたことがあるかどうかはテーブルのid属性で判断されます。

ですから、テーブルにid属性を付けていると、タブを切り替えて再び元のタブに戻ったときに、テーブルが拡張されずに普通のテーブルになってしまいます。


Table columns not sorted by Tablekit after AJAX call is added
http://www.daniweb.com/forums/thread2...

このライブラリによる初期化処理は,テーブルのIDにひもづいているので,同一IDのテーブルに対する2回目の初期化がうまくいかない。


でも,そういうシーンはよくある。

ある特定のIDを持ったtable要素が「Ajaxで繰り返し読み込まれる」ような場合に,

読み込まれたtableに対して,再度TableKitを適用したいのだ。



仕方がないので,TableKit自体のソースに手を加えて解決した。

tablekit.jsの,74行目と76行目をコメントアウトすればOK。

71	getHeaderCells : function(table, cell) {
72		if(!table) { table = $(cell).up('table'); }
73		var id = table.id;
74		//if(!TableKit.tables[id].dom.head) {
75			TableKit.tables[id].dom.head = $A((table.tHead && table.tHead.rows.length > 0) ? table.tHead.rows[table.tHead.rows.length-1].cells : table.rows[0].cells);
76		//}
77		return TableKit.tables[id].dom.head;
78	},

この関数は,加工したいテーブルの見出し行を取得する関数。

オリジナルのソースでは,「もし過去に初期化済みのテーブルがあれば,そのテーブルのキャッシュから見出し行を引っ張ってくる」というふうになっている。

ここでは,キャッシュを使わずに,毎回必ず該当するテーブルの実物を画面上から引っ張ってくるようにしている。


同じような機能を持つライブラリとして,jQueryのflexigridもある。

でも,この TableKit は,DOCTYPEが無くても(QuirksモードのIEでも)正常に動作するので重宝するのだ。


以降,発見され次第追記


 

*1:ここで言うレガシーは,「常識」もしくは「古典」とも言いかえられる良い意味。