jquery javascript 投稿日:2017/09/13

某サイトに見る狂ったjQueryのソースと高速化・改善方法


自宅の光回線が遅すぎて乗り換えを検討していた矢先、Ymobileのキャンペーンページがやけにもっさりしているので、思わずソースを覗いてしまった。一流企業のソースはさぞかしモダンなのだろうと思ったら、jQueryで鬼のDOMいじり。思わず閉口してしまったので同じ轍を踏まないために改善方法を考えます。




経緯

我が家で契約しているSo-net光という回線が常時1Mbps/s以下という稀に見るクソっぷりなので、違約金を払ってでも回線を変更したいと考えていました。

まずなぜSo-net光にしたかと言うと、全ての業者と契約パターンの中で、最も運用費が安かったからです。キャッシュバックが56,000円・月額費用が2,800円・初月無料だったので21ヶ月使ってようやく料金が発生し始めるという計算です。ちなみに違約金は3万円ほどになる予定です。

そこで乗り換え違約金全額キャッシュバックのSoftBank光に目をつけたのですが、そういえば自分のスマホYmobileだったな~と思い、Ymobileサイトのキャンペーンを確認していたら、それを見つけてしまいました。

バッドノウハウがギッシリ詰まったjQueryの巨大なソース!!!

Ymobileサイトのナビゲーション管理javascript
http://www.ymobile.jp/common_files/js/side_navi.js
ちなみに僕が見たキャンペーンページ
http://www.ymobile.jp/sp/zukyun_natsumatsuri/

上記リンクのjsを確認していただいたら分かると思いますが、かたくなにDOMアクセスを繰り返すという地獄絵図が広がっています!DOMツリーの再探索、DOM変更によるリフロー、リペイントが隙間なく1,000行に渡って行われています!やりたい放題です!

まるで僕が初めてjQueryでSPAを作ったときのようです。我流で一生懸命作りましたが、先生がいるわけではないので悪習慣が自覚できません。後にネットで知識を蓄えると恥ずかしさで書き直そうと思いましたが、そんなソースはメンテできるわけがありませんよね!($.ajaxとか普通にネストしまくってましたからね!)

ではどうすれば改善するのか、今すぐ実践できることから指摘していきたいと思います。

今すぐできるjQueryのパフォーマンス改善

Ymobileサイトのバッドノウハウから改善案を提案します。

jQueryの記法以前に根本的なことも指摘します。

できるだけ新しいバージョンのjQueryを使いましょう

読み込まれているjQueryが古すぎるようです。

jQuery JavaScript Library v1.4.4

昔に作ったのかもしれませんが、少なくとも1系ならば1.12.4を使いましょう。
IE9以下を無視できる要件であれば3.0以降を使いましょう。

jQueryはCDNを読み込みましょう

jQueryはほとんどのサイトで読み込まれています。
みんなで足並み揃えてCDNを利用すると、クライアントは一々サイト毎にjQueryをダウンロードする必要がなくなり、転送量の節約になり、表示の高速化に繋がり、インターネット全体が良くなります。

例えばGoogleなどがCDNを公開していますのでぜひ利用しましょう。

CDNが信用出来ない場合でも、CDNが読み込めない場合のみローカルを読み込む指定も可能です。

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script>
window.jQuery || document.write(‘<script src="js/jquery.min.js"><\/script>’)
</script>

scriptタグはbody要素の最後に配置しましょう

Ymobileサイトではhead要素内にscriptを記述しているようです。
scriptタグを読み込んでいる間は、ブラウザは描画を行うことが出来ません。
scriptをbody要素の末尾付近に記述することでDOMの描画を先に行うことが出来ます。
日常的にscriptは実DOM最後尾の後に配置するようにしましょう。

cssもDOMの描画を阻害しますが、デザイン用のcssはheadで読み込んで下さい。そうでないとcssが適用されていないデザインが一瞬表示されてしまいます。

描画や動作に関わらないscriptはasync属性をつけて非同期で読み込むと体感速度が上がります。広告系や測定系など描画に関わらないscriptはasync属性をつけましょう。

ここまでは根本的な指摘でした。
以下からソース自体への指摘になります。

”$(function(){});“は何度も書く必要はありません。

$(function(){

});

上記の記述が少なくとも2回出現しています。
出来るなら一つにまとめましょう。

変数のスコープを分けたいのであれば、さらにメソッドに切り出してください。

var func1 = function(){
    // 関数宣言
};
var func2 = (function(){
    // 即時実行関数
})();
var app = {
    init : function(){
        // app初期設定
    },
    view : function(){
        // app表示処理
    }
};

ちなみに$();の実態はdocument.addEventListener("DOMContentLoaded", function(event) {});です。たまに$(window).load()を使っている人がいますが、イベントの発火タイミングが全く異なります。大抵の場合はDOMContentLodedを使うべきです。$(window).load()は外部メディアのダウンロードを待ちます。誤用があまりにも多すぎてMozzilaも呆れているほどです。

同じDOMを2回以上参照する場合はできるだけ変数にキャッシュしましょう。

$('div.cate_plan').css({'marginLeft':-win_width,height:'310px',opacity:1});
$('div.cate_plan').show();
$('div.cate_plan #column1-1').css({'marginTop':'0px',opacity:1});
$('div.cate_plan #column2-1').css({'marginTop':'0px',opacity:1});
$('div.cate_plan #column3-1').css({'marginTop':'0px',opacity:1});
$('div.cate_plan #column4-1').css({'marginTop':'0px',opacity:1});

上記では6回のDOMツリー全探索が走ります。
変数にキャッシュすることでその負担を減らすことが出来ます。

var $cate_plan = $('div.cate_plan');
$cate_plan.css({'marginLeft':-win_width,height:'310px',opacity:1});
$cate_plan.show();
$cate_plan.find('#column1-1').css({'marginTop':'0px',opacity:1});
$cate_plan.find('#column2-1').css({'marginTop':'0px',opacity:1});
$cate_plan.find('#column3-1').css({'marginTop':'0px',opacity:1});
$cate_plan.find('#column4-1').css({'marginTop':'0px',opacity:1});

2回以上参照するなら必ず変数にキャッシュするようにしましょう。

DOMのcssプロパティを直接変更しないようにしましょう。

$('div.compact_head').css('top','-55px');

上記ではjavascriptから直接cssをいじっています。位置や大きさに影響を与える操作はリフロー(レイアウトとも言います)が発生します。ブラウザが一からレイアウトを計算し直すのです。高負荷な処理ですので避けられるなら避けましょう。

こういう場合はできるだけclassで解決します。

// classを付加する例
$('div.compact_head').addClass('compact_head--active');
// これもよくやります
$('div.compact_head').attr('style', {'top':'-55px'});

イベントのバインドには.onメソッドを使いましょう

$('div.box div.all_view').click(function(){
    $(this).parent('div').find('dl.hide').find('dt').fadeIn('fast');
    $(this).parent('div').find('dl.hide').find('dd').fadeIn('fast');
    //$(this).hide();
});

.on()はjQuery1.7から追加されています(参考}
.click().hover()に代わる最も実用的なイベントのバインド手段です。

いくつかの点で上位互換なので必ず.on()を使うようにしましょう。

$('div.box').on('click',' div.all_view',function(){
    $(this).parent('div').find('dl.hide').find('dt').fadeIn('fast');
    $(this).parent('div').find('dl.hide').find('dd').fadeIn('fast');
    //$(this).hide();
});

.click()にできなくて.on()にできること。

  • 後から追加されたDOMにも反応する。参考
  • 複数のイベントを同時に定義できる。参考
  • 名前空間を宣言できる。イベントの解除を行う場合に必須。参考

booleanには厳密な比較を用いましょう。

jQueryの指摘ではないですが、javascriptにおける比較演算子「==」と「===」は全く挙動が異なります。

//もっと見るボタン
if($('div.box dl').hasClass('hide') == true){
    $('div.box dl.hide').find('dt').eq(4).nextAll('dt').hide();
    $('div.box dl.hide').find('dd').eq(4).nextAll('dd').hide();
} else {
}

if($('div.box dl').hasClass('hide') == true)としていますが、.hasClass()メソッドはbooleanを返します。

==での評価は型変換を伴います。===での評価は型も評価に含みます。booleanの比較では===を用いましょう。

例えば==を用いてbooleanを評価してしまうと、1 == truetrueと評価されます。

1 ==   2     // false
1 ==  "1"    // true
1 ==  '1'    // true
1 ==  true   // true
0 ==  false  // true

一方===を用いた評価では、以下のようになります。

3 === 3     // true
3 === '3'   // false
1 === true  // false

trueを評価したい場面では1 == trueでは厳密な評価ができません。バグの温床となりますので比較演算子の選択には気を使いましょう。

MDN : 比較演算子
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Comparison_Operators

セレクタの要素名は省略しましょう。

$('div.cate_plan').css('display','none');
$('div.cate_lineup').css('display','none');
$('div.cate_service').css('display','none');
$('div.cate_support').css('display','none');

上のコードのdivの部分は不要です。速度低下の原因となりますので、$('.cate_plan')としましょう。もし本当にdivだけ取得したいのであれば、class設計を見直すべきです。

直感的には「divの中から.cate_planを探す」と思いがちですが、実際の挙動は「.cate_planを探した後、再びdivを探索する」となります。

それだけでなく、$('div.cate_plan')の場合は内部的にquerySelectorAllが使われますが、$('.cate_plan')のだとgetElementsByClassNameが使われます(sizzle.jsの実装)

速度が5倍~1,000倍ほど変わるので注意しましょう。

https://jsperf.com/でのセレクタ速度の測定結果
https://jsperf.com/でのセレクタ速度の測定結果

また、上記の例ではcssプロパティを直接操作していますので、.addClass()を使うか少なくとも.style()を使いましょう。

scrollイベントでDOMを変更しない

元ソースは長いですが読まなくていいです。

$(window).scroll(function () {
    var scroll_object = $('div.compact_head');
    var window_position = $(document).scrollTop();
    var current = document.activeElement;
    var display_get = scroll_object.css('top');
    //スクロール量が100より大きい時に実行
    if ($(this).scrollTop() >= 100) {
        //FFはスクロール量を複数回取得する為、スクロール時にコンパクトナビが表示されていない場合に実行
        if (display_get == '-55px') {
            $('div.compact_head').css('display', 'block');
            if (!scroll_object.is(':animated')) { scroll_object.animate({ opacity: 1, top: 0 }, 200, function () { if (current.id == 'search01') { $('div.compact_head dd.search:first input').focus(); } }); };
        }
    } else {
        //スクロール量が100以下の時に実行
        if (!scroll_object.is(':animated')) { scroll_object.animate({ opacity: 0, top: -55 }, 200, function () { if (current.id == 'search02') { $('div#header:first dd.search:first input').focus(); } }); };
        $('div#slide_back').stop(true, true).animate({ opacity: 0, height: 0 }, 300);
    }
    //スクロール時のグロナビは非表示
    $('div#slide_back').animate({
        opacity: 0,
        height: '0px'
    }, 200, function () {
        $('div#slide div#column1-1,div#slide div#column1-2,div#slide div#column1-3,div#slide div#column1-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100);
        $('div#slide div#column2-1,div#slide div#column2-2,div#slide div#column2-3,div#slide div#column2-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100);
        $('div#slide div#column3-1,div#slide div#column3-2,div#slide div#column3-3,div#slide div#column3-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100);
        $('div#slide div#column5-1,div#slide div#column5-2,div#slide div#column5-3,div#slide div#column5-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100);
        $('div#slide div#column6-1,div#slide div#column6-2,div#slide div#column6-3,div#slide div#column6-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100);
        $('div#slide div#column4-1,div#slide div#column4-2,div#slide div#column4-3,div#slide div#column4-4').css({
            'marginTop': '-20px',
            opacity: 0
        }, 100, function () { $('div#slide').hide(); });
    });
    $('div.cate_plan').css('display', 'none');
    $('div.cate_lineup').css('display', 'none');
    $('div.cate_service').css('display', 'none');
    $('div.cate_support').css('display', 'none');
    $('ul.g_navi li').removeClass('active_hover');
    if (window_position == 0) {
        scroll_object.animate({ opacity: 0, top: -55 }, 200, function () { if (current.id == 'search02') { $('div#header:first dd.search:first input').focus(); } });
        $('div#slide_back').stop(true, true).animate({ opacity: 0, height: 0 }, 300);
    };
});

今回最も負荷の高い狂ってる部分となります。

scrollイベントは1スクロールごとに発火します。適当にマウスホイールを回すと10回は発生するので、resizeなどと比較しても全イベントの中で最も超高頻度なイベントです。

そんなイベントの中でDOMのスタイルをいじったり、DOMの位置や高さを計算したり、DOMの追加や削除を行うとどうなるか分かりますね?先程説明した通り、リフロー地獄となり死に至ります

こう言う場合に取り得る選択肢は3つです。

1. debounce : 一定時間の間は処理を行わない

「debounce」とは、同じ処理を一定時間の間行わないようにするという実装です。

// 何ms毎に処理を行うか
var interval = 500;
var timer;
$(window).on('scroll',function() {
    clearTimeout(timer);
    timer = setTimeout(function() {
        //処理内容
    }, interval);
});

こうすることで0.5sごとにしか処理が行われなくなりますので、かなりの節約が可能です。
手軽ですが、intervalの値を100とか小さくしすぎると結局高頻度になり機能しなくなります。
場合によってはif文でのフラグ処理を追加したほうが確実です。

2. throttle : 一定時間経過するまで処理を行わない

「throttle」とは一定時間経過しないと処理を行わないという実装です。

// 何ms毎に処理を行うか
var interval = 500;
// 最後に実行した時間
var lastTime = new Date().getTime() - interval;
$(window).on('scroll',function() {
    if ((lastTime + interval) <= new Date().getTime()) {
        lastTime = new Date().getTime();
        //処理内容
    }
});

少し複雑ですが、コピペで使えます。Global変数がウザい場合は関数化&クロージャにして$(window).on('scroll',throttle);みたいに使って下さい。

var throttle = (function(){
    // 何ms毎に処理を行うか
    var interval = 500;
    // 最後に実行した時間
    var lastTime = new Date().getTime() - interval;
    return function(){
        if ((lastTime + interval) <= new Date().getTime()) {
            lastTime = new Date().getTime();
            console.log(lastTime);
        }
    };
})();
$(window).on('scroll',throttle);

3. observerとかrequestAnimationFrameとか

まだブラウザが対応していませんし、私も使ったことがないのでコードは割愛します。

MDN : Intersection Observer API
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
MDN : window.requestAnimationFrame()
https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame

$.animate()は重いので代替手段としてCSSか別ライブラリを使いましょう。

$('div#header:first dd.search:first').animate({opacity:1});
$('div#header:first dd.search:first input').animate({width:'120px'});
$('div.compact_head dd.search:first').animate({opacity:1});
$('div.compact_head dd.search:first input').animate({width:'120px'});

CSSアニメーションのほうが軽い場合が多いです。

transition-durationプロパティの指定だけで疑似アニメーションが行なえます。

a {
    transition-duration:0.5s;
    opacity:1;
}
a:hover {
    opacity:0.65;
    /* 0.5秒かけて透明度が65%に変化する */
}

@keyframesを用いると完全なアニメーションを表現できます。

/* 文字列がブラウザのウィンドウを横切る */
p {
  animation-duration: 3s;
  animation-name: slidein;
}
@keyframes slidein {
  from {
    margin-left: 100%;
    width: 300%
  }

  to {
    margin-left: 0%;
    width: 100%;
  }
}
https://developer.mozilla.org/ja/docs/Web/CSS/@keyframes
MDN : CSS アニメーション

しかし、CSSだけではアニメーションのコールバックなどが行えず柔軟性に欠けますので、javascriptに頼る機会は多いです。その際は別ライブラリを検討してみて下さい。

Qiita : アニメーション最強のVelocity.jsの使い方
http://qiita.com/kyota/items/754e0e6cb7a144eda850
GreenSock: GSAP, the standard for JavaScript HTML5 animation
https://greensock.com/

終わりに

本当はまだまだあるのですが、よく見たらjQuery1.4時代の化石のようですし、まぁメンテしてないのは悪いですが、このくらいにしてあげましょう。

幸いにもDOMの追加や削除が無かったのでこれくらいで済みましたが、それらの指摘もするとなると・・・3記事くらいに分割しないと書ききれませんね。というか書くのに1週間くらいかかります。

指摘しながら自分でもサボってる部分を反省しつつ、良いTipsとなりました。

回線の乗り換えを検討していただけなのに、どうしてこうなった(汗

comments powered by Disqus