script.aculo.usを読み解く

第5回effects.js(前編)速度的なボトルネックを解消するための土台の最適化

今回解説するeffects.jsは、Script.aculo.usの代名詞ともいえる、魅力的な演出効果の数々を提供するライブラリです。Webサイトをよりドラマティックに彩るこれらの演出効果は、当初から新鮮な驚きを持って迎えられ、現在に至るまで、Web2.0なデザインを実現するのに欠かせないものとなっています。

Web開発によく使われるライブラリとして、Ajaxjanの調査では、2006年2007年ともに圧倒的なシェアを誇り、最新の調査では、jQuery、Prototypeに次いで第3位にランクインしました。その反面、遅い、重いと敬遠されている機能でもあります。もちろん、そういった不満の声は開発者たちにも届いており、不満を解消するための様々な最適化が施されています。

今回の解説の見どころは、演出効果の実現方法もさることながら、そういった速度的なボトルネックを解消するために、いったいどんな最適化が行われているか、という点です。それには、evalでループ内不変式を削除する、というテクニックが使われています。

effects.jsの内部構造として、まず、すべての演出効果の基礎となるEffect.Baseというクラスがあり、それを継承する単純な8種類の演出効果があります。今回の解説ではこの土台部分を解説します。さらに次回の解説では、それらの複合からなる18種類の演出効果のコードを解説します。

それではコードを見ていきましょう。

0001:// script.aculo.us effects.js v1.8.1, Thu Jan 03 22:07:12 -0500 2008
0002:
0003:// Copyright (c) 2005-2007 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
0004:// Contributors:
0005://  Justin Palmer (http://encytemedia.com/)
0006://  Mark Pilgrim (http://diveintomark.org/)
0007://  Martin Bialasinki
0008:// 
0009:// script.aculo.us is freely distributable under the terms of an MIT-style license.
0010:// For details, see the script.aculo.us web site: http://script.aculo.us/ 
0011:

1~11行目は、著作権表示です。

0012:// converts rgb() and #xxx to #xxxxxx format,  
0013:// returns self (or first argument) if not convertable  
0014:String.prototype.parseColor = function() {  
0015:  var color = '#';
0016:  if (this.slice(0,4) == 'rgb(') {  
0017:    var cols = this.slice(4,this.length-1).split(',');  
0018:    var i=0; do { color += parseInt(cols[i]).toColorPart() } while (++i<3);  
0019:  } else {  
0020:    if (this.slice(0,1) == '#') {  
0021:      if (this.length==4) for(var i=1;i<4;i++) color += (this.charAt(i) + this.charAt(i)).toLowerCase();  
0022:      if (this.length==7) color = this.toLowerCase();  
0023:    }  
0024:  }  
0025:  return (color.length==7 ? color : (arguments[0] || this));  
0026:};
0027:
0028:/*--------------------------------------------------------------------------*/
0029:

12~28行目のparseColorは、Stringクラスのメソッドで、'rgb(...)'や'#xxx'という形式で表現された文字列を、'#xxxxxx'という形式の文字列に変換する関数です。変換できない場合は、通常は、元の文字列を返しますが、引数を与えることで、変換できない場合にそれを返すようにできます。

16行目で、'rgb(128,255,0)'といった指定を扱います。文字列が'rgb('で始まっているかを調べます。

17行目で、その場合は、続く文字列を','で区切って、色の指定を取り出します。

18行目で、それぞれの色の指定について、parseIntで整数として読み込み、Prototype.jsのtoColorPartを使って16進数表記の文字列にして、結合し、'#xxxxxx'という形式の文字列にします。

20行目で、'#8F0'、'#80FF00'といった指定を扱います。文字列が'#'で始まっているかを調べます。

21行目で、文字列が4文字ならば'#abc'という形式なので、3つの文字をそれぞれだぶらせて'#aabbcc'という形式にします。さらに、小文字に統一します。

22行目で、文字列が7文字ならば、そのまま使います。さらに、小文字に統一します。

25行目で、変換が成功したかを調べ、失敗していたら、元の文字列か1番めの引数を返します。

0030:Element.collectTextNodes = function(element) {  
0031:  return $A($(element).childNodes).collect( function(node) {
0032:    return (node.nodeType==3 ? node.nodeValue : 
0033:      (node.hasChildNodes() ? Element.collectTextNodes(node) : ''));
0034:  }).flatten().join('');
0035:};
0036:

30~36行目のcollectTextNodesは、与えた要素の子孫ノードをたどって、テキストノードを集めて、それらの文字列を結合して返す関数です。

31行目で、要素の子ノードをたどります。ここでは、要素のchildNodesを、Prototype.jsの$A関数を使って配列にしてから、collectメソッドを使っています。

32行目で、テキストノードは、nodeTypeが3です。テキストノードならその内容の文字列を返します。

33行目で、さらに孫ノードがあれば、それをたどるように、collectTextNodesを再帰的に呼びます。

34行目で、それらをflattenで配列をつぶして結合し、要素の子孫ノードが持っていた文字列を全て集めます。

0037:Element.collectTextNodesIgnoreClass = function(element, className) {  
0038:  return $A($(element).childNodes).collect( function(node) {
0039:    return (node.nodeType==3 ? node.nodeValue : 
0040:      ((node.hasChildNodes() && !Element.hasClassName(node,className)) ? 
0041:        Element.collectTextNodesIgnoreClass(node, className) : ''));
0042:  }).flatten().join('');
0043:};
0044:

37~44行目のcollectTextNodesIgnoreClassは、collectTextNodesと同様の動作をしますが、40行目にあるように、2番めの引数で指定されたクラス名を持つノードを無視するところが違います。

0045:Element.setContentZoom = function(element, percent) {
0046:  element = $(element);  
0047:  element.setStyle({fontSize: (percent/100) + 'em'});   
0048:  if (Prototype.Browser.WebKit) window.scrollBy(0,0);
0049:  return element;
0050:};
0051:

45~51行目のsetContentZoomは、要素のフォントサイズを変えることで、表示の大きさを調整する関数です。倍率をパーセントで与えます(100で等倍⁠⁠。

47行目で、要素のCSSのfontSizeプロパティの値を変えることで、表示の大きさを調整します。

48行目で、Safariブラウザには、window.scrollBy(0,0)で再描画を促します。

0052:Element.getInlineOpacity = function(element){
0053:  return $(element).style.opacity || '';
0054:};
0055:

52~55行目のgetInlineOpacityは、要素の透明度を取得して返します。取得に失敗した場合は''を返します。

53行目で、要素のCSSのopacityプロパティの値を取得して返します。取得に失敗した場合は''を返します。

0056:Element.forceRerendering = function(element) {
0057:  try {
0058:    element = $(element);
0059:    var n = document.createTextNode(' ');
0060:    element.appendChild(n);
0061:    element.removeChild(n);
0062:  } catch(e) { }
0063:};
0064:
0065:/*--------------------------------------------------------------------------*/
0066:

56~66行目のforceRerenderingは、Safariでfloat属性と透明度が絡んだときに、要素が正しくレンダリングされないバグ回避するための関数です。内部的にはEffect.Appearでのみ使われています

Effect

0067:var Effect = {
0068:  _elementDoesNotExistError: {
0069:    name: 'ElementDoesNotExistError',
0070:    message: 'The specified DOM element does not exist, but is required for this effect to operate'
0071:  },

67~71行目の_elementDoesNotExistErrorは、指定のDOM要素が存在しなかった場合に投げられる例外です。

0072:  Transitions: {
0073:    linear: Prototype.K,
0074:    sinoidal: function(pos) {
0075:      return (-Math.cos(pos*Math.PI)/2) + 0.5;
0076:    },
0077:    reverse: function(pos) {
0078:      return 1-pos;
0079:    },
0080:    flicker: function(pos) {
0081:      var pos = ((-Math.cos(pos*Math.PI)/4) + 0.75) + Math.random()/4;
0082:      return pos > 1 ? 1 : pos;
0083:    },
0084:    wobble: function(pos) {
0085:      return (-Math.cos(pos*Math.PI*(9*pos))/2) + 0.5;
0086:    },
0087:    pulse: function(pos, pulses) { 
0088:      pulses = pulses || 5; 
0089:      return (
0090:        ((pos % (1/pulses)) * pulses).round() == 0 ? 
0091:              ((pos * pulses * 2) - (pos * pulses * 2).floor()) : 
0092:          1 - ((pos * pulses * 2) - (pos * pulses * 2).floor())
0093:        );
0094:    },
0095:    spring: function(pos) { 
0096:      return 1 - (Math.cos(pos * 4.5 * Math.PI) * Math.exp(-pos * 6)); 
0097:    },
0098:    none: function(pos) {
0099:      return 0;
0100:    },
0101:    full: function(pos) {
0102:      return 1;
0103:    }
0104:  },

72~104行目のTransitionsには、比例や波などの関数が入っています。等速直線運動や波打つ動きをさせるのに使います。どれも、0.0~1.0の実数をとって、0.0~1.0の実数を返す関数です。linearのPrototype.Kとは、Prototype.jsで定義されている恒等関数です。

linear比例の関数(function(x) { return x })
sinoidalsin関数
reverse逆比例の関数
flicker乱数でゆらめいてから収束する関数
wobble徐々に激しくなる震えのような関数
pulse三角波のような関数
springバネのようにビョーンとする関数
none常に0の定数関数 
full常に1の定数関数 
0105:  DefaultOptions: {
0106:    duration:   1.0,   // seconds
0107:    fps:        100,   // 100= assume 66fps max.
0108:    sync:       false, // true for combining
0109:    from:       0.0,
0110:    to:         1.0,
0111:    delay:      0.0,
0112:    queue:      'parallel'
0113:  },

105~113行目のDefaultOptionsは、エフェクトのデフォルトのオプション設定です。

duration
エフェクトの持続時間です。デフォルトは 1.0(秒)です。
fps
フレームレートです。デフォルトは 100(fps)です。この値が大きいほど、動きが滑らかになります。
sync
他のエフェクトと同期するかどうかです。デフォルトはfalseです。Effect.Parallelにエフェクトを複数追加するときにだけ使います。
from
開始時点のポジションです。デフォルトは0.0です。
to
終了時点のポジションです。デフォルトは1.0です。
delay
エフェクトの開始を遅延する時間です。デフォルトは0.0です。
queue
エフェクトをどのキューにいれるかです。デフォルトは'parallel'です。
0114:  tagifyText: function(element) {
0115:    var tagifyStyle = 'position:relative';
0116:    if (Prototype.Browser.IE) tagifyStyle += ';zoom:1';
0117:    
0118:    element = $(element);
0119:    $A(element.childNodes).each( function(child) {
0120:      if (child.nodeType==3) {
0121:        child.nodeValue.toArray().each( function(character) {
0122:          element.insertBefore(
0123:            new Element('span', {style: tagifyStyle}).update(
0124:              character == ' ' ? String.fromCharCode(160) : character), 
0125:              child);
0126:        });
0127:        Element.remove(child);
0128:      }
0129:    });
0130:  },

114~130行目のtagifyTextは、要素の子ノードにあるテキストノードについて、その文字列の1文字1文字をspan要素で囲ったものに置き換える関数です。内部的には使われていません。これを使うと、文字列の1文字ずつにエフェクトをかけたりできます。

116行目で、IEのCSSのバグを回避するために、CSSのzoomプロパティを1にします。

119行目で、要素の子ノードたちを取り出し、それぞれに以下の処理をします。

120行目で、テキストノードか調べます。

121行目で、テキストノードであれば、その文字列の1文字1文字に、以下の処理をします。

122~125行目で、1文字ずつspanタグで囲い、それらをテキストノードのところに挿入します。new Element('span',...).updateと、Prototype.jsの1.6で入った機能を使っています。

124行目で、文字で' 'と等しいものは、Unicodeの160番(00A0番)に統一します。

127行目で、元となったテキストノードは削除します。これで置き換わったことになります。

0131:  multiple: function(element, effect) {
0132:    var elements;
0133:    if (((typeof element == 'object') || 
0134:        Object.isFunction(element)) && 
0135:       (element.length))
0136:      elements = element;
0137:    else
0138:      elements = $(element).childNodes;
0139:      
0140:    var options = Object.extend({
0141:      speed: 0.1,
0142:      delay: 0.0
0143:    }, arguments[2] || { });
0144:    var masterDelay = options.delay;
0145:
0146:    $A(elements).each( function(element, index) {
0147:      new effect(element, Object.extend(options, { delay: index * options.speed + masterDelay }));
0148:    });
0149:  },

131~149行目のmultipleは、要素(か、その配列)とエフェクトのクラスを引数にとり、それぞれにエフェクトをかける関数です。3番めの引数にオプションを渡すことで、各エフェクトの開始時刻を少しずつずらすこともできます。

133~136行目で、1番めの引数に要素の配列が与えられたらそれらを対象にします。$Aで配列になるものなら良いようです。

138行目で、1番目の引数に要素が与えられたらその子ノードたちを対象にします。

140~143行目で、3番めの引数をオプションとして扱います。speedは開始時刻をずらす幅を、delayは全体の開始時刻を遅らせる幅を、それぞれ表します。

146行目で、それぞれにエフェクトをかけます。少しずつ開始時刻をずらします。

0150:  PAIRS: {
0151:    'slide':  ['SlideDown','SlideUp'],
0152:    'blind':  ['BlindDown','BlindUp'],
0153:    'appear': ['Appear','Fade']
0154:  },

150~154行目のPAIRSは、対義なエフェクトのペアの対応表です。項の値は、現れる・消えるの順番に並んでいて、例えば'slide'の項の値は'Slidedown'(スライドして現れる)と'SlideUp'(スライドして消える)です。

0155:  toggle: function(element, effect) {
0156:    element = $(element);
0157:    effect = (effect || 'appear').toLowerCase();
0158:    var options = Object.extend({
0159:      queue: { position:'end', scope:(element.id || 'global'), limit: 1 }
0160:    }, arguments[2] || { });
0161:    Effect[element.visible() ? 
0162:      Effect.PAIRS[effect][1] : Effect.PAIRS[effect][0]](element, options);
0163:  }
0164:};
0165:

155~165行目のtoggleは、上記の対義なエフェクトのペアの名前を引数にとって、要素が見えていれば、ペアの'閉じるエフェクト'、そうでなければ'開くエフェクト'をかけます。

157行目で、もしエフェクトの指定がなければ、'appear'のペアを使います。

158行目で、オプションを設定して、キューの最後尾に追加するようにします。。要素のidがあれば、idの名前がついたキューに追加します。なければ'global'キューに追加します。limitを1にしているので、既に他のエフェクトがキューにある場合は追加されません。これによって、例えばマウスクリックに応じて出たり消えたりするようにした場合に、連打してもエフェクトが積み重なりません。このキューのオプションについては後述します。

161行目で、要素が見えていれば'閉じるエフェクト'、そうでなければ'開くエフェクト'をかけます。

0166:Effect.DefaultOptions.transition = Effect.Transitions.sinoidal;
0167:

166行目で、Effect.DefaultOptions.transitionsをサイン関数にします。クラス定義が終わってからでないと参照できないのでこうしています。

Effect.ScopedQueue

Effect.ScopedQueueは、エフェクトのキュー(順番待ちの列)を作るためのクラスです。データ構造のキューがあるわけではなく、エフェクトの開始時刻と終了時刻を連続して並べることでキューを表現しています。ちなみに、エフェクトの中でoption.syncがtrueのものは、このキューに入りません。それらは順番にではなく同時に実行する必要があるからです。

0168:/* ------------- core effects ------------- */
0169:
0170:Effect.ScopedQueue = Class.create(Enumerable, {
0171:  initialize: function() {
0172:    this.effects  = [];
0173:    this.interval = null;    
0174:  },

171~174行目のinitializeは、空のキューを作る関数です。

172行目で、effectsには、キューに含まれるすべてのエフェクトが入ります。

173行目で、intervalは、エフェクトを順次実行するタイマが入ります。

0175:  _each: function(iterator) {
0176:    this.effects._each(iterator);
0177:  },

175~177行目の_eachは、キューにあるエフェクトたちに、引数で指定した関数をおのおの適用する関数です。Prototype.jsのEnumerableのeachメソッドを使えるようにするために定義してあります。

0178:  add: function(effect) {
0179:    var timestamp = new Date().getTime();
0180:    
0181:    var position = Object.isString(effect.options.queue) ? 
0182:      effect.options.queue : effect.options.queue.position;
0183:    
0184:    switch(position) {
0185:      case 'front':
0186:        // move unstarted effects after this effect  
0187:        this.effects.findAll(function(e){ return e.state=='idle' }).each( function(e) {
0188:            e.startOn  += effect.finishOn;
0189:            e.finishOn += effect.finishOn;
0190:          });
0191:        break;
0192:      case 'with-last':
0193:        timestamp = this.effects.pluck('startOn').max() || timestamp;
0194:        break;
0195:      case 'end':
0196:        // start effect after last queued effect has finished
0197:        timestamp = this.effects.pluck('finishOn').max() || timestamp;
0198:        break;
0199:    }
0200:    
0201:    effect.startOn  += timestamp;
0202:    effect.finishOn += timestamp;
0203:
0204:    if (!effect.options.queue.limit || (this.effects.length < effect.options.queue.limit))
0205:      this.effects.push(effect);
0206:    
0207:    if (!this.interval)
0208:      this.interval = setInterval(this.loop.bind(this), 15);
0209:  },

178~209行目のaddは、キューにエフェクトを追加する関数です。順番待ちの列のどこに追加するについては、オプションで'front'先頭、'with-last'最後尾と同時、'end'最後尾から選べます。キューの長さがoptions.limitを越えている場合は追加されません。

179行目で、現在時刻をミリ秒単位でブラウザから取得します。エフェクトはすべて、ブラウザの時刻に基づいて実行されます。そのおかげで、コンピュータやブラウザの性能に左右されずにエフェクトを描画できます。

181行目で、options.queueかoptions.queue.positionから、値を取り出します。少々ややこしいですが、options={queue: 'front'}という与えかたと、options.queue = {position: 'front', scope:'hoge', limit:5}という与えかたが用意されているので、こうなっています。このややこしいのは、キューが当初は1本しかなく、スコープの概念は後付けだったためです。

184~199行目で、以下のような場合分けをします。

  • 'front'(先頭)に挿入する場合は、すでにキューにあるエフェクトの開始、終了時刻をすべて後にずらします。この時点ではeffect.finishOnには、何ミリ秒後に終わるかという、相対時間が入っていることに注意してください。202行目で絶対時間になります。
  • 'with-last'(最後尾と同時)にする場合は、すでにキューにあるエフェクトで開始時刻が一番後ろのものと開始時刻をあわせます。
  • 'end'(最後)にする場合は、すでにキューにあるエフェクトで終了時刻が一番後ろのものの後に開始時刻をあわせます。

204行目で、options.queue.limitが設定されている場合は、キューの長さがそれを越えていないことを確認してから、キューに挿入します。

207行目で、まだタイマがなければ、タイマを作ります。このタイマは0.015秒ごとに、キューのエフェクトたちを実行します。

0210:  remove: function(effect) {
0211:    this.effects = this.effects.reject(function(e) { return e==effect });
0212:    if (this.effects.length == 0) {
0213:      clearInterval(this.interval);
0214:      this.interval = null;
0215:    }
0216:  },

210~216行目のremoveは、キューから引数のエフェクトを削除する関数です。

211行目で、キューから引数のエフェクトを削除します。

212行目で、もしキューにひとつもエフェクトがなければ、タイマを止めます。

0217:  loop: function() {
0218:    var timePos = new Date().getTime();
0219:    for(var i=0, len=this.effects.length;i<len;i++) 
0220:      this.effects[i] && this.effects[i].loop(timePos);
0221:  }
0222:});
0223:

217~223行目のloopは、キューのエフェクトたちを実行する関数です。0.015秒ごとに呼ばれます。一見、キューのエフェクトすべてを実行しているように見えますが、現在時刻が開始終了時刻にそぐわないエフェクトはそれを無視するので大丈夫です。

218行目で、現在時刻をミリ秒単位で取得します。

219行目で、for(var i=0, len=this.effects.length;i<len;i++) となっているのは、このメソッドが頻繁に呼ばれるので、ループごとのlength取得さえもったいないということです。これはループ内不変式のくくり出しという最適化です。

Effect.Queues

Effect.Queuesは、上述のEffect.ScopedQueueで作るキューに名前をつけて区別することで、複数のキューを使えるようにするクラスです。

0224:Effect.Queues = {
0225:  instances: $H(),
0226:  get: function(queueName) {
0227:    if (!Object.isString(queueName)) return queueName;
0228:    
0229:    return this.instances.get(queueName) ||
0230:      this.instances.set(queueName, new Effect.ScopedQueue());
0231:  }
0232:};

225行目で、instancesは、単なるハッシュテーブルです。名前とキューの対応表にします。

226~231行目のgetは、名前をとって、それに応じたキューを返す関数です。その名前のキューが無いときは、新しくキューを作って返してきます。

Effect.Queue

0233:Effect.Queue = Effect.Queues.get('global');
0234:

233行目で、単にEffect.Queueと呼んだときは、名前が'global'のキューを返すようにします。キューが1本しかなかったころの名残です。

Effect.Base

全てのエフェクトの基礎となるクラスです。

0235:Effect.Base = Class.create({
0236:  position: null,

236行目のpositionには、常に現在のポジション(0.0~1.0)が入ります。renderメソッドが常にこの値を更新するようになっています。ポジションは進捗度にサイン関数やパルス関数を適用したもので、0.0~1.0の間でいろいろな動きをします。この動きにあわせて、透明度や位置を変化させることで、フェードアウトや揺れる動きができます。

0237:  start: function(options) {
0238:    function codeForEvent(options,eventName){
0239:      return (
0240:        (options[eventName+'Internal'] ? 'this.options.'+eventName+'Internal(this);' : '') +
0241:        (options[eventName] ? 'this.options.'+eventName+'(this);' : '')
0242:      );
0243:    }
0244:    if (options && options.transition === false) options.transition = Effect.Transitions.linear;
0245:    this.options      = Object.extend(Object.extend({ },Effect.DefaultOptions), options || { });
0246:    this.currentFrame = 0;
0247:    this.state        = 'idle';
0248:    this.startOn      = this.options.delay*1000;
0249:    this.finishOn     = this.startOn+(this.options.duration*1000);
0250:    this.fromToDelta  = this.options.to-this.options.from;
0251:    this.totalTime    = this.finishOn-this.startOn;
0252:    this.totalFrames  = this.options.fps*this.options.duration;
0253:    
0254:    eval('this.render = function(pos){ '+
0255:      'if (this.state=="idle"){this.state="running";'+
0256:      codeForEvent(this.options,'beforeSetup')+
0257:      (this.setup ? 'this.setup();':'')+ 
0258:      codeForEvent(this.options,'afterSetup')+
0259:      '};if (this.state=="running"){'+
0260:      'pos=this.options.transition(pos)*'+this.fromToDelta+'+'+this.options.from+';'+
0261:      'this.position=pos;'+
0262:      codeForEvent(this.options,'beforeUpdate')+
0263:      (this.update ? 'this.update(pos);':'')+
0264:      codeForEvent(this.options,'afterUpdate')+
0265:      '}}');
0266:    
0267:    this.event('beforeStart');
0268:    if (!this.options.sync)
0269:      Effect.Queues.get(Object.isString(this.options.queue) ? 
0270:        'global' : this.options.queue.scope).add(this);
0271:  },

237~271行目のstartは、エフェクトの生成時に呼ばれる関数です。

238~243行目のcodeForEventは、'afterFinishInternal'、'afterFinish'などの内部イベントにフックが設定されているかを前もって判定して、適切なコード文字列を生成する関数です。renderメソッドの動的な定義に使われます。

245行目で、optionを読み込みます。Effect.DefaultOptionsが上書きされないように、一度コピーしてから使っています。

246行目で、currentFrameは、常に現在のフレームが入ります。loopメソッドが常にこの値を更新するようになっています。

247行目で、stateは、エフェクトの開始時刻が来るまでは'idle'、実行中は'running'、終了時は'finished'をとります。

248行目で、startOnは、エフェクトの開始時刻です。options.delayを使って、開始時刻を遅らせることもできます。

249行目で、finishOnは、エフェクトの終了時刻です。

250行目で、fromToDeltaは、開始時点のポジションの設定であるoptions.fromと、終了時点のポジションの設定であるoptions.toの差です。

251行目で、totalTimeは、開始時刻と終了時刻の差です。

252行目で、totalFramesは、エフェクトにかかるフレーム数です。

254~265行目は後述します。

267行目で、'beforeStart'関係のフックを呼びます。

268行目で、options.syncが有効でなければ、このエフェクトをキューに追加します。options.queueの扱いが少々ややこしいですが、options={queue: 'front'}という与えかたと、options.queue = {position: 'front', scope: 'hoge', limit:5}という与えかたが用意されているので、こうなっています。

ループ内不変式をevalで削除する

254~265行目でrenderメソッドを、このstartの中で動的に定義します。このrenderメソッドは、0.015秒ごとに繰り返し繰り返し呼ばれる、いわゆるホットスポットです。そのため最適化のテクニックが使われています。それはループ内不変式の削除と呼ばれるテクニックです。読みやすくすると、本来は以下のような定義をしようとしています。

this.render = function(pos){
  if (this.state=="idle"){
    this.state="running";
    if (this.options['beforeSetupInternal']) this.options['beforeSetupInternal'](this);
    if (this.options['beforeSetup']) this.options['beforeSetup'](this);
    if (this.setup) this.setup();
    if (this.options['afterSetupInternal']) this.options['afterSetupInternal'](this);
    if (this.options['afterSetup']) this.options['afterSetup'](this);
  };
  if (this.state=="running"){
    pos=this.options.transition(pos)* this.fromToDelta + this.options.from;
    this.position=pos;
    if (this.options['beforeUpdateInternal']) this.options['beforeUpdateInternal'](this);
    if (this.options['beforeUpdate']) this.options['beforeUpdate'](this);
    if (this.update) this.update(pos);
    if (this.options['afterUpdateInternal']) this.options['afterUpdateInternal'](this);
    if (this.options['afterUpdate']) this.options['afterUpdate'](this);
  }
};

ここで、"フックがあればフックを実行"というif文が何度もでてきています。このif文の結果はループの中で変化しないとわかっているので、前もって判定してしまって、ループの中から削除することができます。プログラムを動的に書き換えるイメージでしょうか、つまり、renderメソッドについて、フックを呼ぶ定義と呼ばない定義とを、evalを使って、動的に作りわけるのです。evalは文字列のパースや評価をする非常に重い処理なので、使いどころを間違えると余計に遅くなることがありますが、このホットスポットの高速化については、それを差し引いておつりが来ます。フックがないことがわかれば、以下のような、より高速に動く定義を動的に作って、renderに与えることができ、性能を改善できます。

this.render = function(pos){
  if (this.state=="idle"){
    this.state="running";
  };
  if (this.state=="running"){
    pos=this.options.transition(pos)* this.fromToDelta + this.options.from;
    this.position=pos;
    this.update(pos);
  }
};
0272:  loop: function(timePos) {
0273:    if (timePos >= this.startOn) {
0274:      if (timePos >= this.finishOn) {
0275:        this.render(1.0);
0276:        this.cancel();
0277:        this.event('beforeFinish');
0278:        if (this.finish) this.finish(); 
0279:        this.event('afterFinish');
0280:        return;  
0281:      }
0282:      var pos   = (timePos - this.startOn) / this.totalTime,
0283:          frame = (pos * this.totalFrames).round();
0284:      if (frame > this.currentFrame) {
0285:        this.render(pos);
0286:        this.currentFrame = frame;
0287:      }
0288:    }
0289:  },

272~289行目のloopは、Effect.ScopedQueue.loopから呼ばれて、引数の現在時刻に応じて、エフェクトの動作を行う関数です。

274行目で、終了時刻を過ぎていた場合、以下の処理をします。

275行目で、最後の描画をします。

276行目で、エフェクトをキューから除きます。

277行目で、'beforeFinish'関係のフックを呼びます。

278行目で、finishフックをあれば呼びます。

279行目で、'afterFinish'関係のフックを呼びます。

開始時刻後の場合、以下の処理をします。

282行目で、現在時刻と開始時刻の差から、現在の進捗度を割り出します。

283行目で、進捗度から、現在のフレームを割り出します。

284行目で、前回のフレームと差があれば、現在の進捗度で描画をします。

0290:  cancel: function() {
0291:    if (!this.options.sync)
0292:      Effect.Queues.get(Object.isString(this.options.queue) ? 
0293:        'global' : this.options.queue.scope).remove(this);
0294:    this.state = 'finished';
0295:  },

290~295行目のcancelは、キューから自身を取り除いて、エフェクトを終了する関数です。

0296:  event: function(eventName) {
0297:    if (this.options[eventName + 'Internal']) this.options[eventName + 'Internal'](this);
0298:    if (this.options[eventName]) this.options[eventName](this);
0299:  },

296~299行目のeventは、内部的なイベント名('afterFinish'など)を引数にとって、それに応じたフック('afterFinishInternal'、'afterFinish'など)を呼ぶ関数です。

0300:  inspect: function() {
0301:    var data = $H();
0302:    for(property in this)
0303:      if (!Object.isFunction(this[property])) data.set(property, this[property]);
0304:    return '#<Effect:' + data.inspect() + ',options:' + $H(this.options).inspect() + '>';
0305:  }
0306:});
0307:

300~306行目のinspectは、エフェクトのプロパティやオプションの内容を、人間に読みやすく記述する関数です。Prototype.jsのObjectクラスのinspectメソッドに対応します。

Effect.Parallel

Effectクラスでは、ひとつのインスタンスに、ひとつのエフェクトが対応するのが普通ですが、Effect.Parallelでは複数のエフェクトが対応します。これら複数のエフェクトはひとつのオプションを共有して、全く同じ開始時刻やスピードで動きます。

0308:Effect.Parallel = Class.create(Effect.Base, {
0309:  initialize: function(effects) {
0310:    this.effects = effects || [];
0311:    this.start(arguments[1]);
0312:  },

309~312行目のinitializeは、初期化をする関数です。エフェクトの配列と、オプションを引数にとります。

310行目で、エフェクトの配列を受け取って、this.effectsに保存しておきます。

311行目で、2番めの引数に与えられたオプションで、startを呼び出します。

0313:  update: function(position) {
0314:    this.effects.invoke('render', position);
0315:  },

313~315行目のupdateは、renderメソッドから描画のたびに呼ばれる関数です。

314行目で、invokeメソッドを使って、配列のエフェクトのそれぞれのrenderメソッドを、同じポジションで呼び出します。

0316:  finish: function(position) {
0317:    this.effects.each( function(effect) {
0318:      effect.render(1.0);
0319:      effect.cancel();
0320:      effect.event('beforeFinish');
0321:      if (effect.finish) effect.finish(position);
0322:      effect.event('afterFinish');
0323:    });
0324:  }
0325:});
0326:

316~326行目のfinishは、エフェクトの終了時に呼ばれる関数です。

配列のエフェクトのそれぞれに、Effect.Base.loopでエフェクトの終了時に行われているのと全く同じ終了処理を行います。

Effect.Tween

Effect.Tweenは、なんでも好きな関数を渡して、エフェクトとして動かせるクラスです。

0327:Effect.Tween = Class.create(Effect.Base, {
0328:  initialize: function(object, from, to) {
0329:    object = Object.isString(object) ? $(object) : object;
0330:    var args = $A(arguments), method = args.last(), 
0331:      options = args.length == 5 ? args[3] : null;
0332:    this.method = Object.isFunction(method) ? method.bind(object) :
0333:      Object.isFunction(object[method]) ? object[method].bind(object) : 
0334:      function(value) { object[method] = value };
0335:    this.start(Object.extend({ from: from, to: to }, options || { }));
0336:  },

328~336行目のinitializeは、初期化をする関数です。1番めの引数にオブジェクトか要素名、2番めに開始時点のポジション、3番めに終了時点のポジションを取ります。4番めの引数にオプションを、5番めの引数に関数か、1番めの引数のオブジェクトのメソッド名か、プロパティ名を与えることができます。

322行目で、5番めの引数をもとに、updateメソッドで呼ばれる関数を作ります。引数が関数であれば、オブジェクトにbindしておきます。あるいはオブジェクト用のメソッド名であれば、やはりbindします。プロパティ名であれば、そのプロパティの値をポジションで更新する関数を作ります。

0337:  update: function(position) {
0338:    this.method(position);
0339:  }
0340:});
0341:

337~340行目のupdateは、initializeで設定した関数を呼び出す関数です。

Effect.Event

持続時間0で、一瞬呼ばれるだけのエフェクトのクラスです。1番目の引数にオプションをとることができます。例えばキューの最後のエフェクトが終わったタイミングで何か処理を挟みたいときなどに使います。

0342:Effect.Event = Class.create(Effect.Base, {
0343:  initialize: function() {
0344:    this.start(Object.extend({ duration: 0 }, arguments[0] || { }));
0345:  },

343~345行目のinitializeは、初期化をする関数です。持続時間0でstartを呼びます。

0346:  update: Prototype.emptyFunction
0347:});
0348:

346行目で、一応、updateに空の関数を入れてあります。呼ばれることはありません。

Effect.Opacity

要素の透明度が変化するエフェクトのクラスです。デフォルトでは現在の透明度から不透明になるフェードインエフェクトを作ります。

0349:Effect.Opacity = Class.create(Effect.Base, {
0350:  initialize: function(element) {
0351:    this.element = $(element);
0352:    if (!this.element) throw(Effect._elementDoesNotExistError);
0353:    // make this work on IE on elements without 'layout'
0354:    if (Prototype.Browser.IE && (!this.element.currentStyle.hasLayout))
0355:      this.element.setStyle({zoom: 1});
0356:    var options = Object.extend({
0357:      from: this.element.getOpacity() || 0.0,
0358:      to:   1.0
0359:    }, arguments[1] || { });
0360:    this.start(options);
0361:  },

350~361行目のinitializeは初期化をする関数です。2番めの引数にオプションをとることができます。

354行目で、IEのCSSのバグを回避するために、CSSのzoomプロパティを1にします。

356行目で、オプションを読み込みます。デフォルトではfromは現在の透明度(なければ透明⁠⁠、toは不透明になります。

0362:  update: function(position) {
0363:    this.element.setOpacity(position);
0364:  }
0365:});
0366:

362~365行目のupdateは、ポジションに応じて透明度を変化させる関数です。

Effect.Move

要素の位置が変化するエフェクトのクラスです。デフォルトでは幅0で移動する(つまり動きません)エフェクトのインスタンスを作ります。後方互換性のために用意されているEffect.MoveByの実体でもあります。

0367:Effect.Move = Class.create(Effect.Base, {
0368:  initialize: function(element) {
0369:    this.element = $(element);
0370:    if (!this.element) throw(Effect._elementDoesNotExistError);
0371:    var options = Object.extend({
0372:      x:    0,
0373:      y:    0,
0374:      mode: 'relative'
0375:    }, arguments[1] || { });
0376:    this.start(options);
0377:  },

368~377行目のinitializeは、初期化をする関数です。2番めの引数にオプションをとることができます。

371行目で、オプションを読み込みます。デフォルトではx移動幅0、y移動幅0で、"相対座標モード"の設定になっています。

0378:  setup: function() {
0379:    this.element.makePositioned();
0380:    this.originalLeft = parseFloat(this.element.getStyle('left') || '0');
0381:    this.originalTop  = parseFloat(this.element.getStyle('top')  || '0');
0382:    if (this.options.mode == 'absolute') {
0383:      this.options.x = this.options.x - this.originalLeft;
0384:      this.options.y = this.options.y - this.originalTop;
0385:    }
0386:  },

378~386行目のsetupは、要素の位置を動かす前の準備をする関数です。初めてrenderが呼ばれたときにこの関数が呼ばれます。

379行目で、prototype.jsのmakePositionedを呼んで、position: relativeにして、要素の位置移動に備えます。

380,381行目で、現在の位置を取得します。移動の基準点として使います。

382行目で、もし"絶対座標モード"なら、絶対座標を計算します。

0387:  update: function(position) {
0388:    this.element.setStyle({
0389:      left: (this.options.x  * position + this.originalLeft).round() + 'px',
0390:      top:  (this.options.y  * position + this.originalTop).round()  + 'px'
0391:    });
0392:  }
0393:});
0394:

387~393行目のupdateは、ポジションに応じて要素の位置を動かす関数です。CSSのleft、topプロパティの値を変更して、移動を表現します。

0395:// for backwards compatibility
0396:Effect.MoveBy = function(element, toTop, toLeft) {
0397:  return new Effect.Move(element, 
0398:    Object.extend({ x: toLeft, y: toTop }, arguments[3] || { }));
0399:};
0400:

395~400行目のEffect.MoveBy は、後方互換性のためにあるクラスです。移動位置をオプション経由でなく引数で渡すところが違います。

Effect.Scale

要素の表示の大きさが変化するエフェクトのクラスです。終了時の大きさの倍率をパーセントで指定(100で等倍)します。

0401:Effect.Scale = Class.create(Effect.Base, {
0402:  initialize: function(element, percent) {
0403:    this.element = $(element);
0404:    if (!this.element) throw(Effect._elementDoesNotExistError);
0405:    var options = Object.extend({
0406:      scaleX: true,
0407:      scaleY: true,
0408:      scaleContent: true,
0409:      scaleFromCenter: false,
0410:      scaleMode: 'box',        // 'box' or 'contents' or { } with provided values
0411:      scaleFrom: 100.0,
0412:      scaleTo:   percent
0413:    }, arguments[2] || { });
0414:    this.start(options);
0415:  },

402~415行目のinitializeは、初期化をする関数です。2番めの引数で終了時の倍率をとります。3番めの引数にオプションをとることができます。

scaleX
横幅を変えるかどうかです。デフォルトはtrueです。
scaleY
縦幅を変えるかどうかです。デフォルトはtrueです。
scaleContent
内容のテキストの大きさを変えるかどうかです。デフォルトはtrueです。
scaleFromCenter
中心を動かさずに拡大縮小する設定です。デフォルトはfalseです。
scaleMode
デフォルトの'box'は、要素のoffsetHeightoffsetWidthに基づいて大きさを変えます。'content'は、要素のscrollHeightscrollWidthに基づいて大きさを変えます。これらのプロパティの意味についてはリンク先を参照してください。
scaleFrom
大きさの初期倍率です。パーセントで指定します。デフォルトは100(等倍)です。
scaleTo
大きさの目標倍率です。2番めの引数の値が入ります。
0416:  setup: function() {
0417:    this.restoreAfterFinish = this.options.restoreAfterFinish || false;
0418:    this.elementPositioning = this.element.getStyle('position');
0419:    
0420:    this.originalStyle = { };
0421:    ['top','left','width','height','fontSize'].each( function(k) {
0422:      this.originalStyle[k] = this.element.style[k];
0423:    }.bind(this));
0424:      
0425:    this.originalTop  = this.element.offsetTop;
0426:    this.originalLeft = this.element.offsetLeft;
0427:    
0428:    var fontSize = this.element.getStyle('font-size') || '100%';
0429:    ['em','px','%','pt'].each( function(fontSizeType) {
0430:      if (fontSize.indexOf(fontSizeType)>0) {
0431:        this.fontSize     = parseFloat(fontSize);
0432:        this.fontSizeType = fontSizeType;
0433:      }
0434:    }.bind(this));
0435:    
0436:    this.factor = (this.options.scaleTo - this.options.scaleFrom)/100;
0437:    
0438:    this.dims = null;
0439:    if (this.options.scaleMode=='box')
0440:      this.dims = [this.element.offsetHeight, this.element.offsetWidth];
0441:    if (/^content/.test(this.options.scaleMode))
0442:      this.dims = [this.element.scrollHeight, this.element.scrollWidth];
0443:    if (!this.dims)
0444:      this.dims = [this.options.scaleMode.originalHeight,
0445:                   this.options.scaleMode.originalWidth];
0446:  },

416~446行目のsetupは、要素の大きさを変える準備をする関数です。

417行目で、options.restoreAfterFinishは、終了時に要素の大きさを元に戻すオプションです。

418行目で、elementPositioningに、要素のCSSの'position'プロパティの値を取りだします。

421~426行目で、終了後に元に戻せるように、元のCSSのプロパティの値をいくつか保存しておきます。

428~434行目で、CSSの'font-size'プロパティの値を取得します。このとき、'font-size'の単位も調べる必要があるので、indexOfを使って単位を探しています。

436行目で、factorは、倍率の変化量です。正なら拡大、負なら縮小を意味します。

438~445行目で、options.scaleModeに応じて、幅と高さを取得します。

439行目で、'box'なら、要素のoffsetHeightoffsetWidthに基づいて大きさを変えます。

441行目で、'content'なら、要素のscrollHeightscrollWidthに基づいて大きさを変えます。これらのプロパティの意味についてはリンク先を参照してください。

443行目で、options.scaleModeが'box'でも'content'でもなければ、scaleMode: { originalHeight: 400, originalWidth: 200 }の形でオプションを与えられたと見込んで読み込みます。

0447:  update: function(position) {
0448:    var currentScale = (this.options.scaleFrom/100.0) + (this.factor * position);
0449:    if (this.options.scaleContent && this.fontSize)
0450:      this.element.setStyle({fontSize: this.fontSize * currentScale + this.fontSizeType });
0451:    this.setDimensions(this.dims[0] * currentScale, this.dims[1] * currentScale);
0452:  },

447~452行目のupdateは、ポジションに応じて要素の大きさを変える関数です。

448行目で、現在の倍率をポジションから計算します。

449行目で、options.scaleContentが有効で、fontSizeの取得に成功していたら、内容のテキストの大きさをフォントサイズによって変えます。CSSのfontSizeプロパティの値を変えます。setContentZoomを使わないのは、高速化のためでしょう。

451行目で、後述のsetDimensions関数を呼びます。現在の倍率の幅と高さを渡します。

0453:  finish: function(position) {
0454:    if (this.restoreAfterFinish) this.element.setStyle(this.originalStyle);
0455:  },

453~455行目のfinishは、エフェクトの終了時に呼ばれる関数です。

454行目で、大きさを元に戻すオプションがあった場合は、前もって保存してあったCSSの値に戻します。

0456:  setDimensions: function(height, width) {
0457:    var d = { };
0458:    if (this.options.scaleX) d.width = width.round() + 'px';
0459:    if (this.options.scaleY) d.height = height.round() + 'px';
0460:    if (this.options.scaleFromCenter) {
0461:      var topd  = (height - this.dims[0])/2;
0462:      var leftd = (width  - this.dims[1])/2;
0463:      if (this.elementPositioning == 'absolute') {
0464:        if (this.options.scaleY) d.top = this.originalTop-topd + 'px';
0465:        if (this.options.scaleX) d.left = this.originalLeft-leftd + 'px';
0466:      } else {
0467:        if (this.options.scaleY) d.top = -topd + 'px';
0468:        if (this.options.scaleX) d.left = -leftd + 'px';
0469:      }
0470:    }
0471:    this.element.setStyle(d);
0472:  }
0473:});
0474:

456~474行目のsetDimensionsは、幅と高さを受け取って、実際に要素の大きさを変える関数です。

458行目で、options.scaleXが有効なら、幅を変更します。

459行目で、options.scaleYが有効なら、高さを変更します。

460行目で、options.scaleFromCenterが有効なら、幅や高さの変化に応じて座標を変更します。具体的には、サイズを変化させるのに、変化量の半分を座標の移動によってまかなうことになります(ここでthis.dimsには元のサイズが入っています⁠⁠。

461、462行目で、topd、leftdのdは、位置の移動量のデルタを意味しています。

464、465行目で、elementPositioningに入った、要素のCSSの'position'プロパティの値に応じて、'abusolute'ならば座標を絶対座標に直します。

467、468行目で、相対座標であれば変化量を加えるだけですみます。

471行目で、以上の計算を実際にCSSに反映します。

Effect.Highlight

要素の背景色が変化するエフェクトのクラスです。デフォルトでは明るい黄色から元の背景色(なければ白)に変化するハイライトエフェクトを作ります。

0475:Effect.Highlight = Class.create(Effect.Base, {
0476:  initialize: function(element) {
0477:    this.element = $(element);
0478:    if (!this.element) throw(Effect._elementDoesNotExistError);
0479:    var options = Object.extend({ startcolor: '#ffff99' }, arguments[1] || { });
0480:    this.start(options);
0481:  },

476~481行目のinitializeは、初期化をする関数です。

479行目で、options.startcolorのデフォルト設定を明るい黄色にします。

0482:  setup: function() {
0483:    // Prevent executing on elements not in the layout flow
0484:    if (this.element.getStyle('display')=='none') { this.cancel(); return; }
0485:    // Disable background image during the effect
0486:    this.oldStyle = { };
0487:    if (!this.options.keepBackgroundImage) {
0488:      this.oldStyle.backgroundImage = this.element.getStyle('background-image');
0489:      this.element.setStyle({backgroundImage: 'none'});
0490:    }
0491:    if (!this.options.endcolor)
0492:      this.options.endcolor = this.element.getStyle('background-color').parseColor('#ffffff');
0493:    if (!this.options.restorecolor)
0494:      this.options.restorecolor = this.element.getStyle('background-color');
0495:    // init color calculations
0496:    this._base  = $R(0,2).map(function(i){ return parseInt(this.options.startcolor.slice(i*2+1,i*2+3),16) }.bind(this));
0497:    this._delta = $R(0,2).map(function(i){ return parseInt(this.options.endcolor.slice(i*2+1,i*2+3),16)-this._base[i] }.bind(this));
0498:  },

482~498行目のsetupは、背景色を変える準備をする関数です。

484行目で、要素が表示されていなかったらエフェクトを中止します。

487行目で、options.keepBackgroundImageが設定されていない場合は、背景画像を無しにします。終了時に元に戻せるよう保存しておきます

491行目で、options.endcolorが設定されていない場合は、元の背景色を取得します。parseColorの機能で、読み取りに失敗した場合は1番めの引数の値が使われるというものがあります。よって元の背景色の取得に失敗すると白('#ffffff')が設定されます。

493行目で、options.restorecolorが設定されていない場合は、元の背景色に戻すように設定します。

496行目の、_baseは、3原色RGBのそれぞれの初期値(0~255)が入った配列です。[255,128,200]という形です。

497行目の、_deltaは、3原色RGBのそれぞれの変化量が入った配列です。[-8,5,-16]という形です。

0499:  update: function(position) {
0500:    this.element.setStyle({backgroundColor: $R(0,2).inject('#',function(m,v,i){
0501:      return m+((this._base[i]+(this._delta[i]*position)).round().toColorPart()); }.bind(this)) });
0502:  },

499~502行目のupdateは、ポジションに応じて背景色を変化させます。

500行目で、CSSのbackground-colorプロパティに値を"#xxxxxx"と設定します。ここでinjectメソッドが使われています。このinjectは、3原色RGBの初期値と変化量の配列から現在の値を計算して、"#xxxxxx"の形式にして返しています。

0503:  finish: function() {
0504:    this.element.setStyle(Object.extend(this.oldStyle, {
0505:      backgroundColor: this.options.restorecolor
0506:    }));
0507:  }
0508:});
0509:

503~509行目のfinishは終了時に呼ばれる関数です。

504行目で、背景イメージや背景色の値を元に戻します。

Effect.ScrollTo

要素に向かってメインウィンドウをスクロールするエフェクトのクラスです。

0510:Effect.ScrollTo = function(element) {
0511:  var options = arguments[1] || { },
0512:    scrollOffsets = document.viewport.getScrollOffsets(),
0513:    elementOffsets = $(element).cumulativeOffset(),
0514:    max = (window.height || document.body.scrollHeight) - document.viewport.getHeight();  
0515:
0516:  if (options.offset) elementOffsets[1] += options.offset;
0517:
0518:  return new Effect.Tween(null,
0519:    scrollOffsets.top,
0520:    elementOffsets[1] > max ? max : elementOffsets[1],
0521:    options,
0522:    function(p){ scrollTo(scrollOffsets.left, p.round()) }
0523:  );
0524:};
0525:

512行目で、prototype.jsの1.6で入った、document.viewport.getScrollOffsetsメソッドで、現在のビューポートのオフセットを取得します。

513行目で、Element.cumulativeOffsetメソッドで、要素のドキュメントの左上からのオフセット位置を取得します。

514行目で、スクロール量の最大値を求めます。このコードは1月28日のリビジョン8686で改訂されているので注意してください。

516行目で、要素のオフセット位置にoptions.offsetの値を加えます。

518行目で、Effect.Tweenを生成して即席のスクロールエフェクトを作っています。

522行目で、ポジションに応じて徐々にスクロールする関数を与えます。

おすすめ記事

記事・ニュース一覧