The v-model Directive
Consider a component like the following.
<script setup>
import { ref } from "vue";
const text = ref("");
</script>
<template>
<input v-model="text" />
</template>
Compilation Result and Overview
The compilation result is as follows.
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;
}
What stands out the most is
_withDirectives(n0, [[_vModelText, () => _ctx.text]]);
_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
Since delegate
appears again, it somewhat indicates that this is for registering event handlers, but mysterious elements like withDirectives
and _vModelText
appear.
While the details will be read later, let's first read the compiler.
Reading the Compiler
Follow the path: transformElement
-> buildProps
-> transformProps
-> directiveTransform
-> transformVModel
.
packages/compiler-vapor/src/transforms/vModel.ts First, bindingMetadata
is extracted from context
.
34 const bindingType = context.options.bindingMetadata[rawExp]
This is collected by compiler-sfc
and contains metadata about variables defined in the SFC, such as whether a let
variable is defined in setup
, or if it's a prop, data, etc.
Specifically, it is enumerated as follows.
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}
How it is collected will be traced elsewhere.
If the bindingType
of exp
is props, an error is thrown. Very considerate.
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 }
Then, the following branching is the main topic.
84 if (
First, if the tag is one of input
, textarea
, or select
.
In this case, it's input
, so it matches here.
84 if (
85 tag === 'input' ||
86 tag === 'textarea' ||
87 tag === 'select' ||
For input
, it reads the type
attribute and determines the 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 }
The previously outputted vModelText
seems to be the initial value of this variable.
83 let runtimeDirective: VaporHelper | undefined = 'vModelText'
At this point, it registers an operation called SET_MODEL_VALUE
, and
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 })
uses the previously calculated runtimeDirective
to register withDirectives
.
151 context.registerOperation({
152 type: IRNodeTypes.WITH_DIRECTIVE,
153 element: context.reference(),
154 dir,
155 name: runtimeDirective,
156 builtin: true,
157 })
It's surprisingly simple.
As for Codegen, it's a breeze once you reach this point.
It's the usual flow. No particular explanation needed.
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}
The withDirectives
part has a slightly different Codegen flow.
It follows 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[] {
is the path.
Reading the Runtime
While the part _delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
is fine, the issue lies with withDirectives
and _vModelText
.
withDirectives
Let's read withDirectives
. The implementation is in packages/runtime-vapor/src/directives.ts.
93export function withDirectives<T extends ComponentInternalInstance | Node>(
94 nodeOrComponent: T,
95 directives: DirectiveArguments,
96): T {
withDirectives
receives either node
or component
, and directives
.
DirectiveArguments
The definition of DirectiveArguments
is as follows.
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'
It's somewhat complicated, but simply put, it defines the behavior for each lifecycle.
(This seems to be the actual behavior of the directive.)
Inside withDirectives
First, there is a concept called DirectiveBinding
.
This is an object that bundles necessary information, such as old and new values, modifiers, the directive itself (ObjectDirective
), and in the case of a component, the instance.
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}
Then, this function withDirectives
, as the name suggests, can apply multiple directives.
It processes each directive in the array of directives received as arguments.
124 for (const directive of directives) {
Let's look at what is done in this for loop.
First, extract various information from the definition.
125 let [dir, source, arg, modifiers] = directive
Also, normalize.
127 if (isFunction(dir)) {
128 dir = {
129 mounted: dir,
130 updated: dir,
131 } satisfies ObjectDirective
132 }
Define the base binding.
134 const binding: DirectiveBinding = {
135 dir,
136 instance,
137 value: null, // set later
138 oldValue: undefined,
139 arg,
140 modifiers,
141 }
Wrap source
with ReactiveEffect
, and set up the scheduler
of that effect
to include update triggers.
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 }
The update trigger is simply a trigger that executes the lifecycle's beforeUpdate
and 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}
Finally, execute the created hook.
168 callDirectiveHook(node, binding, instance, 'created')
vModelText
Now that we've read this far, next let's read the implementation of the specific directive.
The runtimeDirective
related to v-model is implemented in packages/runtime-vapor/src/directives/vModel.ts.
This time, vModelText
is as follows.
44export const vModelText: ObjectDirective<
45 HTMLInputElement | HTMLTextAreaElement,
46 any,
47 'lazy' | 'trim' | 'number'
48> = {
Here, the behavior for each lifecycle related to this directive, such as beforeMount
, mounted
, beforeUpdate
, etc., are defined. Let's look at them one by one.
beforeMount
49 beforeMount(el, { modifiers: { lazy, trim, number } = {} }) {
Registers the event handler.
Performs trimming of values and casts to numbers while updating the values.
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 })
Value updates use the assigner obtained from delegate event handlers.
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
Although the official documentation mentions that v-model handles composing such as IME, this is exactly this process.
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
At mount time, it simply sets the initial value.
83 mounted(el, { value }) {
84 el.value = value == null ? '' : value
85 },
beforeUpdate
Handles composing, such as IME, and skips unnecessary updates up to update.
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 },
With that, the operation of v-model
should be understood.
There are various other directive definitions besides vModelText
, but you should be able to proceed in the same way based on this.
And, since new concepts such as runtimeDirective
and withDirectives
have appeared this time, it became a bit lengthy, but you should be able to continue reading other directives based on this. (Your speed should also increase.)
Let's keep reading in this manner.