Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

mountedで要素がdocument内のDOMツリーに存在しないことがある?

ようやく秋らしく涼しくなって毎日ハッピーなフロントエンドチームEMのこん(@k0n_karin)です!

ある日の業務中、1通の問い合わせが来る

「◯◯ページのスライダーが動かせません!」

という問い合わせが来たので早速該当の画面を見に行くと、確かにスライダーがおかしくなっていました。

NaNmになる図。NaNで?

調べてみると、どうやらmountedフックでスライダー要素の幅からスライダーのステップ単位を計算する際に値が0になり、その後スライダーを操作する箇所で0除算が起きてNaNになっていたようです。

// Slider.vue
const { value, max, min } = defineProps<{ value: number, max: number, min: number }>()
const width = ref(100)
const slideBar = useTemplateRef('slideBar')

onMounted(() => {
  if (!slideBar.value) {
    return;
  }
  width.value = slideBar.value.offsetWidth // widthが0になる
})

function handleValueChange(val: number) {
  const ans = (max -min) * sbRound(val / width.value, 5); // ここで0除算が起きる
  // ...
}

🤔「どうしてmountedフックの中なのに要素の幅が0になっちゃうんだろう?要素はマウント済みのはずでは?」

調べてみると原因っぽいものが見つかる

色々試しているとリロードして画面を表示した際は発生せず、別の画面から遷移して表示した際だけ発生することがわかりました。 Yappliの管理画面では画面遷移時にTransition(NuxtのpageTransition)を使っていたので、Transitionがあるときだけ事象が発生することがわかり、最小限のサンプルを作ることができました。

konkarin.github.io

github.com

サンプルで開発者ツールのコンソールを見てみると、onMountedで出力されるログでテンプレート参照している要素にアクセスできましたが、documentのDOMツリーには存在しませんでした(document.contains(div.value)false)。画面上に存在しないので、getBoundingClientRect()の値もすべて0です。

// Child.vue
const div = useTemplateRef('div')
onMounted(() => {
  if (div.value) {
    // -> false
    console.log('document.contains:', document.contains(div.value));
    // -> { "width": 0, "height": 0, ... }
    console.log('getBoundingClientRect: ', div.value.getBoundingClientRect());
  }
});

サンプルはこのような実装になっています。

  • mode="out-in"<Transition><Suspense>を使っている
  • Transition後に表示されるコンポーネント内で、非同期に状態を更新することで子コンポーネントの表示を制御している

どうして要素がdocument内のDOMツリーに存在しないのでしょうか? 少しずつ紐解いていきましょう。

⚠️注意事項

  1. 本記事はVue: 3.5.21で検証しています。

  2. そもそもtop-level awaitをしてないのにSuspense使うのは変かもしれませんが、NuxtのNuxtLayoutNuxtPageでは内部的にSuspenseが使われているため、それを想定しています。サンプルでは、処理を簡易化するためにVueだけで作っています。

Suspenseの振る舞い

まず、Suspenseは非同期コンポーネントを扱うために、隠しコンテナ(DOMに存在しない要素)を使ってコンポーネントをマウントし、非同期処理が終わるまでコンテンツを隠せるようになっています。Suspenseが最初にレンダリングされるときや、Suspense内のコンポーネントが切り替わるときなどに使われます。

コードを見に行くと、off-domやhiddenContainerというキーワードが登場していますね。

https://github.com/vuejs/core/blob/e60edc06f29b32c8f3a44d0ab3558a0569515e8f/packages/runtime-core/src/components/Suspense.ts#L182-L192

// start mounting the content subtree in an off-dom container
patch(
  null,
  (suspense.pendingBranch = vnode.ssContent!),
  hiddenContainer,
  null,
  parentComponent,
  suspense,
  namespace,
  slotScopeIds,
)

https://github.com/vuejs/core/blob/75220c7995a13a483ae9599a739075be1c8e17f8/packages/runtime-core/src/components/Suspense.ts#L376-L393

// mount pending branch in off-dom container
suspense.pendingBranch = newBranch
if (newBranch.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
  suspense.pendingId = newBranch.component!.suspenseId!
} else {
  suspense.pendingId = suspenseId++
}
patch(
  null,
  newBranch,
  suspense.hiddenContainer,
  null,
  parentComponent,
  suspense,
  namespace,
  slotScopeIds,
  optimized,
)

今回の事象では、リアクティブデータが更新されコンポーネントが切り替わるタイミングなので、2つ目のリンクの処理が実行されています。

SuspenseとTransition mode="out-in"

そして、SuspenseはTransitionのmodeによって挙動が違います。今回の事象はmode="out-in"でしか起きません。mode="out-in" のとき、Suspense配下のコンポーネントが解決されてから、TransitionのafterLeaveフックで隠しコンテナにマウントしておいたコンポーネントがアプリケーションのDOMツリーに挿入されます。

https://github.com/vuejs/core/blob/75220c7995a13a483ae9599a739075be1c8e17f8/packages/runtime-core/src/components/Suspense.ts#L540-L556

// 実DOMへの挿入をafterLeaveで行うかどうかのフラグ
delayEnter =
  activeBranch &&
  pendingBranch!.transition &&
  pendingBranch!.transition.mode === 'out-in'
if (delayEnter) {
  activeBranch!.transition!.afterLeave = () => {
    if (pendingId === suspense.pendingId) {
      // pendingBranch(隠しコンテナで描画されたVNode)をcontainer(実DOM)に移動させる処理
      move(
        pendingBranch!,
        container,
        anchor === initialAnchor ? next(activeBranch!) : anchor,
        MoveType.ENTER,
      )
      // mountedなどを含むDOM更新後に行いたい処理をキューイングする
      queuePostFlushCb(effects)
    }
  }
}

afterLeaveが実行されるのはcssのtransitionが完了してからなので、transitionで指定した秒数が経過した後に実行されます。

つまり、Suspenseツリー直下のコンポーネントが切り替わるとき、このコンポーネントのマウントはTransitionの完了を待たずに隠しコンテナで予め行われており、Transitionが完了したら隠しコンテナでマウントしておいたコンポーネントを実DOMに移動するようになっています。そして最後にコンポーネントのmountedフックが実行されます。

ここまでがTransition mode="out-in"とSuspenseを組み合わせたときの挙動です。

document内に要素が存在しないのは?

ここからが本題です。

改めてサンプルを見ましょう。

App.vueでTransitionが起きるとき、Suspense配下に新しくParent.vueがマウントされます。このParent.vueはsetupで非同期な処理を行い、この処理の終了後にリアクティブデータを更新します。このリアクティブデータの更新によって、Child.vueがマウントされるようになっています。

シーケンス図で処理の順番を見ていきましょう。多分文章よりも図のほうがわかりやすいと思います。

sequenceDiagram participant App as App.vue participant T as Transition participant S as Suspense participant P as Parent.vue participant C as Child.vue App->>T: Transitionをトリガー T->>S: Suspenseのパッチが開始 S->>P: Parent.vueをマウント(隠しコンテナ内) P->>P: 非同期処理開始 P->>S: Parent.vueが隠しコンテナのDOMツリーに追加 Note over S,P: この時点でParent.vueのmountedフックは未実行 S->>T: afterLeaveフックを登録 P->>P: 非同期処理完了 P->>C: Child.vueをマウント C->>P: Child.vueがParent.vueに追加 C->>C: mountedフック実行 Note over S,C: この時点でParent.vueとChild.vueは隠しコンテナのDOMツリーにいる T->>T: 時間経過でTransition完了 T->>S: afterLeaveフックが実行 S->>App: 隠しコンテナをApp.vueのDOMツリーに追加 Note over P: Parent.vueのmountedフックはafterLeaveの最後に実行される P->>P: mountedフック実行

先ほど調べた挙動と照らし合わせると、まず、Parent.vueは隠しコンテナにマウントされています。そしてParent.vueはTransitionの完了まで画面に描画されず、mountedフックも実行されません。
この時点で、既にChild.vueを表示するための非同期処理は開始されています。Transitionが完了するまでに非同期処理が完了すると、Child.vueはマウントされます。ただし、Child.vueはSuspense直下のコンポーネントではないので、mountedフックは通常通りこの時点で実行されます。
Transitionが終わるまではParent.vueもChild.vueもSuspenseの隠しコンテナにマウントされています。そして、Child.vueのmountedフックではまだApp.vueのツリー内には存在しないので、今回の事象が起きていました。

もし、Transitionが終わってからParent.vueの非同期処理(Child.vueを表示する処理)が実行されるとしたら、Transitionの完了のタイミングではParent.vueは既に画面に表示されているため、この問題は避けられます。

sequenceDiagram participant App as App.vue participant T as Transition participant S as Suspense participant P as Parent.vue participant C as Child.vue App->>T: Transitionをトリガー T->>S: Suspenseのパッチが開始 S->>P: Parent.vueをマウント(隠しコンテナ内) P->>P: 非同期処理開始 P->>S: Parent.vueのマウント完了 Note over S,P: この時点でParent.vueのmountedフックは実行されない S->>T: afterLeaveフックを登録 T->>T: 時間経過でTransition完了 T->>S: afterLeaveフックが実行 S->>App: 隠しコンテナをDOMツリーに挿入 P->>P: mountedフック実行 Note over P: Transitionが終わってから非同期処理が完了する P->>P: 非同期処理完了 P->>C: Child.vueをマウント C->>P: Child.vueのマウント完了 P->>P: 自身のDOMツリーをパッチ C->>C: mountedフック実行

遭遇したら

Suspenseを使っているので、データフェッチなどはtop-level awaitを使えば解決できます。何らかの理由でtop-level awaitが使えない場合は、エッジケースだと思いますが、迂回策を考えたり何かしら工夫をする必要があると思います。

Yappliの管理画面ではデータフェッチの非同期処理が原因で発生しており、かつすぐにtop-level awaitに切り替えることは難しかったので、ResizeObserverを使って要素のリサイズを監視し、リサイズ時にサイズを取得して計算するように変更して回避できました。

onMounted(() => {
  console.log(slideBar.value.offsetWidth) // -> 0になる😭
  
  // 代替策
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      if (entry.contentRect.width > 0) // 0除算は防ぐ
        console.log(entry.contentRect.width); // -> リサイズ後(=画面表示後)の幅が取れる😊
    }
  })
  observer.observe(slideBar.value)
})

おわりに

問い合わせや不具合1つ取っても、深掘りしてみると意外と多くの知見が得られるなといつも感じます。気になったら好奇心ドリブンでどこまで調べてみましょう!

ヤプリでは好奇心旺盛なエンジニアを大募集しています。興味を持っていただけたら是非カジュアル面談でお話しましょう。

open.talentio.com