2020年3月14日に投稿

【備忘録】Vueの仮想DOMと再描画の話

今回はVueのライフサイクルメソッドに関わるお話。

リアルタイムチャットアプリなどを作る際にAPIサーバからデータを取得したときに画面を自動的にスクロールさせたいなど、非同期通信に合わせてDOMを操作したいことは割とよくある話だと思います。
でもこれをVueで実現しようとしたところちょっとハマりかけたので備忘録としてブログに残しておきます。
誰かの役に立てば幸いです。

やりたいこと

Firestoreのコレクションを購読して(onquerySnapshot())更新があった時にチャット欄にデータを表示して一番下までスクロールさせる、という機能を考えます。

まずは愚直に実装

mountedメソッドを使ってインスタンスがマウントされたタイミングでonquerySnapshotを張るようにします。
なお晴れて脱Javascriptしたので今回からTypescriptでコードを書いていますがご容赦ください。(型定義いいよ、型定義。)

<template>
  <v-container id="scroll-target" style="max-height: 550px" class="overflow-y-auto">
    <div id="scroll-content">
      <!-- チャットを表示するコンポーネント -->
      <ChatItem
        v-for="chat in chatArray"
        :key="chat.id"
        :chat="chat"
      />
    </div>
  </v-container>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { db } from '@/plugins/firebase'
import { Chat } from '@/models'  // Firestoreのドキュメントの型定義
import ChatItem from '@/components/ChatItem.vue'
@Component({
  components:{
    ChatItem
  }
})
export default class ChatSheet extends Vue {
  unsubscribe: (() => void) | null = null  // 購読解除用関数を格納
  chatArray: Chat[] = []
  
  mounted() {
    let unsubscribe = db.collection(`version/1/chat`)
    .onSnapshot(querySnapshot => {
        querySnapshot.docChanges().forEach(change => {
          if(change.type === "added") {
            this.chatArray.push(change.doc.data() as Chat)
          }
        })
      }, (error => {
        console.error(error)
      }))
    this.unsubscribe = unsubscribe
  }
}
</script>

これでFireStoreでデータの追加が発生した時にChatItemコンポーネントが自動的に増えていく仕組みはできました。

自動スクロール機能を実装する

ここからが本題です。

このデータの変更を検知して<v-container>の中身を自動的にスクロールさせるにはどうするか?

結論から言うと、watchVue.$nextTickを組み合わせて使います。

<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'
import { Chat } from '@/models'  // firestoreのドキュメントの型定義
import ChatItem from '@/components/ChatItem.vue'
@Component({
  components:{
    ChatItem
  }
})
export default class ChatSheet extends Vue {
  unsubscribe: (() => void) | null = null  // 購読解除用関数を格納
  chatArray: Chat[] = []
  
  mounted() {
    let unsubscribe = db.collection(`version/1/chat`)
    .onSnapshot(querySnapshot => {
        querySnapshot.docChanges().forEach(change => {
          if(change.type === "added") {
            this.chatArray.push(change.doc.data() as Chat)
          }
        })
      }, (error => {
        console.error(error)
      }))
    this.unsubscribe = unsubscribe
  }
  
  // dataの'chatArray'を監視する
  @Watch('chatArray')
  onChange() {
    this.scrollToLastItem()
  }
  
  scrollToLastItem() {
    this.$nextTick(() => {
      let content = this.$el.querySelector('#scroll-content')  // 要素の中身
      let target = this.$el.querySelector('#scroll-target')  // スクロールさせる対象のDOM要素
      if(!content || !target) {
        return
      }
      target.scrollTop = target.scrollHeight  // DOMを操作してスクロールさせる
    })
  }
}
</script>

datachatArraywatchで監視して、変更があった時にDOMの更新が完了するのを待ってから中身の要素の高さを算出してスクロールさせるようにします。
もちろんwatchではなくupdatedを使って更新を検知しても良いですが、他の要素が更新された際にも処理が走ってしまうので注意が必要です。
公式にも以下のように書いてあります。

このフックが呼び出されるとき、コンポーネントの DOM は更新した状態になり、このフックで DOM に依存する操作を行うことができます。しかしがながら、ほとんどの場合、無限更新ループに陥る可能性があるため、このフックでは状態を変更するのを回避すべきです。

ライフサイクルフック updatedより引用

DOMの更新と仮想DOMの更新のタイミングに気を付ける

mountedwatchなどが発火するタイミングではDOMはまだ更新されていないことに注意が必要です。
さらに、mountedは全ての子コンポーネントもマウントされていることを保証しません
そのためthis.$nextTickを使わない場合は、mounted内で取得したデータが子コンポーネントにマウントされることが保証されないので、要素の高さを正しく取得することができません。
なので今回の例のように、DOM操作を行う関数の中でDOMの描画が完了するのを待ってから処理を行うことをオススメします。

あまり深い内容ではなく恐縮ですが備忘録として残しておきます。
それではまた次回。