mounted時に要素の幅を測定するカスタムディレクティブは、対象要素または祖先がv-show="false"などでdisplay: noneになっていると、初期幅を0として取得してしまいます。
v-ifへ置き換えると一見直りますが、DOMの再生成によって保持したい状態まで失われるため、常に適切な解決策とは限りません。
この記事では、Vue 3のカスタムディレクティブで要素サイズを測定する際に、ResizeObserverを利用して、初めて有効な幅を取得できた時点まで初期化を遅延させます。
原因はNuxtなどのフレームワーク固有の挙動ではなく、display: noneの要素がレイアウトボックスを持たないことにあります。そのため、Vite、Nuxt 3/4など、Vue 3を利用する環境全般に当てはまります。
-
v-showは要素をDOMに残したまま、displayを切り替える -
display: noneの要素はレイアウトボックスを持たないため、幅の測定結果が0になる -
mountedフックは、v-showがfalseからtrueへ変わっても再実行されない -
v-ifは再マウントによって測定をやり直せるが、DOMやディレクティブの状態も破棄する - 実際に非ゼロのサイズを取得できるまで、
ResizeObserverで初期化を待つ
問題の再現
例として、テーブル列をドラッグでリサイズするカスタムディレクティブを考えます。
import type { Directive } from 'vue'
export const resizable: Directive<HTMLElement> = {
mounted: (el) => {
const initialWidth = el.getBoundingClientRect().width
el.style.width = `${initialWidth}px`
// initialWidthを基準値として、
// リサイズハンドルとpointerイベントを設定する
},
}
テンプレート側では、列の表示状態をv-showで切り替えます。
<th v-show="column.visible" v-resizable>
{{ column.label }}
</th>
初回マウント時にcolumn.visibleがfalseの場合、initialWidthは0になります。
その値を基準にドラッグ処理を初期化すると、次のような問題が発生します。
- 表示後も列幅が潰れたままになる
- 最初のドラッグで幅が大きく跳ねる
- 差分計算が
0を基準に実行される - ハンドル位置と実際の列幅が一致しない
根本原因
v-showは要素を削除しません。要素をDOMに残したまま、インラインスタイルのdisplayを切り替えます。
一方、display: noneが指定された要素にはレイアウトボックスが生成されません。その状態でサイズを取得すると、次の値は基本的に0になります。
getBoundingClientRect().widthoffsetWidthclientWidth
重要なのは、display: noneが対象要素自身に設定されている場合だけではありません。祖先要素のいずれかがdisplay: noneでも、子孫要素は測定できません。
また、カスタムディレクティブのmountedフックは、要素が最初にマウントされた時点で実行されます。v-showの値が後から変わっても、同じDOMノードに対してmountedが再実行されることはありません。
つまり、問題は次の流れで発生します。
- 要素はDOMに存在する
-
v-show="false"によってレイアウトには参加していない -
mountedで幅を測定する - 幅
0のままリサイズ処理を初期化する - 後から表示されても初期化はやり直されない
なぜv-ifにすると直るのか
v-ifは、条件がfalseになると対象のDOMを破棄します。再びtrueになると、新しいDOMノードを生成してマウントします。
そのため、falseからtrueへ切り替わった時点でmountedが再実行され、表示済みの要素から正しい幅を取得できます。
<th v-if="column.visible" v-resizable>
{{ column.label }}
</th>
ただし、この方法は「正しいタイミングで測定した」のではなく、DOMを作り直すことで初期化をやり直しているだけです。
対象によっては、次の状態も失われます。
- ディレクティブ内部で保持していた列幅
- 子コンポーネントのローカル状態
- 入力値、選択範囲、フォーカス
- DOMに紐づいたイベントや一時的な状態
- 展開状態やユーザー操作の途中経過
大きなテーブルでは、表示切り替えのたびに複数のセルやコンポーネントが再生成されるコストも発生します。
したがって、状態を維持する目的でv-showを選んでいる場合、v-ifへの変更で測定問題は解消できますが、状態の破棄やDOMの再生成を許容できるか確認が必要です。
よくある回避策が不十分な理由
-
nextTick()で待つ:保証するのはVueのDOM更新完了までです。対象や祖先がまだdisplay: noneなら、結果は引き続き0です -
setTimeout()で遅らせる:一定時間後に表示されている保証はありません。端末性能・非同期処理・トランジション・親の更新順で結果が変わります -
window.resizeを監視する:v-showの切り替えでは、通常resizeイベントは発生しません -
MutationObserverを使う:検出できるのはDOMやスタイル属性の変更です。祖先や周辺レイアウトの影響で生じた実際のサイズ変化を直接通知する仕組みではありません
updatedフックでの再測定は、ディレクティブへ表示状態を明示的に渡せる場合には有効です。
<th v-show="column.visible" v-resizable="column.visible">
{{ column.label }}
</th>
ただし、祖先側のタブ・モーダル・アコーディオンによる非表示状態は、この値だけでは直接検知できません。また、ディレクティブがアプリ固有の表示状態に依存する点も弱みです。
今回知りたいのは「表示フラグが変わったか」ではなく、要素が測定可能なサイズを持ったかです。この条件にはResizeObserverが適しています。
解決策:ResizeObserverで遅延初期化する
要素が初回マウント時点ですでに表示されていれば、その場で初期化します。
幅が0の場合のみResizeObserverを開始し、最初に非ゼロの幅を取得できた時点で初期化します。初期化後は監視を停止します。
import type { Directive } from 'vue'
type ResizableCleanup = () => void
type ResizableState = {
initialized: boolean
observer?: ResizeObserver
cleanup?: ResizableCleanup
}
const resizableStateMap = new WeakMap<HTMLElement, ResizableState>()
const measureWidth = (el: HTMLElement): number =>
el.getBoundingClientRect().width
const initResizable = (
el: HTMLElement,
initialWidth: number,
): ResizableCleanup => {
el.style.width = `${initialWidth}px`
// ここでリサイズハンドルを追加し、
// initialWidthを基準値としてpointerイベントを登録する
return () => {
// 追加したハンドルとイベントリスナーを破棄する
}
}
export const resizable: Directive<HTMLElement> = {
mounted: (el) => {
const state: ResizableState = {
initialized: false,
}
resizableStateMap.set(el, state)
const initialize = (): boolean => {
const width = measureWidth(el)
if (state.initialized || width <= 0) return false
state.initialized = true
// 初期化処理で監視対象のサイズを変更する可能性があるため、
// 先に監視を停止しておく
state.observer?.disconnect()
state.observer = undefined
state.cleanup = initResizable(el, width)
return true
}
if (initialize()) return
state.observer = new ResizeObserver(() => {
initialize()
})
// 非表示から表示されたことを検知した後は、
// getBoundingClientRect()で改めて測定するため、この例ではboxを指定しない
state.observer.observe(el)
},
unmounted: (el) => {
const state = resizableStateMap.get(el)
state?.observer?.disconnect()
state?.cleanup?.()
resizableStateMap.delete(el)
},
}
処理の流れ
この実装には次の特徴があります。
- 初回から表示されている要素は同期的に初期化する
- 非表示の要素だけ監視する
- 表示状態そのものではなく、実際のサイズを条件にする
- 初期化を一度だけ実行する
- アンマウント時に
ResizeObserverとイベントを破棄する - DOM要素へ独自プロパティを追加せず、
WeakMapで状態を管理する
この例では、初回測定とResizeObserverのコールバック後の測定に、同じgetBoundingClientRect()を使っています。
異なるAPIを混在させると、content-box/border-box、縦書き、transformなどの影響により、測定基準の不一致につながる可能性があります。取得した幅をそのままstyle.widthへ設定する場合は、box-sizing: border-boxも確認してください。
なぜ初期化前にResizeObserverを停止するのか
initResizable()内でwidthを設定すると、監視対象のサイズが再び変化する可能性があります。
ResizeObserverを接続したままサイズを書き換えると、追加のコールバックまで予約されることがあります。initializedフラグだけでも多重初期化は防げます。ただし、一度だけ初期化するなら、処理前にdisconnect()しておくと意図が明確になります。
継続的にサイズ変化へ追従したい場合は、ResizeObserverを切断せず、コールバック内でDOMを書き換える処理が無限ループ(ResizeObserver loop completed with undelivered notifications)を作らないように設計します。
グローバルディレクティブとして登録する
ViteなどのVue 3プロジェクトでは、app.directive()で登録できます。
import { createApp } from 'vue'
import App from './App.vue'
import { resizable } from './directives/resizable'
const app = createApp(App)
app.directive('resizable', resizable)
app.mount('#app')
特定のコンポーネントだけで使う場合は、<script setup>内でvResizableという名前でimportすれば、グローバル登録は不要です。
<script setup lang="ts">
import { resizable as vResizable } from './directives/resizable'
</script>
<template>
<th v-resizable>
Column
</th>
</template>
Nuxtでグローバル登録する場合
Nuxtでは、プラグインから同じディレクティブを登録できます。
import { resizable } from '~/directives/resizable'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('resizable', resizable)
})
Nuxt 3の標準構成ではplugins/resizable.ts、Nuxt 4の標準構成ではapp/plugins/resizable.tsへ配置します。
SSR対象のテンプレートで利用する場合は、サーバー側からもディレクティブ名を解決できるよう、通常のプラグインとして登録します。
この実装はResizeObserverをmountedフック内で生成するため、SSR中にブラウザーAPIへアクセスしません。<ClientOnly>配下など、クライアント側だけで描画する場合は.client.tsでも構いません。
Composableとして実装する場合
ディレクティブより明示的に制御したい場合は、Composableへ切り出せます。
import {
onBeforeUnmount,
onMounted,
readonly,
ref,
type Ref,
} from 'vue'
export const useVisibleElementWidth = (
target: Ref<HTMLElement | null>,
) => {
const width = ref(0)
let observer: ResizeObserver | undefined
onMounted(() => {
const el = target.value
if (!el) return
observer = new ResizeObserver(([entry]) => {
if (!entry) return
const borderBoxSize = entry.borderBoxSize?.[0]
const nextWidth = borderBoxSize?.inlineSize ?? entry.contentRect.width
if (nextWidth > 0) {
width.value = nextWidth
}
})
observer.observe(el, {
box: 'border-box',
})
})
onBeforeUnmount(() => {
observer?.disconnect()
})
return {
width: readonly(width),
}
}
使用側では、対象要素へTemplate Refを設定します。
<script setup lang="ts">
import { ref } from 'vue'
const headerRef = ref<HTMLElement | null>(null)
const { width } = useVisibleElementWidth(headerRef)
</script>
<template>
<th ref="headerRef" v-show="column.visible">
{{ column.label }}: {{ width }}px
</th>
</template>
この形は、次のケースに向いています。
- サイズをリアクティブな値として他の処理でも利用したい
- 初期化処理をコンポーネント側から明示的に呼びたい
- 単体テストでObserverと初期化処理を分離したい
- 複数の要素サイズを組み合わせて計算したい
VueUseを利用する場合
VueUseを導入済みなら、自前のResizeObserverを書かずに済みます。用途に応じて2つのComposableを使い分けます。
サイズをリアクティブに追従するならuseElementSizeが最も簡潔です。現在のVueUseではstop()も返されるため、必要な時点で監視を終了できます。
import { useElementSize } from '@vueuse/core'
import { ref } from 'vue'
const headerRef = ref<HTMLElement | null>(null)
const { width, height, stop } = useElementSize(
headerRef,
{ width: 0, height: 0 },
{ box: 'border-box' },
)
最初の非ゼロ値で処理を実行したいなら、コールバック形式のuseResizeObserverを直接使う方が意図を表しやすくなります。
import { useResizeObserver } from '@vueuse/core'
import { ref } from 'vue'
const headerRef = ref<HTMLElement | null>(null)
const width = ref(0)
useResizeObserver(headerRef, (_entries, observer) => {
const el = headerRef.value
if (!el) return
const nextWidth = el.getBoundingClientRect().width
if (nextWidth <= 0) return
width.value = nextWidth
observer.stop()
})
初期化条件や破棄処理を細かく制御したい場合は、自前実装が適しています。VueUseを導入済みで、サイズをリアクティブに扱いたい場合は、VueUse版を選ぶとよいでしょう。
テーブル固有の注意点
ResizeObserverが解決するのは、測定と初期化のタイミングです。
<th>へ設定したwidth自体が期待どおりに反映されない場合は、別の問題としてテーブルレイアウトも確認してください。
-
table-layout: fixedを利用する -
<colgroup>と<col>で列幅を管理する -
box-sizing: border-boxを揃える -
min-widthやmax-widthとの競合を確認する - ヘッダーとボディーで異なる幅管理をしていないか確認する
まとめ
mountedは「DOMノードが存在する」ことを示しますが、「レイアウトが確定し、測定可能である」ことまでは保証しません。
特に、次のようなUIではmounted時の一度きりのDOM測定が壊れやすくなります。
-
v-showで切り替える要素 - タブやモーダルの内部
- 折りたたみ可能なコンテナー
- 遅延表示されるサイドパネル
- 非表示の祖先を持つ要素
- 再アクティブ化される
<KeepAlive>配下の画面
重要なのは、フレームワークのライフサイクルだけを初期化条件にしないことです。
サイズが必要な処理は、実際に有効なサイズを取得できた時点で初期化する。
その条件をDOMの実測値として扱いたい場合、ResizeObserverはnextTick()や固定時間の待機より堅牢な選択肢になります。