2020-03-31

【Vue.js】 $nextTick() で動的に描写される DOM 要素の位置や高さを取り扱う

やりたいこと

  • Vue.js で、動的に描写される要素の画面上部からの高さを取得したい。
    • ロード中はロード画面が表示される。(loading: bool で状態管理)
    • 絞り込みを行った場合は検索条件ボックスが表示されるため、上からの高さが動的に変化する。

算出プロパティでの取得はできない

おなじみの vm.$refs で特定の DOM 要素にアクセスできるので、computed() で取得しようとしたところ…

<div ref="targetEl"></div>

computed: {
  targetOffsetTop() {
    // Error: Cannot read property 'getBoundingClientRect' of undefined
    return (
      this.$refs.targetEl.getBoundingClientRect().top + window.pageYOffset;
    );
  }
}

computed は マウントよりも早いタイミングで実行されるため、computed の中で vm.$refs にアクセスすることはできません。

参考: Vueライフサイクルの盲点、computedの一発目の発火はmountedより早い

$refs はリアクティブでない

では $refs の変化に応じて computed() させればいいのでは?と考え、以下のように記述。

computed: {
  targetOffsetTop() {
    if (this.$refs.length === 0) {
      return 0;
    }
    return (
      this.$refs.targetEl.getBoundingClientRect().top + window.pageYOffset;
    );
  }
}

しかし、マウントが完了して $refs.targetEl が見れるようになっても、 targetOffsetTop=0 のまま値が変わらない。

これは公式ドキュメントの注意書きにもありますが、$refs がリアクティブでないためです。

$refsはコンポーネントの描画後にデータが反映されるだけで、リアクティブではありません。子コンポーネントへの直接操作のための、退避用ハッチのような意味合いです(テンプレート内または算出プロパティから$refsにアクセスするのは避けるべきです)。

特別な問題に対処する - Vue.js

(そもそもここに算出プロパティで $refs にアクセスするなと書いてありました)

描画が完了してから処理を行うには、this.$nextTick() を使おう

そもそも今回やりたいことは、this.loading=false と状態が変化し DOM が更新されてから $refs.targetEl の位置を取得したいというものです。

このような処理には Vue.nextTick(callback) を利用します。

データの変更後に Vue.js の DOM 更新の完了を待つには、データが変更された直後に Vue.nextTick(callback) を使用することができます。そのコールバックは DOM が更新された後に呼ばれます。

リアクティブの探求- Vue.js

(リアクティブの探求…かっこいい)

したがって処理を整理すると、

  1. this.loading = false として状態を変化
  2. this.nextTick() で状態変化によって DOM が更新されるのを待つ
  3. DOM の描写が完了した状態で、適切に this.$refs.targetEl を取得

というようになります。正しく動作するコードは以下の通りです。

data: () => ({
  items: []
  loading: true,
  targetOffsetTop: 0,
}),
async mounted(): {
  // api を叩いてデータを取得したりする処理
  this.items = await this.fetchItems();
  this.loading = false;
  // DOM が更新されるのを待つ
  await this.$nextTick();
  // 待った後、`$refs` にアクセス
  this.offsetTop = 
    this.$refs.container.getBoundingClientRect().top + window.pageYOffset;
}

リアクティブの探求をすることで問題が解決しました。仮想 DOM は奥が深いですね。