フロントエンドエンジニアの小林です。
Yappliでは「Yappdate Day(ヤップデートデイ)」という取り組みがあります。Yappdate Dayとは、「改善する日」のことで、その日は改善案件のみ実施し、プロダクト開発本部メンバーが1日がかりでプロダクトや業務に関する改善に取り組みます。
今回はYappdate Dayで取り組んだ、YappliのCMS画面のパフォーマンス改善に関して紹介させていただきます。
前提と課題
YappliのCMS画面は、ブロックと簡易プレビューという二つの要素で構築されています。ブロックとして入稿されたコンテンツが、ネイティブアプリ側でどのように表示されるかを簡易プレビューで確認できます。
簡易プレビューではネイティブアプリと近しい閲覧体験を担保する必要があるため、簡易プレビュー・ブロックともにページネーションの実装はありません。
しかし、運用方法によっては毎日コンテンツ入稿が発生し、ブロックの数が大きく増加していきます。改善前はこのユースケースが全く考慮されておらず、全てのブロックがまとめてレンダリングされていました。簡易プレビューの複雑な装飾・ブロック内部の多数の入力フィールドも相まって、ブロック数の増加とともに、FCP(First Contentful Paint)までの時間コストが増加します。
下記は、ブロック数が1個の場合 と 同じ内容のブロックが 100個の場合のサマリーの比較です。
ブロック数 = 1 (Scripting 1759ms) | ブロック数 = 100(Scripting 8324ms) |
---|---|
※ Throttling設定は、No Throttlingです。
ScriptingフェーズのmountComponentの処理数が多く、時間を要しているようです。
各コンポーネント単体のレンダリングコストの削減も必要ではありますが、まずは抜本的なリストレンダリングのチューニングを行う必要がありそうです。
チューニング方法の検討
Virtual Scroll
まず初めに検討したのはVirtual Scrollです。Virtual Scrollは、Viewport外のDOMを削除して必要なDOMのみをレンダリングするため、パフォーマンス改善に有効とされています。 詳細はChrome Dev Summit 2018の講演動画が非常に参考になります。
YappliではフロントエンドのフレームワークとしてNuxt.jsを採用しています。そこで、Vue.jsのコミュニティで最もポピュラーであるvue-virtual-scroll-listの導入を試みました...が、結果的に断念しました。
一つ目の理由は「documentにattachされるスクロールイベント」です。CMSのブロックリストはスクロールコンテナでラップされていません。この場合、vue-virtual-scroll-listのpage-mode
をtrue
にする必要があります。page-mode
がtrue
の場合、documentにスクロールイベントがattachされます。
この問題点は、YappliのModalコンポーネントと競合してしまうことです。Modalコンポーネントは、開いている時に背面コンテンツをスクロールさせないような制御ロジックが実装されています。具体的には、Quasar Frameworkと近しいロジックです。このロジックはbody要素をposition: fixed
にするため、強制的にスクロールイベントが発火してしまいます。すると、vue-virtual-scroll-list側の描画ロジックが崩れてしまいました。
二つ目の理由は「ドラッグアンドドロップによる並び替えのサポートがないこと」です。CMSのブロックリストは、HTML ドラッグ&ドロップ APIを用いて、ブロックの並び替えをサポートしています。この並び替えの実装は、SortableJS/Vue.Draggable を使用しています。どちらのライブラリも互いに公式サポートはされていません。
以上の理由から、オリジナルのVirtual Scrollコンポーネントの実装 や Modalコンポーネントのロジック刷新、ライブラリのブリッジも検討しましたが、
- 現在Yappliにはフロントエンドを専任とするエンジニアが2名しか在籍しておらず、メンテナンスコストを増やしたくない。
- Vue Composition APIに移行しきれておらず、複雑なロジックをcomposableに切り出せない。
という観点から、Virtual Scroll自体の導入を断念しました。
<Lazy> rendering
検討の末、下記の記事を参考にして<Lazy> renderingを採用しました。
詳細は記事に譲りますが、要点のみを絞って記載します。
大元となる<Lazy>
コンポーネントは、IntersectionObserver
を使用して、自身がViewport内にある場合に 限り<slot />
をレンダリングします。これにより、Blockコンポーネントのレンダリング数を一定数に限定することが可能になります。
// App.vue <template> <Lazy v-for="block in thousandBlocks" :key="block.id" :min-height="300"> <!-- content --> </Lazy> </template> // Lazy.vue <template> <div ref="targetEl" :style="`min-height:${minHeight}px`"> <slot v-if="shouldRender" /> </div> </template> <script lang="ts"> import { useIntersectionObserver } from "@vueuse/core"; import { ref } from "vue"; export default { props: { minHeight: Number, }, setup(props) { const shouldRender = ref(false); const targetEl = ref(); useIntersectionObserver( targetEl, ([{ isIntersecting }]) => { if (isIntersecting) { shouldRender.value = true; } else { shouldRender.value = false; } } ); return { targetEl, shouldRender }; }, }; </script>
実装後の検証では、Scriptingに要する時間コストを大幅に削減されることが確認できました。
ブロック数 = 1 (Scripting 2369ms) | ブロック数 = 100(Scripting 2927ms) |
---|---|
※ Throttling設定は、No Throttlingです。
まとめ
パフォーマンスは大幅に改善したものの、まだまだ課題は山積みです。
Throttling設定を6x slowdownにした場合、やはりScriptingフェーズに時間を要します。個々のコンポーネントのレンダリングコストの削減や設計の見直しをしなければ、全ての環境・全てのユーザーに最適な閲覧体験を提供できません。また、Virtual Scrollにしろ、<Lazy> renderingにしろ、DOMツリーからViewport外のブロックが失われてしまいます。これは、ブラウザ標準の検索機能やスクリーン・リーダーの利用が大幅に制限されることを意味します。
Mobile Tech For Allをミッションに掲げているヤプリとして、ユーザ体験に直接影響を与えるフロントエンドのパフォーマンス課題は重要なポイントなので、引き続き改善を進めていきたいと思います!
そして今回のパフォーマンス改善も含め、TypeScript / Vue3 / Composition APIへの移行、デザインシステムの構築やアクセシビリティ対応など、様々な課題が残っています。一緒に改善に取り組んでくださる仲間をYappliでは募集しています!