一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.comホテルページのスマホ版からjQuery依存を取り除くためにやったこと

f:id:ryo-utsunomiya:20190117125131p:plain

一休.comでWebフロントエンドを開発している宇都宮です。

先日、一休.comホテルページのスマホ版から、jQueryを取り除きました。jQueryを取り除いた経緯、やったこと、結果について書きます。

ちなみに、ホテルページには以下のURLでアクセスできます(スマホで開くか、PCの場合はUAをスマホに偽装する必要があります)

https://www.ikyu.com/sd/00001290/

なぜjQueryを取り除いたのか?

JavaScriptサイズの削減のためです。一休.comホテルページは、以前は合計で約300KBのjsファイルを読み込んでいました(300KBはgzip後の転送量なので、実ファイルはもっと大きいです)。

よくいわれる「jsは170KB以内ルール」は、回線速度のベースラインが400Kbpsという前提1です。一休.comの平均的ユーザはもっと良質の回線2を使っているので、170KBまで切り詰めようと思っているわけではありません。

しかし、jQueryで実装されている処理は、最近のDOM APIを使えば代替可能です。ブラウザAPIの統一が進みつつある現在、jQueryを使う理由はないのでは? と考え、jQuery依存を取り除くプロジェクトを進めました。

どうやったのか

jQueryを使用している箇所は多かったため、細かくプルリクエストを切って、都度masterにマージしていく方針で進めました。

結果的に、修正プルリクは12個、総変更行数は±2500行程度になりました。

また、メインプロジェクトと並行して進めていたため、去年の8月頃から着手して、完了は先週でした。約4ヶ月かかった計算になります。

何をやったのか

ここからは、やったことを細かく書いていきます。

jQuery.ajax() => fetch に置き換え

jQuery.ajax() を、ブラウザの標準APIである fetch に置き換えました。fetchが利用可能なのはiOS 10.3以上なので、polyfillも導入しました。

github.com

※ライブラリをバンドルすると、全てのユーザにpolyfillを配信することになります。パフォーマンス観点からは、polyfill.iofetchが使えない場合のみpolyfillを使うのも良いと思います。

基本的には、Promiseを使っているところはそのまま置き換え、コールバックを使っているところはPromiseベースに書き換えました。1カ所だけ同期のajaxを使っているところがあったので、そこは非同期に書き直しました。

$.ajax('https://www.ikyu.com/api/...', {}).then(data => data);

const data = await fetch('https://www.ikyu.com/api/...').then(res => res.json());

fetchのpolyfillを採用した理由

比較したのはXMLHttpRequest(XHR)とaxiosですが、

XHRと比べると、

  • Pros
    • fetchはPromsieベースで、高レベルなAPIになっている
  • Cons
    • iOS 10.2以下ではpolyfillの読み込みが必要

axiosと比べると、

  • Pros
    • fetchはWebの標準APIであるのに対して、axiosはjQuery.ajax風の独自API
    • polyfillなので将来的にライブラリの読み込みをなくせる
    • whatwg-fetchはaxiosよりもサイズが小さい
    • axiosが提供しているような高度な機能(Universal JS、リクエストのキャンセル、transform/intercept等)は今のところ必要ない
  • Cons
    • 機能が少ない(たとえば、タイムアウト機能がない)

という感じかなと思います。

fetchのConsについては、

  • XHRを生で使うのは可読性の観点からはありえない
  • fetchに足りない機能は必要に応じて補うことができる

という理由から実質的に問題ないと考えて、fetchのpolyfillを採用しました。

DOM操作を標準APIに置き換え

jQueryで行っていたDOM操作を、全てブラウザの標準APIに置き換えます。jQuery => DOM APIの置き換えに関する包括的なドキュメントは以下がおすすめです。

github.com

ここでは、今回のプロジェクトで実際に使った置き換えのみ紹介します。

要素の取得

jQueryの $() は単体の取得とリストの取得を透過的に扱えるようになっていますが、DOM APIでは区別が必要です。

$(selector);

// 1個だけ取る
document.querySelector(selector);
// 要素のリストを取る
document.querySelectorAll(selector);
// 要素のリストを取って、一括操作する(NodeList.forEachはiOS 9では使えないので配列化している)
[...document.querySelectorAll(selector)].forEach(/**/);

注意が必要なのは存在しない要素へのクエリです。jQueryは、存在しない要素に対するクエリを発行して、返却されたオブジェクトにメソッドを発行しても、エラーにはなりません。存在したりしなかったりする要素に対する処理をjQueryで行っている場合、DOM APIへの置き換えは一手間必要です。

// エラーにならない
$('こんな要素はない').show();

// document.querySelector()の結果はnullなので、styleへのアクセスでエラー発生
document.querySelector('こんな要素はない').style.display = 'block';

show/hide

$el.show();
$el.hide();
$el.toggle();

el.style.display = '';
el.style.display = 'none';
if (el.ownerDocument.defaultView.getComputedStyle(el, null).display === 'none') {
  el.style.display = '';
} else {
  el.style.display = 'none';
}

実際に使う際は、関数化したほうがよいでしょう。

addClass/removeClass

class操作はclassListで置き換え可能です。IE 10以上対応なので安心。

$el.addClass('class');
$el.removeClass('class');
$el.hasClass('class');

el.classList.add('class');
el.classList.remove('class');
el.classList.contains('class');

html/text

$el.html(html);
$el.text(text);

el.innerHTML = html;
el.textContent = text;

アニメーション

jQueryのアニメーションAPIは手軽に使えて高機能なので、完全な置き換えは難しいです。 ユースケースに合わせて、CSSアニメーションに置き換えていくのがよいでしょう。

これについても https://github.com/nefe/You-Dont-Need-jQuery が参考になります。

You Don't Need jQueryには載っていない、アニメーションを伴うスクロールは以下のように実装しました。

/**
 * 指定した要素までスクロールする
 * @param {string} selector スクロール対象のHTML要素のCSSセレクタ
 * @param {number} step スクロール幅(px)
 * @param {number} timeout スクロールを行う間隔(ms)
 */
export function scrollToElement(
  selector,
  { step = 100, timeout = 16 } = {},
) {
  const target = document.querySelector(selector);
  if (!target) return;

  // 目的地のY座標
  const destY = target.offsetTop;

  // 目的地が現在位置より上にある場合は上(負のstep)、下にある場合は下(正のstep)にスクロール
  const stepWithDirection = destY < window.scrollY ? -step : step;

  const scrollByStep = () => {
    if (Math.abs(window.scrollY - destY) > step) {
      // step よりも距離が開いているときはscrollByで近づく
      window.scrollBy(0, stepWithDirection);
      setTimeout(scrollByStep, timeout);
    } else {
      // step 以下の距離まで近づいたらscrollToでピッタリ移動する
      window.scrollTo(0, destY);
    }
  };

  setTimeout(scrollByStep, timeout);
}

$.ready()

$.readyはブラウザの対応状況にあわせて load と DOMContentLoaded を使い分けてくれます。が、すでにDOMContentLoaded未対応ブラウザ(IE 8以前)は滅びているので、DOMContentLoaded のみでOKでしょう。

$.ready(function() {
  // 処理
});
$(function() {
  // 処理
});

document.addEventListener('DOMContentLoaded', () => {
  // 処理
})

イベントフィルタリング

jQueryだと、「doument配下のclickイベントを全てキャッチし、そのクリック対象、およびクリック対象の親要素が特定の属性をもつ場合にだけハンドラを実行する」という処理が、以下のように簡単に書けます。

$(document).on('click', '[data-xxx]', eventHandler);

これをDOMの標準APIで実装すると、少々面倒です。

function findParentByAttribute(target, attributeName) {
  let el = target;
  while (el.parentNode) {
    if (el.getAttribute(attributeName)) {
      return el;
    }
    el = el.parentNode;
  }
  return null;
}

document.addEventListener('click', event => {
  if (!findParentByAttribute(event.target, 'data-xxx')) return;
  // handle event
});

jQueryの使用を防ぐ目印

jQueryを取り除く作業をしたファイルには、先頭に以下の記述を追加して、jQueryを使ってはダメなことがわかるようにしました。

// このファイルではjQuery使用禁止!
const $ = undefined;

このコードは、ローカル変数の $ を定義して、undefinedで初期化します。これによって、グローバルな $ はローカルの $ でシャドウされます(グローバルな $ は上書きされませんが、シンボルの探索ではローカルの $ が優先されます)。さらに、$ の値はundefined なので、 $() などの呼び出しを行うとエラーが発生します。constなので再代入もできません。

これでも、 window.$window.jQuery 、jqueryのimportなど、jQueryにアクセスする手段は残されています。が、一休.com開発チームの規模やスキルを考えると、この方法で十分と判断しました。

なお、上記コードはES Modules(またはwebpack)環境での動作を前提にしています。ES Modulesはファイル毎のスコープを切ってくれますが、ES Modulesを使っていない場合も即時関数でスコープを切ることで同じことができます。

(function(){ // ファイルの先頭
  // このファイルではjQuery使用禁止!
  const $ = undefined;
...
})(); // ファイルの末尾

jQuery削除の効果

f:id:ryo-utsunomiya:20190117155101p:plain
jQuery削除前

f:id:ryo-utsunomiya:20190117154713p:plain
jQuery削除後

↑は、jQuery削除前後のPageSpeed Insightsのスコアです。どちらも71点。Time To Interactive/First CPU Idleは改善していますが、SpeedIndexは悪化しています。この程度の変動は何も変更しなくても起きるので、スコアが変わるほどのインパクトはなかったということですね。

パフォーマンス改善の観点からは、jQuery削除は、コスパが悪かったという結論になるかと思います。たぶん、同じ時間を別のタスクに使えば、もっと改善できたはず…。

なお、今回この結論に達したのは、既存コードのjQueryへの依存度が高かったからという理由もあります。サクッと取り除けるような状態なら、もっとコスパは良かったと思います。また、一休.comのホテルページスマホ版においては効果がなかったということであり、条件が異なれば、別の結果が得られると思います。

まとめ

パフォーマンスの観点からは、ロードするJSの量を減らすことは重要です。一方で、JSライブラリ30KB程度の削除だと、誤差の範囲程度の改善効果しか得られない、ということもわかりました。塵も積もれば山となるので、無駄ではないと思いますが、もっとコスパの良い改善施策を実施していきたいところです。

告知

Bonfire Frontend #3が、1/24に開催されます。テーマは「パフォーマンス改善」です。今回、Yahooグループのよしみで(?)お声がけいただき、登壇する機会をいただきました。今回の記事のような、一休.comで進めているパフォーマンス改善のお話しをしようと思っているので、是非ご参加ください!(すでに満席ですが、1/18に抽選なので、まだ間に合います)

yj-meetup.connpass.com


  1. http://infrequently.org/2017/10/can-you-afford-it-real-world-web-performance-budgets/

  2. 一休.comでは回線速度のベースラインを1.4Mbpsで考えています。LighthouseのSlow 4G相当です。