はじめに
DIGGLE エンジニアのitoです。
寒さが厳しくなり部屋を温めすぎると頭がぼーっとして集中できず、寒すぎると手がかじかんでしまう季節がやってきました。 ギターなどの木製の楽器を持っていることもあり、寒さとともに湿度との戦いも厳しくなってきた今日この頃です。
部屋の温湿度管理に四苦八苦していたのですが、最近DIGGLEで行ったテーブル表示のパフォーマンス改善も同じように四苦八苦していました。
DIGGLEのプロダクトの成熟にともなって、巨大なテーブルを表示したいというニーズが高まっていました。 当初の想定を超えるような大きいサイズのテーブルが要求されるようになり、その結果大きなテーブルを表示できはしたものの他のコンポーネントのパフォーマンスに悪影響を与えてしまうといった課題が表出しました。
そこでDIGGLEでは課題解消のためにCanvasを活用して巨大なテーブル構造の表示をすることを決めました。 最近無事にリリースが完了し、Cavnasを活用することでテーブル表示のパフォーマンスの改善とともに他コンポーネントへの悪影響も解消できました。実装を通して、現時点かつDIGGLEでのCanvas実装の最適な形を見つけ出すことができたためブログ記事としてまとめます。
Canvasとは
この記事内でのCanvasは、HTMLのcanvasタグのことを指しています。
canvasタグを用いることで、JavaScriptによってタグで指定した領域内に2Dおよび3Dグラフィックを描くことができます。
Webアプリケーション開発においてdivタグやaタグといった頻出のタグとは異なり常に使われるわけではないのですが、Google ドキュメントやGoogle スプレッドシート、FigmaのJamBoardといった有名なサービスで使われています。
上記のサービスでcanvasタグがどのように活用されているのか?を参考にしながらDIGGLEにおいても実装を進めていきました。
DIGGLEで活用している部分
前述の通り様々なWebアプリケーションで活用されているCanvasですが、DIGGLEでは損益計算書(PL*1 ) を表示するレポートで活用しています。
PLレポートは様々な条件での表示を要求され、ユーザーによってはとてもサイズが大きい(=セル数が多い)レポートの表示が必要になります。 Canvasによる描画に変更したおかげで、大きなサイズのレポートの表示にも対応できるようになり、ユーザーによりよいサービスを提供できるようになりました。
DIGGLEでCanvasを利用することにした経緯
なぜDIGGLEでCanvasを利用することにしたのか?という話をするためには、DIGGLEでのPLレポートの表現の変遷を踏まえたほうがいいため、そこから説明をさせていただきます。
初期のPLレポート
初期の一般的なWebアプリケーションと同様にバックエンド側でJSONを返し、フロントエンド側でHTMLでテーブルを組み表示していました。
1万セルを超えるようなPLレポートを表示しようとすると、フロントエンド側でHTMLを組むコストが高くなってしまい表示がままならなくなってしまいました。
初期のPLレポートでの限界 < 1万セル
つい最近のPLレポート
初期のPLレポートではHTMLをフロントエンドで組むコストが高かったため、計算資源が豊富なバックエンドで組む形に変えました。
パフォーマンスも上がり一時期まで問題なかったものの、ユーザーの練度が上がっていくにともない、さらに大きなPLレポートが組まれるようになりました。
巨大なPLレポートを組んだ場合、HTMLを組む部分がバックエンドにあるとはいえ、受け取ったHTMLをフロントエンド側でペイントすることに負荷がかかり全体の描画が著しく重くなる事象が観測されました。 その影響で、レポートの表示設定を変更するコンポーネントの操作がおぼつかなくなってしまう場面もみられ、パフォーマンスを改善するプロジェクトが立ち上がりました。
つい最近のPLレポートでの限界 < 25万セル
CanvasによるPLレポート
レポート部分のペイントに負荷がかかり全体の描画が著しく重くなる事象は巨大なHTMLテーブル全体を常に表示しているため、スクロールなどのイベントごとにテーブル全体の再描画がかかることでパフォーマンスに悪影響を与えていたことが原因でした。 そのため、基本的には描画領域を制限する方法を取ることとなり、実際の方法としては下記の2つの方法に絞られました。
- Canvasによって表示領域のみレポートを描画する
- 仮想スクロールによって表示領域のみHTML描画するような制御を入れる
どちらの方法も甲乙つけ難かったのですが、仮想スクロールには下記の点にネックがあったためCanvas化へ舵を切ることにしました。*2
- ライブラリを利用することになりコア機能がライブラリに振り回されることになりかねない
- フロントエンド側でHTMLを組む時点で実施していれば、流用できるコンポーネントが豊富にあるため採用するメリットは大きいものの、バックエンド側でHTMLを組むようにしていたため、流用できるコンポーネントはなくどちらにしても1からの実装が必要だった
- 適切なコード分割ができれば複雑なテーブルの表現がCanvasのほうが容易だと感じた
Canvas化はメリットだけではなくデメリットもありました。 例えば、Canvas要素に描画されるテーブルはテーブルタグのような構造の情報を持っているわけではありません。
そのため、下記は諦めることになりました。
- テーブルをドラッグして選択することで、テーブル構造を維持しながらExcelやスプレッドシートにコピー&ペーストすること*3
結論として、下記の図の形でCanvasを活用してユーザーが見ている範囲のみを描画する形に変更しました。
その結果、描画が高速になった上、ペイントは見ている部分のみのため負荷が激減し他のコンポーネント描画を阻害することも無くなりました。 バックエンド側のパフォーマンスやフロント側で持てるデータサイズの限界もあり、セル数制限を撤廃するところまではできなかったものの、制限を大きく緩和することに成功しました。
CanvasによるPLレポートでの制限 < 50万セル
実装上の工夫
Canvasは便利ではあるもののReactでサポートがほとんどされていないこともあり、気をつけないと可読性が低く後から手を入れづらいコードになってしまいます。 そのような事態を回避するためにも、CanvasをReactで活用する際には下記のことを常に意識するといいと感じました。
CanvasはReactの世界で扱えない
そのため、DIGGLEでは下記のような工夫をしました。
- 避難ハッチを使う(useRef)部分を隔離
- 複数キャンバスを用意しレイヤーすることで、それぞれのキャンバスでの描画をきちんと独立させる
- React部分とVanillaJS部分を隔離
避難ハッチを使う部分(useRef)を隔離
前述の通りCanvasはReactの世界で扱えないため、Reactに用意されている避難ハッチであるuseRefを活用する必要がありました。
useRefはReact公式でベストプラクティスの案内に従ってブラウザAPIと連携をする場所でのみ利用することにしました。
refが有用なのは、外部システムやブラウザAPIと連携する場合です
useRefを使う部分は基本的にカスタムHookで隔離を行い、useRefを直接触らないようにする。できない場面ではコンポーネントごと隔離をすることでuseRefの乱用による予測のできないコンポーネントの発生を抑止しました。
具体的には下記のような形です。
- Canvasに関わるRefはカスタムHookなどに押し込み、基本的にはcanvasタグに渡すときのみ利用する
- スクロールイベントなどのユーザーの操作に伴って描画が変わるイベントハンドラー(イベントの管理)は一つのコンポーネントにまとめることで、どのコンポーネントでイベントハンドラーが発火された結果Canvasの描画が変化したのか分かりやすくする
Canvasに関わるRefはカスタムHookなどに押し込み、基本的にはcanvasタグに渡すときのみ利用する
クラスにCanvasに関わるRefと関連関数を押し込みました。 クラスを通してRefに触ることで、Refの利用範囲を制限してどう言ったことをしているのかイメージしやすくしています。
class ReportCanvasRef extends BaseClass(zObject) { klass: new ( ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, canvasRef?: ReportCanvasRef | undefined ) => BaseRenderer = ValueRenderer injectRef = (ref: HTMLCanvasElement | null) => { if (_.isNil(ref)) return this.canvasRef = ref } ...
スクロールイベントなどのユーザーの操作に伴って描画が変わるイベントハンドラー(イベントの管理)は一つのコンポーネントにまとめることで、どのコンポーネントでイベントハンドラーが発火された結果Canvasの描画が変化したのか分かりやすくする
ControlAreaというコンポーネントを用意し、その中にスクロールイベントやクリックイベントなどのEventListenerをまとめています。
const ControlArea = forwardRef<ControlAreaRefHandler, ControlAreaProps>( ( { ... }, ref ) => { const controlAreaRef = useRef<DivRefType | null>(null) const scrollInnerRef = useRef<DivRefType | null>(null) ... useEffect(() => { if (!controlAreaRef.current?.divRef) return controlAreaRef.current.divRef.addEventListener('click', clickEvent) ...
複数キャンバスを用意しレイヤーすることで、それぞれのキャンバスでの描画をきちんと独立させる
MDN Web Docsでは「キャンバスの最適化」というページでCanvasのベストプラクティスを紹介してくれています。
CanvasはWebアプリケーションで常に利用されるわけではないせいか、情報が乏しくどういった構成にすることがよりよいのかを判断することが難しかったです。そういった判断においても、このページの情報がとても貴重でした。
DIGGLEにおいて特に役立った内容が複数レイヤーのキャンバスを利用です。
テーブルのヘッダー固定が必須の要件のうちの一つでした。一つのレイヤーで考えていた際にもglobalCompositeOperationを利用することで実現自体はできました。 しかしながら、可読性や運用の面から考えてみると一部でglobalCompositeOperationを使うことで、どの描画が優先的に上に表示されるのかがわかりづらくなる上に、描画の順番の制約が発生することを懸念しました。
MDN Web Docsのベストプラクティスに従い、DIGGLEでは複数レイヤーを利用することで基本的にはHTMLで表示の優先順位を決め、描画するレイヤーごとにglobalCompositeOperationが必要であれば活用する形を取ることで可読性や運用のしやすさを向上させました。
さらに、複数のCanvasを取り回す際にやりやすくするため、前述のReportCanvasRefクラスを使ってRefを準備するようなカスタムHookも用意しました。
import type { ReportCanvasRef } from './Refs/ReportCanvasRef' interface CanvasRender { handleRender: () => void } type PropTypes = { reportRef: MutableRefObject<Report> ... } export const useCanvasRender = ({ reportRef, ... }: PropTypes): CanvasRender => {
React部分とVanillaJS部分を隔離
Reactが使えない部分が多いということはVanillaJSで書く部分も増えます。 Reactに依存する部分と依存しない部分を分けておくことでReactのライフサイクルを予測できるものにしています。
さらに、canvasに実際に図形を書くdrawerと、それをまとめるrendererに分けて、rendererとdrawerの依存関係をinterfaceを使って逆転させておくことで後からの機能追加、機能削除をスムーズにできるようにしています。
まとめ
ユーザーにとってPLレポートがより使いやすく価値のあるものにするため、Canvasを活用したパフォーマンス改善を行い大きなレポートを表示できるようにしました。
Canvasの導入は経験もなくチャレンジングな取り組みでしたが、大きな問題もなく無事リリースできたことにホッとしています。
大きな問題なくリリースするためにはDIGGLEにおいてどのような実装をするべきかを整理することが重要でした。どのような実装にするといいのか?という資料が少ない中でよりよい形を見つけられたのは壁打ちや色々なアドバイスをしてくれた開発メンバーのおかげだと感じており、DIGGLEのバリューである「誠心敬意」を体現した事象だったと思います。
今回の経験を糧に、ユーザーの価値のさらなる最大化に向けて今後ともチャレンジを行なっていきます。
We're hiring!
DIGGLEではともにプロダクトを開発してくれるエンジニアを大募集中です。
少しでも興味があれば、ぜひ下記採用サイトからエントリーください。
カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!
*1:DIGGLEは管理会計のSaaSのため、ここでの損益計算書は変動損益計算書などの管理会計向けのものになります。 “変動損益計算書”の確認が優良企業への第一歩 | 情報誌「戦略経営者」 | 経営者の皆様へ | TKCグループ
*2:仮想テーブルも要件によっては最適な選択肢になることがあります。例えば、株式会社エモーションテックさんでは仮想テーブルを採用しています。 https://tech.emotion-tech.co.jp/entry/performance-improvement-by-intersection-observer
*3:代替機能として、TSV形式でテーブル構造をクリップボードにコピーする機能を実装したため、ユーザーへの影響を最小限に抑えることはできました