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] = directiveAlso, 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) return73 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.
