v-model ディレクティブ
以下のようなコンポーネントを考えます.
<script setup>
import { ref } from "vue";
const text = ref("");
</script>
<template>
<input v-model="text" />
</template>
コンパイル結果と概要
コンパイル結果は以下のようになります.
const _sfc_main = {
vapor: true,
__name: "App",
setup(__props, { expose: __expose }) {
__expose();
const text = ref("");
const __returned__ = { text, ref };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
};
import {
vModelText as _vModelText,
withDirectives as _withDirectives,
delegate as _delegate,
template as _template,
} from "vue/vapor";
const t0 = _template("<input>");
function _sfc_render(_ctx) {
const n0 = t0();
_withDirectives(n0, [[_vModelText, () => _ctx.text]]);
_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
return n0;
}
何といっても注目するべきは
_withDirectives(n0, [[_vModelText, () => _ctx.text]]);
_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
の部分です.
delegate がまた登場しているので,これがイベントハンドラの登録であることは何となくわかりますが,withDirectives
と _vModelText
という謎のものが登場しています.
詳細は後で読むとして,とりあえずコンパイラを読んでみましょう.
コンパイラを読む
transformElement
-> buildProps
-> transformProps
-> directiveTransform
-> transformVModel
と辿っていきます.
packages/compiler-vapor/src/transforms/vModel.ts まず,context
から bindingMetadata
というものを取り出しています.
34 const bindingType = context.options.bindingMetadata[rawExp]
これは compiler-sfc
が収集したもので,SFC に定義されている変数のメタデータです.
setup で定義された let の変数なのか, props なのか, data なのかなどです.
具体的には以下のように列挙されています.
109export enum BindingTypes {
110 /**
111 * returned from data()
112 */
113 DATA = 'data',
114 /**
115 * declared as a prop
116 */
117 PROPS = 'props',
118 /**
119 * a local alias of a `<script setup>` destructured prop.
120 * the original is stored in __propsAliases of the bindingMetadata object.
121 */
122 PROPS_ALIASED = 'props-aliased',
123 /**
124 * a let binding (may or may not be a ref)
125 */
126 SETUP_LET = 'setup-let',
127 /**
128 * a const binding that can never be a ref.
129 * these bindings don't need `unref()` calls when processed in inlined
130 * template expressions.
131 */
132 SETUP_CONST = 'setup-const',
133 /**
134 * a const binding that does not need `unref()`, but may be mutated.
135 */
136 SETUP_REACTIVE_CONST = 'setup-reactive-const',
137 /**
138 * a const binding that may be a ref.
139 */
140 SETUP_MAYBE_REF = 'setup-maybe-ref',
141 /**
142 * bindings that are guaranteed to be refs
143 */
144 SETUP_REF = 'setup-ref',
145 /**
146 * declared by other options, e.g. computed, inject
147 */
148 OPTIONS = 'options',
149 /**
150 * a literal constant, e.g. 'foo', 1, true
151 */
152 LITERAL_CONST = 'literal-const',
153}
どのように収集されているかはまたどこかで追いましょう.
exp
の bindingType
が props だった場合にはエラーを出力しています.親切ですね.
37 if (
38 bindingType === BindingTypes.PROPS ||
39 bindingType === BindingTypes.PROPS_ALIASED
40 ) {
41 context.options.onError(
42 createCompilerError(ErrorCodes.X_V_MODEL_ON_PROPS, exp.loc),
43 )
44 return
45 }
そして,以下の分岐以降が本題です.
84 if (
まずは tag が input
textarea
select
のいずれかである場合です.
今回は input
なのでここに該当します.
84 if (
85 tag === 'input' ||
86 tag === 'textarea' ||
87 tag === 'select' ||
input
の場合には type
属性を読みつつ,runtimeDirective
を決定します.
90 if (tag === 'input' || isCustomElement) {
91 const type = findProp(node, 'type')
92 if (type) {
93 if (type.type === NodeTypes.DIRECTIVE) {
94 // :type="foo"
95 runtimeDirective = 'vModelDynamic'
96 } else if (type.value) {
97 switch (type.value.content) {
98 case 'radio':
99 runtimeDirective = 'vModelRadio'
100 break
101 case 'checkbox':
102 runtimeDirective = 'vModelCheckbox'
103 break
104 case 'file':
105 runtimeDirective = undefined
106 context.options.onError(
107 createDOMCompilerError(
108 DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
109 dir.loc,
110 ),
111 )
112 break
113 default:
114 // text type
115 __DEV__ && checkDuplicatedValue()
116 break
117 }
118 }
先ほど出力に出ていた vModelText
はこの変数の初期値のようです.
83 let runtimeDirective: VaporHelper | undefined = 'vModelText'
ここまできたら SET_MODEL_VALUE
な operation を登録して,
142 context.registerOperation({
143 type: IRNodeTypes.SET_MODEL_VALUE,
144 element: context.reference(),
145 key: arg || createSimpleExpression('modelValue', true),
146 value: exp,
147 isComponent,
148 })
先ほど算出した runtimeDirective
を使って withDirectives
を登録します.
151 context.registerOperation({
152 type: IRNodeTypes.WITH_DIRECTIVE,
153 element: context.reference(),
154 dir,
155 name: runtimeDirective,
156 builtin: true,
157 })
意外とシンプルですね.
あとは Codegen ですがここまで来れば楽勝でしょう.
お決まりの流れです.特に説明はありません.
33export function genOperation(
34 oper: OperationNode,
35 context: CodegenContext,
36): CodeFragment[] {
52 case IRNodeTypes.SET_MODEL_VALUE:
53 return genSetModelValue(oper, context)
8export function genSetModelValue(
9 oper: SetModelValueIRNode,
10 context: CodegenContext,
11): CodeFragment[] {
12 const { vaporHelper } = context
13 const name = oper.key.isStatic
14 ? [JSON.stringify(`update:${camelize(oper.key.content)}`)]
15 : ['`update:${', ...genExpression(oper.key, context), '}`']
16
17 const handler = genModelHandler(oper.value, context)
18
19 return [
20 NEWLINE,
21 ...genCall(vaporHelper('delegate'), `n${oper.element}`, name, handler),
22 ]
23}
24
25export function genModelHandler(
26 value: SimpleExpressionNode,
27 context: CodegenContext,
28): CodeFragment[] {
29 const {
30 options: { isTS },
31 } = context
32
33 return [
34 `() => ${isTS ? `($event: any)` : `$event`} => (`,
35 ...genExpression(value, context, '$event'),
36 ')',
37 ]
38}
withDirectives の方は少し codegen の動線が違います.genBlockContent
-> genChildren
-> genDirectivesForElement
-> genWithDirective
になります.
36export function genBlockContent(
37 block: BlockIRNode,
38 context: CodegenContext,
39 root?: boolean,
40 customReturns?: (returns: CodeFragment[]) => CodeFragment[],
41): CodeFragment[] {
↓
51 for (const child of dynamic.children) {
52 push(...genChildren(child, context, child.id!))
53 }
↓
18export function genChildren(
19 dynamic: IRDynamicInfo,
20 context: CodegenContext,
21 from: number,
22 paths: number[] = [],
23): CodeFragment[] {
↓
31 push(...genDirectivesForElement(id, context))
or
31 push(...genDirectivesForElement(id, context))
↓
23export function genDirectivesForElement(
24 id: number,
25 context: CodegenContext,
26): CodeFragment[] {
27 const dirs = filterDirectives(id, context.block.operation)
28 return dirs.length ? genWithDirective(dirs, context) : []
29}
↓
31export function genWithDirective(
32 opers: WithDirectiveIRNode[],
33 context: CodegenContext,
34): CodeFragment[] {
になります.
ランタイムを読む
_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
の部分はまあ良いとして,問題は withDirectives
と _vModelText
です.
withDirectives
withDirectives
を読んでみましょう. 実装は packages/runtime-vapor/src/directives.ts にあります.
93export function withDirectives<T extends ComponentInternalInstance | Node>(
94 nodeOrComponent: T,
95 directives: DirectiveArguments,
96): T {
node
か component
と,directives
を受け取ります.
DirectiveArguments
DirectiveArguments
の定義は以下です.
81export type DirectiveArguments = Array<
82 | [Directive | undefined]
83 | [Directive | undefined, () => any]
84 | [Directive | undefined, () => any, argument: string]
85 | [
86 Directive | undefined,
87 value: () => any,
88 argument: string,
89 modifiers: DirectiveModifiers,
90 ]
91>
71export type Directive<T = any, V = any, M extends string = string> =
72 | ObjectDirective<T, V, M>
73 | FunctionDirective<T, V, M>
65export type FunctionDirective<
66 T = any,
67 V = any,
68 M extends string = string,
69> = DirectiveHook<T, V, M>
58export type ObjectDirective<T = any, V = any, M extends string = string> = {
59 [K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined
60} & {
61 /** Watch value deeply */
62 deep?: boolean | number
63}
41export type DirectiveHook<
42 T = any | null,
43 V = any,
44 M extends string = string,
45> = (node: T, binding: DirectiveBinding<T, V, M>) => void
46
47// create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted`
48// effect update -> `beforeUpdate` -> node updated -> `updated`
49// `beforeUnmount`-> node unmount -> `unmounted`
50export type DirectiveHookName =
51 | 'created'
52 | 'beforeMount'
53 | 'mounted'
54 | 'beforeUpdate'
55 | 'updated'
56 | 'beforeUnmount'
57 | 'unmounted'
ややこしいですが,簡単にいうと各ライフサイクルでの挙動を定義しています.
(ディレクティブの動作の実態と言えそうです.)
withDirectives の中身
まず,DirectiveBinding
という概念があります.
これは新旧の値や修飾子,ディレクティブ本体 (ObjectDirective
), コンポーネントの場合はインスタンスなど,必要な情報をまとめたオブジェクトです.
29export interface DirectiveBinding<T = any, V = any, M extends string = string> {
30 instance: ComponentInternalInstance
31 source?: () => V
32 value: V
33 oldValue: V | null
34 arg?: string
35 modifiers?: DirectiveModifiers<M>
36 dir: ObjectDirective<T, V, M>
37}
そして,この withDirectives
という関数ですが,名前が複数形になっていることからもわかる通り,複数のディレクティブを適用することができます.
引数で受け取ったディレクティブの配列を 1 つづつ回して処理を行います.
124 for (const directive of directives) {
この for 文で行われていることを見ていきましょう.
まずは定義から各種情報を取り出します.
125 let [dir, source, arg, modifiers] = directive
normalize もしておきます
127 if (isFunction(dir)) {
128 dir = {
129 mounted: dir,
130 updated: dir,
131 } satisfies ObjectDirective
132 }
ベースとなる binding を定義して
134 const binding: DirectiveBinding = {
135 dir,
136 instance,
137 value: null, // set later
138 oldValue: undefined,
139 arg,
140 modifiers,
141 }
source
を ReactiveEffect
でラップし,その effect
の scheduler
には update trigger を仕込みます.
143 if (source) {
144 if (dir.deep) {
145 const deep = dir.deep === true ? undefined : dir.deep
146 const baseSource = source
147 source = () => traverse(baseSource(), deep)
148 }
149
150 const effect = new ReactiveEffect(() =>
151 callWithErrorHandling(
152 source!,
153 instance,
154 VaporErrorCodes.RENDER_FUNCTION,
155 ),
156 )
157 const triggerRenderingUpdate = createRenderingUpdateTrigger(
158 instance,
159 effect,
160 )
161 effect.scheduler = () => queueJob(triggerRenderingUpdate)
162
163 binding.source = effect.run.bind(effect)
164 }
update trigger は単純にライフサイクルの beforeUpdate
, updated
を実行するトリガです.
228export function createRenderingUpdateTrigger(
229 instance: ComponentInternalInstance,
230 effect: ReactiveEffect,
231): SchedulerJob {
232 job.id = instance.uid
233 return job
234 function job() {
235 if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
236 return
237 }
238
239 if (instance.isMounted && !instance.isUpdating) {
240 instance.isUpdating = true
241 const reset = setCurrentInstance(instance)
242
243 const { bu, u, scope } = instance
244 const { dirs } = scope
245 // beforeUpdate hook
246 if (bu) {
247 invokeArrayFns(bu)
248 }
249 invokeDirectiveHook(instance, 'beforeUpdate', scope)
250
251 queuePostFlushCb(() => {
252 instance.isUpdating = false
253 const reset = setCurrentInstance(instance)
254 if (dirs) {
255 invokeDirectiveHook(instance, 'updated', scope)
256 }
257 // updated hook
258 if (u) {
259 queuePostFlushCb(u)
260 }
261 reset()
262 })
263 reset()
264 }
265 }
266}
最後に created hook を実行しておしまいです.
168 callDirectiveHook(node, binding, instance, 'created')
vModelText
さてここまで読めたら次は具体的なディレクティブの実装を読んでいきましょう.\
v-model に関する runtimeDirective は packages/runtime-vapor/src/directives/vModel.ts に実装されています.
今回の vModelText
は以下です.
44export const vModelText: ObjectDirective<
45 HTMLInputElement | HTMLTextAreaElement,
46 any,
47 'lazy' | 'trim' | 'number'
48> = {
ここには,beforeMount
, mounted
, beforeUpdate
, などのこのディレクティブに関するライフサイクルごとの動作が定義されています. 順に見ていきましょう.
beforeMount
49 beforeMount(el, { modifiers: { lazy, trim, number } = {} }) {
イベントハンドラの登録を行っています.
値のトリムを行ったり,数値へのキャストを行ったりしながら,値を更新しています.
56 addEventListener(el, lazy ? 'change' : 'input', e => {
57 if ((e.target as any).composing) return
58 let domValue: string | number = el.value
59 if (trim) {
60 domValue = domValue.trim()
61 }
62 if (castToNumber) {
63 domValue = looseToNumber(domValue)
64 }
65 assigner(domValue)
66 })
値の更新は delegate されたイベントハンドラから assigner を取得し,それを使っています.
56 addEventListener(el, lazy ? 'change' : 'input', e => {
57 if ((e.target as any).composing) return
58 let domValue: string | number = el.value
59 if (trim) {
60 domValue = domValue.trim()
61 }
62 if (castToNumber) {
63 domValue = looseToNumber(domValue)
64 }
65 assigner(domValue)
66 })
21function getModelAssigner(el: Element): AssignerFn {
22 const metadata = getMetadata(el)
23 const fn = metadata[MetadataKind.event]['update:modelValue'] || []
24 return value => invokeArrayFns(fn, value)
25}
Tips
v-model は IME などの composing のハンドリングが行われていることが公式ドキュメントでも触れらていますが,これはまさにこの処理です.
56 addEventListener(el, lazy ? 'change' : 'input', e => {
57 if ((e.target as any).composing) return
73 addEventListener(el, 'compositionstart', onCompositionStart)
74 addEventListener(el, 'compositionend', onCompositionEnd)
27function onCompositionStart(e: Event) {
28 ;(e.target as any).composing = true
29}
30
31function onCompositionEnd(e: Event) {
32 const target = e.target as any
33 if (target.composing) {
34 target.composing = false
35 target.dispatchEvent(new Event('input'))
36 }
37}
mounted
マウント時は初期値の設定をしておしまいです.
83 mounted(el, { value }) {
84 el.value = value == null ? '' : value
85 },
beforeUpdate
更新までは composing のハンドリングを行ったり,必要のない更新をスキップするなどの処理を行っています.
86 beforeUpdate(el, { value, modifiers: { lazy, trim, number } = {} }) {
87 assignFnMap.set(el, getModelAssigner(el))
88
89 // avoid clearing unresolved text. #2302
90 if ((el as any).composing) return
91
92 const elValue =
93 number || el.type === 'number' ? looseToNumber(el.value) : el.value
94 const newValue = value == null ? '' : value
95
96 if (elValue === newValue) {
97 return
98 }
99
100 // eslint-disable-next-line no-restricted-globals
101 if (document.activeElement === el && el.type !== 'range') {
102 if (lazy) {
103 return
104 }
105 if (trim && el.value.trim() === newValue) {
106 return
107 }
108 }
109
110 el.value = newValue
111 },
以上で v-model
の動作は理解できたはずです.vModelText
以外にもさまざまなディレクティブ定義がありますが,同じ要領で読み進めることができるはずです.
そして,今回は runtimeDirective や withDirectives などの新しい概念が登場したので少し長くなりましがた,他のディレクティブについてもこれをベースに読み進めていけるはずです.(スピードも上がるはずです.)
この調子でどんどん読んでいきましょう.