Vue 2で大きなデータを扱うときの性能改善手法

Tags
Vue 2で大きなデータを扱うときの性能改善手法
Page content

本エントリは2021年8月30日に開催されたNode学園 37時限目 オンラインにて、「Vue 2で大きなデータを扱うときの性能改善手法」というタイトルで発表させていただいた内容をテックブログ記事化したものです。発表当日の様子はYouTubeにアーカイブで公開されておりますので、そちらも合わせてご覧いただけましたら幸いです。

はじめに

ストックマークでは、法人ユーザー向けに「Astrategy」というウェブサービスを開発・提供しています。Astartegyの詳細や技術的な全体構成についてはAstrategyを支える技術: gRPC, Elasticsearch, Cloud TPU, Fargate… SaaS型AIサービスの内側の世界というエントリで紹介しておりますのでそちらを参照いただくとして、本エントリではAstrategyのフロントエンドを構築する上で重要である性能改善手法について紹介したいと思います。

Astartegyでは大きなテーブルとして可視化されたデータをインタラクティブに操作する部分があり(図1)、そのため、大きなデータを一括で取得しフロントエンドで可視化するといったことをしています。ここの設計の是非については議論の余地がある部分かとも思いますが、本エントリではそこは一旦置いておき、1MB前後のJSONがバックエンドから返され、それを高速に取り扱う必要がある場面で、いかに性能改善するかをお伝えします。

図1: ドラッグ & ドロップでインタラクティブにデータを可視化

Astrategyで取り組んだ性能改善

前提として、本エントリのタイトルにもあるとおり、AstrategyではVue 2をフロントエンド構築のためのフレームワークとして採用しています(Vue 3への移行は絶賛準備中です)。また、一口に性能と言っても様々な切り口がありますが、本エントリにおいては大きいデータを取り回してインタラクティブなコンテンツを作るということで、いわゆるランタイム性能の話に着目します。

そして、Astrategyの開発においても「推測するな、計測せよ」の鉄則に従い、Chrome DevToolsでプロファイリングしてボトルネックを見つける、そのボトルネックを改善する、さらに計測する、といったことを何度も繰り返しています。なお、DevToolsの使い方そのものについての詳細は本エントリでは紹介しませんので、適宜公式ドキュメントなどを参照いただけましたらと思います。また、以降いくつかDevToolsによるプロファイリング結果を紹介しますが、以下の環境での計測結果であることに留意ください。

  • MacBook Pro (16インチ, 2019)
    • 2.4 GHz 8コア Intel Core i9
    • メモリ 32GB
  • macOS 10.15.7
  • Vue 2.6.11

サンプルアプリ

ここで、Astrategyそのものを題材として取り上げても悪くはないのですが、複雑な構成になっていて性能改善だけに着目した説明の題材としてはふさわしくないため、性能改善の効果が分かりやすいサンプルアプリを使って以降の説明を行いたいと思います。サンプルアプリは https://github.com/tsukkee/vue2-handle-big-dataにて公開しており、GitHub pagesで動作も確認できますので、興味のある方は併せて参照ください。

このサンプルアプリでは、「データロード」ボタンを押すと、20,000件のダミーデータが表示され、チェックボックスを押すと対応する箇所がハイライトされるような動きになっています(図2)。この動き自体に深い意味はありませんが、大きなリストを表示しつつその一部を変更するようなサンプルになっていると思ってください。図2を見たり実際に動かしたりすると分かるのですが、単にチェックボックスを押すとリストの一部がハイライトされるだけなのにナイーブな実装(初期状態)だと体感できるレベルで遅いことが分かるかと思います。

図2: サンプルアプリの動作

測定と改善 その1

ということで、ともかくこの初期状態で、Chrome DevToolsでチェックボックスが押されてから画面に反映されるまでをプロファイリングしてみましょう。図3に、測定結果を示していますが、updateChildrenという部分など、処理全体の後半に時間が掛かっていることが分かります。このあたりはプロファイラで末端の方まで読み解いていくと分かるのですが、DOMの更新をしている部分です。

図3: 初期状態でのプロファイリング

このようなときに最も良く行われる解決方法はVirtual Scrollの導入です。Virtual Scrollとは、画面上に表示されている部分にだけDOMを配置してDOMの更新を最低限にする仕組みで、さらに、同じDOMのインスタンスを何度も使い回すことによってDOMの生成・破棄のコストも削減しています。

Vue向けにもいくつかライブラリが存在するので(例えば以下)、ドキュメントを参照し、使い勝手やそれが自分たちの要件に合っているかなどを勘案して選定すれば良いかと思います。今回のサンプルアプリでは上のVue Virtual Scrollerを採用しています。

なお、実際のAstrategyで、図1の箇所など一部画面が複雑になっており既存のライブラリがそのまま適用できなかったので、IntersectionObserverを使って似たような仕組みを自作するなどの工夫を取り入れています。

Virtual Scroll導入時の注意

Virtual Scrollは導入するだけで大きなリストの描画を最適化できるので便利なのですが、いくつか利用上の注意点もあります。

まず、Virtual Scrollを導入すると、当然ながらスクロールアウトしてしまっているテキストが実際のDOM上には存在しなくなるため、ブラウザのテキスト検索で探せなくなってしまいます。そのため、ユースケースによっては別途リストに絞り込み機能や検索機能などを実装し、ユーザビリティを損なわないようにする必要があるかも知れません。

また、Virtual Scroll自体の初期化時間も無視できなくなってくるケースもあります。例えば、Astrategyだと一つの画面上に10〜20ぐらいのVirtual Scrollリストが配置されることがあり(図4)、この画面の表示が遅くなるという問題があります。この場合は例えば、初期状態では通常のv-forを使ったリストで上位10件程度のみを表示し、mouseoverで動的にVirtual Scrollに置き換えることによって、初期化を遅延させるなどの工夫が必要となります。

図4: 赤枠の箇所が全部Virtual Scrollリストで、それぞれ数100の企業名が並んでいる

測定と改善 その2

それでは、再度プロファイリングしてみましょう。図5に結果を示しますが、Virtual ScrollのおかげでDOMの更新に関する部分はほとんど消えましたが、今後はcomputedGetterであらわされる箇所にまだ222msぐらい改善の余地があることが見えてきました。さらに図中にもあるとおり、プロファイラで末端の方まで見てみるとaddDepという関数が何回も呼ばれていることが分かります。

図5: Virtual Scroll導入後のプロファイリング

VueのReactivityについて

図5の部分を分析するためには、VueのReactivityの仕組みについて理解する必要があります。

Vue 2では、コンポーネントにデータが渡されると、自動的にそのデータを再帰的に辿ってすべてのプロパティをgetter/setterでラップしてその中で変更検知する、という仕組みになっています(このあたりの詳細は公式ドキュメントもぜひ読んでみてください)。したがって、大きなデータをコンポーネントに渡すと、その長大な構造を全部再帰的に変換し、かつ、変更検知のときにそれら全てについて依存関係を計算する、といったことが起こります。

ここで、上記computedGetteraddDepが具体的に何をしているかについてはVueのソースコードに潜る必要がありますが、簡単に説明しておきます。今回のサンプルアプリでは、大きなダミーデータをcomputedで変換しリストに渡しています。そのcomputedの値を得る処理の一部がcomputedGetterであり、computedなデータがどのdatapropsなどに依存するかを把握するために呼ばれているのがaddDepです。今回のように長大なデータが全部VueのReactiveなデータに変換されていると、その数だけaddDepが呼ばれて図5のプロファイリング結果のような状態になります。

このような処理は少ないデータ量を扱っている間はそれほど問題になることはないのですが、今回のサンプルアプリのようなケースだと問題になってきます。なお、Vue 3ではReactivityの実装がgetter/setterベースからProxyベースへと変わっており、この部分はいくらか改善されているようです。Astrategyでどの程度その効果があるかなどはまだ検証できていませんが、この恩恵に授かるためにもVue 3への移行は進めたいと考えているところです。

VueのReactivityを抑制する

VueのReactivityについてちょっと長く説明してしまいましたが、よくよく考えると、バックエンドから得られたデータの中身の一部が勝手に後から変わることはない(イミュータブルに扱える)はずなので、再帰的に変更検知するのは無駄だと言えます。ところが、Vue 2のOption APIだとそこを制御する術がないように思えます。

が、実はVue 2ではisFrozenなオブジェクトはReactiveへと変換されません。Vueのソースコードの該当箇所を見ると分かるのですが、isFronzenなときは変更検知する意味がないので、Reactiveへの変換処理がスキップされています。また、これについてはHandling long arrays performantly in Vue.js - Reside-ICという記事も詳しいので、併せて参考にしていただけると良いと思います。

つまり、やるべきことは単純で、バックエンドから得られた大きなデータはObject.freeze()してからVueのコンポーネントに渡せば良い、というとになります。以下にそのときの疑似コードを掲載しますが、ソースコードの変更は非常に簡単です。

const data = await fetch(...);   // バックエンドからデータを取得して…
this.data = Object.freeze(data); // Object.freeze()してからVueのコンポーネントに渡す

Node学園での発表においては、発表後の質疑でObject.freeze()によるオーバーヘッドについても議論しておりますが、MDNのObject.freeze()の項

Object.freeze(object) を呼び出した結果は、object の直属のプロパティにのみ適用され、object 上のみに対するその後のプロパティの追加、削除、値の再割り当て操作を禁止します。これらのプロパティの値がオブジェクトそのものであった場合、これらのオブジェクトは凍結されず、プロパティの追加、削除、値の再割り当て操作の対象になり得ます。

と説明されているように、Object.freeze()は再帰的に適用されるわけではないので、そのオーバーヘッドも変更検知処理と比べると非常に軽微であり、気にしなくても問題ありません。

なお、ここまではOption APIでの例でしたが、Vueでは、Vue 3から導入されVue 2にバックポートされているComposition APIというコンポーネントの作り方もあります。Composition APIではOption APIとは違い、Reactiveへの変換を明示的に行う必要があります。そのためのAPIとして、refreactiveという関数があるのですが、それぞれデータを再帰的に変換しないAPIとしてshallowRef/shallowReactiveという関数も用意されています。また、markRawでReactiveに変換しないようマークすることもできます。実際、公式ドキュメントのこのあたりを説明している箇所でも、

Skipping proxy conversion can provide performance improvements when rendering large lists with immutable data sources.

のように述べられており、まさに今回のようなケースで使うものであると言えます。すなわち、Composition APIではshallowRef/shallowReactive/markRawといった関数を使うことで、Option APIでObject.freeze()を使ったときと同等の効果を得ることができます。この場合のソースコードは例えば以下のようになります。

// const data = ref([]);       //よくある定義方法
const data = shallowRef([]);   // shallowRefでデータを定義
data.value = await fetch(...); // ここで再帰的にreactiveにはならない

最後の測定

それでは、さらにプロファイリングしてみましょう。図6にあるとおり、computedGetterの処理時間が約222msから47ms程度まで減少しました。このReactivityの抑制による性能改善は、データ構造が複雑で長大な構造ほど効果があるので、Astrategyでは目を見張るような効果がありました。同じようなユースケースで性能改善に困っている方は試してみることをおすすめします。

図6: Object.freeze()で不要なReactivityを抑制した後のプロファイリング

おまけ

Astrategyの性能改善では、ここまで紹介した以外にもVueでよく実施される最適化も適宜行っています。適切な粒度でのコンポーネント化、computedの適切な利用など、色々あると思いますが、例えば以下の記事によくまとまっており参考にさせていただきました。

また、JavaScriptレベルでの最適化が必要な場面もありました。

例えば、JavaScriptの配列の結合はarray1.concat(array2)よりarray1.push(...array2)の方が圧倒的に高速です。詳細はJavascript Array.push is 945x faster than Array.concat 🤯🤔 - DEV Communityの記事が詳しいのですが、Astrategyでは大きいデータ全体から配列になっているプロパティを集めてくるという処理があり、この部分を取り入れることでかなり高速化しました。

さらに、配列の中身をユニークにするという処理も頻発し、JavaScriptのArrayでuniqする8つの方法(と、その中で最速の方法) - Qiitaの記事でも紹介されているようにArray.from(new Set(array))を使うことで最適化しています。余談ですが、Vue 2ではSet/Mapなどのコレクション型はReactivityの対象とならずaddしても変更検知されないのですが、Vue 3ではこれらのコレクション型に対するaddなどの操作も変更検知されるようになっているので、合わせて理解しておくと良いと思います。

まとめ

本エントリでは、Vue 2で大きなデータを扱うときの性能改善について説明しました。大きくは以下の3点に集約され、Vueで性能改善を考えている方の参考になるのではないかと思います。

  • まずはとにかくChrome DevToolsなどで計測する
  • 不要なDOMの更新がないかを確認して対処する
  • VueのReactivityの仕組みを理解し制御する

最後になりましたが、本エントリの内容に関して発表の機会をいただきましたNode学園主催者及び関係者の皆様、本エントリで引用させていただきました有用な記事の著者の皆様に、あらためて感謝いたします。

ちなみに、Astrategyは2019年12月にローンチ後、事業会社や大手コンサルファームを中心に50社以上のお客様に導入いただき、PMFに向けて急速にグロースしているところです。一緒にAstrategyを成長させられる開発メンバーを募集中ですので、興味のある方はぜひ採用ページをご覧ください!