Skip to content

v-on ディレクティブ

さて,v-on ディレクティブについて見てみましょう.
v-on はネイティブ要素に対するものと,コンポーネントに対するものの 2 種類のものがありますが,まだコンポーネントの扱い方は知らないので,ここではネイティブ要素に対するものについて説明します.
(ちなみにコンポーネントの際はほぼ props なので,あまり説明することがありません)

以下のようなコンポーネントを考えます.

vue
<script setup>
import { ref } from "vue";

const count = ref(0);
function increment() {
  count.value++;
}
</script>

<template>
  <button type="button" @click="increment">{{ count }}</button>
</template>

よくあるカウンターコンポーネントです.

コンパイル結果

コンパイル結果は以下のような感じになります.

js
const _sfc_main = {
  vapor: true,
  __name: "App",
  setup(__props, { expose: __expose }) {
    __expose();

    const count = ref(0);
    function increment() {
      count.value++;
    }

    const __returned__ = { count, increment, ref };
    Object.defineProperty(__returned__, "__isScriptSetup", {
      enumerable: false,
      value: true,
    });
    return __returned__;
  },
};

import {
  delegate as _delegate,
  renderEffect as _renderEffect,
  setText as _setText,
  delegateEvents as _delegateEvents,
  template as _template,
} from "vue/vapor";

const t0 = _template('<button type="button"></button>');

_delegateEvents("click");

function _sfc_render(_ctx) {
  const n0 = t0();
  _delegate(n0, "click", () => _ctx.increment);
  _renderEffect(() => _setText(n0, _ctx.count));
  return n0;
}

例の如く,script 部分は大したものではないので,以下の部分を重点的にみていきましょう.

js
import {
  delegate as _delegate,
  renderEffect as _renderEffect,
  setText as _setText,
  delegateEvents as _delegateEvents,
  template as _template,
} from "vue/vapor";

const t0 = _template('<button type="button"></button>');

_delegateEvents("click");

function _sfc_render(_ctx) {
  const n0 = t0();
  _delegate(n0, "click", () => _ctx.increment);
  _renderEffect(() => _setText(n0, _ctx.count));
  return n0;
}

概要の理解

template の生成や renderEffect ~ setText はいつも通りです.
今回のメイン部分は

js
_delegateEvents("click");

js
_delegate(n0, "click", () => _ctx.increment);

です.

予想的には後者の方は n0 に対して click イベントを追加しているのだろうと思います.
が,"delegate" というのが何なのか,と前者の _delegateEvents が何をしているのかがわかりません.

とりあえず,ここは謎なまま置いておいて,コンパイラの実装を見ていきましょう.
謎はランタイムを読み進める時に把握していきましょう.

コンパイラを読む

IR

例の如く IR を覗いてみましょう.
IR としては怪しそうなものは SET_EVENT というものがありますが,他には見当たりません.

みてみましょう.

115export interface SetEventIRNode extends BaseIRNode {
116  type: IRNodeTypes.SET_EVENT
117  element: number
118  key: SimpleExpressionNode
119  value?: SimpleExpressionNode
120  modifiers: {
121    // modifiers for addEventListener() options, e.g. .passive & .capture
122    options: string[]
123    // modifiers that needs runtime guards, withKeys
124    keys: string[]
125    // modifiers that needs runtime guards, withModifiers
126    nonKeys: string[]
127  }
128  keyOverride?: KeyOverride
129  delegate: boolean
130  /** Whether it's in effect */
131  effect: boolean
132}

どうやらこの Node が delegate というフラグを持っているようです.

それでは,この Node を生成している transformer を探してみましょう.
ありました.packages/compiler-vapor/src/transforms/vOn.ts です.

73  const operation: SetEventIRNode = {
74    type: IRNodeTypes.SET_EVENT,
75    element: context.reference(),
76    key: arg,
77    value: exp,
78    modifiers: {
79      keys: keyModifiers,
80      nonKeys: nonKeyModifiers,
81      options: eventOptionModifiers,
82    },
83    keyOverride,
84    delegate,
85    effect: !arg.isStatic,
86  }

DirectiveTransform

なんだかんだで DirectiveTransform が登場するのは初めてなので,どういう流れでこれが呼ばれるのかは見ておきましょう.
DirectiveTransform は transformElement から呼ばれます.
具体的には要素の属性を取り回す処理の途中で呼ばれます.

42export const transformElement: NodeTransform = (node, context) => {

56    const propsResult = buildProps(
57      node,
58      context as TransformContext<ElementNode>,
59      isComponent,
60    )

188export function buildProps(
189  node: ElementNode,
190  context: TransformContext<ElementNode>,
191  isComponent: boolean,
192): PropsResult {

255    const result = transformProp(prop, node, context)

284function transformProp(
285  prop: VaporDirectiveNode | AttributeNode,
286  node: ElementNode,
287  context: TransformContext<ElementNode>,
288): DirectiveTransformResult | void {

301  const directiveTransform = context.options.directiveTransforms[name]
302  if (directiveTransform) {
303    return directiveTransform(prop, node, context)
304  }

今回の場合は,on 等名前から v-on の transformer を取得し,transformVOn が呼ばれます.
そして,transformVOn では最後に context.registerEffect が呼ばれており,ここで effect が登録されます.

88  context.registerEffect([arg], operation)

transformVOn

それでは transformVOn を見ていきましょう.

dir というのはディレクティブの AST です. これは runtime-core に実装されたもので,parse の時点で作られます.

ここから argexpr, modifiers などを取り出していきます.

21  let { arg, exp, loc, modifiers } = dir

簡単に説明しておくと,v-on:click.stop="handler"clickarg に,stopmodifiers に,handlerexpr に該当します.

まずは modifiers を種類別に解決して整理しておきます.

32  const { keyModifiers, nonKeyModifiers, eventOptionModifiers } =
33    resolveModifiers(
34      arg.isStatic ? `on${arg.content}` : arg,
35      modifiers,
36      null,
37      loc,
38    )

resolveModifierscompiler-dom に実装された関数で,modifiers を分類します.

35export const resolveModifiers = (
36  key: ExpressionNode | string,
37  modifiers: string[],
38  context: TransformContext | null,
39  loc: SourceLocation,
40): {
41  keyModifiers: string[]
42  nonKeyModifiers: string[]
43  eventOptionModifiers: string[]
44} => {

続いて,delegate を有効にするかの判定です. (とりあえず delegate が何なのか,というのは置いておきましょう.)

42  const delegate =
43    arg.isStatic && !eventOptionModifiers.length && delegatedEvents(arg.content)

以下の条件を全て満たす時に有効になります.

あとはこれまでに得られた情報をもとに,registerEffect で effect を登録したらおしまいです.

73  const operation: SetEventIRNode = {
74    type: IRNodeTypes.SET_EVENT,
75    element: context.reference(),
76    key: arg,
77    value: exp,
78    modifiers: {
79      keys: keyModifiers,
80      nonKeys: nonKeyModifiers,
81      options: eventOptionModifiers,
82    },
83    keyOverride,
84    delegate,
85    effect: !arg.isStatic,
86  }
87
88  context.registerEffect([arg], operation)

Codegen を読む.

delegate フラグあたりがどう影響しているかだけ重点的に見て,あとはさらっと見ていきましょう.

33export function genOperation(
34  oper: OperationNode,
35  context: CodegenContext,
36): CodeFragment[] {
44    case IRNodeTypes.SET_EVENT:
45      return genSetEvent(oper, context)
17export function genSetEvent(
18  oper: SetEventIRNode,
19  context: CodegenContext,
20): CodeFragment[] {
21  const { vaporHelper } = context
22  const { element, key, keyOverride, value, modifiers, delegate, effect } = oper
23
24  const name = genName()
25  const handler = genEventHandler(context, value)
26  const eventOptions = genEventOptions()
27
28  if (delegate) {
29    // key is static
30    context.delegates.add(key.content)
31  }
32
33  return [
34    NEWLINE,
35    ...genCall(
36      vaporHelper(delegate ? 'delegate' : 'on'),
37      `n${element}`,
38      name,
39      handler,
40      eventOptions,
41    ),
42  ]

この流れはもはや見慣れたもので,難しいところはないはずです.
注目したいのは特にここです.

36      vaporHelper(delegate ? 'delegate' : 'on'),

IRdelegate が有効な場合には delegate ヘルパーを,そうでない場合は on ヘルパーを生成しています.
つまりはこの次にランタイムを読む時にこの 2 つを見比べてみると delegate の役割が掴めそうです.

直前で,context.delegates にイベントを登録しているのもわかります.
これがおそらく hoist された _delegateEvents("click"); の部分であることもわかります.

28  if (delegate) {
29    // key is static
30    context.delegates.add(key.content)
31  }

ランタイムを読む

さて,読みたい関数は 3 つです.
delegateEventsdelegateon です.

まずは実行順番的に delegateEvents から見ていきましょう.

delegateEvents

実装は以下です.

83/**
84 * Event delegation borrowed from solid
85 */
86const delegatedEvents = Object.create(null)
87
88export const delegateEvents = (...names: string[]): void => {
89  for (const name of names) {
90    if (!delegatedEvents[name]) {
91      delegatedEvents[name] = true
92      // eslint-disable-next-line no-restricted-globals
93      document.addEventListener(name, delegatedEventHandler)
94    }
95  }
96}

コメントアウトを見る通り,どうやらこの概念は Solid から拝借したもののようです.
Solid のドキュメントを見てみると delegate の説明が書いてあります.

https://docs.solidjs.com/concepts/components/event-handlers

Solid provides two ways to add event listeners to the browser:

  • on:__: adds an event listener to the element. This is also known as a native event.

  • on__: adds an event listener to the document and dispatches it to the element. This can be referred to as a delegated event.

Delegated events flow through the component tree, and save some resources by performing better on commonly used events. Native events, however, flow through the DOM tree, and provide more control over the behavior of the event.

delegateEvents は渡された events で document に対して delegatedEventHandler をリッスンしています.

delegatedEventHandler を見てみましょう.

98const delegatedEventHandler = (e: Event) => {
99  let node = ((e.composedPath && e.composedPath()[0]) || e.target) as any
100  if (e.target !== node) {
101    Object.defineProperty(e, 'target', {
102      configurable: true,
103      value: node,
104    })
105  }
106  Object.defineProperty(e, 'currentTarget', {
107    configurable: true,
108    get() {
109      // eslint-disable-next-line no-restricted-globals
110      return node || document
111    },
112  })
113  while (node !== null) {
114    const handlers = getMetadata(node)[MetadataKind.event][e.type]
115    if (handlers) {
116      for (const handler of handlers) {
117        if (handler.delegate && !node.disabled) {
118          handler(e)
119          if (e.cancelBubble) return
120        }
121      }
122    }
123    node =
124      node.host && node.host !== node && node.host instanceof Node
125        ? node.host
126        : node.parentNode
127  }
128}

e.composedPath はイベントの経路 (EventTarget) を配列で返すメソッドです.

https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath

js
// 例

e.composedPath(); // [button, div, body, html, document]

まず,getMetadata という関数で node からメタデータを取得し,そこにあるイベント情報からハンドラを取得します.\

114    const handlers = getMetadata(node)[MetadataKind.event][e.type]

そしてそのハンドラを全て実行します.

115    if (handlers) {
116      for (const handler of handlers) {
117        if (handler.delegate && !node.disabled) {
118          handler(e)

そしたらこの流れを host, parent を遡って実行していきます.

123    node =
124      node.host && node.host !== node && node.host instanceof Node
125        ? node.host
126        : node.parentNode
113  while (node !== null) {

この流れで delegate も読んでしまいましょう.

delegate

58export function delegate(
59  el: HTMLElement,
60  event: string,
61  handlerGetter: () => undefined | ((...args: any[]) => any),
62  options: ModifierOptions = {},
63): void {
64  const handler: DelegatedHandler = eventHandler(handlerGetter, options)
65  handler.delegate = true
66  recordEventMetadata(el, event, handler)
67}

delegate は handler を作り, delegate フラグを立てた状態でメタデータに登録します.

recordEventMetadatapackages/runtime-vapor/src/componentMetadata.ts という別のファイルに実装されています.

21export function recordPropMetadata(el: Node, key: string, value: any): any {
22  const metadata = getMetadata(el)[MetadataKind.prop]
23  const prev = metadata[key]
24  metadata[key] = value
25  return prev
26}

これを見てもわかる通り,メタデータは要素に直接,$$metadata というプロパティに登録されるもので.以下のような型になっています

5export enum MetadataKind {
6  prop,
7  event,
8}
9
10export type ComponentMetadata = [
11  props: Data,
12  events: Record<string, DelegatedHandler[]>,
13]

イベントハンドラと Props を持っているようです.

つまり,ここでは直接イベントハンドラの登録を行わずに,ハンドラだけ持たせて,実際には delegateEventsdocument に登録されたハンドラが呼び出された時にこのメタデータを参照してハンドラを実行する,という流れになっているようです.

on

さて,IRdelegate フラグが立っていない場合は on が呼ばれます.

こちらは非常に単純で,queuePostFlushCb で addEventListener を呼び出しています.

29export function on(
30  el: Element,
31  event: string,
32  handlerGetter: () => undefined | ((...args: any[]) => any),
33  options: AddEventListenerOptions &
34    ModifierOptions & { effect?: boolean } = {},
35): void {
36  const handler: DelegatedHandler = eventHandler(handlerGetter, options)
37  let cleanupEvent: (() => void) | undefined
38  queuePostFlushCb(() => {
39    cleanupEvent = addEventListener(el, event, handler, options)
40  })
41
42  if (options.effect) {
43    onEffectCleanup(cleanup)
44  } else if (getCurrentScope()) {
45    onScopeDispose(cleanup)
46  }
47
48  function cleanup() {
49    cleanupEvent && cleanupEvent()
50  }
51}
14export function addEventListener(
15  el: Element,
16  event: string,
17  handler: (...args: any) => any,
18  options?: AddEventListenerOptions,
19) {
20  el.addEventListener(event, handler, options)
21  return (): void => el.removeEventListener(event, handler, options)
22}

クリーンナップ処理も実装されています.

delegate vs on

さて,それぞれの実装の違いはわかったのですが,これらは何のために使い分けているのでしょうか.
Solid のドキュメントを参照してもわかる通り,delegate の場合はリソースの節約になるようです.

もう少し具体的に説明すると,「イベントを要素にアタッチする」という行為はコスト (時間やメモリ) がかかるものです.
on は各要素に対してそれぞれにイベントをアタッチするのに対し,delegate は document に対してのみイベントをアタッチし,イベントが発生した時にそのイベントがどの要素から発生したかを調べ,該当する要素に対してイベントを発火させる,という流れです. これによりパフォーマンスに貢献しているようです.(手元でベンチを取ったわけではないので Vapor でどれくらい効果が出ているのかは私は知りません (ぜひ知っていたら教えてください))