一休.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.io でfetchが使えない場合のみ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);
document .querySelector(selector);
document .querySelectorAll(selector);
[ ...document .querySelectorAll(selector)] .forEach();
注意が必要なのは存在しない要素へのクエリです。jQueryは、存在しない要素に対するクエリを発行して、返却されたオブジェクトにメソッドを発行しても、エラーにはなりません。存在したりしなかったりする要素に対する処理をjQueryで行っている場合、DOM APIへの置き換えは一手間必要です。
$('こんな要素はない' ).show();
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には載っていない、アニメーションを伴うスクロールは以下のように実装しました。
export function scrollToElement(
selector,
{ step = 100, timeout = 16 } = {} ,
) {
const target = document .querySelector(selector);
if (!target) return ;
const destY = target.offsetTop;
const stepWithDirection = destY < window .scrollY ? -step : step;
const scrollByStep = () => {
if (Math.abs(window .scrollY - destY) > step) {
window .scrollBy(0, stepWithDirection);
setTimeout(scrollByStep, timeout);
} else {
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 ;
} );
jQueryの使用を防ぐ目印
jQueryを取り除く作業をしたファイルには、先頭に以下の記述を追加して、jQueryを使ってはダメなことがわかるようにしました。
const $ = undefined ;
このコードは、ローカル変数の $
を定義して、undefinedで初期化します。これによって、グローバルな $
はローカルの $
でシャドウされます(グローバルな $
は上書きされませんが、シンボルの探索ではローカルの $
が優先されます)。さらに、$
の値はundefined なので、 $()
などの呼び出しを行うとエラーが発生します。constなので再代入もできません。
これでも、 window.$
、 window.jQuery
、jqueryのimportなど、jQueryにアクセスする手段は残されています。が、一休.com開発チームの規模やスキルを考えると、この方法で十分と判断しました。
なお、上記コードはES Modules(またはwebpack)環境での動作を前提にしています。ES Modulesはファイル毎のスコープを切ってくれますが、ES Modulesを使っていない場合も即時関数でスコープを切ることで同じことができます。
(function (){
const $ = undefined ;
...
} )();
jQuery削除の効果
jQuery削除前
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