v-on ディレクティブ
さて,v-on
ディレクティブについて見てみましょう.v-on
はネイティブ要素に対するものと,コンポーネントに対するものの 2 種類のものがありますが,まだコンポーネントの扱い方は知らないので,ここではネイティブ要素に対するものについて説明します.
(ちなみにコンポーネントの際はほぼ props なので,あまり説明することがありません)
以下のようなコンポーネントを考えます.
<script setup>
import { ref } from "vue";
const count = ref(0);
function increment() {
count.value++;
}
</script>
<template>
<button type="button" @click="increment">{{ count }}</button>
</template>
よくあるカウンターコンポーネントです.
コンパイル結果
コンパイル結果は以下のような感じになります.
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 部分は大したものではないので,以下の部分を重点的にみていきましょう.
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
はいつも通りです.
今回のメイン部分は
_delegateEvents("click");
と
_delegate(n0, "click", () => _ctx.increment);
です.
予想的には後者の方は n0
に対して click
イベントを追加しているのだろうと思います.
が,"delegate" というのが何なのか,と前者の _delegateEvents
が何をしているのかがわかりません.
とりあえず,ここは謎なまま置いておいて,コンパイラの実装を見ていきましょう.
謎はランタイムを読み進める時に把握していきましょう.
コンパイラを読む
IR
例の如く IR
を覗いてみましょう.
IR としては怪しそうなものは SET_EVENT
というものがありますが,他には見当たりません.
22 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
というフラグを持っているようです.
129 delegate: boolean
それでは,この 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 の時点で作られます.
ここから arg
や expr
, modifiers
などを取り出していきます.
21 let { arg, exp, loc, modifiers } = dir
簡単に説明しておくと,v-on:click.stop="handler"
の click
が arg
に,stop
が modifiers
に,handler
が expr
に該当します.
まずは modifiers
を種類別に解決して整理しておきます.
32 const { keyModifiers, nonKeyModifiers, eventOptionModifiers } =
33 resolveModifiers(
34 arg.isStatic ? `on${arg.content}` : arg,
35 modifiers,
36 null,
37 loc,
38 )
resolveModifiers
は compiler-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)
以下の条件を全て満たす時に有効になります.
- arg が static である
v-on[eventName]="handler"
のようなものでない場合です. - modifiers が空である
- delegate 対象である
ここに定義されたイベントであるかどうかの判定です. https://github.com/vuejs/core-vapor/blob/30583b9ee1c696d3cb836f0bfd969793e57e849d/packages/compiler-vapor/src/transforms/vOn.ts#L13-L18
あとはこれまでに得られた情報をもとに,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'),
IR
の delegate
が有効な場合には delegate
ヘルパーを,そうでない場合は on
ヘルパーを生成しています.
つまりはこの次にランタイムを読む時にこの 2 つを見比べてみると delegate
の役割が掴めそうです.
直前で,context.delegates
にイベントを登録しているのもわかります.
これがおそらく hoist された _delegateEvents("click");
の部分であることもわかります.
28 if (delegate) {
29 // key is static
30 context.delegates.add(key.content)
31 }
ランタイムを読む
さて,読みたい関数は 3 つです.delegateEvents
,delegate
,on
です.
まずは実行順番的に 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
// 例
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 フラグを立てた状態でメタデータに登録します.
recordEventMetadata
は packages/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 を持っているようです.
つまり,ここでは直接イベントハンドラの登録を行わずに,ハンドラだけ持たせて,実際には delegateEvents
で document
に登録されたハンドラが呼び出された時にこのメタデータを参照してハンドラを実行する,という流れになっているようです.
on
さて,IR
の delegate
フラグが立っていない場合は 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 でどれくらい効果が出ているのかは私は知りません (ぜひ知っていたら教えてください))