こんにちは、SmartHRでプロダクトエンジニアをやっている @Tokky0425 です。
みなさんは普段組織で働いていますか?僕は組織で働いています。
組織で働くにあたって便利なもの、ありますよね。そう、組織図です。
SmartHR にも組織図の機能があるのですが、部署数や従業員数が多いとブラウザ上での操作が重くなってしまうという問題がありました。
最近「数万人規模の組織図での 60fps」を目指してこの組織図機能の描画パフォーマンス改善を行ったので、その中で実践したフロントエンド開発における大量描画処理の Tips を共有します。(React を前提としています)
そもそも問題はどこにあるのか
最近まで、SmartHRの組織図機能は数万人規模の企業で十分に使えるような作りになっていませんでした。というのも、対象従業員数が数万人を超えてくると、いろんな操作が著しく重くなり、実用に耐える操作性を提供できないという問題があり、一部の機能を制限するような形で提供せざるをえないという状況だったのです。
操作が遅くなっている原因は描画している要素が多すぎるということにあることは明らかだったのですが、この問題を、
- ブラウザのレンダリングに時間がかかっている
- JavaScript (React) の処理に時間がかかっている
の2つに切り分けてみると、どうやら前者により大きな問題が発生しているようでした。
具体的には、Chrome の Performance タブから組織図をスクロール移動したときの処理を計測をしてみると「Hit test」という項目が大量に発生しており、これが快適な操作のボトルネックになっていることがわかりました。

Hit test とは、マウスカーソルがどの要素に重なっているかなどをブラウザ側が判定する処理のことです。これによってどのボタンがクリックされたのかなどが判定されるわけです。
ブラウザの仕事を減らす
問題は把握できたので、次はどう解決するかです。
Hit test を減らすための大方針は、画面外の要素を表示しない (もしくは display: none; をつける) ようにするというものになります。Hit test を発生させうる要素を減らすことで、ブラウザにさせる仕事を減らすということですね。
IntersectionObserver で描画要素を減らす
この実装をするためには、IntersectionObserver API が有効です。IntersectionObserver は、対象の DOM が画面内に入ってきたこと (もしくは出ていったこと) を判定できる API で、これを使うことで「画面外の要素は表示しない」という処理が書けるようになります。
React での具体的な実装としては、下記のようなイメージです。(実際は後述する他の工夫も盛り込む必要があります)
/* 要素が画面外にある場合は children を描画しないようにするコンポーネント */ const VisibilityHandler = ({ children }) => { const wrapperRef = useRef(null) const [isVisible, setIsVisible] = useState(false) useEffect(() => { const wrapper = wrapperRef.current! const observer = new IntersectionObserver((entries) => { setIsVisible(entries.some((entry) => entry.isIntersecting)) }) observer.observe(wrapper) return () => { observer.unobserve(wrapper) } }, []) return <div ref={wrapperRef}>{isVisible ? children : null}</div> }
組織図の UI の場合、それぞれの部署ノードが画面内にあるかどうかを判定して、画面内の場合は表示、画面外の場合は非表示、という形にすれば良さそうです。

ただ、組織図の UI の組み立て方としては、部署ノードの幅や高さをブラウザのレンダリングエンジンによしなに計算してもらう仕組み (要するに width や height が固定値ではない) なので、このままでは IntersectionObserver を使うことができません。 画面外にあるからと言って部署ノードを非表示にしてしまうと、その部分は幅や高さが 0 になってしまい、その結果全体のレイアウトにも影響を与えてしまうからです。
そのため、部署ノードのラッパー要素に width と height を指定する形に変更し、部署ノードの DOM がレンダリングされていない状態でも幅/高さを持った空の DOM だけは常に描画されるように変更しました。

イメージ画像では簡略化のためにすべてのノードの幅/高さは同じにしていますが、実際は部署ノードごとに幅/高さは異なるので、ノードの構成要素から幅/高さを割り出すような関数を書くというかなり泥臭い実装になっています。
ともあれ、これによって、組織図のレイアウトを保ちつつ、IntersectionObserver によって画面外の部署は DOM として描画しないようにし、組織図操作時の Hit test の発生を大幅に抑えることができました。
Hit test が減ったことで、ブラウザの描画負荷は大幅に軽減され、組織図の操作性が大きく改善されました。
表示されていない要素にタブキー移動ができない問題を解消する
IntersectionObserver を使った要素の出し分けは大量描画の場面において簡単に効果が出せるものではありますが、 クリアすべき課題もいくつかありました。
課題の一つは、描画されていない要素にはタブキーによるフォーカスの移動ができないということです。DOM 自体が HTML 上に存在しないので、当然と言えば当然です。
IntersectionObserver のオプションに rootMargin を持たせることで、画面外の DOM でも画面枠に近いものであれば描画されるようになるので、縦に積み上がるリスト形式の UI などであればそれで解決できるパターンもあるかもしれません。 しかし、組織図のようなツリー形式の UI だと、次にフォーカスを当てるべき DOM が今見ている画面から遠く離れた場所にあることもあります。そのため、rootMargin では解決になりません。
対策として、組織図機能の実装では、フォーカスが当たっている要素を含む部署ノードの前後の部署ノードは、IntersectionObserver での判定にかかわらず常にレンダリングするようにしました。(focusin のイベントを listen し、都度判定処理をするようなイメージです)
同様に、組織図の先頭または末尾の部署ノードに関しても、組織図ツリーの UI 外からのタブキー操作によっていつでもフォーカスを移動できる状態になっているべきです。そのため、この2つの部署ノードに関しても常にレンダリングはしておくようにしています。

これらの実装によって、通常通りタブキーによる操作が問題なくできるようになり、描画パフォーマンスを高めることによるトレードオフを解消できました。
表示されていない要素が印刷できない問題を解消する
別の課題として、IntersectionObserver によって画面外の要素を非表示にすると、その要素は印刷時にも表示されなくなってしまうというものもありました。
もともと、組織図機能では、印刷用の CSS を書くことで組織図表示エリア外の部署ノードも含めて印刷対象となるような作りになっていました。(印刷時のみ overflow: hidden; を外して、枠外の要素も表示するようなイメージです)
今回 IntersectionObserver によって画面外の要素を非表示にするようにしたことで、この CSS があったとしても、画面外の部署ノードが表示されないという問題が発生してしまいました。印刷用 CSS で overflow: hidden; を外したとしても、そもそも DOM が存在しないので印刷時に表示されないということです。
この問題は、印刷モードに入ったときだけ true を返すような hook を書き、この hook の返す値が true の場合は IntersectionObserver での判定にかかわらずコンポーネントを描画することで解決できました。
実際に書いたのは下記のような hook です。
import { useSyncExternalStore } from 'react' let isPrintMode = false const getIsPrintMode = () => isPrintMode const onPrintModeChange = (callback: () => void) => { const mediaQuery = window.matchMedia('print') const listener = (e: MediaQueryListEvent) => { isPrintMode = e.matches callback() } mediaQuery.addEventListener('change', listener) return () => { mediaQuery.removeEventListener('change', listener) } } export const usePrintMode = () => useSyncExternalStore(onPrintModeChange, getIsPrintMode)
この処理によって、印刷ウィジェットを開いたタイミングで全部署ノードのレンダリングが発生するようになったので、印刷プレビューが表示されるまでに多少時間がかかるようになってしまったのですが、印刷対応という課題自体はクリアできました。
React の仕事を減らす
ここまでの対応ですでに大幅な操作性の改善は見られたものの、モニターが大きかったり、縮小表示で組織図を表示したりするとそれなりの数の部署や従業員が画面には描画されることになり、目標としていた「数万人規模の組織図での 60fps」を達成するためには、まだまだ改善の余地がある状態でした。
ブラウザの過度な仕事量は抑えられたとして、次に考えるべきは JavaScript の実行コスト、具体的には React のコンポーネントのレンダリングをいかに抑えるかということです。
コンポーネントの memo 化によって再レンダリングを抑制するということはすでにやっていたのですが、それらに加え下記の2つを新たな対策として導入してみました。
- コンポーネントレベルで出し分けるのではなく CSS レベルで出し分ける
- 本当に必要な情報だけ props で渡す
コンポーネントレベルで出し分けるのではなく CSS レベルで出し分ける
下記の Node コンポーネントは、Parent コンポーネントから渡される props によって Node コンポーネントの文字サイズが変わるようなコンポーネントです。
const Parent = ({ nodes, fontSize }) => { return ( <ul> {nodes.map((node) => <Node key={node.id} fontSize={fontSize} />) } </ul> ); }; const Node = memo(({ fontSize }) => { return ( <li> {/* fontSize の props によって文字サイズが変わる */} <p style={{ fontSize }}>{node.name}</p> </li> ); });
このコードも悪くないのですが、Node コンポーネントは fontSize を props として受け取っているため、memo 化虚しく fontSize の値が変わるたびに再レンダリングせざるを得ません。
この問題は、fontSize によって表示される要素を CSS で出し分けるように変更することで解決します。
const Parent = ({ nodes, fontSize }) => { return ( <> <style>{`.node_name { font-size: ${fontSize}px; }`}</style> <ul> {nodes.map((node) => <Node key={node.id} />) } </ul> </> ); }; const Node = memo(() => { return ( <li> {/* CSS によって文字サイズが変わる */} <p className="node_name">{node.name}</p> </li> ); });
この変更で、fontSize が変更されても Node コンポーネントは再レンダリングがされなくなるので、JavaScript の実行を減らすことができます。
要素を表示するかどうかのロジックが分散してしまうことでコンポーネントのカプセル化が損なわれ、保守性は下がってしまうのですが、カリッカリにパフォーマンスチューニングをする必要がある場合は、有効な手段となります。
本当に必要な情報だけ props で渡す
上記の例の続きで、fontSize という props が更に上流の GrandParent コンポーネントから渡されていたケースを考えます。
GrandParent コンポーネントは、scale という値を受けとり、その値に応じて fontSize の値を決めています。組織図が縮小表示になっても文字を見やすくするために文字サイズを大きくするようなイメージです。また、scale の値は 0.75 や 1 などの数値で、かつマウスホイール操作などによって細かくかつ頻繁に更新される、という前提を置きます。
/* scale の値は 0.75 や 1 などの数値で、マウスホイール操作などによって細かくかつ頻繁に更新される */ const GrandParent = ({ nodes, scale }) => { /* scale に応じて fontSize を決定する */ const fontSize = 16 / scale; return ( <Parent nodes={nodes} fontSize={fontSize} /> ); }; const Parent = ({ nodes, fontSize }) => { return ( <> <style>{`.node_name { font-size: ${fontSize}px; }`}</style> <ul> {nodes.map((node) => <Node key={node.id} />) } </ul> </> ); }; const Node = memo(({ scale }) => { // 略 });
ここで、Parent コンポーネントに渡す fontSize は整数部分だけで良いことに気づきます。なぜなら、CSS の font-size (px) はブラウザ上では整数単位でしか反映されないからです。(16.5px などは反映されません)
そのため、GrandParent コンポーネント内で値を整数に丸めてあげれば、Parent コンポーネントの無駄な再レンダリングを防げます。
const GrandParent = ({ nodes, scale }) => { /* 小数点を丸める */ const fontSize = Math.round(16 / scale); return ( <Parent nodes={nodes} fontSize={fontSize} /> ); }; /* memo 化も忘れずに */ const Parent = memo(({ nodes, fontSize }) => { // 略 }); const Node = memo(({ scale }) => { // 略 });
上記はやや都合が良すぎる例ではありますが、頻繁に変更されるような値を扱う場合は、このようなちょっとした props の整理によってパフォーマンスを高められる余地がないか見直してみると良いかもしれません。
まとめ
この記事内で紹介したようなパフォーマンス改善の結果、数万人規模の組織図でもほとんどの操作で 60fps を保てるようになりました。
大量描画による操作のカクつきに困らされているという場合は、是非参考にしてみてください!
We Are Hiring!
SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!