画面をスクロールした時に、下から要素が現れたり、
その要素が画面の上部にたどり着いた時に何らかの処理をする、
という事をする時がたまにあります。
この処理をカッコ良く書くにはどのように書けばいいだろうかと思い、
色々と試してみました。
もっと正解はあるかとは思いますが、
僕のレベルとしては割りとシュッとしたコードが書けたんじゃないかなと思っています。
目標としては、
- 汎用性のあるコード
- 管理しやすいコード
- グローバル変数をできるだけ少なく
- メモリ消費を少なく、処理をできるだけ軽く
こんな感じの目標を立てて考えてみました。
作成するページ
作成するのは、下記のサンプルページです。
大きいブロックが縦にならんでいて、
各ブロックの位置の状態を、指定のリスト要素に表示していく、
というページです。
準備(HTML、CSSの準備)
HTMLには、空のリストと空のブロックを用意しておきます。
ブロックとリストを関連付けるため 、div要素にオリジナルのdata-target属性を持たせるようにしました。
最後にjsファイルを読み込ませます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <body>   <ul>     <li id="text-1"></li>     <li id="text-2"></li>     <li id="text-3"></li>     <li id="text-4"></li>     <li id="text-5"></li>     <li id="text-6"></li>     <li id="text-7"></li>     <li id="text-8"></li>     <li id="text-9"></li>     <li id="text-10"></li>   </ul>   <div id="box-1" data-target="text-1" class="box"></div>   <div id="box-2" data-target="text-2" class="box"></div>   <div id="box-3" data-target="text-3" class="box"></div>   <div id="box-4" data-target="text-4" class="box"></div>   <div id="box-5" data-target="text-5" class="box"></div>   <div id="box-6" data-target="text-6" class="box"></div>   <div id="box-7" data-target="text-7" class="box"></div>   <div id="box-8" data-target="text-8" class="box"></div>   <div id="box-9" data-target="text-9" class="box"></div>   <div id="box-10" data-target="text-10" class="box"></div>   <script src="sample.js" type="text/javascript"></script> </body> | 
CSSは適当です。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |   body {     margin: 0;     padding: 0;   }   ul {     position: fixed;     top: 0;     left: 0;     width: 300px;     margin: 0;     padding: 0;     background: #fff;     border: 1px solid #000;     list-style: none;     font-weight: bold;   }   li {     padding: 10px;     border: 1px solid #000;   }   #text-1,   #text-7{     border-left: 50px solid #f00;   }   #text-2,   #text-8{     border-left: 50px solid #0f0;   }   #text-3,   #text-9{     border-left: 50px solid #00f;   }   #text-4,   #text-10{     border-left: 50px solid #ff0;   }   #text-5 {     border-left: 50px solid #0ff;   }   #text-6 {     border-left: 50px solid #f0f;   }   .box {     height: 600px;   }   #box-1,   #box-7{     background: #f00;   }   #box-2,   #box-8 {     background: #0f0;   }   #box-3,   #box-9 {     background: #00f;   }   #box-4,   #box-10 {     background: #ff0;   }   #box-5 {     background: #0ff;   }   #box-6 {     background: #f0f;   } | 
実験(JavaScript)
まず、スクロール量を測る要素の取得と、各ブロック(.box)の取得、
状態を表す文言の設定をしておきます。
スクロール量を測る要素の取得は、先日書いた記事「【JavaScript】Chromeの最新版(61)から画面スクロール量を取得する要素がdocument.bodyじゃなくてdocument.documentElementに変わっていた事を知った。」のコードを利用します。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | var scrollElm = (function() {   if('scrollingElement' in document) {     return document.scrollingElement;   }   if(navigator.userAgent.indexOf('WebKit') != -1) {     return document.body;   }   return document.documentElement; })(); var boxes = document.querySelectorAll('.box'); var text = ['下にいます', 'ハローワールド', '気持ちいい', '嗚呼…']; | 
これ以降の処理を色々試してみます。
カッコ良いコードにたどり着くために、まずは阿呆っぽいコードから書いてみる所から始めてみました。
不細工なコードで書いてみる
思いつくままに、時に荒々しく、普段なら直感的に避けるような書き方もあえてしてみます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | var changeStatus = function() {   var scroll = scrollElm.scrollTop;   for(var i = 0; boxes.length > i; i++) {     var status;     var pos = boxes[i].offsetTop;     var limit = pos + boxes[i].offsetHeight;     var target = document.getElementById(boxes[i].getAttribute('data-target'));     if(pos > scroll && pos <= (scroll + window.innerHeight)) {       status = 1;     } else     if(pos <= scroll && limit > scroll) {       status = 2;     } else     if(limit <= scroll) {       status = 3;     } else {       status = 0;     }     target.innerHTML = text[status];   } } window.addEventListener('load', changeStatus); window.addEventListener('scroll', changeStatus); | 
イベントリスナー用の関数を1つ作っただけですが、
スクロールイベントが発生する度にこの関数が実行されます。
サンプルページは、中身の無い軽いページなので、見た目上は問題なく動きますが、
大きいブロックを100個、リストを100個にして試してみたら、スクロールの動作がかなりガクガクしました。
デベロッパーツールでPerformanceを見ても、メモリのグラフが激しく動いていました。
上のコードで、大きく問題がありそうなのは、
7行目の
			var target = document.getElementById(boxes[i].getAttribute('data-target'));
で、毎回getElementByIdメソッドを動かしている所と、
19行目の
			target.innerHTML = text[status];
で、毎回テキストを書き換えている所です。
スクロールイベントの発生数×ブロックの要素数の数なので、すごい回数です。
この2箇所の問題をクリアするために、各要素の情報・状態を保存しておく事を考えた方が良さそうです。
オブジェクトを使ってみる
オブジェクトに各要素の情報を保存して、
イベント発生時に情報を更新するようにしてみました。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | var box = []; for(var i = 0; boxes.length > i; i++) {   box[i] = {     prev: -1,     pos: boxes[i].offsetTop,     limit: boxes[i].offsetTop + boxes[i].offsetHeight,     target: document.getElementById(boxes[i].getAttribute('data-target'))   } } var changeStatus = function() {   var scroll = scrollElm.scrollTop;   for(var i = 0; boxes.length > i; i++) {     var status = 0;     if(box[i].pos > scroll && box[i].pos <= (scroll + window.innerHeight)) {       status = 1;     } else     if(box[i].pos <= scroll && box[i].limit > scroll) {       status = 2;     } else     if(box[i].limit <= scroll) {       status = 3;     }     if(status != box[i].prev) {       box[i].target.innerHTML = text[status];     }     box[i].prev = status;   } } window.addEventListener('load', changeStatus); window.addEventListener('scroll', changeStatus); | 
デベロッパーツールのPerformanceで見てみると、
驚くほど軽くなりました。
処理が重かった原因は、ほとんどが .innerHTML = text[status]; の所だったようです。
オブジェクトのprevプロパティに、前回のstatusを保存しておき、
前回と今回のstatusが違う場合のみ、テキストを書き換えるようにしました。
このような判定はスクロールイベントで何かする時には必ずやるような事ですが、
やはりとても大事な処理なんだなと実感しました。
かなり軽くなったので、正直もうこれで完成形で良さそうなぐらいです。
ただ、if文が並んでる感じと、
box[i]が氾濫している感じが、見た目的に気に入りません。
処理的にはとりあえず問題無さそうですが、自己満足のためにもう少し手を加えてみます。
クロージャを使ってみる
目標である、グローバル変数の節約と、変数を保存しておくというのは、まさにクロージャが得意そうな事なので、
これをクロージャに置き換えて書いてみました。
又、if文よりswitch文の方が速いという情報がありましたので、switch文に置き換えてみました。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | var box = []; for(var i = 0; boxes.length > i; i++) {   box[i] = boxFunc(boxes[i]);   box[i](scrollElm.scrollTop); } function boxFunc(elm) {   var status, prev;   var pos = elm.offsetTop;   var limit = pos + elm.offsetHeight;   var target = document.getElementById(elm.getAttribute('data-target'));   return function(scroll) {     switch(true) {       case pos > scroll && pos <= (scroll + window.innerHeight):         status = 1;         break;       case pos <= scroll && limit > scroll:         status = 2;         break;       case limit <= scroll:         status = 3;         break;       default:         status = 0;     }     if(status != prev) {       target.innerHTML = text[status];     }     prev = status;   } }; window.addEventListener('scroll', function() {   var scroll = scrollElm.scrollTop;   for(var i = 0; boxes.length > i; i++) {     box[i](scroll);   } }); | 
クロージャにした事によって、イベントリスナーの関数と情報を更新する関数が分離されたので、
ロードイベントに指定していた処理を、最初のクロージャを代入するループの中でついでに行う事ができるようになりました。
ロードイベントとは実行のタイミングが変わってしまいますが、今回の場合は実際のサイト作成時を想定しても、大きな問題にはならなさそうなので、良しとしておきます。
この事によって、グローバル変数を1つ減らす事に成功しました。
又、変数と関数がboxFunc内でまとまっているので、わかりやすくなったような気がします。
ただ、オブジェクトを使用した時に比べてコードの行数は増えてますし、処理速度が上がっている事もありません。
switch文にした事で読みやすくはなりましたが、僕の予想では、このswitch文の使い方の場合、if文より速いという事は無さそうな気がします(勝手な予想です)。
まとめ
とりあえず、最後のクロージャでの書き方が、僕の中での最終形態になったのですが、
上級者の方はどんな書き方をするのでしょうか。
スクロールイベント毎にループを回している所にモヤモヤするのですが、
汎用性を考えた場合、このやり方しか思いつきませんでした。
もっと良い書き方があれば、優しく教えていただけると大変喜びます。
今回、クロージャを上手い事活用する事が目的で実験を始めたのですが、
			target.innerHTML = text[status];
を、
			if(status != prev) { target.innerHTML = text[status]; }
にした時の変化がもの凄く大きく、
スクロールイベントで何かをする時には、フラグや状態を判定して、変更があるものだけを処理する、
という事の大事さが一番身にしみました。