実践 Off the main thread

実践 Off the main thread

実際に Off the main thread をやりつつ、パフォーマンスチューニングをする際にどこに気をつけるべきかを今やっているので、それについて話します。

Off the main thread とは

JavaScript の処理は基本的にメインスレッドで実施します。JavaScriptの実行処理以外にも記述された内容を解釈するためのパース処理やGC処理もメインスレッドをブロックします。メインスレッドの処理が多いとUI jankと呼ばれるガタツキ、チラツキ、画面の固まりの原因になります。

UI jankが発生していると、ユーザーがクリックしたり、text入力をしようとしてから反応するまでの時間(Input Latency)が即時ではなくなります

このUI jankを無くすために、なるべくメインスレッドを阻害する要因を減らすことが Off the main thread と呼ばれるトピックです。

Input Latency

ユーザが入力してから反応するまでの時間を指しています。 Off the main thread は前述の通り、このInput Latencyを少なくするための試みです。 メインスレッドでLong Taskを実行している間、特にレンダリングなどの重たい処理を実行している間は入力があっても即座に反応しません。

f:id:yosuke_furukawa:20190319025141p:plain
Long Task実行中に入力処理があった場合

この場合、inputの処理はキューイングされ、後回しになります。

f:id:yosuke_furukawa:20190319025356p:plain
入力されてから実際に発火して反応があるまでの時間

このレイテンシを最小限にしつつ、スムーズに動作しているように見せないと、固まっているかのように見えます。

「どれくらいで固まっているように見えるか」ですが、Google が出している、RAIL と呼ばれる原則では、100ms 以内に反応がないと、ユーザーは遅れているように見えるという指標があり、少なくともそれを満たす必要があります。 ただし、 lighthouse では 50ms 以内にしないと警告が出ます(アプリが表示し切るまでにさらに 50ms かかると推定されており、トータルで 100ms が Input に対しての Response になるため)。つまり、 Input Latency は表示し始めてから 50ms 、 入力が始まってから 100ms 以内に反応する必要があります。

developers.google.com

developers.google.com

何が Input Latency の遅延原因になっているか

Input Latency の遅延原因となっているのは主に JavaScript の実行だというレポート結果があります。

tdresser.github.io

f:id:yosuke_furukawa:20190319030621p:plain
input latencyの中で処理の内訳

2300以上のサイトを調査し、最初の30秒間に入力処理をした際の処理の内訳を精査したところ、V8.Execute という JavaScript 実行中の処理で25% ~ 70%の時間が発生しています。

これを Web Worker など、 Worker Thread で行えるようにし、 Input Latency を改善するというのが Chrome での topic の1つです。ただし、実際にやってみると、それ以前の問題が多く、実際にやればやるほど「Worker 以前に処理するべき問題」のが多く見つかります。

Worker 以前に処理するべき問題を放置して Worker Thread にしても Input Latency は改善できても処理そのものが重たいので、結局もっさりした動きになります。

実践 Off the main thread

今回の話はこの input latency を改善するため、 UI Jank をどうやって見つけるかとそれを改善するときのやり方、また実際にReact などの SPA を改善する時に見つけたありがちな問題について解説します。

UI Jank の見つけ方

Chrome だと DevTools の Performance タブから比較的簡単に見つけられます。 Performance タブで赤くなっている箇所では、 Jank が起きています。

UI Jank
UI Jank

この Jank を見つけたら、そこから中で何をやっているか見つけに行きます。この JSConf.JP のサイトでは hydration 処理と呼ばれる SSRCSR の状態を同期する処理で Jank が起きています。

Hydration処理
Hydration処理

このケースで言うと、 Hydration と呼ばれる SSRCSR の同期処理が走った後、React の props が変わり、 React の render 処理が中で走っています。結果として hydration 後に render が走ることで React の state が SSR 時のものと同期されることになります。一方で、 Input Latency は落ちる事になります。

ただし、『 UI Jank がある == 不具合』ではないので、これを必ずしも直さないといけないわけではありません。現時点ではどうしようもない処理もあります。 Hydration はその典型です。

よくある Jank のパターン

この hydration 以外にも発生するケーススタディがありますので紹介しておきます。

スクロールごとに重たい計算をしてしまうパターン

スクロールするたびにガタつくケースはだいたいコレですね。よくあるのは、スクロールしたタイミングで要素を変更する lazyload や 無限スクロールのような処理があるケースで、実装がまずいパターンです。

実装がまずい、と一口に書きましたが、「重たいオブジェクトを毎回生成する」、「scrollのたびに現在のviewportに要素が入っているかを毎回計算する」といった処理を指しています。

前者はオブジェクトのキャッシュかインスタンス生成を減らす事を検討し、後者は Intersection Observer などの API で再実装が求められます。 また、 scroll イベントを毎回発生させる必要がないなら、 throttle, debounce といったイベントを間引くことで処理そのものが発生する回数を減らすことができます。

ちなみにスクロールに限らず、『頻度高く発生するイベントをトリガーに重たい処理をしている』事がそもそも問題になります。

他のケースとしては、 moment のような時刻計算をするためのオブジェクトを表示されるたびに計算し、その計算のたびにインスタンスを作ってから時間の差分を計算しているケースもありました。

不要な props を渡してしまうパターン

ここからは主に React の話ですが、React に限らず、 SPA の view libraryではよく発生すると思います。

<Foo {...props} />

のように prop を全て展開して渡しているパターンや、 実際には使わないけど渡しているパターンですね。Reactの SPA の場合、UI Jank の8割方はこれです。この不要な props を渡しているところのバリエーションが多いです。

不要に Reconciliation といった差分検出処理が走ったり、 render が走ってしまい、 Jank が発生しやすくなります。

shouldComponentUpdate を書いて除外するか、きちんと渡す時に精査してから渡してあげれば発生しません。また、型を真面目に書いていればある程度防げたりもするでしょう。

関数をそのまま handler に渡してしまうパターン

こちらもよく見ます。以下のような状況ですね。

<Foo onClick={(e) => { ... }} />

これも、ある種の不要な props なのですが、趣が若干異なります。アロー関数に限らず、関数をその場で定義した場合、propsを渡す際に毎回 function オブジェクトが生成されて React に渡されます。こうなると関数オブジェクトそのものが毎回変わっているため、差分検出処理時に必ず差分がある事になります。

useCallback 等で callback をmemo化した状態で渡すか、 class componentにするなら、関数定義を constructor で定義するか static 関数にするかして、関数を再定義しない方法で handler に渡す必要があります。

どうにもならないときの処理として Worker を使う

処理をダイエットしたけど、どうしても減らない、とにかく重い計算処理を走らせる必要がある、という時に Worker を使いましょう。 Worker は Comlink 経由で使うと利用しやすいです。

実際に利用したときのシーンは AirSHIFT のブログに掲載されています。

web.dev

// Cost計算する処理、 worker を comlink で promisified している。
import React from 'react';
import { proxy } from 'comlink';
 
// import the workerlized calc function with comlink
const WorkerlizedCostCalc = proxy(new Worker('./WorkerlizedCostCalc.js'));
export default function Cost({ userInfo }) {
  // execute the calculation in the worker
  const instance = await new WorkerlizedCostCalc();
  const cost = await instance.calc(userInfo);
  return <p>{cost}</p>;
}

ただ Worker にしただけで速くなるわけではありません。 Worker にすることで Main Thread を逼迫することは減りますが、そもそも無駄な処理がないかを地道に特定し、逼迫する時間を減らすことが一番重要です。

まとめ

Off the main thread だからといって、なんでも worker でやれば OK という話ではありません。自分のアプリ、サイトが遅い要因を特定し、ある程度性能を改善してからどうしようもない時に Worker を使いましょう。そうすると Input Latency だけではなく、処理全体が軽くなります。

Worker は速くなると言っても、 Main Thread の逼迫を軽減するためのものであり、使ったからといって手放しで高速になるわけではありません。一方で、使いこなせれば非常に強力な武器になります。 Comlink などのツールは使いこなせると良いでしょう。

また、仕様側でもよりよい API 設計を考えている話が去年から出ています。この辺も追っておくと良いでしょう。

nhiroki.jp

また、 『UI Jank』の原因は JavaScript のメインスレッド逼迫だけではありません。 CSS や Layout 計算などの要因でも数多く発生します。

Jank の原因をまとめたサイトやチェックリスト等もあるので参考にしてください。

calendar.perfplanet.com

docs.google.com

jankfree.org