Katashin .info

SSR + vue-meta で hydration 直後の変更が反映されない問題の対策

発生した問題 #

最近 Nuxt を使った Web サイトを実装することがあり、以下のように SSR の hydration 後に vue-meta を使用して viewport の値を書き換えるようなコードを書いていました。

// layouts/default.vue

export default {
  data() {
    return { screenWidth: 0 }
  },

  mounted() {
    // クライアントサイドでデバイスの幅を取得する
    this.screenWidth = window.screen.availWidth
  },

  head() {
    const contentWidth = 1024
    const breakpoint = 767
    const likelyTablet =
      breakpoint < this.screenWidth && this.screenWidth < contentWidth

    // タブレットっぽかったら viewport をコンテンツ幅に指定
    const content = likelyTablet
      ? `width=${contentWidth}`
      : 'width=device-width,initial-scale=1'

    return {
      meta: [{ hid: 'viewport', name: 'viewport', content }],
    }
  },
}

SSR の段階では screenWidth が設定されないため、viewport が width=device-width,initial-scale=1 となりますが、その後、スクリーンの幅を見て、その値がある一定の範囲に収まる場合は viewport の設定を変えています。

vue-meta は Vue インスタンスのデータが更新されたらそれに対応する meta 要素を更新するようにしているので、これでうまくいくような気がしますが、動きません。実際には SSR 直後の値のまま変わらないです。

原因 #

原因は上記のような SSR 直後に発生させる更新はすべて vue-meta の hydration 処理として扱われてしまっているためでした。

vue-meta の実装を追いかけてみると、各コンポーネントで metaInfo (Nuxt では head) が指定されているとき、meta 系の要素の更新リクエストを発生させているようですが、それらはすべて requestAnimationFrame で遅延させて、短い間に複数の更新リクエストが走っても 1 つにまとめられているようです。

また、一方で、SSR で描画された meta 要素を再描画してしまわないために、1 回目の更新では実際の DOM の更新は行われないようです。

これによって、mounted フックの中で発生させた値の更新 (とそれに伴う meta の更新) は SSR 直後の 1 回目の更新に吸収され、期待した挙動をしていなかったということでした。

対策 #

とりあえず以下のように setTimeout などで mounted 内の更新も遅らせましょう。

export default {
  // ... 省略 ...

  mounted() {
    // vue-meta の hydration を待つ
    setTimeout(() => {
      // クライアントサイドでデバイスの幅を取得する
      this.screenWidth = window.screen.availWidth
    }, 0)
  },
}

vue-meta を直してもらうのが一番ですが、実装を見る感じだとこれに対処するのは難しそう & マウント直後に meta 要素を更新するケースは結構ニッチ (な気がする) なので、ドキュメントでこの挙動と対策について触れるのが落とし所になるのかなーという気がしています。

(Issue は一応作ってます。 https://github.com/declandewet/vue-meta/issues/224)