メインコンテンツまでスキップ

provide / inject とカスタムディレクティブ — 階層を飛ばす受け渡しと DOM 操作

props が渡せるのは親から子への 1 段です。10 章v-model11 章のスロットも、相手は直接の子でした。祖先から遠い子孫へ何かを届けたいときは、間のコンポーネントが全部それを prop として宣言して素通しすることになります。この章では 2 つの仕組みを扱います。provide と inject は階層を飛ばして値を渡し、カスタムディレクティブはテンプレートの記法では書けない DOM 操作を要素に紐づけます。

この章で学ぶこと

  • inject が値を探す順序と、その探索が自分自身を含まない理由
  • 既定値が使われる条件を「見つからなかったとき」より正確に言える
  • InjectionKey<T> が型検査に足すものと足さないものを区別できる
  • カスタムディレクティブの 7 つのフックが呼ばれる時点で、要素がどんな状態か
  • 更新フックが「値が変わったとき」ではなく何をきっかけに呼ばれるか
  • コンポーネントにディレクティブを付けたときの届き方

階層を飛ばして渡す

provide は子孫が取り出せる値を置き、inject がそれを取り出します。キーには文字列か Symbol を使います。公式ガイドは、大きなアプリや他人が使うコンポーネントでは Symbol を専用ファイルへ置くことを勧めています。衝突を避けられて、TypeScript の型も一緒に運べます。

keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
theme: Readonly<Ref<'light' | 'dark'>>
toggle: () => void
}

export const themeKey = Symbol('theme') as InjectionKey<ThemeContext>

値と変更手段を 1 つのオブジェクトにまとめてあります。キーを 2 本に分けるより、対応関係が型に残ります。

components/ThemeProvider.vue
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { themeKey } from '../keys'

const theme = ref<'light' | 'dark'>('light')

function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide(themeKey, { theme: readonly(theme), toggle })
</script>

<template>
<div :class="theme">
<slot />
</div>
</template>
components/ThemeButton.vue
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from '../keys'

const ctx = inject(themeKey)
if (!ctx) throw new Error('themeKey を provide する祖先がありません')

const { theme, toggle } = ctx
</script>

<template>
<button type="button" @click="toggle">
{{ theme === 'light' ? 'ダークへ' : 'ライトへ' }}
</button>
</template>

受け取る側は、間に何段挟まっていても同じ書き方です。provideinject はどちらも setup の中で同期的に呼びます。1 つのコンポーネントが別のキーで何度でも provide を呼べます。app.provide(themeKey, ...) と書けばアプリ全体へ置けて、自分のコンポーネントを持たないプラグインはこちらを使います。

値はどこから探されるか

inject は親をたどって最初に見つかったキーを返します。同じキーを複数の祖先が置いていれば、近い方が遠い方を覆います。コンポーネントの provideapp.provide の同じキーも覆います。

実装では、各コンポーネントの provides は親の provides をプロトタイプにしたオブジェクトです。自分用のオブジェクトができるのは provide を呼んだときだけです。

let provides = currentInstance.provides
const parentProvides = currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key] = value

provide を呼ばないコンポーネントは親のオブジェクトを共有したままなので、置いた値が見えるのは自分の子孫だけです。兄弟には届きません。

探索の出発点はprovides です。そのため自分が provide した値は自分では読めません。同じキーを親も置いていれば、自分の値ではなく親の値が返ります。例外はルートのコンポーネントと Custom Element で、こちらは app.provide の置き場から出発します。

たどるのはマウントされた先の親チェーンです。11 章のスロットで渡した子孫は、中身を書いたコンポーネントではなく、置かれた先を親として解決します。<ThemeProvider> の中身としてボタンを書けば、ボタンを書いた側が何も provide していなくても届きます。

見つからなかったときに起きること

キーが無ければ injectundefined を返し、開発ビルドでは injection "Symbol(theme)" not found. の警告が出ます。既定値を渡せば警告は出ません。作るのに手間がかかる値なら、関数を渡して第 3 引数に true を添えるとファクトリとして扱われます。

判定はこうなっています。

if (provides && key in provides) {
return provides[key]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance && instance.proxy)
: defaultValue
} else {
warn(`injection "${String(key)}" not found.`)
}

ここから次のことが決まります。

  • 探索は key in provides です。プロトタイプチェーンをたどるので祖先の値まで届きます。同時に、provide(key, undefined) としたキーも「ある」と数えます。 この場合は undefined が返り、既定値は使われず、警告も出ません。
  • 既定値を使うかは arguments.length > 1 で決まります。第 2 引数に undefined と書くだけで警告は黙ります。 値は undefined のままです。
  • ファクトリ扱いになるのは第 3 引数が真かつ既定値が関数のときだけです。true を書き忘れると、関数そのものが値として入ります

つまり「既定値は provider がいないときの保険」という理解では足りません。キーが存在するかどうかだけを見ているので、undefined を置いた provider が 1 つあれば既定値は素通りします。

inject を setup の外で呼ぶと、こちらは別の警告になります。

warn(`inject() can only be used inside setup() or functional components.`)

イベントハンドラの中で呼んだ場合がこれに当たり、戻り値は undefined です。app.runWithContext() の中は例外ですが、こちらは app.provide の置き場だけを見ます。setup の中で使っても祖先の provide は見えません。

ref はそのまま届く

provide した値が ref なら、アンラップされずそのまま届きます。子孫が受け取るのは ref なので、provider 側で値を変えれば子孫が描き直ります。

変更をどこに置くかは設計の問題です。公式ガイドは変更を provider の側に閉じることを勧めていて、子孫から変えたいときは変更用の関数を一緒に provide する形を挙げています。前掲の ThemeContextthemetoggle を組で持っているのはこの形です。

さらに readonly() で包めば、子孫からの代入を止められます。

provide(themeKey, { theme: readonly(theme), toggle })

子孫が theme.value = 'dark' と書いても値は変わらず、開発ビルドでは target is readonly の警告が出ます。provider 側は元の theme を持っているので toggle() からは変更できます。

ただし型の側の守りは限定的です。readonly(ref(0)) の型は Readonly<Ref<number, number>> で、.value への代入は型エラーになります。一方でこの型は Ref<number> の入れ物へ代入できますreadonly 修飾子は代入の可否に効かないためです。Ref<T> を受ける関数や interface のフィールドを経由した時点で、コンパイル時の守りは落ちます。実行時の守りは残ります。

型を合わせる

InjectionKey<T>Symbol を拡張したユーティリティ型で、provide と inject の型を結び付けます。

書き方戻り値の型provide 側の値
inject(themeKey)ThemeContext | undefined型が合わないと型エラー
inject(themeKey, 既定値)ThemeContext同上
inject('theme')unknown何を渡しても通る
inject<ThemeContext>('theme')ThemeContext | undefined何を渡しても通る

文字列キーでは両側の型が結び付きません。型引数を書いても、それは inject 側の申告にすぎず、provider が別の型を渡していても検査されません。

InjectionKey<T> にも穴があります。定義はこうです。

interface InjectionConstraint<T> {}
export type InjectionKey<T> = symbol & InjectionConstraint<T>

T は型の中で一度も使われていません。そのため InjectionKey<string>InjectionKey<number>互いに代入できます。効いているのは provideinject がキーの宣言された型から T を取り出すところだけです。Symbol() as InjectionKey<T> のキャストも検査されないので、キーの宣言そのものが正しいことは型では保証できません。

もう 1 つ。既定値を渡した inject の戻り値は Tundefined が外れますが、実行時には undefined が返る経路が残っています — 前節の provide(key, undefined) です。

テンプレートから DOM を触る

組み込みディレクティブで宣言的に書けることは、そちらで書く方が速くサーバー描画にも向きます。カスタムディレクティブは、直接 DOM を操作するしか手がないときのための道具です。要素をフォーカスする、要素の寸法を測る、といった処理が該当します。<script setup> では、v で始まる camelCase の変数がそのままディレクティブになります。

components/SearchBox.vue
<script setup lang="ts">
import type { Directive } from 'vue'
import { vHighlight } from '../directives/highlight'

const vFocus: Directive<HTMLInputElement> = {
mounted: (el) => el.focus(),
}
</script>

<template>
<label>
<span v-highlight:text.bold="'crimson'">キーワード</span>
<input v-focus type="search" />
</label>
</template>

この対応はコンパイル時に決まります。v-focusvFocus への参照へ直接置き換わります (_hoisted_1 は静的な属性をまとめたもの)。

_withDirectives(_createElementVNode("input", _hoisted_1, null, 512 /* NEED_PATCH */), [
[vFocus]
])

<script setup> を使わない場合や、app.directive() でグローバル登録した場合は実行時の解決になります。

const _directive_focus = _resolveDirective("focus")

解決できないと Failed to resolve directive: focus の警告が出て、そのディレクティブは無かったことになります。要素は普通に描かれるので、名前の綴り違いは見た目からは気づきにくい失敗です。

置ける場所にも制限があります。11 章<slot> 出口へ付けると Unexpected custom directive on <slot> outlet. でビルドが止まります。この検査に引っかかるのは実行時のディレクティブが要るものだけなので、v-if / v-bind / v-on は通り、v-show は組み込みなのに同じメッセージで止まります。

フックと binding

DOM の生存期間に対応するフックは 7 つで、すべて任意です。created / beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted。実測した順序はこうなります。

局面順序
マウントcreatedbeforeMountmounted → そのコンポーネントの onMounted
更新beforeUpdateupdated → そのコンポーネントの onUpdated
アンマウントそのコンポーネントの onBeforeUnmountbeforeUnmountunmountedonUnmounted

mounted / updated / unmounted はポストフラッシュのキューに入ります。7 章onMounted / onUpdated / onUnmounted も同じキューに入りますが、要素側のフックが先に積まれるので先に走ります

クライアントで新しく描くときの 3 つでは、呼ばれた時点の要素の状態が違います。

フック属性が付いているか親ノードへ挿入されているか
createdいいえいいえある
beforeMountはいいいえある
mountedはいはいある

created は「属性が付く前」ですが、子の mount は済んでいます。例外は <select multiple>multiple で、これだけは要素を作る時点で付くので created から見えます。el.focus() のように文書に入っていないと効かない処理は mounted に置きます。hydration ではこの表が逆になります — サーバーが出した要素をそのまま使うので、created の時点で属性も挿入も済んでいます。

アンマウント側では、beforeUnmount の時点で要素はまだ親ノードにあり、unmounted では外れています。<KeepAlive> にキャッシュされる間は、非表示になってもどちらのフックも呼ばれません — 走るのは onDeactivated だけです。max で押し出される経路では逆にアンマウントされるので呼ばれますが、そのときの要素は退避先へ移されていて文書には繋がっていません。経路ごとの違いは 7 章にまとめてあります。

第 2 引数の binding が持つものはこれだけです。

プロパティ中身
valueディレクティブに渡した式の値
oldValue前の値。マウント時は undefined で、更新で入る (値が変わっていなくても入る)。直前に更新があればアンマウント側のフックにも残る
argv-highlight:texttext:[expr] と書けば動的にできる
modifiers.bold{ bold: true } にしたもの
instanceそのディレクティブを書いた側のコンポーネント
dirディレクティブの定義オブジェクトそのもの

el 以外は読み取り専用として扱います。フックの間で情報を渡すなら、公式ガイドは要素の dataset を使うことを勧めています。

引数と修飾子を型で縛るには Directive の型引数を使います。並びは要素 / 値 / 修飾子 / 引数です。

directives/highlight.ts
import type { Directive, DirectiveBinding } from 'vue'

export type HighlightDirective = Directive<HTMLElement, string, 'bold', 'bg' | 'text'>

type HighlightBinding = DirectiveBinding<string, 'bold', 'bg' | 'text'>

function apply(el: HTMLElement, binding: HighlightBinding) {
const target = binding.arg === 'text' ? 'color' : 'backgroundColor'
el.style[target] = binding.value
el.style.fontWeight = binding.modifiers.bold ? 'bold' : ''
}

export const vHighlight: HighlightDirective = {
mounted: apply,
updated: apply,
}

mountedupdated が同じ処理なら、オブジェクトの代わりに関数 1 つを書けます。この形は { mounted: fn, updated: fn } へ正規化されるので、残りの 5 つのフックは持ちません

型検査が捕まえるのは宣言との突き合わせで、名前と要素の対応は見ません。

書いたものvue-tsc
値の型が宣言と違う型エラー
宣言に無い引数型エラー
宣言に無い修飾子型エラー
登録されていない名前 (v-nope)通る
宣言と合わない要素 (Directive<HTMLInputElement><div> へ)通る

グローバル登録したディレクティブに型を効かせるには、GlobalDirectives インターフェースを拡張します。なお組み込みディレクティブと同じ名前で登録すると Do not use built-in directive ids as custom directive id の警告が出ます。app.directive()directives オプションの両方で検査されます。

更新フックが呼ばれるきっかけ

beforeUpdate / updated が呼ばれるのは、その要素が patch される瞬間です。ディレクティブの値が変わったかどうかは条件に入りません。同じテンプレート内の無関係な ref を変えただけでも、oldValuevalue が同じ値のまま両方のフックが呼ばれます。

要素が patch から漏れないよう、コンパイラが 2 通りの手当てをします。子を持つ要素は block へ格上げされます。 実装のコメントが理由を書いています — 「カスタムディレクティブは beforeUpdate を使うことがあるので、子の更新より先にそれを呼べるよう block を強制する」。patchFlag は問いません。

block にならない要素には patchFlag が付きます。 条件は patchFlag が 0 か 32 だけになることです。32 は hydration 用の印で、これだけでは動的な子として集められません。

const needsPatch =
(patchFlag === 0 || patchFlag === 32) &&
(hasRef || hasVnodeHook || runtimeDirectives.length > 0)
if (!shouldUseBlock && needsPatch) {
patchFlag |= 512
}

静的な巻き上げの対象からも外れます。ただし v-once と同居させると vnode が使い回されるので、patch そのものが起きず更新フックは来ません。v-memo は依存が変わらない再描画で同じことが起き、依存が変われば描き直すので来ます。

フックの中の読み取りには制約があります。呼び出しは追跡を止めた状態で行われます。

pauseTracking()
callWithAsyncErrorHandling(hook, instance, 8, [vnode.el, binding, vnode, prevVNode])
resetTracking()

そのためフックの中でリアクティブな値を読んでも依存には入りません。その値を変えても、囲むコンポーネントは描き直しません。反応させたい値はディレクティブの value として渡します。ただし渡したオブジェクトの中身だけを書き換えても、囲むテンプレートがそれを読んでいなければ更新フックは来ません。定義に deep: true を書くと値を辿って依存に入れます。

サーバー描画では DOM を触るフックが 1 つも呼ばれません。走るのは getSSRProps だけで、そこで返したオブジェクトが属性として HTML に載ります。ブラウザだけで意味を持つ処理は、9 章の Composable と同じ判断になります。

コンポーネントに付けたとき

<MyComponent v-focus /> と書くと、ディレクティブはそのコンポーネントの描画結果のルートノードへ移されます。公式ガイドはこの使い方を推奨していません。届く先が描画結果次第で変わるためです。

判定は 2 段に分かれています。フックが走るのはルートが要素のときだけです。警告が出るのは、ルートが要素・コンポーネント・コメントのいずれでもないときです。測った 4 つの形はこうなりました。

ルートノード結果
要素 1 つその要素へ届く
さらに別のコンポーネントその下のルート要素まで下りる
複数ルート (Fragment)警告が出てフックが 1 つも呼ばれない
v-if が偽でコメントノード警告なしでフックが呼ばれない。真に転じた時点で created から走る

複数ルートのときの警告は Runtime directive used on component with non-element root node. です。属性と違い、v-bind="$attrs" で別の要素へ渡すこともできません。

binding.instance が指すのは、そのディレクティブを書いた側のコンポーネントです。ディレクティブが実際に付いた要素を描いたコンポーネントではありません。11 章のスロットの中身に書いた場合も同じで、中身を書いた親を指します。

まとめ

  • provide は子孫が取り出せる値を置き、inject が親をたどって最初に見つかったキーを返します。キーには文字列か Symbol を使い、公式ガイドは Symbol を専用ファイルへ置くことを勧めています。探索の出発点は親なので、自分が provide した値は自分では読めません (ルートと Custom Element だけ app.provide の置き場から出発し、app.runWithContext() の中はそこだけを見ます)
  • 各コンポーネントの provides は親のものをプロトタイプにしたオブジェクトで、自分用のものができるのは provide を呼んだときです。置いた値が見えるのは自分の子孫だけで、兄弟には届きません
  • たどるのはマウントされた先の親チェーンです。スロットの中身として渡した子孫は、置かれた先を親として解決します
  • 探索は key in provides です。provide(key, undefined) としたキーも「ある」と数えるので、既定値は使われず警告も出ません。既定値を使うかは arguments.length > 1 で決まるため、第 2 引数に undefined と書くだけで警告は黙ります。ファクトリ扱いになるのは第 3 引数が真かつ既定値が関数のときだけで、true を書き忘れると関数そのものが値になります
  • provide した ref はアンラップされずそのまま届きます。公式ガイドは変更を provider 側に閉じることを勧めていて、readonly() で包めば子孫からの代入は警告だけになります。ただし型の守りは .value への代入までで、Ref<T> の入れ物へ渡すと落ちます
  • InjectionKey<T>T は型の中で使われていないので、別の T 同士でも代入できます。効くのは provide / inject がキーの宣言された型から T を取り出すところだけです。文字列キーでは両側の型が結び付きません
  • カスタムディレクティブは直接 DOM を操作するしか手がないときの道具です。<script setup>v プレフィックスはコンパイル時に解決され、それ以外は実行時の resolveDirective になります。解決できなければ警告が出て、そのディレクティブは無かったことになります
  • DOM の生存期間に対応するフックは 7 つです。順序は createdbeforeMountmounted で、コンポーネントの onMounted より先に走りますcreated は属性が付く前ですが子の mount は済んでいます。<KeepAlive> にキャッシュされている間はアンマウント側の 2 つが呼ばれず、押し出されるときに呼ばれます
  • beforeUpdate / updated が呼ばれるのはその要素が patch される瞬間で、値が変わったかどうかは条件ではありません。漏れないよう、子を持つ要素は block へ格上げされ、block にならない要素は patchFlag が 0 か 32 だけのときに 512 を付けられます。v-once と、依存が変わらない v-memo では patch 自体が起きません
  • フックの呼び出しは追跡を止めた状態で行われるので、中でリアクティブな値を読んでも依存には入りません。反応させたい値は value として渡します。サーバー描画では DOM を触るフックが呼ばれず、走るのは getSSRProps だけです
  • コンポーネントに付けると描画結果のルートノードへ移ります。フックが走るのはルートが要素のときだけで、ルートがさらにコンポーネントなら下りていき、複数ルートなら警告が出て呼ばれず、v-if が偽でコメントになったときは警告なしで呼ばれません
  • binding.instance が指すのはそのディレクティブを書いた側のコンポーネントで、実際に付いた要素を描いた側ではありません
関連リファレンス

次に読む