リストアイテムの高さが固定されていない場合のVirtual Scroll(Virtual Rendering)の代替手段

  • 2
    いいね
  • 3
    コメント

前提

ウェブフロントエンドにおけるリストのレンダリング高速化についてのTipsです。
vue.jsなどのバインディング機構をもったフレームワークを使っていることを想定していますが、アイディア自体は汎用的なものです。

Virtual Scroll/Virtual Rendering

大量のアイテムをリスト表示する場合などに、実際のレンダリング対象を絞ることでレンダリングを高速化するというテクニックがあります。このテクニックはVirtual ScrollやVirtual Renderingと呼ばれることが多いです。

Virtual Scrollを実現するライブラリのほとんどはレンダリング前に各アイテムの高さが分かっている必要があります。
高さが分からなければ、現在のスクロール位置において、どのアイテムをレンダリングすべきなのかを判断できないからです。

しかし、アイテムの高さを事前に決めるのが難しい場合があります。HTMLにおいては、表示内容によってアイテムの高さが変わることがあるからです。
アイテムの高さが事前に判断できない場合であっても、Virtual Scrollのようなレンダリングの高速化を実現するテクニックを思いついたので共有します。

代替手段のアイディア

アイディアは単純で、ビューポートの外にある要素はサイズを固定して内容を空にするというものです。
内容を空にすることでレンダリング対象のDOMノードを削減して高速化を図ります。
カード形式のコンポーネントなど複雑な要素を数百程度表示する場合などに効果を発揮します。
ただし、一般的なVirtual Scrollと比較して、項目ごとの表示要素が残るため、表示要素がシンプルで大量の要素を表示するようなケースでは逆効果となってしまいます。

実装例

下記にVue.jsによるサンプルを示します。保存してブラウザで表示すれば動作します。

<body>
<div id="app">
  <ul style="display:block; width: 320px; height:480px; overflow-y:scroll;" @scroll="onScroll">
    <li v-for="item in items" :key="item.id" :data-item-id="item.id">
      <div v-if="!item.virtual">
        <strong>{{ item.title }}</strong><br/>
        {{ item.description }}
      </div>
    </li>
  </ul>
</div>

<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script>

function isOutOfViewport (ulRect, el) {
  let rect = el.getBoundingClientRect()
  return (
    rect.top + rect.height < ulRect.top ||
    rect.top > ulRect.bottom
  )
}

new Vue({
  el: '#app',
  data: function () {
    let items = {}
    let texts = [
      'あのイーハトーヴォ',
      'あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら',
      'あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。'
    ]
    for ( let i = 0; i < 300; i++ ) {
      items[i] = {
        id: i,
        title: 'Title ' + i,
        description: texts[Math.floor((Math.random() * 10) % 3)],
        virtual: false
      }
    }
    return {
      items: items
    }
  },
  methods: {
    onScroll (event) {
      let ulRect = document.querySelector('ul').getBoundingClientRect()
      document.querySelectorAll('li').forEach(el => {
        let id = el.getAttribute('data-item-id')
        if (isOutOfViewport(ulRect, el)) {
          this.items[id].virtual = true
          let r = el.getBoundingClientRect()
          el.style.width = r.width + 'px'
          el.style.height = r.height + 'px'
        } else {
          this.items[id].virtual = false
        }
      })
    }
  }
})

</script>
</body>

前述した、「ビューポートの外にある要素はサイズを固定して内容を空にする」 をしているだけです。
スクロールイベントの度に行う計算が多いのでやや不安になりますが、要素が数百程度であれば問題にはならないでしょう。

私の環境でこの仕組みが必要となったのはモバイルでカードコンポーネントを大量に表示するケースでした。この仕組みを導入するまえはスクロールがガクガクだったのですが、導入後はスムーズになりました。

見えない位置の表示要素をひとつのブロックにまとめられると更に高速化できると思うのですが、それをうまく実装するのが難しく断念しました。