📷

[macy.js拡張] Pinterest風レイアウトの仮想化ノウハウ

2024/08/07に公開

弊社アプリ Arounds ではindexページにて
Masonry layoutにてユーザーが投稿したアイテムなどをタイル状に表示しています。
※ 提供中のサービスでもありコード自体ではなく、初稿としては課題と対策について概念を示しています。
※ もしかすると、この記事をご覧の頃にはどこかに仮想化に対応した美しいmasonryレイアウトのライブラリもあるかもしれませんが、当時それがなかったために工夫した軌跡を綴ったものです。

Masonry Layoutとは?

Pinterestで採用されていることで有名なレイアウトで、
上から下方向にタイル上に隙間を埋めるようにアイテムが並んでいくレイアウト形式です。
(※この記事では、タイルに並べる一つ一つの要素をアイテムと呼びます)


Arounds: https://arnds.me

今回目指したレイアウト

  1. Masonry layoutを用いて
  2. 一定のルールで優先度付けしたアイテムを上から順番に敷き詰める
  3. 並べる各アイテムの高さは固定しない
  4. そのとき inifinite scroll に耐えられるよう仮想化する

課題: "高さがまばらなアイテムを下方に追加していく"への障壁

課題1. レイアウトライブラリによって並べ方が様々

上から下に隙間なく並べていくのでなく、一番左の列を並べきってから、その隣の列、、、と並べていく方式のライブラリも散見されます。これでは、infinite scrollで要素を追加したときに全部を並べ直すことになり、追加ロード分が発生するとレイアウト全体をメモリに展開して再配置したうえで、下方でなく、最後の列(右列)に押し込まれることになります。負荷としてもレイアウトの実現性の意味でも不適切です。

※ infinite scrollなしで、並び順も気にしない人は、それらライブラリでも十分と思うので、この記事は読む必要はありません。

課題2. アイテム高さの確定とレイアウトタイミングをあわせる難しさ

前提として、レスポンシブなレイアウトで、かつ、アイテムにはアスペクト比に自由度のある画像が含まれます。
このアプリでは表示する要素の中にInstagramのEmbed表示も含まれており、つまり、動的に高さが変わる外部要素を積極的に受け付けています。その意味でも、ロードの瞬間に動的に配置を判断させる形を選択しています。この高さ確定のタイミングとmasonryレイアウトのタイミングを調整することが地味に厄介でした。

課題3. masonryへのアイテム追加 = recalculateでの高さ潰れ

普通にmasonryレイアウトにアイテムを追加して、追加分の配置をしようレイアウトの再計算を実行すると、すべてのレイアウトを破棄して再度積み上げようとしています。そのとき、幾分スクロールしていると、その領域が一瞬なくなるので画面がちらつく挙動となります。

課題4. 標準の仮想化はアイテム高さが固定されることが前提なので使えない

(記憶が曖昧ですが)ライブラリ標準の仮想化は、各要素の高さを固定値(例えば300px)と仮定して、そのうえで、スクロール距離がXだから、N番目からM番目まで表示する、といった計算を行っていました。上記の通り、本アプリでは高さがアイテムごとに異なるので、この用意された仮想化は適用できませんでした。

解決方法

課題1への対策 -> macy.js!

結果、採用したのは macy.js 。コードの限り、上から順に高さを計算して積み上げていく方式。配置を完了した要素にはフラグを立てて、再計算コストを抑えるような機構もあるので、仮想化にも向いている。

https://github.com/bigbite/macy.js

課題2への対策 -> img.metadataのロード + debouncedなMutationObserverの活用

アイテム内の画像のmetadataがロードされてアスペクト比が判明し、それを元に、用意された表示幅に合う表示高さが計算された後に、その下に積み上がっていく要素のtop位置も確定させるようにする。(※)

「配置しそこねること」は絶対に避けたかったので、個別アイテムが配置されたら、macyのrecalculateイベントを発火するようにする必要があった。しかし、普通に発火させると、アイテムの個数分イベントが発火してバグる。そのため、recalculateにdebounce処理(イベント発生から一定時間同じイベントが発火しなければ実行。もし発生すれば、また一定時間待機して発火判定、、とする処理)をかませた。そのうえでアイテム数分EventListenerが増えていくのも微妙かと思い、MutationObserverを用いて、アイテムを並べる箱(div)自体とそのchildren(=アイテム群)を監視させて、それをきっかけにdebounced recalculateを叩くようにした。結果、概ねこの方式で問題は解決した。

ちなみに、重要なオマケ要素としては、macy.recalculateのオプションとしては以下を採用しています。
refresh = true, markAsCompleted = true
前者は確実によりレイアウトするため、後者は仮想化時にも正しく最下部に追加アイテムを並べるためです。

※ そもそも画像のアップロード時点で画像のアスペクト比をDB格納しておけば上記ステップをいくつか省けますが、上記の通り、そもそも高さが動的に変わり得る外部コンテンツも積極的に取り込んでいるので、フロントでロード後にでも全て解決できるように取り組んでみています。

課題3への対策 -> min-heightを上手に使う

これは簡単で、macy.recalculateを包含する関数にて、recalculateを実行する直前のstyleからheightを拝借して、min-heightを次のようにセットすることで解決した。

containerRef.current.style.setProperty("min-height", styles.height);

課題4への対策 -> 自前実装: data要素を活用しての仮想化

これが地味に今回の投稿の肝です。表示物が多い際の"仮想化"は、つまりは「部分的にレンダリングする」ということです。アイテムが100件あれば、20件目から40件目までレンダリングして、あとはメモリに置いておくなど。主にモバイル端末での描画性能の問題を回避するための措置です。

今回どう対処したかと言うと、簡単な話で、"丸めたtop位置"を各アイテムにdata-masonry-top=0000001などとセットして、実際のスクロール位置から、表示範囲を割り出してフィルタするというものです。

地道な処理ですが、軽く抜粋すると以下の通りです。この処理で取得したkeyのリストをもとにレンダリング対象を特定します。

    const { top: containerTop } = container.getBoundingClientRect();
    const windowHeight = window.innerHeight;

    let visibleItemKeys: string[] = [];
    // 概算でUI上の表示エリアから上下windowHeight分の範囲を取得
    const minTopx1000px = Math.floor(
      Math.max(0, -1 * containerTop - windowHeight) / 1000,
    );
    const maxTopx1000px = Math.ceil(
      (-1 * containerTop + windowHeight * 2) / 1000,
    );
    for (let i = minTopx1000px; i <= maxTopx1000px; i += 1) {
      // 0000001XXX という形式で、上からの位置を表現する。
      const targetTopStr = zeroPadXdigitsString(i, 7);
      visibleItemKeys = visibleItemKeys.concat(
        Array.from(
          container.querySelectorAll<HTMLDivElement>(
            `[data-masonry-top^="${targetTopStr}"]`,
          ),
        )
          .map((element) => element.dataset.key)
          .filter((key) => key !== undefined) as string[],
      );
    }
 const uniqVisibleItemKeys = Array.from(new Set(visibleItemKeys));

まとめ

Masonryレイアウト自体はかっこいいし、没入感を持って浴びるように閲覧してもらうのに向いています。

上にも書きましたが、例えば並べる要素の高さを固定することで、一気にこの記事に書いたような課題や対策は不要になり、簡単に実装できるようになるはずなので、目的に沿ってコスト判断が必要です。

いざinfinite loadとなると考慮事項が多くて結構辛さがありましたが、
Mutation Observerの活用や、data要素を用いた仮想化のデジアナな解決方法など、面白い開発でした。
より詳しく知見を聞きたい方なども、お気軽にご連絡ください。

告知
この記事が少しでも誰かのお役に立てば幸いです。
よかったら、今回の知見を得たきっかけになったサービス、Aroundsに遊びに来てください♪
趣味や愛用品があなたを物語ってくれる、新しいスタイルのLink in bioプラットフォームです。

エンジニアチームのコミュニケーション促進も果たせると思うので
好きな漫画や信仰している技術書、デスク周りのガジェット、思い出の旅行先など集めて教えてください^^

Happy hacking!

FunnySideUp

Discussion