📜️

交差オブザーバーAPIとLINE風の過去発言読み込み無限スクロールUIの勘所

2023/12/25に公開

交差オブザーバー API とは?

  • Intersection Observer API とも言う
  • 交差とは?
    • そんな難しそうなことは考えなくていい
    • 「指定要素が人間に見える状態まで出てきているか?」の判定に使うとだけ考える
    • したがって API の名前が用途と合ってない
      • 命名がダメ
      • 技術駆動命名に寄りすぎ
  • 監視者と監視対象
    • 1:n の関係
    • 監視者インスタンスを1つ作り、監視される要素を複数登録していく
    • observe で監視登録し、(必要であれば) unobserve で解除する
    • 監視者は disconnect で全体の監視をやめることができる
  • コールバック
    • 見え隠れしたタイミングで呼ばれる
    • 監視登録したタイミングでも呼ばれる (重要)
      • 初回は見えてないことが多いので isIntersecting: false で来ることが多い
    • どのタイミングで呼ばれるかを正確に把握しておく (重要)
  • コールバック時の情報
    • 基本、必要なのは見えているかどうかを表わす isIntersecting だけ
    • 監視対象は target で参照できる
      • 監視解除する場合はそれを使う → observer.unobserve(e.target)
  • オプションについて
    • root
      • あまり重要ではない
      • 親要素つまりスクロールするエリアの要素を指定しておく
      • でも、指定しなくても動作に何の影響もなかった
      • 重要じゃないけど、とりあえず指定しておいたほうがよさそうぐらいの認識
    • rootMargin
      • わりと重要
      • スクロールエリア内に隙間がある場合に指定する
      • これを正確に指定しないと動くけど何かずれてる状態になる
      • とはいえ頭で理解して指定するのは(自分には)難しいので動作確認しながら設定する
      • 基本 CSS の指定と合わせる
        • しかし "50px 0" などと書くとはまる
        • "50px 0px" と書くのが正しい
    • threshold
      • 要素がどれだけ見えたら「見えた」と判断するかの調整に使う
        • 「見えた」とき isIntersecting: true でコールバックされる
      • 柔軟に設定できる
        • ぜんぶ見えたら → 1.0
        • 一瞬見えたら → 0.0
        • 半分見えたら → 0.5
        • 25% 見えるごとにトリガー → [0.25, 0.5, 0.75, 1.0]
      • が、とくに事情がなければ 1.0 固定で進めるのがわかりやすい

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

もっとも簡単な例


完全に見えていないときはグレースケールにする

コード
<template lang="pug">
.App
  .block
</template>

<script>
export default {
  mounted() {
    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(e => e.target.classList.toggle("visible_false", !e.isIntersecting))
    }, {
      threshold: 1.0,
    })
    observer.observe(document.querySelector(".block"))
  },
}
</script>

<style lang="sass">
.App
  height: 400dvh
  display: flex
  align-items: center
  justify-content: center
  margin: 0 64px
  .block
    width: 320px
    height: 240px
    background-color: LightSkyBlue
    &.visible_false
      filter: grayscale(1)
</style>

特定の領域内で複数要素を監視する例


完全に見えたとき青にする

コード
<template lang="pug">
.App
  p {{flags}}
  .parent
    .child.A A
    .child.B B
    .child.C C
    .child.D D
</template>

<script>
export default {
  data() {
    return {
      flags: {}, // 監視対象の状態確認用
    }
  },
  mounted() {
    // 1. 監視者を作る
    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(e => {
        this.flags[e.target.innerText] = e.isIntersecting // true:完全に見えている false:完全には見えていない
        console.log(`${e.target.innerText} ${e.isIntersecting}`)
        e.target.classList.toggle("visible_true", e.isIntersecting)     // visible_true: 完全に見えた
        e.target.classList.toggle("visible_false", !e.isIntersecting)  // visible_false: 完全には見えてない
      })
    }, {
      root: document.querySelector(".parent"), // スコープを指定しておく (指定しなくても動作は変わらなかった)
      rootMargin: "50px 0px",                  // CSS側の隙間と合わせる (重要)
      threshold: 1.0,                          // 完全に見えたときに isIntersecting を true にする
    })
    // 2. 監視対象を登録する
    observer.observe(document.querySelector(".A"))
    observer.observe(document.querySelector(".B"))
    observer.observe(document.querySelector(".C"))
    observer.observe(document.querySelector(".D"))
  },
}
</script>

<style lang="sass">
.App
  margin: 2rem
  .parent
    border: 8px solid hsl(0 0% 60%)
    height: 420px
    overflow: scroll
    overscroll-behavior-y: none
    display: flex
    flex-direction: column
    gap: 50px
    padding: 50px 0px  // ← これを rootMargin にも指定する(重要)
    .child
      font-size: 8rem
      color: hsl(0 0% 60%)
      border: 1px solid hsl(0 0% 60%)
      flex: 1 0 200px
      display: flex
      align-items: center
      justify-content: center
      background-color: hsl(0 0% 85%)
      &.visible_false
        background-color: LightPink
      &.visible_true
        background-color: LightSkyBlue
</style>

LINE 風の遡り無限スクロール


赤の要素がぜんぶ見えた瞬間に2つの要素を追加するが視点はぶれない

コード
<template lang="pug">
.App
  p flags={{flags}}
  p old_scroll_height={{old_scroll_height}}
  p seek_pos={{seek_pos}}
  p entries_count={{entries_count}}
  .parent(ref="parent")
    .row(v-for="row in rows" :key="row") {{row}}
</template>

<script>
const LATEST  = 10              // 最新の発言番号
const STEP    = 2               // まとめて読み込む発言数
const PADDING = 100             // スクロールエリア内の隙間 (px)

export default {
  data() {
    return {
      seek_pos: LATEST - STEP,  // 読み込んだ最後の位置
      old_scroll_height: null,  // 古い発言を差し込む直前の高さ
      observer: null,           // 監視者

      // デバッグ用
      flags: {},                // 監視した要素の状態
      entries_count: 0,         // 監視した要素の数 (常に1のはず)
    }
  },
  mounted() {
    // 発言の最上位(一番古いもの)を監視する
    this.observer_start()

    // 一番下にスクロールしておく
    this.$refs.parent.scrollTop = this.$refs.parent.scrollHeight

    // スクロール操作の自動化
    setInterval(() => this.$refs.parent.scrollTop -= 1, 1000 * 0.01)
  },
  beforeUnmount() {
    // 実戦ではコンポーネントを離れるときに解除しておく
    this.observer_stop()
  },
  methods: {
    // 発言の最上位(一番古いもの)を監視する
    observer_start() {
      this.observer_stop()
      this.observer = new IntersectionObserver((entries, observer) => {
        this.entries_count = entries.length // 監視対象の数を確認する

        this.flags = {}
        entries.forEach(e => {
          this.flags[e.target.innerText] = e.isIntersecting // 状態確認用
          console.log(`${e.target.innerText} ${e.isIntersecting} ${e.intersectionRatio}`)
          e.target.classList.toggle("visible_true", e.isIntersecting)   // 見えたら visible_true
          e.target.classList.toggle("visible_false", !e.isIntersecting) // 見えなかったら visible_false

          // ぜんぶ見えたとき
          if (e.isIntersecting) {
            // もう用はないので解除する
            observer.unobserve(e.target)

            // 差し込む前の領域の高さを保持しておく
            this.old_scroll_height = this.$refs.parent.scrollHeight

            // 過去のコンテンツを STEP 件数読み込む (と仮定する)
            // (実際はサーバーから読み込んだのを、現在のコンテンツの奥に差し込む感じになる)
            this.seek_pos = this.seek_pos - STEP
            if (this.seek_pos < 0) {
              this.seek_pos = 0
            }

            // もうコンテンツがない場合は監視を止める
            // (実際はサーバーから読み込んだコンテンツがそれで最後だった場合)
            if (this.seek_pos === 0) {
              this.observer_stop()
            }

            // コンテンツが更新されてからスクロール位置を元に戻す
            this.$nextTick(() => {
              this.$refs.parent.scrollTop = this.$refs.parent.scrollHeight - this.old_scroll_height + PADDING

              // スクロール位置を元に戻してから次のフレームで最上位の要素を監視する (順序重要)
              this.observer_observe()
            })
          }
        })
      }, {
        root: this.$refs.parent,                 // なくても動作に影響なかったが指定しておいたほうが良さそう
        rootMargin: `${PADDING}px 0px`,          // CSS と合わせる。これがないと判定もずれる。
        threshold: 1.0,                          // isIntersecting: true とするタイミング。1.0:すべて 0.5:半分 0.0:一瞬
      })

      // スクロール位置が一番下まで移動したあとで最上位の要素を監視する
      this.observer_observe()
    },

    // 最上位の要素を監視する
    observer_observe() {
      if (this.observer) {
        this.$nextTick(() => {
          this.observer.observe(document.querySelector(".row:first-child"))
        })
      }
    },

    // 監視をやめる
    observer_stop() {
      if (this.observer) {
        this.observer.disconnect()
        this.observer = null
      }
    },
  },
  computed: {
    // 表示する発言の配列
    rows() {
      const av = []
      for (let i = this.seek_pos; i <= LATEST; i++) {
        av.push(i)
      }
      return av
    },
  },
}
</script>

<style lang="sass">
.App
  margin: 2rem
  .parent
    border: 8px solid hsl(0 0% 60%)
    height: 420px
    overflow: scroll
    overscroll-behavior-y: none
    display: flex
    flex-direction: column
    gap: 0px
    padding: 100px 0px // PADDING と合わせる。rootMargin にも指定する(重要)
    .row
      font-size: 4rem
      color: hsl(0 0% 60%)
      border: 1px solid hsl(0 0% 60%)
      flex: 1 0 200px
      display: flex
      align-items: center
      justify-content: center
      background-color: hsl(0 0% 85%)
      &.visible_false
        background-color: LightPink
      &.visible_true
        background-color: LightSkyBlue
</style>

スクロール中の視点を維持する

LINE や Slack のように過去にスクロールするとサーバーから読み込まれた古いコンテンツが差し込まれる。このとき何もしないと差し込まれたコンテンツの一番古い発言に視点が瞬間移動してしまう。こうなるとユーザーはどこまで辿ったかが、わからなくなるばかりでなく、連続的にトリガーが発生し、セルフDoS攻撃でサービスが不安定にもなる。つまり何もしないだけでわりと大変なことになる。

その対策がこちらで紹介されている。

https://tech.kurojica.com/archives/30885/

それを元にしたのが、

this.$refs.parent.scrollTop = this.$refs.parent.scrollHeight - this.old_scroll_height + PADDING

の部分で、最後に rootMargin で微調整した長さを足した。これでコンテンツ領域の高さが変化しても視点は1ピクセルもぶれない。

最上位の要素を監視するタイミングに気をつける

this.$nextTick(() => {
  this.observer.observe(document.querySelector(".row:first-child"))
})
  • 初回 → スクロール位置を調整して視点を一番下に移動したあと
  • 次回 → スクロール位置を調整して視点を戻したあと

どちらにしてもセルフDoS攻撃にならないよう「最上位の要素が隠れたあと」で行う。それにはスクロール位置を変更したあと次のフレームで行うのが適しているので $nextTick 内で実行するのが適している。

Discussion