昔話
Google MapsやPrototypeライブラリが認知されるにつれ、JavaScriptに対する評価はずいぶんと変わってきました。
それ以前は、我々が制作するような企業向けのサイトでは、JavaScriptへの依存は最低限にとどめて、古いブラウザ(Netscape Navigator 4やInternet Explorer 4)でも同じように動作することが求められてきました。
JavaScriptを使ったとしても、実験的なページ(今はもうありませんがNavigation Voodoo(1999年)など)での公開か、ブラウザごとにJavaScriptをまったく別個に記述する(今も残っているものとしてはearth from above(富士写真フィルム, 2000年)がIEで動くようです)ようなことをしてきました。
今ではブラウザの UserAgent 文字列やバージョンを見て分岐する方法よりは、object detectionを使ってより長生きするページを制作する手法が好まれますが、この当時では(少なくとも弊社では)このあたりが限界でした。
最近は「古いブラウザでもまったく同じに見えるように」というリクエストは少なくなり、最新のブラウザではその機能の恩恵を受けた実装を行い、古いブラウザではそれなりに見える、くらいのところが落としどころとなっています。
では、今回は前回から引き続いてElementへの拡張です。
1609行目からはgetDimensions()です。
style.displayが表示状態の場合、offsetWidth, offsetHeightを使います。Safariではstyle.displayが'none'の場合、値を取得するとnullが返ってきますので、それにも対応できるようにしてあります。
次に、style.displayが非表示の場合には、visibility: hidden、position:absolute、display:blockとした上でclientWidth、clientHeightを取得します。positionがabsoluteになっているので、この変更によってレンダリングが崩れることが無いようになっています。もちろんvisibilityもhiddenなので要素自体もレンダリングはされません。
displayの値によって、offset{Width,Height}、client{Width,Height}が使い分けられています。ボーダーの幅を含むか含まないかの違いがあるので注意してください。
1632行目からはmakePositioned()です。
style.positionを見て、'static'か何も指定されていなければ、'relative'に変更します。その際、後でundoPositioned()で利用するために、_madePositionedフラグを設定しておきます。
'relative'に設定することで、内部に含まれるposition:absoluteな要素がこの要素からの相対位置になるようにしたり、要素自身を本来のposition:staticな位置からtop, leftを使って移動させたい時に利用します。
コメントで言及されているOperaでの問題ですが、positionをstaticからrelativeに変えたとき、top, leftはFirefoxでは0pxに、IEではnullになります。ところがOperaでは親のうちposition:static以外のものからの相対位置が返ってきてしまいます。W3Cの勧告では、positionプロパティが変更された際にtop, leftがどうなっているべき、という記述は無いようなので、どのブラウザの挙動も間違っているわけではないのですが(といっても取得した値をそのまま再度設定すると移動してしまう、というのもどうかと思いますが)、Operaだけが他と大きく異なるので、Firefoxに合わせるように代入しています。
1648行目からはundoPositioned()です。
先ほどmakePositioned()で設定した_madePositionedフラグが設定されていれば元に戻す処理を行い、そうでなければ何もしません。
戻す際には、_madePositionedを使っていない状態に戻し、styleのposition、top、left、bottom、rightを空文字に設定することでデフォルトの挙動に戻します。
1661行目からはmakeClipping()です。
この関数とundoClipping()では、_overflowプロパティを保存領域としてつかっています。もしそこに値が設定されていれば、すでにmakeClipping()が呼ばれている状態と判断してそのまま要素を返します。
そして、1664行目でstyle.overflowを_overflowに保存します。style.overflowが空の場合は'auto'扱いですが、空のままだと1663行目の判別式で偽となってしまうので、'auto'という文字列を入れておきます。
次にElement.getStyle()を使ってできるだけその要素自体の現状のoverflowプロパティを取得しようとします。何も返ってこなければ、デフォルトの'visible'の扱いとします。そしてその取得した値が'hidden'でなければ、style.overflowを'hidden'に設定して、クリップされる状態にします。
そして1670行目からはundoClipping()です。
makeClipping()関数により_overflowプロパティが設定されていなければ、呼び出しは無視されます。
設定されていれば、その値が'auto'なら空文字列を要素のstyle.overflowに代入し、そうでなければ_overflowに入っている文字列を代入します。
最後に、判別用の_overflowプロパティをnullクリアして終了です。
1679行目からはElement.MethodsのdescendantOf、immediateDescendantsに別名をそれぞれchildOf、childElementsとして設定しています。
ここからは、Element.Methodsの関数を、ブラウザごとに微調整します。
まずはOpera用にgetStyle()を再定義します。
元のgetStyleを_getStyleとして保存しておき、新たにgetStyleを定義します。style.positionが'static'で、'left', 'top', 'right', 'bottom' のスタイル情報を取得しようとするとnullを返し、それ以外は元の_getStyle()を呼び出して値を返しています。
先ほどと似ていますが、Operaではposition指定が無い要素に明示的にposition:staticを指定すると、top, leftなどが自分の親のうちposition:static以外のものからの相対座標、に設定されます。これを嫌ってPrototypeライブラリのgetStyle()を使う場合はIEと同じようにnullを返すようにしています。
IE用の修正として、getStyle()、setOpacity()、update()を入れ替えています。
1698行目のgetStyle()では、floatスタイルの取得に'float', 'cssFloat'ではなく'styleFloat'を使うようにしています。
また、IEではdocument.defaultView.getComputedStyle()が使えないので、その代わりにcurrentStyleプロパティを参照しています。こちらも要素に指定されたスタイルではなく、実際に描画されているスタイル情報を取得することになります。
'opacity'を取得しようとしている場合は、'filter'に'alpha(opacity=50)'のような指定が入っているかどうかをチェックし、その値をFirefoxなどと合わせる為に100で割って返しています(IEは100で不透明)。
このalpha()の内側は、opacity=50などの他にもいくつかのパラメータが利用できます。また大文字で記述しても効果があるので、この正規表現だとカバーできない条件が多いでしょう。
得られた値が'auto'の場合、元のgetStyle()では単にnullを返していましたが、ここでは取得しようとしているスタイルが'width'か'height'でその要素のdisplayスタイルが'none'以外の場合、offsetWidthかoffsetHeightで値が取れるのでそれを使って返します。
1718行目からはsetOpacity()の上書きです。
半透明化はIEではfilter: alpha(opacity=<integer>)を使うので、その形で実装しなおします。
まず、現状のfilterスタイルの値を取得しておきます。指定しようとしている値が1もしくは空文字列の場合、完全な不透明を指定していることになるので、filter文字列からalpha()関数呼び出しをまるごと削除します。
もし指定しようとしている値が極端に小さければ0として扱うようにし、その後alpha()呼び出しを削除した後、指定された値でalpha(opacity=値)を追加しています。
先ほども書きましたが、alpha()にはopacity以外にもいろいろと指定できるため、alpha()まるごと削除、はちょっと乱暴でしょう。
1730行目からはupdate()の上書きです。
IEではテーブル関係の一部だけをinnerHTMLで書き換えることができないので、その場合の例外処理を行います。
書き換えようとしている要素のタグ名がthead、tbody、tr、td以外の場合は、元々のupdate()と挙動は同じです。
例外対象のタグの場合は、空の div 要素を作って、その中に必要な階層のテーブル関係のタグと、今回更新したい内容をくっつけて、まとめて innerHTML に代入します。その際、後で何階層下のタグを取り出すのか、ということを示す数値を depth 変数に入れておきます。
1751行目で、要素の子供ノードをすべて削除します。その後、depthで指定された回数だけdiv.firstChildを辿ります。これでdivは今回html変数に入った文字列から作った要素のひとつ親の要素を示すようになります。
最後に、divのchildNodesすべてを、elementにappendChild()して終了です。
1761行目からは Gecko(Mozilla系)用の修正です。元々の関数との変更点は、指定された値が1だったときに、style.opacityに''を設定していたのが0.999999を設定している、という点です。
この0.999999を渡す、という処理は他のライブラリでも見かけますが、由来が見つけられませんでした。簡単なテストでは1.0でも0.999999でもレンダリング結果は変わらないように見えます。
1770行目からはElement._attributeTranslationsです。
Element.readAttribute()でIE用の例外処理に用いられたり、Element.Methods.Simulated.hasAttribute()の中でプロパティ名の正規化に用いられたりします。
namesプロパティに入っているのは、要素の属性名の大文字小文字を正規化するための変換テーブルです。
valuesの方には、例外処理を必要とする要素の属性が含まれており、"style"、"title"は独自の関数を、それ以外は_getAttr()か_flag()が使われます。
_getAttr()内ではgetAttribute()関数の第二引数に2を渡しています。これは、IE独自の拡張かと思われますが、真だと大文字小文字を区別して属性を探すようです。
_flags()内では、checked="checked"というようなフラグ形式のものにおいて、適切な文字列が返されるようにするものです。
"href"、"src"、"type"、"disabled"、"checked"、"readonly"、"multiple"属性用に、内部用関数_getAttr、_flagを割り当てます。
Element._attributeTranslations.valuesのプロパティを、そのオブジェクト内の関数を値として設定するため、無名関数とcall()を使ってthisとして表記できるようにして短く記述しています。callを使わずに引数として渡してしまう方が素直かと思います。
Element.Methods.Simulatedは、特定のブラウザでHTMLElementインターフェイスの実装が欠けているのを補うための関数が置かれています。
といっても今のところはIE 6, 7用のhasAttribute()関数のみです。Element.extend()で自動的に拡張されたり、Element.hasAttribute()から間接的に呼ばれたり、Element.addMethods()で拡張するのに使われています。
hasAttribute()では、Element._attributeTranslations.namesハッシュを使ってIE用にプロパティ名を正規化し、getAttributeNode()で属性ノードを取得しています。ここで、この関数が真を返すのはnodeが取得でき、かつnode.specifiedが真の場合です。このgetAttributeNode()が返すnodeはInterface Attrを実装する属性ノードで、そのspecifiedはユーザーが値を設定した場合に真となる、と定義されています(要素がその属性としてデフォルト値を持っていても、ユーザーが値を設定しなければ偽)。一方hasAttribute()はその要素がデフォルト値を持っていても真となる、と定義されています。それに従うと、ここではnode.specifiedを使っているのは間違いかと思われます。
1821行目では、Element.MEthods.ByTagオブジェクトを用意しています。この中身はElement.addMethods()で詰め込まれ、利用されています。
ここまでで用意してきたElement.Methods内の拡張用のメソッド群を、Elementクラスに拡張しています。
1825行目からは、window.HTMLElementプロパティが存在しているかどうかを見て、無いようなら作成しています。
div要素の __proto__ としてプロトタイプオブジェクトが存在するようなら、そこに様々なHTMLElementインターフェイスが実装されているはずなので、それをwindow.HTMLElementとして流用します。
クロスブラウザで使えるhasAttribute()関数です。
Element化した要素は、メソッドとしてhasAttribute()を持っていますが、それ以外の要素にも使えるようにグローバルにElement.hasAttribute()を用意しています。
ちょっと大きめなElement.addMethods()関数です。
$()でElement化される要素に自動的に追加されるメソッドに、自分で作ったものを加えることができます。また、このElement.addMethods()はprototype.js読み込みの最後で引数なしで呼び出され、デフォルトの追加メソッドのセットアップも行います。
まずは関数内で短く記述するために、Prototype.BrowserFeaturesオブジェクトとElement.Methods.ByTagオブジェクトをそれぞれF、Tという変数に代入しておきます。
次に、引数がなにも指定されていなかった場合は、Form, Form.Elementに対してそれぞれ指定されたメソッドを追加します。さらに、Element.Methods.ByTagに<form>, <input>, <select>, <textarea>に対する追加メソッドも加えておきます。
addMethods()は、引数ひとつの場合はすべてのタグを対象とし、引数が二つの場合は特定のタグだけを対象とします。1851行目からにおいて、引数が二つあったときに指定されたタグ名を保存しています。
1856行目で、タグ名が指定されていなければ、Object.extend()を使って、拡張メソッドが格納されているElement.Methodsオブジェクトに指定されたmethodsをマージします。
もしタグ名が指定されているなら、この関数内ローカルの関数extend()を呼び出します。タグ名が配列の場合はそれぞれに対してextend()を呼び出します。
1862行目からは、関数内の関数定義としてextend()が定義されています。タグ名を大文字に揃え、Element.Methods.ByTagがタグ名をキーとするハッシュということになっているので存在していなければ空で初期化し、Object.extend()を使ってタグ名とメソッド定義を含むハッシュ、のペアとして格納しておきます。この時methods変数は、外側のaddMethods()関数内のローカル変数ですが、関数内で定義された関数の中からでも外側の変数を参照できるので、特に引数として渡さなくてもエラーとはなりません。
1869行目からも関数内の関数copy()です。これは1905行目以降で使います。
Element.extend()でも使ったElement.extend.cacheを使い、メモリを無駄に使わないようにしつつ、methodsをdestinationにコピーします。onlyIfAbsentフラグですでにメソッドがあった時に上書きするかしないかを指定しています。
次も関数内関数でfindDOMClass()です。
Firefox、WebKitではwindow.HTMLDivElementなどにHTML要素のコンストラクタとなる関数が入っています。IEではこれらが存在しないので、その場合は(完全に等価なものではありませんが)作成してそれを返します。
DOMで定義されているHTML Elementインターフェイスには、要素名と一致しない名前が多々存在するので、その対応表となるハッシュをtransとして用意しています。
その対応表に存在するタグで、window直下にその名前のプロパティが存在すればそれを返します。次は大文字となっているタグ名を使って"HTMLDIVElement"などの文字列を作り、それがwindowのプロパティに存在するかどうかを確かめます。
それでもダメならタグ名の部分の先頭だけ大文字にした形"HTMLDivElement"等で試します。
それでも見つからなければ、window直下にその名前のオブジェクトを作ってしまい、要素を一旦作成してそのプロトタイプを拾って代入しておきます。一般にHTMLElementなどはコンストラクタとなるべき関数オブジェクトが適切かと思われるので、ここは {} という空オブジェクトではなく function(){} のような空関数オブジェクトの方が良さそうです。
ということで、このfindDOMClass()は、タグ名を渡されると、(適切なprototypeプロパティを持つ)そのコンストラクタを返す、という関数になります。
1904行目からまたaddMethods()のメインの処理に戻ります。実行中のブラウザにwindow.HTMLElementプロパティが存在していたなら、そのHTMLElementのprototypeに、Element.Methodsをコピーしておきます。Element.Methods.Simulatedの方は、HTMLElementに関数が存在しなければ追加しています。
1909行目からは、SpecificElementExtensionsが真の時に実行されます。このフラグは <div>, <form> のプロトタイプが異なるときに真になります。ということは、HTMLDivElement, HTMLFormElementでそれぞれ異なるプロトタイプオブジェクトを持つ、ということなので、様々な要素型ごとに独自のメソッド群を実装している、と推測できます。
Element.Methods.ByTagで指定されたタグそれぞれについて、先ほど定義したfindDOMClass()でDOM HTMLインターフェイスを取得し、もし存在すればそのprototypeにElement.Methods.ByTagで指定された関数オブジェクトをコピーします。
後は、再度ElementをElement.Methodsで拡張しなおして終了です。1823行目で既にElementに追加していますが、この関数内でElement.Methodsが変更されている場合があるので再度拡張しています。
また、1910行目の Element.Methods.ByTagは、せっかくTに代入してあるのでそれを使う方が短く記述できます。
最後の delete Element.ByTagは、今のところ参照されていない変数のようですが、なぜかdeleteしています。Ticket #7888で指摘された問題に対して、Changeset 6561で変更された部分ですが、これらを見てもやはり理由はわかりません。
Toggleオブジェクトを定義していますが、今のところ公式ドキュメントにも載っていませんし、他から参照されているわけでもないようです。
Abstract.Insertion オブジェクト
Abstract.Insertionオブジェクトのコンストラクタです。
adjacencyには、"beforeBegin", "afterBegin", "beforeEnd", "afterEnd"が指定され、後ほどinsertAdjacentHTML()に渡されます。
Abstract.Insertionのprototypeです。
具体的なInsertion.Before などの prototype を構築する際に、
のような形を取ります。new Abstract.Insertion()から返されるメモリ上のインスタンスオブジェクトには、prototypeプロパティとしてAbstract.Insertion.prototypeを持つことになります。これをベースに、Insertion.Beforeならそれ独自のプロパティ・メソッドを{ ... }に加えていくことになります。
よって、1930行目からが各Insertion.*クラスで共有されるコンストラクタとなります。
Insertion.Beforeなどのクラスは、利用時にはnew Insertion.Beforeとnewを使うことで動作します。コンストラクタでは、adjacency引数が指定されていて、elementにinsertAdjacentHTMLプロパティとしてメソッドが存在する(IE, Opera)かどうかを見ています。
もし偽の場合、insertAdjacentHTML()のように細かく位置を指定することができません。まずcreateRange(), createContextualFragment()を使ってコンテキストに合ったDOMツリーフラグメントを作成します。それをinsertContent()に渡して実際に挿入します。insertContent()はBefore, Top, Bottom, After各クラスで上書き定義されており、その中で状況に合った位置に挿入するようになっています。
insertAdjacentHTML()が使える場合はそれをtry {}し、もし失敗したらタグがテーブル関係かどうかを調べます。IE では<tbody>, <tr>などの中途半端なDOMツリーは構築できないので、その場合はcontentFromAnonymousTable()を使って、空のテーブルを作ってそこに渡されたHTMLを流し込んだ状態でDOMツリー化する、という手順を踏みます。
最後に、<script>タグ部分を非同期に実行して終了です。
1954行目からは、先ほどコンストラクタで呼び出されたcontentFromAnonymousTable()です。
IEでは<td>, <th>タグを頂点とするDOMツリー生成ができないので、いったん外側のテーブルタグ文字列で囲った文字列を作り、それをダミーの<div>のinnerHTMLプロパティに流し込んで、その後奥に入っている<td>, <th>部分のDOMツリーを取り出して返す、ということをしています。
Insertion.*クラス用の入れ物として、Insertionオブジェクトを用意しています。この下にBefore, Top, Bottom, Afterが入ります。
Insertion.Before クラス
1963行目からは Insertion.Beforeクラスです。Class.create()した後で、前述のとおりAbstract.Insertionをnewしたものに、Insertion.Before独自の拡張をObject.extend()して、Insertion.Before.prototypeに入れています。
Insertion.* 各クラスで拡張しているのは、insertAdjacentHTML()が使えないときに、コンストラクタから呼び出されるinitializeRange()とinsertContent()というメソッドになります。
initializeRange()は、this.rangeにcreateRange()したものが入っている前提で、HTML文字列をDOMツリー化する際のコンテキストを設定します。
Insertion.Beforeの場合、渡されたelementの直前に挿入されるのでrange.setStartBefore()を使います。
1969行目からのinsertContent()では、渡された文書フラグメントを、element.parentNode.insertBefore()を使ってelementの直前に流し込んでいます。
Insertion.Top クラス
1976行目からはInsertion.Topです。
基本的な構造はInsertion.Beforeと同じで、Abstract.Insertionコンストラクタに渡す文字列が'afterBegin'となっています。
initializeRange()では、selectNodeContents()でelement全体を示す範囲を作り、collapse(true) (trueは先頭側に縮める)でelementの直前を指すようにします。
insertContent()の方は、受け取ったfragmentsをreverse(false)でコピーしつつ逆順に並び替え、それぞれに対してinsertBefore(element.firstChild)してelementの直前にひとつずつ入れていきます。ひとつずつinsertBefore()しているので、reverse()しておかないと、HTML文字列から作ったDOMツリーの配列が逆順に現れてしまいます。
Insertion.Bottomクラス
1990行目からはInsertion.Bottomです。
initializeRange()はInsertion.Topとほぼ同様で、collapse()へのパラメータがthis.elementとなっています。ここはfalseで後ろ側へ縮める、ということになっているので、falseであるべきでしょう(prototype.jsのtrunkではInsertionまわりがごっそり置き換わっているので、最新版ではすでのこの部分のコードは存在しません)。
insertContent()では、fragmentsそれぞれをappendChild()してやるだけ、という簡単なもので済んでいます。
Insertion.After クラス
2004行目からはInsertion.Afterです。
initializeRange()では、rangeに対してsetStartAfter()を呼んでいます。
insertContent()では、fragmentsそれぞれに対して、parentNode.insertBefore(element.nextSibling)を呼び出すことで、elementの後ろに挿入されるようになっています。
elementのnextSiblingが存在しない場合、insertBefore()にnullを渡すことになりますが、その場合も子供ノードの最後に挿入されることになっています。
Element.ClassNames
ある要素が持つ、複数になりうるクラス名をEnumerableとして抽象化するためのクラスです。いつもどおりClass.create()した上でprototypeを拡張しています。
コンストラクタです。渡されたelementをthis.elementに保存しているだけです。
Enumerable.each()から呼び出される_each()メソッドです。要素のclassNameプロパティを、空白文字でsplit()して、長さがあるものだけをselect()で抽出しています。
これにより、Enumerable.each()を使うと、要素中の各クラス指定ごとにイテレータ関数が実行されることになります。
2032行目からはset()です。これは単に指定された文字列をclassNameプロパティにセットします。事前にクラスが指定されていたとしても、この指定ですべて上書きされます。
add()メソッドでは、すでに指定されたクラスが要素に含まれている場合には何もせずに帰ります。
$A(this)すると Enumerable.toArray()が呼ばれ、map()経由でeach()が呼ばれるので、結果としてクラス指定の文字列を空白文字でsplit()した配列になります。これにconcat()した結果を最後にjoin(' ')して、単一のクラス指定文字列に変換します。それをset()でclassNameプロパティに設定して終了です。
2041行目からはremove()メソッドです。
this.include()を使って、指定されたクラス名が自分に含まれていなければそのまま帰ります。
含まれていれば、$A(this)で配列化したものにwithout()で指定されたクラス以外を集めて、join(' ')したものをset()で上書きしています。
toString()では、配列化したものをjoin(' ')で繋げて返しています。element.classNameをそのまま返してもいいような気がしますが、この方が余計な空白などが取り除かれた形に正規化されるので、そういう理由かもしれません。
最後にElement.ClassNamesのprototypeにEnumerableを拡張してElement.ClassNamesは完成です。