こんにちは、ふろしきです。HTML5 Conference 2016の当日は、38度近くの熱があり、発表時はろれつが回ってませんでした。しかし、伝えたいことは伝えられたと思っています。その内容とは…
「この1年でWebのパフォーマンスの技術にどんな動きがあったのか」
というダイジェスト。ここで話した3つのテーマについて、本記事でもご紹介。
1. レイアウト処理を減らす
HTML5がバスワードするよりもずっと前から、CSSでアニメーションさせることはごくごくあたりまえ。JSが扱えないデザイナーであっても手軽にアニメーションできる良い世界になりました。しかしそこに、モバイルが出現したことで、JSだけで満足という人たちもCSSの機能を活用しなくてはいけなくなりました。
モバイルのUIによくありがちなのが、あるコンポーネントの形状を変化させるようなアニメーションや、スワイプ操作のような指の位置に応じてコンポーネントを動かすもの。それを描画するとなると、CPUはあまりにも非力で、GPUの力を借りることが求められています。CSSはGPUと相性がよいため、JSからCSSの機能を呼び出して効率的にGPUを活用するということが求められています。
そのアプローチについては、HTML5 Experts.jpでも「モバイルWebのUIを速くする基本テクニックがわかる──Google I/O 2016 High Performance Web UI」で紹介していますので、本記事では簡単にだけ説明。
GPUでできることは、極めてシンプル。ある四角形の描画結果を、どの位置に表示させるのか、拡大縮小させるのか、回転させるのか、といった変更加えるだけ。それだけでいいというのであれば、CPUを介さず、GPUのみで描画処理を完結させることができます。
これをブラウザで行う場合は、ある特定のDOMとその配下にあるDOMの描画結果をGPU側に転送し、GPU側にどういう描画を行わせるのか指示を送ることで、高速に処理させます。その間、操作はすべてCSSのプロパティを使って行うことになります。
CSSも、従来の left
や top
といった、レイアウトを操作するようなものではなく、 transform
プロパティ経由で指定。 JS上で、以下のように指定することで、CPUによるレイアウト処理を避けつつ、GPUで描画の位置のみを変えるというもの。
// ある要素の横の位置を移動
elem.style.transform = translateX(#{position}%)
;
この方法は一昔前には泥臭いとされ、バッドプラクティスと考えられていました。しかしそれも、最近は will-change
というCSSプロパティが正式に標準化の流れに入ったことで、考えが改められています。GPU上に描画結果を保存させるのはバッテリー消費が大きいため、開始時に will-change: transform;
で有効にし、終了時には will-change: none;
で解除する、という流れになります。
簡単な例をあげると、以下の通りです。
// スワイプ時、指の位置にあわせてコンポーネントを左右に動かす。
class MovableComponent {
constructor(elem) {
this.elem = elem; // 動かす対象のコンポーネントのDOM
}
// ある要素の横の位置移動を開始
function begin(beginX) {
this.x = beginX; // 開始位置をセット
this.elem.style.willChange = 'transform'; // GPU側に描画結果を置く
}
// 位置を変更(指の位置がかわるごとに呼び出される)
move(currentX) {
this.x = currentX; // 現在の位置をセット
}
// 描画(requestAnimationFrameで毎回呼び出される)
render() {
this.elem.style.transform = translateX(#{this.x}px)
;
}
// 横移動の完了
end() {
this.elem.style.willChange = 'none'; // 解除
}
}
2. スクロールイベントを減らす
ブラウザのscrollイベントが、スクロールの滑らかさを落としてしまう。とはいえ、「どれだけスクロールされたのか?」というのに応じて何かしらのJSの処理を動かしたくなるという需要はなくならず、できるかぎりパフォーマンスを上げようという努力はされてきました。
2015年の4月にChromium Blogにポストされた「Chromium Blog News and developments from the open source browser project 」は有名な話です。Chromeではブラウザ上で動作するタスクに優先度を与え、JSの処理やブラウザ側の内部で行われる処理のうち、スクリーン上への描画が関わるタスクを優先的に扱うようにすることで、スクロールの滑らかさを損なわないようにしようという取り組み。確かに、ユーザーの体感としてパフォーマンスは上がったようにみえるでしょう。
しかしこの問題、その本質は、イベント自体が無駄に多く呼び出されてしまっているというところが大きかったりします。したがって、ユーザーワールド側でも、scrollイベントを毎回素直に実行するのではなく、頻度を落として実行するThrottlingという方法が一般的になっています。
これらはパフォーマンスを改善する上で、たしかに有効ではあります。ただ、デベロッパーが本当にやりたいことに目をむけ、そこにより特化した機能をブラウザ側が持てば、もっと良い解決につながるのは間違いありません。ハードウェアが貧弱で、スレッドの扱いに制約の多いブラウザでは、そこに踏み込んだアプローチが求められています。
scrollイベントの利用ケースで、かなり多いもののひとつに「画像の遅延読み込み」があげられます。例えば、ATF(Above The Fold : Webページアクセス直後にスクリーン内に映り込む領域)の表示を速くしたい!それこそ、headタグ内の大量のCSSファイルやトップの巨大な画像ファイルに帯域を譲らないことには高速化が困難ということになれば、ATF外にある画像ファイルは後から読み込んだほうがいい、ということになります。もちろん、サーバーの負荷を下げるという用途でも活用されるでしょう。
それを解決する方法として、Web標準「Resource Priorities」にて、lazyloadというプロパティをHTMLのタグに追加しようという流れが生じ、IE11にも実装されたのですが、「そもそもそういう用途で使われるようなプロパティは追加されるべきなのか?」「本来JSであったり、より低レイヤーなアプローチで解決すべきでは?」という意見もあり、削除されてしまいました。
あれから2年が経ち、lazyloadは理想的な形で実現できるようになりました。それが「Intersection Observer」です。
Intersection Observerは、スクロール時に特定のDOMがスクリーン上に現れるタイミングでイベントを発火させてくれます。したがって、scrollイベントよりも軽量な上、scrollイベントの多くのユースケースを巻き取ることができます。そもそもDOMの位置を取得する、scrollTop、offset、getBoundingClientRect、といったプロパティやメソッドは、本記事の1章でもあげたような、レイアウト処理というヘビーな処理を呼び出しのタイミングで必要とする場合がある。それも無くなるとなれば、一石二鳥でlazyloadのような類の処理を最適化できるというわけです。
以下、使い方の例。jQuery.lazyloadのようなものを実装するとしたら、以下のようになります。
// 遅延読み込みをさせる専用のObserverを生成 let imageLazyLoader = new IntersectionObserver((changes) => { // イベント発火のタイミングで、遅延読み込みさせる changes.forEach((change) => { let image = change.target; image.src = image.getAttribute('data-original'); }); }, { rootMargin: "120% 0" // 読み込みを開始するタイミング }); // 遅延読み込みさせたいimg要素を取り出し、Observeさせる let images = Array.from(document.querySelectorAll('img.lazyload')); images.forEach((image) => { imageLazyLoader.observe(image); });
3. タスクキューの待ちを減らす
最後に、タスクの最適化の話です。ここの話については、ピクシブのブログのエントリー「JSがブラウザを固めてつらいので、新しいAPI「requestIdleCallback」を使うことにした」でも取り上げてますので、ざっくりと背景と概要を。
元々は、2016年頃にMicrosoft側が「setImmediateをちゃんと実装しよう!」と。Microsoftのプラットフォームにおいて、多くのユーザーがsetImmediateを利用しているということを、W3C Web Performance WGのMLで問題提議し、 setTimeout(fn,0)
を使ってタスクを遅延実行させるバッドプラクティスを緩和しようという提案から始まった話です。こうした悩みは「requestAnimationFrameで解決されるものだ!」という意見もあったのですが、現実問題としてはそうはうまくいかないというのは、フロントエンドをやっているエンジニアなら感じられたはずです。
そもそもこの問題はどこから生じたのか?一昔前のビデオカードが、1つのスレッドでしか描画を扱えないという制約があった。そういった背景もあり、現在のブラウザもまた、ブラウザの内部の処理からJSで実装したユーザーワールドの処理に至るまで、その多くのUIスレッドと呼ばれる単一のスレッドだけで処理しざる得ない状況となった。その恩恵というべきか、JSはこれだけ単一スレッドで処理実行させることに進化したのですが!JSでやることが多くなってしまった今、本記事の2章でも語られたような「タスクの優先度」に手を加えないことには、解決出来ない問題も多くなったのです。
こうした中ででてきたのが、requestIdleCallbackという機能です。
2章にもでてきた、Chromeのスクロールパフォーマンスを改善した、パフォーマンス周りを専門としているGoogleのエンジニアRoss McIlroyら。彼らが、Microsoftがだした「setImmediateを全ブラウザでちゃんと実装しようぜ!」という案をはねのけて提案したのがこのrequestIdleCallback。
scrollイベントの改善で、彼らはあくまでブラウザ側のタスクの優先度に対して手を加えたわけですが、ここにきてデベロッパーが作ったJSの処理。ユーザーワールドで実行されるものについても、優先度のようなものを与えて、タスクが実行されるタイミングをうまく制御しようという提案をしたのでした。scrollイベントで培ったノウハウを広げようとしたんだろうなぁと、私は推測してます。
requestIdleCallbackはどういうものか?scrollイベントの一件では、緊急度の高いブラウザ内のタスクを、あらゆるタスクよりも優先して速く実行するというものでした。一方でrequestIdleCallbackは、ユーザー側の体験としてパフォーマンスが低いという状況を生み出しがちな、緊急度が低いユーザーワールドのタスクを、あらゆるタスクよりも低く実行するというもの。ブラウザ側だけじゃ限界があるから、デベロッパー側でもなんとかする術というのを提供したといったところです。
その仕組はとてもシンプルで、処理されることを待っているタスクがあれば絶対に実行しない。無ければそのタイミングで実行しようというもの。
サンプルは以下の通り。
var hd = requestIdleCallback( => { // タスクキューが空になってから実行されるタスク },{ timeout: 10000 // 何msであきらめるか。 });
最後に
CPUからGPUに処理を移したいというお話から、それでもどうしようもないシングルスレッド問題を、いかにして効率的に運用するのかというお話まで。GPUがデベロッパーを幸せにしたり、不幸にされたからどうにかしようとしたり、という感じで、GPUとブラウザのパフォーマンス問題は切っても切り離せないようですね。
ちなみに、この記事を書いている今、TPACというイベントが開かれ、パフォーマンスに関する議論も行われています。そこでは様々な議論が繰り広げられているのですが「Measuring and improving scroll latency (~30m; rbyers)」というスクロールをもっと良くしようよという話や、「Paint metrics」といったレイアウト処理のさらに先の描画系の発展的な話であったり、「Long Tasks API」みたいな、requestIdleCallbackとか以前にタスクの処理時間長くね?ってところをなんとか変えていこうよという議論もあったりします。
個人的に注目しているのは、以前からデベロッパーの間で薄っすら思われていた、SPAの時にブラウザのナビゲーション計測系のAPIが役に立たないのだけどどうするの?って問題に注目しているところです。SPAと一言でいっても、Ruby on RailsのTurbo Linksのようなものもあり、ブラウザの持つページ遷移機能が活用されないケースがある中で、パフォーマンス計測はどうあるべきなのか。「ナビゲーションの開始」と断定することが困難であり、それゆえに画面の切り替わりにどの程度の時間がかかったのかわからないこの問題を、今後どう扱うのかが議論されるようです。
一通り終わったなぁ、パフォーマンス周りの機能整備、なんて思ったのですが、よくよく考えたら実態にそこまで即した感じでもないんですよね。今後も様子を見ておきたいと思います。それでは!