塵も積もれば山となる、Vue.js製スプレッドシートのパフォーマンス改善記

はじめに

はじめまして、オクトのフロントエンドエンジニアの小泉です。約1年前に入社し、Vue.js(Nuxt.js)でプロダクトのWebフロント開発に携わっています。

 

初めて会社のブログに寄稿するにあたって、自分がオクトでどんなことをしているかを書こうと思ったのですが、私が担当しているプロダクトは現時点で正式リリース前のため、今回はその中に組み込まれている簡易スプレッドシート機能について、また特にそのパフォーマンスを改善するために行ったことについて書いていきます。

 

といっても、スプレッドシート的なものを自前で実装しようという人はなかなかいないと思いますので、データ量の多いリアクティブなサービスをVue(Nuxt)で開発する際のハマりポイントとして参考程度に流し読みして頂ければ幸いです。

 

スプレッドシート機能とは

現在、オクトで自分が開発に携わっているプロダクトには、商品情報を入力していって、合計金額を計算する明細編集という機能があります。

 

この機能について、チームのデザイナーさんから最初に提案されたデザインイメージは下記のようなものでした。

f:id:ytr0903:20200424122028p:plain

この時点では「こういう画面で編集したい」という程度で、リッチなキーボード操作というものが具体的な要件には含まれていなかったのですが、

 

このExcelライクなデザインを実現すると、ユーザーさんは一般的なスプレッドシートと同じ操作を期待し、「なぜキーボードの矢印キーでセル移動できないのか」「どうして複数選択したセルをDeleteキーで一括消去できないのか」などの要望が上がってくることは想像に難くありません。

 

なのでここは、「このデザインに対して自分が欲しいと思う機能」を実装する必要があると判断し、専用のスプレッドシート機能の開発に取りかかりました。

 

ANDPADのサービス内には他にも明細編集機能が存在しますが、使っているフレームワークが異なるなどの事情から、流用するよりも作り直した方が早いと判断し、一から作っていくことにしました。既存のライブラリを使わなかった理由は

  • 要件に適したものがあまり見つからなかったこと
  • 既存のライブラリを使ってしまうとデザインや機能面での細かいカスタマイズが難しくなること
  • 一般的なスプレッドシートと違って関数機能やセル同士の連動などを汎用的に実装する必要がない

ことから、そこまで複雑な実装にならないだろうと見込んだからです。

 

基本的な機能を1週間ほどで実装し、そこから半年ほど、他の作業の合間に少しずつ機能を追加したりパフォーマンスを改善したりを続けていった結果、現在はこのような形になりました。 

f:id:ytr0903:20200424122235g:plain

当然ながら、機能面でGoogleスプレッドシートなどの有名サービスには及ばないものの、キーボードでの操作、マウスでの範囲選択、切り取り・コピー・ペースト、履歴での元に戻す・やり直すなど、いわゆる一般的なスプレッドシートに期待される機能を一通り搭載しながら、それなりのパフォーマンスと操作性を実現できているのではないかと思います。

 

基本的な実装の仕組み

この項は、パフォーマンス改善の項のための前提知識でしかなく、他のプロダクトに応用できるようなものではないので、興味がない方は適度に読み飛ばして頂いて大丈夫です。

 

スプレッドシートを構成するコンポーネントは大きく分けると

SpreadSheetWrapper.vue > SpreadSheet.vue > SpreadSheetCell.vue

の3つに分かれています。

 

SpreadSheetWrapper は描画ではなく処理のみを行っているコンポーネントで、まとめてしまうとあまりにコンポーネントサイズが大きくなりすぎるために分けています。サーバーから渡されたデータとスプレッドシートで編集するためのデータの相互変換とバリデーションを担っています。

 

SpreadSheet.vue に渡されるデータは、列の配列の中に行の配列があり、どこに表示するかは配列番号で管理しています。

f:id:ytr0903:20200424122318p:plain



いずれかのセルが編集された後、セルの移動やフォーカスが外れるタイミングで、編集後の配列がSpreadSheetWrapperにemitされます。

 

配列からオブジェクトへの変換や金額計算などは、全てスプレッドシートから配列を受け取ったSpreadSheetWrapperで行い、その結果を履歴に保存しつつ、金額計算が反映された状態で配列に再変換したものがスプレッドシートに渡されます。

 

編集完了のタイミングで毎回ラッパーを通して、長すぎる文字数のバリデーションや全角数字の自動半角数字変換を行うので、常に有効なデータのみが保持されます。

 

SpreadSheetではこのデータを、行の数と列の数だけv-forでループしてひたすらセルを並べています。それぞれのセルには表示用のdivタグと編集用のinputタグ(またはtextareaタグ)があり、選択したタイミングでセルのinputタグにフォーカスすることで、そこからキーイベントを取得したり、そのまま文字の編集を行えたりします。

f:id:ytr0903:20200424122405p:plain


シート切り替えや履歴管理、コピー/ペーストや列幅の変更など、個々の機能ごとの実装を見ていくとキリがないので、ざっくり説明するとこのような仕組みで動いています。

 

機能を列挙していくと何となくすごいものに見えますが、実際には一つ一つのキーイベントやマウスイベントに対してひたすらロジックを追加していくだけの地味な作業なので、これを作ること自体は時間をかければ誰でもできます。

f:id:ytr0903:20200424163546p:plain

このコードだけでも何となくアナログ感が伝わるでしょうか。

 実際に苦労したのは機能そのものの実装ではなく、それらの機能を次々に入れなが、データ量が増えても破綻しないようにパフォーマンスを両立させることでした。

初期の実装では10行程度のデータでも、表示に数秒かかり、操作をするたびにまた数秒待たせてしまうような状態でした。そこからどのように改善していったかを書いていきます。

 

パフォーマンスを改善するための道のり

当たり前ですが、「これをやれば一気に速くなる」というような銀の弾丸はもちろん存在せず、基本的にはトライアンドエラーで少しずつ改善していきました。

 

本当はきちんと秒数を計測してどの程度改善されたかを数値化すべきなのですが、フロントエンドの速度はブラウザやサーバーなど様々な条件に左右されてしまうため、計測は行えていません。この記事では体感で明らかに速くなった改善をいくつか挙げています。

 

この時、数値ではない体感ベースでの改善をなるべく継続的に行うために、一定のデータ量を基準にして進めるようにしました。今回のケースでは、「100行のデータをストレスなく表示・編集できる」ことを目標に改善していきました。

 

inputタグをできる限り減らす

機能を作り始めた最初期の段階は、全てのセルを、CSSで見た目を整えたinputタグで作り、クリックしてそのinputタグにフォーカスが移るようにしていました。

これは自然な実装ですが、早々に破綻しました。inputタグをmountする描画コストが大きいため、数行ならまだしも、10行~20行のデータでも既に表示するまでに数秒待たされるようになります。

そこで、セルコンポーネントをさらに、表示用のセル(SpreadSheetPreviewCell.vue)と、編集用のリアクティブなセル(`SpreadSheetTextCell.vue)に分け、

基本的にはPreviewCellしか表示せず、選択中のセルのみinputタグ(またはselectタグ)と切り替える形でその都度マウントし、mountedのイベントでフォーカスを移すように変えたところ、描画が約10倍速くなりました。

その後、プルダウンメニューを使うセルだけ、デザインの都合でそのままにしていたのですが、これもデータが多くなるにつれて無視できない遅延になっていったので、

編集中以外はPreviewCellに統一して、矢印マークをCSSで同じ場所に配置するようにしてさらに高速化させました。

 

背景とセルを分離し、背景はCSSのみで描画する

TextCellとPreviewCellを分離したものの、PreviewCellの状態でも行うべき処理がいくつか残っていました。例えば、選択中かどうか、切り取り中のセルかどうか、バリデーションエラーかどうか、などは、アクティブでないセルであっても表示に反映させる必要があります。

f:id:ytr0903:20200424123119p:plain

ところが、この判定とデザインの切り替えをPreviewCellで行ってしまうと、選択範囲が変わるたびに個々のセルで「いま自分が選択対象になったかどうか」の判定が行われ、全てのセルの更新が走ってしまいました。

 

これを避けるために、「PreviewCellは単純に自身が持っている値だけを表示する」ようにして、選択中かどうかといった見た目の変化は、セルと同じサイズの背景を描画する単一のコンポーネントで処理する方式に変更しました。

 

SpreadSheetCellsBackground.vue という長ったらしい名前のコンポーネントですが、やっていることは配列をそのままdivタグのループにしてCSSで装飾をしているだけです。

f:id:ytr0903:20200424123237p:plain

この変更の結果、選択されているかどうかなどのフラグをpropsで渡す必要がなくなり、選択中のセルのフォーカスを切り替える際のラグが4秒から1秒以下に改善されました。最初の改善と併せて、「見た目に関することはできる限りCSSで行う」というのがシンプルながら有効であることを再確認しました。

 

見えている範囲以外は描画しない

こちらも基本的ながら効果の高い改善でした。

Vue.jsにはIntersection Observerを利用したvue-observe-visibilityというプラグインがあるので、これを利用すれば表示範囲に出入りするごとにイベントをトリガーさせることができます。

f:id:ytr0903:20200424135743p:plain初めは行番号を管理するVisibleRowsという変数を用意して、表示領域に入るたびに行番号を追加し、visibleRows.includes表示を切り替える実装を行っていました。

 

ところが、確かに初期描画は速くなったものの、今度はスクロール時にかなりの待ち時間が発生するようになりました。

f:id:ytr0903:20200424160738g:plain

これでは初期描画が速くなっても意味がありません。

 

一定時間の経過後に、全ての行が一気に表示されていることから、処理後のDOM更新がボトルネックになっていると推測し、Vueの秘密のパフォーマンステク9選紹介 - Qiitaでも紹介されていた、子コンポーネントへの処理の分離を導入することにしました。

 

既に実装が進んでいるものを子コンポーネントに移すのはかなり困難だったため、自身のdataとしてisVisibleのみを持ち、渡されたslotの表示を切り替えるだけの単純なラッパーコンポーネントを作りました。

f:id:ytr0903:20200424161612p:plain

正直、これだけで改善されるかは確証がなかったのですが、これが予想以上の効果を生みました。

f:id:ytr0903:20200424161445g:plain

改善前のgifと比べると一目瞭然。すぐに表示されるのでわかりにくいですが、今までは同じタイミングで一気に表示されていたものが、行ごとに別々のタイミングで表示されており、処理が分散されていることがわかります。

 

中身は親コンポーネントに残したままなのでpropsのバケツリレーも起こらず、実装が複雑にならずに処理速度が明確に向上しました。

 

コンポーネントのupdatedの回数を減らす

表示自体は速くなったものの、セルの編集が完了してから別のセルに移動するまでの処理が遅いという問題が依然として残っています。

 

これを改善するために、Chrome拡張機能Vue.js devtoolsのPerformanceタブを見ながら原因を探っていると、updatedの回数が明らかにおかしいことに気づきました。

f:id:ytr0903:20200424161854p:plain

一度セルを移動しただけでPreviewCellのupdateイベントが6500回走っています。

 

100行×13列で1300個のセルがあるので、1回の編集で全てのPreviewCellが5回更新されているのではと推測できます。(誤差があるのは選択中のセルの切り替えに伴うものと考えられます)

 

Total TimeやAverage TimeはブラウザやPCの性能によって毎回バラつきがあるのですが、Countは信用して良いはずです。

 

対処法は大きく分けて2つ、「更新されていない箇所でupdatedが走らないようにする」「1回の処理でupdatedが2回以上走らないようにする」。

具体的には

  • 不要なpropsを渡さない

  • 1度の変更で同じ値が複数回更新されないようにする

  • propsをオブジェクトでまとめて渡さない(差分を検知できずに毎回updateが走ってしまうため。面倒でもStringやNumberに分解して渡す)

 

どれも当たり前ですが、根本的なロジックを見直す必要があるうえ、後から要らなくなったpropsを消さなくても困らないため、開発しているとつい後回しにしてしまいがちな部分でした。

f:id:ytr0903:20200424162102p:plain

渡されているpropsの中身や、その値を操作する親コンポーネントのイベントに無駄がないかを1つ1つ見直していった結果、

 

期待通りにPreviewCellの更新回数を1回(編集が完了したことで値が更新されたもののみ)に抑えることができました。

 

データのバリデーションをセルでは行わない

これも当たり前といえば当たり前なのですが、最初は「文字数を超えていたらアラートを出す」「全角数字が入力されたら半角に直す」みたいな簡単な処理はセルで行っていました。

 

しかし、値の修正が複数個所で行われていると、「wrapperから間違った値が渡されたら、cellで修正して再度emitする」というような、遠回りな処理が発生してしまいます。

 

さすがに無限ループにはならないように気を遣っていましたが、それでも親子間でのデータのやり取りが増えると、必然的にコンポーネントの更新回数も無駄に増えてしまいます。

 

全てのデータ修正をwrapperに寄せたことで、1度の操作で2回以上更新が行われなくなり、パフォーマンスを最適化することができました。加えて、処理の流れを一元化したことで履歴機能もスムーズに実装できるようになりました。

 

おわりに

スプレッドシートの改善について、まとまりなくつらつらと書いてきましたが、この記事の内容を一言で表すと「塵も積もれば山となる」です。

 

例えばこれが10行程度のデータを編集するためのものであれば、全てのセルで毎回更新が走ったとしてもそんなに重くなりませんし、そこの改善に力を入れても得られるものはそんなにありません。

f:id:ytr0903:20200424161854p:plain


updatedの項で貼ったこの画像も、セルごとの平均処理時間は230ms。並行処理の数が少なければもっと少なくなるので、本来であればそこまで致命的な数字ではありません。そもそも、細かく気を遣わなくても必要十分なパフォーマンスを提供してくれるのがVue.jsやReact.jsのようなフレームワークであるとも考えています。

 

しかし、それが100件、セルの個数にすると1300個という単位のデータになると、一つ一つの処理が数ミリ秒増えるだけでも一気に重くなってしまいます。

 

記事に書いてきたことの1つ1つは非常に単純ですが、だからこそ普段は軽視しがちな領域の処理でもあります。

 

このような大規模なデータを扱う場合には、Vueのライフサイクルの正しい理解と、ほんの少しの高速化の積み重ねが必要になっていくことを学びました。

 

ちなみに、上記の改善をすべて行ったことで、最終的にどの程度の変化があったかというと、 

f:id:ytr0903:20200424213222p:plain

初回描画が9秒から3秒、セル選択が5秒から1秒未満、セル編集が3秒から1秒未満に短縮されました。

……これでもまだ遅い部分はありますが、データ量に依存することも踏まえれば、ある程度実用的な範囲には収めることができたかなと思っています。

まだまだ最適化の余地はあると思うので、今後もリリースに向けて改善を続けていく予定です。

 

ここまで読んでくださってありがとうございました。何らかの参考になれば幸いです。

 

また、このようなプロダクトの改善や新規開発に興味を持った方は、ぜひ一度弊社の採用サイトを覗いてみて頂ければと思います。Webブラウザ上でこのような複雑な機能開発をするのは、困難を伴いますが刺激的でとても楽しいです。一緒にお仕事できる方をお待ちしております。

hrmos.co