Scripting工程の速度改善方法いろいろ

引き続き「Webフロントエンド ハイパフォーマンスチューニング」読んでよかったところ要点まとめ。
今回は「URL叩く → Loading → Scripting → Rendering → Painting(画面表示)」のレンダリング一連の流れの中でScripting工程の速度改善方法。この章分厚くていろんな手法が載ってたので、実際に使えそうだな〜って思ったものを
・JS処理速度のチューニングについて
・パフォーマンスの良いJS記法について
・UXを改善するJS記法について
の3パターンに分けてまとめとく


JS処理速度のチューニングについて

GC&メモリリークを避ける

JSは明示的にメモリを扱うことができず、GC(ガベージコレクション)で勝手に解放される。うまくGCが行われずにメモリリークになる場合や、GCの実行によってJSが数ms停止する場合があり、これで処理速度が落ちる。

・console.〜()を使わない
メモリリークを引き起こす

・WeakMap,WeakSetを使う
GCによってメモリが解放される

・Web Workersを使う
並列にJSを実行できる(マルチスレッド化)、ただしDOM操作ができないなどいくつか制限がある

asm.jsを使ってJSの実行速度を上げる

asm.js(Mozillaが開発した、JavaScript言語仕様のサブセット)を使うことによってJSの実行速度をあげることができるが、以下のようなデメリットが発生する。
・ロード時間が長い
・一度JSのパーサーで読み込む必要がある
・ヒープサイズ、メモリの制限が強い
これらの問題を解決するために代替案として最近はWebAssemblyが話題みたい。
ちなみに計算処理を高速化するSIMD.jsってのもあるんだけど、これもWebAssemblyに置き換えられていく予定らしい…WebAssemblyちゃんと勉強しよう


パフォーマンスの良いJS記法について

scroll,resizeなどの高頻度で発火するイベントの抑制

■問題
次から次にイベントの発火が続くと処理が詰まる場合がある

■解決策
setTimeout()やrequestAnimationFrame()を使用して一定頻度で処理を行うようにする。requestAnimationFrame()は、ブラウザの描画更新単位と同じ単位で呼び出されるのであまり抑制感がない…
setTimeout()でサクッとイベント頻度下げるならこんな具合に↓
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// setTimeout()を使ってscrollイベントを100msに一回発火する例
let isRun = false;
let runCount = 1;
const callback = () => {
 // 処理
 console.log(`scrollイベント発火:${runCount++}回目`);
}
 
window.addEventListener('scroll', function() {
 if (!isRun) {
  isRun = true;
  setTimeout(() => {
   isRun = false;
   callback();
  }, 100); // 100msに1回実行する
 }
})

ページが表示されていない時に不必要なタスクの実行を抑止する

■問題
ウィンドウが最小化されている時やタブがバックグラウンドで読み込まれている時に動画の再生やエフェクト、アニメーションなどの必要のないタスクが実行されてしまう

■解決策
Page Visibility API…ユーザがページを見ている時だけ実行する。ページの正確なページビューを測定することにも使える!
1
2
3
4
5
6
7
8
9
10
11
12
13
document.addEventListener('visibilitychange', function() {
 let pageState = document.visibilityState;
 if (pageState === "hidden") {
  // ウィンドウが最小化されているか、バックグラウンドで読み込まれている状態
  console.log("ページ非表示中")
 } else if (pageState === "visible") {
  // フォアグラウンドで読み込まれている状態
  console.log("ページ表示中")
 } else if (pageState === "prerendering") {
  // link要素によるプリレンダリングがされてユーザから見えていない状態
  console.log("プリレンダリング中")
 }
})

Forced Synchronous Layoutを減らす

■問題
Forced Synchronous Layout…offsetHeightプロパティのようなレイアウトに影響を与えるDOM操作を行った時に、レイアウトの強制同期が起こりJSの実行パフォーマンスが落ちる問題

■解決策
・DOM操作を行う前にレイアウト情報を参照する
・requestAnimationFrame()メソッドで計算ずみのレイアウト情報を参照する
DOM操作完了後にコールバックが呼び出されるため、Forced Synchronous Layoutを避けることができる

DOM要素の追加速度を上げる

DOMツリーに対するDOM要素を追加するappendChild()メソッドやinsertAfter()メソッドなどの代わりにDocumentFragmentオブジェクトを使用することで追加速度をあげることができる。DocumentFragmentオブジェクトを使うことによって一度の操作で複数のDOM要素の追加が可能になる
1
2
3
4
5
6
7
8
9
let fragment = document.createDocumentFragment();
for (let i = 0; i < 10000; i++) {
 fragment.append(document.createElement('div'));
}
document.body.appendChild(fragment);
 
for (let i = 0; i < 10000; i++) {
 document.body.appendChild(document.createElement('div'));
}
fragmentを使うと普通にappendChildで要素追加するより数倍早くなると書いてあったので↑のタイムをそれぞれ測ってみたけど、fragmentの方は15msでappendChildの方は17msだったのでそんなに変わらないんじゃないかなあってのが所感です。


パフォーマンス考えつつDOM要素がウィンドウ内に表示されたことを検知する

■問題
lazyloadみたいな要素表示時に読み込む処理。普通にscrollプロパティを使用して実行すると高頻度でイベントが発生してパフォーマンスが落ちる

■解決策
IntersectionObserverを使う
1
2
3
4
5
6
7
8
9
10
11
12
// InterSection Observerがサポートされてるブラウザか判定(IE11はサポート外)
if (typeof window.IntersectionObserver !=== 'undefined') {
 // 表示状態が変更されたら発火
 let setObserver = new IntersectionObserver((change) => {
  // targetDomが表示中ならtrue,非表示でfalseを出力
  console.log(change[0].isIntersecting);
 });
 let target = document.getElementById('targetDom');
 setObserver.observe(target);
} else {
 // サポートされてない場合
}


ユーザの入力を邪魔せず空き時間に処理を挟む

パッと使えるケースが思い浮かばないけど…
requestIdelCallBack()…UIスレッドがアイドル状態時に処理を実行、UIスレッドがタスクをこなしてる間には邪魔をしないことができる
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// アイドル状態時に処理を実行
let idelCallBack = requestIdelCallBack(idolStateProcess);
 
let idolStateProcess = (deadLine) => {
 // アイドル状態が続く限り処理を繰り返す
 while(deadLine.timeRemaining() > 0) {
  // 処理
 }
 
 // 他タスクを実行後、アイドル状態時に再度実行
 idelCallBack;
}
 
// requestIdelCallBackの呼び出しをキャンセル
cancelIdelCallBack(idelCallBack);



UXを改善するJS記法について

モバイル端末で起こるclickイベント、input要素の発火遅延をなくす

モバイルブラウザではclickイベントやinput要素をタップすると、300ms〜350ms遅延してイベントが発火する。確かに会員登録画面とかで何かしら入力するとき、ちょっとラグってるな〜って感じるときある…!!その正体がこれだった。

本書ではこの遅延の解決策として、meta要素のviewportに「content="width=device-width, user-scalable=no"」を設定することを紹介している。これはダブルタップでピンチするのを防ぐ設定なんだけど、アクセシビリティ的によろしくなかったりiOS10から仕様変更されて動作しなかったりと、いろいろ問題が絶えないみたい。Tappy.js、FastClick.jsなどのクリック応答速度を速くするライブラリを使うのが◎


ScrollJank問題を改善する

ScrollJank問題-イベントリスナがインタラクションをブロックするせいでtouchmoveやscrollを使った時に起こるスクロールが遅延、反応がないように見える問題。Passive Event Listnersを有効にすることで解決できる。
1
2
3
document.addEventListener('touchmove', function(e) {
 // 処理
}, {passive: true});
スクロール以外でもイベントリスナがインタラクションを遅延させる場合に使うと◎


非同期処理で指定したタイミングと実際の実行がずれる場合を防ぐ

setTimeout()を使った遅延処理は、指定した秒数後に処理が実行されているわけではなく、ブラウザによって設定されている「時間解像度」によって実行されるため、指定したタイミングと実際の実行がずれる場合がある。
setImmediate()…ブラウザの時間解像度に関係なくUIスレッドがアイドル状態になったら実行される
※2019年現在の時点で、IE&Edgeとnode製のライブラリでのみ実装されていて、Gecko(Firefox)とWebkit(google,apple)はこの機能に否定的らしい…ので使用は現実的ではなさそう


アニメーション実行のずれを防ぐ

アニメーション実装にsetTimeout()、setInterval()を使うと固定的なタイミングでしかJSを実行できず、以下のようなデメリットが発生する。
・ユーザの環境によって処理が追いつかずアニメーションが遅延する場合がある
・タブがバックグラウンドになってても実行されるためパフォーマンスが悪い

解決策1:requestAnimationFrame()メソッド
ブラウザのレンダリング処理に合わせて適切なタイミングに呼び出される。バックグラウンドで読まれている時は呼び出しタイミングを遅くしてくれる

解決策2:CSSアニメーションを使用する
レンダリングエンジン内部で最適なタイミングでアニメーション処理が行われる。CSSアニメーションはUIスレッドと別スレッドで実行されるためJSの実行をブロックしない